Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add total row in report tables #179

Merged
merged 13 commits into from
Oct 22, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const CommuneLevelReportTable = ({
const [head, ...rest] = arr;
const acc = { ...head, commune: undefined } as Record<
string,
string | number | undefined
string | string[] | number | undefined
>;
const keys = Object.keys(acc);
rest.forEach(x => {
Expand Down Expand Up @@ -74,7 +74,7 @@ export const CommuneLevelReportTable = ({
const [head, ...rest] = arr;
const acc = { ...head[0], district: undefined } as Record<
string,
string | number | undefined
string | string[] | number | undefined
>;
const keys = Object.keys(acc);
rest.forEach(x => {
Expand Down Expand Up @@ -148,6 +148,9 @@ export const CommuneLevelReportTable = ({
columnHeaderHeight="large"
variant={disasterTableParams.variant}
isFirstTable={disasterTableParams.isFirstTable}
aggregateRowFilter={row =>
ericboucher marked this conversation as resolved.
Show resolved Hide resolved
row.commune === undefined && row.district === undefined
}
/>
);
};
4 changes: 2 additions & 2 deletions apps/frontend/components/DisasterTable/CustomCommuneSort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface ExtendedGridSortCellParams extends GridSortCellParams {
}

export const findParentData = (
data: Record<string, string | number | undefined>[],
data: Record<string, string | string[] | number | undefined>[],
row: ExtendedGridSortCellParams,
): {
provinceData: number | undefined;
Expand All @@ -47,7 +47,7 @@ const isNumber = (
typeof value === 'number' || value === undefined;

export const createCustomComparator =
(summedData: Record<string, string | number | undefined>[]) =>
(summedData: Record<string, string | string[] | number | undefined>[]) =>
(
v1: number | undefined,
v2: number | undefined,
Expand Down
39 changes: 30 additions & 9 deletions apps/frontend/components/DisasterTable/DisasterTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import React, { useMemo } from 'react';
import { usePrintContext } from 'components/PrintWrapper/PrintWrapper';
import { colors } from 'theme/muiTheme';
import CustomToolMenu from 'utils/CustomToolMenu';
import { useAggregatedRow } from 'utils/tableFormatting';

import ScrollArrows from './ScrollArrows';

const isLastCovered = (group: GridColumnNode[], field: string): boolean => {
for (let index = 0; index < group.length; index++) {
Expand All @@ -36,14 +39,12 @@ const isLastCovered = (group: GridColumnNode[], field: string): boolean => {
return false;
};

import ScrollArrows from './ScrollArrows';

export type DisasterTableVariant = 'open' | 'bordered';

export interface DisasterTableProps {
columns: GridColDef[];
columnGroup: GridColumnGroupingModel;
data: Record<string, string | number | undefined>[];
data: Record<string, string | string[] | number | undefined>[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange?: (event: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -53,6 +54,9 @@ export interface DisasterTableProps {
variant: DisasterTableVariant;
getRowClassName?: DataGridProps['getRowClassName'];
isFirstTable?: boolean;
aggregateRowFilter?: (
row: Record<string, string | string[] | number | undefined>,
) => boolean;
}

export const DisasterTable = ({
Expand All @@ -66,6 +70,7 @@ export const DisasterTable = ({
columnHeaderHeight = 'large',
getRowClassName,
isFirstTable = true,
aggregateRowFilter,
}: DisasterTableProps): JSX.Element => {
const theme = useTheme();
const outerRef = React.useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -189,6 +194,19 @@ export const DisasterTable = ({
]
: _updatedColumns;

const {
data: extendedData,
columns: extendedColumns,
getRowId: extendedGetRowId,
getRowClassName: extendedGetRowClassName,
} = useAggregatedRow({
data: nonEmptyData,
columns: updatedColumns,
getRowId,
getRowClassName,
rowFilter: aggregateRowFilter,
});

const totalWidth = sum(updatedColumns.map(x => x.width ?? 0)) + 2; // 2px for borders on the sides
const maxPrintWidth = 1600; // Maximum print width in pixels
const scaleFactor = Math.min(1, maxPrintWidth / totalWidth);
Expand All @@ -205,8 +223,8 @@ export const DisasterTable = ({

const rowsPerPage = Math.floor(28 / scaleFactor);
const dataChunks = useMemo(() => {
return isPrinting ? chunk(nonEmptyData, rowsPerPage) : [nonEmptyData];
}, [isPrinting, nonEmptyData, rowsPerPage]);
return isPrinting ? chunk(extendedData, rowsPerPage) : [extendedData];
}, [isPrinting, extendedData, rowsPerPage]);

return (
<>
Expand Down Expand Up @@ -290,6 +308,9 @@ export const DisasterTable = ({
'& .MuiDataGrid-row.highlight-2': {
background: `#D0EBF9`,
},
'& .MuiDataGrid-row.total-row': {
fontWeight: 700,
},
'& .MuiDataGrid-cell.highlighted-cell': {
background: '#D0EBF9',
},
Expand Down Expand Up @@ -358,18 +379,18 @@ export const DisasterTable = ({
disableRowSelectionOnClick={!isEditable}
showCellVerticalBorder
showColumnVerticalBorder
rows={chunkOfRows}
columns={updatedColumns}
hideFooter
rows={chunkOfRows}
columns={extendedColumns}
columnGroupingModel={updatedColumnGroup}
isCellEditable={() => isEditable}
processRowUpdate={(newRow: GridRowModel) => {
if (onChange) onChange(newRow);

return newRow;
}}
getRowId={getRowId}
getRowClassName={getRowClassName}
getRowId={extendedGetRowId}
getRowClassName={extendedGetRowClassName}
autoHeight
columnHeaderHeight={
columnHeaderHeight === 'large' ? 100 : 72
Expand Down
13 changes: 0 additions & 13 deletions apps/frontend/components/pages/Report/ReportContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import DownloadIcon from '@mui/icons-material/Download';
import PrintIcon from '@mui/icons-material/Print';
import { IconButton, Skeleton, Stack, useTheme } from '@mui/material';
import { DisasterMapping } from '@wfp-dmp/interfaces';
Expand Down Expand Up @@ -98,18 +97,6 @@ export const ReportContainer = () => {
}
/>
<Stack direction="row" spacing={1}>
<IconButton
onClick={handlePrint}
style={{
border: `1px solid ${colors.color3}`,
borderRadius: '4px',
aspectRatio: 1,
height: '2.5rem',
color: colors.color3,
}}
>
<DownloadIcon />
</IconButton>
<IconButton
onClick={handlePrint}
style={{
Expand Down
3 changes: 2 additions & 1 deletion apps/frontend/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,8 @@
"district": "District",
"commune": "Commune",
"village": "Villages Reported"
}
},
"total": "Total"
}
},
"province": {
Expand Down
3 changes: 2 additions & 1 deletion apps/frontend/translations/km.json
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,8 @@
"district": "ស្រុក",
"commune": "ឃុំ",
"village": "ភូមិ"
}
},
"total": "សរុប"
}
},
"province": {
Expand Down
130 changes: 116 additions & 14 deletions apps/frontend/utils/tableFormatting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
GridColumnGroupingModel,
GridColumnHeaderParams,
GridColumnNode,
GridComparatorFn,
GridRenderCellParams,
GridRowClassNameParams,
GridSortDirection,
gridStringOrNumberComparator,
} from '@mui/x-data-grid';
import { DisasterType, KoboCommonKeys } from '@wfp-dmp/interfaces';
import React from 'react';
Expand Down Expand Up @@ -85,7 +89,7 @@ const getLocationColumnSetup = (
<FormattedMessage id={`forms_table.headers.${params.field}`} />
</Typography>
),
valueFormatter: value =>
valueGetter: value =>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explicit why this change is needed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return value of valueGetter is used for sorting and it's being passed in the valueFormatter or renderCell. With this change:

  1. We can use params.value in the renderCell without the need to duplicate the logic to construct the value.
  2. The sorting is based on the displayed value (the actual name of the location) and not on the code ("01-002") and behaves as expected (unless we need a custom order based on the code and not alphanumerical order).

From docs:

Note, that the value returned by valueFormatter is only used for rendering purposes. Filtering and sorting are based on the raw value (row[field]) or the value returned by valueGetter.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use the same approach here to calculate formattedList once in the valueGetter

valueFormatter: value => {
const villageList = value as string[] | undefined;
const formattedList = villageList
?.map(village =>
intl.formatMessage({ id: `${field}.${village}` }),
)
.sort((a, b) => a.localeCompare(b, intl.locale)) // Sort alphabetically
.join(', ');
return formattedList;
},
renderCell: (params: GridRenderCellParams) => {
const villageList = params.value as string[] | undefined;
const formattedList = villageList
?.map(village =>
intl.formatMessage({ id: `${field}.${village}` }),
)
.sort((a, b) => a.localeCompare(b, intl.locale)) // Sort alphabetically
.join(', ');

I skipped this as I thought it was out of scope and required more testing, but we can make that change since we are working on this area

Copy link
Collaborator

@ericboucher ericboucher Oct 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, worth exploring. Two things come to mind though:

  1. Ideally, for sorting, we might actually want to order by number of villages instead of the alphabetical order of the first village (which doesn't really mean anything). Noting that sorting doesn't really work for the village column at the moment, esp. in "Commune" reports.

  2. We still need to display the tooltip

// eslint-disable-next-line @typescript-eslint/no-unsafe-return
intl.formatMessage({ id: `${field}.${value as string}` }),
};
Expand Down Expand Up @@ -114,28 +118,21 @@ const getLocationCountColumnSetup = (
// all villages in the tooltip
...(field === KoboCommonKeys.village
? {
valueFormatter: value => {
valueGetter: value => {
const villageList = value as string[] | undefined;
const formattedList = villageList
?.map(village =>
intl.formatMessage({ id: `${field}.${village}` }),
)
.sort((a, b) => a.localeCompare(b, intl.locale)) // Sort alphabetically
.join(', ');
.sort((a, b) => a.localeCompare(b, intl.locale)); // Sort alphabetically

return formattedList;
return formattedList ?? [];
},
renderCell: (params: GridRenderCellParams) => {
const villageList = params.value as string[] | undefined;
const formattedList = villageList
?.map(village =>
intl.formatMessage({ id: `${field}.${village}` }),
)
.sort((a, b) => a.localeCompare(b, intl.locale)) // Sort alphabetically
.join(', ');
const displayLabel = (params.value as string[]).join(', ');

return (
<Tooltip title={formattedList} arrow>
<Tooltip title={displayLabel} arrow>
<div
style={{
overflow: 'hidden',
Expand All @@ -144,11 +141,17 @@ const getLocationCountColumnSetup = (
textAlign: 'left',
}}
>
{formattedList}
{displayLabel}
</div>
</Tooltip>
);
},
sortComparator: (v1, v2) => {
const v1List = v1 as string[];
const v2List = v2 as string[];

return v1List.length - v2List.length;
},
}
: {}),
};
Expand Down Expand Up @@ -343,3 +346,102 @@ export const wrapGroupAsTitle = ({
},
];
};

export const TOTAL_ROW_ID = 'total-row';

type UseAggregatedRowParams<R extends Record<string, unknown>> = {
data: R[];
columns: GridColDef[];
getRowId?: (row: R) => string;
getRowClassName?: (params: GridRowClassNameParams<R>) => string;
rowFilter?: (row: R) => boolean;
};

/**
* Add an aggregated row with the sum of the values.
* It configures the table to keep the total row on top & highlight it
* Pass the returned properties to the DataGrid component
*/
export const useAggregatedRow = <
R extends Record<string, unknown> = Record<
string,
string | number | undefined
>,
>({
data,
columns,
getRowId,
getRowClassName,
rowFilter = () => true,
}: UseAggregatedRowParams<R>) => {
const intl = useIntl();

const aggregatedRow = data
.filter(rowFilter)
.reduce<Record<string, string | number | string[]>>(
(acc, row) => {
columns.forEach(({ type, field }) => {
if (field === KoboCommonKeys.village) {
acc[field] = ((acc[field] as string[] | undefined) ?? []).concat(
(row[field] as string | undefined) ?? [],
);
} else if (type === 'number') {
acc[field] =
((acc[field] as number | undefined) ?? 0) +
Number(row[field] ?? 0);
}
});

return acc;
},
{ id: TOTAL_ROW_ID },
);

aggregatedRow[KoboCommonKeys.village] = Array.from(
new Set(aggregatedRow[KoboCommonKeys.village] as string[]),
);

// Update column add custofromm comparator to keep total on top & override first column cell
const updatedColumns = columns.map((col, i) => ({
...col,
getSortComparator: getTotalRowComparatorFactory(col.sortComparator),
...(i === 0 && {
renderCell: (params: GridRenderCellParams<R>) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
params.id === TOTAL_ROW_ID
? intl.formatMessage({ id: 'table.COMMON.total' })
: col.renderCell?.(params) ?? params.value,
}),
}));

const updatedGetRowId = (row: R) =>
row.id === TOTAL_ROW_ID ? TOTAL_ROW_ID : getRowId?.(row) ?? '';
const updatedGetRowClassName = (params: GridRowClassNameParams<R>) =>
params.id === TOTAL_ROW_ID ? 'total-row' : getRowClassName?.(params) ?? '';

return {
data: [aggregatedRow, ...data],
columns: updatedColumns,
getRowId: updatedGetRowId,
getRowClassName: updatedGetRowClassName,
};
};

const getTotalRowComparatorFactory =
(customComparator?: GridComparatorFn) =>
(sortDirection: GridSortDirection): GridComparatorFn =>
(v1, v2, param1, param2) => {
const modifier = sortDirection === 'desc' ? -1 : 1;
if (param1.id === TOTAL_ROW_ID) {
return -1;
}
if (param2.id === TOTAL_ROW_ID) {
return 1;
}

if (customComparator) {
return modifier * customComparator(v1, v2, param1, param2);
}

return modifier * gridStringOrNumberComparator(v1, v2, param1, param2);
};
Loading