Skip to content

Commit

Permalink
Enable roles to be reordered through drag and drop
Browse files Browse the repository at this point in the history
  • Loading branch information
beverloo committed Mar 4, 2024
1 parent c7eca5d commit 9bb1a2c
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 37 deletions.
82 changes: 75 additions & 7 deletions app/admin/components/RemoteDataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 <DragIndicatorIcon fontSize="small" color="primary" sx={{ mt: 0.25 }} />
}

/**
* Type describing a column definition in the DataTable API.
*/
Expand Down Expand Up @@ -89,6 +101,15 @@ type RemoteDataTableProps<Endpoint extends keyof ApiEndpoints['get'],
*/
enableDelete?: `${Endpoint}/:id` extends keyof ApiEndpoints['delete'] ? boolean : false;

/**
* Whether rows can be reordered. An extra column will automatically be added to the table with
* drag handles, that the user is able to freely move upwards and downwards.
*
* @note The ability to sort rows is incompatible with manual reordering, thus we automatically
* disable that when this property is set to a truthy value.
*/
enableReorder?: `${Endpoint}/:id` extends keyof ApiEndpoints['put'] ? boolean : false;

/**
* Whether rows can be updated. When set, rows can be double clicked to move to edit mode, after
* which the full row will be sent to the server using a PUT request.
Expand Down Expand Up @@ -136,7 +157,7 @@ export function RemoteDataTable<
(
props: RemoteDataTableProps<Endpoint, RowModel>)
{
const { enableCreate, enableDelete, enableUpdate, refreshOnUpdate } = props;
const { enableCreate, enableDelete, enableReorder, enableUpdate, refreshOnUpdate } = props;

const subject = props.subject ?? 'item';
const context = useMemo(() =>
Expand Down Expand Up @@ -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<RowModel>[] = [];
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;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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} />
Expand Down
17 changes: 3 additions & 14 deletions app/admin/volunteers/teams/Roles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,6 @@ import { VolunteerBadge, VolunteerBadgeVariant } from '@components/VolunteerBadg
*/
export function Roles() {
const columns: RemoteDataTableColumn<RoleRowModel>[] = [
{
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',
Expand Down Expand Up @@ -113,8 +101,9 @@ export function Roles() {
<Alert severity="info" sx={{ mb: 2 }}>
This table is editable, and can be used to update the settings for each role.
</Alert>
<RemoteDataTable columns={columns} endpoint="/api/admin/volunteers/roles" enableUpdate
defaultSort={{ field: 'roleOrder', sort: 'asc' }} disableFooter />
<RemoteDataTable columns={columns} endpoint="/api/admin/volunteers/roles" enableReorder
enableUpdate defaultSort={{ field: 'roleOrder', sort: 'asc' }}
disableFooter />
</Paper>
);
}
23 changes: 21 additions & 2 deletions app/api/admin/volunteers/roles/[[...id]]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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({
Expand All @@ -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)
Expand Down
89 changes: 75 additions & 14 deletions app/api/createDataTableApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,29 @@ type DataTableListHandlerResponse<RowModel extends AnyZodObject> = DataTableHand
rows: z.infer<RowModel>[];
};

/**
* Request and response expected for PUT requests with the purpose of reordering rows.
*/
type DataTableReorderHandlerRequest<Context extends ZodTypeAny> = DataTableContext<Context> & {
/**
* 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.
*/
Expand All @@ -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<RowModel extends AnyZodObject, Context extends ZodTypeAny> =
DataTableReorderHandlerRequest<Context> | DataTableUpdateHandlerRequest<RowModel, Context>;

/**
* Request context that can be shared with the `writeLog` function.
*/
Expand Down Expand Up @@ -208,7 +238,7 @@ export type DataTableEndpoints<RowModel extends AnyZodObject, Context extends Zo
response: DataTableListHandlerResponse<RowModel>,
},
update: {
request: DataTableUpdateHandlerRequest<RowModel, Context>,
request: DataTablePutHandlerRequest<RowModel, Context>,
response: DataTableUpdateHandlerResponse,
},
};
Expand All @@ -222,7 +252,8 @@ export interface DataTableApi<RowModel extends AnyZodObject, Context extends Zod
* if necessary. This call will be _awaited_ for, and can also return synchronously.
*/
accessCheck?(request: DataTableContext<Context>,
action: 'create' | 'delete' | 'get' | 'list' | 'update', props: ActionProps)
action: 'create' | 'delete' | 'get' | 'list' | 'reorder' | 'update',
props: ActionProps)
: Promise<void> | void;

/**
Expand Down Expand Up @@ -253,6 +284,13 @@ export interface DataTableApi<RowModel extends AnyZodObject, Context extends Zod
list?(request: DataTableListHandlerRequest<RowModel, Context>, props: ActionProps)
: Promise<DataTableListHandlerResponse<RowModel>>;

/**
* 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<Context>, props: ActionProps)
: Promise<DataTableReorderHandlerResponse>;

/**
* 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.
Expand All @@ -265,7 +303,7 @@ export interface DataTableApi<RowModel extends AnyZodObject, Context extends Zod
* this action has taken place. This call will be _awaited_ for.
*/
writeLog?(request: DataTableWriteLogRequest<Context>,
mutation: 'Created' | 'Deleted' | 'Updated', props: ActionProps)
mutation: 'Created' | 'Deleted' | 'Reordered' | 'Updated', props: ActionProps)
: Promise<void> | void;
}

Expand Down Expand Up @@ -461,8 +499,13 @@ export function createDataTableApi<RowModel extends AnyZodObject, Context extend
const putInterface = z.object({
request: zContext.and(z.object({
id: z.coerce.number(),
row: rowModel.and(z.object({ id: z.number() })),
})),
})).and(
z.object({
row: rowModel.and(z.object({ id: z.number() })),
}).or(z.object({
order: z.array(z.number()),
}))
),
response: z.discriminatedUnion('success', [
zErrorResponse,
z.object({
Expand All @@ -473,19 +516,37 @@ export function createDataTableApi<RowModel extends AnyZodObject, Context extend

const PUT: DataTableApiHandler = async(request, { params }) => {
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);
};

Expand Down

0 comments on commit 9bb1a2c

Please sign in to comment.