From 096be7b717da6399c8cf2e847c764f36670e5d0a Mon Sep 17 00:00:00 2001 From: Sven van de Scheur Date: Fri, 29 Mar 2024 14:07:54 +0100 Subject: [PATCH] :sparkles: - feat: add support for editable rows in DataGrid component --- src/components/button/button.scss | 17 +- src/components/button/button.tsx | 11 +- src/components/data/datagrid/datagrid.scss | 33 +- .../data/datagrid/datagrid.stories.tsx | 52 +- src/components/data/datagrid/datagrid.tsx | 542 ++++++++++++------ src/components/data/value/value.tsx | 26 +- src/components/form/.storybook/decorators.tsx | 2 +- src/components/form/checkbox/checkbox.scss | 2 +- src/components/form/checkbox/checkbox.tsx | 9 +- .../form/choicefield/choicefield.tsx | 4 +- src/components/form/form/form.stories.tsx | 63 ++ src/components/form/form/form.tsx | 14 +- .../form/formcontrol/formcontrol.tsx | 10 +- src/components/form/input/input.scss | 1 + src/components/form/select/select.tsx | 26 +- src/lib/data/attributedata.ts | 84 +++ src/lib/form/typeguards.ts | 25 +- src/lib/form/utils.ts | 59 +- 18 files changed, 771 insertions(+), 209 deletions(-) diff --git a/src/components/button/button.scss b/src/components/button/button.scss index c3489ac4..f7664b05 100644 --- a/src/components/button/button.scss +++ b/src/components/button/button.scss @@ -33,9 +33,7 @@ font-family: var(--typography-font-family-body); font-size: var(--mykn-button-font-size); font-weight: var(--mykn-button-font-weight); - justify-content: center; line-height: var(--mykn-button-line-height); - text-align: center; text-decoration: none; transition: all var(--animation-duration-fast) var(--animation-timing-function); @@ -51,6 +49,21 @@ pointer-events: none; } + &--align-start { + justify-content: start; + text-align: start; + } + + &--align-center { + justify-content: center; + text-align: center; + } + + &--align-end { + justify-content: end; + text-align: end; + } + &--bold { --mykn-button-font-weight: var(--typography-font-weight-bold); } diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx index 4a6361ec..341d0486 100644 --- a/src/components/button/button.tsx +++ b/src/components/button/button.tsx @@ -6,6 +6,9 @@ import "./button.scss"; type BaseButtonProps = { active?: boolean; + /** Aligns the contents based on the current direction. */ + align?: "start" | "center" | "end" | "space-between"; + /** Whether the text should be presented bold. */ bold?: boolean; @@ -40,6 +43,7 @@ export type ButtonLinkProps = React.AnchorHTMLAttributes & /** * Button component * @param active + * @param align * @param bold * @param justify * @param muted @@ -50,10 +54,11 @@ export type ButtonLinkProps = React.AnchorHTMLAttributes & * @param wrap * @constructor */ -export const Button = React.forwardRef( +export const Button = React.forwardRef( ( { active = false, + align = "center", bold = false, justify = false, muted = false, @@ -71,6 +76,7 @@ export const Button = React.forwardRef( ref={ref as LegacyRef} className={clsx( "mykn-button", + `mykn-button--align-${align}`, `mykn-button--size-${size}`, `mykn-button--variant-${variant}`, { @@ -96,6 +102,7 @@ Button.displayName = "Button"; /** * Button component * @param active + * @param align * @param bold * @param justify * @param muted @@ -110,6 +117,7 @@ export const ButtonLink = React.forwardRef( ( { active = false, + align = "center", bold = false, justify = false, muted = false, @@ -127,6 +135,7 @@ export const ButtonLink = React.forwardRef( ref={ref as LegacyRef} className={clsx( "mykn-button", + `mykn-button--align-${align}`, `mykn-button--size-${size}`, `mykn-button--variant-${variant}`, { diff --git a/src/components/data/datagrid/datagrid.scss b/src/components/data/datagrid/datagrid.scss index 8ed311b6..cf23b493 100644 --- a/src/components/data/datagrid/datagrid.scss +++ b/src/components/data/datagrid/datagrid.scss @@ -23,7 +23,7 @@ } &__head &__row:first-child &__cell { - border-top: 1px solid var(--typography-color-border); + border-block-start: 1px solid var(--typography-color-border); } &__head &__cell { @@ -36,9 +36,11 @@ } &__cell { - border-bottom: 1px solid var(--typography-color-border); + border-block-start: 1px solid transparent; + border-block-end: 1px solid var(--typography-color-border); box-sizing: border-box; padding: var(--spacing-v) var(--spacing-h); + position: relative; .mykn-a:not(:last-child) { margin-inline-end: var(--spacing-h); @@ -57,13 +59,36 @@ width: 0; } + &__cell--editable .mykn-button { + border: none; + } + + &__cell--editable:not(#{&}__cell--type-boolean) .mykn-form-control { + height: 100%; + left: 50%; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 100%; + z-index: 1; + + .mykn-input { + width: 100%; + } + } + + &__cell--editable:not(#{&}__cell--type-boolean) { + padding: 0; + } + &__foot { position: sticky; bottom: 0; } &__foot &__cell { - border-bottom: none; + border-block-start: 1px solid var(--typography-color-border); + border-block-end: none; padding: 0; } @@ -100,7 +125,7 @@ } &__row:nth-child(even) &__cell { - border-bottom: 1px solid var(--typography-color-background); + border-block-end: 1px solid var(--typography-color-background); } &__cell { diff --git a/src/components/data/datagrid/datagrid.stories.tsx b/src/components/data/datagrid/datagrid.stories.tsx index 37f8e6da..0eb91859 100644 --- a/src/components/data/datagrid/datagrid.stories.tsx +++ b/src/components/data/datagrid/datagrid.stories.tsx @@ -188,7 +188,6 @@ export const JSONPlaceholderExample: Story = { {...args} count={100} objectList={objectList} - onSort={(field) => setSort(field)} loading={loading} page={page} pageSize={pageSize} @@ -201,10 +200,32 @@ export const JSONPlaceholderExample: Story = { ]} selected={ // SelectableRows story + args.selectable && objectList.length > 0 && [objectList[1], objectList[3], objectList[4]] } onPageChange={setPage} onPageSizeChange={setPageSize} + onSort={(field) => setSort(field)} + onEdit={async (rowData: AttributeData) => { + setLoading(true); + await fetch( + `https://jsonplaceholder.typicode.com/posts/${rowData.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(rowData), + }, + ); + const index = objectList.findIndex( + (r) => Number(r.id) === rowData.id, + ); + const newObjectList = [...objectList]; + newObjectList[index] = rowData; + setObjectList(newObjectList); + setLoading(false); + }} /> ); }, @@ -214,6 +235,7 @@ export const SelectableRows: Story = { ...JSONPlaceholderExample, args: { ...JSONPlaceholderExample.args, + fields: ["userId", "id", "title"], selectable: true, }, argTypes: { @@ -221,3 +243,31 @@ export const SelectableRows: Story = { onSelectionChange: { action: "onSelectionChange" }, }, }; + +export const EditableRows: Story = { + ...JSONPlaceholderExample, + args: { + ...JSONPlaceholderExample.args, + // editable: true, + fields: [ + { + type: "number", + name: "userId", + options: [ + { label: 1, value: 1 }, + { label: 2, value: 2 }, + ], + editable: true, + }, + { name: "id", type: "number", editable: false }, + "title", + { + type: "boolean", + name: "published", + }, + ], + }, + argTypes: { + onEdit: { action: "onEdit" }, + }, +}; diff --git a/src/components/data/datagrid/datagrid.tsx b/src/components/data/datagrid/datagrid.tsx index 975118c9..38e3d372 100644 --- a/src/components/data/datagrid/datagrid.tsx +++ b/src/components/data/datagrid/datagrid.tsx @@ -1,17 +1,25 @@ import clsx from "clsx"; import React, { useEffect, useId, useState } from "react"; -import { formatMessage, useIntl } from "../../../lib"; +import { + Field, + SerializedFormData, + TypedField, + formatMessage, + isPrimitive, + serializeForm, + typedFieldByFields, + useIntl, +} from "../../../lib"; import { AttributeData, - isNull, sortAttributeDataArray, } from "../../../lib/data/attributedata"; import { field2Caption, isLink } from "../../../lib/format/string"; import { BadgeProps } from "../../badge"; import { BoolProps } from "../../boolean"; import { Button } from "../../button"; -import { Checkbox } from "../../form"; +import { Checkbox, FormControl } from "../../form"; import { Outline } from "../../icon"; import { Toolbar } from "../../toolbar"; import { A, AProps, H3, P, PProps } from "../../typography"; @@ -36,8 +44,14 @@ export type DataGridProps = { /** The object list (after pagination), only primitive types supported for now. */ objectList: AttributeData[]; - /** A `string[]` containing the keys in `objectList` to show object for. */ - fields?: string[]; + /** + * Whether values should be made editable, defaults is determined by whether any of `fields` is a `TypedField` and has + * `editable` set. + */ + editable?: boolean; + + /** A `string[]` or `TypedField[]` containing the keys in `objectList` to show object for. */ + fields?: Array; /** Whether to allow sorting/the field to sort on. */ sort?: boolean | string; @@ -96,6 +110,9 @@ export type DataGridProps = { attributeData: DataGridProps["objectList"][number], ) => void; + /** Gets called when a row value is edited. */ + onEdit?: (rowData: SerializedFormData) => void; + /** Gets called when the object list is sorted. */ onSort?: (sort: string) => Promise | void; } & PaginatorPropsAliases & @@ -127,6 +144,15 @@ type DataGridSelectableFalseProps = { labelSelectAll?: string; }; +const getRenderableFields = ( + fields: Array, + objectList: AttributeData[], + urlFields: string[], +): TypedField[] => + typedFieldByFields(fields, objectList).filter( + (f) => !urlFields.includes(String(f.name)), + ); + /** * A subset of `PaginatorProps` that act as aliases. * @see {PaginatorProps} @@ -147,6 +173,7 @@ type PaginatorPropsAliases = { * @param badgeProps * @param boolProps * @param objectList + * @param editable * @param onSort * @param paginatorProps * @param fields @@ -159,6 +186,7 @@ type PaginatorPropsAliases = { * @param urlFields * @param labelSelect * @param labelSelectAll + * @param onEdit * @param onSelect * @param onSelectionChange * @param count @@ -178,6 +206,7 @@ export const DataGrid: React.FC = ({ boolProps, objectList, fields = objectList?.length ? Object.keys(objectList[0]) : [], + editable = Boolean(fields.find((f) => !isPrimitive(f) && f.editable)), paginatorProps, showPaginator = Boolean(paginatorProps), pProps, @@ -188,6 +217,7 @@ export const DataGrid: React.FC = ({ urlFields = DEFAULT_URL_FIELDS, labelSelect, labelSelectAll, + onEdit, onSelect, onSelectionChange, onSort, @@ -203,8 +233,10 @@ export const DataGrid: React.FC = ({ ...props }) => { const id = useId(); - const intl = useIntl(); + const [editingState, setEditingState] = useState< + [AttributeData | null, number | null] + >([null, null]); const [selectedState, setSelectedState] = useState( null, ); @@ -230,25 +262,25 @@ export const DataGrid: React.FC = ({ } }, [sort]); - const renderableFields = fields.filter((f) => !urlFields.includes(f)); + const renderableFields = getRenderableFields(fields, objectList, urlFields); const sortField = sortState?.[0]; const sortDirection = sortState?.[1]; const titleId = title ? `${id}-caption` : undefined; - const sortedObjectList = + const renderableRows = !onSort && sortField && sortDirection ? sortAttributeDataArray(objectList, sortField, sortDirection) : objectList || []; const allSelected = - selectedState?.every((a) => sortedObjectList.includes(a)) && - sortedObjectList.every((a) => selectedState.includes(a)); + selectedState?.every((a) => renderableRows.includes(a)) && + renderableRows.every((a) => selectedState.includes(a)); /** * Gets called when tha select all checkbox is clicked. */ const handleSelectAll = () => { - const value = allSelected ? [] : sortedObjectList; + const value = allSelected ? [] : renderableRows; setSelectedState(value); onSelect?.(value, !allSelected); }; @@ -271,62 +303,29 @@ export const DataGrid: React.FC = ({ * Get called when a column is sorted. * @param field */ - const handleSort = (field: string) => { + const handleSort = (field: TypedField) => { const newSortDirection = sortDirection === "ASC" ? "DESC" : "ASC"; - setSortState([field, newSortDirection]); - onSort && onSort(newSortDirection === "ASC" ? field : `-${field}`); - }; - - const contextSelectAll = { - count: count, - countPage: sortedObjectList.length, - selected: selectedState?.length || 0, - unselected: (count || 0) - (selectedState?.length || 0), - unselectedPage: sortedObjectList.length - (selectedState?.length || 0), + setSortState([field.name, newSortDirection]); + onSort && + onSort(newSortDirection === "ASC" ? field.name : `-${field.name}`); }; - /** - * Renders a cell based on type of `rowData[field]`. - * @param rowData - * @param field - */ - const renderCell = (rowData: AttributeData, field: string) => { - const rowIndex = sortedObjectList.indexOf(rowData); - const fieldIndex = renderableFields.indexOf(field); - const key = `sort-${sortField}${sortDirection}-page-${page}-row-$${rowIndex}-column-${fieldIndex}`; - - // Run assertions for aliased fields. - if (showPaginator) { - console.assert( - count || paginatorProps?.count, - "Either `count` or `paginatorProps.count` should be set when `showPaginator` is `true`.", - ); - - console.assert( - page || paginatorProps?.page, - "Either `page` or `paginatorProps.page` should be set when `showPaginator` is `true`.", - ); - console.assert( - pageSize || paginatorProps?.pageSize, - "Either `pageSize` or `paginatorProps.pageSize` should be set when `showPaginator` is `true`.", - ); - } + // Run assertions for aliased fields. + if (showPaginator) { + console.assert( + count || paginatorProps?.count, + "Either `count` or `paginatorProps.count` should be set when `showPaginator` is `true`.", + ); - return ( - + console.assert( + page || paginatorProps?.page, + "Either `page` or `paginatorProps.page` should be set when `showPaginator` is `true`.", ); - }; + console.assert( + pageSize || paginatorProps?.pageSize, + "Either `pageSize` or `paginatorProps.pageSize` should be set when `showPaginator` is `true`.", + ); + } return (
@@ -353,79 +352,35 @@ export const DataGrid: React.FC = ({ "mykn-datagrid__cell--checkbox", )} > - , - ) - } + count={count} + handleSelect={handleSelectAll} + labelSelect={labelSelectAll} + selected={selectedState?.length || 0} + sortedObjectList={renderableRows} /> )} - {renderableFields.map((field) => { - const caption = field2Caption(field); - const data = objectList?.[0]?.[field]; - const type = typeof data; - const isSorted = sortField === field; - - return ( - - {sort ? ( - - ) : ( -

- {caption} -

- )} - - ); - })} + {renderableFields.map((field) => ( + + {field2Caption(field.name)} + + ))} {/* Cells */} - {sortedObjectList.map((rowData, index) => ( + {renderableRows.map((rowData, index) => ( = ({ `mykn-datagrid__cell--checkbox`, )} > - handleSelect(rowData)} - aria-label={ - labelSelect - ? formatMessage(labelSelect, { - ...contextSelectAll, - ...rowData, - }) - : intl.formatMessage( - { - id: "mykn.components.DataGrid.labelSelect", - description: - "mykn.components.DataGrid: The select row (accessible) label", - defaultMessage: "(de)selecteer rij", - }, - { - ...contextSelectAll, - ...rowData, - } as unknown as Record, - ) - } + count={count} + handleSelect={handleSelect} + labelSelect={labelSelect} + rowData={rowData} + selected={selectedState?.length || 0} + sortedObjectList={renderableRows} /> )} - {renderableFields.map((field) => - renderCell(rowData, String(field)), - )} + {renderableFields.map((field) => ( + { + if (editable) { + setEditingState([ + rowData, + renderableFields.indexOf(field), + ]); + e.preventDefault(); + } + e.currentTarget.nodeName === "A" && onClick?.(e, rowData); + }} + onEdit={onEdit} + /> + ))} ))} @@ -505,77 +474,308 @@ export const DataGrid: React.FC = ({ ); }; -export type DataGridCellProps = { +export type DataGridHeadingCellProps = React.PropsWithChildren<{ + handleSort: (field: TypedField) => void; + field: TypedField; + isSorted: boolean; + sortable: boolean; + sortDirection: "ASC" | "DESC" | undefined; +}>; + +/** + * DataGrid (heading) cell + * @param children + * @param handleSort + * @param field + * @param isSorted + * @param sortable + * @param sortDirection + * @constructor + */ +export const DataGridHeadingCell: React.FC = ({ + children, + handleSort, + field, + isSorted, + sortable, + sortDirection, +}) => ( + + {sortable ? ( + + ) : ( +

+ {children} +

+ )} + +); + +export type DataGridContentCellProps = { aProps: DataGridProps["aProps"]; badgeProps: DataGridProps["badgeProps"]; boolProps: DataGridProps["boolProps"]; + editable: boolean; + editingRow: boolean; + editingField: boolean; pProps: DataGridProps["pProps"]; + formId: string; rowData: AttributeData; - field: string; + field: TypedField; fields: DataGridProps["fields"]; urlFields: DataGridProps["urlFields"]; onClick: DataGridProps["onClick"]; + onEdit: DataGridProps["onEdit"]; }; /** - * DataGrid cell + * DataGrid (content) cell * @param aProps * @param badgeProps * @param boolProps * @param pProps + * @param formId + * @param editable + * @param editingRow + * @param editingField * @param field * @param fields * @param rowData * @param urlFields * @param onClick + * @param onEdit * @constructor - * @private */ -export const DataGridCell: React.FC = ({ +export const DataGridContentCell: React.FC = ({ aProps, badgeProps, boolProps, pProps, + formId, + editable, + editingRow, + editingField, field, fields = [], rowData, urlFields = DEFAULT_URL_FIELDS, onClick, + onEdit, }) => { - const renderableFields = fields.filter((f) => !urlFields.includes(f)); + const [pristine, setPristine] = useState(true); + + const fieldEditable = + typeof field.editable === "boolean" ? field.editable : editable; + const renderableFields = getRenderableFields(fields, [rowData], urlFields); const fieldIndex = renderableFields.indexOf(field); const urlField = urlFields.find((f) => rowData[f]); const rowUrl = urlField ? rowData[urlField] : null; - const value = rowData[field]; - const type = isNull(value) ? "null" : typeof value; + const value = rowData[field.name]; const isImplicitLink = rowUrl && fieldIndex === 0 && !isLink(String(value)); const link = isImplicitLink ? String(rowUrl) : ""; + /** + * Renders a button triggering the editing state. + */ + const renderButton = () => ( + + ); + + /** + * Renders a form control for editing the fields value. + */ + const renderFormControl = () => ( +
e.preventDefault()}> + setPristine(false)} + onBlur={(e: React.FocusEvent) => { + const data = serializeForm(e.target.form as HTMLFormElement, true); + !pristine && onEdit?.(data); + }} + /> + + ); + /** + * Renders a hidden input allowing the form to serialize this fields data. + */ + const renderHiddenInput = () => ( + + ); + + /** + * Renders the value according to Value component + */ + const renderValue = () => ( + + ); + return ( {link && ( - onClick && onClick(e, rowData)} - > + onClick?.(e, rowData)}> )} - + {editingRow && !editingField && renderHiddenInput()} + {editingField && renderFormControl()} + {!editingField && fieldEditable && renderButton()} + {!editingField && !fieldEditable && renderValue()} ); }; + +export type DataGridSelectionCheckboxProps = { + checked: boolean; + count: DataGridProps["count"]; + handleSelect: (attributeData: AttributeData) => void; + selected: number; + sortedObjectList: AttributeData[]; + labelSelect?: string; + rowData?: AttributeData; + selectAll?: boolean; +}; + +/** + * A select (all) checkbox + * @param checked + * @param count + * @param handleSelect + * @param labelSelect + * @param rowData + * @param selected + * @param selectAll + * @param sortedObjectList + * @constructor + */ +export const DataGridSelectionCheckbox: React.FC< + DataGridSelectionCheckboxProps +> = ({ + checked, + count, + handleSelect, + labelSelect, + rowData, + selected, + selectAll, + sortedObjectList, +}) => { + const intl = useIntl(); + + const contextSelectAll = { + count: count, + countPage: sortedObjectList.length, + selected: selected, + selectAll: selectAll, + unselected: (count || 0) - (selected || 0), + unselectedPage: sortedObjectList.length - (selected || 0), + }; + + const label = labelSelect + ? formatMessage(labelSelect, { + ...contextSelectAll, + ...rowData, + }) + : selectAll + ? intl.formatMessage( + { + id: "mykn.components.DataGrid.labelSelectAll", + description: + "mykn.components.DataGrid: The select row (accessible) label", + defaultMessage: "(de)selecteer {countPage} rijen", + }, + contextSelectAll as unknown as Record, + ) + : intl.formatMessage( + { + id: "mykn.components.DataGrid.labelSelect", + description: + "mykn.components.DataGrid: The select row (accessible) label", + defaultMessage: "(de)selecteer rij", + }, + { + ...contextSelectAll, + ...rowData, + } as unknown as Record, + ); + + return ( + handleSelect(rowData || {})} + aria-label={label} + /> + ); +}; diff --git a/src/components/data/value/value.tsx b/src/components/data/value/value.tsx index 6f27a109..264ef0b9 100644 --- a/src/components/data/value/value.tsx +++ b/src/components/data/value/value.tsx @@ -13,7 +13,7 @@ import { Badge, BadgeProps } from "../../badge"; import { Bool, BoolProps } from "../../boolean"; import { A, AProps, P, PProps } from "../../typography"; -export type ValueProps = { +export type ValueProps = React.HTMLAttributes & { value: Attribute; aProps?: AProps; boolProps?: Omit; @@ -29,6 +29,7 @@ export type ValueProps = { * @param boolProps * @param pProps * @param value + * @param props * @constructor */ export const Value: React.FC = ({ @@ -37,18 +38,27 @@ export const Value: React.FC = ({ boolProps, pProps, value, + ...props }) => { if (isBool(value)) { // BoolProps must be provided if value can be bool. - return ; + return ; } if (isNumber(value)) { - return {value}; + return ( + + {value} + + ); } if (isNull(value) || isUndefined(value)) { - return

-

; + return ( +

+ - +

+ ); } if (isString(value)) { @@ -56,7 +66,7 @@ export const Value: React.FC = ({ if (isLink(string)) { return ( -

+

{string} @@ -64,6 +74,10 @@ export const Value: React.FC = ({ ); } - return

{string || "-"}

; + return ( +

+ {string || "-"} +

+ ); } }; diff --git a/src/components/form/.storybook/decorators.tsx b/src/components/form/.storybook/decorators.tsx index 911dc26e..a8b9507d 100644 --- a/src/components/form/.storybook/decorators.tsx +++ b/src/components/form/.storybook/decorators.tsx @@ -10,7 +10,7 @@ export const FORM_TEST_DECORATOR: Decorator = (Story) => { const getData = () => { const form = document.forms[0]; - return serializeForm(form); + return form && serializeForm(form); }; return ( diff --git a/src/components/form/checkbox/checkbox.scss b/src/components/form/checkbox/checkbox.scss index 132b7df8..b6604e0d 100644 --- a/src/components/form/checkbox/checkbox.scss +++ b/src/components/form/checkbox/checkbox.scss @@ -1,4 +1,4 @@ -.mykn-checkbox { +.mykn-checkbox:has(.mykn-label) { display: flex; gap: var(--spacing-h); } diff --git a/src/components/form/checkbox/checkbox.tsx b/src/components/form/checkbox/checkbox.tsx index 126fa30b..e96cf92f 100644 --- a/src/components/form/checkbox/checkbox.tsx +++ b/src/components/form/checkbox/checkbox.tsx @@ -16,12 +16,17 @@ export type CheckboxProps = InputProps & { * @param props * @constructor */ -export const Checkbox: React.FC = ({ children, ...props }) => { +export const Checkbox: React.FC = ({ + children, + value, + ...props +}) => { const id = useId(); const _id = props.id || id; + const _props = value ? { ...props, value } : props; return (
- + {children && (