From 83541560992e196b5c8673ce3bd8927e9c73d2b5 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Mon, 20 Apr 2026 12:09:14 +0000 Subject: [PATCH] Add ability to reorder apps on the home screen Implements drag-and-drop functionality for reordering apps using @dnd-kit, adds a new `userAppOrdersTable` to the database schema to store user-specific app order preferences, and introduces a new API endpoint `/api/me/app-order` for updating these preferences. --- artifacts/api-server/src/routes/apps.ts | 101 ++++++++++++------ artifacts/teaboy-os/package.json | 3 + artifacts/teaboy-os/src/pages/home.tsx | 91 ++++++++++++++-- .../src/generated/api.schemas.ts | 5 + lib/api-client-react/src/generated/api.ts | 87 +++++++++++++++ lib/api-spec/openapi.yaml | 37 +++++++ lib/api-zod/src/generated/api.ts | 27 +++++ lib/db/src/schema/index.ts | 1 + lib/db/src/schema/user-app-orders.ts | 19 ++++ pnpm-lock.yaml | 56 ++++++++++ 10 files changed, 381 insertions(+), 46 deletions(-) create mode 100644 lib/db/src/schema/user-app-orders.ts diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts index ec827d12..e731a1b2 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -1,5 +1,5 @@ import { Router, type IRouter } from "express"; -import { eq, asc, inArray } from "drizzle-orm"; +import { eq, asc, inArray, sql } from "drizzle-orm"; import { db } from "@workspace/db"; import { appsTable, @@ -7,6 +7,7 @@ import { userRolesTable, rolePermissionsTable, rolesTable, + userAppOrdersTable, } from "@workspace/db"; import { requireAuth, requireAdmin } from "../middlewares/auth"; import { @@ -15,13 +16,12 @@ import { GetAppParams, UpdateAppParams, DeleteAppParams, + UpdateMyAppOrderBody, } from "@workspace/api-zod"; const router: IRouter = Router(); -router.get("/apps", requireAuth, async (req, res): Promise => { - const userId = req.session.userId!; - +async function getVisibleAppsForUser(userId: number): Promise { const userRoleRows = await db .select({ roleName: rolesTable.name, roleId: userRolesTable.roleId }) .from(userRolesTable) @@ -30,18 +30,30 @@ router.get("/apps", requireAuth, async (req, res): Promise => { const isAdmin = userRoleRows.some((r) => r.roleName === "admin"); - if (isAdmin) { - const apps = await db - .select() - .from(appsTable) - .where(eq(appsTable.isActive, true)) - .orderBy(asc(appsTable.sortOrder), asc(appsTable.nameEn)); - res.json(apps); - return; - } + // Pull the user's preferred order, then order by COALESCE(user_order, app.sort_order, name). + const orderedRows = await db + .select({ + app: appsTable, + userSort: userAppOrdersTable.sortOrder, + }) + .from(appsTable) + .leftJoin( + userAppOrdersTable, + sql`${userAppOrdersTable.appId} = ${appsTable.id} and ${userAppOrdersTable.userId} = ${userId}`, + ) + .where(eq(appsTable.isActive, true)) + .orderBy( + sql`CASE WHEN ${userAppOrdersTable.sortOrder} IS NULL THEN 1 ELSE 0 END`, + asc(userAppOrdersTable.sortOrder), + asc(appsTable.sortOrder), + asc(appsTable.nameEn), + ); + + const apps = orderedRows.map((r) => r.app); + + if (isAdmin) return apps; const userRoleIds = userRoleRows.map((r) => r.roleId); - const userPermissionIds = userRoleIds.length > 0 ? (await db .select({ permissionId: rolePermissionsTable.permissionId }) @@ -50,29 +62,12 @@ router.get("/apps", requireAuth, async (req, res): Promise => { ).map((r) => r.permissionId) : []; - const apps = await db - .select() - .from(appsTable) - .where( - eq(appsTable.isActive, true), - ) - .orderBy(asc(appsTable.sortOrder), asc(appsTable.nameEn)); - const appsWithPermissions = await db .select({ appId: appPermissionsTable.appId }) .from(appPermissionsTable); - const restrictedAppIds = new Set(appsWithPermissions.map((r) => r.appId)); - if (restrictedAppIds.size === 0) { - const unrestricted = await db - .select() - .from(appsTable) - .where(eq(appsTable.isActive, true)) - .orderBy(asc(appsTable.sortOrder), asc(appsTable.nameEn)); - res.json(unrestricted); - return; - } + if (restrictedAppIds.size === 0) return apps; const allowedRestrictedAppIds = userPermissionIds.length > 0 ? (await db @@ -83,12 +78,48 @@ router.get("/apps", requireAuth, async (req, res): Promise => { : []; const allowedAppIdSet = new Set(allowedRestrictedAppIds); - - const visibleApps = apps.filter( + return apps.filter( (app) => !restrictedAppIds.has(app.id) || allowedAppIdSet.has(app.id), ); +} - res.json(visibleApps); +router.get("/apps", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const visible = await getVisibleAppsForUser(userId); + res.json(visible); +}); + +router.put("/me/app-order", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const parsed = UpdateMyAppOrderBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const { order } = parsed.data; + const visible = await getVisibleAppsForUser(userId); + const visibleIds = new Set(visible.map((a) => a.id)); + + const filtered = order.filter((id) => visibleIds.has(id)); + const seen = new Set(); + const dedup = filtered.filter((id) => { + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + + await db.transaction(async (tx) => { + await tx.delete(userAppOrdersTable).where(eq(userAppOrdersTable.userId, userId)); + if (dedup.length > 0) { + await tx.insert(userAppOrdersTable).values( + dedup.map((appId, idx) => ({ userId, appId, sortOrder: idx })), + ); + } + }); + + const updated = await getVisibleAppsForUser(userId); + res.json(updated); }); router.post("/apps", requireAdmin, async (req, res): Promise => { diff --git a/artifacts/teaboy-os/package.json b/artifacts/teaboy-os/package.json index d1151837..8f341f00 100644 --- a/artifacts/teaboy-os/package.json +++ b/artifacts/teaboy-os/package.json @@ -78,6 +78,9 @@ "zod": "catalog:" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@uppy/aws-s3": "^5.1.0", "@uppy/core": "^5.2.0", "@uppy/dashboard": "^5.1.1", diff --git a/artifacts/teaboy-os/src/pages/home.tsx b/artifacts/teaboy-os/src/pages/home.tsx index ddb501aa..7c45f1d8 100644 --- a/artifacts/teaboy-os/src/pages/home.tsx +++ b/artifacts/teaboy-os/src/pages/home.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "wouter"; import { @@ -10,13 +10,31 @@ import { getListNotificationsQueryKey, useLogout, useUpdateLanguage, + useUpdateMyAppOrder, getGetMeQueryKey, + type App, } from "@workspace/api-client-react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/contexts/AuthContext"; import { Bell, LogOut, Globe } from "lucide-react"; import * as LucideIcons from "lucide-react"; import i18n from "@/i18n"; +import { + DndContext, + PointerSensor, + TouchSensor, + useSensor, + useSensors, + closestCenter, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; function gradientForColor(color: string): string { const c = (color ?? "").toLowerCase(); @@ -51,6 +69,22 @@ function AppIcon({ app, onClick }: { app: { nameAr: string; nameEn: string; icon ); } +function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: app.id }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.6 : 1, + zIndex: isDragging ? 20 : "auto", + touchAction: "none", + }; + return ( +
+ +
+ ); +} + export default function HomePage() { const { t, i18n: i18nInstance } = useTranslation(); const [, setLocation] = useLocation(); @@ -60,6 +94,37 @@ export default function HomePage() { const lang = i18nInstance.language; const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } }); + const updateOrder = useUpdateMyAppOrder(); + const [orderedApps, setOrderedApps] = useState(null); + + useEffect(() => { + if (apps && !updateOrder.isPending) setOrderedApps(apps); + }, [apps, updateOrder.isPending]); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id || !orderedApps) return; + const oldIndex = orderedApps.findIndex((a) => a.id === active.id); + const newIndex = orderedApps.findIndex((a) => a.id === over.id); + if (oldIndex < 0 || newIndex < 0) return; + const next = arrayMove(orderedApps, oldIndex, newIndex); + setOrderedApps(next); + updateOrder.mutate( + { data: { order: next.map((a) => a.id) } }, + { + onError: () => { + queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() }); + }, + }, + ); + }; + + const sortableIds = useMemo(() => (orderedApps ?? []).map((a) => a.id), [orderedApps]); const { data: stats } = useGetHomeStats({ query: { queryKey: getGetHomeStatsQueryKey() } }); const { data: notifications } = useListNotifications({ query: { queryKey: getListNotificationsQueryKey() } }); @@ -108,7 +173,7 @@ export default function HomePage() { ); }; - const dockApps = apps?.filter((a) => ["services", "chat", "notifications", "admin"].includes(a.slug)) ?? []; + const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "chat", "notifications", "admin"].includes(a.slug)) ?? []; return (
@@ -189,15 +254,19 @@ export default function HomePage() { {/* Apps Grid */}

{t("home.myApps")}

-
- {apps?.map((app) => ( - setLocation(app.route)} - /> - ))} -
+ + +
+ {orderedApps?.map((app) => ( + setLocation(app.route)} + /> + ))} +
+
+
diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 7091b7db..17e18b9a 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -264,6 +264,11 @@ export interface AppSettings { updatedAt?: string; } +export interface UpdateMyAppOrderBody { + /** App IDs in the desired display order */ + order: number[]; +} + export interface UpdateAppSettingsBody { /** * @minLength 1 diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index da736e1e..34009082 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -40,6 +40,7 @@ import type { UpdateAppBody, UpdateAppSettingsBody, UpdateLanguageBody, + UpdateMyAppOrderBody, UpdateServiceBody, UpdateUserBody, UserProfile, @@ -928,6 +929,92 @@ export const useDeleteApp = < return useMutation(getDeleteAppMutationOptions(options)); }; +/** + * @summary Set the current user's preferred home apps order + */ +export const getUpdateMyAppOrderUrl = () => { + return `/api/me/app-order`; +}; + +export const updateMyAppOrder = async ( + updateMyAppOrderBody: UpdateMyAppOrderBody, + options?: RequestInit, +): Promise => { + return customFetch(getUpdateMyAppOrderUrl(), { + ...options, + method: "PUT", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(updateMyAppOrderBody), + }); +}; + +export const getUpdateMyAppOrderMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const mutationKey = ["updateMyAppOrder"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return updateMyAppOrder(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateMyAppOrderMutationResult = NonNullable< + Awaited> +>; +export type UpdateMyAppOrderMutationBody = BodyType; +export type UpdateMyAppOrderMutationError = ErrorType; + +/** + * @summary Set the current user's preferred home apps order + */ +export const useUpdateMyAppOrder = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getUpdateMyAppOrderMutationOptions(options)); +}; + /** * @summary Get application settings (public) */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 0ef9193f..6a6a12e4 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -236,6 +236,33 @@ paths: "204": description: Deleted + /me/app-order: + put: + operationId: updateMyAppOrder + tags: [apps] + summary: Set the current user's preferred home apps order + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMyAppOrderBody" + responses: + "200": + description: Updated apps in new order + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/App" + "400": + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + # Settings /settings: get: @@ -1191,6 +1218,16 @@ components: type: string format: date-time + UpdateMyAppOrderBody: + type: object + required: [order] + properties: + order: + type: array + items: + type: integer + description: App IDs in the desired display order + UpdateAppSettingsBody: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index bbce445a..0fdf5526 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -197,6 +197,33 @@ export const DeleteAppParams = zod.object({ id: zod.coerce.number(), }); +/** + * @summary Set the current user's preferred home apps order + */ +export const UpdateMyAppOrderBody = zod.object({ + order: zod + .array(zod.number()) + .describe("App IDs in the desired display order"), +}); + +export const UpdateMyAppOrderResponseItem = zod.object({ + id: zod.number(), + slug: zod.string(), + nameAr: zod.string(), + nameEn: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + iconName: zod.string(), + route: zod.string(), + color: zod.string(), + isActive: zod.boolean(), + isSystem: zod.boolean(), + sortOrder: zod.number(), + createdAt: zod.coerce.date(), + updatedAt: zod.coerce.date(), +}); +export const UpdateMyAppOrderResponse = zod.array(UpdateMyAppOrderResponseItem); + /** * @summary Get application settings (public) */ diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index 8ff8b90e..9636d39b 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -5,3 +5,4 @@ export * from "./services"; export * from "./conversations"; export * from "./notifications"; export * from "./settings"; +export * from "./user-app-orders"; diff --git a/lib/db/src/schema/user-app-orders.ts b/lib/db/src/schema/user-app-orders.ts new file mode 100644 index 00000000..528f15ea --- /dev/null +++ b/lib/db/src/schema/user-app-orders.ts @@ -0,0 +1,19 @@ +import { pgTable, integer, primaryKey } from "drizzle-orm/pg-core"; +import { usersTable } from "./users"; +import { appsTable } from "./apps"; + +export const userAppOrdersTable = pgTable( + "user_app_orders", + { + userId: integer("user_id") + .notNull() + .references(() => usersTable.id, { onDelete: "cascade" }), + appId: integer("app_id") + .notNull() + .references(() => appsTable.id, { onDelete: "cascade" }), + sortOrder: integer("sort_order").notNull(), + }, + (t) => [primaryKey({ columns: [t.userId, t.appId] })], +); + +export type UserAppOrder = typeof userAppOrdersTable.$inferSelect; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 290e9d07..cdcacc23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -343,6 +343,15 @@ importers: artifacts/teaboy-os: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.1.0) '@uppy/aws-s3': specifier: ^5.1.0 version: 5.1.0(@uppy/core@5.2.0) @@ -748,6 +757,28 @@ packages: '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: 19.1.0 + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: 19.1.0 + react-dom: 19.1.0 + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: 19.1.0 + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: 19.1.0 + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -4567,6 +4598,31 @@ snapshots: '@date-fns/tz@1.4.1': {} + '@dnd-kit/accessibility@3.1.1(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + '@drizzle-team/brocli@0.10.2': {} '@esbuild-kit/core-utils@3.3.2':