@ditojs/admin 2.85.2 → 2.87.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.
@@ -0,0 +1,402 @@
1
+ import { expectTypeOf, assertType, describe, it } from 'vitest'
2
+ import type {
3
+ Form,
4
+ Components,
5
+ PanelSchema,
6
+ UploadSchema,
7
+ DitoComponentInstance,
8
+ HiddenSchema
9
+ } from '../index.d.ts'
10
+ import type { Entry, Parent } from './fixtures.ts'
11
+
12
+ describe('SchemaFields: methods', () => {
13
+ it('methods have `this` typed as component instance', () => {
14
+ assertType<Form<Entry>>({
15
+ type: 'form',
16
+ components: {
17
+ title: { type: 'text' }
18
+ },
19
+ methods: {
20
+ greet() {
21
+ expectTypeOf(this).not.toBeAny()
22
+ expectTypeOf(this).toEqualTypeOf<DitoComponentInstance<Entry>>()
23
+ expectTypeOf(this.item).not.toBeAny()
24
+ expectTypeOf(this.item.title).toBeString()
25
+ expectTypeOf(this.item.id).toBeNumber()
26
+ },
27
+ getTitle() {
28
+ return this.item.title
29
+ }
30
+ }
31
+ })
32
+ })
33
+ })
34
+
35
+ describe('SchemaFields: computed', () => {
36
+ it('computed getter uses `this` typing', () => {
37
+ assertType<Form<Entry>>({
38
+ type: 'form',
39
+ components: {
40
+ title: { type: 'text' }
41
+ },
42
+ computed: {
43
+ upperTitle() {
44
+ expectTypeOf(this).not.toBeAny()
45
+ expectTypeOf(this.item).not.toBeAny()
46
+ expectTypeOf(this.item.title).toBeString()
47
+ return this.item.title.toUpperCase()
48
+ }
49
+ }
50
+ })
51
+ })
52
+
53
+ it('computed getter/setter object uses `this` typing', () => {
54
+ assertType<Form<Entry>>({
55
+ type: 'form',
56
+ components: {
57
+ title: { type: 'text' }
58
+ },
59
+ computed: {
60
+ upperTitle: {
61
+ get() {
62
+ expectTypeOf(this).not.toBeAny()
63
+ expectTypeOf(this.item).not.toBeAny()
64
+ expectTypeOf(this.item.title).toBeString()
65
+ return this.item.title.toUpperCase()
66
+ },
67
+ set(_value) {
68
+ expectTypeOf(this).not.toBeAny()
69
+ expectTypeOf(this.item.id).toBeNumber()
70
+ }
71
+ }
72
+ }
73
+ })
74
+ })
75
+ })
76
+
77
+ describe('SchemaFields: watch', () => {
78
+ it('watch with plain handler function', () => {
79
+ assertType<Form<Entry>>({
80
+ type: 'form',
81
+ components: {
82
+ title: { type: 'text' }
83
+ },
84
+ watch: {
85
+ title(value, oldValue) {
86
+ expectTypeOf(this).not.toBeAny()
87
+ expectTypeOf(this.item).not.toBeAny()
88
+ expectTypeOf(this.item.title).toBeString()
89
+ expectTypeOf(value).toBeAny()
90
+ expectTypeOf(oldValue).toBeAny()
91
+ }
92
+ }
93
+ })
94
+ })
95
+
96
+ it('watch with object containing deep and immediate', () => {
97
+ assertType<Form<Entry>>({
98
+ type: 'form',
99
+ components: {
100
+ title: { type: 'text' }
101
+ },
102
+ watch: {
103
+ title: {
104
+ handler(value, oldValue) {
105
+ expectTypeOf(this).not.toBeAny()
106
+ expectTypeOf(this.item).not.toBeAny()
107
+ expectTypeOf(this.item.title).toBeString()
108
+ expectTypeOf(value).toBeAny()
109
+ expectTypeOf(oldValue).toBeAny()
110
+ },
111
+ deep: true,
112
+ immediate: true
113
+ }
114
+ }
115
+ })
116
+ })
117
+
118
+ it('watch as factory function returning handlers', () => {
119
+ assertType<Form<Entry>>({
120
+ type: 'form',
121
+ components: {
122
+ title: { type: 'text' }
123
+ },
124
+ watch() {
125
+ expectTypeOf(this).not.toBeAny()
126
+ expectTypeOf(this.item).not.toBeAny()
127
+ expectTypeOf(this.item.title).toBeString()
128
+ return {
129
+ title(value) {
130
+ expectTypeOf(this).not.toBeAny()
131
+ expectTypeOf(this.item).not.toBeAny()
132
+ expectTypeOf(this.item.id).toBeNumber()
133
+ }
134
+ }
135
+ }
136
+ })
137
+ })
138
+ })
139
+
140
+ describe('SchemaFields: panels', () => {
141
+ it('form with panels containing components and buttons', () => {
142
+ assertType<Form<Entry>>({
143
+ type: 'form',
144
+ components: {
145
+ title: { type: 'text' }
146
+ },
147
+ panels: {
148
+ sidebar: {
149
+ type: 'panel',
150
+ label: 'Sidebar',
151
+ components: {
152
+ id: { type: 'number' }
153
+ },
154
+ buttons: {
155
+ save: { text: 'Save' }
156
+ },
157
+ panelButtons: {
158
+ refresh: { text: 'Refresh' }
159
+ }
160
+ }
161
+ }
162
+ })
163
+ })
164
+ })
165
+
166
+ describe('SchemaFields: lifecycle callbacks', () => {
167
+ it('onInitialize and onChange receive typed context', () => {
168
+ assertType<Form<Entry>>({
169
+ type: 'form',
170
+ components: {
171
+ title: { type: 'text' }
172
+ },
173
+ onInitialize({ item }) {
174
+ expectTypeOf(item).not.toBeAny()
175
+ expectTypeOf(item.title).toBeString()
176
+ expectTypeOf(item.id).toBeNumber()
177
+ },
178
+ onChange({ item }) {
179
+ expectTypeOf(item).not.toBeAny()
180
+ expectTypeOf(item.title).toBeString()
181
+ }
182
+ })
183
+ })
184
+ })
185
+
186
+ describe('SchemaTypeMixin: parse and process callbacks', () => {
187
+ it('parse callback receives typed item', () => {
188
+ assertType<Components<Parent>>({
189
+ title: {
190
+ type: 'text',
191
+ parse({ item }) {
192
+ expectTypeOf(item).not.toBeAny()
193
+ expectTypeOf(item.title).toBeString()
194
+ expectTypeOf(item.entries).toEqualTypeOf<Entry[]>()
195
+ }
196
+ }
197
+ })
198
+ })
199
+
200
+ it('process callback receives typed item', () => {
201
+ assertType<Components<Parent>>({
202
+ title: {
203
+ type: 'text',
204
+ process({ item }) {
205
+ expectTypeOf(item).not.toBeAny()
206
+ expectTypeOf(item.title).toBeString()
207
+ return item.title.trim()
208
+ }
209
+ }
210
+ })
211
+ })
212
+ })
213
+
214
+ describe('SchemaDataMixin: data callback', () => {
215
+ it('data callback receives typed item on HiddenSchema', () => {
216
+ assertType<HiddenSchema<Entry>>({
217
+ type: 'hidden',
218
+ data({ item }) {
219
+ expectTypeOf(item).not.toBeAny()
220
+ expectTypeOf(item.title).toBeString()
221
+ expectTypeOf(item.id).toBeNumber()
222
+ return { computed: item.title }
223
+ }
224
+ })
225
+ })
226
+ })
227
+
228
+ describe('UploadSchema', () => {
229
+ it('accepts multiple, extensions, accept, and maxSize', () => {
230
+ assertType<Components<Parent>>({
231
+ entries: {
232
+ type: 'upload',
233
+ multiple: true,
234
+ extensions: ['jpg', 'png', 'gif'],
235
+ accept: ['image/png', 'image/jpeg'],
236
+ maxSize: '5mb'
237
+ }
238
+ })
239
+ })
240
+
241
+ it('accepts extensions as regex', () => {
242
+ assertType<Components<Parent>>({
243
+ entries: {
244
+ type: 'upload',
245
+ extensions: /\.(gif|jpe?g|png)$/i
246
+ }
247
+ })
248
+ })
249
+
250
+ it('accepts thumbnails and downloadUrl as callbacks', () => {
251
+ assertType<UploadSchema<Entry>>({
252
+ type: 'upload',
253
+ thumbnails({ item }) {
254
+ expectTypeOf(item).not.toBeAny()
255
+ expectTypeOf(item.title).toBeString()
256
+ return true
257
+ },
258
+ downloadUrl({ item }) {
259
+ expectTypeOf(item).not.toBeAny()
260
+ expectTypeOf(item.id).toBeNumber()
261
+ return `/files/${item.id}`
262
+ }
263
+ })
264
+ })
265
+
266
+ it('accepts thumbnailUrl and render as callbacks', () => {
267
+ assertType<UploadSchema<Entry>>({
268
+ type: 'upload',
269
+ thumbnailUrl({ item }) {
270
+ expectTypeOf(item).not.toBeAny()
271
+ expectTypeOf(item.title).toBeString()
272
+ return `/thumbs/${item.id}`
273
+ },
274
+ render({ item }) {
275
+ expectTypeOf(item).not.toBeAny()
276
+ return item.title
277
+ }
278
+ })
279
+ })
280
+
281
+ it('accepts draggable and deletable as callbacks', () => {
282
+ assertType<UploadSchema<Entry>>({
283
+ type: 'upload',
284
+ draggable({ item }) {
285
+ expectTypeOf(item).not.toBeAny()
286
+ expectTypeOf(item.id).toBeNumber()
287
+ return true
288
+ },
289
+ deletable({ item }) {
290
+ expectTypeOf(item).not.toBeAny()
291
+ expectTypeOf(item.title).toBeString()
292
+ return false
293
+ }
294
+ })
295
+ })
296
+ })
297
+
298
+ describe('PanelSchema', () => {
299
+ it('constrains component keys to item properties', () => {
300
+ assertType<PanelSchema<Entry>>({
301
+ type: 'panel',
302
+ components: {
303
+ id: { type: 'number' },
304
+ title: { type: 'text' }
305
+ }
306
+ })
307
+ })
308
+
309
+ it('rejects unknown component keys', () => {
310
+ assertType<PanelSchema<Entry>>({
311
+ type: 'panel',
312
+ components: {
313
+ // @ts-expect-error — 'extra' is not a key of Entry
314
+ extra: { type: 'text' }
315
+ }
316
+ })
317
+ })
318
+
319
+ it('accepts buttons and panelButtons', () => {
320
+ assertType<PanelSchema<Entry>>({
321
+ type: 'panel',
322
+ buttons: {
323
+ save: {
324
+ text: 'Save',
325
+ events: {
326
+ click({ item }) {
327
+ expectTypeOf(item).not.toBeAny()
328
+ expectTypeOf(item.title).toBeString()
329
+ }
330
+ }
331
+ }
332
+ },
333
+ panelButtons: {
334
+ refresh: { text: 'Refresh' }
335
+ }
336
+ })
337
+ })
338
+
339
+ it('accepts sticky as callback', () => {
340
+ assertType<PanelSchema<Entry>>({
341
+ type: 'panel',
342
+ sticky({ item }) {
343
+ expectTypeOf(item).not.toBeAny()
344
+ expectTypeOf(item.id).toBeNumber()
345
+ return true
346
+ }
347
+ })
348
+ })
349
+
350
+ it('component callbacks in panels receive typed item', () => {
351
+ assertType<PanelSchema<Parent>>({
352
+ type: 'panel',
353
+ components: {
354
+ title: {
355
+ type: 'text',
356
+ format({ item }) {
357
+ expectTypeOf(item).not.toBeAny()
358
+ expectTypeOf(item.title).toBeString()
359
+ expectTypeOf(item.entries)
360
+ .toEqualTypeOf<Entry[]>()
361
+ return ''
362
+ }
363
+ }
364
+ }
365
+ })
366
+ })
367
+
368
+ it('panels on Form inherit item type', () => {
369
+ assertType<Form<Parent>>({
370
+ type: 'form',
371
+ components: {
372
+ title: { type: 'text' }
373
+ },
374
+ panels: {
375
+ info: {
376
+ type: 'panel',
377
+ components: {
378
+ title: {
379
+ type: 'text',
380
+ format({ item }) {
381
+ expectTypeOf(item).not.toBeAny()
382
+ expectTypeOf(item.title).toBeString()
383
+ return ''
384
+ }
385
+ }
386
+ },
387
+ buttons: {
388
+ save: {
389
+ text: 'Save',
390
+ events: {
391
+ click({ item }) {
392
+ expectTypeOf(item).not.toBeAny()
393
+ expectTypeOf(item.id).toBeNumber()
394
+ }
395
+ }
396
+ }
397
+ }
398
+ }
399
+ }
400
+ })
401
+ })
402
+ })
@@ -0,0 +1,125 @@
1
+ import { assertType, describe, it } from 'vitest'
2
+ import type {
3
+ View,
4
+ Form,
5
+ Component,
6
+ PanelSchema,
7
+ ButtonSchema,
8
+ ResolvableForm
9
+ } from '../index.d.ts'
10
+ import type { Entry, Parent } from './fixtures.ts'
11
+
12
+ type DeepItem = {
13
+ title: string
14
+ items: { type: string }[]
15
+ tags: string[]
16
+ }
17
+
18
+ describe('Variance: typed schemas assignable to any', () => {
19
+ it('Component<Entry> is assignable to Component<any>', () => {
20
+ const component: Component<Entry> = {
21
+ type: 'text'
22
+ }
23
+ assertType<Component<any>>(component)
24
+ })
25
+
26
+ it('ButtonSchema<Entry> is assignable to Component<any>', () => {
27
+ const button: ButtonSchema<Entry> = {
28
+ type: 'button',
29
+ text: 'Click me'
30
+ }
31
+ assertType<Component<any>>(button)
32
+ })
33
+
34
+ it('PanelSchema<Entry> is assignable to PanelSchema<any>', () => {
35
+ const panel: PanelSchema<Entry> = {
36
+ type: 'panel',
37
+ components: {
38
+ title: { type: 'text' }
39
+ }
40
+ }
41
+ assertType<PanelSchema<any>>(panel)
42
+ })
43
+
44
+ it('View<Entry> is assignable to View<any>', () => {
45
+ const view: View<Entry> = {
46
+ type: 'view',
47
+ components: {
48
+ title: { type: 'text' }
49
+ }
50
+ }
51
+ assertType<View<any>>(view)
52
+ })
53
+
54
+ it('View<Entry> with panels is assignable to View<any>', () => {
55
+ const view: View<Entry> = {
56
+ type: 'view',
57
+ components: {
58
+ title: { type: 'text' }
59
+ },
60
+ panels: {
61
+ sidebar: {
62
+ type: 'panel',
63
+ components: {
64
+ title: { type: 'text' }
65
+ }
66
+ }
67
+ }
68
+ }
69
+ assertType<View<any>>(view)
70
+ })
71
+
72
+ it('Record of typed views assignable to Record<string, View<any>>', () => {
73
+ const views = {
74
+ entries: {
75
+ type: 'view',
76
+ components: {
77
+ title: { type: 'text' }
78
+ }
79
+ } satisfies View<Entry>
80
+ }
81
+ assertType<Record<string, View<any>>>(views)
82
+ })
83
+
84
+ it('Form<Entry> is assignable to ResolvableForm<any>', () => {
85
+ const form: Form<Entry> = {
86
+ type: 'form',
87
+ components: {
88
+ title: { type: 'text' }
89
+ }
90
+ }
91
+ assertType<ResolvableForm<any>>(form)
92
+ })
93
+
94
+ it('Form<Parent> (array props) is assignable to ResolvableForm<any>', () => {
95
+ const form: Form<Parent> = {
96
+ type: 'form',
97
+ components: {
98
+ title: { type: 'text' }
99
+ }
100
+ }
101
+ assertType<ResolvableForm<any>>(form)
102
+ })
103
+
104
+ it('Form with primitive + nested arrays is assignable to ResolvableForm<any>', () => {
105
+ const form: Form<DeepItem> = {
106
+ type: 'form',
107
+ components: {
108
+ title: { type: 'text' }
109
+ }
110
+ }
111
+ assertType<ResolvableForm<any>>(form)
112
+ })
113
+
114
+ it('Record of typed forms assignable to Record<string, ResolvableForm>', () => {
115
+ const forms = {
116
+ entry: {
117
+ type: 'form',
118
+ components: {
119
+ title: { type: 'text' }
120
+ }
121
+ } satisfies Form<Entry>
122
+ }
123
+ assertType<Record<string, ResolvableForm>>(forms)
124
+ })
125
+ })
@@ -0,0 +1,146 @@
1
+ import { expectTypeOf, assertType, describe, it } from 'vitest'
2
+ import type { Form, View } from '../index.d.ts'
3
+ import type { Entry } from './fixtures.ts'
4
+
5
+ describe('View with list component', () => {
6
+ it('accepts view with single list component and inline form', () => {
7
+ const view: View<Entry> = {
8
+ type: 'view',
9
+ component: {
10
+ type: 'list',
11
+ itemLabel: 'title',
12
+ form: {
13
+ type: 'form',
14
+ components: {
15
+ title: { type: 'text' }
16
+ }
17
+ }
18
+ }
19
+ }
20
+ })
21
+
22
+ it('accepts view with single list component and Form variable', () => {
23
+ const entryForm: Form<Entry> = {
24
+ type: 'form',
25
+ components: {
26
+ title: { type: 'text' }
27
+ }
28
+ }
29
+
30
+ const view: View<Entry> = {
31
+ type: 'view',
32
+ component: {
33
+ type: 'list',
34
+ itemLabel: 'title',
35
+ form: entryForm
36
+ }
37
+ }
38
+ })
39
+
40
+ it('accepts view with single list component and promised form', () => {
41
+ const promisedForm: Promise<Record<string, Form<Entry>>> = Promise.resolve({
42
+ entryForm: { type: 'form', components: {} }
43
+ })
44
+
45
+ const view: View<Entry> = {
46
+ type: 'view',
47
+ component: {
48
+ type: 'list',
49
+ itemLabel: 'title',
50
+ form: promisedForm
51
+ }
52
+ }
53
+ })
54
+
55
+ it('accepts view with components map', () => {
56
+ const view: View<Entry> = {
57
+ type: 'view',
58
+ components: {
59
+ title: {
60
+ type: 'text',
61
+ format({ item }) {
62
+ expectTypeOf(item).not.toBeAny()
63
+ expectTypeOf(item.title).toBeString()
64
+ }
65
+ }
66
+ }
67
+ }
68
+ })
69
+ })
70
+
71
+ describe('View tabs and events', () => {
72
+ it('accepts tabs on a view', () => {
73
+ const view: View<Entry> = {
74
+ type: 'view',
75
+ tabs: {
76
+ overview: {
77
+ type: 'tab',
78
+ label: 'Overview'
79
+ },
80
+ details: {
81
+ type: 'tab',
82
+ label: 'Details',
83
+ defaultTab: true
84
+ }
85
+ }
86
+ }
87
+ })
88
+
89
+ it('accepts events on a view', () => {
90
+ const view: View<Entry> = {
91
+ type: 'view',
92
+ events: {
93
+ open({ item, open }) {
94
+ expectTypeOf(item).not.toBeAny()
95
+ expectTypeOf(item.title).toBeString()
96
+ expectTypeOf(open).toBeBoolean()
97
+ },
98
+ change({ item }) {
99
+ expectTypeOf(item).not.toBeAny()
100
+ expectTypeOf(item.id).toBeNumber()
101
+ }
102
+ }
103
+ }
104
+ })
105
+ })
106
+
107
+ describe('List filters', () => {
108
+ it('accepts filter with custom components using arbitrary keys', () => {
109
+ const view: View<Entry> = {
110
+ type: 'view',
111
+ component: {
112
+ type: 'list',
113
+ filters: {
114
+ title: {
115
+ filter: 'text',
116
+ operators: ['contains', 'equals']
117
+ },
118
+ createdAt: {
119
+ filter: 'date-range'
120
+ },
121
+ status: {
122
+ components: {
123
+ pattern: {
124
+ type: 'select',
125
+ options: ['active', 'inactive']
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+ })
133
+
134
+ it('accepts boolean and sticky filters', () => {
135
+ const view: View<Entry> = {
136
+ type: 'view',
137
+ component: {
138
+ type: 'list',
139
+ filters: {
140
+ sticky: true,
141
+ title: true
142
+ }
143
+ }
144
+ }
145
+ })
146
+ })