proje-react-panel 1.8.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.
Files changed (32) hide show
  1. package/README.md +1 -0
  2. package/dist/__tests__/components/form/FormGroups.test.d.ts +1 -0
  3. package/dist/__tests__/components/form/RichTextField.test.d.ts +4 -0
  4. package/dist/__tests__/optionalTiptap.test.d.ts +4 -0
  5. package/dist/components/form/RichTextField.d.ts +7 -0
  6. package/dist/decorators/form/Form.d.ts +8 -0
  7. package/dist/decorators/form/Input.d.ts +5 -1
  8. package/dist/decorators/form/inputs/RichTextInput.d.ts +13 -0
  9. package/dist/index.cjs.js +1 -1
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.esm.js +2 -2
  12. package/how_to.md +150 -32
  13. package/jest.config.js +1 -0
  14. package/package.json +13 -1
  15. package/src/__tests__/components/form/FormGroups.test.tsx +194 -0
  16. package/src/__tests__/components/form/RichTextField.test.tsx +181 -0
  17. package/src/__tests__/optionalTiptap.test.tsx +89 -0
  18. package/src/components/form/FormField.tsx +8 -1
  19. package/src/components/form/InnerForm.tsx +101 -19
  20. package/src/components/form/RichTextField.tsx +218 -0
  21. package/src/decorators/form/Form.ts +11 -0
  22. package/src/decorators/form/Input.ts +6 -1
  23. package/src/decorators/form/inputs/RichTextInput.ts +38 -0
  24. package/src/index.ts +8 -1
  25. package/src/styles/components/rich-text.scss +115 -0
  26. package/src/styles/form.scss +48 -0
  27. package/src/styles/index.scss +1 -0
  28. package/dist/components/DashboardContainer.d.ts +0 -7
  29. package/dist/components/DashboardGrid.d.ts +0 -9
  30. package/dist/components/DashboardItem.d.ts +0 -10
  31. package/dist/components/ThemeSwitcher.d.ts +0 -7
  32. package/dist/store/themeStore.d.ts +0 -23
