From 80fb477c9424ded2a8069669f38497e8d13b5475 Mon Sep 17 00:00:00 2001 From: amrit Date: Wed, 8 Jul 2026 15:06:06 +0545 Subject: [PATCH] refactor(capacity-and-resources): add drag and reorder in dashboards --- app/hooks/useDashboardReorder.tsx | 110 ++++++++++++++++++ app/queries.ts | 13 +++ .../ResourceDashboards/index.tsx | 24 +++- app/views/CapacityAndResources/query.ts | 4 +- app/views/OurWorks/index.tsx | 12 +- app/views/Preparedness/index.tsx | 12 +- backend | 2 +- 7 files changed, 167 insertions(+), 10 deletions(-) create mode 100644 app/hooks/useDashboardReorder.tsx create mode 100644 app/queries.ts diff --git a/app/hooks/useDashboardReorder.tsx b/app/hooks/useDashboardReorder.tsx new file mode 100644 index 0000000..652c9fd --- /dev/null +++ b/app/hooks/useDashboardReorder.tsx @@ -0,0 +1,110 @@ +import { + cloneElement, + useCallback, + useRef, + useState, +} from 'react'; +import { DragDropLineIcon } from '@ifrc-go/icons'; +import { createElementColumn } from '@ifrc-go/ui/utils'; + +import { useBulkUpdateExternalDashboardsMutation } from '#generated/types/graphql'; +import useAlert from '#hooks/useAlert'; +import { errorMessage } from '#utils/common'; + +// eslint-disable-next-line react-refresh/only-export-components +function DragHandleCell() { + return ; +} + +export function createDragHandleColumn() { + return createElementColumn( + 'dragHandle', + '', + DragHandleCell, + () => ({}), + { columnWidth: 40 }, + ); +} + +interface ReorderItem { + id: string; + no: string; + order: number; +} + +function useDashboardReorder( + serverData: T[], + page: number, + limit: number, +) { + const alert = useAlert(); + const [, bulkUpdateExternalDashboards] = useBulkUpdateExternalDashboardsMutation(); + + const [tableData, setTableData] = useState(serverData); + const [prevServerData, setPrevServerData] = useState(serverData); + if (serverData !== prevServerData) { + setPrevServerData(serverData); + setTableData(serverData); + } + + const dragIndexRef = useRef(undefined); + + const handleRowDrop = useCallback((dropIndex: number) => { + const dragIndex = dragIndexRef.current; + dragIndexRef.current = undefined; + if (dragIndex === undefined || dragIndex === dropIndex) { + return; + } + const newData = [...tableData]; + const [moved] = newData.splice(dragIndex, 1); + newData.splice(dropIndex, 0, moved); + const reorderedData = newData.map((item, index) => ({ + ...item, + no: String((page - 1) * limit + index + 1), + order: (page - 1) * limit + index + 1, + })); + setTableData(reorderedData); + bulkUpdateExternalDashboards({ + data: reorderedData.map((item) => ({ + id: item.id, + order: item.order, + })), + }).then((resp) => { + const result = resp.data?.bulkUpdateExternalDashboards; + if (result?.ok) { + alert.show('Dashboard order updated successfully', { variant: 'success' }); + } else { + setTableData(serverData); + alert.show(errorMessage, { variant: 'danger' }); + } + }).catch(() => { + setTableData(serverData); + alert.show(errorMessage, { variant: 'danger' }); + }); + }, [tableData, serverData, page, limit, bulkUpdateExternalDashboards, alert]); + + const rowModifier = useCallback(({ row, datum }: { + row: React.ReactElement; + datum: T; + }) => { + const index = tableData.indexOf(datum); + return cloneElement(row, { + draggable: true, + style: { cursor: 'grab' }, + title: 'Drag to reorder', + onDragStart: () => { + dragIndexRef.current = index; + }, + onDragOver: (e: React.DragEvent) => { + e.preventDefault(); + }, + onDrop: () => { + handleRowDrop(index); + }, + } as React.HTMLAttributes); + }, [tableData, handleRowDrop]); + + return { tableData, rowModifier }; +} + +export default useDashboardReorder; diff --git a/app/queries.ts b/app/queries.ts new file mode 100644 index 0000000..086a3e8 --- /dev/null +++ b/app/queries.ts @@ -0,0 +1,13 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { gql } from 'urql'; + +const BULK_UPDATE_EXTERNAL_DASHBOARDS = gql` + mutation BulkUpdateExternalDashboards($data: [ExternalDashboardOrderInput!]!) { + bulkUpdateExternalDashboards(data: $data) { + ... on ExternalDashboardTypeListMutationResponseType { + ok + errors + } + } + } +`; diff --git a/app/views/CapacityAndResources/ResourceDashboards/index.tsx b/app/views/CapacityAndResources/ResourceDashboards/index.tsx index 8370a2e..79e929b 100644 --- a/app/views/CapacityAndResources/ResourceDashboards/index.tsx +++ b/app/views/CapacityAndResources/ResourceDashboards/index.tsx @@ -21,12 +21,14 @@ import StatusCell from '#components/StatusCell'; import { AdminAreaLevel, type ExternalDashboardFilter, + Ordering, type ResourceDashboardsQuery, useCapacityAndResourceDetailQuery, useDeleteResourceDashboardMutation, useResourceDashboardsQuery, } from '#generated/types/graphql'; import useAlert from '#hooks/useAlert'; +import useDashboardReorder, { createDragHandleColumn } from '#hooks/useDashboardReorder'; import useFilterState from '#hooks/useFilterState'; import useRegionMap from '#hooks/useRegionMap'; import useRouting from '#hooks/useRouting'; @@ -70,7 +72,7 @@ function ResourceDashboards() { const regionMap = useRegionMap(AdminAreaLevel.Region); - const [{ data: detailData, fetching: detailFetching }] = useCapacityAndResourceDetailQuery({ + const [{ data: detailData }] = useCapacityAndResourceDetailQuery({ variables: { id: id ?? '' }, pause: !id, }); @@ -88,17 +90,20 @@ function ResourceDashboards() { limit, offset, }, + order: { order: Ordering.Asc }, }, pause: !id, }); - const tableData: DashboardListItem[] = useMemo(() => ( + const serverData: DashboardListItem[] = useMemo(() => ( (data?.externalDashboards?.results ?? []).map((dashboard, index) => ({ ...dashboard, no: String((page - 1) * limit + index + 1), })) ), [page, data, limit]); + const { tableData, rowModifier } = useDashboardReorder(serverData, page, limit); + const onDeleteClick = useCallback( (dashboardId: string) => { deleteResourceDashboard({ id: dashboardId }).then((resp) => { @@ -117,6 +122,7 @@ function ResourceDashboards() { ); const columns = useMemo(() => [ + createDragHandleColumn(), createStringColumn( 'no', 'No.', @@ -137,6 +143,11 @@ function ResourceDashboards() { 'Region', (item) => (isDefined(item.regionId) ? regionMap[item.regionId] : '-'), ), + createStringColumn( + 'order', + 'Display Order', + (item) => (isDefined(item.order) ? String(item.order) : '-'), + ), createElementColumn( 'status', 'Status', @@ -152,7 +163,7 @@ function ResourceDashboards() { (_, datum) => ({ id: id ?? '', dashboard: datum.id, - onDelete: onDeleteClick, + onDelete: () => onDeleteClick(datum.id), itemTitle: datum.title, to: 'editResourceDashboard', }), @@ -169,7 +180,9 @@ function ResourceDashboards() { return ( ); diff --git a/app/views/CapacityAndResources/query.ts b/app/views/CapacityAndResources/query.ts index 14664e3..bcc2031 100644 --- a/app/views/CapacityAndResources/query.ts +++ b/app/views/CapacityAndResources/query.ts @@ -74,8 +74,8 @@ const UPDATE_CAPACITY_AND_RESOURCE = gql` `; const RESOURCE_DASHBOARDS = gql` - query ResourceDashboards($pagination: OffsetPaginationInput, $filters: ExternalDashboardFilter) { - externalDashboards(pagination: $pagination, filters: $filters) { + query ResourceDashboards($pagination: OffsetPaginationInput, $filters: ExternalDashboardFilter, $order: ExternalDashboardOrder) { + externalDashboards(pagination: $pagination, filters: $filters, order: $order) { results { id title diff --git a/app/views/OurWorks/index.tsx b/app/views/OurWorks/index.tsx index e597d92..fed9a07 100644 --- a/app/views/OurWorks/index.tsx +++ b/app/views/OurWorks/index.tsx @@ -26,6 +26,7 @@ import { useExternalDashboardsQuery, } from '#generated/types/graphql'; import useAlert from '#hooks/useAlert'; +import useDashboardReorder, { createDragHandleColumn } from '#hooks/useDashboardReorder'; import useFilterState from '#hooks/useFilterState'; import useRegionMap from '#hooks/useRegionMap'; import useRouting from '#hooks/useRouting'; @@ -92,7 +93,7 @@ function OurWorks() { }); const [, deleteExternalDashboard] = useDeleteExternalDashboardMutation(); - const tableData = useMemo(() => ( + const serverData = useMemo(() => ( (data?.externalDashboards?.results ?? []).map((dashboard, index) => { const no = (page - 1) * limit + index + 1; return { @@ -101,6 +102,8 @@ function OurWorks() { }; }) as unknown as WorksListItem[]), [page, data, limit]); + const { tableData, rowModifier } = useDashboardReorder(serverData, page, limit); + const onDeleteClick = useCallback( (id: string) => { deleteExternalDashboard({ id }).then((resp) => { @@ -119,6 +122,7 @@ function OurWorks() { ); const columns = useMemo(() => [ + createDragHandleColumn(), createStringColumn( 'no', 'No.', @@ -139,6 +143,11 @@ function OurWorks() { 'Region', (item) => (isDefined(item.regionId) ? regionMap[item.regionId] : '-'), ), + createStringColumn( + 'order', + 'Display Order', + (item) => (isDefined(item.order) ? String(item.order) : '-'), + ), createElementColumn( 'status', 'Status', @@ -201,6 +210,7 @@ function OurWorks() { data={tableData} filtered={filtered} pending={fetching} + rowModifier={rowModifier} /> ); diff --git a/app/views/Preparedness/index.tsx b/app/views/Preparedness/index.tsx index ef6a0a1..09eae66 100644 --- a/app/views/Preparedness/index.tsx +++ b/app/views/Preparedness/index.tsx @@ -26,6 +26,7 @@ import { usePreparednessExternalDashboardsQuery, } from '#generated/types/graphql'; import useAlert from '#hooks/useAlert'; +import useDashboardReorder, { createDragHandleColumn } from '#hooks/useDashboardReorder'; import useFilterState from '#hooks/useFilterState'; import useRegionMap from '#hooks/useRegionMap'; import useRouting from '#hooks/useRouting'; @@ -92,13 +93,15 @@ function PreparednessList() { }); const [, deleteExternalDashboard] = usePreparednessDeleteExternalDashboardMutation(); - const tableData: PreparednessListItem[] = useMemo(() => ( + const serverData: PreparednessListItem[] = useMemo(() => ( (data?.externalDashboards?.results ?? []).map((dashboard, index) => ({ ...dashboard, no: String((page - 1) * limit + index + 1), })) ), [page, data, limit]); + const { tableData, rowModifier } = useDashboardReorder(serverData, page, limit); + const onDeleteClick = useCallback( (id: string) => { deleteExternalDashboard({ id }).then((resp) => { @@ -117,6 +120,7 @@ function PreparednessList() { ); const columns = useMemo(() => [ + createDragHandleColumn(), createStringColumn( 'no', 'No.', @@ -137,6 +141,11 @@ function PreparednessList() { 'Region', (item) => (isDefined(item.regionId) ? regionMap[item.regionId] : '-'), ), + createStringColumn( + 'order', + 'Display Order', + (item) => (isDefined(item.order) ? String(item.order) : '-'), + ), createElementColumn( 'status', 'Status', @@ -199,6 +208,7 @@ function PreparednessList() { data={tableData} filtered={filtered} pending={fetching} + rowModifier={rowModifier} /> ); diff --git a/backend b/backend index cca87cb..f8cb4e9 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit cca87cbf1b50ad0436a34efe48e74a3cb17a01b3 +Subproject commit f8cb4e979b3139696d59849216891c10541b5d59