-
Notifications
You must be signed in to change notification settings - Fork 55
Add ReferenceOneField component
#79
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ac6ad77
a2ddda8
d9857fc
b817303
382b7b7
fd73c40
7a3400b
c91c9cc
a5b1eb1
d313305
00d3e9f
731a444
2d73b88
5fb7b89
dfaea48
73c57b2
845e17b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 │ | ||
| └──────────────┘ | ||
| ``` | ||
|
|
||
| `<ReferenceOneField>` behaves like `<ReferenceManyField>`: 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. | ||
|
|
||
| `<ReferenceOneField>` 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 (`<TextField>`, `<SimpleShowLayout>`, etc.) as child. | ||
|
|
||
| For the inverse relationships (the book linked to a book_detail), you can use a [`<ReferenceField>`](./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 = () => ( | ||
| <Show> | ||
| <SimpleShowLayout> | ||
| <TextField source="title" /> | ||
| <DateField source="published_at" /> | ||
| <ReferenceField source="authorId" reference="authors" /> | ||
| <ReferenceOneField reference="book_details" target="book_id"> | ||
| <RecordField label="Genre" source="genre" /> | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why use UPDATE: reading the whole PR and notably the stories, I realize you may be confused about what component is responsible to render the label.
I hope it makes sense. Feel free to ask further questions if still not clear. |
||
| </ReferenceOneField> | ||
| <ReferenceOneField reference="book_details" target="book_id"> | ||
| <RecordField label="ISBN" source="ISBN" /> | ||
| </ReferenceOneField> | ||
| </SimpleShowLayout> | ||
| </Show> | ||
| ); | ||
| ``` | ||
|
|
||
| **Tip**: As with `<ReferenceField>`, you can call `<ReferenceOneField>` 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` | `<span>` 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 | | ||
|
Comment on lines
+51
to
+64
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems you forgot about the |
||
|
|
||
| ## Record Representation | ||
|
|
||
| [See `ReferenceField`](./ReferenceField.md#record-representation) | ||
|
|
||
| ## Tips | ||
|
|
||
| [See `ReferenceField`](./ReferenceField.md#tips) | ||
|
Comment on lines
+66
to
+72
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you copy these sections instead of referencing them? |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(<Basic />); | ||
| await expect.element(screen.getByText(EXPECTED_WORKOUT_NOTE)).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('should allow to render the referenceRecord using a render prop', async () => { | ||
| const screen = render(<WithRenderProp />); | ||
| await expect.element(screen.getByText(EXPECTED_WORKOUT_NOTE)).toBeInTheDocument(); | ||
| }); | ||
|
Comment on lines
+8
to
+16
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you also add a test to cover the case where |
||
|
|
||
| it('should show empty string', async () => { | ||
| const screen = render(<EmptyWithString />); | ||
| await expect.element(screen.getByText('This workout does not exists')).toBeInTheDocument(); | ||
| }) | ||
|
|
||
| it('should translate emptyText', async () => { | ||
| const screen = render(<EmptyWithTranslate />); | ||
| await expect.element(screen.getByText('Workout not found')).toBeInTheDocument(); | ||
| }) | ||
|
|
||
| it('should render the offline prop node when offline', async () => { | ||
| const screen = render(<Offline />); | ||
|
|
||
| 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() | ||
| }) | ||
|
|
||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLAnchorElement>) => | ||
| 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<RecordType>; | ||
|
|
||
| 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<RecordType> | ||
| ) => { | ||
| const { loading, error, empty, render, ...rest } = props; | ||
| const id = useFieldValue<RecordType>(props); | ||
| const translate = useTranslate(); | ||
|
|
||
| return id == null ? ( | ||
| renderEmpty(translate, empty) | ||
| ) : ( | ||
| <ReferenceOneFieldBase {...rest}> | ||
| <ReferenceOneFieldView | ||
| render={render} | ||
| loading={loading} | ||
| error={error} | ||
| empty={empty} | ||
| {...rest} | ||
| /> | ||
| </ReferenceOneFieldBase> | ||
| ); | ||
| }; | ||
|
|
||
| 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 || <span>{getRecordRepresentation(referenceRecord)}</span>; | ||
|
|
||
| if (link) { | ||
| return ( | ||
| <div className={className}> | ||
| <Link to={link} onClick={stopPropagation}> | ||
| {child} | ||
| </Link> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return <>{child}</>; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please sort by alphabetical order