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 @@ -84,6 +84,7 @@ export default defineConfig({
"recordfield",
"referencearrayfield",
"referencefield",
"referenceonefield",
Copy link
Collaborator

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

"referencemanycount",
"referencemanyfield",
enterpriseEntry("ReferenceManyToManyFieldBase"),
Expand Down
72 changes: 72 additions & 0 deletions docs/src/content/docs/ReferenceOneField.md
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" />
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use RecordField and not, say, TextField here? 🤔
This is contradictory to what this doc says at line 20.

UPDATE: reading the whole PR and notably the stories, I realize you may be confused about what component is responsible to render the label.

  • RecordField should be used only for cases where you are not a direct children of a component responsible of rendering the labels, like SimpleShowLayout for instance.
  • Using ReferenceOneField as direct child of a SimpleShowLayout should render a label on top of the field, derived either from the source or from the label prop if one is provided.
  • Usually, ReferenceOneField will be used to render a single child, like a TextField, that's why it doesn't need to render its child component's label too.
  • However if you choose to render several children fields in a ReferenceOneField, then yes it makes sense to use RecordField here. And you will need to hide the label of the ReferenceOneField by either not rendering it in a SimpleShowLayout or by passing label={false} to it.

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems you forgot about the filter and sort props. Also, can you order the Optional props alphabetically?


## Record Representation

[See `ReferenceField`](./ReferenceField.md#record-representation)

## Tips

[See `ReferenceField`](./ReferenceField.md#tips)
Comment on lines +66 to +72
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you copy these sections instead of referencing them?

47 changes: 47 additions & 0 deletions src/components/admin/reference-one-field.spec.tsx
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also add a test to cover the case where children are passed?


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

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