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.
This commit is contained in:
Riyadh
2026-04-20 12:09:14 +00:00
parent c46115ea2d
commit 8354156099
10 changed files with 381 additions and 46 deletions
+66 -35
View File
@@ -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<void> => {
const userId = req.session.userId!;
async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$inferSelect[]> {
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<void> => {
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<void> => {
).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<void> => {
: [];
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<void> => {
const userId = req.session.userId!;
const visible = await getVisibleAppsForUser(userId);
res.json(visible);
});
router.put("/me/app-order", requireAuth, async (req, res): Promise<void> => {
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<number>();
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<void> => {
+3
View File
@@ -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",
+80 -11
View File
@@ -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 (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<AppIcon app={app} onClick={onClick} />
</div>
);
}
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<App[] | null>(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 (
<div className="min-h-screen os-bg flex flex-col overflow-hidden relative">
@@ -189,15 +254,19 @@ export default function HomePage() {
{/* Apps Grid */}
<div className="mb-4">
<h3 className="text-sm font-medium text-muted-foreground mb-4">{t("home.myApps")}</h3>
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-4">
{apps?.map((app) => (
<AppIcon
key={app.id}
app={app}
onClick={() => setLocation(app.route)}
/>
))}
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-4">
{orderedApps?.map((app) => (
<SortableAppIcon
key={app.id}
app={app}
onClick={() => setLocation(app.route)}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>
@@ -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
+87
View File
@@ -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<App[]> => {
return customFetch<App[]>(getUpdateMyAppOrderUrl(), {
...options,
method: "PUT",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateMyAppOrderBody),
});
};
export const getUpdateMyAppOrderMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMyAppOrder>>,
TError,
{ data: BodyType<UpdateMyAppOrderBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateMyAppOrder>>,
TError,
{ data: BodyType<UpdateMyAppOrderBody> },
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<ReturnType<typeof updateMyAppOrder>>,
{ data: BodyType<UpdateMyAppOrderBody> }
> = (props) => {
const { data } = props ?? {};
return updateMyAppOrder(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateMyAppOrderMutationResult = NonNullable<
Awaited<ReturnType<typeof updateMyAppOrder>>
>;
export type UpdateMyAppOrderMutationBody = BodyType<UpdateMyAppOrderBody>;
export type UpdateMyAppOrderMutationError = ErrorType<ErrorResponse>;
/**
* @summary Set the current user's preferred home apps order
*/
export const useUpdateMyAppOrder = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMyAppOrder>>,
TError,
{ data: BodyType<UpdateMyAppOrderBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateMyAppOrder>>,
TError,
{ data: BodyType<UpdateMyAppOrderBody> },
TContext
> => {
return useMutation(getUpdateMyAppOrderMutationOptions(options));
};
/**
* @summary Get application settings (public)
*/
+37
View File
@@ -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:
+27
View File
@@ -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)
*/
+1
View File
@@ -5,3 +5,4 @@ export * from "./services";
export * from "./conversations";
export * from "./notifications";
export * from "./settings";
export * from "./user-app-orders";
+19
View File
@@ -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;
+56
View File
@@ -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':