diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 098276e..e76bafe 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -84,6 +84,7 @@ export default defineConfig({ "recordfield", "referencearrayfield", "referencefield", + "referenceonefield", "referencemanycount", "referencemanyfield", enterpriseEntry("ReferenceManyToManyFieldBase"), diff --git a/docs/src/content/docs/ReferenceOneField.md b/docs/src/content/docs/ReferenceOneField.md new file mode 100644 index 0000000..3334b1a --- /dev/null +++ b/docs/src/content/docs/ReferenceOneField.md @@ -0,0 +1,72 @@ +--- +title: "ReferenceOneField" +--- + +This field fetches a one-to-one relationship, e.g. the details of a book, when using a foreign key on the distant resource. + +``` +┌──────────────┐ ┌──────────────┐ +│ books │ │ book_details │ +│--------------│ │--------------│ +│ id │───┐ │ id │ +│ title │ └──╼│ book_id │ +│ published_at │ │ genre │ +└──────────────┘ │ ISBN │ + └──────────────┘ +``` + +`` behaves like ``: it uses the current `record` (a book in this example) to build a filter for the book details with the foreign key (`book_id`). Then, it uses `dataProvider.getManyReference('book_details', { target: 'book_id', id: book.id })` to fetch the related details, and takes the first one. + +`` renders the [`recordRepresentation`](https://marmelab.com/ra-core/resource/#recordrepresentation) of the related record. It also creates a `RecordContext` with the reference record, so you can use any component relying on this context (``, ``, etc.) as child. + +For the inverse relationships (the book linked to a book_detail), you can use a [``](./ReferenceField.md). + +## Usage + +Here is how to render a field of the `book_details` resource inside e Show view for the `books` resource: + + +```jsx +const BookShow = () => ( + + + + + + + + + + + + + +); +``` + +**Tip**: As with ``, you can call `` as many times as you need in the same component, react-admin will only make one call to `dataProvider.getManyReference()` per reference. + +## Props + +| Prop | Required | Type | Default | Description | +|------|----------|------|---------|-------------| +| `source` | Required | `string` | - | Foreign key in current record | +| `reference` | Required | `string` | - | Target resource name | +| `target` | Required | `string` | - | Target field carrying the relationship on the referenced resource, e.g. `book_id` | +| `children` | Optional | `ReactNode` | `` representation | Custom child (can use context hooks) | +| `empty` | Optional | `ReactNode` | - | Placeholder when no id / value | +| `error` | Optional | `ReactNode` | - | Error element (set `false` to hide) | +| `loading` | Optional | `ReactNode` | - | Element while loading (set `false` to hide) | +| `queryOptions` | Optional | `UseQueryOptions` | - | TanStack Query options (meta, staleTime, etc.) | +| `record` | Optional | `object` | Context record | Explicit record | +| `render` | Optional | `(ctx)=>ReactNode` | - | Custom renderer receiving reference field context | +| `link` | Optional | `LinkToType` | `edit` | Link target or false / function | +| `offline` | Optional | `ReactNode` | - | The text or element to display when there is no network connectivity | + +## Record Representation + +[See `ReferenceField`](./ReferenceField.md#record-representation) + +## Tips + +[See `ReferenceField`](./ReferenceField.md#tips) \ No newline at end of file diff --git a/src/components/admin/reference-one-field.spec.tsx b/src/components/admin/reference-one-field.spec.tsx new file mode 100644 index 0000000..816da89 --- /dev/null +++ b/src/components/admin/reference-one-field.spec.tsx @@ -0,0 +1,47 @@ +import { Basic, EmptyWithString, EmptyWithTranslate, Offline, WithRenderProp } from "@/stories/reference-one-field.stories"; +import { describe, expect, it } from "vitest"; +import { render } from "vitest-browser-react"; + +const EXPECTED_WORKOUT_NOTE = 'Not very hard. Bit tired after!'; + +describe('ReferenceOneField', () => { + it('should render its child in the context of the related record', async () => { + const screen = render(); + await expect.element(screen.getByText(EXPECTED_WORKOUT_NOTE)).toBeInTheDocument(); + }); + + it('should allow to render the referenceRecord using a render prop', async () => { + const screen = render(); + await expect.element(screen.getByText(EXPECTED_WORKOUT_NOTE)).toBeInTheDocument(); + }); + + it('should show empty string', async () => { + const screen = render(); + await expect.element(screen.getByText('This workout does not exists')).toBeInTheDocument(); + }) + + it('should translate emptyText', async () => { + const screen = render(); + await expect.element(screen.getByText('Workout not found')).toBeInTheDocument(); + }) + + it('should render the offline prop node when offline', async () => { + const screen = render(); + + const toggleOfflineButton = await screen.getByText('Simulate offline'); + await toggleOfflineButton.click(); + await expect.element(screen.getByText('You are currently offline')).toBeInTheDocument(); + + const toggleReferenceOneFieldVisibilityButton = await screen.getByText('Toggle ReferenceOneField visibility'); + await toggleReferenceOneFieldVisibilityButton.click(); + + await expect.element(screen.getByText('You are offline, cannot load data!')).toBeInTheDocument(); + + const toggleOnlineButton = await screen.getByText('Simulate online'); + await toggleOnlineButton.click(); + await expect.element(screen.getByText('You are currently online')).toBeInTheDocument(); + + await expect.element(screen.getByText(EXPECTED_WORKOUT_NOTE)).toBeInTheDocument() + }) + +}) \ No newline at end of file diff --git a/src/components/admin/reference-one-field.tsx b/src/components/admin/reference-one-field.tsx new file mode 100644 index 0000000..14814d1 --- /dev/null +++ b/src/components/admin/reference-one-field.tsx @@ -0,0 +1,122 @@ +import { + ExtractRecordPaths, + LinkToType, + RaRecord, + ReferenceOneFieldBase, + SortPayload, + TranslateFunction, + type UseReferenceFieldControllerResult, + UseReferenceOneFieldControllerParams, + useFieldValue, + useGetRecordRepresentation, + useReferenceFieldContext, + useTranslate, +} from "ra-core"; +import { MouseEvent, ReactNode } from "react"; +import { Link } from "react-router"; + +// useful to prevent click bubbling in a datagrid with rowClick +const stopPropagation = (e: MouseEvent) => + e.stopPropagation(); + +// might be usefull for other components +const renderEmpty = (translate: TranslateFunction, empty: ReferenceOneFieldProps['empty']) => { + return typeof empty === "string" ? ( + <>{empty && translate(empty, { _: empty })} + ) : ( + empty + ); +} + +export interface ReferenceOneFieldProps< + RecordType extends RaRecord = RaRecord, +> extends ReferenceOneFieldViewProps { + source: ExtractRecordPaths; + + queryOptions?: UseReferenceOneFieldControllerParams['queryOptions'] + record?: RecordType; + sort?: SortPayload; + link?: LinkToType; + offline?: ReactNode; +} + +export interface ReferenceOneFieldViewProps { + reference: string; + source: string; + target: string; + + children?: ReactNode; + className?: string; + empty?: ReactNode; + loading?: ReactNode; + render?: (props: UseReferenceFieldControllerResult) => ReactNode; + error?: ReactNode; +} + +export const ReferenceOneField = < + RecordType extends RaRecord = RaRecord, +>( + props: ReferenceOneFieldProps +) => { + const { loading, error, empty, render, ...rest } = props; + const id = useFieldValue(props); + const translate = useTranslate(); + + return id == null ? ( + renderEmpty(translate, empty) + ) : ( + + + + ); +}; + +export const ReferenceOneFieldView = ( + props: ReferenceOneFieldViewProps +) => { + const { + children, + className, + empty, + error: errorElement, + render, + reference, + loading, + } = props; + const referenceFieldContext = useReferenceFieldContext(); + const { error, link, isPending, referenceRecord } = referenceFieldContext; + const getRecordRepresentation = useGetRecordRepresentation(reference); + const translate = useTranslate(); + + if (error && errorElement !== false) { + return errorElement; + } + if (isPending && loading !== false) { + return loading; + } + if (!referenceRecord && empty !== false) { + return renderEmpty(translate, empty); + } + + const child = render + ? render(referenceFieldContext) + : children || {getRecordRepresentation(referenceRecord)}; + + if (link) { + return ( +
+ + {child} + +
+ ); + } + + return <>{child}; +}; \ No newline at end of file diff --git a/src/stories/reference-one-field.stories.tsx b/src/stories/reference-one-field.stories.tsx new file mode 100644 index 0000000..f72258a --- /dev/null +++ b/src/stories/reference-one-field.stories.tsx @@ -0,0 +1,215 @@ +import { CoreAdminContext, I18nContextProvider, RecordContextProvider, ResourceContextProvider, TestMemoryRouter, useIsOffline } from "ra-core"; +import fakeRestProvider from "ra-data-fakerest"; +import polyglotI18nProvider from "ra-i18n-polyglot"; +import { TextField, ThemeProvider } from "@/components/admin"; +import { RecordField } from "@/components/admin/record-field"; +import { ReferenceOneField } from "@/components/admin/reference-one-field"; +import englishMessages from "ra-language-english"; +import React from "react"; +import { onlineManager } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; + +export default { + title: "Fields/ReferenceOneField", + parameters: { + docs: { + // 👇 Enable Code panel for all stories in this file + codePanel: true, + }, + }, +}; + +const i18nProvider = polyglotI18nProvider( + () => ({ + ...englishMessages, + resources: { + workouts: { + name: "Workouts", + not_found: 'Workout not found' + } + } + }), + 'en' +); + +const fakeData = { + workoutDetails: [ + { + id: 1, + workout_id: 'LegDay01', + duration: 120, + name: 'Leg Day', + note: 'Not very hard. Bit tired after!' + }, + ], +}; + +const dataProvider = fakeRestProvider(fakeData, true); + +const slowDataProvider = { + getManyReference: () => new Promise(resolve => { + setTimeout(() => { + resolve({ + data: [ + fakeData.workoutDetails[0] + ], + total: 1 + }); + }, 2000); + }) +}; + +const emptyDataProvider = { + getManyReference: () => new Promise(resolve => { + resolve({ + data: [], + total: 0 + }); + }) +}; + +const Wrapper = ({ + children, + dataProvider, +}: { + children: React.ReactNode; + dataProvider: any; +}) => ( + + + + + + {children} + + + + + ) + + +export const Basic = () => ( + + + + + +); + +export const Loading = () => ( + + + + + +) + +export const WithRenderProp = () => ( + + { + if (isPending) { + return

Loading...

; + } + if (error) { + return

{error.toString()}

+ } + return ({referenceRecord ? referenceRecord.note : No note.}) + }} /> +
+) + +export const InShowLayout = () => ( + +
+ + + + + +
+
+) + +export const EmptyWithString = () => ( + + + + + + + +) + +export const EmptyWithTranslate = () => ( + + + + + + + +) +const RenderChildOnDemand = ({ children }: { children: React.ReactNode }) => { + const [showChild, setShowChild] = React.useState(false); + return ( + <> + + {showChild &&
{children}
} + + ); +}; + +const SimulateOfflineButton = () => { + const isOffline = useIsOffline(); + return ( + <> + + You are currently {isOffline ? 'offline' : 'online'} + + ); +}; + +export const Offline = () => ( + + +
+ + + You are offline, cannot load data! + + } + > + + + + +
+
+
+) \ No newline at end of file