diff --git a/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.spec.tsx b/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.spec.tsx index a4a45135cc2..01b0ad3622c 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.spec.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.spec.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import expect from 'expect'; +import cloneDeep from 'lodash/cloneDeep'; import { Basic, @@ -8,7 +9,13 @@ import { PreferenceKey, LabelElement, NullChildren, + Wrapper, + data, } from './DatagridConfigurable.stories'; +import { DatagridConfigurable } from './DatagridConfigurable'; +import { TextField } from '../../field'; +import { EditButton } from '../../button'; +import { memoryStore } from 'ra-core'; describe('', () => { it('should render a datagrid with configurable columns', async () => { @@ -103,4 +110,256 @@ describe('', () => { expect(screen.queryAllByText('War and Peace')).toHaveLength(1); }); }); + describe('store/code synchronization', () => { + const storeDefaultValue = { + preferences: { + books1: { + datagrid: { + columns: ['0', '1', '2', '3', '4'], + availableColumns: [ + { + index: '0', + source: 'id', + }, + { + index: '1', + source: 'title', + label: 'Original title', + }, + { + index: '2', + source: 'author', + }, + { + index: '3', + source: 'year', + }, + { + index: '4', + label: 'Unlabeled column #4', + }, + ], + }, + }, + }, + }; + + it('should preserve hidden columns from the store and show new non omitted columns last', async () => { + const store = memoryStore(cloneDeep(storeDefaultValue)); + store.setItem('preferences.books1.datagrid.columns', [ + '1', + '0', + '3', + ]); + const { rerender } = render( + + + + + + + + + ); + + await screen.findByText('War and Peace'); + // author column is hidden + expect(screen.queryByText('Leo Tolstoy')).toBeNull(); + + // Render something else (to be able to tell when the next rerender is finished) + rerender(Something Else); + await screen.findByText('Something Else'); + + // Add 'year' column + rerender( + + + + + + + + + + ); + await screen.findByText('War and Peace'); + // Year column should be displayed + await screen.findByText('1869'); + // author column is still hidden + expect(screen.queryByText('Leo Tolstoy')).toBeNull(); + // Store value should be updated + expect( + store.getItem('preferences.books1.datagrid.columns') + ).toEqual(['2', '0', '1', '4']); + // Check the order is preserved + const columnsTexts = Array.from( + screen.getByText('War and Peace')?.closest('tr') + ?.children as HTMLCollection + ).map(child => child.textContent); + expect(columnsTexts).toEqual([ + 'War and Peace', + '1', + '1869', + 'ra.action.edit', + ]); + }); + + it('should preserve hidden columns from the store when column order is changed in the code', async () => { + const store = memoryStore(cloneDeep(storeDefaultValue)); + // hide the 'year' column + store.setItem('preferences.books1.datagrid.columns', [ + '0', + '1', + '2', + '4', + ]); + const { rerender } = render( + + + + + + + + + + ); + + await screen.findByText('Leo Tolstoy'); + // Year column should be hidden + expect(screen.queryByText('1869')).toBeNull(); + // Store value should be preserved + expect( + store.getItem('preferences.books1.datagrid.columns') + ).toEqual(['0', '1', '2', '4']); + + // Render something else (to be able to tell when the next rerender is finished) + rerender(Something Else); + await screen.findByText('Something Else'); + + // Invert 'year' and 'author' columns + rerender( + + + + + + + + + + ); + await screen.findByText('Leo Tolstoy'); + // Year column should still be hidden + expect(screen.queryByText('1869')).toBeNull(); + // Store value should be updated + expect( + store.getItem('preferences.books1.datagrid.columns') + ).toEqual(['0', '1', '3', '4']); + }); + + it('should preserve hidden columns from the store when a column is renamed in the code', async () => { + const store = memoryStore(cloneDeep(storeDefaultValue)); + // invert the 'year' and 'author' columns + store.setItem('preferences.books1.datagrid.columns', [ + '0', + '1', + '3', + '2', + '4', + ]); + store.setItem('preferences.books1.datagrid.availableColumns', [ + { + index: '0', + source: 'id', + }, + { + index: '1', + source: 'title', + label: 'Original title', + }, + { + index: '3', + source: 'year', + }, + { + index: '2', + source: 'author', + }, + { + index: '4', + label: 'Unlabeled column #4', + }, + ]); + const { rerender } = render( + + + + + + + + + + ); + + await screen.findByText('Leo Tolstoy'); + // Store value should be preserved + expect( + store.getItem('preferences.books1.datagrid.columns') + ).toEqual(['0', '1', '3', '2', '4']); + + // Render something else (to be able to tell when the next rerender is finished) + rerender(Something Else); + await screen.findByText('Something Else'); + + // Rename the 'title' column + rerender( + + + + + + + + + + ); + await screen.findByText('Leo Tolstoy'); + // Store value should be preserved + expect( + store.getItem('preferences.books1.datagrid.columns') + ).toEqual(['0', '1', '3', '2', '4']); + }); + }); }); diff --git a/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.stories.tsx b/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.stories.tsx index 007875bec28..bc395008426 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.stories.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.stories.tsx @@ -5,6 +5,7 @@ import { StoreContextProvider, PreferencesEditorContextProvider, TestMemoryRouter, + localStorageStore, } from 'ra-core'; import { DatagridConfigurable } from './DatagridConfigurable'; @@ -14,9 +15,12 @@ import { EditButton } from '../../button'; import { createTheme, ThemeProvider } from '@mui/material/styles'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -export default { title: 'ra-ui-materialui/list/DatagridConfigurable' }; +export default { + title: 'ra-ui-materialui/list/DatagridConfigurable', + excludeStories: ['data', 'Wrapper'], +}; -const data = [ +export const data = [ { id: 1, title: 'War and Peace', @@ -47,8 +51,12 @@ const AuthorField = () => ; const theme = createTheme(); -const Wrapper = ({ children, queryClient = new QueryClient() }) => ( - +export const Wrapper = ({ + children, + queryClient = new QueryClient(), + store = memoryStore(), +}) => ( + @@ -163,3 +171,20 @@ export const NullChildren = () => ( ); + +export const LocalStorage = () => ( + + + + + + + + + +); diff --git a/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.tsx b/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.tsx index 345d84c1b76..052717a9464 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.tsx @@ -1,10 +1,10 @@ -import * as React from 'react'; import { - useResourceContext, usePreference, + useResourceContext, useStore, useTranslate, } from 'ra-core'; +import * as React from 'react'; import { Configurable } from '../../preferences'; import { Datagrid, DatagridProps } from './Datagrid'; @@ -50,6 +50,11 @@ export const DatagridConfigurable = ({ ConfigurableDatagridColumn[] >(`preferences.${finalPreferenceKey}.availableColumns`, []); + const [columns, setColumns] = useStore( + `preferences.${finalPreferenceKey}.columns`, + [] + ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, setOmit] = useStore( `preferences.${finalPreferenceKey}.omit`, @@ -58,7 +63,7 @@ export const DatagridConfigurable = ({ React.useEffect(() => { // first render, or the preference have been cleared - const columns = React.Children.toArray(props.children) + const newAvailableColumns = React.Children.toArray(props.children) .filter(child => React.isValidElement(child)) .map((child: React.ReactElement, index) => ({ index: String(index), @@ -75,8 +80,74 @@ export const DatagridConfigurable = ({ _: `Unlabeled column #%{column}`, }), })); - if (columns.length !== availableColumns.length) { - setAvailableColumns(columns); + const hasChanged = newAvailableColumns.some(column => { + const availableColumn = availableColumns.find( + availableColumn => + (!!availableColumn.source && + availableColumn.source === column?.source) || + (!!availableColumn.label && + availableColumn.label === column?.label) + ); + return !availableColumn || availableColumn.index !== column.index; + }); + if (hasChanged) { + // first we need to update the columns indexes to match the new availableColumns so we keep the same order + const newColumnsSortedAsOldColumns = columns.flatMap(column => { + const oldColumn = availableColumns.find( + availableColumn => availableColumn.index === column + ); + const newColumn = newAvailableColumns.find( + availableColumn => + (!!availableColumn.source && + availableColumn.source === oldColumn?.source) || + (!!availableColumn.label && + availableColumn.label === oldColumn?.label) + ); + return newColumn?.index ? [newColumn.index] : []; + }); + setColumns([ + // we add the old columns in the same order as before + ...newColumnsSortedAsOldColumns, + // then we add at the new columns which are not omited + ...newAvailableColumns + .filter( + c => + !availableColumns.some( + ac => + (!!ac.source && ac.source === c.source) || + (!!ac.label && ac.label === c.label) + ) && !omit?.includes(c.source as string) + ) + .map(c => c.index), + ]); + + // Then we update the available columns to include the new columns while keeping the same order as before + const newAvailableColumnsSortedAsBefore = [ + // First the existing columns, in the same order + ...(availableColumns + .map(oldAvailableColumn => + newAvailableColumns.find( + c => + (!!c.source && + c.source === oldAvailableColumn.source) || + (!!c.label && + c.label === oldAvailableColumn.label) + ) + ) + .filter(c => !!c) as ConfigurableDatagridColumn[]), // Remove undefined columns + // Then the new columns + ...newAvailableColumns.filter( + c => + !availableColumns.some( + oldAvailableColumn => + (!!oldAvailableColumn.source && + oldAvailableColumn.source === c.source) || + (!!oldAvailableColumn.label && + oldAvailableColumn.label === c.label) + ) + ), + ]; + setAvailableColumns(newAvailableColumnsSortedAsBefore); setOmit(omit); } }, [availableColumns]); // eslint-disable-line react-hooks/exhaustive-deps