Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export default defineConfig({
"referencefield",
"referencemanycount",
"referencemanyfield",
"referenceonefield",
enterpriseEntry("ReferenceManyToManyFieldBase"),
"selectfield",
"singlefieldlist",
Expand Down
121 changes: 121 additions & 0 deletions docs/src/content/docs/ReferenceOneField.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
---
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"
label="Genre"
>
<TextField source="genre" />
</ReferenceOneField>
<ReferenceOneField
reference="book_details"
target="book_id"
label="ISBN"
>
<TextField 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 |
| -------------- | -------- | ------------------ | ----------------------- | --------------------------------------------------------------------------------- |
| `reference` | Required | `string` | - | Target resource name |
| `source` | Required | `string` | - | Foreign key in current record |
| `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) |
| `filter` | Optional | `object` | - | Filters to apply to the query |
| `link` | Optional | `LinkToType` | `edit` | Link target or false / function |
| `loading` | Optional | `ReactNode` | - | Element while loading (set `false` to hide) |
| `offline` | Optional | `ReactNode` | - | The text or element to display when there is no network connectivity |
| `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 |
| `sort` | Optional | `SortPayload` | - | Sort order to apply to the query |

## Record Representation

By default, `<ReferenceOneField>` renders the [`recordRepresentation`](https://marmelab.com/ra-core/resource/#recordrepresentation) of the related record.

So it's a good idea to configure the `<Resource recordRepresentation>` to render related records in a meaningful way. For instance, for the `book_details` resource, if you want the `<ReferenceOneField>` to display the genre:

```jsx
<Resource
name="book_details"
list={BookDetailsList}
recordRepresentation={(record) => record.genre}
/>
```

If you pass a child component, `<ReferenceOneField>` will render it instead of the `recordRepresentation`. Usual child components for `<ReferenceOneField>` are other `<Field>` components (e.g. [`<TextField>`](./TextField.md)).

```jsx
<ReferenceOneField reference="book_details" target="book_id">
<TextField source="genre" />
</ReferenceOneField>
```

Alternatively to `children`, pass a `render` prop. It will receive the `ReferenceFieldContext` as its argument, and should return a React node.

This allows to inline the render logic for the related record.

```jsx
<ReferenceOneField
reference="book_details"
target="book_id"
render={({ error, isPending, referenceRecord }) => {
if (isPending) {
return <p>Loading...</p>;
}
if (error) {
return <p className="error">{error.message}</p>;
}
return <p>{referenceRecord.genre}</p>;
}}
/>
```

## Tips

- Use `link={false}` to disable navigation.
- `<ReferenceOneField>` uses `dataProvider.getManyReference()` to fetch the related record. When using several `<ReferenceOneField>` in the same page, this allows to call the `dataProvider` once instead of once per field.
- If you need to display multiple fields from the same related record, you can pass multiple child components to `<ReferenceOneField>`. However, for a better layout, you may want to use `label={false}` on the field and render each child field with its own label.
77 changes: 77 additions & 0 deletions src/components/admin/reference-one-field.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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();
});

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();
});

it("should render children when passed", async () => {
const screen = render(<Basic />);
await expect
.element(screen.getByText(EXPECTED_WORKOUT_NOTE))
.toBeInTheDocument();
});
});
122 changes: 122 additions & 0 deletions src/components/admin/reference-one-field.tsx
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}</>;
};
Loading