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.
- package/README.md +1 -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/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/RichTextInput.d.ts +13 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.esm.js +2 -2
- package/how_to.md +150 -32
- package/jest.config.js +1 -0
- package/package.json +13 -1
- 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/FormField.tsx +8 -1
- package/src/components/form/InnerForm.tsx +101 -19
- package/src/components/form/RichTextField.tsx +218 -0
- package/src/decorators/form/Form.ts +11 -0
- package/src/decorators/form/Input.ts +6 -1
- package/src/decorators/form/inputs/RichTextInput.ts +38 -0
- package/src/index.ts +8 -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/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
|
|
26
|
-
|
|
|
27
|
-
| Auth header | `Authorization: Bearer <jwt>` on every protected request
|
|
28
|
-
| Login
|
|
29
|
-
| List
|
|
30
|
-
| Item
|
|
31
|
-
| Mutations
|
|
32
|
-
| File upload | `multipart/form-data`, field name `file`
|
|
33
|
-
| Errors
|
|
34
|
-
| 401
|
|
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:
|
|
101
|
+
getAll: getAll<ProductList>('products'),
|
|
96
102
|
details: getOne<ProductDetails>('products'),
|
|
97
|
-
create:
|
|
98
|
-
update:
|
|
99
|
-
remove:
|
|
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=""
|
|
142
|
-
<Route path="create"
|
|
143
|
-
<Route path=":id"
|
|
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.
|
|
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
|
-
##
|
|
285
|
+
## 9. Where to next
|
|
168
286
|
|
|
169
|
-
| Topic
|
|
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
|
|
173
|
-
| Layout example code
|
|
174
|
-
| Dashboard widgets
|
|
175
|
-
| Colors / theme tokens
|
|
176
|
-
| Feature overview
|
|
177
|
-
| Live runnable example
|
|
178
|
-
| Product/tech doc
|
|
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
|
-
##
|
|
300
|
+
## 10. Version & license
|
|
183
301
|
|
|
184
|
-
`proje-react-panel` v1.
|
|
302
|
+
`proje-react-panel` v1.9.0 — ISC license. See [`package.json`](./package.json).
|
package/jest.config.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proje-react-panel",
|
|
3
|
-
"version": "1.
|
|
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
|
+
});
|