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

Implement Table Actions pattern and default columns #998

Merged
merged 23 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
89b582e
Implement Table Actions pattern and default columns
martha Dec 16, 2024
50b472b
Add random comment
martha Dec 16, 2024
bb38721
Merge branch 'release-145' into martha/6761-tables
martha Dec 18, 2024
e47d030
Merge branch 'martha/6825-hh-member-row' into martha/6761-tables
martha Dec 18, 2024
7a4dd51
Add storybook for table actions and fix cde story
martha Dec 18, 2024
401f777
Merge branch 'martha/6825-hh-member-row' into martha/6761-tables
martha Dec 31, 2024
5c4d33a
Implement PR suggestion of moving table row to column def
martha Dec 31, 2024
2d2d133
Merge branch 'martha/6825-hh-member-row' into martha/6761-tables
martha Dec 31, 2024
8b6a383
Merge branch 'martha/6825-hh-member-row' into martha/6761-tables
martha Dec 31, 2024
2feec78
add todo
martha Dec 31, 2024
5026d48
Merge branch 'martha/6825-hh-member-row' into martha/6761-tables
martha Dec 31, 2024
39ce9ce
Merge branch 'martha/6825-hh-member-row' into martha/6761-tables
martha Jan 2, 2025
f91ea56
Merge branch 'martha/6825-hh-member-row' into martha/6761-tables
martha Jan 2, 2025
0605c2a
Finish refactor to use column def instead of getTableAction
martha Jan 2, 2025
d5bd5aa
Fix bulk services loading
martha Jan 2, 2025
e316ed0
Provide a visually hidden header when header isnt provided
martha Jan 2, 2025
7686543
Merge branch 'martha/6825-hh-member-row' into martha/6761-tables
martha Jan 2, 2025
004ea99
Keep existing behavior of individual/household assessment viewing
martha Jan 2, 2025
5bc2ea6
Merge branch 'martha/6825-hh-member-row' into martha/6761-tables
martha Jan 3, 2025
bdde162
Fix typescript
martha Jan 3, 2025
7339f04
Add accessible text for RelativeDateDisplay
martha Jan 3, 2025
eee782f
implement one possible approach for client ID typescript
martha Jan 6, 2025
61499cf
Remove unnecessary cast
martha Jan 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/elements/CommonMenuButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { To } from 'react-router-dom';

import RouterLink from './RouterLink';
import { MoreMenuIcon } from './SemanticIcons';
import { LocationState } from '@/routes/routeUtil';

