diff --git a/docs/data/toolpad/core/components/crud/CrudAdvanced.js b/docs/data/toolpad/core/components/crud/CrudAdvanced.js index c3858a7ec60..9f620aeab58 100644 --- a/docs/data/toolpad/core/components/crud/CrudAdvanced.js +++ b/docs/data/toolpad/core/components/crud/CrudAdvanced.js @@ -5,7 +5,14 @@ import StickyNote2Icon from '@mui/icons-material/StickyNote2'; import { AppProvider } from '@toolpad/core/AppProvider'; import { DashboardLayout } from '@toolpad/core/DashboardLayout'; import { PageContainer } from '@toolpad/core/PageContainer'; -import { Create, CrudProvider, Edit, List, Show } from '@toolpad/core/Crud'; +import { + Create, + CrudProvider, + DataSourceCache, + Edit, + List, + Show, +} from '@toolpad/core/Crud'; import { useDemoRouter } from '@toolpad/core/internal'; const NAVIGATION = [ @@ -183,6 +190,8 @@ export const notesDataSource = { }, }; +const notesCache = new DataSourceCache(); + function matchPath(pattern, pathname) { const regex = new RegExp(`^${pattern.replace(/:[^/]+/g, '([^/]+)')}$`); const match = pathname.match(regex); @@ -263,7 +272,7 @@ function CrudAdvanced(props) { {/* preview-start */} - + {router.pathname === listPath ? ( ) : null} {editNoteId ? ( - + ) : null} {/* preview-end */} diff --git a/docs/data/toolpad/core/components/crud/CrudAdvanced.tsx b/docs/data/toolpad/core/components/crud/CrudAdvanced.tsx index 92c0c3a84ba..cc4a491bce4 100644 --- a/docs/data/toolpad/core/components/crud/CrudAdvanced.tsx +++ b/docs/data/toolpad/core/components/crud/CrudAdvanced.tsx @@ -9,6 +9,7 @@ import { CrudProvider, DataModel, DataSource, + DataSourceCache, Edit, List, Show, @@ -196,6 +197,8 @@ export const notesDataSource: DataSource = { }, }; +const notesCache = new DataSourceCache(); + function matchPath(pattern: string, pathname: string): string | null { const regex = new RegExp(`^${pattern.replace(/:[^/]+/g, '([^/]+)')}$`); const match = pathname.match(regex); @@ -284,7 +287,10 @@ export default function CrudAdvanced(props: DemoProps) { {/* preview-start */} - dataSource={notesDataSource}> + + dataSource={notesDataSource} + dataSourceCache={notesCache} + > {router.pathname === listPath ? ( initialPageSize={10} @@ -308,11 +314,7 @@ export default function CrudAdvanced(props: DemoProps) { /> ) : null} {editNoteId ? ( - - id={editNoteId} - onSubmitSuccess={handleEdit} - resetOnSubmit={false} - /> + id={editNoteId} onSubmitSuccess={handleEdit} /> ) : null} {/* preview-end */} diff --git a/docs/data/toolpad/core/components/crud/CrudAdvanced.tsx.preview b/docs/data/toolpad/core/components/crud/CrudAdvanced.tsx.preview index c96b9bf32ec..063a8372309 100644 --- a/docs/data/toolpad/core/components/crud/CrudAdvanced.tsx.preview +++ b/docs/data/toolpad/core/components/crud/CrudAdvanced.tsx.preview @@ -1,4 +1,7 @@ - dataSource={notesDataSource}> + + dataSource={notesDataSource} + dataSourceCache={notesCache} +> {router.pathname === listPath ? ( initialPageSize={10} @@ -22,10 +25,6 @@ /> ) : null} {editNoteId ? ( - - id={editNoteId} - onSubmitSuccess={handleEdit} - resetOnSubmit={false} - /> + id={editNoteId} onSubmitSuccess={handleEdit} /> ) : null} \ No newline at end of file diff --git a/docs/data/toolpad/core/components/crud/CrudBasic.js b/docs/data/toolpad/core/components/crud/CrudBasic.js index eea908fef82..436e18bf286 100644 --- a/docs/data/toolpad/core/components/crud/CrudBasic.js +++ b/docs/data/toolpad/core/components/crud/CrudBasic.js @@ -5,7 +5,7 @@ import StickyNote2Icon from '@mui/icons-material/StickyNote2'; import { AppProvider } from '@toolpad/core/AppProvider'; import { DashboardLayout } from '@toolpad/core/DashboardLayout'; import { PageContainer } from '@toolpad/core/PageContainer'; -import { Crud } from '@toolpad/core/Crud'; +import { Crud, DataSourceCache } from '@toolpad/core/Crud'; import { useDemoRouter } from '@toolpad/core/internal'; const NAVIGATION = [ @@ -183,6 +183,8 @@ export const notesDataSource = { }, }; +const notesCache = new DataSourceCache(); + function matchPath(pattern, pathname) { const regex = new RegExp(`^${pattern.replace(/:[^/]+/g, '([^/]+)')}$`); const match = pathname.match(regex); @@ -225,6 +227,7 @@ function CrudBasic(props) { {/* preview-start */} = { }, }; +const notesCache = new DataSourceCache(); + function matchPath(pattern: string, pathname: string): string | null { const regex = new RegExp(`^${pattern.replace(/:[^/]+/g, '([^/]+)')}$`); const match = pathname.match(regex); @@ -238,6 +240,7 @@ export default function CrudBasic(props: DemoProps) { {/* preview-start */} dataSource={notesDataSource} + dataSourceCache={notesCache} rootPath="/notes" initialPageSize={10} defaultValues={{ title: 'New note' }} diff --git a/docs/data/toolpad/core/components/crud/CrudBasic.tsx.preview b/docs/data/toolpad/core/components/crud/CrudBasic.tsx.preview index 95e6e563757..6fe5e4520ab 100644 --- a/docs/data/toolpad/core/components/crud/CrudBasic.tsx.preview +++ b/docs/data/toolpad/core/components/crud/CrudBasic.tsx.preview @@ -1,5 +1,6 @@ dataSource={notesDataSource} + dataSourceCache={notesCache} rootPath="/notes" initialPageSize={10} defaultValues={{ title: 'New note' }} diff --git a/docs/data/toolpad/core/components/crud/CrudCreate.js b/docs/data/toolpad/core/components/crud/CrudCreate.js index 190e2d78cbd..0ec64ae5681 100644 --- a/docs/data/toolpad/core/components/crud/CrudCreate.js +++ b/docs/data/toolpad/core/components/crud/CrudCreate.js @@ -5,7 +5,7 @@ import PersonIcon from '@mui/icons-material/Person'; import { AppProvider } from '@toolpad/core/AppProvider'; import { DashboardLayout } from '@toolpad/core/DashboardLayout'; import { PageContainer } from '@toolpad/core/PageContainer'; -import { Create } from '@toolpad/core/Crud'; +import { Create, DataSourceCache } from '@toolpad/core/Crud'; import { useDemoRouter } from '@toolpad/core/internal'; const NAVIGATION = [ @@ -90,6 +90,8 @@ export const peopleDataSource = { }, }; +const peopleCache = new DataSourceCache(); + function CrudCreate(props) { const { window } = props; @@ -114,6 +116,7 @@ function CrudCreate(props) { {/* preview-start */} & }, }; +const peopleCache = new DataSourceCache(); + interface DemoProps { /** * Injected by the documentation to work in an iframe. @@ -129,6 +131,7 @@ export default function CrudCreate(props: DemoProps) { {/* preview-start */} dataSource={peopleDataSource} + dataSourceCache={peopleCache} initialValues={{ age: 18 }} onSubmitSuccess={handleSubmitSuccess} resetOnSubmit diff --git a/docs/data/toolpad/core/components/crud/CrudCreate.tsx.preview b/docs/data/toolpad/core/components/crud/CrudCreate.tsx.preview index 8faa83f920b..2820651ba42 100644 --- a/docs/data/toolpad/core/components/crud/CrudCreate.tsx.preview +++ b/docs/data/toolpad/core/components/crud/CrudCreate.tsx.preview @@ -1,5 +1,6 @@ dataSource={peopleDataSource} + dataSourceCache={peopleCache} initialValues={{ age: 18 }} onSubmitSuccess={handleSubmitSuccess} resetOnSubmit diff --git a/docs/data/toolpad/core/components/crud/CrudEdit.js b/docs/data/toolpad/core/components/crud/CrudEdit.js index abfe3da6cf8..b84eeeb6132 100644 --- a/docs/data/toolpad/core/components/crud/CrudEdit.js +++ b/docs/data/toolpad/core/components/crud/CrudEdit.js @@ -5,7 +5,7 @@ import PersonIcon from '@mui/icons-material/Person'; import { AppProvider } from '@toolpad/core/AppProvider'; import { DashboardLayout } from '@toolpad/core/DashboardLayout'; import { PageContainer } from '@toolpad/core/PageContainer'; -import { Edit } from '@toolpad/core/Crud'; +import { DataSourceCache, Edit } from '@toolpad/core/Crud'; import { useDemoRouter } from '@toolpad/core/internal'; const NAVIGATION = [ @@ -113,6 +113,8 @@ export const peopleDataSource = { }, }; +const peopleCache = new DataSourceCache(); + function CrudEdit(props) { const { window } = props; @@ -138,6 +140,7 @@ function CrudEdit(props) { {/* preview-end */} diff --git a/docs/data/toolpad/core/components/crud/CrudEdit.tsx b/docs/data/toolpad/core/components/crud/CrudEdit.tsx index 07ed3535df3..6343bf198d2 100644 --- a/docs/data/toolpad/core/components/crud/CrudEdit.tsx +++ b/docs/data/toolpad/core/components/crud/CrudEdit.tsx @@ -4,7 +4,7 @@ import PersonIcon from '@mui/icons-material/Person'; import { AppProvider, type Navigation } from '@toolpad/core/AppProvider'; import { DashboardLayout } from '@toolpad/core/DashboardLayout'; import { PageContainer } from '@toolpad/core/PageContainer'; -import { DataModel, DataSource, Edit } from '@toolpad/core/Crud'; +import { DataModel, DataSource, DataSourceCache, Edit } from '@toolpad/core/Crud'; import { useDemoRouter } from '@toolpad/core/internal'; const NAVIGATION: Navigation = [ @@ -120,6 +120,8 @@ export const peopleDataSource: DataSource & }, }; +const peopleCache = new DataSourceCache(); + interface DemoProps { /** * Injected by the documentation to work in an iframe. @@ -153,6 +155,7 @@ export default function CrudEdit(props: DemoProps) { id={1} dataSource={peopleDataSource} + dataSourceCache={peopleCache} onSubmitSuccess={handleSubmitSuccess} /> {/* preview-end */} diff --git a/docs/data/toolpad/core/components/crud/CrudEdit.tsx.preview b/docs/data/toolpad/core/components/crud/CrudEdit.tsx.preview index 743a8da1e1b..9aa1ab0805e 100644 --- a/docs/data/toolpad/core/components/crud/CrudEdit.tsx.preview +++ b/docs/data/toolpad/core/components/crud/CrudEdit.tsx.preview @@ -1,5 +1,6 @@ id={1} dataSource={peopleDataSource} + dataSourceCache={peopleCache} onSubmitSuccess={handleSubmitSuccess} /> \ No newline at end of file diff --git a/docs/data/toolpad/core/components/crud/CrudList.js b/docs/data/toolpad/core/components/crud/CrudList.js index 781050f1bf5..feb70e6d010 100644 --- a/docs/data/toolpad/core/components/crud/CrudList.js +++ b/docs/data/toolpad/core/components/crud/CrudList.js @@ -5,7 +5,7 @@ import PersonIcon from '@mui/icons-material/Person'; import { AppProvider } from '@toolpad/core/AppProvider'; import { DashboardLayout } from '@toolpad/core/DashboardLayout'; import { PageContainer } from '@toolpad/core/PageContainer'; -import { List } from '@toolpad/core/Crud'; +import { DataSourceCache, List } from '@toolpad/core/Crud'; import { useDemoRouter } from '@toolpad/core/internal'; const NAVIGATION = [ @@ -139,6 +139,8 @@ export const peopleDataSource = { }, }; +const peopleCache = new DataSourceCache(); + function CrudList(props) { const { window } = props; @@ -175,6 +177,7 @@ function CrudList(props) { {/* preview-start */} & }, }; +const peopleCache = new DataSourceCache(); + interface DemoProps { /** * Injected by the documentation to work in an iframe. @@ -190,6 +192,7 @@ export default function CrudList(props: DemoProps) { {/* preview-start */} dataSource={peopleDataSource} + dataSourceCache={peopleCache} initialPageSize={4} onRowClick={handleRowClick} onCreateClick={handleCreateClick} diff --git a/docs/data/toolpad/core/components/crud/CrudList.tsx.preview b/docs/data/toolpad/core/components/crud/CrudList.tsx.preview index f9784c407a9..ae61f3e6365 100644 --- a/docs/data/toolpad/core/components/crud/CrudList.tsx.preview +++ b/docs/data/toolpad/core/components/crud/CrudList.tsx.preview @@ -1,5 +1,6 @@ dataSource={peopleDataSource} + dataSourceCache={peopleCache} initialPageSize={4} onRowClick={handleRowClick} onCreateClick={handleCreateClick} diff --git a/docs/data/toolpad/core/components/crud/CrudListDataGrid.js b/docs/data/toolpad/core/components/crud/CrudListDataGrid.js index 2d20682fa8f..37265917c66 100644 --- a/docs/data/toolpad/core/components/crud/CrudListDataGrid.js +++ b/docs/data/toolpad/core/components/crud/CrudListDataGrid.js @@ -6,7 +6,7 @@ import { DataGridPro } from '@mui/x-data-grid-pro'; import { AppProvider } from '@toolpad/core/AppProvider'; import { DashboardLayout } from '@toolpad/core/DashboardLayout'; import { PageContainer } from '@toolpad/core/PageContainer'; -import { List } from '@toolpad/core/Crud'; +import { DataSourceCache, List } from '@toolpad/core/Crud'; import { useDemoRouter } from '@toolpad/core/internal'; const NAVIGATION = [ @@ -140,6 +140,8 @@ export const peopleDataSource = { }, }; +const peopleCache = new DataSourceCache(); + function CrudListDataGrid(props) { const { window } = props; @@ -176,6 +178,7 @@ function CrudListDataGrid(props) { {/* preview-start */} & }, }; +const peopleCache = new DataSourceCache(); + interface DemoProps { /** * Injected by the documentation to work in an iframe. @@ -191,6 +193,7 @@ export default function CrudListDataGrid(props: DemoProps) { {/* preview-start */} dataSource={peopleDataSource} + dataSourceCache={peopleCache} initialPageSize={4} onRowClick={handleRowClick} onCreateClick={handleCreateClick} diff --git a/docs/data/toolpad/core/components/crud/CrudListDataGrid.tsx.preview b/docs/data/toolpad/core/components/crud/CrudListDataGrid.tsx.preview index 746c803eb0f..f2c24799d15 100644 --- a/docs/data/toolpad/core/components/crud/CrudListDataGrid.tsx.preview +++ b/docs/data/toolpad/core/components/crud/CrudListDataGrid.tsx.preview @@ -1,5 +1,6 @@ dataSource={peopleDataSource} + dataSourceCache={peopleCache} initialPageSize={4} onRowClick={handleRowClick} onCreateClick={handleCreateClick} diff --git a/docs/data/toolpad/core/components/crud/CrudNoCache.js b/docs/data/toolpad/core/components/crud/CrudNoCache.js new file mode 100644 index 00000000000..ae292e51876 --- /dev/null +++ b/docs/data/toolpad/core/components/crud/CrudNoCache.js @@ -0,0 +1,248 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { createTheme } from '@mui/material/styles'; +import StickyNote2Icon from '@mui/icons-material/StickyNote2'; +import { AppProvider } from '@toolpad/core/AppProvider'; +import { DashboardLayout } from '@toolpad/core/DashboardLayout'; +import { PageContainer } from '@toolpad/core/PageContainer'; +import { Crud } from '@toolpad/core/Crud'; +import { useDemoRouter } from '@toolpad/core/internal'; + +const NAVIGATION = [ + { + segment: 'notes', + title: 'Notes', + icon: , + pattern: 'notes{/:noteId}*', + }, +]; + +const demoTheme = createTheme({ + cssVariables: { + colorSchemeSelector: 'data-toolpad-color-scheme', + }, + colorSchemes: { light: true, dark: true }, + breakpoints: { + values: { + xs: 0, + sm: 600, + md: 600, + lg: 1200, + xl: 1536, + }, + }, +}); + +let notesStore = [ + { id: 1, title: 'Grocery List Item', text: 'Buy more coffee.' }, + { id: 2, title: 'Personal Goal', text: 'Finish reading the book.' }, +]; + +export const notesDataSource = { + fields: [ + { field: 'id', headerName: 'ID' }, + { field: 'title', headerName: 'Title', flex: 1 }, + { field: 'text', headerName: 'Text', type: 'longString', flex: 1 }, + ], + getMany: async ({ paginationModel, filterModel, sortModel }) => { + return new Promise((resolve) => { + setTimeout(() => { + let processedNotes = [...notesStore]; + + // Apply filters (demo only) + if (filterModel?.items?.length) { + filterModel.items.forEach(({ field, value, operator }) => { + if (!field || value == null) { + return; + } + + processedNotes = processedNotes.filter((note) => { + const noteValue = note[field]; + + switch (operator) { + case 'contains': + return String(noteValue) + .toLowerCase() + .includes(String(value).toLowerCase()); + case 'equals': + return noteValue === value; + case 'startsWith': + return String(noteValue) + .toLowerCase() + .startsWith(String(value).toLowerCase()); + case 'endsWith': + return String(noteValue) + .toLowerCase() + .endsWith(String(value).toLowerCase()); + case '>': + return noteValue > value; + case '<': + return noteValue < value; + default: + return true; + } + }); + }); + } + + // Apply sorting + if (sortModel?.length) { + processedNotes.sort((a, b) => { + for (const { field, sort } of sortModel) { + if (a[field] < b[field]) { + return sort === 'asc' ? -1 : 1; + } + if (a[field] > b[field]) { + return sort === 'asc' ? 1 : -1; + } + } + return 0; + }); + } + + // Apply pagination + const start = paginationModel.page * paginationModel.pageSize; + const end = start + paginationModel.pageSize; + const paginatedNotes = processedNotes.slice(start, end); + + resolve({ + items: paginatedNotes, + itemCount: processedNotes.length, + }); + }, 750); + }); + }, + getOne: (noteId) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + const noteToShow = notesStore.find((note) => note.id === Number(noteId)); + + if (noteToShow) { + resolve(noteToShow); + } else { + reject(new Error('Note not found')); + } + }, 750); + }); + }, + createOne: (data) => { + return new Promise((resolve) => { + setTimeout(() => { + const newNote = { id: notesStore.length + 1, ...data }; + + notesStore = [...notesStore, newNote]; + + resolve(newNote); + }, 750); + }); + }, + updateOne: (noteId, data) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + let updatedNote = null; + + notesStore = notesStore.map((note) => { + if (note.id === Number(noteId)) { + updatedNote = { ...note, ...data }; + return updatedNote; + } + return note; + }); + + if (updatedNote) { + resolve(updatedNote); + } else { + reject(new Error('Note not found')); + } + }, 750); + }); + }, + deleteOne: (noteId) => { + return new Promise((resolve) => { + setTimeout(() => { + notesStore = notesStore.filter((note) => note.id !== Number(noteId)); + + resolve(); + }, 750); + }); + }, + validate: (formValues) => { + const errors = {}; + + if (!formValues.title) { + errors.title = 'Title is required'; + } + if (formValues.title && formValues.title.length < 3) { + errors.title = 'Title must be at least 3 characters long'; + } + if (!formValues.text) { + errors.text = 'Text is required'; + } + + return errors; + }, +}; + +function matchPath(pattern, pathname) { + const regex = new RegExp(`^${pattern.replace(/:[^/]+/g, '([^/]+)')}$`); + const match = pathname.match(regex); + return match ? match[1] : null; +} + +function CrudNoCache(props) { + const { window } = props; + + const router = useDemoRouter('/notes'); + + // Remove this const when copying and pasting into your project. + const demoWindow = window !== undefined ? window() : undefined; + + const title = React.useMemo(() => { + if (router.pathname === '/notes/new') { + return 'New Note'; + } + const editNoteId = matchPath('/notes/:noteId/edit', router.pathname); + if (editNoteId) { + return `Note ${editNoteId} - Edit`; + } + const showNoteId = matchPath('/notes/:noteId', router.pathname); + if (showNoteId) { + return `Note ${showNoteId}`; + } + + return undefined; + }, [router.pathname]); + + return ( + + + + {/* preview-start */} + + {/* preview-end */} + + + + ); +} + +CrudNoCache.propTypes = { + /** + * Injected by the documentation to work in an iframe. + * Remove this when copying and pasting into your project. + */ + window: PropTypes.func, +}; + +export default CrudNoCache; diff --git a/docs/data/toolpad/core/components/crud/CrudNoCache.tsx b/docs/data/toolpad/core/components/crud/CrudNoCache.tsx new file mode 100644 index 00000000000..7d81737934f --- /dev/null +++ b/docs/data/toolpad/core/components/crud/CrudNoCache.tsx @@ -0,0 +1,251 @@ +import * as React from 'react'; +import { createTheme } from '@mui/material/styles'; +import StickyNote2Icon from '@mui/icons-material/StickyNote2'; +import { AppProvider, type Navigation } from '@toolpad/core/AppProvider'; +import { DashboardLayout } from '@toolpad/core/DashboardLayout'; +import { PageContainer } from '@toolpad/core/PageContainer'; +import { Crud, DataModel, DataSource } from '@toolpad/core/Crud'; +import { useDemoRouter } from '@toolpad/core/internal'; + +const NAVIGATION: Navigation = [ + { + segment: 'notes', + title: 'Notes', + icon: , + pattern: 'notes{/:noteId}*', + }, +]; + +const demoTheme = createTheme({ + cssVariables: { + colorSchemeSelector: 'data-toolpad-color-scheme', + }, + colorSchemes: { light: true, dark: true }, + breakpoints: { + values: { + xs: 0, + sm: 600, + md: 600, + lg: 1200, + xl: 1536, + }, + }, +}); + +export interface Note extends DataModel { + id: number; + title: string; + text: string; +} + +let notesStore: Note[] = [ + { id: 1, title: 'Grocery List Item', text: 'Buy more coffee.' }, + { id: 2, title: 'Personal Goal', text: 'Finish reading the book.' }, +]; + +export const notesDataSource: DataSource = { + fields: [ + { field: 'id', headerName: 'ID' }, + { field: 'title', headerName: 'Title', flex: 1 }, + { field: 'text', headerName: 'Text', type: 'longString', flex: 1 }, + ], + getMany: async ({ paginationModel, filterModel, sortModel }) => { + return new Promise<{ items: Note[]; itemCount: number }>((resolve) => { + setTimeout(() => { + let processedNotes = [...notesStore]; + + // Apply filters (demo only) + if (filterModel?.items?.length) { + filterModel.items.forEach(({ field, value, operator }) => { + if (!field || value == null) { + return; + } + + processedNotes = processedNotes.filter((note) => { + const noteValue = note[field]; + + switch (operator) { + case 'contains': + return String(noteValue) + .toLowerCase() + .includes(String(value).toLowerCase()); + case 'equals': + return noteValue === value; + case 'startsWith': + return String(noteValue) + .toLowerCase() + .startsWith(String(value).toLowerCase()); + case 'endsWith': + return String(noteValue) + .toLowerCase() + .endsWith(String(value).toLowerCase()); + case '>': + return (noteValue as number) > value; + case '<': + return (noteValue as number) < value; + default: + return true; + } + }); + }); + } + + // Apply sorting + if (sortModel?.length) { + processedNotes.sort((a, b) => { + for (const { field, sort } of sortModel) { + if ((a[field] as number) < (b[field] as number)) { + return sort === 'asc' ? -1 : 1; + } + if ((a[field] as number) > (b[field] as number)) { + return sort === 'asc' ? 1 : -1; + } + } + return 0; + }); + } + + // Apply pagination + const start = paginationModel.page * paginationModel.pageSize; + const end = start + paginationModel.pageSize; + const paginatedNotes = processedNotes.slice(start, end); + + resolve({ + items: paginatedNotes, + itemCount: processedNotes.length, + }); + }, 750); + }); + }, + getOne: (noteId) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + const noteToShow = notesStore.find((note) => note.id === Number(noteId)); + + if (noteToShow) { + resolve(noteToShow); + } else { + reject(new Error('Note not found')); + } + }, 750); + }); + }, + createOne: (data) => { + return new Promise((resolve) => { + setTimeout(() => { + const newNote = { id: notesStore.length + 1, ...data } as Note; + + notesStore = [...notesStore, newNote]; + + resolve(newNote); + }, 750); + }); + }, + updateOne: (noteId, data) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + let updatedNote: Note | null = null; + + notesStore = notesStore.map((note) => { + if (note.id === Number(noteId)) { + updatedNote = { ...note, ...data }; + return updatedNote; + } + return note; + }); + + if (updatedNote) { + resolve(updatedNote); + } else { + reject(new Error('Note not found')); + } + }, 750); + }); + }, + deleteOne: (noteId) => { + return new Promise((resolve) => { + setTimeout(() => { + notesStore = notesStore.filter((note) => note.id !== Number(noteId)); + + resolve(); + }, 750); + }); + }, + validate: (formValues) => { + const errors: Record = {}; + + if (!formValues.title) { + errors.title = 'Title is required'; + } + if (formValues.title && formValues.title.length < 3) { + errors.title = 'Title must be at least 3 characters long'; + } + if (!formValues.text) { + errors.text = 'Text is required'; + } + + return errors; + }, +}; + +function matchPath(pattern: string, pathname: string): string | null { + const regex = new RegExp(`^${pattern.replace(/:[^/]+/g, '([^/]+)')}$`); + const match = pathname.match(regex); + return match ? match[1] : null; +} + +interface DemoProps { + /** + * Injected by the documentation to work in an iframe. + * Remove this when copying and pasting into your project. + */ + window?: () => Window; +} + +export default function CrudNoCache(props: DemoProps) { + const { window } = props; + + const router = useDemoRouter('/notes'); + + // Remove this const when copying and pasting into your project. + const demoWindow = window !== undefined ? window() : undefined; + + const title = React.useMemo(() => { + if (router.pathname === '/notes/new') { + return 'New Note'; + } + const editNoteId = matchPath('/notes/:noteId/edit', router.pathname); + if (editNoteId) { + return `Note ${editNoteId} - Edit`; + } + const showNoteId = matchPath('/notes/:noteId', router.pathname); + if (showNoteId) { + return `Note ${showNoteId}`; + } + + return undefined; + }, [router.pathname]); + + return ( + + + + {/* preview-start */} + + dataSource={notesDataSource} + dataSourceCache={null} + rootPath="/notes" + initialPageSize={10} + defaultValues={{ title: 'New note' }} + /> + {/* preview-end */} + + + + ); +} diff --git a/docs/data/toolpad/core/components/crud/CrudNoCache.tsx.preview b/docs/data/toolpad/core/components/crud/CrudNoCache.tsx.preview new file mode 100644 index 00000000000..ad1072142d5 --- /dev/null +++ b/docs/data/toolpad/core/components/crud/CrudNoCache.tsx.preview @@ -0,0 +1,7 @@ + + dataSource={notesDataSource} + dataSourceCache={null} + rootPath="/notes" + initialPageSize={10} + defaultValues={{ title: 'New note' }} +/> \ No newline at end of file diff --git a/docs/data/toolpad/core/components/crud/CrudShow.js b/docs/data/toolpad/core/components/crud/CrudShow.js index 9dd800f595b..bf09f4d59cc 100644 --- a/docs/data/toolpad/core/components/crud/CrudShow.js +++ b/docs/data/toolpad/core/components/crud/CrudShow.js @@ -5,7 +5,7 @@ import PersonIcon from '@mui/icons-material/Person'; import { AppProvider } from '@toolpad/core/AppProvider'; import { DashboardLayout } from '@toolpad/core/DashboardLayout'; import { PageContainer } from '@toolpad/core/PageContainer'; -import { Show } from '@toolpad/core/Crud'; +import { DataSourceCache, Show } from '@toolpad/core/Crud'; import { useDemoRouter } from '@toolpad/core/internal'; const NAVIGATION = [ @@ -84,6 +84,8 @@ export const peopleDataSource = { }, }; +const peopleCache = new DataSourceCache(); + function CrudShow(props) { const { window } = props; @@ -113,6 +115,7 @@ function CrudShow(props) { diff --git a/docs/data/toolpad/core/components/crud/CrudShow.tsx b/docs/data/toolpad/core/components/crud/CrudShow.tsx index 36881886fc1..8973ca8a56e 100644 --- a/docs/data/toolpad/core/components/crud/CrudShow.tsx +++ b/docs/data/toolpad/core/components/crud/CrudShow.tsx @@ -4,7 +4,7 @@ import PersonIcon from '@mui/icons-material/Person'; import { AppProvider, type Navigation } from '@toolpad/core/AppProvider'; import { DashboardLayout } from '@toolpad/core/DashboardLayout'; import { PageContainer } from '@toolpad/core/PageContainer'; -import { DataModel, DataSource, Show } from '@toolpad/core/Crud'; +import { DataModel, DataSource, DataSourceCache, Show } from '@toolpad/core/Crud'; import { useDemoRouter } from '@toolpad/core/internal'; const NAVIGATION: Navigation = [ @@ -91,6 +91,8 @@ export const peopleDataSource: DataSource & }, }; +const peopleCache = new DataSourceCache(); + interface DemoProps { /** * Injected by the documentation to work in an iframe. @@ -128,6 +130,7 @@ export default function CrudShow(props: DemoProps) { id={1} dataSource={peopleDataSource} + dataSourceCache={peopleCache} onEditClick={handleEditClick} onDelete={handleDelete} /> diff --git a/docs/data/toolpad/core/components/crud/CrudShow.tsx.preview b/docs/data/toolpad/core/components/crud/CrudShow.tsx.preview index 6990de00050..44a1ce745e3 100644 --- a/docs/data/toolpad/core/components/crud/CrudShow.tsx.preview +++ b/docs/data/toolpad/core/components/crud/CrudShow.tsx.preview @@ -1,6 +1,7 @@ id={1} dataSource={peopleDataSource} + dataSourceCache={peopleCache} onEditClick={handleEditClick} onDelete={handleDelete} /> \ No newline at end of file diff --git a/docs/data/toolpad/core/components/crud/crud.md b/docs/data/toolpad/core/components/crud/crud.md index 32c999aa775..6de955c4c93 100644 --- a/docs/data/toolpad/core/components/crud/crud.md +++ b/docs/data/toolpad/core/components/crud/crud.md @@ -25,6 +25,8 @@ The pages will be present in the following routes: These default paths and other out-of-the-box settings can be overriden and configured in more detail by following the [advanced configuration](https://mui.com/toolpad/core/react-crud/#advanced-configuration) below. +It is recommended to include the `dataSourceCache` prop in order to properly cache results from the data source query methods. See more in the section about [data caching](#data-caching) below. + Optionally, additional configuration options can be provided such as `initialPageSize` for the paginated list of items, or `defaultValues` to set the initial form values when using a form to create new items. :::info @@ -283,13 +285,30 @@ const dataSource = { }; ``` +## Data caching + +Data sources cache fetched data by default. This means that if the user queries data that has already been fetched, query methods such as `getMany` and `getOne` will not be called again to avoid unnecessary calls to the server. + +Successfully calling mutation methods such as `createOne`, `updateOne` or `deleteOne` automatically clears the cache for all queries in the same data source. + +It is recommended to always pass an instance of `DataSourceCache` to the `dataSourceCache` prop in order to cache data, as seen in the [demo above](#demo), otherwise by default the cache will be scoped to the component being used. `DataSourceCache` is a simple in-memory cache that stores the data in a plain object. + +Each data source should have its own single cache instance across the whole application. + +### Disable cache + +To disable the data source cache, pass `null` to the `dataSourceCache` prop. + +{{"demo": "CrudNoCache.js", "height": 600, "iframe": true}} + ## Advanced configuration For more flexibility of customization, and especially if you want full control over where to place the different CRUD pages, you can use the `List`, `Show`, `Create` and `Edit` subcomponents instead of the all-in-one `Crud` component. {{"demo": "CrudAdvanced.js", "height": 600, "iframe": true}} -The `CrudProvider` component is optional, but it can be used to easily pass a single `dataSource` to the CRUD subcomponents inside it as context. Alternatively, each of those components can take its own `dataSource` as a prop. +The `CrudProvider` component is optional, but it can be used to easily pass a single `dataSource` and `dataSourceCache` to the CRUD subcomponents inside it as context. +Alternatively, each of those components can take its own `dataSource` and `dataSourceCache` as props. ### `List` component diff --git a/packages/toolpad-core/src/Crud/Create.tsx b/packages/toolpad-core/src/Crud/Create.tsx index 8f4fa24eeef..2a43466b6e3 100644 --- a/packages/toolpad-core/src/Crud/Create.tsx +++ b/packages/toolpad-core/src/Crud/Create.tsx @@ -31,7 +31,7 @@ export interface CreateProps { /** * Cache for the data source. */ - dataSourceCache?: DataSourceCache; + dataSourceCache?: DataSourceCache | null; } /** @@ -61,10 +61,10 @@ function Create(props: CreateProps) { invariant(dataSource, 'No data source found.'); - const cache = React.useMemo( - () => dataSourceCache ?? crudContext.dataSourceCache ?? new DataSourceCache(), - [crudContext.dataSourceCache, dataSourceCache], - ); + const cache = React.useMemo(() => { + const manualCache = dataSourceCache ?? crudContext.dataSourceCache; + return typeof manualCache !== 'undefined' ? manualCache : new DataSourceCache(); + }, [crudContext.dataSourceCache, dataSourceCache]); const cachedDataSource = useCachedDataSource(dataSource, cache) as NonNullable< typeof props.dataSource >; diff --git a/packages/toolpad-core/src/Crud/Crud.tsx b/packages/toolpad-core/src/Crud/Crud.tsx index 2d8cdecf4de..aa7a51bf559 100644 --- a/packages/toolpad-core/src/Crud/Crud.tsx +++ b/packages/toolpad-core/src/Crud/Crud.tsx @@ -34,7 +34,7 @@ export interface CrudProps { /** * Cache for the data source. */ - dataSourceCache?: DataSourceCache; + dataSourceCache?: DataSourceCache | null; } /** * diff --git a/packages/toolpad-core/src/Crud/CrudProvider.tsx b/packages/toolpad-core/src/Crud/CrudProvider.tsx index a9df1f8af74..2b9bc1f5c65 100644 --- a/packages/toolpad-core/src/Crud/CrudProvider.tsx +++ b/packages/toolpad-core/src/Crud/CrudProvider.tsx @@ -13,7 +13,7 @@ export interface CrudProviderProps { /** * Cache for the data source. */ - dataSourceCache?: DataSourceCache; + dataSourceCache?: DataSourceCache | null; children?: React.ReactNode; } /** @@ -29,7 +29,10 @@ export interface CrudProviderProps { function CrudProvider(props: CrudProviderProps) { const { dataSource, dataSourceCache, children } = props; - const cache = React.useMemo(() => dataSourceCache ?? new DataSourceCache(), [dataSourceCache]); + const cache = React.useMemo( + () => (typeof dataSourceCache !== 'undefined' ? dataSourceCache : new DataSourceCache()), + [dataSourceCache], + ); return ( { /** * Cache for the data source. */ - dataSourceCache?: DataSourceCache; + dataSourceCache?: DataSourceCache | null; } /** @@ -170,10 +170,10 @@ function Edit(props: EditProps) { invariant(dataSource, 'No data source found.'); - const cache = React.useMemo( - () => dataSourceCache ?? crudContext.dataSourceCache ?? new DataSourceCache(), - [crudContext.dataSourceCache, dataSourceCache], - ); + const cache = React.useMemo(() => { + const manualCache = dataSourceCache ?? crudContext.dataSourceCache; + return typeof manualCache !== 'undefined' ? manualCache : new DataSourceCache(); + }, [crudContext.dataSourceCache, dataSourceCache]); const cachedDataSource = useCachedDataSource(dataSource, cache) as NonNullable< typeof props.dataSource >; diff --git a/packages/toolpad-core/src/Crud/List.tsx b/packages/toolpad-core/src/Crud/List.tsx index 0e093160a3d..597ecb39c81 100644 --- a/packages/toolpad-core/src/Crud/List.tsx +++ b/packages/toolpad-core/src/Crud/List.tsx @@ -93,7 +93,7 @@ export interface ListProps { /** * Cache for the data source. */ - dataSourceCache?: DataSourceCache; + dataSourceCache?: DataSourceCache | null; /** * The components used for each slot inside. * @default {} @@ -135,10 +135,10 @@ function List(props: ListProps) { invariant(dataSource, 'No data source found.'); - const cache = React.useMemo( - () => dataSourceCache ?? crudContext.dataSourceCache ?? new DataSourceCache(), - [crudContext.dataSourceCache, dataSourceCache], - ); + const cache = React.useMemo(() => { + const manualCache = dataSourceCache ?? crudContext.dataSourceCache; + return typeof manualCache !== 'undefined' ? manualCache : new DataSourceCache(); + }, [crudContext.dataSourceCache, dataSourceCache]); const cachedDataSource = useCachedDataSource(dataSource, cache) as NonNullable< typeof props.dataSource >; diff --git a/packages/toolpad-core/src/Crud/Show.tsx b/packages/toolpad-core/src/Crud/Show.tsx index 498a31a1c20..b71c6ecaf8b 100644 --- a/packages/toolpad-core/src/Crud/Show.tsx +++ b/packages/toolpad-core/src/Crud/Show.tsx @@ -38,7 +38,7 @@ export interface ShowProps { /** * Cache for the data source. */ - dataSourceCache?: DataSourceCache; + dataSourceCache?: DataSourceCache | null; } /** @@ -61,10 +61,10 @@ function Show(props: ShowProps) { invariant(dataSource, 'No data source found.'); - const cache = React.useMemo( - () => dataSourceCache ?? crudContext.dataSourceCache ?? new DataSourceCache(), - [crudContext.dataSourceCache, dataSourceCache], - ); + const cache = React.useMemo(() => { + const manualCache = dataSourceCache ?? crudContext.dataSourceCache; + return typeof manualCache !== 'undefined' ? manualCache : new DataSourceCache(); + }, [crudContext.dataSourceCache, dataSourceCache]); const cachedDataSource = useCachedDataSource(dataSource, cache) as NonNullable< typeof props.dataSource >; diff --git a/packages/toolpad-core/src/Crud/useCachedDataSource.ts b/packages/toolpad-core/src/Crud/useCachedDataSource.ts index aaefb02de2c..a5187be50f9 100644 --- a/packages/toolpad-core/src/Crud/useCachedDataSource.ts +++ b/packages/toolpad-core/src/Crud/useCachedDataSource.ts @@ -4,9 +4,13 @@ import type { DataModel, DataSource } from './types'; function useCachedDataSource( dataSource: DataSource, - cache: DataSourceCache, + cache: DataSourceCache | null, ): DataSource { return React.useMemo(() => { + if (!cache) { + return dataSource; + } + const { getMany, getOne, createOne, updateOne, deleteOne, ...rest } = dataSource; return {