From 9bb1a2ce708ef0240eafb9bcef46401d7e84f0b2 Mon Sep 17 00:00:00 2001 From: Peter Beverloo Date: Mon, 4 Mar 2024 20:50:31 +0000 Subject: [PATCH] Enable roles to be reordered through drag and drop --- app/admin/components/RemoteDataTable.tsx | 82 +++++++++++++++-- app/admin/volunteers/teams/Roles.tsx | 17 +--- .../admin/volunteers/roles/[[...id]]/route.ts | 23 ++++- app/api/createDataTableApi.ts | 89 ++++++++++++++++--- 4 files changed, 174 insertions(+), 37 deletions(-) diff --git a/app/admin/components/RemoteDataTable.tsx b/app/admin/components/RemoteDataTable.tsx index 27f7bd9d..724d9ca9 100644 --- a/app/admin/components/RemoteDataTable.tsx +++ b/app/admin/components/RemoteDataTable.tsx @@ -6,8 +6,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import type { GridAlignment, GridCellParams, GridColDef, GridPaginationModel, GridRenderCellParams, - GridRowModesModel, GridSortItem, GridSortModel, GridValidRowModel } from '@mui/x-data-grid-pro'; +import type { + GridAlignment, GridCellParams, GridColDef, GridPaginationModel, GridRenderCellParams, + GridRowModesModel, GridRowOrderChangeParams, GridSortItem, GridSortModel, GridValidRowModel +} from '@mui/x-data-grid-pro'; + import { DataGridPro, GridRowModes } from '@mui/x-data-grid-pro'; import AddCircleIcon from '@mui/icons-material/AddCircle'; @@ -19,12 +22,21 @@ import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import IconButton from '@mui/material/IconButton'; import LoadingButton from '@mui/lab/LoadingButton'; import Tooltip from '@mui/material/Tooltip'; import { type ApiEndpoints, callApi } from '@lib/callApi'; +/** + * Icon used to be able to drag and re-order rows. We use a smaller icon than default to fit in with + * the dense-by-default look our data tables have. + */ +function RemoteDataTableMoveIcon() { + return +} + /** * Type describing a column definition in the DataTable API. */ @@ -89,6 +101,15 @@ type RemoteDataTableProps) { - const { enableCreate, enableDelete, enableUpdate, refreshOnUpdate } = props; + const { enableCreate, enableDelete, enableReorder, enableUpdate, refreshOnUpdate } = props; const subject = props.subject ?? 'item'; const context = useMemo(() => @@ -192,13 +213,18 @@ export function RemoteDataTable< }, [ context, enableCreate, props.columns, props.endpoint, subject ]); const columns = useMemo(() => { - if (!enableCreate && !enableDelete) + if (!enableCreate && !enableDelete && !enableReorder) return props.columns; const columns: GridColDef[] = []; for (const column of props.columns) { + let sortable: boolean | undefined = column.sortable; + if (enableReorder) { + sortable = /* mutually exclusive w/ reordering */ false; + } + if (column.field !== 'id') { - columns.push(column); + columns.push({ ...column, sortable }); continue; } @@ -248,12 +274,13 @@ export function RemoteDataTable< headerAlign, renderCell, renderHeader, + sortable, }); } return columns; - }, [ enableCreate, handleCreate, enableDelete, props.columns, subject ]); + }, [ enableCreate, handleCreate, enableDelete, enableReorder, props.columns, subject ]); // --------------------------------------------------------------------------------------------- // Capability: (R)ead existing rows @@ -314,6 +341,43 @@ export function RemoteDataTable< // Capability: (U)pdate existing rows // --------------------------------------------------------------------------------------------- + const handleReorder = useCallback(async (params: GridRowOrderChangeParams) => { + if (params.oldIndex === params.targetIndex) + return; // ignore no-op changes + + if (params.oldIndex < 0 || params.oldIndex >= rows.length) + return; + if (params.targetIndex < 0 || params.targetIndex >= rows.length) + return; + + setError(undefined); + try { + if (!enableReorder) + throw new Error('reorder actions are not supported for this type'); + + const copiedRows = [ ...rows ]; + const copiedRow = copiedRows.splice(params.oldIndex, 1)[0]; + copiedRows.splice(params.targetIndex, 0, copiedRow); + + const response = await callApi('put', `${props.endpoint}` as any, { + ...context, + id: copiedRow.id, + order: copiedRows.map(row => row.id), + }); + + if (response.success) { + if (!!refreshOnUpdate) + router.refresh(); + + setRows(copiedRows); + } else { + setError(response.error ?? 'Unable to update the row order'); + } + } catch (error: any) { + setError(`Unable to update the row order (${error.message})`); + } + }, [ context, enableReorder, props.endpoint, refreshOnUpdate, router, rows ]); + const handleUpdate = useCallback(async (newRow: RowModel, oldRow: RowModel) => { setError(undefined); try { @@ -403,12 +467,16 @@ export function RemoteDataTable< rowModesModel={rowModesModel} onRowModesModelChange={setRowModesModel} processRowUpdate={handleUpdate} editMode={enableUpdate ? 'row' : undefined} + rowReordering={enableReorder} onRowOrderChange={handleReorder} + slots={{ rowReorderIcon: RemoteDataTableMoveIcon }} + pageSizeOptions={[ 10, 25, 50, 100 ]} paginationMode="server" paginationModel={paginationModel} onPaginationModelChange={handlePaginationModelChange} sortingMode="server" - sortModel={sortModel} onSortModelChange={handleSortModelChange} + sortModel={ enableReorder ? undefined : sortModel } + onSortModelChange={ enableReorder ? undefined : handleSortModelChange } autoHeight density="compact" disableColumnMenu hideFooterSelectedRowCount loading={loading} hideFooter={!!props.disableFooter} /> diff --git a/app/admin/volunteers/teams/Roles.tsx b/app/admin/volunteers/teams/Roles.tsx index 51941dae..5d138122 100644 --- a/app/admin/volunteers/teams/Roles.tsx +++ b/app/admin/volunteers/teams/Roles.tsx @@ -19,18 +19,6 @@ import { VolunteerBadge, VolunteerBadgeVariant } from '@components/VolunteerBadg */ export function Roles() { const columns: RemoteDataTableColumn[] = [ - { - field: 'roleOrder', - headerName: '#', - headerAlign: 'center', - editable: true, - sortable: true, - align: 'center', - width: 75, - - type: 'singleSelect', - valueOptions: [ 0, 1, 2, 3, 4, 5 ], - }, { field: 'roleName', headerName: 'Role', @@ -113,8 +101,9 @@ export function Roles() { This table is editable, and can be used to update the settings for each role. - + ); } diff --git a/app/api/admin/volunteers/roles/[[...id]]/route.ts b/app/api/admin/volunteers/roles/[[...id]]/route.ts index acbb7af7..2dbf02ea 100644 --- a/app/api/admin/volunteers/roles/[[...id]]/route.ts +++ b/app/api/admin/volunteers/roles/[[...id]]/route.ts @@ -92,7 +92,7 @@ export const { GET, PUT } = createDataTableApi(kRoleRowModel, kRoleContext, { }); }, - async list({ sort }) { + async list() { const roles = await db.selectFrom(tRoles) .select({ id: tRoles.roleId, @@ -104,7 +104,7 @@ export const { GET, PUT } = createDataTableApi(kRoleRowModel, kRoleContext, { hotelEligible: tRoles.roleHotelEligible.equals(/* true= */ 1), trainingEligible: tRoles.roleTrainingEligible.equals(/* true= */ 1), }) - .orderBy(sort?.field ?? 'roleOrder', sort?.sort ?? 'asc') + .orderBy('roleOrder', 'asc') .executeSelectMany(); return { @@ -114,6 +114,22 @@ export const { GET, PUT } = createDataTableApi(kRoleRowModel, kRoleContext, { }; }, + async reorder({ order }) { + const dbInstance = db; + await dbInstance.transaction(async () => { + for (let index = 0; index < order.length; ++index) { + await db.update(tRoles) + .set({ roleOrder: index }) + .where(tRoles.roleId.equals(order[index])) + .executeUpdate(); + } + }); + + return { + success: true, + } + }, + async update({ row }) { const affectedRows = await db.update(tRoles) .set({ @@ -132,6 +148,9 @@ export const { GET, PUT } = createDataTableApi(kRoleRowModel, kRoleContext, { }, async writeLog({ id }, mutation, props) { + if (mutation === 'Reordered') + return; // no need to log when someone changes the order of roles + const roleName = await db.selectFrom(tRoles) .where(tRoles.roleId.equals(id)) .selectOneColumn(tRoles.roleName) diff --git a/app/api/createDataTableApi.ts b/app/api/createDataTableApi.ts index 91bd47e9..88cfb632 100644 --- a/app/api/createDataTableApi.ts +++ b/app/api/createDataTableApi.ts @@ -153,6 +153,29 @@ type DataTableListHandlerResponse = DataTableHand rows: z.infer[]; }; +/** + * Request and response expected for PUT requests with the purpose of reordering rows. + */ +type DataTableReorderHandlerRequest = DataTableContext & { + /** + * Unique ID of the row that's been moved. + */ + id: number; + + /** + * The order in which the rows should be sorted. Inclusive of all IDs that were known on the + * client. + */ + order: number[]; +}; + +type DataTableReorderHandlerResponse = DataTableHandlerErrorResponse | { + /** + * Whether the operation could be completed successfully. + */ + success: true, +}; + /** * Request and response expected for PUT requests with the purpose of updating rows. */ @@ -177,6 +200,13 @@ type DataTableUpdateHandlerResponse = DataTableHandlerErrorResponse | { success: true, }; +/** + * The input request format for PUT requests to this Data Table API's endpoint. The endpoint + * supports two formats - either updating a single row, or updating row order. + */ +type DataTablePutHandlerRequest = + DataTableReorderHandlerRequest | DataTableUpdateHandlerRequest; + /** * Request context that can be shared with the `writeLog` function. */ @@ -208,7 +238,7 @@ export type DataTableEndpoints, }, update: { - request: DataTableUpdateHandlerRequest, + request: DataTablePutHandlerRequest, response: DataTableUpdateHandlerResponse, }, }; @@ -222,7 +252,8 @@ export interface DataTableApi, - action: 'create' | 'delete' | 'get' | 'list' | 'update', props: ActionProps) + action: 'create' | 'delete' | 'get' | 'list' | 'reorder' | 'update', + props: ActionProps) : Promise | void; /** @@ -253,6 +284,13 @@ export interface DataTableApi, props: ActionProps) : Promise>; + /** + * Reorders the rows in the data table in accordance with the `request`, which contains all + * rows that were fetched. This functionality does not work well with pagination. + */ + reorder?(request: DataTableReorderHandlerRequest, props: ActionProps) + : Promise; + /** * Updates a given row in the database. The full to-be-updated row must be given, including the * fields that are not editable. The row ID is mandatory in this request. @@ -265,7 +303,7 @@ export interface DataTableApi, - mutation: 'Created' | 'Deleted' | 'Updated', props: ActionProps) + mutation: 'Created' | 'Deleted' | 'Reordered' | 'Updated', props: ActionProps) : Promise | void; } @@ -461,8 +499,13 @@ export function createDataTableApi { return executeAction(request, putInterface, async (innerRequest, props) => { - if (!implementation.update) - throw new Error('Cannot handle PUT requests without an update handler'); + if ('order' in innerRequest) { + if (!implementation.reorder) + throw new Error('Cannot handle PUT requests without an reorder handler'); - if (innerRequest.id !== innerRequest.row.id) - throw new Error('ID mismatch between the route and the contained row'); + if (innerRequest.order.length < 2) + throw new Error('Expected at least two rows when reordering rows.'); - await implementation.accessCheck?.(innerRequest, 'update', props); + await implementation.accessCheck?.(innerRequest, 'reorder', props); - const response = await implementation.update(innerRequest, props); - if (response.success) - await implementation.writeLog?.(innerRequest, 'Updated', props); + const response = await implementation.reorder(innerRequest, props); + if (response.success) + await implementation.writeLog?.(innerRequest, 'Reordered', props); + + return response; + + } else { + if (!implementation.update) + throw new Error('Cannot handle PUT requests without an update handler'); + + if (innerRequest.id !== innerRequest.row.id) + throw new Error('ID mismatch between the route and the contained row'); + + await implementation.accessCheck?.(innerRequest, 'update', props); + + const response = await implementation.update(innerRequest, props); + if (response.success) + await implementation.writeLog?.(innerRequest, 'Updated', props); + + return response; + } - return response; }, params); };