@proteinjs/db-ui 1.11.3 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
+ import React from 'react';
1
2
  import { Loadable, SourceRepository } from '@proteinjs/reflection';
2
3
  import { FormButtons } from '@proteinjs/ui';
3
- import { Table } from '@proteinjs/db';
4
+ import { Column, Record, Table } from '@proteinjs/db';
4
5
 
5
6
  export const getRecordFormCustomizations = () =>
6
7
  SourceRepository.get().objects<RecordFormCustomization>('@proteinjs/db-ui/RecordFormCustomization');
@@ -14,6 +15,28 @@ export const getRecordFormCustomization = (tableName: string) => {
14
15
  }
15
16
  };
16
17
 
18
+ /**
19
+ * What a custom field component receives. The component OWNS its field: it presents `value`
20
+ * (read from the loaded record) and performs any edit through a service of its own, then calls
21
+ * `reload` so the slot shows the stored truth. The form's save payload never carries a
22
+ * custom-rendered field.
23
+ */
24
+ export type RecordFormFieldProps<T extends Record = any, V = any> = {
25
+ table: Table<T>;
26
+ column: Column<T, any>;
27
+ /** The column's property name on the record (the field's key in the form layout). */
28
+ fieldName: string;
29
+ /** The humanized label the default control would have carried. */
30
+ label: string;
31
+ /** The loaded record. Renderers are only consulted for existing records, so this is never undefined. */
32
+ record: T;
33
+ value: V;
34
+ /** Re-read the record through the db service and re-render the form from it. */
35
+ reload: () => Promise<void>;
36
+ };
37
+
38
+ export type RecordFormFieldRenderer<T extends Record = any> = React.ComponentType<RecordFormFieldProps<T>>;
39
+
17
40
  export abstract class RecordFormCustomization implements Loadable {
18
41
  abstract table: Table<any>;
19
42
 
@@ -29,4 +52,17 @@ export abstract class RecordFormCustomization implements Loadable {
29
52
  getFieldLayout(record: any, defaultFieldLayout: string[] | string[][]): string[] | string[][] {
30
53
  return defaultFieldLayout;
31
54
  }
55
+
56
+ /**
57
+ * Take over a field's slot with a component of your own (see `RecordFormFieldProps` for what it
58
+ * receives and owns). Declaring a renderer also surfaces a column the default form hides (ie. an
59
+ * `ArrayColumn`), so service-owned state like `user.roles` can be presented and acted on from the
60
+ * record form without the form's generic controls pretending to edit it.
61
+ *
62
+ * Consulted only for existing records: a record that doesn't exist yet has no stored state to
63
+ * present or service to write through, so the new-record form renders its default controls.
64
+ */
65
+ getFieldRenderer(fieldName: string, record: any): RecordFormFieldRenderer | undefined {
66
+ return undefined;
67
+ }
32
68
  }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ *
4
+ * Custom field components on the record form (`RecordFormCustomization.getFieldRenderer`).
5
+ *
6
+ * A customization can take over a field's slot with its own component. The component OWNS the
7
+ * field: it presents the stored value and routes edits through a service of its own, then calls
8
+ * `reload` so the slot shows the stored truth. Consequences pinned here:
9
+ * - the component renders in the field's slot instead of the default control, and declaring a
10
+ * renderer surfaces a column the default form hides (ArrayColumn is ui.hidden by default —
11
+ * the reason user.roles never appeared on the user form);
12
+ * - the form's save payload never carries a custom-rendered field (its writes are the
13
+ * component's, through its service — a form save that echoed the value back would either be
14
+ * refused as a protected-column write or clobber the service's state);
15
+ * - `reload` re-reads the record through the db service and re-renders the slot;
16
+ * - renderers are not consulted on the new-record form: a record that doesn't exist yet has no
17
+ * stored state to present or service to write through.
18
+ */
19
+ import React from 'react';
20
+ import { createRoot, Root } from 'react-dom/client';
21
+ import { act } from 'react-dom/test-utils';
22
+ import { MemoryRouter } from 'react-router-dom';
23
+ import { ArrayColumn, Record, StringColumn, Table, withRecordColumns } from '@proteinjs/db';
24
+ import '../generated';
25
+ import { RecordForm } from '../src/form/RecordForm';
26
+ import {
27
+ RecordFormCustomization,
28
+ RecordFormFieldProps,
29
+ RecordFormFieldRenderer,
30
+ } from '../src/form/RecordFormCustomization';
31
+
32
+ interface Doc extends Record {
33
+ title: string;
34
+ labels: string[];
35
+ }
36
+
37
+ class DocTable extends Table<Doc> {
38
+ public name = 'field_renderer_test_doc';
39
+ public columns = withRecordColumns<Doc>({
40
+ title: new StringColumn('title'),
41
+ // ArrayColumn is ui.hidden by default; only the renderer surfaces it
42
+ labels: new ArrayColumn<string>('labels'),
43
+ });
44
+ }
45
+
46
+ /** The stored row the (fake) labels service writes and the db service reads back. */
47
+ let stored: Doc;
48
+
49
+ const mockLabelsService = {
50
+ addLabel: jest.fn(async (docId: string, label: string) => {
51
+ stored = { ...stored, labels: [...stored.labels, label] };
52
+ }),
53
+ };
54
+
55
+ const mockDbService = {
56
+ get: jest.fn(async (table: any, query: any) => stored),
57
+ insert: jest.fn(async (table: any, record: any) => record),
58
+ update: jest.fn(async (table: any, record: any) => record),
59
+ delete: jest.fn(async () => 1),
60
+ };
61
+
62
+ jest.mock('@proteinjs/db', () => ({
63
+ ...jest.requireActual('@proteinjs/db'),
64
+ getDbService: () => mockDbService,
65
+ }));
66
+
67
+ function LabelsField({ record, value, reload, label }: RecordFormFieldProps<Doc, string[]>) {
68
+ return (
69
+ <div data-field-renderer='labels'>
70
+ <span>{label}</span>
71
+ {value.map((item) => (
72
+ <span key={item} data-label-chip>
73
+ {item}
74
+ </span>
75
+ ))}
76
+ <button
77
+ type='button'
78
+ onClick={async () => {
79
+ await mockLabelsService.addLabel(record.id, 'c');
80
+ await reload();
81
+ }}
82
+ >
83
+ Add label
84
+ </button>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ class DocRecordFormCustomization extends RecordFormCustomization {
90
+ public table = new DocTable();
91
+
92
+ getFieldRenderer(fieldName: string, record: Doc): RecordFormFieldRenderer<Doc> | undefined {
93
+ return fieldName === 'labels' ? LabelsField : undefined;
94
+ }
95
+ }
96
+
97
+ // Stands in for the SourceRepository registration a real customization gets from reflection-build.
98
+ jest.mock('../src/form/RecordFormCustomization', () => ({
99
+ ...jest.requireActual('../src/form/RecordFormCustomization'),
100
+ getRecordFormCustomization: (tableName: string) =>
101
+ tableName === 'field_renderer_test_doc' ? new DocRecordFormCustomization() : undefined,
102
+ }));
103
+
104
+ declare global {
105
+ // eslint-disable-next-line no-var
106
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
107
+ }
108
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
109
+
110
+ describe('RecordForm custom field renderers', () => {
111
+ let container: HTMLDivElement;
112
+ let root: Root;
113
+
114
+ beforeEach(() => {
115
+ jest.clearAllMocks();
116
+ stored = { id: 'doc-1', title: 'Doc', labels: ['a', 'b'] } as Doc;
117
+ container = document.createElement('div');
118
+ document.body.appendChild(container);
119
+ root = createRoot(container);
120
+ });
121
+
122
+ afterEach(() => {
123
+ act(() => {
124
+ root.unmount();
125
+ });
126
+ container.remove();
127
+ });
128
+
129
+ const mount = async (record?: Doc) => {
130
+ await act(async () => {
131
+ root.render(
132
+ <MemoryRouter>
133
+ <RecordForm table={new DocTable()} record={record} />
134
+ </MemoryRouter>
135
+ );
136
+ });
137
+ await act(async () => {
138
+ await Promise.resolve();
139
+ });
140
+ };
141
+
142
+ const findButton = (name: string) => {
143
+ const button = Array.from(document.querySelectorAll('button')).find((b) => b.textContent === name);
144
+ if (!button) {
145
+ throw new Error(`Button not rendered: ${name}`);
146
+ }
147
+
148
+ return button;
149
+ };
150
+
151
+ const click = async (element: Element) => {
152
+ await act(async () => {
153
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
154
+ });
155
+ await act(async () => {
156
+ await Promise.resolve();
157
+ });
158
+ };
159
+
160
+ const chips = () => Array.from(document.querySelectorAll('[data-label-chip]')).map((chip) => chip.textContent);
161
+ const labelsInput = () => Array.from(document.querySelectorAll('label')).find((l) => l.textContent === 'Labels');
162
+
163
+ it('renders the customization component in the field slot, surfacing a column the default form hides', async () => {
164
+ await mount({ ...stored });
165
+
166
+ expect(document.querySelector('[data-field-renderer="labels"]')).not.toBeNull();
167
+ expect(chips()).toEqual(['a', 'b']);
168
+ // The default control is replaced, not doubled: no text input for the field
169
+ expect(labelsInput()).toBeUndefined();
170
+ // Default controls still render for the other columns
171
+ expect(Array.from(document.querySelectorAll('label')).some((l) => l.textContent?.startsWith('Title'))).toBe(true);
172
+ });
173
+
174
+ it('never carries a custom-rendered field in the save payload', async () => {
175
+ await mount({ ...stored });
176
+
177
+ await click(findButton('Save'));
178
+
179
+ expect(mockDbService.update).toHaveBeenCalledTimes(1);
180
+ const payload = mockDbService.update.mock.calls[0][1];
181
+ expect(payload.title).toBe('Doc');
182
+ expect('labels' in payload).toBe(false);
183
+ });
184
+
185
+ it('reload re-reads the record so the slot shows the stored truth after a service write', async () => {
186
+ await mount({ ...stored });
187
+
188
+ await click(findButton('Add label'));
189
+
190
+ expect(mockLabelsService.addLabel).toHaveBeenCalledWith('doc-1', 'c');
191
+ expect(mockDbService.get).toHaveBeenCalledTimes(1);
192
+ expect(mockDbService.get.mock.calls[0][1]).toEqual({ id: 'doc-1' });
193
+ expect(chips()).toEqual(['a', 'b', 'c']);
194
+ });
195
+
196
+ it('does not consult renderers on the new-record form; hidden columns stay hidden there', async () => {
197
+ await mount(undefined);
198
+
199
+ expect(document.querySelector('[data-field-renderer="labels"]')).toBeNull();
200
+ expect(labelsInput()).toBeUndefined();
201
+ });
202
+ });