From b36c133b581149efc1fa66c61f747d0aa21f0443 Mon Sep 17 00:00:00 2001 From: Sven van de Scheur Date: Fri, 23 Aug 2024 14:47:39 +0200 Subject: [PATCH 1/2] :recycle: - refactor: refactor DataGrid translation setup --- src/components/data/datagrid/datagrid.tsx | 210 +++++++++---------- src/components/data/datagrid/translations.ts | 42 ++++ src/lib/i18n/defineMessages.tsx | 8 + src/lib/i18n/index.ts | 1 + src/lib/i18n/types.d.ts | 7 + src/lib/i18n/useIntl.tsx | 8 +- 6 files changed, 155 insertions(+), 121 deletions(-) create mode 100644 src/components/data/datagrid/translations.ts create mode 100644 src/lib/i18n/defineMessages.tsx create mode 100644 src/lib/i18n/types.d.ts diff --git a/src/components/data/datagrid/datagrid.tsx b/src/components/data/datagrid/datagrid.tsx index c986ad9b..3d7820f3 100644 --- a/src/components/data/datagrid/datagrid.tsx +++ b/src/components/data/datagrid/datagrid.tsx @@ -1,5 +1,12 @@ import clsx from "clsx"; -import React, { useContext, useEffect, useId, useRef, useState } from "react"; +import React, { + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; import { Attribute, @@ -32,6 +39,7 @@ import { AProps, Body, H2, H3, P, PProps } from "../../typography"; import { Paginator, PaginatorProps } from "../paginator"; import { Value } from "../value"; import "./datagrid.scss"; +import { TRANSLATIONS } from "./translations"; export type DataGridProps = { /** The object list (after pagination), only primitive types supported for now. */ @@ -601,27 +609,11 @@ export const DataGridCaption: React.FC = () => { const _labelSelectFields = labelSelectFields ? formatMessage(labelSelectFields, context) - : intl.formatMessage( - { - id: "mykn.components.DataGrid.labelSelectFields", - description: - "mykn.components.Modal: The datagrid select fields label", - defaultMessage: "selecteer kolommen", - }, - context, - ); + : intl.formatMessage(TRANSLATIONS.LABEL_SELECT_FIELDS, context); const _labelSaveFieldSelection = labelSaveFieldSelection ? formatMessage(labelSaveFieldSelection, context) - : intl.formatMessage( - { - id: "mykn.components.DataGrid.labelSaveFieldSelection", - description: - "mykn.components.Modal: The datagrid save selection label", - defaultMessage: "kolommen opslaan", - }, - context, - ); + : intl.formatMessage(TRANSLATIONS.LABEL_SAVE_FIELD_SELECTION, context); return ( @@ -783,15 +775,7 @@ export const DataGridHeading: React.FC = () => { const _labelFilterField = labelFilterField ? formatMessage(labelFilterField, context) - : intl.formatMessage( - { - id: "mykn.components.DataGrid.labelFilterField", - description: - "mykn.components.DataGrid: The filter field (accessible) label", - defaultMessage: 'filter veld "{name}"', - }, - context, - ); + : intl.formatMessage(TRANSLATIONS.LABEL_FILTER_FIELD, context); return ( void) | ((rows: AttributeData) => void); - - switch (selectAll) { - case "page": - allSelected = - selectedRows?.every((a) => renderableRows.includes(a)) && - renderableRows.every((a) => selectedRows.includes(a)); - checked = allSelected || false; - disabled = Boolean(allPagesSelectedManaged && allPagesSelected); - handleSelect = () => onSelectAll(!allSelected); - break; - - case "allPages": - allSelected = Boolean(allPagesSelected); - checked = allPagesSelected || false; - handleSelect = () => onSelectAllPages(!allSelected); - break; - - default: - allSelected = false; - checked = - (rowData && - !!selectedRows.find((element) => - equalityChecker(element, rowData), - )) || - false; - disabled = Boolean(allPagesSelectedManaged && allPagesSelected); - handleSelect = onSelect; - } - - const contextSelectAll = { - count: count, - countPage: renderableRows.length, - pages: pages, - amountSelected: amountSelected, - selectAll: selectAll, - amountUnselected: (count || 0) - (amountSelected || 0), - amountUnselectedPage: renderableRows.length - (amountSelected || 0), - }; - - const contextSelectAllPages = { - pages: count, - }; + const { checked, disabled, onChange, ariaLabel } = useMemo(() => { + let allSelected: boolean = false; + let checked: boolean = false; + let disabled: boolean = false; + let handleSelect: (() => void) | ((rows: AttributeData) => void); + let i18nContext; + let ariaLabel: string = ""; + + switch (selectAll) { + case "page": + allSelected = + selectedRows?.every((a) => renderableRows.includes(a)) && + renderableRows.every((a) => selectedRows.includes(a)); + checked = allSelected || false; + disabled = Boolean(allPagesSelectedManaged && allPagesSelected); + handleSelect = () => onSelectAll(!allSelected); + + i18nContext = { + count: count, + countPage: renderableRows.length, + pages: pages, + amountSelected: amountSelected, + selectAll: selectAll, + amountUnselected: (count || 0) - (amountSelected || 0), + amountUnselectedPage: renderableRows.length - (amountSelected || 0), + }; + ariaLabel = + labelSelect || + intl.formatMessage(TRANSLATIONS.LABEL_SELECT_ALL, i18nContext); + break; + + case "allPages": + allSelected = Boolean(allPagesSelected); + checked = allPagesSelected || false; + handleSelect = () => onSelectAllPages(!allSelected); + + i18nContext = { pages: count }; + ariaLabel = + labelSelect || + intl.formatMessage(TRANSLATIONS.LABEL_SELECT_ALL_PAGES, i18nContext); + break; + + default: + allSelected = false; + checked = + (rowData && + !!selectedRows.find((element) => + equalityChecker(element, rowData), + )) || + false; + disabled = Boolean(allPagesSelectedManaged && allPagesSelected); + handleSelect = onSelect; + + i18nContext = { + count: count, + countPage: renderableRows.length, + pages: pages, + amountSelected: amountSelected, + selectAll: selectAll, + amountUnselected: (count || 0) - (amountSelected || 0), + amountUnselectedPage: renderableRows.length - (amountSelected || 0), + ...rowData, + }; + ariaLabel = + labelSelect || + intl.formatMessage(TRANSLATIONS.LABEL_SELECT, i18nContext); + } - const label = labelSelect - ? formatMessage(labelSelect, { - ...contextSelectAll, - ...rowData, - }) - : selectAll === "page" - ? 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, - ) - : selectAll === "allPages" - ? intl.formatMessage( - { - id: "mykn.components.DataGrid.labelSelectAllPages", - description: - "mykn.components.DataGrid: The select all pages (accessible) label", - defaultMessage: "(de)selecteer {pages} pagina's", - }, - contextSelectAllPages 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, - ); + const onChange = () => handleSelect(rowData || {}); + return { checked, disabled, onChange, ariaLabel }; + }, [ + allPagesSelected, + allPagesSelectedManaged, + amountSelected, + count, + labelSelect, + pages, + renderableRows, + rowData, + selectAll, + selectedRows, + ]); return ( handleSelect(rowData || {})} - aria-label={label} + onChange={onChange} + aria-label={ariaLabel} > - {selectAll && label} + {selectAll && ariaLabel} ); }; diff --git a/src/components/data/datagrid/translations.ts b/src/components/data/datagrid/translations.ts new file mode 100644 index 00000000..c3b307ef --- /dev/null +++ b/src/components/data/datagrid/translations.ts @@ -0,0 +1,42 @@ +// Define the structure of a single message descriptor +import { defineMessages } from "../../../lib"; + +export const TRANSLATIONS = defineMessages({ + LABEL_FILTER_FIELD: { + id: "mykn.components.DataGrid.labelFilterField", + description: + "mykn.components.DataGrid: The filter field (accessible) label", + defaultMessage: 'filter veld "{name}"', + }, + + LABEL_SAVE_FIELD_SELECTION: { + id: "mykn.components.DataGrid.labelSaveFieldSelection", + description: "mykn.components.Modal: The datagrid save selection label", + defaultMessage: "kolommen opslaan", + }, + + LABEL_SELECT: { + id: "mykn.components.DataGrid.labelSelect", + description: "mykn.components.DataGrid: The select row (accessible) label", + defaultMessage: "(de)selecteer rij", + }, + + LABEL_SELECT_ALL: { + id: "mykn.components.DataGrid.labelSelectAll", + description: "mykn.components.DataGrid: The select row (accessible) label", + defaultMessage: "(de)selecteer {countPage} rijen", + }, + + LABEL_SELECT_ALL_PAGES: { + id: "mykn.components.DataGrid.labelSelectAllPages", + description: + "mykn.components.DataGrid: The select all pages (accessible) label", + defaultMessage: "(de)selecteer {pages} pagina's", + }, + + LABEL_SELECT_FIELDS: { + id: "mykn.components.DataGrid.labelSelectFields", + description: "mykn.components.Modal: The datagrid select fields label", + defaultMessage: "selecteer kolommen", + }, +}); diff --git a/src/lib/i18n/defineMessages.tsx b/src/lib/i18n/defineMessages.tsx new file mode 100644 index 00000000..90701298 --- /dev/null +++ b/src/lib/i18n/defineMessages.tsx @@ -0,0 +1,8 @@ +import { MessageRecord } from "./types"; + +/** + * Fallback implementation in case react-intl is not installed. + * @param messages + */ +export const defineMessages = (messages: T): T => + messages; diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 8a9b21bb..0c1d7143 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -1,2 +1,3 @@ +export * from "./defineMessages"; export * from "./formatmessage"; export * from "./useIntl"; diff --git a/src/lib/i18n/types.d.ts b/src/lib/i18n/types.d.ts new file mode 100644 index 00000000..25edb117 --- /dev/null +++ b/src/lib/i18n/types.d.ts @@ -0,0 +1,7 @@ +export type MessageRecord = Record; + +export type MessageDescriptor = { + id?: string; + description?: string; + defaultMessage?: string; +}; diff --git a/src/lib/i18n/useIntl.tsx b/src/lib/i18n/useIntl.tsx index 76754163..c1717ea9 100644 --- a/src/lib/i18n/useIntl.tsx +++ b/src/lib/i18n/useIntl.tsx @@ -1,6 +1,7 @@ import React, { useContext } from "react"; import { formatMessage } from "./formatmessage"; +import { MessageDescriptor } from "./types"; /* IMPORTANT! @@ -44,12 +45,6 @@ try { // Redefine (minimal) react-intl types. type MessageContext = Record; -type MessageDescriptor = { - id?: string; - description: string; - defaultMessage: string; -}; - type Intl = { formatMessage: ( descriptor: MessageDescriptor, @@ -87,7 +82,6 @@ const getLocalizedFallbackIntl = ( if (messages.default) { messages = messages.default as unknown as Record; } - const message = messages[descriptor.id as string]; return formatMessage(message, context); }, From 5b98a5f4a8b2ce282cc23bd33085a74e4af833dd Mon Sep 17 00:00:00 2001 From: Sven van de Scheur Date: Fri, 23 Aug 2024 15:19:35 +0200 Subject: [PATCH 2/2] :zap: - refactor: attempt to reduce required render cycles of DataGrid --- src/components/data/datagrid/datagrid.tsx | 183 +++++++++++++--------- 1 file changed, 106 insertions(+), 77 deletions(-) diff --git a/src/components/data/datagrid/datagrid.tsx b/src/components/data/datagrid/datagrid.tsx index 3d7820f3..52c5a16d 100644 --- a/src/components/data/datagrid/datagrid.tsx +++ b/src/components/data/datagrid/datagrid.tsx @@ -1,5 +1,6 @@ import clsx from "clsx"; import React, { + useCallback, useContext, useEffect, useId, @@ -229,22 +230,11 @@ export type DataGridContextType = Omit< onSelectAllPages: (selected: boolean) => void; onSort: (field: TypedField) => void; }; + const DataGridContext = React.createContext( {} as unknown as DataGridContextType, ); -const getTypedFields = ( - fields: Array, - objectList: AttributeData[], - urlFields: string[], - editable: DataGridProps["editable"], - filterable: DataGridProps["filterable"], -): TypedField[] => - typedFieldByFields(fields, objectList, { - editable: Boolean(editable), - filterable: Boolean(filterable), - }).filter((f) => !urlFields.includes(String(f.name))); - /** * A subset of `PaginatorProps` that act as aliases. * @see {PaginatorProps} @@ -391,19 +381,26 @@ export const DataGrid: React.FC = (props) => { } }, [objectList]); - const typedFieldsState = getTypedFields( - fieldsState, - objectList, - urlFields as string[], - editable, - filterable, + // Convert `Array` to `TypedField[]`. + const typedFields = useMemo( + () => + typedFieldByFields(fieldsState, objectList, { + editable: Boolean(editable), + filterable: Boolean(filterable), + }).filter((f) => !(urlFields || []).includes(String(f.name))), + [fieldsState, objectList, urlFields, editable, filterable], + ); + + // Exclude inactive fields. + const renderableFields = useMemo( + () => typedFields.filter((f) => f.active !== false), + [typedFields], ); - const renderableFields = typedFieldsState.filter((f) => f.active !== false); + // Variable. const sortField = sortState?.[0]; const sortDirection = sortState?.[1]; const titleId = title ? `${id}-caption` : undefined; - const _count = count || paginatorProps?.count || 0; const _pageSize = pageSize || _count; const _pages = Math.ceil(_count / _pageSize); @@ -413,80 +410,112 @@ export const DataGrid: React.FC = (props) => { ? objectList : selectedState) || []; - const filteredObjectList = filterState - ? filterAttributeDataArray(objectList, filterState) - : objectList || []; + // Filter rows. + const filteredObjectList = useMemo( + () => + filterState + ? filterAttributeDataArray(objectList, filterState) + : objectList || [], + [objectList, filterState], + ); - const renderableRows = - !onSort && sortField && sortDirection - ? sortAttributeDataArray(filteredObjectList, sortField, sortDirection) - : filteredObjectList; + // Sort rows. + const renderableRows = useMemo( + () => + !onSort && sortField && sortDirection + ? sortAttributeDataArray(filteredObjectList, sortField, sortDirection) + : filteredObjectList, + [onSort, sortField, sortDirection, filteredObjectList], + ); /** - * Gets called when tha select all checkbox is clicked. + * Gets called when the select checkbox is clicked. */ - const handleSelectAll = (selected: boolean) => { - const value = selected ? renderableRows : []; - setSelectedState(value); - onSelect?.(value, selected); - onSelectionChange?.(value); - }; + const handleSelect = useCallback( + (attributeData: AttributeData) => { + const currentlySelected = selectedState || []; + + const isAttributeDataCurrentlySelected = currentlySelected.find( + (element) => equalityChecker?.(element, attributeData), + ); + + const newSelectedState = isAttributeDataCurrentlySelected + ? currentlySelected.filter((a) => !equalityChecker?.(a, attributeData)) + : [...currentlySelected, attributeData]; + + setSelectedState(newSelectedState); + onSelect?.([attributeData], !isAttributeDataCurrentlySelected); + onSelectionChange?.(newSelectedState); + }, + [ + selectedState, + setSelectedState, + onSelect, + onSelectionChange, + equalityChecker, + ], + ); /** - * Gets called when tha select all checkbox is clicked. + * Gets called when the select all checkbox is clicked. */ - const handleSelectAllPages = (selected: boolean) => { - setAllPagesSelectedState(selected); - onSelectAllPages?.(selected); - }; - - const handleSelect = (attributeData: AttributeData) => { - const currentlySelected = selectedState || []; - - const isAttributeDataCurrentlySelected = currentlySelected.find((element) => - equalityChecker?.(element, attributeData), - ); - - const newSelectedState = isAttributeDataCurrentlySelected - ? [...currentlySelected].filter( - (a) => !equalityChecker?.(a, attributeData), - ) - : [...currentlySelected, attributeData]; + const handleSelectAll = useCallback( + (selected: boolean) => { + const value = selected ? renderableRows : []; + setSelectedState(value); + onSelect?.(value, selected); + onSelectionChange?.(value); + }, + [renderableRows, setSelectedState, onSelect, onSelectionChange], + ); - setSelectedState(newSelectedState); - onSelect?.([attributeData], !isAttributeDataCurrentlySelected); - onSelectionChange?.(newSelectedState); - }; + /** + * Gets called when the select all pages checkbox is clicked. + */ + const handleSelectAllPages = useCallback( + (selected: boolean) => { + setAllPagesSelectedState(selected); + onSelectAllPages?.(selected); + }, + [setAllPagesSelectedState, onSelectAllPages], + ); /** * Get called when a column is sorted. * @param field */ - const handleSort = (field: TypedField) => { - const newSortDirection = sortDirection === "ASC" ? "DESC" : "ASC"; - setSortState([field.name, newSortDirection]); - onSort && - onSort(newSortDirection === "ASC" ? field.name : `-${field.name}`); - }; + const handleSort = useCallback( + (field: TypedField) => { + const newSortDirection = sortDirection === "ASC" ? "DESC" : "ASC"; + setSortState([field.name, newSortDirection]); + if (onSort) { + onSort(newSortDirection === "ASC" ? field.name : `-${field.name}`); + } + }, + [sortDirection, setSortState, onSort], // Dependencies + ); + /** * Get called when a column is filtered. * @param data */ - const handleFilter = (data: AttributeData) => { - if (onFilter) { - const handler = () => onFilter(data); - onFilterTimeoutRef.current && clearTimeout(onFilterTimeoutRef.current); - onFilterTimeoutRef.current = setTimeout(handler, 300); - setFilterState(null); - return; - } + const handleFilter = useCallback( + (data: AttributeData) => { + if (onFilter) { + const handler = () => onFilter(data); + if (onFilterTimeoutRef.current) { + clearTimeout(onFilterTimeoutRef.current); + } + onFilterTimeoutRef.current = setTimeout(handler, 300); + setFilterState(null); + return; + } - if (Object.keys(data).filter((key) => data[key]).length === 0) { - setFilterState(null); - } else { - setFilterState(data); - } - }; + const hasFilterData = Object.keys(data).some((key) => data[key]); + setFilterState(hasFilterData ? data : null); + }, + [onFilter, onFilterTimeoutRef, setFilterState], + ); // Run assertions for aliased fields. if (showPaginator) { @@ -521,7 +550,7 @@ export const DataGrid: React.FC = (props) => { editable: Boolean(renderableFields.find((f) => f.editable)), editingFieldIndex: editingState[1], editingRow: editingState[0], - fields: typedFieldsState, + fields: typedFields, pages: _pages, renderableFields: renderableFields, renderableRows: renderableRows,