package/how_to.md CHANGED
@@ -12,7 +12,7 @@ For deep walkthroughs see [`guides/IMPLEMENTATION_GUIDE.md`](./guides/IMPLEMENTA
12
12
  - `<Panel>`, `<Layout>`, `<Login>` — app shell, sidebar, and login page.
13
13
  - `<Dashboard>`, `<DashboardGrid>`, `<DashboardItem>`, `<Counter>` — dashboard widgets.
14
14
  - `initApi`, `setAuthToken`, `getAll`, `getOne`, `create`, `update`, `remove`, `createFormData`, `updateFormData` — backend wiring helpers.
15
- - Decorators: `@List`, `@Form`, `@Details`, `@Cell`, `@LinkCell`, `@ImageCell`, `@DownloadCell`, `@Input`, `@SelectInput`, `@DetailsItem`.
15
+ - Decorators: `@List`, `@Form`, `@Details`, `@Cell`, `@LinkCell`, `@ImageCell`, `@DownloadCell`, `@Input`, `@SelectInput`, `@RichTextInput`, `@CustomInput`, `@DetailsItem`.
16
16
 
17
17
  See the full export surface in [`src/index.ts`](./src/index.ts).
18
18
 
@@ -22,16 +22,16 @@ See the full export surface in [`src/index.ts`](./src/index.ts).
22
22
 
23
23
  The library is built against a plain REST backend with these conventions. Any backend that respects them will work — a compatible NestJS reference implementation is available as a sibling project; use it as a starting point if you don't have a backend yet.
24
24
 
25
- | Concern | Shape |
26
- | --- | --- |
27
- | Auth header | `Authorization: Bearer <jwt>` on every protected request |
28
- | Login | `POST /auth/login` → `{ access_token, admin: { id, email } }` |
29
- | List | `GET /:resource?page=<n>&limit=<n>` → `{ total: number, data: T[] }` |
30
- | Item | `GET /:resource/:id` → entity JSON (no envelope) |
31
- | Mutations | `POST /:resource`, `PUT /:resource/:id`, `DELETE /:resource/:id` |
32
- | File upload | `multipart/form-data`, field name `file` |
33
- | Errors | `{ statusCode, message, error }` (NestJS default works out of the box) |
34
- | 401 | The library auto-clears the token and redirects to `/login` |
25
+ | Concern | Shape |
26
+ | ----------- | ---------------------------------------------------------------------- |
27
+ | Auth header | `Authorization: Bearer <jwt>` on every protected request |
28
+ | Login | `POST /auth/login` → `{ access_token, admin: { id, email } }` |
29
+ | List | `GET /:resource?page=<n>&limit=<n>` → `{ total: number, data: T[] }` |
30
+ | Item | `GET /:resource/:id` → entity JSON (no envelope) |
31
+ | Mutations | `POST /:resource`, `PUT /:resource/:id`, `DELETE /:resource/:id` |
32
+ | File upload | `multipart/form-data`, field name `file` |
33
+ | Errors | `{ statusCode, message, error }` (NestJS default works out of the box) |
34
+ | 401 | The library auto-clears the token and redirects to `/login` |
35
35
 
36
36
  ---
37
37
 
@@ -65,6 +65,12 @@ npm install proje-react-panel \
65
65
 
66
66
  Peer-dep versions: `react >= 19`, `react-router 7.3.0`, `react-hook-form >= 7.54.2`, `zustand >= 5.0.3`, `axios >= 1.0.0`, `react-select ^5.10.1`, `use-sync-external-store >= 1.4.0` (see [`package.json`](./package.json)).
67
67
 
68
+ Rich text (`@RichTextInput`) needs two more packages, and only if you use it — they are declared **optional** peer dependencies, so npm/yarn will not pull them into panels that have no richtext field:
69
+
70
+ ```bash
71
+ npm install @tiptap/react @tiptap/starter-kit
72
+ ```
73
+
68
74
  ### 4.2 Initialize at app root
69
75
 
70
76
  ```ts
@@ -92,11 +98,11 @@ import { ProductList, CreateProductForm, EditProductForm, ProductDetails } from
92
98
 
93
99
  export const dataFetchers = Object.freeze({
94
100
  products: {
95
- getAll: getAll<ProductList>('products'),
101
+ getAll: getAll<ProductList>('products'),
96
102
  details: getOne<ProductDetails>('products'),
97
- create: create<CreateProductForm>('products'),
98
- update: update<EditProductForm>('products'),
99
- remove: remove('products', 'id'),
103
+ create: create<CreateProductForm>('products'),
104
+ update: update<EditProductForm>('products'),
105
+ remove: remove('products', 'id'),
100
106
  },
101
107
  });
102
108
  ```
@@ -138,10 +144,10 @@ import { ListPage, FormPage, DetailsPage } from 'proje-react-panel';
138
144
  import { ProductList, CreateProductForm, ProductDetails } from './models/Product';
139
145
 
140
146
  <Route path="products">
141
- <Route path="" element={<ListPage model={ProductList} />} />
142
- <Route path="create" element={<FormPage model={CreateProductForm} />} />
143
- <Route path=":id" element={<DetailsPage model={ProductDetails} />} />
144
- </Route>
147
+ <Route path="" element={<ListPage model={ProductList} />} />
148
+ <Route path="create" element={<FormPage model={CreateProductForm} />} />
149
+ <Route path=":id" element={<DetailsPage model={ProductDetails} />} />
150
+ </Route>;
145
151
  ```
146
152
 
147
153
  That's it — you have a fully functional list / create / detail flow against `GET|POST|GET /products`.
@@ -156,7 +162,119 @@ A complete worked example lives in [`examples/src/models/Asset.ts`](./examples/s
156
162
 
157
163
  ---
158
164
 
159
- ## 6. Login & 401
165
+ ## 6. Form field types
166
+
167
+ Beyond `@Input`, a field can opt into a richer control. Each decorator is a drop-in replacement for `@Input` on the property.
168
+
169
+ ### 6.1 `@SelectInput` — dropdown
170
+
171
+ ```ts
172
+ @Form({ onSubmit: dataFetchers.products.create })
173
+ export class CreateProductForm {
174
+ @SelectInput({ label: 'Category', defaultOptions: [{ label: 'Books', value: 'books' }] })
175
+ category!: string;
176
+
177
+ @SelectInput({
178
+ label: 'Owner',
179
+ onSelectPreloader: async () => (await listUsers()).map(u => ({ label: u.email, value: u.id })),
180
+ })
181
+ ownerId!: string;
182
+ }
183
+ ```
184
+
185
+ | Option | Meaning |
186
+ | ------------------- | ----------------------------------------------------------------------- |
187
+ | `defaultOptions` | Options known up front |
188
+ | `onSelectPreloader` | `async () => { label, value }[]`, fetched once and cached across fields |
189
+
190
+ ### 6.2 `@RichTextInput` — rich text
191
+
192
+ Renders a TipTap editor and stores **HTML** in the field. Requires the optional peers from §4.1; without them the field renders an "install these packages" notice and the rest of the panel keeps working.
193
+
194
+ ```ts
195
+ import { Form, Input, RichTextInput } from 'proje-react-panel';
196
+
197
+ @Form({ onSubmit: dataFetchers.articles.create })
198
+ export class CreateArticleForm {
199
+ @Input({ label: 'Title' })
200
+ title!: string;
201
+
202
+ @RichTextInput({
203
+ label: 'Body',
204
+ placeholder: 'Write the article…',
205
+ toolbar: ['bold', 'italic', 'heading', 'bulletList', 'orderedList', 'link'],
206
+ minHeight: 240,
207
+ })
208
+ body!: string;
209
+ }
210
+ ```
211
+
212
+ | Option | Meaning |
213
+ | ------------- | ------------------------------------------------------------------------------------------------------------------------ |
214
+ | `toolbar` | Buttons to show, in order — any of `bold`, `italic`, `heading`, `bulletList`, `orderedList`, `link`. Defaults to all six |
215
+ | `minHeight` | Editor height in px (default `160`) |
216
+ | `placeholder` | Shown while the document is empty |
217
+
218
+ Notes:
219
+
220
+ - The value is an HTML string (`<p>…</p>`); an empty editor reports `''`, so `@IsNotEmpty()` behaves as expected.
221
+ - Values loaded through `@Form({ getDetailsData })` arrive after the form mounts; the editor picks them up without disturbing a caret that is already in the text.
222
+
223
+ ### 6.3 `@CustomInput` — anything else
224
+
225
+ ```ts
226
+ @CustomInput({ label: 'Color', render: ({ value, onChange }) => <ColorPicker value={value} onChange={onChange} /> })
227
+ color!: string;
228
+ ```
229
+
230
+ ---
231
+
232
+ ## 7. Grouping form fields
233
+
234
+ Long forms can be split into sections. Fields opt in with `group`, and `@Form({ groups })` decides the headings and their order:
235
+
236
+ ```ts
237
+ @Form({
238
+ onSubmit: dataFetchers.articles.create,
239
+ groups: [
240
+ { key: 'content', label: 'Content' },
241
+ { key: 'seo', label: 'SEO', collapsible: true },
242
+ { key: 'advanced', label: 'Advanced', collapsible: true, defaultCollapsed: true },
243
+ ],
244
+ })
245
+ export class CreateArticleForm {
246
+ @Input({ label: 'Title', group: 'content' })
247
+ title!: string;
248
+
249
+ @RichTextInput({ label: 'Body', group: 'content' })
250
+ body!: string;
251
+
252
+ @Input({ label: 'Meta description', type: 'textarea', rows: 4, group: 'seo' })
253
+ metaDescription!: string;
254
+
255
+ @Input({ label: 'Cache key', group: 'advanced' })
256
+ cacheKey!: string;
257
+ }
258
+ ```
259
+
260
+ | Group option | Meaning |
261
+ | ------------------ | ---------------------------------------------------------------- |
262
+ | `key` | Matched against `@Input({ group })` |
263
+ | `label` | Section heading |
264
+ | `collapsible` | Renders as `<details>/<summary>` instead of `<fieldset><legend>` |
265
+ | `defaultCollapsed` | Starts closed (collapsible groups only) |
266
+
267
+ Rules worth knowing:
268
+
269
+ - **A form where no field sets `group` renders exactly as before** — no extra wrapper element, so existing panel CSS is untouched.
270
+ - Fields without a `group` render first, above the sections, in declaration order.
271
+ - A `group` value missing from `@Form({ groups })` still renders: it is appended after the declared sections and labelled with its own key.
272
+ - `hidden` fields never go into a section.
273
+ - `rows` works on any `type: 'textarea'` field, grouped or not.
274
+
275
+ ---
276
+
277
+ ## 8. Login & 401
160
278
 
161
279
  - `initAuthToken()` reads `localStorage["token"]` on boot and applies it to axios.
162
280
  - `setAuthToken(token)` writes the token to `localStorage` and axios defaults — call it from your login form's success handler (or use the built-in `<Login model={DefaultLoginForm} />`).
@@ -164,21 +282,21 @@ A complete worked example lives in [`examples/src/models/Asset.ts`](./examples/s
164
282
 
165
283
  ---
166
284
 
167
- ## 7. Where to next
285
+ ## 9. Where to next
168
286
 
169
- | Topic | File |
170
- | --- | --- |
287
+ | Topic | File |
288
+ | ------------------------------------------ | -------------------------------------------------------------------- |
171
289
  | Step-by-step walkthrough (the deepest doc) | [`guides/IMPLEMENTATION_GUIDE.md`](./guides/IMPLEMENTATION_GUIDE.md) |
172
- | Sidebar + authenticated layout | [`guides/AUTH_LAYOUT_GUIDE.md`](./guides/AUTH_LAYOUT_GUIDE.md) |
173
- | Layout example code | [`guides/AUTH_LAYOUT_EXAMPLE.md`](./guides/AUTH_LAYOUT_EXAMPLE.md) |
174
- | Dashboard widgets | [`guides/DASHBOARD_GUIDE.md`](./guides/DASHBOARD_GUIDE.md) |
175
- | Colors / theme tokens | [`guides/COLOR_SYSTEM_GUIDE.md`](./guides/COLOR_SYSTEM_GUIDE.md) |
176
- | Feature overview | [`guides/USAGE.md`](./guides/USAGE.md) |
177
- | Live runnable example | [`examples/`](./examples) |
178
- | Product/tech doc | [`PTD.md`](./PTD.md) |
290
+ | Sidebar + authenticated layout | [`guides/AUTH_LAYOUT_GUIDE.md`](./guides/AUTH_LAYOUT_GUIDE.md) |
291
+ | Layout example code | [`guides/AUTH_LAYOUT_EXAMPLE.md`](./guides/AUTH_LAYOUT_EXAMPLE.md) |
292
+ | Dashboard widgets | [`guides/DASHBOARD_GUIDE.md`](./guides/DASHBOARD_GUIDE.md) |
293
+ | Colors / theme tokens | [`guides/COLOR_SYSTEM_GUIDE.md`](./guides/COLOR_SYSTEM_GUIDE.md) |
294
+ | Feature overview | [`guides/USAGE.md`](./guides/USAGE.md) |
295
+ | Live runnable example | [`examples/`](./examples) |
296
+ | Product/tech doc | [`PTD.md`](./PTD.md) |
179
297
 
180
298
  ---
181
299
 
182
- ## 8. Version & license
300
+ ## 10. Version & license
183
301
 
184
- `proje-react-panel` v1.7.0 — ISC license. See [`package.json`](./package.json).
302
+ `proje-react-panel` v1.9.0 — ISC license. See [`package.json`](./package.json).
package/jest.config.js CHANGED
@@ -10,6 +10,7 @@ export default {
10
10
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
11
11
  moduleNameMapper: {
12
12
  '^@/(.*)$': '<rootDir>/src/$1',
13
+ '\\.svg$': '<rootDir>/jest/svgStub.js',
13
14
  },
14
15
  collectCoverage: true,
15
16
  coverageDirectory: 'coverage',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proje-react-panel",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "description": "",
6
6
  "author": "SEFA DEMİR",
@@ -32,6 +32,8 @@
32
32
  "@svgr/rollup": "^8.1.0",
33
33
  "@testing-library/dom": "^10.4.1",
34
34
  "@testing-library/react": "^16.3.2",
35
+ "@tiptap/react": "^3.29.2",
36
+ "@tiptap/starter-kit": "^3.29.2",
35
37
  "@types/react": "^19.0.10",
36
38
  "@typescript-eslint/eslint-plugin": "^8.26.1",
37
39
  "@typescript-eslint/parser": "^8.29.1",
@@ -66,6 +68,8 @@
66
68
  "zustand": "^5.0.3"
67
69
  },
68
70
  "peerDependencies": {
71
+ "@tiptap/react": "^3.0.0",
72
+ "@tiptap/starter-kit": "^3.0.0",
69
73
  "axios": ">=1.0.0",
70
74
  "react": ">=19.0.0",
71
75
  "react-hook-form": ">=7.54.2",
@@ -73,5 +77,13 @@
73
77
  "react-select": "^5.10.1",
74
78
  "use-sync-external-store": ">=1.4.0",
75
79
  "zustand": ">=5.0.3"
80
+ },
81
+ "peerDependenciesMeta": {
82
+ "@tiptap/react": {
83
+ "optional": true
84
+ },
85
+ "@tiptap/starter-kit": {
86
+ "optional": true
87
+ }
76
88
  }
77
89
  }
@@ -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
+ });