export type CommonMenuItem = {
key: string;
Expand All @@ -22,6 +23,7 @@ export type CommonMenuItem = {
divider?: boolean;
disabled?: boolean;
ariaLabel?: string;
linkState?: LocationState;
};

interface Props {
Expand Down
1 change: 0 additions & 1 deletion src/components/elements/table/GenericTable.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ const Template =
);

const clientColumns = [
CLIENT_COLUMNS.id,
CLIENT_COLUMNS.first,
CLIENT_COLUMNS.last,
CLIENT_COLUMNS.ssn,
Expand Down
13 changes: 5 additions & 8 deletions src/components/elements/table/GenericTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,15 @@ import {
import TableRowActions, {
TableRowActionsType,
} from '@/components/elements/table/TableRowActions';
import { LocationState } from '@/routes/routeUtil';

export interface Props<T> {
rows: T[];
handleRowClick?: (row: T) => void;
rowLinkTo?: (row: T) => To | null | undefined;
rowLinkState?: LocationState;
columns?: ColumnDef<T>[];
paginated?: boolean;
loading?: boolean;
loadingRegardlessOfData?: boolean; // needed for correctly updating AssignBulkService button
martha marked this conversation as resolved.
Show resolved Hide resolved
loadingVariant?: 'circular' | 'linear';
tablePaginationProps?: TablePaginationProps;
tableContainerProps?: TableContainerProps;
Expand All @@ -74,12 +73,11 @@ export interface Props<T> {
filterToolbar?: ReactNode;
noData?: ReactNode;
renderRow?: (row: T, columnKeys: string[]) => ReactNode;
condensed?: boolean;
// if overrideTableBody is true, GenericTable doesn't render a `tbody` element.
// This should only be used by tables that take over rendering using renderRow and render a `tbody` within their custom render fn
overrideTableBody?: boolean;
injectBelowRows?: ReactNode; // component to inject below all rendered rows, above footer
getTableRowActions?: (record: T) => TableRowActionsType;
getTableRowActions?: (record: T, loading?: boolean) => TableRowActionsType;
getRowAccessibleName?: (row: T) => string;
}

Expand Down Expand Up @@ -118,6 +116,7 @@ const GenericTable = <T extends { id: string }>({
columns: columnProp,
paginated = false,
loading = false,
loadingRegardlessOfData = false,
vertical = false,
renderVerticalHeaderCell,
tablePaginationProps,
Expand All @@ -135,8 +134,6 @@ const GenericTable = <T extends { id: string }>({
renderRow,
noData = 'No data',
loadingVariant = 'circular',
condensed = false,
rowLinkState,
overrideTableBody = false,
injectBelowRows,
getTableRowActions,
Expand Down Expand Up @@ -435,7 +432,6 @@ const GenericTable = <T extends { id: string }>({
{isLinked ? (
<RouterLink
to={rowLink}
state={rowLinkState}
aria-label={ariaLabel && ariaLabel(row)}
plain={!linkTreatment}
data-testid={linkTreatment && 'table-linkedCell'}
Expand All @@ -455,7 +451,7 @@ const GenericTable = <T extends { id: string }>({
height: '100%',
alignItems: 'center',
px: 2,
py: condensed ? 1 : 2,
py: 2,
martha marked this conversation as resolved.
Show resolved Hide resolved
}}
>
{renderCellContents(row, render)}
Expand All @@ -477,6 +473,7 @@ const GenericTable = <T extends { id: string }>({
: row.id
}
getActions={getTableRowActions}
loading={loadingRegardlessOfData}
/>
</TableCell>
)}
Expand Down
61 changes: 49 additions & 12 deletions src/components/elements/table/TableRowActions.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,88 @@
import { Stack } from '@mui/material';
import { useMemo } from 'react';
import { Button, Stack } from '@mui/material';
import React, { ReactNode, useMemo } from 'react';
import ButtonLink from '../ButtonLink';
import CommonMenuButton, { CommonMenuItem } from '../CommonMenuButton';

export type TableRowActionsType = {
primaryAction?: CommonMenuItem;
primaryAction?: CommonMenuItem | ReactNode;
secondaryActions?: CommonMenuItem[];
};

interface TableRowActionsProps<T> {
record: T;
recordName?: string;
getActions: (record: T) => TableRowActionsType;
loading?: boolean;
getActions: (record: T, loading?: boolean) => TableRowActionsType;
}

const TableRowActions = <T extends { id: string }>({
record,
recordName,
getActions,
loading,
}: TableRowActionsProps<T>) => {
const accessibleName = useMemo(
() => recordName || record.id,
[record.id, recordName]
);

const { primaryAction, secondaryActions } = useMemo(
() => getActions(record),
[getActions, record]
() => getActions(record, loading),
[getActions, loading, record]
);

const { primaryActionNode, primaryActionTypesafe } = useMemo(() => {
return React.isValidElement(primaryAction)
? { primaryActionNode: primaryAction }
: { primaryActionTypesafe: primaryAction as CommonMenuItem };
}, [primaryAction]);

const secondariesWithAria = useMemo(
() =>
secondaryActions?.map((s) => {
return {
...s,
ariaLabel: s.ariaLabel || `${s.title}, ${recordName}`,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we need to support multiple ways of specifying the aria label? I wonder if we should just make it required on the primary/secondary action definitions, to simplify the logic. (Instead of inferring them based on recordName and title)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm yeah I agree that it's not worth the extra code complexity. But I also don't necessarily think we should make ariaLabel required on CommonMenuItem. In general, adding additional aria labels that express something beyond what the button text itself says should be optional.

I'm proposing this middle ground where the primary action's default aria label is set using title and recordName, if not provided in props, while secondary actions can "fend for themselves" in terms of aria labels. I think it's an ok balance. Let me know what you think, though, I'm happy to reconfigure it.

};
}),
[secondaryActions, recordName]
);

return (
<Stack direction='row' alignItems='center' gap={0.5}>
{!!primaryAction && (
{primaryActionNode}
{!!primaryActionTypesafe && !!primaryActionTypesafe.to && (
<ButtonLink
to={primaryAction.to || ''}
to={primaryActionTypesafe.to}
size='small'
variant='outlined'
aria-label={primaryAction.ariaLabel}
aria-label={
primaryActionTypesafe.ariaLabel ||
`${primaryActionTypesafe.title}, ${recordName}`
}
state={primaryActionTypesafe.linkState}
>
{primaryAction.title}
{primaryActionTypesafe.title}
</ButtonLink>
)}
{!!secondaryActions && secondaryActions.length > 0 && (
{!!primaryActionTypesafe && !!primaryActionTypesafe.onClick && (
<Button
onClick={primaryActionTypesafe.onClick}
size='small'
variant='outlined'
aria-label={
primaryActionTypesafe.ariaLabel ||
`${primaryActionTypesafe.title}, ${recordName}`
}
>
{primaryActionTypesafe.title}
</Button>
)}
{!!secondariesWithAria && secondariesWithAria.length > 0 && (
<CommonMenuButton
iconButton
title='Actions'
items={secondaryActions}
items={secondariesWithAria}
aria-label={`Action menu for ${accessibleName}`}
MenuProps={{
MenuListProps: {
Expand Down
74 changes: 74 additions & 0 deletions src/components/elements/table/tableActions/tableRowActionUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { generateAssessmentPath } from '@/modules/assessments/util';
import {
assessmentDescription,
clientBriefName,
entryExitRange,
parseAndFormatDate,
} from '@/modules/hmis/hmisUtil';
import { ServiceFields } from '@/modules/services/components/ProjectServicesTable';
import { getServiceTypeForDisplay } from '@/modules/services/serviceColumns';
import {
ClientDashboardRoutes,
EnrollmentDashboardRoutes,
} from '@/routes/routes';
import {
AssessmentFieldsFragment,
ClientNameFragment,
EnrollmentFieldsFragment,
} from '@/types/gqlTypes';
import { generateSafePath } from '@/utils/pathEncoding';

export const getViewClientAction = (client: ClientNameFragment) => {
return {
title: 'View Client',
key: 'client',
ariaLabel: `View Client, ${clientBriefName(client)}`,
to: generateSafePath(ClientDashboardRoutes.PROFILE, {
clientId: client.id,
}),
};
};

export const getViewEnrollmentAction = (
enrollment: Pick<EnrollmentFieldsFragment, 'id' | 'entryDate' | 'exitDate'>,
client: Pick<ClientNameFragment, 'id'> | ClientNameFragment
) => {
return {
title: 'View Enrollment',
key: 'enrollment',
ariaLabel: `View Enrollment, ${client.hasOwnProperty('firstName') ? clientBriefName(client as ClientNameFragment) : ''} ${entryExitRange(enrollment)}`,
to: generateSafePath(EnrollmentDashboardRoutes.ENROLLMENT_OVERVIEW, {
clientId: client.id,
enrollmentId: enrollment.id,
}),
};
};

export const getViewAssessmentAction = (
assessment: AssessmentFieldsFragment,
clientId: string,
enrollmentId: string
) => {
return {
title: 'View Assessment',
key: 'assessment',
ariaLabel: `View Assessment, ${assessmentDescription(assessment)}`,
to: generateAssessmentPath(assessment, clientId, enrollmentId),
};
};

export const getViewServiceAction = (
service: Pick<ServiceFields, 'serviceType' | 'dateProvided'>,
enrollmentId: string,
clientId: string
) => {
return {
title: 'View Service',
key: 'service',
ariaLabel: `View Service, ${getServiceTypeForDisplay(service.serviceType)} on ${parseAndFormatDate(service.dateProvided)}`,
to: generateSafePath(EnrollmentDashboardRoutes.SERVICES, {
clientId: clientId,
enrollmentId: enrollmentId,
}),
};
};
25 changes: 17 additions & 8 deletions src/modules/admin/components/services/ServiceTypeTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Chip } from '@mui/material';
import { ColumnDef } from '@/components/elements/table/types';
import GenericTableWithData from '@/modules/dataFetching/components/GenericTableWithData';
import { useFilters } from '@/modules/hmis/filterUtil';
import { getServiceTypeForDisplay } from '@/modules/services/serviceColumns';
import { AdminDashboardRoutes } from '@/routes/routes';
import {
GetServiceTypesDocument,
Expand All @@ -11,11 +12,10 @@ import {
} from '@/types/gqlTypes';
import { generateSafePath } from '@/utils/pathEncoding';

const columns: ColumnDef<ServiceTypeConfigFieldsFragment>[] = [
const COLUMNS: ColumnDef<ServiceTypeConfigFieldsFragment>[] = [
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We had briefly discussed storing all column definitions centrally, in a code-version of the beautiful column spreadsheet. In the end I didn't decide to do that in this PR, because I was worried it would be too much code churn for now. But I'm still somewhat interested in the idea -- you can see in this PR that we have a mix of approaches, some columns defined inline, some defined and exported and used by other components, and some defined in a util file.

I did try to standardize around only using useMemo/useCallback for columns and table actions when they have dependencies in the component props/state, and otherwise defining them in the same file but outside the component, like here. Let me know if you think this is a good approach.

{
header: 'Service Name',
render: 'name',
linkTreatment: true,
},
{
header: 'Service Category',
Expand All @@ -42,6 +42,18 @@ const columns: ColumnDef<ServiceTypeConfigFieldsFragment>[] = [
},
];

const getTableRowActions = (row: ServiceTypeConfigFieldsFragment) => {
return {
primaryAction: {
title: 'View Service Type',
key: 'service type',
to: generateSafePath(AdminDashboardRoutes.CONFIGURE_SERVICE_TYPE, {
serviceTypeId: row.id,
}),
},
};
};

const ServiceTypeTable = () => {
const filters = useFilters({
type: 'ServiceTypeFilterOptions',
Expand All @@ -57,17 +69,14 @@ const ServiceTypeTable = () => {
>
queryVariables={{}}
queryDocument={GetServiceTypesDocument}
columns={columns}
columns={COLUMNS}
getTableRowActions={getTableRowActions}
getRowAccessibleName={(record) => getServiceTypeForDisplay(record)}
pagePath='serviceTypes'
noData='No service types'
filters={filters}
recordType='ServiceType'
paginationItemName='service type'
rowLinkTo={(row) =>
generateSafePath(AdminDashboardRoutes.CONFIGURE_SERVICE_TYPE, {
serviceTypeId: row.id,
})
}
/>
</>
);
Expand Down
Loading