Skip to content

Commit

Permalink
Merge pull request #1635 from SeedCompany/engagement-datagrid-editing
Browse files Browse the repository at this point in the history
  • Loading branch information
CarsonF authored Jan 28, 2025
2 parents 11ca2a9 + 5b06cb9 commit f54a54f
Show file tree
Hide file tree
Showing 16 changed files with 227 additions and 8 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
"body-parser": "^1.20.2",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"cross-fetch": "^3.1.8",
"cross-fetch": "^4.1.0",
"editorjs-blocks-react-renderer": "^1.3.0",
"express": "^4.18.2",
"file-type": "^16.5.4",
Expand Down
24 changes: 23 additions & 1 deletion src/components/EngagementDataGrid/EngagementColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '../../api/schema/enumLists';
import {
booleanColumn,
booleanNullableColumn,
enumColumn,
getInitialVisibility,
QuickFilterButton,
Expand Down Expand Up @@ -80,17 +81,38 @@ export const EngagementColumns: Array<GridColDef<Engagement>> = [
? row.milestoneReached.value
: null,
filterable: false,
editable: true,
isEditable: ({ row }) =>
row.__typename === 'LanguageEngagement' && row.milestoneReached.canEdit,
valueSetter: (value, row) =>
row.__typename === 'LanguageEngagement'
? { ...row, milestoneReached: { ...row.milestoneReached, value } }
: row,
},
{
headerName: 'AI Assist',
description: 'Is using AI assistance in translation?',
field: 'usingAIAssistedTranslation',
...booleanColumn(),
...booleanNullableColumn(),
valueGetter: (_, row) =>
row.__typename === 'LanguageEngagement'
? row.usingAIAssistedTranslation.value
: null,
filterable: false,
editable: true,
isEditable: ({ row }) =>
row.__typename === 'LanguageEngagement' &&
row.usingAIAssistedTranslation.canEdit,
valueSetter: (value, row) =>
row.__typename === 'LanguageEngagement'
? {
...row,
usingAIAssistedTranslation: {
...row.usingAIAssistedTranslation,
value,
},
}
: row,
},
{
headerName: 'Type',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
mutation UpdateLanguageEngagementGrid($input: UpdateLanguageEngagement!) {
updateLanguageEngagement(input: { engagement: $input }) {
engagement {
...engagementDataGridRow
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ fragment engagementDataGridRow on Engagement {
}
}
milestoneReached {
canRead
canEdit
value
}
...aiAssistedTranslation
Expand Down
1 change: 1 addition & 0 deletions src/components/EngagementDataGrid/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './EngagementColumns';
export * from './engagementDataGridRow.graphql';
export * from './useProcessEngagementUpdate';
54 changes: 54 additions & 0 deletions src/components/EngagementDataGrid/useProcessEngagementUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useMutation } from '@apollo/client';
import { UpdateLanguageEngagement as UpdateLanguageEngagementInput } from '~/api/schema.graphql';
import { EngagementDataGridRowFragment } from './engagementDataGridRow.graphql';
import { UpdateLanguageEngagementGridDocument as UpdateLanguageEngagement } from './UpdateLanguageEngagementGrid.graphql';

export const useProcessEngagementUpdate = () => {
const [updateLanguageEngagement] = useMutation(UpdateLanguageEngagement);

return (updated: EngagementDataGridRowFragment) => {
if (updated.__typename !== 'LanguageEngagement') {
return updated;
}

const input: UpdateLanguageEngagementInput = {
id: updated.id,
milestoneReached: updated.milestoneReached.value,
usingAIAssistedTranslation: updated.usingAIAssistedTranslation.value,
};
// Don't wait for the mutation to finish/error, which allows
// the grid to close the editing state immediately.
// There shouldn't be any business errors from these current changes,
// and network errors are handled with snackbars.
// Additionally, MUI doesn't handle thrown errors either; it just gives
// them straight back to us on the `onProcessRowUpdateError` callback.
void updateLanguageEngagement({
variables: { input },
// Inform Apollo of these async/queued updates.
// This is important because users can make multiple changes
// quickly, since we don't `await` above.
// These optimistic updates are layered/stacked.
// So if two value changes are in flight, the value from the first
// API response isn't presented as the latest value,
// since there is still another optimistic update left.
// Said another way: this prevents the UI from presenting the final change,
// then looking like it reverted to the first change,
// and then flipping back to the final change again.
// This also ensures these pending updates are maintained
// even if the grid is unmounted/remounted.
optimisticResponse: {
updateLanguageEngagement: {
__typename: 'UpdateLanguageEngagementOutput',
// This is an easy/cheap/hacky way to "unparse" the date scalars
// before writing them to the cache.
// Our read policies expect them to be ISO strings as we receive
// them from the network this way.
// Since our temporal objects have a toJSON, this works fine to revert that.
engagement: JSON.parse(JSON.stringify(updated)),
},
},
});

return updated;
};
};
73 changes: 73 additions & 0 deletions src/components/Grid/ColumnTypes/booleanNullableColumn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
Check as CheckIcon,
Close as CloseIcon,
QuestionMark,
} from '@mui/icons-material';
import { GridEditSingleSelectCell } from '@mui/x-data-grid-pro';
import { mapEntries } from '@seedcompany/common';
import { column, RowLike } from './definition.types';
import { enumColumn } from './enumColumn';

const labelToValue = {
Unknown: null,
Yes: true,
No: false,
} as const;

const icons = {
Yes: <CheckIcon color="success" sx={{ margin: 'auto' }} />,
No: <CloseIcon color="error" sx={{ margin: 'auto' }} />,
Unknown: <QuestionMark color="action" sx={{ margin: 'auto' }} />,
};

type Formatted = keyof typeof labelToValue;
const options = Object.keys(labelToValue) as Formatted[];
const labels = mapEntries(labelToValue, ([k]) => [k, k]).asRecord;
const valueToLabel = mapEntries(labelToValue, ([k, v]) => [v, k]).asMap;

export const booleanNullableColumn = <Row extends RowLike>() =>
column<Row>()({
...enumColumn<Row, Formatted>(options, labels),
type: 'singleSelect',
// filterOperators: [], // TODO
valueFormatter: (value: boolean | null) => valueToLabel.get(value),
getOptionLabel: (value: Formatted) => icons[value],
display: 'flex',
renderCell: ({ formattedValue }) => {
const v = formattedValue as Formatted;
return v !== 'Unknown' ? icons[v] : null;
},
renderEditCell: (params) => {
if (typeof params.value === 'string') {
// Value selected, and therefore about to close, but given formatted value.
// Don't bother trying to remap value just close.
return null;
}
return (
<GridEditSingleSelectCell
{...params}
value={valueToLabel.get(params.value)}
// Stop editing on the first value changed.
// This way users' change will be sent to the server immediately after
// selecting a new value without needing another interaction.
onValueChange={async (event, formatted: Formatted) => {
const { api, id, field } = params;
const value = labelToValue[formatted];
await api.setEditCellValue({ id, field, value }, event);
api.stopCellEditMode({ id, field });
}}
sx={{
'.MuiSelect-select': {
// center icon
display: 'flex',
justifyContent: 'center',
// adjust to render outline within the cell
// and ignore arrow padding to center horizontally
px: '4px !important',
},
'[data-testid="ArrowDropDownIcon"]': { display: 'none' },
}}
/>
);
},
});
18 changes: 17 additions & 1 deletion src/components/Grid/ColumnTypes/enumColumn.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { getGridSingleSelectOperators } from '@mui/x-data-grid-pro';
import {
getGridSingleSelectOperators,
GridEditSingleSelectCell,
} from '@mui/x-data-grid-pro';
import { EmptyEnumFilterValue } from '../DefaultDataGridStyles';
import { column, RowLike } from './definition.types';

Expand All @@ -17,4 +20,17 @@ export const enumColumn = <Row extends RowLike, T extends string>(
getOptionLabel: (v) => labels[v as T] ?? EmptyEnumFilterValue,
valueFormatter: (value: T) => labels[value],
...(orderByIndex ? { sortBy: (v) => (v ? list.indexOf(v) : null) } : {}),
renderEditCell: (params) => (
<GridEditSingleSelectCell
{...params}
// Stop editing on the first value changed.
// This way users' change will be sent to the server immediately after
// selecting a new value without needing another interaction.
onValueChange={async (event, formatted) => {
const { api, id, field } = params;
await api.setEditCellValue({ id, field, value: formatted }, event);
api.stopCellEditMode({ id, field });
}}
/>
),
});
2 changes: 1 addition & 1 deletion src/components/Grid/ColumnTypes/multiEnumColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const multiEnumColumn = <Row extends RowLike, T extends string>(
list: readonly T[],
labels: Record<T, string>
) => {
const base = enumColumn(list, labels);
const { renderEditCell: _, ...base } = enumColumn(list, labels);
const valuesFormatter = (values: T[]) =>
values.map(base.valueFormatter).join(', ');
return column<Row, readonly T[], string>()({
Expand Down
2 changes: 2 additions & 0 deletions src/components/Grid/DefaultDataGridStyles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@mui/x-data-grid-pro';
import { mapEntries } from '@seedcompany/common';
import { Sx } from '../../common';
import { isCellEditable } from './isCellEditable';

GRID_DEFAULT_LOCALE_TEXT.filterPanelOperator = 'Condition';

Expand Down Expand Up @@ -75,6 +76,7 @@ export const DefaultDataGridStyles = {
},
},
onMenuOpen: scrollIntoView,
isCellEditable,
} satisfies Partial<DataGridProps>;

export const EmptyEnumFilterValue = (
Expand Down
2 changes: 2 additions & 0 deletions src/components/Grid/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ export * from './DefaultDataGridStyles';
export * from './EditNumberCell';
export * from './useCurrencyColumn';
export * from './ColumnTypes/booleanColumn';
export * from './ColumnTypes/booleanNullableColumn';
export * from './ColumnTypes/enumColumn';
export * from './ColumnTypes/textColumn';
export * from './useDataGridSource';
export * from './Toolbar';
export * from './QuickFilters';
export * from './ColumnTypes/multiEnumColumn';
export * from './ColumnTypes/dateColumn';
export * from './isCellEditable';
24 changes: 24 additions & 0 deletions src/components/Grid/isCellEditable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
GridCellParams as CellParams,
GridValidRowModel as RowLike,
} from '@mui/x-data-grid-pro';

export const isCellEditable = <R extends RowLike>(params: CellParams<R>) => {
if (params.colDef.isEditable) {
return params.colDef.isEditable(params);
}
return true;
};

declare module '@mui/x-data-grid/internals' {
interface GridBaseColDef<R extends RowLike = RowLike, V = any, F = V> {
/**
* Is this cell editable?
* Useful when it needs to be dynamic based on the row.
*
* Requires {@link editable} == true in addition to this.
* Requires {@link isCellEditable} to be passed to the Grid.
*/
isEditable?: (params: CellParams<R, V, F>) => boolean;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ mutation UpdateLanguageEngagement($input: UpdateLanguageEngagementInput!) {
engagement {
...LanguageEngagementDetail
...RecalculateChangesetDiff
...EngagementDescription
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
EngagementColumns,
EngagementInitialState,
EngagementToolbar,
useProcessEngagementUpdate,
} from '~/components/EngagementDataGrid';
import {
DefaultDataGridStyles,
Expand Down Expand Up @@ -45,6 +46,8 @@ export const PartnerDetailEngagements = () => {
[props.slotProps]
);

const processEngagementUpdate = useProcessEngagementUpdate();

return (
<TabPanelContent>
<DataGrid<Engagement>
Expand All @@ -54,6 +57,7 @@ export const PartnerDetailEngagements = () => {
slotProps={slotProps}
columns={EngagementColumns}
initialState={EngagementInitialState}
processRowUpdate={processEngagementUpdate}
headerFilters
hideFooter
sx={[flexLayout, noHeaderFilterButtons, noFooter]}
Expand Down
4 changes: 4 additions & 0 deletions src/scenes/Projects/List/EngagementsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
EngagementColumns,
EngagementInitialState,
EngagementToolbar,
useProcessEngagementUpdate,
} from '~/components/EngagementDataGrid';
import {
DefaultDataGridStyles,
Expand Down Expand Up @@ -42,6 +43,8 @@ export const EngagementsPanel = () => {
[dataGridProps.slotProps]
);

const processEngagementUpdate = useProcessEngagementUpdate();

return (
<DataGrid<Engagement>
{...DefaultDataGridStyles}
Expand All @@ -50,6 +53,7 @@ export const EngagementsPanel = () => {
slotProps={slotProps}
columns={EngagementColumns}
initialState={EngagementInitialState}
processRowUpdate={processEngagementUpdate}
headerFilters
hideFooter
sx={[flexLayout, noHeaderFilterButtons, noFooter]}
Expand Down
15 changes: 12 additions & 3 deletions yarn.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit f54a54f

Please sign in to comment.