diff --git a/docs/Upgrade.md b/docs/Upgrade.md
index b94408ae5f8..434624e65bf 100644
--- a/docs/Upgrade.md
+++ b/docs/Upgrade.md
@@ -771,6 +771,27 @@ describe('my test suite', () => {
});
```
+## TypeScript: `useRecordContext` Returns `undefined` When No Record Is Available
+
+The `useRecordContext` hook reads the current record from the `RecordContext`. This context may be empty (e.g. while the record is being fetched). The return type for `useRecordContext` has been modified to `Record | undefined` instead of `Record` to denote this possibility.
+
+As a consequence, the TypeScript compilation of your project may fail if you don't check the existence of the record before reading it.
+
+To fix this error, your code should handle the case where `useRecordContext` returns `undefined`:
+
+```diff
+const MyComponent = () => {
+ const record = useRecordContext();
++ if (!record) return null;
+ return (
+
+
{record.title}
+
{record.body}
+
+ );
+};
+```
+
## TypeScript: Page Contexts Are Now Types Instead of Interfaces
The return type of page controllers is now a type. If you were using an interface extending one of:
diff --git a/examples/crm/src/companies/CompanyShow.tsx b/examples/crm/src/companies/CompanyShow.tsx
index 28955ae5b73..02ffb139eee 100644
--- a/examples/crm/src/companies/CompanyShow.tsx
+++ b/examples/crm/src/companies/CompanyShow.tsx
@@ -202,7 +202,7 @@ const CreateRelatedContactButton = () => {
{
- {record.gender === 'male' ? 'He/Him' : 'She/Her'}
+ {record
+ ? record.gender === 'male'
+ ? 'He/Him'
+ : 'She/Her'
+ : ''}
Background
diff --git a/examples/crm/src/contacts/TagsListEdit.tsx b/examples/crm/src/contacts/TagsListEdit.tsx
index e56ff70abff..96e99514252 100644
--- a/examples/crm/src/contacts/TagsListEdit.tsx
+++ b/examples/crm/src/contacts/TagsListEdit.tsx
@@ -43,14 +43,16 @@ export const TagsListEdit = () => {
);
const { data: tags, isPending: isPendingRecordTags } = useGetMany(
'tags',
- { ids: record.tags },
+ { ids: record?.tags },
{ enabled: record && record.tags && record.tags.length > 0 }
);
const [update] = useUpdate();
const [create] = useCreate();
const unselectedTags =
- allTags && allTags.filter(tag => !record.tags.includes(tag.id));
+ allTags &&
+ record &&
+ allTags.filter(tag => !record.tags.includes(tag.id));
const handleOpen = (event: React.MouseEvent) => {
setAnchorEl(event.currentTarget);
@@ -61,6 +63,9 @@ export const TagsListEdit = () => {
};
const handleDeleteTag = (id: Identifier) => {
+ if (!record) {
+ throw new Error('No contact record found');
+ }
const tags = record.tags.filter(tagId => tagId !== id);
update('contacts', {
id: record.id,
@@ -70,6 +75,9 @@ export const TagsListEdit = () => {
};
const handleAddTag = (id: Identifier) => {
+ if (!record) {
+ throw new Error('No contact record found');
+ }
const tags = [...record.tags, id];
update('contacts', {
id: record.id,
@@ -93,6 +101,9 @@ export const TagsListEdit = () => {
const handleCreateTag = (event: FormEvent) => {
event.preventDefault();
+ if (!record) {
+ throw new Error('No contact record found');
+ }
setDisabled(true);
create(
'tags',
diff --git a/examples/crm/src/notes/Note.tsx b/examples/crm/src/notes/Note.tsx
index b561262af48..559ad613826 100644
--- a/examples/crm/src/notes/Note.tsx
+++ b/examples/crm/src/notes/Note.tsx
@@ -50,8 +50,8 @@ export const Note = ({
onSuccess: () => {
notify('Note deleted', { type: 'info', undoable: true });
update(reference, {
- id: record.id,
- data: { nb_notes: record.nb_notes - 1 },
+ id: record?.id,
+ data: { nb_notes: record?.nb_notes - 1 },
previousData: record,
});
},
diff --git a/examples/demo/src/products/CreateRelatedReviewButton.tsx b/examples/demo/src/products/CreateRelatedReviewButton.tsx
index 1a2d00c98f4..3cfe2522c2c 100644
--- a/examples/demo/src/products/CreateRelatedReviewButton.tsx
+++ b/examples/demo/src/products/CreateRelatedReviewButton.tsx
@@ -7,7 +7,7 @@ const CreateRelatedReviewButton = () => {
return (
);
};
diff --git a/examples/demo/src/reviews/AcceptButton.tsx b/examples/demo/src/reviews/AcceptButton.tsx
index 25e45f2bf52..006cfa30fe3 100644
--- a/examples/demo/src/reviews/AcceptButton.tsx
+++ b/examples/demo/src/reviews/AcceptButton.tsx
@@ -22,7 +22,7 @@ const AcceptButton = () => {
const [approve, { isPending }] = useUpdate(
'reviews',
- { id: record.id, data: { status: 'accepted' }, previousData: record },
+ { id: record?.id, data: { status: 'accepted' }, previousData: record },
{
mutationMode: 'undoable',
onSuccess: () => {
diff --git a/examples/demo/src/reviews/RejectButton.tsx b/examples/demo/src/reviews/RejectButton.tsx
index f001d0f8ce1..c23fba4ca17 100644
--- a/examples/demo/src/reviews/RejectButton.tsx
+++ b/examples/demo/src/reviews/RejectButton.tsx
@@ -22,7 +22,7 @@ const RejectButton = () => {
const [reject, { isPending }] = useUpdate(
'reviews',
- { id: record.id, data: { status: 'rejected' }, previousData: record },
+ { id: record?.id, data: { status: 'rejected' }, previousData: record },
{
mutationMode: 'undoable',
onSuccess: () => {
diff --git a/examples/demo/src/visitors/Aside.tsx b/examples/demo/src/visitors/Aside.tsx
index ecbeed31ac8..c1b2c45dc47 100644
--- a/examples/demo/src/visitors/Aside.tsx
+++ b/examples/demo/src/visitors/Aside.tsx
@@ -51,16 +51,18 @@ const EventList = () => {
{
pagination: { page: 1, perPage: 100 },
sort: { field: 'date', order: 'DESC' },
- filter: { customer_id: record.id },
- }
+ filter: { customer_id: record?.id },
+ },
+ { enabled: !!record?.id }
);
const { data: reviews, total: totalReviews } = useGetList(
'reviews',
{
pagination: { page: 1, perPage: 100 },
sort: { field: 'date', order: 'DESC' },
- filter: { customer_id: record.id },
- }
+ filter: { customer_id: record?.id },
+ },
+ { enabled: !!record?.id }
);
const events = mixOrdersAndReviews(orders, reviews);
@@ -87,7 +89,7 @@ const EventList = () => {
- {totalOrders! > 0 && (
+ {totalOrders! > 0 && record && (
<>
{
>
{translate(
'resources.commands.amount',
- {
- smart_count: totalOrders,
- }
+ { smart_count: totalOrders }
)}
>
@@ -128,7 +128,7 @@ const EventList = () => {
- {totalReviews! > 0 && (
+ {totalReviews! > 0 && record && (
<>
Promise.resolve(),
logout: () => Promise.resolve(),
checkAuth: () => Promise.resolve(),
@@ -13,8 +13,6 @@ const defaultProvider: AuthProvider = {
getIdentity: () => Promise.resolve(defaultIdentity),
};
-const AuthContext = createContext(defaultProvider);
+export const AuthContext = createContext(defaultAuthProvider);
AuthContext.displayName = 'AuthContext';
-
-export default AuthContext;
diff --git a/packages/ra-core/src/auth/index.ts b/packages/ra-core/src/auth/index.ts
index 023b4f8de99..b8cf8dec7cb 100644
--- a/packages/ra-core/src/auth/index.ts
+++ b/packages/ra-core/src/auth/index.ts
@@ -1,4 +1,3 @@
-import AuthContext from './AuthContext';
import useAuthProvider from './useAuthProvider';
import useAuthState from './useAuthState';
import usePermissions from './usePermissions';
@@ -10,6 +9,7 @@ import useLogoutIfAccessDenied from './useLogoutIfAccessDenied';
import convertLegacyAuthProvider from './convertLegacyAuthProvider';
export * from './Authenticated';
+export * from './AuthContext';
export * from './types';
export * from './useAuthenticated';
export * from './useCheckAuth';
@@ -19,7 +19,6 @@ export * from './addRefreshAuthToAuthProvider';
export * from './addRefreshAuthToDataProvider';
export {
- AuthContext,
useAuthProvider,
convertLegacyAuthProvider,
// low-level hooks for calling a particular verb on the authProvider
diff --git a/packages/ra-core/src/auth/useAuthProvider.ts b/packages/ra-core/src/auth/useAuthProvider.ts
index d17d26501c3..93a78faf60e 100644
--- a/packages/ra-core/src/auth/useAuthProvider.ts
+++ b/packages/ra-core/src/auth/useAuthProvider.ts
@@ -1,7 +1,7 @@
import { useContext } from 'react';
import { AuthProvider } from '../types';
-import AuthContext from './AuthContext';
+import { AuthContext } from './AuthContext';
export const defaultAuthParams = {
loginUrl: '/login',
diff --git a/packages/ra-core/src/auth/useCheckAuth.spec.tsx b/packages/ra-core/src/auth/useCheckAuth.spec.tsx
index ba38501ef78..8f21feb25b5 100644
--- a/packages/ra-core/src/auth/useCheckAuth.spec.tsx
+++ b/packages/ra-core/src/auth/useCheckAuth.spec.tsx
@@ -6,7 +6,7 @@ import { Location } from 'react-router-dom';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { useCheckAuth } from './useCheckAuth';
-import AuthContext from './AuthContext';
+import { AuthContext } from './AuthContext';
import { BasenameContextProvider, TestMemoryRouter } from '../routing';
import { useNotify } from '../notification/useNotify';
diff --git a/packages/ra-core/src/auth/useGetIdentity.spec.tsx b/packages/ra-core/src/auth/useGetIdentity.spec.tsx
index 27d2764144c..cc537062517 100644
--- a/packages/ra-core/src/auth/useGetIdentity.spec.tsx
+++ b/packages/ra-core/src/auth/useGetIdentity.spec.tsx
@@ -4,7 +4,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Basic, ErrorCase, ResetIdentity } from './useGetIdentity.stories';
import useGetIdentity from './useGetIdentity';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import AuthContext from './AuthContext';
+import { AuthContext } from './AuthContext';
describe('useGetIdentity', () => {
it('should return the identity', async () => {
diff --git a/packages/ra-core/src/auth/useGetIdentity.stories.tsx b/packages/ra-core/src/auth/useGetIdentity.stories.tsx
index 9d78a7f37ec..387345b2c9d 100644
--- a/packages/ra-core/src/auth/useGetIdentity.stories.tsx
+++ b/packages/ra-core/src/auth/useGetIdentity.stories.tsx
@@ -1,7 +1,7 @@
import * as React from 'react';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { useGetIdentity } from './useGetIdentity';
-import AuthContext from './AuthContext';
+import { AuthContext } from './AuthContext';
export default {
title: 'ra-core/auth/useGetIdentity',
diff --git a/packages/ra-core/src/auth/useHandleAuthCallback.spec.tsx b/packages/ra-core/src/auth/useHandleAuthCallback.spec.tsx
index b51e26abf7e..1ba624e777d 100644
--- a/packages/ra-core/src/auth/useHandleAuthCallback.spec.tsx
+++ b/packages/ra-core/src/auth/useHandleAuthCallback.spec.tsx
@@ -5,7 +5,7 @@ import { Route, Routes } from 'react-router-dom';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { useHandleAuthCallback } from './useHandleAuthCallback';
-import AuthContext from './AuthContext';
+import { AuthContext } from './AuthContext';
import { AuthProvider } from '../types';
import { TestMemoryRouter } from '../routing';
diff --git a/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx
index e02d1e365d3..dcd79382766 100644
--- a/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx
+++ b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx
@@ -5,7 +5,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom';
import useLogoutIfAccessDenied from './useLogoutIfAccessDenied';
-import AuthContext from './AuthContext';
+import { AuthContext } from './AuthContext';
import useLogout from './useLogout';
import { useNotify } from '../notification/useNotify';
import { AuthProvider } from '../types';
diff --git a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx
index 997eef0f3db..422d57bf2b5 100644
--- a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx
+++ b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx
@@ -68,7 +68,7 @@ const useDeleteWithConfirmController = (
): UseDeleteWithConfirmControllerReturn => {
const {
record,
- redirect: redirectTo,
+ redirect: redirectTo = 'list',
mutationMode,
onClick,
mutationOptions = {},
@@ -81,11 +81,7 @@ const useDeleteWithConfirmController = (
const redirect = useRedirect();
const [deleteOne, { isPending }] = useDelete(
resource,
- {
- id: record.id,
- previousData: record,
- meta: mutationMeta,
- },
+ undefined,
{
onSuccess: () => {
setOpen(false);
@@ -94,7 +90,7 @@ const useDeleteWithConfirmController = (
messageArgs: { smart_count: 1 },
undoable: mutationMode === 'undoable',
});
- unselect([record.id]);
+ record && unselect([record.id]);
redirect(redirectTo, resource);
},
onError: (error: Error) => {
@@ -133,6 +129,11 @@ const useDeleteWithConfirmController = (
const handleDelete = useCallback(
event => {
event.stopPropagation();
+ if (!record) {
+ throw new Error(
+ 'The record cannot be deleted because no record has been passed'
+ );
+ }
deleteOne(
resource,
{
diff --git a/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx b/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx
index 36bd051970d..547307dd0a4 100644
--- a/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx
+++ b/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx
@@ -59,11 +59,7 @@ const useDeleteWithUndoController = (
const redirect = useRedirect();
const [deleteOne, { isPending }] = useDelete(
resource,
- {
- id: record.id,
- previousData: record,
- meta: mutationMeta,
- },
+ undefined,
{
onSuccess: () => {
notify('ra.notification.deleted', {
@@ -71,7 +67,7 @@ const useDeleteWithUndoController = (
messageArgs: { smart_count: 1 },
undoable: true,
});
- unselect([record.id]);
+ record && unselect([record.id]);
redirect(redirectTo, resource);
},
onError: (error: Error) => {
@@ -98,6 +94,11 @@ const useDeleteWithUndoController = (
const handleDelete = useCallback(
event => {
event.stopPropagation();
+ if (!record) {
+ throw new Error(
+ 'The record cannot be deleted because no record has been passed'
+ );
+ }
deleteOne(
resource,
{
diff --git a/packages/ra-core/src/controller/field/ReferenceFieldContext.tsx b/packages/ra-core/src/controller/field/ReferenceFieldContext.tsx
index 6e87f4c9ec8..0176d1a5a45 100644
--- a/packages/ra-core/src/controller/field/ReferenceFieldContext.tsx
+++ b/packages/ra-core/src/controller/field/ReferenceFieldContext.tsx
@@ -1,9 +1,9 @@
import { createContext, useContext } from 'react';
import type { UseReferenceFieldControllerResult } from './useReferenceFieldController';
-export const ReferenceFieldContext = createContext<
- UseReferenceFieldControllerResult
->(null);
+export const ReferenceFieldContext = createContext(
+ null
+);
export const ReferenceFieldContextProvider = ReferenceFieldContext.Provider;
diff --git a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts
index 624d1de6072..8a3d33ca96c 100644
--- a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts
+++ b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts
@@ -26,7 +26,6 @@ export interface UseReferenceArrayFieldControllerParams<
const emptyArray = [];
const defaultFilter = {};
-const defaultSort = { field: null, order: null };
/**
* Hook that fetches records from another resource specified
@@ -66,7 +65,7 @@ export const useReferenceArrayFieldController = <
perPage = 1000,
record,
reference,
- sort = defaultSort,
+ sort,
source,
queryOptions = {},
} = props;
@@ -126,7 +125,7 @@ export const useReferenceArrayFieldController = <
return {
...listProps,
- defaultTitle: null,
+ defaultTitle: undefined,
refetch,
resource: reference,
};
diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts
index 66395fb0703..34a39731f75 100644
--- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts
+++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts
@@ -78,7 +78,7 @@ export const useReferenceManyFieldController = <
record,
target,
filter = defaultFilter,
- source,
+ source = 'id',
page: initialPage,
perPage: initialPerPage,
sort: initialSort = { field: 'id', order: 'DESC' },
@@ -223,7 +223,7 @@ export const useReferenceManyFieldController = <
return {
sort,
data,
- defaultTitle: null,
+ defaultTitle: undefined,
displayedFilters,
error,
filterValues,
diff --git a/packages/ra-core/src/controller/input/useReferenceInputController.spec.tsx b/packages/ra-core/src/controller/input/useReferenceInputController.spec.tsx
index 57449d36adb..8ef7f60c826 100644
--- a/packages/ra-core/src/controller/input/useReferenceInputController.spec.tsx
+++ b/packages/ra-core/src/controller/input/useReferenceInputController.spec.tsx
@@ -170,7 +170,7 @@ describe('useReferenceInputController', () => {
);
await waitFor(() => {
- expect(children.mock.calls.length).toBeGreaterThanOrEqual(4);
+ expect(children.mock.calls.length).toBeGreaterThanOrEqual(3);
});
expect(children).toHaveBeenCalledWith(
expect.objectContaining({
diff --git a/packages/ra-core/src/controller/input/useReferenceInputController.ts b/packages/ra-core/src/controller/input/useReferenceInputController.ts
index 103cb6b5179..7f1fec4c2aa 100644
--- a/packages/ra-core/src/controller/input/useReferenceInputController.ts
+++ b/packages/ra-core/src/controller/input/useReferenceInputController.ts
@@ -132,13 +132,15 @@ export const useReferenceInputController = (
// We need to delay the update of the referenceRecord and the finalData
// to the next React state update, because otherwise it can raise a warning
// with AutocompleteInput saying the current value is not in the list of choices
- const [referenceRecord, setReferenceRecord] = useState(null);
+ const [referenceRecord, setReferenceRecord] = useState<
+ RecordType | undefined
+ >(undefined);
useEffect(() => {
setReferenceRecord(currentReferenceRecord);
}, [currentReferenceRecord]);
// add current value to possible sources
- let finalData: RecordType[], finalTotal: number;
+ let finalData: RecordType[], finalTotal: number | undefined;
if (
!referenceRecord ||
possibleValuesData.find(record => record.id === referenceRecord.id)
@@ -166,7 +168,7 @@ export const useReferenceInputController = (
sort: currentSort,
allChoices: finalData,
availableChoices: possibleValuesData,
- selectedChoices: [referenceRecord],
+ selectedChoices: referenceRecord ? [referenceRecord] : [],
displayedFilters: params.displayedFilters,
error: errorReference || errorPossibleValues,
filter: params.filter,
diff --git a/packages/ra-core/src/controller/input/useReferenceParams.ts b/packages/ra-core/src/controller/input/useReferenceParams.ts
index 6213523b637..d31a0a220fc 100644
--- a/packages/ra-core/src/controller/input/useReferenceParams.ts
+++ b/packages/ra-core/src/controller/input/useReferenceParams.ts
@@ -92,10 +92,11 @@ export const useReferenceParams = ({
const changeParams = useCallback(action => {
if (!tempParams.current) {
// no other changeParams action dispatched this tick
- tempParams.current = queryReducer(query, action);
+ const newTempParams = queryReducer(query, action);
+ tempParams.current = newTempParams;
// schedule side effects for next tick
setTimeout(() => {
- setParams(tempParams.current);
+ setParams(newTempParams);
tempParams.current = undefined;
}, 0);
} else {
@@ -178,10 +179,10 @@ export const useReferenceParams = ({
}, requestSignature); // eslint-disable-line react-hooks/exhaustive-deps
return [
{
- displayedFilters: displayedFilterValues,
filterValues,
requestSignature,
...query,
+ displayedFilters: displayedFilterValues,
},
{
changeParams,
@@ -270,6 +271,9 @@ export const getNumberOrDefault = (
possibleNumber: string | number | undefined,
defaultValue: number
) => {
+ if (typeof possibleNumber === 'undefined') {
+ return defaultValue;
+ }
const parsedNumber =
typeof possibleNumber === 'string'
? parseInt(possibleNumber, 10)
diff --git a/packages/ra-core/src/controller/list/useListParams.ts b/packages/ra-core/src/controller/list/useListParams.ts
index 5d52e795d33..531501af25a 100644
--- a/packages/ra-core/src/controller/list/useListParams.ts
+++ b/packages/ra-core/src/controller/list/useListParams.ts
@@ -157,6 +157,10 @@ export const useListParams = ({
tempParams.current = queryReducer(query, action);
// schedule side effects for next tick
setTimeout(() => {
+ if (!tempParams.current) {
+ // the side effects were already processed by another changeParams
+ return;
+ }
if (disableSyncWithLocation) {
setLocalParams(tempParams.current);
} else {
@@ -262,10 +266,10 @@ export const useListParams = ({
return [
{
- displayedFilters: displayedFilterValues,
filterValues,
requestSignature,
...query,
+ displayedFilters: displayedFilterValues,
},
{
changeParams,
@@ -375,6 +379,9 @@ export const getNumberOrDefault = (
possibleNumber: string | number | undefined,
defaultValue: number
) => {
+ if (typeof possibleNumber === 'undefined') {
+ return defaultValue;
+ }
const parsedNumber =
typeof possibleNumber === 'string'
? parseInt(possibleNumber, 10)
diff --git a/packages/ra-core/src/controller/list/useRecordSelection.ts b/packages/ra-core/src/controller/list/useRecordSelection.ts
index 768411930a9..f986a854c0f 100644
--- a/packages/ra-core/src/controller/list/useRecordSelection.ts
+++ b/packages/ra-core/src/controller/list/useRecordSelection.ts
@@ -11,10 +11,21 @@ type UseRecordSelectionWithNoStoreArgs = {
resource?: string;
disableSyncWithStore: true;
};
-type UseRecordSelectionArgs =
+
+export type UseRecordSelectionArgs =
| UseRecordSelectionWithResourceArgs
| UseRecordSelectionWithNoStoreArgs;
+export type UseRecordSelectionResult = [
+ RecordType['id'][],
+ {
+ select: (ids: RecordType['id'][]) => void;
+ unselect: (ids: RecordType['id'][]) => void;
+ toggle: (id: RecordType['id']) => void;
+ clearSelection: () => void;
+ }
+];
+
/**
* Get the list of selected items for a resource, and callbacks to change the selection
*
@@ -25,23 +36,20 @@ type UseRecordSelectionArgs =
*/
export const useRecordSelection = (
args: UseRecordSelectionArgs
-): [
- RecordType['id'][],
- {
- select: (ids: RecordType['id'][]) => void;
- unselect: (ids: RecordType['id'][]) => void;
- toggle: (id: RecordType['id']) => void;
- clearSelection: () => void;
- }
-] => {
+): UseRecordSelectionResult => {
const { resource = '', disableSyncWithStore = false } = args;
const storeKey = `${resource}.selectedIds`;
- const [localIds, setLocalIds] = useState(defaultSelection);
+ const [localIds, setLocalIds] = useState(
+ defaultSelection
+ );
// As we can't conditionally call a hook, if the storeKey is false,
// we'll ignore the params variable later on and won't call setParams either.
- const [storeIds, setStoreIds] = useStore(storeKey, defaultSelection);
+ const [storeIds, setStoreIds] = useStore(
+ storeKey,
+ defaultSelection
+ );
const resetStore = useRemoveFromStore(storeKey);
const ids = disableSyncWithStore ? localIds : storeIds;
diff --git a/packages/ra-core/src/controller/list/useUnselect.ts b/packages/ra-core/src/controller/list/useUnselect.ts
index 4aafd6e1fb3..ce5742eaeba 100644
--- a/packages/ra-core/src/controller/list/useUnselect.ts
+++ b/packages/ra-core/src/controller/list/useUnselect.ts
@@ -11,8 +11,10 @@ import { Identifier } from '../../types';
* const unselect = useUnselect('posts');
* unselect([123, 456]);
*/
-export const useUnselect = (resource: string) => {
- const [, { unselect }] = useRecordSelection({ resource });
+export const useUnselect = (resource?: string) => {
+ const [, { unselect }] = useRecordSelection(
+ resource ? { resource } : { disableSyncWithStore: true }
+ );
return useCallback(
(ids: Identifier[]) => {
unselect(ids);
diff --git a/packages/ra-core/src/controller/list/useUnselectAll.ts b/packages/ra-core/src/controller/list/useUnselectAll.ts
index 6ae5aa7a761..d7dae4d76e8 100644
--- a/packages/ra-core/src/controller/list/useUnselectAll.ts
+++ b/packages/ra-core/src/controller/list/useUnselectAll.ts
@@ -10,10 +10,10 @@ import { useRecordSelection } from './useRecordSelection';
* const unselectAll = useUnselectAll('posts');
* unselectAll();
*/
-export const useUnselectAll = (resource: string) => {
- const [, { clearSelection }] = useRecordSelection({
- resource,
- });
+export const useUnselectAll = (resource?: string) => {
+ const [, { clearSelection }] = useRecordSelection(
+ resource ? { resource } : { disableSyncWithStore: true }
+ );
return useCallback(() => {
clearSelection();
}, [clearSelection]);
diff --git a/packages/ra-core/src/controller/saveContext/useRegisterMutationMiddleware.ts b/packages/ra-core/src/controller/saveContext/useRegisterMutationMiddleware.ts
index b1a8aec1882..4a08e3a10bf 100644
--- a/packages/ra-core/src/controller/saveContext/useRegisterMutationMiddleware.ts
+++ b/packages/ra-core/src/controller/saveContext/useRegisterMutationMiddleware.ts
@@ -17,6 +17,9 @@ export const useRegisterMutationMiddleware = <
} = useSaveContext();
useEffect(() => {
+ if (!registerMutationMiddleware || !unregisterMutationMiddleware) {
+ return;
+ }
registerMutationMiddleware(callback);
return () => {
unregisterMutationMiddleware(callback);
diff --git a/packages/ra-core/src/controller/useFilterState.ts b/packages/ra-core/src/controller/useFilterState.ts
index daa352c42c6..89498992a3b 100644
--- a/packages/ra-core/src/controller/useFilterState.ts
+++ b/packages/ra-core/src/controller/useFilterState.ts
@@ -21,10 +21,11 @@ interface UseFilterStateProps {
setFilter: (v: string) => void;
}
+const defaultFilter = {};
const defaultFilterToQuery = (v: string) => ({ q: v });
/**
- * Hooks to provide filter state and setFilter which update the query part of the filter
+ * Hooks to provide filter state and setFilter which updates the query part of the filter
*
* @example
*
@@ -59,7 +60,7 @@ export default ({
}: UseFilterStateOptions): UseFilterStateProps => {
const permanentFilterProp = useRef(permanentFilter);
const latestValue = useRef();
- const [filter, setFilterValue] = useSafeSetState({
+ const [filter, setFilterValue] = useSafeSetState({
...permanentFilter,
...filterToQuery(''),
});
@@ -76,7 +77,7 @@ export default ({
permanentFilterProp.current = permanentFilter;
setFilterValue({
...permanentFilter,
- ...filterToQuery(latestValue.current),
+ ...filterToQuery(latestValue.current || ''),
});
}
}, [permanentFilterSignature, permanentFilterProp, filterToQuery]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -94,7 +95,7 @@ export default ({
);
return {
- filter,
+ filter: filter ?? defaultFilter,
setFilter,
};
};
diff --git a/packages/ra-core/src/controller/usePrevNextController.ts b/packages/ra-core/src/controller/usePrevNextController.ts
index 6d42e0748ed..e2e6f2d43a5 100644
--- a/packages/ra-core/src/controller/usePrevNextController.ts
+++ b/packages/ra-core/src/controller/usePrevNextController.ts
@@ -129,7 +129,7 @@ export const usePrevNextController = (
);
}
- const [storedParams] = useStore>(
+ const [storedParams] = useStore(
storeKey || `${resource}.listParams`,
{
filter: filterDefaultValues,
@@ -137,6 +137,7 @@ export const usePrevNextController = (
sort: initialSort.field,
page: 1,
perPage: 10,
+ displayedFilters: {},
}
);
@@ -172,8 +173,10 @@ export const usePrevNextController = (
const isRecordIndexFirstInNonFirstPage =
recordIndexInQueryData === 0 && storedParams.page > 1;
const isRecordIndexLastInNonLastPage =
- recordIndexInQueryData === queryData?.data?.length - 1 &&
- storedParams.page < queryData?.total / storedParams.perPage;
+ queryData?.data && queryData?.total
+ ? recordIndexInQueryData === queryData?.data?.length - 1 &&
+ storedParams.page < queryData?.total / storedParams.perPage
+ : undefined;
const canUseCacheData =
record &&
queryData?.data &&
diff --git a/packages/ra-core/src/core/CoreAdminContext.tsx b/packages/ra-core/src/core/CoreAdminContext.tsx
index 1fd7ca4edb3..28bd896d60a 100644
--- a/packages/ra-core/src/core/CoreAdminContext.tsx
+++ b/packages/ra-core/src/core/CoreAdminContext.tsx
@@ -3,7 +3,11 @@ import { useMemo } from 'react';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { AdminRouter } from '../routing';
-import { AuthContext, convertLegacyAuthProvider } from '../auth';
+import {
+ AuthContext,
+ defaultAuthProvider,
+ convertLegacyAuthProvider,
+} from '../auth';
import {
DataProviderContext,
convertLegacyDataProvider,
@@ -187,9 +191,11 @@ React-admin requires a valid dataProvider function to work.`);
const finalAuthProvider = useMemo(
() =>
- authProvider instanceof Function
- ? convertLegacyAuthProvider(authProvider)
- : authProvider,
+ authProvider
+ ? authProvider instanceof Function
+ ? convertLegacyAuthProvider(authProvider)
+ : authProvider
+ : defaultAuthProvider,
[authProvider]
);
diff --git a/packages/ra-core/src/core/CoreAdminRoutes.tsx b/packages/ra-core/src/core/CoreAdminRoutes.tsx
index c2bd3b923eb..65db8e83a56 100644
--- a/packages/ra-core/src/core/CoreAdminRoutes.tsx
+++ b/packages/ra-core/src/core/CoreAdminRoutes.tsx
@@ -48,6 +48,11 @@ export const CoreAdminRoutes = (props: CoreAdminRoutesProps) => {
}, [checkAuth, requireAuth]);
if (status === 'empty') {
+ if (!Ready) {
+ throw new Error(
+ 'The admin is empty. Please provide an empty component, or pass Resource or CustomRoutes as children.'
+ );
+ }
return ;
}
diff --git a/packages/ra-core/src/core/OptionalResourceContextProvider.tsx b/packages/ra-core/src/core/OptionalResourceContextProvider.tsx
new file mode 100644
index 00000000000..3ea65ae0cfd
--- /dev/null
+++ b/packages/ra-core/src/core/OptionalResourceContextProvider.tsx
@@ -0,0 +1,34 @@
+import * as React from 'react';
+import { ReactElement } from 'react';
+import { ResourceContextValue } from './ResourceContext';
+import { ResourceContextProvider } from './ResourceContextProvider';
+
+/**
+ * Wrap children with a ResourceContext provider only if the value is defined.
+ *
+ * Allows a component to work outside of a resource context.
+ *
+ * @example
+ *
+ * import { OptionalResourceContextProvider, EditButton } from 'react-admin';
+ *
+ * const Button = ({ resource }) => (
+ *
+ *
+ *
+ * );
+ */
+export const OptionalResourceContextProvider = ({
+ value,
+ children,
+}: {
+ value?: ResourceContextValue;
+ children: ReactElement;
+}) =>
+ value ? (
+
+ {children}
+
+ ) : (
+ children
+ );
diff --git a/packages/ra-core/src/core/index.ts b/packages/ra-core/src/core/index.ts
index 5d1221dd656..b4aa0310beb 100644
--- a/packages/ra-core/src/core/index.ts
+++ b/packages/ra-core/src/core/index.ts
@@ -5,6 +5,7 @@ export * from './CoreAdminUI';
export * from './CustomRoutes';
export * from './DefaultTitleContext';
export * from './HasDashboardContext';
+export * from './OptionalResourceContextProvider';
export * from './Resource';
export * from './ResourceContext';
export * from './ResourceContextProvider';
diff --git a/packages/ra-core/src/core/useConfigureAdminRouterFromChildren.tsx b/packages/ra-core/src/core/useConfigureAdminRouterFromChildren.tsx
index 993b089ce72..34b26dd327d 100644
--- a/packages/ra-core/src/core/useConfigureAdminRouterFromChildren.tsx
+++ b/packages/ra-core/src/core/useConfigureAdminRouterFromChildren.tsx
@@ -91,6 +91,9 @@ const useRoutesAndResourcesFromChildren = (
...routesAndResources,
})
);
+ if (!status) {
+ throw new Error('Status should be defined');
+ }
useEffect(() => {
const resolveChildFunction = async (
@@ -196,7 +199,7 @@ const useRoutesAndResourcesState = (
* @param permissions: The permissions
*/
const useRegisterResources = (
- resources: (ReactElement & ResourceWithRegisterFunction)[],
+ resources: (ReactElement & ResourceWithRegisterFunction)[],
permissions: any
) => {
const { register, unregister } = useResourceDefinitionContext();
@@ -246,10 +249,10 @@ const getStatus = ({
customRoutesWithoutLayout,
}: {
children: AdminChildren;
- resources: ReactElement[];
- customRoutesWithLayout: ReactElement[];
- customRoutesWithoutLayout: ReactElement[];
-}) => {
+ resources: ReactNode[];
+ customRoutesWithLayout: ReactNode[];
+ customRoutesWithoutLayout: ReactNode[];
+}): AdminRouterStatus => {
return getSingleChildFunction(children)
? 'loading'
: resources.length > 0 ||
@@ -295,9 +298,9 @@ const getSingleChildFunction = (
const getRoutesAndResourceFromNodes = (
children: AdminChildren
): RoutesAndResources => {
- const customRoutesWithLayout = [];
- const customRoutesWithoutLayout = [];
- const resources = [];
+ const customRoutesWithLayout: ReactNode[] = [];
+ const customRoutesWithoutLayout: ReactNode[] = [];
+ const resources: (ReactElement & ResourceWithRegisterFunction)[] = [];
if (typeof children === 'function') {
return {
@@ -339,7 +342,9 @@ const getRoutesAndResourceFromNodes = (
customRoutesWithLayout.push(customRoutesElement.props.children);
}
} else if ((element.type as any).raName === 'Resource') {
- resources.push(element as ReactElement);
+ resources.push(
+ element as ReactElement & ResourceWithRegisterFunction
+ );
}
});
@@ -351,9 +356,9 @@ const getRoutesAndResourceFromNodes = (
};
type RoutesAndResources = {
- customRoutesWithLayout: ReactElement[];
- customRoutesWithoutLayout: ReactElement[];
- resources: (ReactElement & ResourceWithRegisterFunction)[];
+ customRoutesWithLayout: ReactNode[];
+ customRoutesWithoutLayout: ReactNode[];
+ resources: (ReactElement & ResourceWithRegisterFunction)[];
};
type ResourceWithRegisterFunction = {
diff --git a/packages/ra-core/src/core/useGetRecordRepresentation.ts b/packages/ra-core/src/core/useGetRecordRepresentation.ts
index ffdb5662f72..59a251c8f9b 100644
--- a/packages/ra-core/src/core/useGetRecordRepresentation.ts
+++ b/packages/ra-core/src/core/useGetRecordRepresentation.ts
@@ -16,7 +16,7 @@ import { useResourceDefinition } from './useResourceDefinition';
* getRecordRepresentation({ id: 1, title: 'Hello' }); // => "Hello"
*/
export const useGetRecordRepresentation = (
- resource: string
+ resource?: string
): ((record: any) => ReactNode) => {
const { recordRepresentation } = useResourceDefinition({ resource });
return useCallback(
diff --git a/packages/ra-core/src/form/FormDataConsumer.tsx b/packages/ra-core/src/form/FormDataConsumer.tsx
index 4a2b6126f29..05550e03af0 100644
--- a/packages/ra-core/src/form/FormDataConsumer.tsx
+++ b/packages/ra-core/src/form/FormDataConsumer.tsx
@@ -67,7 +67,8 @@ export const FormDataConsumerView = <
const { children, formData, source } = props;
let ret;
- const finalSource = useWrappedSource(source);
+ const finalSource = useWrappedSource(source || '');
+
// Passes an empty string here as we don't have the children sources and we just want to know if we are in an iterator
const matches = ArraySourceRegex.exec(finalSource);
diff --git a/packages/ra-core/src/form/FormGroupContext.ts b/packages/ra-core/src/form/FormGroupContext.ts
index 104b08ecfa5..d7f1d46e1d5 100644
--- a/packages/ra-core/src/form/FormGroupContext.ts
+++ b/packages/ra-core/src/form/FormGroupContext.ts
@@ -7,6 +7,8 @@ import { createContext } from 'react';
*
* This should only be used through a FormGroupContextProvider.
*/
-export const FormGroupContext = createContext(undefined);
+export const FormGroupContext = createContext(
+ null
+);
export type FormGroupContextValue = string;
diff --git a/packages/ra-core/src/form/choices/useChoicesContext.ts b/packages/ra-core/src/form/choices/useChoicesContext.ts
index 517fef7250d..a4cc8ea7851 100644
--- a/packages/ra-core/src/form/choices/useChoicesContext.ts
+++ b/packages/ra-core/src/form/choices/useChoicesContext.ts
@@ -11,9 +11,9 @@ export const useChoicesContext = (
>;
const { data, ...list } = useList({
data: options.choices,
- isLoading: options.isLoading,
- isPending: options.isPending,
- isFetching: options.isFetching,
+ isLoading: options.isLoading ?? false,
+ isPending: options.isPending ?? false,
+ isFetching: options.isFetching ?? false,
// When not in a ChoicesContext, paginating does not make sense (e.g. AutocompleteInput).
perPage: Infinity,
});
@@ -33,9 +33,9 @@ export const useChoicesContext = (
hasPreviousPage:
options.hasPreviousPage ?? list.hasPreviousPage,
hideFilter: options.hideFilter ?? list.hideFilter,
- isLoading: list.isLoading, // we must take the one for useList, otherwise the loading state isn't synchronized with the data
- isPending: list.isPending, // same
- isFetching: list.isFetching, // same
+ isLoading: list.isLoading ?? false, // we must take the one for useList, otherwise the loading state isn't synchronized with the data
+ isPending: list.isPending ?? false, // same
+ isFetching: list.isFetching ?? false, // same
page: options.page ?? list.page,
perPage: options.perPage ?? list.perPage,
refetch: options.refetch ?? list.refetch,
diff --git a/packages/ra-core/src/form/useFormGroup.ts b/packages/ra-core/src/form/useFormGroup.ts
index d0de0bbcb27..2d6fbba3a04 100644
--- a/packages/ra-core/src/form/useFormGroup.ts
+++ b/packages/ra-core/src/form/useFormGroup.ts
@@ -73,6 +73,7 @@ export const useFormGroup = (name: string): FormGroupState => {
});
const updateGroupState = useCallback(() => {
+ if (!formGroups) return;
const fields = formGroups.getGroupFields(name);
const fieldStates = fields
.map(field => {
@@ -109,11 +110,13 @@ export const useFormGroup = (name: string): FormGroupState => {
);
useEffect(() => {
+ if (!formGroups) return;
// Whenever the group content changes (input are added or removed)
// we must update its state
- return formGroups.subscribe(name, () => {
+ const unsubscribe = formGroups.subscribe(name, () => {
updateGroupState();
});
+ return unsubscribe;
}, [formGroups, name, updateGroupState]);
return state;
diff --git a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx
index 35fbae21679..32ab655a702 100644
--- a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx
+++ b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx
@@ -63,9 +63,9 @@ export const useWarnWhenUnsavedChanges = (
translate('ra.message.unsaved_changes')
);
if (shouldProceed) {
- blocker.proceed();
+ blocker.proceed && blocker.proceed();
} else {
- blocker.reset();
+ blocker.reset && blocker.reset();
}
}
setShouldNotify(false);
diff --git a/packages/ra-core/src/preferences/PreferencesEditorContextProvider.tsx b/packages/ra-core/src/preferences/PreferencesEditorContextProvider.tsx
index b9c0220441b..4118b15d49f 100644
--- a/packages/ra-core/src/preferences/PreferencesEditorContextProvider.tsx
+++ b/packages/ra-core/src/preferences/PreferencesEditorContextProvider.tsx
@@ -7,10 +7,10 @@ import {
export const PreferencesEditorContextProvider = ({ children }) => {
const [isEnabled, setIsEnabled] = useState(false);
- const [editor, setEditor] = useState(null);
+ const [editor, setEditor] = useState(null);
const [preferenceKey, setPreferenceKey] = useState();
- const [path, setPath] = useState(null);
- const [title, setTitleString] = useState(null);
+ const [path, setPath] = useState(null);
+ const [title, setTitleString] = useState(null);
const [titleOptions, setTitleOptions] = useState();
const enable = useCallback(() => setIsEnabled(true), []);
const disable = useCallback(() => {
diff --git a/packages/ra-core/src/preferences/useSetInspectorTitle.ts b/packages/ra-core/src/preferences/useSetInspectorTitle.ts
index b5e6dec6208..ce990db8397 100644
--- a/packages/ra-core/src/preferences/useSetInspectorTitle.ts
+++ b/packages/ra-core/src/preferences/useSetInspectorTitle.ts
@@ -8,7 +8,13 @@ import { usePreferencesEditor } from './usePreferencesEditor';
* useSetInspectorTitle('Datagrid');
*/
export const useSetInspectorTitle = (title: string, options?: any) => {
- const { setTitle } = usePreferencesEditor();
+ const preferencesEditorContext = usePreferencesEditor();
+ if (!preferencesEditorContext) {
+ throw new Error(
+ 'useSetInspectorTitle cannot be called outside of a PreferencesEditorContext'
+ );
+ }
+ const { setTitle } = preferencesEditorContext;
useEffect(() => {
setTitle(title, options);
diff --git a/packages/ra-core/src/routing/useCreatePath.ts b/packages/ra-core/src/routing/useCreatePath.ts
index ab7ff9b1283..a7668e87bf9 100644
--- a/packages/ra-core/src/routing/useCreatePath.ts
+++ b/packages/ra-core/src/routing/useCreatePath.ts
@@ -41,6 +41,14 @@ export const useCreatePath = () => {
const basename = useBasename();
return useCallback(
({ resource, id, type }: CreatePathParams): string => {
+ if (
+ ['list', 'create', 'edit', 'show'].includes(type) &&
+ !resource
+ ) {
+ throw new Error(
+ 'Cannot create a link without a resource. You must provide the resource name.'
+ );
+ }
switch (type) {
case 'list':
return removeDoubleSlashes(`${basename}/${resource}`);
@@ -81,7 +89,7 @@ export type CreatePathType = 'list' | 'edit' | 'show' | 'create' | AnyString;
export interface CreatePathParams {
type: CreatePathType;
- resource: string;
+ resource?: string;
id?: Identifier;
}
diff --git a/packages/ra-core/tsconfig.json b/packages/ra-core/tsconfig.json
index 6fb08aeb372..d4c2c252ddb 100644
--- a/packages/ra-core/tsconfig.json
+++ b/packages/ra-core/tsconfig.json
@@ -3,7 +3,8 @@
"compilerOptions": {
"outDir": "lib",
"rootDir": "src",
- "allowJs": false
+ "allowJs": false,
+ "strictNullChecks": true
},
"exclude": [
"**/*.spec.ts",
diff --git a/packages/ra-ui-materialui/src/field/ReferenceArrayField.spec.tsx b/packages/ra-ui-materialui/src/field/ReferenceArrayField.spec.tsx
index 6c77958af63..dfb11201309 100644
--- a/packages/ra-ui-materialui/src/field/ReferenceArrayField.spec.tsx
+++ b/packages/ra-ui-materialui/src/field/ReferenceArrayField.spec.tsx
@@ -70,7 +70,7 @@ describe(' ', () => {
record={{ id: 123, barIds: [1, 2] }}
reference="bar"
>
-
+
@@ -99,7 +99,7 @@ describe(' ', () => {
record={{ id: 123, barIds: [1, 2] }}
reference="bar"
>
-
+
@@ -130,7 +130,7 @@ describe(' ', () => {
reference="bar"
source="barIds"
>
-
+
@@ -165,7 +165,7 @@ describe(' ', () => {
reference="bar"
source="barIds"
>
-
+
@@ -201,7 +201,7 @@ describe(' ', () => {
reference="bar"
source="barIds"
>
-
+
diff --git a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx
index 9c296ced984..c0447a57104 100644
--- a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx
+++ b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx
@@ -16,6 +16,7 @@ import {
sanitizeListRestProps,
useListContextWithProps,
Identifier,
+ OptionalResourceContextProvider,
RaRecord,
SortPayload,
} from 'ra-core';
@@ -229,60 +230,62 @@ export const Datagrid: FC = React.forwardRef((props, ref) => {
*/
return (
-
- {bulkActionButtons !== false ? (
-
- {isValidElement(bulkActionButtons)
- ? bulkActionButtons
- : defaultBulkActionButtons}
-
- ) : null}
-
-
- {createOrCloneElement(
- header,
- {
- children,
- sort,
- data,
- hasExpand: !!expand,
- hasBulkActions,
- isRowSelectable,
- onSelect,
- resource,
- selectedIds,
- setSort,
- },
- children
- )}
- {createOrCloneElement(
- body,
- {
- expand,
- rowClick,
- data,
- hasBulkActions,
- hover,
- onToggleItem: handleToggleItem,
- resource,
- rowSx,
- rowStyle,
- selectedIds,
- isRowSelectable,
- },
- children
- )}
-
-
-
+
+
+ {bulkActionButtons !== false ? (
+
+ {isValidElement(bulkActionButtons)
+ ? bulkActionButtons
+ : defaultBulkActionButtons}
+
+ ) : null}
+
+
+ {createOrCloneElement(
+ header,
+ {
+ children,
+ sort,
+ data,
+ hasExpand: !!expand,
+ hasBulkActions,
+ isRowSelectable,
+ onSelect,
+ resource,
+ selectedIds,
+ setSort,
+ },
+ children
+ )}
+ {createOrCloneElement(
+ body,
+ {
+ expand,
+ rowClick,
+ data,
+ hasBulkActions,
+ hover,
+ onToggleItem: handleToggleItem,
+ resource,
+ rowSx,
+ rowStyle,
+ selectedIds,
+ isRowSelectable,
+ },
+ children
+ )}
+
+
+
+
);
});