Persist sidebar order preferences

This commit is contained in:
Dotta committed 2026-04-14 11:51:38 -05:00
1 parent 84f0ffb9dd
commit 4694a15217
26 files changed
+14004 -145

No files matched your search

@@ -0,0 +1,22 @@
CREATE TABLE "company_user_sidebar_preferences" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"user_id" text NOT NULL,
"project_order" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_sidebar_preferences" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"company_order" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "company_user_sidebar_preferences" ADD CONSTRAINT "company_user_sidebar_preferences_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "company_user_sidebar_preferences_company_idx" ON "company_user_sidebar_preferences" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX "company_user_sidebar_preferences_user_idx" ON "company_user_sidebar_preferences" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "company_user_sidebar_preferences_company_user_uq" ON "company_user_sidebar_preferences" USING btree ("company_id","user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "user_sidebar_preferences_user_uq" ON "user_sidebar_preferences" USING btree ("user_id");
File diff suppressed because it is too large. Load diff
@@ -393,6 +393,13 @@
"when": 1775825256196,
"tag": "0055_kind_weapon_omega",
"breakpoints": true
},
{
"idx": 56,
"version": "7",
"when": 1776084034244,
"tag": "0056_spooky_ultragirl",
"breakpoints": true
}
]
}
@@ -0,0 +1,22 @@
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const companyUserSidebarPreferences = pgTable(
"company_user_sidebar_preferences",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
projectOrder: jsonb("project_order").$type<string[]>().notNull().default([]),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIdx: index("company_user_sidebar_preferences_company_idx").on(table.companyId),
userIdx: index("company_user_sidebar_preferences_user_idx").on(table.userId),
companyUserUq: uniqueIndex("company_user_sidebar_preferences_company_user_uq").on(
table.companyId,
table.userId,
),
}),
);
+2
View File
@@ -3,10 +3,12 @@ export { companyLogos } from "./company_logos.js";
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
export { instanceSettings } from "./instance_settings.js";
export { instanceUserRoles } from "./instance_user_roles.js";
export { userSidebarPreferences } from "./user_sidebar_preferences.js";
export { agents } from "./agents.js";
export { boardApiKeys } from "./board_api_keys.js";
export { cliAuthChallenges } from "./cli_auth_challenges.js";
export { companyMemberships } from "./company_memberships.js";
export { companyUserSidebarPreferences } from "./company_user_sidebar_preferences.js";
export { principalPermissionGrants } from "./principal_permission_grants.js";
export { invites } from "./invites.js";
export { joinRequests } from "./join_requests.js";
@@ -0,0 +1,15 @@
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
export const userSidebarPreferences = pgTable(
"user_sidebar_preferences",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: text("user_id").notNull(),
companyOrder: jsonb("company_order").$type<string[]>().notNull().default([]),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
userUq: uniqueIndex("user_sidebar_preferences_user_uq").on(table.userId),
}),
);
+1
View File
@@ -13,6 +13,7 @@ export const API = {
activity: `${API_PREFIX}/activity`,
dashboard: `${API_PREFIX}/dashboard`,
sidebarBadges: `${API_PREFIX}/sidebar-badges`,
sidebarPreferences: `${API_PREFIX}/sidebar-preferences`,
invites: `${API_PREFIX}/invites`,
joinRequests: `${API_PREFIX}/join-requests`,
members: `${API_PREFIX}/members`,
+7
View File
@@ -305,6 +305,7 @@ export type {
DashboardSummary,
ActivityEvent,
SidebarBadges,
SidebarOrderPreference,
InboxDismissal,
CompanyMembership,
PrincipalPermissionGrant,
@@ -378,6 +379,12 @@ export type {
ProviderQuotaResult,
} from "./types/index.js";
export {
sidebarOrderPreferenceSchema,
upsertSidebarOrderPreferenceSchema,
type UpsertSidebarOrderPreference,
} from "./validators/sidebar-preferences.js";
export { workspaceRuntimeControlTargetSchema } from "./validators/execution-workspace.js";
export {
findWorkspaceCommandDefinition,
+1
View File
@@ -169,6 +169,7 @@ export type { LiveEvent } from "./live.js";
export type { DashboardSummary } from "./dashboard.js";
export type { ActivityEvent } from "./activity.js";
export type { SidebarBadges } from "./sidebar-badges.js";
export type { SidebarOrderPreference } from "./sidebar-preferences.js";
export type { InboxDismissal } from "./inbox-dismissal.js";
export type {
CompanyMembership,
@@ -0,0 +1,4 @@
export interface SidebarOrderPreference {
orderedIds: string[];
updatedAt: Date | null;
}
+5
View File
@@ -32,6 +32,11 @@ export {
upsertIssueFeedbackVoteSchema,
type UpsertIssueFeedbackVote,
} from "./feedback.js";
export {
sidebarOrderPreferenceSchema,
upsertSidebarOrderPreferenceSchema,
type UpsertSidebarOrderPreference,
} from "./sidebar-preferences.js";
export {
companySkillSourceTypeSchema,
companySkillTrustLevelSchema,
@@ -0,0 +1,14 @@
import { z } from "zod";
const sidebarOrderedIdSchema = z.string().uuid();
export const sidebarOrderPreferenceSchema = z.object({
orderedIds: z.array(sidebarOrderedIdSchema),
updatedAt: z.coerce.date().nullable(),
});
export const upsertSidebarOrderPreferenceSchema = z.object({
orderedIds: z.array(sidebarOrderedIdSchema),
});
export type UpsertSidebarOrderPreference = z.infer<typeof upsertSidebarOrderPreferenceSchema>;
@@ -0,0 +1,166 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { sidebarPreferenceRoutes } from "../routes/sidebar-preferences.js";
const mockSidebarPreferenceService = vi.hoisted(() => ({
getCompanyOrder: vi.fn(),
upsertCompanyOrder: vi.fn(),
getProjectOrder: vi.fn(),
upsertProjectOrder: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
sidebarPreferenceService: () => mockSidebarPreferenceService,
logActivity: mockLogActivity,
}));
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor as never;
next();
});
app.use("/api", sidebarPreferenceRoutes({} as never));
app.use(errorHandler);
return app;
}
const ORDERED_IDS = [
"11111111-1111-4111-8111-111111111111",
"22222222-2222-4222-8222-222222222222",
];
describe("sidebar preference routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSidebarPreferenceService.getCompanyOrder.mockResolvedValue({
orderedIds: ORDERED_IDS,
updatedAt: null,
});
mockSidebarPreferenceService.upsertCompanyOrder.mockResolvedValue({
orderedIds: ORDERED_IDS,
updatedAt: null,
});
mockSidebarPreferenceService.getProjectOrder.mockResolvedValue({
orderedIds: ORDERED_IDS,
updatedAt: null,
});
mockSidebarPreferenceService.upsertProjectOrder.mockResolvedValue({
orderedIds: ORDERED_IDS,
updatedAt: null,
});
});
it("returns company rail order for board users", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app).get("/api/sidebar-preferences/me");
expect(res.status).toBe(200);
expect(res.body).toEqual({
orderedIds: ORDERED_IDS,
updatedAt: null,
});
expect(mockSidebarPreferenceService.getCompanyOrder).toHaveBeenCalledWith("user-1");
});
it("updates company rail order for board users", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: ["company-1"],
});
const res = await request(app)
.put("/api/sidebar-preferences/me")
.send({ orderedIds: ORDERED_IDS });
expect(res.status).toBe(200);
expect(mockSidebarPreferenceService.upsertCompanyOrder).toHaveBeenCalledWith("user-1", ORDERED_IDS);
});
it("returns project order for companies the board user can access", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app).get("/api/companies/company-1/sidebar-preferences/me");
expect(res.status).toBe(200);
expect(mockSidebarPreferenceService.getProjectOrder).toHaveBeenCalledWith("company-1", "user-1");
});
it("logs project order updates for company-scoped writes", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
runId: "run-1",
});
const res = await request(app)
.put("/api/companies/company-1/sidebar-preferences/me")
.send({ orderedIds: ORDERED_IDS });
expect(res.status).toBe(200);
expect(mockSidebarPreferenceService.upsertProjectOrder).toHaveBeenCalledWith("company-1", "user-1", ORDERED_IDS);
expect(mockLogActivity).toHaveBeenCalledWith(
{} as never,
expect.objectContaining({
companyId: "company-1",
action: "sidebar_preferences.project_order_updated",
details: expect.objectContaining({
userId: "user-1",
orderedIds: ORDERED_IDS,
}),
}),
);
});
it("rejects company-scoped reads when the board user lacks company access", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-2"],
});
const res = await request(app).get("/api/companies/company-1/sidebar-preferences/me");
expect(res.status).toBe(403);
expect(mockSidebarPreferenceService.getProjectOrder).not.toHaveBeenCalled();
});
it("rejects agent callers", async () => {
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
});
const res = await request(app).get("/api/sidebar-preferences/me");
expect(res.status).toBe(403);
expect(mockSidebarPreferenceService.getCompanyOrder).not.toHaveBeenCalled();
});
});
+2
View File
@@ -24,6 +24,7 @@ import { costRoutes } from "./routes/costs.js";
import { activityRoutes } from "./routes/activity.js";
import { dashboardRoutes } from "./routes/dashboard.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { llmRoutes } from "./routes/llms.js";
@@ -167,6 +168,7 @@ export async function createApp(
api.use(activityRoutes(db));
api.use(dashboardRoutes(db));
api.use(sidebarBadgeRoutes(db));
api.use(sidebarPreferenceRoutes(db));
api.use(inboxDismissalRoutes(db));
api.use(instanceSettingsRoutes(db));
const hostServicesDisposers = new Map<string, () => void>();
+1
View File
@@ -12,6 +12,7 @@ export { costRoutes } from "./costs.js";
export { activityRoutes } from "./activity.js";
export { dashboardRoutes } from "./dashboard.js";
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
export { sidebarPreferenceRoutes } from "./sidebar-preferences.js";
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
export { llmRoutes } from "./llms.js";
export { accessRoutes } from "./access.js";
+71
View File
@@ -0,0 +1,71 @@
import { Router, type Request, type Response } from "express";
import type { Db } from "@paperclipai/db";
import { upsertSidebarOrderPreferenceSchema } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { logActivity, sidebarPreferenceService } from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
function requireBoardUserId(req: Request, res: Response): string | null {
assertBoard(req);
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return null;
}
return req.actor.userId;
}
export function sidebarPreferenceRoutes(db: Db) {
const router = Router();
const svc = sidebarPreferenceService(db);
router.get("/sidebar-preferences/me", async (req, res) => {
const userId = requireBoardUserId(req, res);
if (!userId) return;
res.json(await svc.getCompanyOrder(userId));
});
router.put("/sidebar-preferences/me", validate(upsertSidebarOrderPreferenceSchema), async (req, res) => {
const userId = requireBoardUserId(req, res);
if (!userId) return;
res.json(await svc.upsertCompanyOrder(userId, req.body.orderedIds));
});
router.get("/companies/:companyId/sidebar-preferences/me", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const userId = requireBoardUserId(req, res);
if (!userId) return;
res.json(await svc.getProjectOrder(companyId, userId));
});
router.put(
"/companies/:companyId/sidebar-preferences/me",
validate(upsertSidebarOrderPreferenceSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const userId = requireBoardUserId(req, res);
if (!userId) return;
const result = await svc.upsertProjectOrder(companyId, userId, req.body.orderedIds);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "sidebar_preferences.project_order_updated",
entityType: "company",
entityId: companyId,
details: {
userId,
orderedIds: result.orderedIds,
},
});
res.json(result);
},
);
return router;
}
+1
View File
@@ -19,6 +19,7 @@ export { financeService } from "./finance.js";
export { heartbeatService } from "./heartbeat.js";
export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js";
export { sidebarPreferenceService } from "./sidebar-preferences.js";
export { inboxDismissalService } from "./inbox-dismissals.js";
export { accessService } from "./access.js";
export { boardAuthService } from "./board-auth.js";
@@ -0,0 +1,97 @@
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
companyUserSidebarPreferences,
userSidebarPreferences,
} from "@paperclipai/db";
import type { SidebarOrderPreference } from "@paperclipai/shared";
function normalizeOrderedIds(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const orderedIds: string[] = [];
const seen = new Set<string>();
for (const item of value) {
if (typeof item !== "string") continue;
const trimmed = item.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
orderedIds.push(trimmed);
}
return orderedIds;
}
function toPreference(orderedIds: unknown, updatedAt: Date | null): SidebarOrderPreference {
return {
orderedIds: normalizeOrderedIds(orderedIds),
updatedAt,
};
}
export function sidebarPreferenceService(db: Db) {
return {
async getCompanyOrder(userId: string): Promise<SidebarOrderPreference> {
const row = await db.query.userSidebarPreferences.findFirst({
where: eq(userSidebarPreferences.userId, userId),
});
return toPreference(row?.companyOrder ?? [], row?.updatedAt ?? null);
},
async upsertCompanyOrder(userId: string, orderedIds: string[]): Promise<SidebarOrderPreference> {
const now = new Date();
const normalized = normalizeOrderedIds(orderedIds);
const [row] = await db
.insert(userSidebarPreferences)
.values({
userId,
companyOrder: normalized,
updatedAt: now,
})
.onConflictDoUpdate({
target: [userSidebarPreferences.userId],
set: {
companyOrder: normalized,
updatedAt: now,
},
})
.returning();
return toPreference(row?.companyOrder ?? normalized, row?.updatedAt ?? now);
},
async getProjectOrder(companyId: string, userId: string): Promise<SidebarOrderPreference> {
const row = await db.query.companyUserSidebarPreferences.findFirst({
where: and(
eq(companyUserSidebarPreferences.companyId, companyId),
eq(companyUserSidebarPreferences.userId, userId),
),
});
return toPreference(row?.projectOrder ?? [], row?.updatedAt ?? null);
},
async upsertProjectOrder(
companyId: string,
userId: string,
orderedIds: string[],
): Promise<SidebarOrderPreference> {
const now = new Date();
const normalized = normalizeOrderedIds(orderedIds);
const [row] = await db
.insert(companyUserSidebarPreferences)
.values({
companyId,
userId,
projectOrder: normalized,
updatedAt: now,
})
.onConflictDoUpdate({
target: [companyUserSidebarPreferences.companyId, companyUserSidebarPreferences.userId],
set: {
projectOrder: normalized,
updatedAt: now,
},
})
.returning();
return toPreference(row?.projectOrder ?? normalized, row?.updatedAt ?? now);
},
};
}
+1
View File
@@ -15,5 +15,6 @@ export { dashboardApi } from "./dashboard";
export { heartbeatsApi } from "./heartbeats";
export { instanceSettingsApi } from "./instanceSettings";
export { sidebarBadgesApi } from "./sidebarBadges";
export { sidebarPreferencesApi } from "./sidebarPreferences";
export { inboxDismissalsApi } from "./inboxDismissals";
export { companySkillsApi } from "./companySkills";
+12
View File
@@ -0,0 +1,12 @@
import type { SidebarOrderPreference, UpsertSidebarOrderPreference } from "@paperclipai/shared";
import { api } from "./client";
export const sidebarPreferencesApi = {
getCompanyOrder: () => api.get<SidebarOrderPreference>("/sidebar-preferences/me"),
updateCompanyOrder: (data: UpsertSidebarOrderPreference) =>
api.put<SidebarOrderPreference>("/sidebar-preferences/me", data),
getProjectOrder: (companyId: string) =>
api.get<SidebarOrderPreference>(`/companies/${companyId}/sidebar-preferences/me`),
updateProjectOrder: (companyId: string, data: UpsertSidebarOrderPreference) =>
api.put<SidebarOrderPreference>(`/companies/${companyId}/sidebar-preferences/me`, data),
};
+19 -88
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo } from "react";
import { Paperclip, Plus } from "lucide-react";
import { useQueries } from "@tanstack/react-query";
import { useQueries, useQuery } from "@tanstack/react-query";
import {
DndContext,
closestCenter,
@@ -22,6 +22,8 @@ import { cn } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { sidebarBadgesApi } from "../api/sidebarBadges";
import { heartbeatsApi } from "../api/heartbeats";
import { authApi } from "../api/auth";
import { useCompanyOrder } from "../hooks/useCompanyOrder";
import { useLocation, useNavigate } from "@/lib/router";
import {
Tooltip,
@@ -31,42 +33,6 @@ import {
import type { Company } from "@paperclipai/shared";
import { CompanyPatternIcon } from "./CompanyPatternIcon";
const ORDER_STORAGE_KEY = "paperclip.companyOrder";
function getStoredOrder(): string[] {
try {
const raw = localStorage.getItem(ORDER_STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch { /* ignore */ }
return [];
}
function saveOrder(ids: string[]) {
localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids));
}
/** Sort companies by stored order, appending any new ones at the end. */
function sortByStoredOrder(companies: Company[]): Company[] {
const order = getStoredOrder();
if (order.length === 0) return companies;
const byId = new Map(companies.map((c) => [c.id, c]));
const sorted: Company[] = [];
for (const id of order) {
const c = byId.get(id);
if (c) {
sorted.push(c);
byId.delete(id);
}
}
// Append any companies not in stored order
for (const c of byId.values()) {
sorted.push(c);
}
return sorted;
}
function SortableCompanyItem({
company,
isSelected,
@@ -103,6 +69,10 @@ function SortableCompanyItem({
<a
href={`/${company.issuePrefix}/dashboard`}
onClick={(e) => {
if (isDragging) {
e.preventDefault();
return;
}
e.preventDefault();
onSelect();
}}
@@ -164,6 +134,11 @@ export function CompanyRail() {
() => companies.filter((company) => company.status !== "archived"),
[companies],
);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const companyIds = useMemo(() => sidebarCompanies.map((company) => company.id), [sidebarCompanies]);
const liveRunsQueries = useQueries({
@@ -195,52 +170,10 @@ export function CompanyRail() {
return result;
}, [companyIds, sidebarBadgeQueries]);
// Maintain sorted order in local state, synced from companies + localStorage
const [orderedIds, setOrderedIds] = useState<string[]>(() =>
sortByStoredOrder(sidebarCompanies).map((c) => c.id)
);
// Re-sync orderedIds from localStorage whenever companies changes.
// Handles initial data load (companies starts as [] before query resolves)
// and subsequent refetches triggered by live updates.
useEffect(() => {
if (sidebarCompanies.length === 0) {
setOrderedIds([]);
return;
}
setOrderedIds(sortByStoredOrder(sidebarCompanies).map((c) => c.id));
}, [sidebarCompanies]);
// Sync order across tabs via the native storage event
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key !== ORDER_STORAGE_KEY) return;
try {
const ids: string[] = e.newValue ? JSON.parse(e.newValue) : [];
setOrderedIds(ids);
} catch { /* ignore malformed data */ }
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
// Re-derive when companies change (new company added/removed)
const orderedCompanies = useMemo(() => {
const byId = new Map(sidebarCompanies.map((c) => [c.id, c]));
const result: Company[] = [];
for (const id of orderedIds) {
const c = byId.get(id);
if (c) {
result.push(c);
byId.delete(id);
}
}
// Append any new companies not yet in our order
for (const c of byId.values()) {
result.push(c);
}
return result;
}, [sidebarCompanies, orderedIds]);
const { orderedCompanies, persistOrder } = useCompanyOrder({
companies: sidebarCompanies,
userId: currentUserId,
});
// Require 8px of movement before starting a drag to avoid interfering with clicks
const sensors = useSensors(
@@ -260,11 +193,9 @@ export function CompanyRail() {
const newIndex = ids.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
const newIds = arrayMove(ids, oldIndex, newIndex);
setOrderedIds(newIds);
saveOrder(newIds);
persistOrder(arrayMove(ids, oldIndex, newIndex));
},
[orderedCompanies]
[orderedCompanies, persistOrder]
);
return (
+5 -1
View File
@@ -76,7 +76,11 @@ function SortableProjectItem({
<NavLink
to={`/projects/${routeRef}/issues`}
state={SIDEBAR_SCROLL_RESET_STATE}
onClick={() => {
onClick={(e) => {
if (isDragging) {
e.preventDefault();
return;
}
if (isMobile) setSidebarOpen(false);
}}
className={cn(
+100
View File
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Company } from "@paperclipai/shared";
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
import { queryKeys } from "../lib/queryKeys";
function areEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
function sortCompaniesByOrder(companies: Company[], orderedIds: string[]): Company[] {
if (companies.length === 0) return [];
if (orderedIds.length === 0) return companies;
const byId = new Map(companies.map((company) => [company.id, company]));
const sorted: Company[] = [];
for (const id of orderedIds) {
const company = byId.get(id);
if (!company) continue;
sorted.push(company);
byId.delete(id);
}
for (const company of byId.values()) {
sorted.push(company);
}
return sorted;
}
function buildOrderIds(companies: Company[], orderedIds: string[]) {
return sortCompaniesByOrder(companies, orderedIds).map((company) => company.id);
}
type UseCompanyOrderParams = {
companies: Company[];
userId: string | null | undefined;
};
export function useCompanyOrder({ companies, userId }: UseCompanyOrderParams) {
const queryClient = useQueryClient();
const queryKey = useMemo(
() => queryKeys.sidebarPreferences.companyOrder(userId ?? "__anon__"),
[userId],
);
const { data } = useQuery({
queryKey,
queryFn: () => sidebarPreferencesApi.getCompanyOrder(),
enabled: Boolean(userId),
});
const [orderedIds, setOrderedIds] = useState<string[]>(() => buildOrderIds(companies, []));
useEffect(() => {
const nextIds = buildOrderIds(companies, data?.orderedIds ?? []);
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
}, [companies, data?.orderedIds]);
const mutation = useMutation({
mutationFn: (nextIds: string[]) => sidebarPreferencesApi.updateCompanyOrder({ orderedIds: nextIds }),
onSuccess: (preference) => {
queryClient.setQueryData(queryKey, preference);
},
});
const orderedCompanies = useMemo(
() => sortCompaniesByOrder(companies, orderedIds),
[companies, orderedIds],
);
const persistOrder = useCallback(
(ids: string[]) => {
const idSet = new Set(companies.map((company) => company.id));
const filtered = ids.filter((id) => idSet.has(id));
for (const company of companies) {
if (!filtered.includes(company.id)) filtered.push(company.id);
}
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
if (!userId) return;
queryClient.setQueryData(queryKey, (current: { orderedIds?: string[]; updatedAt?: Date | null } | undefined) => ({
orderedIds: filtered,
updatedAt: current?.updatedAt ?? null,
}));
mutation.mutate(filtered);
},
[companies, mutation, queryClient, queryKey, userId],
);
return {
orderedCompanies,
orderedIds,
persistOrder,
};
}
+32 -52
View File
@@ -1,12 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import {
getProjectOrderStorageKey,
PROJECT_ORDER_UPDATED_EVENT,
readProjectOrder,
sortProjectsByStoredOrder,
writeProjectOrder,
} from "../lib/project-order";
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
import { sortProjectsByStoredOrder } from "../lib/project-order";
import { queryKeys } from "../lib/queryKeys";
type UseProjectOrderParams = {
projects: Project[];
@@ -14,11 +11,6 @@ type UseProjectOrderParams = {
userId: string | null | undefined;
};
type ProjectOrderUpdatedDetail = {
storageKey: string;
orderedIds: string[];
};
function areEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
@@ -32,48 +24,33 @@ function buildOrderIds(projects: Project[], orderedIds: string[]) {
}
export function useProjectOrder({ projects, companyId, userId }: UseProjectOrderParams) {
const storageKey = useMemo(() => {
if (!companyId) return null;
return getProjectOrderStorageKey(companyId, userId);
}, [companyId, userId]);
const queryClient = useQueryClient();
const queryKey = useMemo(
() => queryKeys.sidebarPreferences.projectOrder(companyId ?? "__none__", userId ?? "__anon__"),
[companyId, userId],
);
const { data } = useQuery({
queryKey,
queryFn: () => sidebarPreferencesApi.getProjectOrder(companyId!),
enabled: Boolean(companyId && userId),
});
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (!storageKey) return projects.map((project) => project.id);
return buildOrderIds(projects, readProjectOrder(storageKey));
return buildOrderIds(projects, []);
});
useEffect(() => {
const nextIds = storageKey
? buildOrderIds(projects, readProjectOrder(storageKey))
: projects.map((project) => project.id);
const nextIds = buildOrderIds(projects, data?.orderedIds ?? []);
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
}, [projects, storageKey]);
}, [data?.orderedIds, projects]);
useEffect(() => {
if (!storageKey) return;
const syncFromIds = (ids: string[]) => {
const nextIds = buildOrderIds(projects, ids);
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
};
const onStorage = (event: StorageEvent) => {
if (event.key !== storageKey) return;
syncFromIds(readProjectOrder(storageKey));
};
const onCustomEvent = (event: Event) => {
const detail = (event as CustomEvent<ProjectOrderUpdatedDetail>).detail;
if (!detail || detail.storageKey !== storageKey) return;
syncFromIds(detail.orderedIds);
};
window.addEventListener("storage", onStorage);
window.addEventListener(PROJECT_ORDER_UPDATED_EVENT, onCustomEvent);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(PROJECT_ORDER_UPDATED_EVENT, onCustomEvent);
};
}, [projects, storageKey]);
const mutation = useMutation({
mutationFn: (nextIds: string[]) => sidebarPreferencesApi.updateProjectOrder(companyId!, { orderedIds: nextIds }),
onSuccess: (preference) => {
queryClient.setQueryData(queryKey, preference);
},
});
const orderedProjects = useMemo(
() => sortProjectsByStoredOrder(projects, orderedIds),
@@ -89,11 +66,15 @@ export function useProjectOrder({ projects, companyId, userId }: UseProjectOrder
}
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
if (storageKey) {
writeProjectOrder(storageKey, filtered);
}
if (!companyId || !userId) return;
queryClient.setQueryData(queryKey, (current: { orderedIds?: string[]; updatedAt?: Date | null } | undefined) => ({
orderedIds: filtered,
updatedAt: current?.updatedAt ?? null,
}));
mutation.mutate(filtered);
},
[projects, storageKey],
[companyId, mutation, projects, queryClient, queryKey, userId],
);
return {
@@ -102,4 +83,3 @@ export function useProjectOrder({ projects, companyId, userId }: UseProjectOrder
persistOrder,
};
}
+5
View File
@@ -95,6 +95,11 @@ export const queryKeys = {
auth: {
session: ["auth", "session"] as const,
},
sidebarPreferences: {
companyOrder: (userId: string) => ["sidebar-preferences", "company-order", userId] as const,
projectOrder: (companyId: string, userId: string) =>
["sidebar-preferences", "project-order", companyId, userId] as const,
},
instance: {
generalSettings: ["instance", "general-settings"] as const,
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
+4 -4
View File
@@ -13,9 +13,9 @@ import { useToast } from "../context/ToastContext";
import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
import { agentsApi } from "../api/agents";
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
import { queryKeys } from "../lib/queryKeys";
import { getAgentOrderStorageKey, writeAgentOrder } from "../lib/agent-order";
import { getProjectOrderStorageKey, writeProjectOrder } from "../lib/project-order";
import { MarkdownBody } from "../components/MarkdownBody";
import { Button } from "@/components/ui/button";
import { EmptyState } from "../components/EmptyState";
@@ -346,7 +346,7 @@ function prefixedName(prefix: string | null, originalName: string): string {
return `${prefix}-${originalName}`;
}
function applyImportedSidebarOrder(
async function applyImportedSidebarOrder(
preview: CompanyPortabilityPreviewResult | null,
result: {
company: { id: string };
@@ -381,7 +381,7 @@ function applyImportedSidebarOrder(
writeAgentOrder(getAgentOrderStorageKey(result.company.id, userId), orderedAgentIds);
}
if (orderedProjectIds.length > 0) {
writeProjectOrder(getProjectOrderStorageKey(result.company.id, userId), orderedProjectIds);
await sidebarPreferencesApi.updateProjectOrder(result.company.id, { orderedIds: orderedProjectIds });
}
}
@@ -859,7 +859,7 @@ export function CompanyImport() {
?? refreshedSession?.user?.id
?? refreshedSession?.session?.userId
?? null;
applyImportedSidebarOrder(importPreview, result, sidebarOrderUserId);
await applyImportedSidebarOrder(importPreview, result, sidebarOrderUserId);
setSelectedCompanyId(importedCompany.id);
pushToast({
tone: "success",