proje-react-panel 1.7.0 → 1.9.0
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.
- package/.vscode/settings.json +1 -1
- package/README.md +43 -3
- package/dist/__tests__/components/form/CustomField.test.d.ts +4 -0
- package/dist/__tests__/components/form/FormGroups.test.d.ts +1 -0
- package/dist/__tests__/components/form/RichTextField.test.d.ts +4 -0
- package/dist/__tests__/optionalTiptap.test.d.ts +4 -0
- package/dist/components/form/CustomField.d.ts +9 -0
- package/dist/components/form/RichTextField.d.ts +7 -0
- package/dist/decorators/form/Form.d.ts +8 -0
- package/dist/decorators/form/Input.d.ts +5 -1
- package/dist/decorators/form/inputs/CustomInput.d.ts +16 -0
- package/dist/decorators/form/inputs/RichTextInput.d.ts +13 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.esm.js +2 -2
- package/{DASHBOARD_GUIDE.md → guides/DASHBOARD_GUIDE.md} +1 -1
- package/guides/USAGE.md +85 -0
- package/how_to.md +302 -0
- package/jest.config.js +1 -0
- package/package.json +17 -1
- package/src/__tests__/components/form/CustomField.test.tsx +92 -0
- package/src/__tests__/components/form/FormGroups.test.tsx +194 -0
- package/src/__tests__/components/form/RichTextField.test.tsx +181 -0
- package/src/__tests__/optionalTiptap.test.tsx +89 -0
- package/src/components/form/CustomField.tsx +34 -0
- package/src/components/form/FormField.tsx +13 -2
- package/src/components/form/InnerForm.tsx +101 -19
- package/src/components/form/RichTextField.tsx +218 -0
- package/src/components/layout/Layout.tsx +18 -2
- package/src/components/list/ListPage.tsx +1 -1
- package/src/decorators/form/Form.ts +11 -0
- package/src/decorators/form/Input.ts +6 -1
- package/src/decorators/form/inputs/CustomInput.ts +26 -0
- package/src/decorators/form/inputs/RichTextInput.ts +38 -0
- package/src/index.ts +13 -1
- package/src/styles/components/rich-text.scss +115 -0
- package/src/styles/form.scss +48 -0
- package/src/styles/index.scss +1 -0
- package/dist/components/DashboardContainer.d.ts +0 -7
- package/dist/components/DashboardGrid.d.ts +0 -9
- package/dist/components/DashboardItem.d.ts +0 -10
- package/dist/components/ThemeSwitcher.d.ts +0 -7
- package/dist/store/themeStore.d.ts +0 -23
- /package/{AUTH_LAYOUT_EXAMPLE.md → guides/AUTH_LAYOUT_EXAMPLE.md} +0 -0
- /package/{AUTH_LAYOUT_GUIDE.md → guides/AUTH_LAYOUT_GUIDE.md} +0 -0
- /package/{COLOR_SYSTEM_GUIDE.md → guides/COLOR_SYSTEM_GUIDE.md} +0 -0
- /package/{IMPLEMENTATION_GUIDE.md → guides/IMPLEMENTATION_GUIDE.md} +0 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { TextDecoder, TextEncoder } from 'util';
|
|
5
|
+
// react-router reads TextEncoder at import time and jsdom has none — set it up before the
|
|
6
|
+
// imports below pull the router in.
|
|
7
|
+
Object.assign(globalThis, { TextEncoder, TextDecoder });
|
|
8
|
+
|
|
9
|
+
import 'reflect-metadata';
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { describe, expect, it } from '@jest/globals';
|
|
12
|
+
import { render } from '@testing-library/react';
|
|
13
|
+
import { MemoryRouter } from 'react-router';
|
|
14
|
+
import { FormProvider, useForm } from 'react-hook-form';
|
|
15
|
+
import { Input, getInputFields } from '../../../decorators/form/Input';
|
|
16
|
+
import { Form, getFormConfiguration } from '../../../decorators/form/Form';
|
|
17
|
+
import { InnerForm } from '../../../components/form/InnerForm';
|
|
18
|
+
import { AnyClass } from '../../../types/AnyClass';
|
|
19
|
+
|
|
20
|
+
const onSubmit = async () => ({});
|
|
21
|
+
|
|
22
|
+
@Form({ onSubmit })
|
|
23
|
+
class UngroupedForm {
|
|
24
|
+
@Input({ label: 'Name' })
|
|
25
|
+
name: string;
|
|
26
|
+
|
|
27
|
+
@Input({ label: 'Slug' })
|
|
28
|
+
slug: string;
|
|
29
|
+
|
|
30
|
+
@Input({ type: 'textarea', label: 'Summary', rows: 6 })
|
|
31
|
+
summary: string;
|
|
32
|
+
|
|
33
|
+
@Input({ type: 'hidden' })
|
|
34
|
+
id: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Form({
|
|
38
|
+
onSubmit,
|
|
39
|
+
groups: [
|
|
40
|
+
{ key: 'content', label: 'Content' },
|
|
41
|
+
{ key: 'seo', label: 'SEO', collapsible: true },
|
|
42
|
+
{ key: 'advanced', label: 'Advanced', collapsible: true, defaultCollapsed: true },
|
|
43
|
+
// Declared but never used by a field — it must not produce an empty fieldset.
|
|
44
|
+
{ key: 'unused', label: 'Unused' },
|
|
45
|
+
],
|
|
46
|
+
})
|
|
47
|
+
class GroupedForm {
|
|
48
|
+
@Input({ label: 'Title', group: 'content' })
|
|
49
|
+
title: string;
|
|
50
|
+
|
|
51
|
+
@Input({ label: 'Meta title', group: 'seo' })
|
|
52
|
+
metaTitle: string;
|
|
53
|
+
|
|
54
|
+
@Input({ label: 'Body', group: 'content' })
|
|
55
|
+
body: string;
|
|
56
|
+
|
|
57
|
+
@Input({ label: 'Cache key', group: 'advanced' })
|
|
58
|
+
cacheKey: string;
|
|
59
|
+
|
|
60
|
+
// No group at all: stays outside every fieldset.
|
|
61
|
+
@Input({ label: 'Name' })
|
|
62
|
+
name: string;
|
|
63
|
+
|
|
64
|
+
// Hidden fields never belong to a section, even when they ask for one.
|
|
65
|
+
@Input({ type: 'hidden', group: 'content' })
|
|
66
|
+
id: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@Form({
|
|
70
|
+
onSubmit,
|
|
71
|
+
groups: [{ key: 'content', label: 'Content' }],
|
|
72
|
+
})
|
|
73
|
+
class UndeclaredGroupForm {
|
|
74
|
+
@Input({ label: 'Notes', group: 'internal' })
|
|
75
|
+
notes: string;
|
|
76
|
+
|
|
77
|
+
@Input({ label: 'Title', group: 'content' })
|
|
78
|
+
title: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderForm<T extends AnyClass>(model: new () => T) {
|
|
82
|
+
const inputs = getInputFields(model as never);
|
|
83
|
+
const formClass = getFormConfiguration(model as never);
|
|
84
|
+
|
|
85
|
+
function Harness() {
|
|
86
|
+
const form = useForm();
|
|
87
|
+
return (
|
|
88
|
+
<MemoryRouter>
|
|
89
|
+
<FormProvider {...form}>
|
|
90
|
+
<InnerForm inputs={inputs} formClass={formClass} />
|
|
91
|
+
</FormProvider>
|
|
92
|
+
</MemoryRouter>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return render(<Harness />);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const labelsOf = (root: ParentNode) =>
|
|
100
|
+
Array.from(root.querySelectorAll('.form-field .label span')).map(node => node.textContent);
|
|
101
|
+
|
|
102
|
+
describe('form field groups', () => {
|
|
103
|
+
it('leaves a form without groups exactly as it was', () => {
|
|
104
|
+
const { container } = renderForm(UngroupedForm);
|
|
105
|
+
|
|
106
|
+
expect(container.querySelectorAll('fieldset').length).toBe(0);
|
|
107
|
+
expect(container.querySelectorAll('details').length).toBe(0);
|
|
108
|
+
expect(labelsOf(container)).toEqual(['Name:', 'Slug:', 'Summary:']);
|
|
109
|
+
|
|
110
|
+
// Every field is still a direct child of the form's single wrapper div, in source order.
|
|
111
|
+
const fields = Array.from(container.querySelectorAll('.form-field'));
|
|
112
|
+
const wrapper = container.querySelector('form > div');
|
|
113
|
+
expect(fields.length).toBe(4);
|
|
114
|
+
fields.forEach(field => expect(field.parentElement).toBe(wrapper));
|
|
115
|
+
expect(fields[3].querySelector('input')?.getAttribute('type')).toBe('hidden');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('honours rows on a textarea', () => {
|
|
119
|
+
const { container } = renderForm(UngroupedForm);
|
|
120
|
+
|
|
121
|
+
expect(container.querySelector('textarea')?.getAttribute('rows')).toBe('6');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('puts each field in the fieldset its group names', () => {
|
|
125
|
+
const { container } = renderForm(GroupedForm);
|
|
126
|
+
|
|
127
|
+
expect(labelsOf(container.querySelector('fieldset[data-group="content"]')!)).toEqual([
|
|
128
|
+
'Title:',
|
|
129
|
+
'Body:',
|
|
130
|
+
]);
|
|
131
|
+
expect(labelsOf(container.querySelector('fieldset[data-group="seo"]')!)).toEqual([
|
|
132
|
+
'Meta title:',
|
|
133
|
+
]);
|
|
134
|
+
expect(labelsOf(container.querySelector('fieldset[data-group="advanced"]')!)).toEqual([
|
|
135
|
+
'Cache key:',
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('orders the fieldsets like @Form({ groups }) does and skips the empty ones', () => {
|
|
140
|
+
const { container } = renderForm(GroupedForm);
|
|
141
|
+
|
|
142
|
+
const groups = Array.from(container.querySelectorAll('fieldset')).map(fieldset =>
|
|
143
|
+
fieldset.getAttribute('data-group')
|
|
144
|
+
);
|
|
145
|
+
expect(groups).toEqual(['content', 'seo', 'advanced']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('labels a non-collapsible group with a legend', () => {
|
|
149
|
+
const { container } = renderForm(GroupedForm);
|
|
150
|
+
|
|
151
|
+
const content = container.querySelector('fieldset[data-group="content"]')!;
|
|
152
|
+
expect(content.querySelector('legend')?.textContent).toBe('Content');
|
|
153
|
+
expect(content.querySelector('details')).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('renders collapsible groups as details/summary and honours defaultCollapsed', () => {
|
|
157
|
+
const { container } = renderForm(GroupedForm);
|
|
158
|
+
|
|
159
|
+
const seo = container.querySelector('fieldset[data-group="seo"] details') as HTMLDetailsElement;
|
|
160
|
+
const advanced = container.querySelector(
|
|
161
|
+
'fieldset[data-group="advanced"] details'
|
|
162
|
+
) as HTMLDetailsElement;
|
|
163
|
+
|
|
164
|
+
expect(seo.querySelector('summary')?.textContent).toBe('SEO');
|
|
165
|
+
expect(seo.open).toBe(true);
|
|
166
|
+
expect(advanced.open).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('keeps ungrouped and hidden fields outside every fieldset', () => {
|
|
170
|
+
const { container } = renderForm(GroupedForm);
|
|
171
|
+
|
|
172
|
+
const wrapper = container.querySelector('form > div')!;
|
|
173
|
+
const directFields = Array.from(wrapper.children).filter(child =>
|
|
174
|
+
child.classList.contains('form-field')
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(directFields.length).toBe(2);
|
|
178
|
+
expect(directFields[0].querySelector('.label span')?.textContent).toBe('Name:');
|
|
179
|
+
expect(directFields[1].querySelector('input')?.getAttribute('type')).toBe('hidden');
|
|
180
|
+
expect(container.querySelector('fieldset input[type="hidden"]')).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('appends a group nobody declared at the end, labelled with its key', () => {
|
|
184
|
+
const { container } = renderForm(UndeclaredGroupForm);
|
|
185
|
+
|
|
186
|
+
const groups = Array.from(container.querySelectorAll('fieldset')).map(fieldset =>
|
|
187
|
+
fieldset.getAttribute('data-group')
|
|
188
|
+
);
|
|
189
|
+
expect(groups).toEqual(['content', 'internal']);
|
|
190
|
+
expect(container.querySelector('fieldset[data-group="internal"] legend')?.textContent).toBe(
|
|
191
|
+
'internal'
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import 'reflect-metadata';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { describe, expect, it, beforeEach, jest } from '@jest/globals';
|
|
7
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
8
|
+
import { FormProvider, useForm, UseFormReturn } from 'react-hook-form';
|
|
9
|
+
import { getInputFields } from '../../../decorators/form/Input';
|
|
10
|
+
import { RichTextInput } from '../../../decorators/form/inputs/RichTextInput';
|
|
11
|
+
import { FormField } from '../../../components/form/FormField';
|
|
12
|
+
|
|
13
|
+
// The real editor is kept — only its instance is captured, so the assertions below run against
|
|
14
|
+
// actual ProseMirror state (document, selection) instead of a stand-in.
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
|
+
let capturedEditor: any = null;
|
|
17
|
+
|
|
18
|
+
jest.mock('@tiptap/react', () => {
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
const actual = jest.requireActual('@tiptap/react') as any;
|
|
21
|
+
return {
|
|
22
|
+
...actual,
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
useEditor: (...args: any[]) => {
|
|
25
|
+
const editor = actual.useEditor(...args);
|
|
26
|
+
capturedEditor = editor;
|
|
27
|
+
return editor;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
class ArticleForm {
|
|
33
|
+
@RichTextInput({ label: 'Body', placeholder: 'Write something…' })
|
|
34
|
+
body: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class ToolbarForm {
|
|
38
|
+
@RichTextInput({ label: 'Body', toolbar: ['bold', 'italic'] })
|
|
39
|
+
body: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
43
|
+
let form: UseFormReturn<any>;
|
|
44
|
+
|
|
45
|
+
function Harness({
|
|
46
|
+
model = ArticleForm,
|
|
47
|
+
defaultValue = '',
|
|
48
|
+
}: {
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
+
model?: any;
|
|
51
|
+
defaultValue?: string;
|
|
52
|
+
}) {
|
|
53
|
+
form = useForm({ defaultValues: { body: defaultValue } });
|
|
54
|
+
const inputs = getInputFields(model);
|
|
55
|
+
return (
|
|
56
|
+
<FormProvider {...form}>
|
|
57
|
+
{inputs.map(input => (
|
|
58
|
+
<FormField key={input.name} input={input} register={form.register} />
|
|
59
|
+
))}
|
|
60
|
+
</FormProvider>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function renderEditor(props: Parameters<typeof Harness>[0] = {}) {
|
|
65
|
+
const utils = render(<Harness {...props} />);
|
|
66
|
+
// The editor is behind a lazy import (optional peer dependency), so it lands a tick later.
|
|
67
|
+
await screen.findByTestId('rich-text-body');
|
|
68
|
+
await waitFor(() => expect(capturedEditor).not.toBeNull());
|
|
69
|
+
return utils;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function typeIntoEditor(text: string) {
|
|
73
|
+
act(() => {
|
|
74
|
+
capturedEditor.commands.insertContent(text);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('RichTextInput', () => {
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
capturedEditor = null;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('marks the field as richtext in the metadata', () => {
|
|
84
|
+
const body = getInputFields(ArticleForm).find(input => input.name === 'body');
|
|
85
|
+
|
|
86
|
+
expect(body?.type).toBe('richtext');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('mounts with the HTML it is given as a value', async () => {
|
|
90
|
+
await renderEditor({ defaultValue: '<p>kayıtlı içerik</p>' });
|
|
91
|
+
|
|
92
|
+
expect(capturedEditor.getHTML()).toBe('<p>kayıtlı içerik</p>');
|
|
93
|
+
expect(screen.getByTestId('rich-text-body').textContent).toContain('kayıtlı içerik');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('shows the placeholder while the document is empty', async () => {
|
|
97
|
+
await renderEditor();
|
|
98
|
+
|
|
99
|
+
const content = screen.getByTestId('rich-text-body');
|
|
100
|
+
expect(content.className).toContain('is-empty');
|
|
101
|
+
expect(content.getAttribute('data-placeholder')).toBe('Write something…');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('writes the edited HTML into the form value', async () => {
|
|
105
|
+
await renderEditor();
|
|
106
|
+
|
|
107
|
+
typeIntoEditor('merhaba');
|
|
108
|
+
|
|
109
|
+
await waitFor(() => expect(form.getValues('body')).toBe('<p>merhaba</p>'));
|
|
110
|
+
expect(screen.getByTestId('rich-text-body').className).not.toContain('is-empty');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('reports an emptied editor as an empty value, not as <p></p>', async () => {
|
|
114
|
+
await renderEditor({ defaultValue: '<p>silinecek</p>' });
|
|
115
|
+
|
|
116
|
+
act(() => {
|
|
117
|
+
capturedEditor.commands.clearContent(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await waitFor(() => expect(form.getValues('body')).toBe(''));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('syncs content that arrives after mount (getDetailsData)', async () => {
|
|
124
|
+
await renderEditor();
|
|
125
|
+
|
|
126
|
+
act(() => {
|
|
127
|
+
form.setValue('body', '<p>sunucudan gelen</p>');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await waitFor(() => expect(capturedEditor.getHTML()).toBe('<p>sunucudan gelen</p>'));
|
|
131
|
+
expect(screen.getByTestId('rich-text-body').textContent).toContain('sunucudan gelen');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('leaves the caret alone while typing in the middle of the text', async () => {
|
|
135
|
+
await renderEditor({ defaultValue: '<p>AB</p>' });
|
|
136
|
+
|
|
137
|
+
// Typing at the very end would hide a caret reset, since a reset lands there too.
|
|
138
|
+
act(() => {
|
|
139
|
+
capturedEditor.commands.setTextSelection(2);
|
|
140
|
+
});
|
|
141
|
+
typeIntoEditor('X');
|
|
142
|
+
const afterFirstKey = capturedEditor.state.selection.from;
|
|
143
|
+
typeIntoEditor('Y');
|
|
144
|
+
|
|
145
|
+
// Re-feeding the value into the editor on every keystroke would push the caret to the end
|
|
146
|
+
// of the document, so the second character would land after the B instead of before it.
|
|
147
|
+
expect(afterFirstKey).toBe(3);
|
|
148
|
+
expect(capturedEditor.state.selection.from).toBe(4);
|
|
149
|
+
expect(capturedEditor.getText()).toBe('AXYB');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('renders only the toolbar buttons the decorator asks for', async () => {
|
|
153
|
+
await renderEditor({ model: ToolbarForm });
|
|
154
|
+
|
|
155
|
+
const buttons = document.querySelectorAll('.rich-text-toolbar-button');
|
|
156
|
+
expect(Array.from(buttons).map(button => button.getAttribute('title'))).toEqual([
|
|
157
|
+
'Bold',
|
|
158
|
+
'Italic',
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('defaults to the full toolbar', async () => {
|
|
163
|
+
await renderEditor();
|
|
164
|
+
|
|
165
|
+
expect(document.querySelectorAll('.rich-text-toolbar-button').length).toBe(6);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('applies formatting from the toolbar to the form value', async () => {
|
|
169
|
+
await renderEditor();
|
|
170
|
+
|
|
171
|
+
typeIntoEditor('kalın');
|
|
172
|
+
act(() => {
|
|
173
|
+
capturedEditor.commands.selectAll();
|
|
174
|
+
});
|
|
175
|
+
act(() => {
|
|
176
|
+
(screen.getByTitle('Bold') as HTMLButtonElement).click();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await waitFor(() => expect(form.getValues('body')).toBe('<p><strong>kalın</strong></p>'));
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import 'reflect-metadata';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { TextDecoder, TextEncoder } from 'util';
|
|
7
|
+
import { beforeAll, describe, expect, it, jest } from '@jest/globals';
|
|
8
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
9
|
+
import { FormProvider, useForm } from 'react-hook-form';
|
|
10
|
+
import { Input, getInputFields } from '../decorators/form/Input';
|
|
11
|
+
import { RichTextInput } from '../decorators/form/inputs/RichTextInput';
|
|
12
|
+
import { FormField } from '../components/form/FormField';
|
|
13
|
+
|
|
14
|
+
// @tiptap/* is declared optional in peerDependenciesMeta, so a panel that never uses a richtext
|
|
15
|
+
// field must work with the packages absent. Both are made unresolvable here; the flags record
|
|
16
|
+
// whether anything reached for them at all.
|
|
17
|
+
let reactRequested = false;
|
|
18
|
+
let starterKitRequested = false;
|
|
19
|
+
|
|
20
|
+
jest.mock('@tiptap/react', () => {
|
|
21
|
+
reactRequested = true;
|
|
22
|
+
throw new Error("Cannot find module '@tiptap/react'");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
jest.mock('@tiptap/starter-kit', () => {
|
|
26
|
+
starterKitRequested = true;
|
|
27
|
+
throw new Error("Cannot find module '@tiptap/starter-kit'");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
class PlainForm {
|
|
31
|
+
@Input({ label: 'Name' })
|
|
32
|
+
name: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class ArticleForm {
|
|
36
|
+
@RichTextInput({ label: 'Body' })
|
|
37
|
+
body: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
function Harness({ model }: { model: any }) {
|
|
42
|
+
const form = useForm({ defaultValues: { name: '', body: '' } });
|
|
43
|
+
const inputs = getInputFields(model);
|
|
44
|
+
return (
|
|
45
|
+
<FormProvider {...form}>
|
|
46
|
+
{inputs.map(input => (
|
|
47
|
+
<FormField key={input.name} input={input} register={form.register} />
|
|
48
|
+
))}
|
|
49
|
+
</FormProvider>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('@tiptap as an optional peer dependency', () => {
|
|
54
|
+
beforeAll(() => {
|
|
55
|
+
// jsdom ships neither; react-router reads them at import time. Nothing to do with tiptap.
|
|
56
|
+
Object.assign(globalThis, { TextEncoder, TextDecoder });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('imports the whole library without @tiptap installed', async () => {
|
|
60
|
+
const library = await import('../index');
|
|
61
|
+
|
|
62
|
+
expect(typeof library.FormPage).toBe('function');
|
|
63
|
+
expect(typeof library.RichTextInput).toBe('function');
|
|
64
|
+
expect(typeof library.ListPage).toBe('function');
|
|
65
|
+
expect(reactRequested).toBe(false);
|
|
66
|
+
expect(starterKitRequested).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('renders the other field types without @tiptap installed', () => {
|
|
70
|
+
render(<Harness model={PlainForm} />);
|
|
71
|
+
|
|
72
|
+
expect(screen.getByText('Name:')).toBeTruthy();
|
|
73
|
+
expect(reactRequested).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('tells the developer what to install when a richtext field is actually used', async () => {
|
|
77
|
+
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
78
|
+
|
|
79
|
+
render(<Harness model={ArticleForm} />);
|
|
80
|
+
|
|
81
|
+
const message = await screen.findByRole('alert');
|
|
82
|
+
expect(message.textContent).toContain('@tiptap/react');
|
|
83
|
+
expect(message.textContent).toContain('@tiptap/starter-kit');
|
|
84
|
+
await waitFor(() => expect(reactRequested).toBe(true));
|
|
85
|
+
expect(consoleError).toHaveBeenCalled();
|
|
86
|
+
|
|
87
|
+
consoleError.mockRestore();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { InputConfiguration } from '../../decorators/form/Input';
|
|
3
|
+
import { useFormContext, Controller } from 'react-hook-form';
|
|
4
|
+
import { CustomInputConfiguration } from '../../decorators/form/inputs/CustomInput';
|
|
5
|
+
|
|
6
|
+
interface CustomFieldProps {
|
|
7
|
+
input: InputConfiguration;
|
|
8
|
+
fieldName: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function CustomField({ input, fieldName, error }: CustomFieldProps) {
|
|
13
|
+
const inputCustom = input as CustomInputConfiguration;
|
|
14
|
+
const { control } = useFormContext();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Controller
|
|
18
|
+
name={fieldName}
|
|
19
|
+
control={control}
|
|
20
|
+
render={({ field }) => {
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
{inputCustom.render({
|
|
24
|
+
fieldName,
|
|
25
|
+
value: field.value,
|
|
26
|
+
onChange: field.onChange,
|
|
27
|
+
error,
|
|
28
|
+
})}
|
|
29
|
+
</>
|
|
30
|
+
);
|
|
31
|
+
}}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -5,6 +5,10 @@ import { Uploader } from './Uploader';
|
|
|
5
5
|
import { Checkbox } from './Checkbox';
|
|
6
6
|
import { Label } from './Label';
|
|
7
7
|
import { Select } from './Select';
|
|
8
|
+
import { CustomField } from './CustomField';
|
|
9
|
+
//NOTE: safe to import statically — RichTextField pulls the optional @tiptap/* peers in itself,
|
|
10
|
+
// through a dynamic import, only once a richtext field is actually rendered.
|
|
11
|
+
import { RichTextField } from './RichTextField';
|
|
8
12
|
|
|
9
13
|
interface FormFieldProps {
|
|
10
14
|
input: InputConfiguration;
|
|
@@ -79,9 +83,15 @@ export function FormField({ input, register, error, baseName }: FormFieldProps)
|
|
|
79
83
|
const renderedField = useMemo(() => {
|
|
80
84
|
switch (input.type) {
|
|
81
85
|
case 'textarea':
|
|
82
|
-
return
|
|
86
|
+
return (
|
|
87
|
+
<textarea {...register(fieldName)} placeholder={input.placeholder} rows={input.rows} />
|
|
88
|
+
);
|
|
83
89
|
case 'select':
|
|
84
90
|
return <Select input={input} fieldName={fieldName} />;
|
|
91
|
+
case 'richtext':
|
|
92
|
+
return <RichTextField input={input} fieldName={fieldName} />;
|
|
93
|
+
case 'custom':
|
|
94
|
+
return <CustomField input={input} fieldName={fieldName} error={error?.message} />;
|
|
85
95
|
case 'input': {
|
|
86
96
|
return (
|
|
87
97
|
<input type={input.inputType} {...register(fieldName)} placeholder={input.placeholder} />
|
|
@@ -98,7 +108,8 @@ export function FormField({ input, register, error, baseName }: FormFieldProps)
|
|
|
98
108
|
default:
|
|
99
109
|
return null;
|
|
100
110
|
}
|
|
101
|
-
|
|
111
|
+
// NOTE: error message is a dependency because 'custom' forwards it into the render prop.
|
|
112
|
+
}, [input, register, fieldName, error?.message]);
|
|
102
113
|
|
|
103
114
|
return (
|
|
104
115
|
<div className={`form-field ${input.type === 'nested' ? 'nested-form-field' : ''}`}>
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import React, { useRef, useState } from 'react';
|
|
1
|
+
import React, { useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { useFormContext } from 'react-hook-form';
|
|
3
3
|
import { InputConfiguration } from '../../decorators/form/Input';
|
|
4
4
|
import { FormField } from './FormField';
|
|
5
5
|
import { useNavigate } from 'react-router';
|
|
6
|
-
import { FormConfiguration } from '../../decorators/form/Form';
|
|
6
|
+
import { FormConfiguration, FormGroup } from '../../decorators/form/Form';
|
|
7
7
|
import { AnyClass } from '../../types/AnyClass';
|
|
8
8
|
import { toast } from 'react-toastify';
|
|
9
9
|
|
|
@@ -12,6 +12,63 @@ interface InnerFormProps<T extends AnyClass> {
|
|
|
12
12
|
formClass: FormConfiguration<T>;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
interface GroupSection {
|
|
16
|
+
key: string;
|
|
17
|
+
label: string;
|
|
18
|
+
collapsible: boolean;
|
|
19
|
+
defaultCollapsed: boolean;
|
|
20
|
+
inputs: InputConfiguration[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Splits the inputs into the fields rendered straight into the form and the grouped sections.
|
|
25
|
+
* With no `group` on any field every input stays ungrouped, so the markup is byte-for-byte what
|
|
26
|
+
* it was before groups existed — no stray fieldset around existing panels' CSS.
|
|
27
|
+
*/
|
|
28
|
+
function splitIntoGroups(
|
|
29
|
+
inputs: InputConfiguration[],
|
|
30
|
+
groups?: FormGroup[]
|
|
31
|
+
): { ungrouped: InputConfiguration[]; sections: GroupSection[] } {
|
|
32
|
+
const ungrouped: InputConfiguration[] = [];
|
|
33
|
+
const inputsByGroup = new Map<string, InputConfiguration[]>();
|
|
34
|
+
const appearanceOrder: string[] = [];
|
|
35
|
+
|
|
36
|
+
inputs?.forEach(input => {
|
|
37
|
+
// Hidden fields carry no label and no layout; a fieldset around them would only add noise.
|
|
38
|
+
if (!input.group || input.type === 'hidden') {
|
|
39
|
+
ungrouped.push(input);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!inputsByGroup.has(input.group)) {
|
|
43
|
+
inputsByGroup.set(input.group, []);
|
|
44
|
+
appearanceOrder.push(input.group);
|
|
45
|
+
}
|
|
46
|
+
inputsByGroup.get(input.group)!.push(input);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const declared = groups ?? [];
|
|
50
|
+
const declaredKeys = declared.map(group => group.key);
|
|
51
|
+
const orderedKeys = [
|
|
52
|
+
...declaredKeys.filter(key => inputsByGroup.has(key)),
|
|
53
|
+
// A group nobody declared in @Form({ groups }) still renders — labelled with its own key,
|
|
54
|
+
// appended after the declared ones, in the order the fields introduced it.
|
|
55
|
+
...appearanceOrder.filter(key => !declaredKeys.includes(key)),
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const sections = orderedKeys.map(key => {
|
|
59
|
+
const declaration = declared.find(group => group.key === key);
|
|
60
|
+
return {
|
|
61
|
+
key,
|
|
62
|
+
label: declaration?.label ?? key,
|
|
63
|
+
collapsible: declaration?.collapsible ?? false,
|
|
64
|
+
defaultCollapsed: declaration?.defaultCollapsed ?? false,
|
|
65
|
+
inputs: inputsByGroup.get(key)!,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return { ungrouped, sections };
|
|
70
|
+
}
|
|
71
|
+
|
|
15
72
|
export function InnerForm<T extends AnyClass>({ inputs, formClass }: InnerFormProps<T>) {
|
|
16
73
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
17
74
|
//TODO: any is not a good solution, we need to find a better way to do this
|
|
@@ -19,6 +76,29 @@ export function InnerForm<T extends AnyClass>({ inputs, formClass }: InnerFormPr
|
|
|
19
76
|
const navigate = useNavigate();
|
|
20
77
|
const form = useFormContext<T>();
|
|
21
78
|
const loadingRef = useRef(false);
|
|
79
|
+
const { ungrouped, sections } = useMemo(
|
|
80
|
+
() => splitIntoGroups(inputs, formClass.groups),
|
|
81
|
+
[inputs, formClass.groups]
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const renderField = (input: InputConfiguration) => (
|
|
85
|
+
<FormField
|
|
86
|
+
key={input.name || ''}
|
|
87
|
+
input={input}
|
|
88
|
+
register={form.register}
|
|
89
|
+
error={
|
|
90
|
+
input.name
|
|
91
|
+
? {
|
|
92
|
+
message: (
|
|
93
|
+
form.formState.errors[input.name as keyof T] as {
|
|
94
|
+
message: string;
|
|
95
|
+
}
|
|
96
|
+
)?.message,
|
|
97
|
+
}
|
|
98
|
+
: undefined
|
|
99
|
+
}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
22
102
|
|
|
23
103
|
return (
|
|
24
104
|
<form
|
|
@@ -75,23 +155,25 @@ export function InnerForm<T extends AnyClass>({ inputs, formClass }: InnerFormPr
|
|
|
75
155
|
{errorMessage}
|
|
76
156
|
</div>
|
|
77
157
|
)}
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
158
|
+
{ungrouped.map(renderField)}
|
|
159
|
+
{sections.map(section => (
|
|
160
|
+
<fieldset
|
|
161
|
+
key={section.key}
|
|
162
|
+
data-group={section.key}
|
|
163
|
+
className={`form-group-section${section.collapsible ? ' form-group-section-collapsible' : ''}`}
|
|
164
|
+
>
|
|
165
|
+
{section.collapsible ? (
|
|
166
|
+
<details open={!section.defaultCollapsed}>
|
|
167
|
+
<summary className="form-group-summary">{section.label}</summary>
|
|
168
|
+
<div className="form-group-fields">{section.inputs.map(renderField)}</div>
|
|
169
|
+
</details>
|
|
170
|
+
) : (
|
|
171
|
+
<>
|
|
172
|
+
<legend className="form-group-legend">{section.label}</legend>
|
|
173
|
+
<div className="form-group-fields">{section.inputs.map(renderField)}</div>
|
|
174
|
+
</>
|
|
175
|
+
)}
|
|
176
|
+
</fieldset>
|
|
95
177
|
))}
|
|
96
178
|
<button type="submit" className="submit-button">
|
|
97
179
|
Submit
|