@pilotiq/pilotiq 0.1.0 → 0.3.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +6 -5
- package/dist/Pilotiq.d.ts +20 -1
- package/dist/Pilotiq.d.ts.map +1 -1
- package/dist/Pilotiq.js.map +1 -1
- package/dist/actions/Action.d.ts +25 -0
- package/dist/actions/Action.d.ts.map +1 -1
- package/dist/actions/Action.js +25 -0
- package/dist/actions/Action.js.map +1 -1
- package/dist/elements/dispatchForm.d.ts +0 -14
- package/dist/elements/dispatchForm.d.ts.map +1 -1
- package/dist/elements/dispatchForm.js +28 -0
- package/dist/elements/dispatchForm.js.map +1 -1
- package/dist/fields/BuilderField.d.ts +27 -1
- package/dist/fields/BuilderField.d.ts.map +1 -1
- package/dist/fields/BuilderField.js +36 -1
- package/dist/fields/BuilderField.js.map +1 -1
- package/dist/fields/FileUploadField.d.ts +65 -0
- package/dist/fields/FileUploadField.d.ts.map +1 -1
- package/dist/fields/FileUploadField.js +72 -0
- package/dist/fields/FileUploadField.js.map +1 -1
- package/dist/fields/RepeaterField.d.ts +34 -1
- package/dist/fields/RepeaterField.d.ts.map +1 -1
- package/dist/fields/RepeaterField.js +43 -1
- package/dist/fields/RepeaterField.js.map +1 -1
- package/dist/fields/RowButton.d.ts +9 -2
- package/dist/fields/RowButton.d.ts.map +1 -1
- package/dist/fields/TextField.d.ts +106 -0
- package/dist/fields/TextField.d.ts.map +1 -1
- package/dist/fields/TextField.js +115 -0
- package/dist/fields/TextField.js.map +1 -1
- package/dist/filters/queryBuilder/Constraint.d.ts +1 -1
- package/dist/filters/queryBuilder/Constraint.d.ts.map +1 -1
- package/dist/filters/queryBuilder/TextConstraint.d.ts.map +1 -1
- package/dist/filters/queryBuilder/TextConstraint.js +2 -3
- package/dist/filters/queryBuilder/TextConstraint.js.map +1 -1
- package/dist/orm/modelDefaults.d.ts +1 -1
- package/dist/orm/modelDefaults.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.js +108 -7
- package/dist/react/SchemaRenderer.js.map +1 -1
- package/dist/react/fields/BuilderInput.d.ts.map +1 -1
- package/dist/react/fields/BuilderInput.js +32 -3
- package/dist/react/fields/BuilderInput.js.map +1 -1
- package/dist/react/fields/FieldShell.d.ts +9 -1
- package/dist/react/fields/FieldShell.d.ts.map +1 -1
- package/dist/react/fields/FieldShell.js +4 -3
- package/dist/react/fields/FieldShell.js.map +1 -1
- package/dist/react/fields/FileUploadInput.d.ts +17 -4
- package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
- package/dist/react/fields/FileUploadInput.js +204 -25
- package/dist/react/fields/FileUploadInput.js.map +1 -1
- package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
- package/dist/react/fields/RepeaterInput.js +33 -2
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/fields/TextLikeInput.d.ts +5 -1
- package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
- package/dist/react/fields/TextLikeInput.js +17 -2
- package/dist/react/fields/TextLikeInput.js.map +1 -1
- package/dist/react/fields/rowChromeButton.d.ts +24 -5
- package/dist/react/fields/rowChromeButton.d.ts.map +1 -1
- package/dist/react/fields/rowChromeButton.js +51 -8
- package/dist/react/fields/rowChromeButton.js.map +1 -1
- package/dist/react/fields/textInputControls.d.ts +47 -0
- package/dist/react/fields/textInputControls.d.ts.map +1 -0
- package/dist/react/fields/textInputControls.js +134 -0
- package/dist/react/fields/textInputControls.js.map +1 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +32 -1
- package/dist/routes.js.map +1 -1
- package/dist/schema/Alert.d.ts +58 -0
- package/dist/schema/Alert.d.ts.map +1 -1
- package/dist/schema/Alert.js +68 -1
- package/dist/schema/Alert.js.map +1 -1
- package/dist/schema/resolveSchema.d.ts.map +1 -1
- package/dist/schema/resolveSchema.js +32 -0
- package/dist/schema/resolveSchema.js.map +1 -1
- package/package.json +12 -11
- package/src/Pilotiq.test.ts +78 -0
- package/src/Pilotiq.ts +20 -1
- package/src/actions/Action.test.ts +47 -0
- package/src/actions/Action.ts +35 -0
- package/src/elements/dispatchForm.ts +28 -0
- package/src/fields/BuilderField.ts +38 -1
- package/src/fields/FileUploadField.test.ts +46 -0
- package/src/fields/FileUploadField.ts +90 -2
- package/src/fields/RepeaterField.ts +45 -1
- package/src/fields/RowButton.test.ts +70 -0
- package/src/fields/RowButton.ts +11 -1
- package/src/fields/TextField.test.ts +168 -0
- package/src/fields/TextField.ts +141 -1
- package/src/filters/QueryBuilderFilter.test.ts +18 -0
- package/src/filters/queryBuilder/Constraint.ts +1 -1
- package/src/filters/queryBuilder/TextConstraint.ts +5 -6
- package/src/orm/modelDefaults.ts +1 -1
- package/src/react/SchemaRenderer.tsx +222 -14
- package/src/react/fields/BuilderInput.tsx +37 -0
- package/src/react/fields/FieldShell.tsx +13 -2
- package/src/react/fields/FileUploadInput.tsx +516 -85
- package/src/react/fields/RepeaterInput.tsx +39 -0
- package/src/react/fields/TextLikeInput.tsx +22 -2
- package/src/react/fields/rowChromeButton.tsx +102 -6
- package/src/react/fields/textInputControls.tsx +238 -0
- package/src/routes.ts +33 -1
- package/src/schema/Alert.test.ts +46 -0
- package/src/schema/Alert.ts +90 -8
- package/src/schema/resolveSchema.ts +32 -0
|
@@ -180,6 +180,18 @@ export async function dispatchFormSubmit<R = unknown>(
|
|
|
180
180
|
*
|
|
181
181
|
* Other field types are passed through untouched.
|
|
182
182
|
*/
|
|
183
|
+
/** Remove every occurrence of any character in `chars` from `value`.
|
|
184
|
+
* O(n) — uses a Set for membership lookup. Multi-codepoint entries
|
|
185
|
+
* in `chars` (e.g. an emoji passed as one mask token) are matched
|
|
186
|
+
* whole; the function compares against `Array.from(value)` so
|
|
187
|
+
* surrogate pairs round-trip correctly. */
|
|
188
|
+
function stripChars(value: string, chars: string[]): string {
|
|
189
|
+
const set = new Set(chars)
|
|
190
|
+
let out = ''
|
|
191
|
+
for (const ch of value) if (!set.has(ch)) out += ch
|
|
192
|
+
return out
|
|
193
|
+
}
|
|
194
|
+
|
|
183
195
|
export function coerceFormValues(
|
|
184
196
|
elements: Element[],
|
|
185
197
|
body: Record<string, unknown>,
|
|
@@ -384,6 +396,22 @@ export function coerceFormValues(
|
|
|
384
396
|
// text/textarea/email/select/slug — leave as string.
|
|
385
397
|
break
|
|
386
398
|
}
|
|
399
|
+
|
|
400
|
+
// `TextField.stripCharacters([…])` — applies after type-specific
|
|
401
|
+
// coercion so the persisted value never carries the listed
|
|
402
|
+
// characters. Duck-typed: any Field whose `getStripCharacters?`
|
|
403
|
+
// returns a non-empty list opts in. Skipped for non-strings (the
|
|
404
|
+
// pre-coerce switch may have produced numbers / booleans / arrays).
|
|
405
|
+
const stripper = (field as { getStripCharacters?: () => string[] | undefined }).getStripCharacters
|
|
406
|
+
if (typeof stripper === 'function') {
|
|
407
|
+
const chars = stripper.call(field)
|
|
408
|
+
if (chars && chars.length > 0) {
|
|
409
|
+
const cur = out[name]
|
|
410
|
+
if (typeof cur === 'string' && cur.length > 0) {
|
|
411
|
+
out[name] = stripChars(cur, chars)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
387
415
|
})
|
|
388
416
|
return out
|
|
389
417
|
}
|
|
@@ -462,9 +462,46 @@ export class BuilderField extends Field {
|
|
|
462
462
|
*/
|
|
463
463
|
reorderAction(b: RowButton): this { this._buttons.reorder = b; return this }
|
|
464
464
|
|
|
465
|
-
/**
|
|
465
|
+
/**
|
|
466
|
+
* Customize the per-row collapse chevron. Applies to BOTH states by
|
|
467
|
+
* default — the open chevron and the collapsed chevron share the
|
|
468
|
+
* override unless `expandAction(...)` is also set, in which case
|
|
469
|
+
* `collapseAction` covers only the open state and `expandAction`
|
|
470
|
+
* covers the collapsed state. Mirrors `RepeaterField.collapseAction`.
|
|
471
|
+
*/
|
|
466
472
|
collapseAction(b: RowButton): this { this._buttons.collapse = b; return this }
|
|
467
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Customize the per-row chevron when the row is currently *collapsed*.
|
|
476
|
+
* Sibling of `collapseAction` for the closed-state glyph. Mirrors
|
|
477
|
+
* `RepeaterField.expandAction`.
|
|
478
|
+
*/
|
|
479
|
+
expandAction(b: RowButton): this { this._buttons.expand = b; return this }
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Mount an "Expand all" button in the field header. Opt-in: calling
|
|
483
|
+
* without args shows the button with default chrome; pass a
|
|
484
|
+
* `RowButton` to override icon / label / tooltip / color. Auto-arms
|
|
485
|
+
* `collapsible()`. In `accordion()` mode the button opens the first
|
|
486
|
+
* visible row. Mirrors `RepeaterField.expandAllAction`.
|
|
487
|
+
*/
|
|
488
|
+
expandAllAction(button?: RowButton): this {
|
|
489
|
+
this._buttons.expandAll = button ?? RowButton.make()
|
|
490
|
+
this._collapsible = true
|
|
491
|
+
return this
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Mount a "Collapse all" button in the field header. Opt-in (calling
|
|
496
|
+
* enables; pass a `RowButton` to customize). Auto-arms `collapsible()`.
|
|
497
|
+
* Mirrors `RepeaterField.collapseAllAction`.
|
|
498
|
+
*/
|
|
499
|
+
collapseAllAction(button?: RowButton): this {
|
|
500
|
+
this._buttons.collapseAll = button ?? RowButton.make()
|
|
501
|
+
this._collapsible = true
|
|
502
|
+
return this
|
|
503
|
+
}
|
|
504
|
+
|
|
468
505
|
// ─── Read-only access ────────────────────────────────
|
|
469
506
|
|
|
470
507
|
override getChildren(): undefined {
|
|
@@ -56,6 +56,52 @@ describe('FileUploadField', () => {
|
|
|
56
56
|
assert.equal('uploadUrl' in meta, false)
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
+
describe('image editor (Phase A)', () => {
|
|
60
|
+
it('imageEditor() stamps imageEditor:true', () => {
|
|
61
|
+
const meta = FileUploadField.make('photo').imageEditor().toMeta()
|
|
62
|
+
assert.equal(meta['imageEditor'], true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('imageEditor() absent by default', () => {
|
|
66
|
+
const meta = FileUploadField.make('photo').toMeta()
|
|
67
|
+
assert.equal('imageEditor' in meta, false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('imageEditorAspectRatioOptions() stamps options array', () => {
|
|
71
|
+
const opts = [{ ratio: 16 / 9, label: 'Widescreen' }, { ratio: 1, label: 'Square' }]
|
|
72
|
+
const meta = FileUploadField.make('photo').imageEditor().imageEditorAspectRatioOptions(opts).toMeta()
|
|
73
|
+
assert.deepEqual(meta['imageEditorAspectRatioOptions'], opts)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('circleCropper() stamps circleCropper:true', () => {
|
|
77
|
+
const meta = FileUploadField.make('photo').imageEditor().circleCropper().toMeta()
|
|
78
|
+
assert.equal(meta['circleCropper'], true)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('automaticallyCropImagesToAspectRatio() stamps flag', () => {
|
|
82
|
+
const meta = FileUploadField.make('photo').imageEditor().automaticallyCropImagesToAspectRatio().toMeta()
|
|
83
|
+
assert.equal(meta['automaticallyCropImagesToAspectRatio'], true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('automaticallyResize() stamps width+height', () => {
|
|
87
|
+
const meta = FileUploadField.make('photo').automaticallyResize(800, 600).toMeta()
|
|
88
|
+
assert.deepEqual(meta['automaticallyResize'], { width: 800, height: 600 })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('avatar() sets imageEditor + circleCropper + multiple:false', () => {
|
|
92
|
+
const meta = FileUploadField.make('avatar').avatar().toMeta()
|
|
93
|
+
assert.equal(meta['imageEditor'], true)
|
|
94
|
+
assert.equal(meta['circleCropper'], true)
|
|
95
|
+
assert.equal(meta['multiple'], false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('avatar() does not override an explicit multiple(true)', () => {
|
|
99
|
+
// avatar() forces multiple false — explicit multiple() after avatar() wins
|
|
100
|
+
const meta = FileUploadField.make('avatar').avatar().multiple(true).toMeta()
|
|
101
|
+
assert.equal(meta['multiple'], true)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
59
105
|
describe('coerceFormValues', () => {
|
|
60
106
|
it('passes URL string through (single mode)', () => {
|
|
61
107
|
const out = coerceFormValues(
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { Field, type FieldMeta } from './Field.js'
|
|
2
2
|
import type { RenderContext } from '../schema/resolveSchema.js'
|
|
3
3
|
|
|
4
|
+
export type PanelLayout = 'list' | 'grid' | 'integrated'
|
|
5
|
+
|
|
6
|
+
export interface AspectRatioOption {
|
|
7
|
+
/** Numeric ratio, e.g. `16/9`, `4/3`, `1` for square. */
|
|
8
|
+
ratio: number
|
|
9
|
+
/** Human-readable label shown in the editor picker, e.g. `'Widescreen'`. */
|
|
10
|
+
label: string
|
|
11
|
+
}
|
|
12
|
+
|
|
4
13
|
/**
|
|
5
14
|
* File upload. The field stores the resolved upload's URL string —
|
|
6
15
|
* `string` for single-file mode, `string[]` for multi.
|
|
@@ -17,6 +26,16 @@ export class FileUploadField extends Field {
|
|
|
17
26
|
private _multiple = false
|
|
18
27
|
private _preview = true
|
|
19
28
|
private _directory?: string
|
|
29
|
+
private _downloadable = false
|
|
30
|
+
private _openable = false
|
|
31
|
+
private _reorderable = false
|
|
32
|
+
private _appendFiles = false
|
|
33
|
+
private _panelLayout: PanelLayout = 'list'
|
|
34
|
+
private _imageEditor = false
|
|
35
|
+
private _imageEditorAspectRatioOptions?: AspectRatioOption[]
|
|
36
|
+
private _circleCropper = false
|
|
37
|
+
private _automaticallyCropImagesToAspectRatio = false
|
|
38
|
+
private _automaticallyResize?: { width: number; height: number }
|
|
20
39
|
|
|
21
40
|
private constructor(name: string) {
|
|
22
41
|
super(name, 'fileUpload')
|
|
@@ -45,20 +64,89 @@ export class FileUploadField extends Field {
|
|
|
45
64
|
*/
|
|
46
65
|
directory(d: string): this { this._directory = d; return this }
|
|
47
66
|
|
|
67
|
+
/** Add a download button to each uploaded file item. */
|
|
68
|
+
downloadable(value: boolean = true): this { this._downloadable = value; return this }
|
|
69
|
+
|
|
70
|
+
/** Make the file thumbnail/icon link to the file (open in new tab). */
|
|
71
|
+
openable(value: boolean = true): this { this._openable = value; return this }
|
|
72
|
+
|
|
73
|
+
/** Allow files to be reordered via drag-and-drop. Only useful with `multiple()`. */
|
|
74
|
+
reorderable(value: boolean = true): this { this._reorderable = value; return this }
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Append new uploads to the existing file list instead of replacing it.
|
|
78
|
+
* By default new picks replace the current value. Combine with `multiple()`.
|
|
79
|
+
*/
|
|
80
|
+
appendFiles(value: boolean = true): this { this._appendFiles = value; return this }
|
|
81
|
+
|
|
82
|
+
/** Display layout for the uploaded file list. `'grid'` shows square tiles; `'integrated'` embeds the upload button as a tile. */
|
|
83
|
+
panelLayout(layout: PanelLayout): this { this._panelLayout = layout; return this }
|
|
84
|
+
|
|
85
|
+
/** Open a crop/resize modal after the user selects an image, before it is uploaded. */
|
|
86
|
+
imageEditor(value: boolean = true): this { this._imageEditor = value; return this }
|
|
87
|
+
|
|
88
|
+
/** Aspect ratio presets shown in the image editor picker. */
|
|
89
|
+
imageEditorAspectRatioOptions(options: AspectRatioOption[]): this { this._imageEditorAspectRatioOptions = options; return this }
|
|
90
|
+
|
|
91
|
+
/** Render a circular crop overlay in the image editor. */
|
|
92
|
+
circleCropper(value: boolean = true): this { this._circleCropper = value; return this }
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* When aspect ratio options are configured, automatically apply the first
|
|
96
|
+
* ratio and skip showing the editor UI unless the user explicitly opens it.
|
|
97
|
+
*/
|
|
98
|
+
automaticallyCropImagesToAspectRatio(value: boolean = true): this { this._automaticallyCropImagesToAspectRatio = value; return this }
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resize the image to the given dimensions on the server after upload.
|
|
102
|
+
* Requires `@rudderjs/image` to be installed and the upload adapter to
|
|
103
|
+
* forward the `resize_width` / `resize_height` FormData fields.
|
|
104
|
+
*/
|
|
105
|
+
automaticallyResize(width: number, height: number): this { this._automaticallyResize = { width, height }; return this }
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Shorthand for `.imageEditor().circleCropper().multiple(false)`.
|
|
109
|
+
* Ideal for single avatar / profile-picture fields.
|
|
110
|
+
*/
|
|
111
|
+
avatar(value: boolean = true): this {
|
|
112
|
+
if (value) { this._imageEditor = true; this._circleCropper = true; this._multiple = false }
|
|
113
|
+
return this
|
|
114
|
+
}
|
|
115
|
+
|
|
48
116
|
getAccept(): string[] | undefined { return this._accept }
|
|
49
117
|
getMaxSize(): number | undefined { return this._maxSize }
|
|
50
118
|
isMultiple(): boolean { return this._multiple }
|
|
51
119
|
hasPreview(): boolean { return this._preview }
|
|
52
120
|
getDirectory(): string | undefined { return this._directory }
|
|
121
|
+
isDownloadable(): boolean { return this._downloadable }
|
|
122
|
+
isOpenable(): boolean { return this._openable }
|
|
123
|
+
isReorderable(): boolean { return this._reorderable }
|
|
124
|
+
doesAppendFiles(): boolean { return this._appendFiles }
|
|
125
|
+
getPanelLayout(): PanelLayout { return this._panelLayout }
|
|
126
|
+
hasImageEditor(): boolean { return this._imageEditor }
|
|
127
|
+
getImageEditorAspectRatioOptions(): AspectRatioOption[] | undefined { return this._imageEditorAspectRatioOptions }
|
|
128
|
+
hasCircleCropper(): boolean { return this._circleCropper }
|
|
129
|
+
doesAutomaticallyCrop(): boolean { return this._automaticallyCropImagesToAspectRatio }
|
|
130
|
+
getAutomaticallyResize(): { width: number; height: number } | undefined { return this._automaticallyResize }
|
|
53
131
|
|
|
54
132
|
override toMeta(ctx?: RenderContext): FieldMeta {
|
|
55
133
|
return {
|
|
56
134
|
...this.buildMeta(ctx),
|
|
57
135
|
multiple: this._multiple,
|
|
58
136
|
preview: this._preview,
|
|
59
|
-
...(this._accept
|
|
137
|
+
...(this._accept ? { accept: this._accept } : {}),
|
|
60
138
|
...(this._maxSize !== undefined ? { maxSize: this._maxSize } : {}),
|
|
61
|
-
...(this._directory
|
|
139
|
+
...(this._directory ? { directory: this._directory } : {}),
|
|
140
|
+
...(this._downloadable ? { downloadable: true } : {}),
|
|
141
|
+
...(this._openable ? { openable: true } : {}),
|
|
142
|
+
...(this._reorderable ? { reorderable: true } : {}),
|
|
143
|
+
...(this._appendFiles ? { appendFiles: true } : {}),
|
|
144
|
+
...(this._panelLayout !== 'list' ? { panelLayout: this._panelLayout } : {}),
|
|
145
|
+
...(this._imageEditor ? { imageEditor: true } : {}),
|
|
146
|
+
...(this._imageEditorAspectRatioOptions?.length ? { imageEditorAspectRatioOptions: this._imageEditorAspectRatioOptions } : {}),
|
|
147
|
+
...(this._circleCropper ? { circleCropper: true } : {}),
|
|
148
|
+
...(this._automaticallyCropImagesToAspectRatio ? { automaticallyCropImagesToAspectRatio: true } : {}),
|
|
149
|
+
...(this._automaticallyResize ? { automaticallyResize: this._automaticallyResize } : {}),
|
|
62
150
|
// `uploadUrl` is stamped via RenderContext by the page-data
|
|
63
151
|
// builders. Without it the renderer falls back to a clear error
|
|
64
152
|
// ("no upload URL configured"); the route handler (and therefore
|
|
@@ -588,9 +588,53 @@ export class RepeaterField extends Field {
|
|
|
588
588
|
*/
|
|
589
589
|
reorderAction(b: RowButton): this { this._buttons.reorder = b; return this }
|
|
590
590
|
|
|
591
|
-
/**
|
|
591
|
+
/**
|
|
592
|
+
* Customize the per-row collapse chevron. Applies to BOTH states by
|
|
593
|
+
* default — the open chevron and the collapsed chevron share the
|
|
594
|
+
* override unless `expandAction(...)` is also set, in which case
|
|
595
|
+
* `collapseAction` covers only the open state and `expandAction`
|
|
596
|
+
* covers the collapsed state.
|
|
597
|
+
*/
|
|
592
598
|
collapseAction(b: RowButton): this { this._buttons.collapse = b; return this }
|
|
593
599
|
|
|
600
|
+
/**
|
|
601
|
+
* Customize the per-row chevron when the row is currently *collapsed*
|
|
602
|
+
* (i.e. the "click me to expand" state). Sibling of `collapseAction`
|
|
603
|
+
* for the closed-state glyph; without this, both states fall through
|
|
604
|
+
* to `collapseAction` (back-compat) and ultimately to the default
|
|
605
|
+
* chevron pair (right when collapsed, down when open).
|
|
606
|
+
*/
|
|
607
|
+
expandAction(b: RowButton): this { this._buttons.expand = b; return this }
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Mount an "Expand all" button in the field header — clicking it opens
|
|
611
|
+
* every collapsed row. Opt-in: calling without args shows the button
|
|
612
|
+
* with the default icon (chevron-down) + label ("Expand all"); pass a
|
|
613
|
+
* `RowButton` to override icon / label / tooltip / color.
|
|
614
|
+
*
|
|
615
|
+
* Auto-arms `collapsible()` since the affordance is meaningless without
|
|
616
|
+
* collapsible rows. In `accordion()` mode the button opens the first
|
|
617
|
+
* visible row (accordion's "only one open" invariant survives).
|
|
618
|
+
*/
|
|
619
|
+
expandAllAction(button?: RowButton): this {
|
|
620
|
+
this._buttons.expandAll = button ?? RowButton.make()
|
|
621
|
+
this._collapsible = true
|
|
622
|
+
return this
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Mount a "Collapse all" button in the field header — clicking it
|
|
627
|
+
* collapses every open row. Opt-in (calling enables; pass a
|
|
628
|
+
* `RowButton` to customize). Auto-arms `collapsible()`. In `accordion()`
|
|
629
|
+
* mode the button closes the currently-open row, leaving everything
|
|
630
|
+
* collapsed.
|
|
631
|
+
*/
|
|
632
|
+
collapseAllAction(button?: RowButton): this {
|
|
633
|
+
this._buttons.collapseAll = button ?? RowButton.make()
|
|
634
|
+
this._collapsible = true
|
|
635
|
+
return this
|
|
636
|
+
}
|
|
637
|
+
|
|
594
638
|
/**
|
|
595
639
|
* Per-row action buttons rendered in each row's header alongside the
|
|
596
640
|
* built-in clone/delete strip. Useful for "Mark featured", "Send test",
|
|
@@ -98,6 +98,63 @@ describe('RepeaterField row-action customizers', () => {
|
|
|
98
98
|
assert.equal(r.getButton('delete')!.getColor(), 'destructive')
|
|
99
99
|
assert.equal(r.getButton('clone'), undefined)
|
|
100
100
|
})
|
|
101
|
+
|
|
102
|
+
test('expandAction lands on meta.buttons.expand (separate from collapseAction)', () => {
|
|
103
|
+
const meta = Repeater.make('items')
|
|
104
|
+
.schema([TextField.make('text')])
|
|
105
|
+
.collapsible()
|
|
106
|
+
.collapseAction(RowButton.make().icon('chevron-down'))
|
|
107
|
+
.expandAction(RowButton.make().icon('chevron-right').tooltip('Open'))
|
|
108
|
+
.toMeta() as { buttons?: { collapse?: { icon?: string }; expand?: { icon?: string; tooltip?: string } } }
|
|
109
|
+
assert.deepEqual(meta.buttons?.collapse, { icon: 'chevron-down' })
|
|
110
|
+
assert.deepEqual(meta.buttons?.expand, { icon: 'chevron-right', tooltip: 'Open' })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('expandAllAction() with no arg flips on the slot with empty defaults', () => {
|
|
114
|
+
const meta = Repeater.make('items')
|
|
115
|
+
.schema([TextField.make('text')])
|
|
116
|
+
.expandAllAction()
|
|
117
|
+
.toMeta()
|
|
118
|
+
assert.deepEqual(meta.buttons?.expandAll, {})
|
|
119
|
+
assert.equal(meta.collapsible, true, 'expandAllAction() auto-arms collapsible()')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('expandAllAction(button) keeps the override and still auto-arms collapsible', () => {
|
|
123
|
+
const meta = Repeater.make('items')
|
|
124
|
+
.schema([TextField.make('text')])
|
|
125
|
+
.expandAllAction(RowButton.make().label('Open everything').icon('chevrons-down'))
|
|
126
|
+
.toMeta() as { buttons?: { expandAll?: { label?: string; icon?: string } }; collapsible?: boolean }
|
|
127
|
+
assert.deepEqual(meta.buttons?.expandAll, { label: 'Open everything', icon: 'chevrons-down' })
|
|
128
|
+
assert.equal(meta.collapsible, true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('collapseAllAction() with no arg flips on the slot and auto-arms collapsible', () => {
|
|
132
|
+
const meta = Repeater.make('items')
|
|
133
|
+
.schema([TextField.make('text')])
|
|
134
|
+
.collapseAllAction()
|
|
135
|
+
.toMeta()
|
|
136
|
+
assert.deepEqual(meta.buttons?.collapseAll, {})
|
|
137
|
+
assert.equal(meta.collapsible, true)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('collapseAllAction(button) routes through the override', () => {
|
|
141
|
+
const meta = Repeater.make('items')
|
|
142
|
+
.schema([TextField.make('text')])
|
|
143
|
+
.collapseAllAction(RowButton.make().label('Hide all').color('muted'))
|
|
144
|
+
.toMeta() as { buttons?: { collapseAll?: { label?: string; color?: string } } }
|
|
145
|
+
assert.deepEqual(meta.buttons?.collapseAll, { label: 'Hide all', color: 'muted' })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('non-customized field omits the new slots entirely', () => {
|
|
149
|
+
// Bare field never serializes any of the four new slots — back-compat
|
|
150
|
+
// for renderers that read `meta.buttons` and assume only the original
|
|
151
|
+
// seven keys are reachable.
|
|
152
|
+
const meta = Repeater.make('items')
|
|
153
|
+
.schema([TextField.make('text')])
|
|
154
|
+
.collapsible()
|
|
155
|
+
.toMeta() as { buttons?: unknown }
|
|
156
|
+
assert.equal(meta.buttons, undefined)
|
|
157
|
+
})
|
|
101
158
|
})
|
|
102
159
|
|
|
103
160
|
describe('BuilderField row-action customizers', () => {
|
|
@@ -146,4 +203,17 @@ describe('BuilderField row-action customizers', () => {
|
|
|
146
203
|
.toMeta() as { buttons?: unknown }
|
|
147
204
|
assert.deepEqual(r.buttons, b.buttons)
|
|
148
205
|
})
|
|
206
|
+
|
|
207
|
+
test('expandAction / expandAllAction / collapseAllAction land on Builder meta with the same kind keys', () => {
|
|
208
|
+
const meta = Builder.make('blocks')
|
|
209
|
+
.blocks([Block.make('hero').schema([TextField.make('title')])])
|
|
210
|
+
.expandAction(RowButton.make().icon('chevron-right'))
|
|
211
|
+
.expandAllAction(RowButton.make().label('Open everything'))
|
|
212
|
+
.collapseAllAction()
|
|
213
|
+
.toMeta()
|
|
214
|
+
assert.deepEqual(meta.buttons?.expand, { icon: 'chevron-right' })
|
|
215
|
+
assert.deepEqual(meta.buttons?.expandAll, { label: 'Open everything' })
|
|
216
|
+
assert.deepEqual(meta.buttons?.collapseAll, {})
|
|
217
|
+
assert.equal(meta.collapsible, true, 'Builder bulk setters auto-arm collapsible()')
|
|
218
|
+
})
|
|
149
219
|
})
|
package/src/fields/RowButton.ts
CHANGED
|
@@ -107,9 +107,16 @@ export class RowButton {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
|
-
* Slot id for one of the
|
|
110
|
+
* Slot id for one of the built-in row chrome buttons. The renderer
|
|
111
111
|
* looks these up in `meta.buttons[kind]` to merge customizer overrides
|
|
112
112
|
* onto its hardcoded defaults.
|
|
113
|
+
*
|
|
114
|
+
* `expand` is the per-row sibling of `collapse` — used when the row is
|
|
115
|
+
* currently collapsed so authors can override icon/label/tooltip
|
|
116
|
+
* separately for each state. `expandAll` / `collapseAll` are the bulk
|
|
117
|
+
* field-header buttons; presence of either slot is what flips the
|
|
118
|
+
* matching button into existence (different from the always-rendered
|
|
119
|
+
* per-row chrome — see `RepeaterField.expandAllAction` for posture).
|
|
113
120
|
*/
|
|
114
121
|
export type RowButtonKind =
|
|
115
122
|
| 'add'
|
|
@@ -119,6 +126,9 @@ export type RowButtonKind =
|
|
|
119
126
|
| 'moveDown'
|
|
120
127
|
| 'reorder'
|
|
121
128
|
| 'collapse'
|
|
129
|
+
| 'expand'
|
|
130
|
+
| 'expandAll'
|
|
131
|
+
| 'collapseAll'
|
|
122
132
|
|
|
123
133
|
export type RowButtonsMeta = {
|
|
124
134
|
[K in RowButtonKind]?: RowButtonMeta
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { TextField } from './TextField.js'
|
|
5
|
+
import { Action } from '../actions/Action.js'
|
|
6
|
+
import { resolveSchema } from '../schema/resolveSchema.js'
|
|
7
|
+
import { coerceFormValues } from '../elements/dispatchForm.js'
|
|
8
|
+
import { formatWithMask } from '../react/fields/textInputControls.js'
|
|
9
|
+
|
|
10
|
+
describe('TextField rich affordances (audit gap #3)', () => {
|
|
11
|
+
describe('password / revealable', () => {
|
|
12
|
+
it('emits password + revealable flags only when set', () => {
|
|
13
|
+
const a = TextField.make('p').password().revealable().toMeta()
|
|
14
|
+
assert.equal(a['password'], true)
|
|
15
|
+
assert.equal(a['revealable'], true)
|
|
16
|
+
|
|
17
|
+
const b = TextField.make('p').toMeta()
|
|
18
|
+
assert.equal(b['password'], undefined)
|
|
19
|
+
assert.equal(b['revealable'], undefined)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('disarms with explicit false', () => {
|
|
23
|
+
const a = TextField.make('p').password(false).toMeta()
|
|
24
|
+
assert.equal(a['password'], undefined)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('copyable', () => {
|
|
29
|
+
it('flag emits and message defaults are sparse', () => {
|
|
30
|
+
const a = TextField.make('x').copyable().toMeta()
|
|
31
|
+
assert.equal(a['copyable'], true)
|
|
32
|
+
assert.equal(a['copyMessage'], undefined)
|
|
33
|
+
|
|
34
|
+
const b = TextField.make('x').copyable('Got it').toMeta()
|
|
35
|
+
assert.equal(b['copyable'], true)
|
|
36
|
+
assert.equal(b['copyMessage'], 'Got it')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('mask', () => {
|
|
41
|
+
it('emits the pattern verbatim', () => {
|
|
42
|
+
const a = TextField.make('phone').mask('(999) 999-9999').toMeta()
|
|
43
|
+
assert.equal(a['mask'], '(999) 999-9999')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('datalist', () => {
|
|
48
|
+
it('emits a defensive copy of the values array', () => {
|
|
49
|
+
const values = ['gmail.com', 'outlook.com']
|
|
50
|
+
const a = TextField.make('email').datalist(values).toMeta()
|
|
51
|
+
const out = a['datalist'] as string[]
|
|
52
|
+
assert.deepEqual(out, values)
|
|
53
|
+
// Mutating the original after the fact must not leak in.
|
|
54
|
+
values.push('yahoo.com')
|
|
55
|
+
assert.deepEqual(a['datalist'], ['gmail.com', 'outlook.com'])
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('stripCharacters', () => {
|
|
60
|
+
it('accepts a string of single chars', () => {
|
|
61
|
+
const a = TextField.make('phone').stripCharacters('()- ').toMeta()
|
|
62
|
+
assert.deepEqual(a['stripCharacters'], ['(', ')', '-', ' '])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('accepts an explicit array', () => {
|
|
66
|
+
const a = TextField.make('phone').stripCharacters(['(', ')']).toMeta()
|
|
67
|
+
assert.deepEqual(a['stripCharacters'], ['(', ')'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('omits when empty', () => {
|
|
71
|
+
const a = TextField.make('x').stripCharacters('').toMeta()
|
|
72
|
+
assert.equal(a['stripCharacters'], undefined)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('strips configured chars during coerce', () => {
|
|
76
|
+
const f = TextField.make('phone').stripCharacters('()- ')
|
|
77
|
+
const out = coerceFormValues([f], { phone: '(415) 555-1212' })
|
|
78
|
+
assert.equal(out['phone'], '4155551212')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('coerce no-ops when not configured', () => {
|
|
82
|
+
const f = TextField.make('plain')
|
|
83
|
+
const out = coerceFormValues([f], { plain: 'a-b-c' })
|
|
84
|
+
assert.equal(out['plain'], 'a-b-c')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('coerce skips non-string values', () => {
|
|
88
|
+
const f = TextField.make('plain').stripCharacters('-')
|
|
89
|
+
const out = coerceFormValues([f], { plain: 42 as unknown as string })
|
|
90
|
+
assert.equal(out['plain'], 42)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('inputMode + autocapitalize', () => {
|
|
95
|
+
it('emits each attribute when set', () => {
|
|
96
|
+
const a = TextField.make('q').inputMode('search').autocapitalize('off').toMeta()
|
|
97
|
+
assert.equal(a['inputMode'], 'search')
|
|
98
|
+
assert.equal(a['autocapitalize'], 'off')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('omits each attribute when unset', () => {
|
|
102
|
+
const a = TextField.make('q').toMeta()
|
|
103
|
+
assert.equal(a['inputMode'], undefined)
|
|
104
|
+
assert.equal(a['autocapitalize'], undefined)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('prefixAction / suffixAction', () => {
|
|
109
|
+
it('resolves bound Actions through resolveSchema as ActionMetas', async () => {
|
|
110
|
+
const result = await resolveSchema([
|
|
111
|
+
TextField.make('apiKey')
|
|
112
|
+
.prefixAction(Action.make('generate').icon('plus'))
|
|
113
|
+
.suffixAction(Action.make('rotate').icon('refresh')),
|
|
114
|
+
])
|
|
115
|
+
const meta = result[0]!
|
|
116
|
+
const pre = meta['prefixAction'] as Record<string, unknown> | undefined
|
|
117
|
+
const suf = meta['suffixAction'] as Record<string, unknown> | undefined
|
|
118
|
+
assert.equal(pre?.['type'], 'action')
|
|
119
|
+
assert.equal(pre?.['name'], 'generate')
|
|
120
|
+
assert.equal(suf?.['type'], 'action')
|
|
121
|
+
assert.equal(suf?.['name'], 'rotate')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('drops a hidden Action from the slot', async () => {
|
|
125
|
+
const result = await resolveSchema([
|
|
126
|
+
TextField.make('q').prefixAction(Action.make('hide').visible(false)),
|
|
127
|
+
])
|
|
128
|
+
assert.equal(result[0]!['prefixAction'], undefined)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('omits the slots when not configured', async () => {
|
|
132
|
+
const result = await resolveSchema([TextField.make('q')])
|
|
133
|
+
assert.equal(result[0]!['prefixAction'], undefined)
|
|
134
|
+
assert.equal(result[0]!['suffixAction'], undefined)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('formatWithMask (client mask helper)', () => {
|
|
140
|
+
it('formats a US phone via the documented alphabet', () => {
|
|
141
|
+
assert.equal(formatWithMask('4155551212', '(999) 999-9999'), '(415) 555-1212')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('emits literals even with no remaining input', () => {
|
|
145
|
+
assert.equal(formatWithMask('415', '(999) 999-9999'), '(415) ')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('skips characters that do not match the token kind', () => {
|
|
149
|
+
assert.equal(formatWithMask('a4b1c5', '999'), '415')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('handles alpha tokens', () => {
|
|
153
|
+
assert.equal(formatWithMask('xy12', 'aa-99'), 'xy-12')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('any-token (*) accepts any character', () => {
|
|
157
|
+
assert.equal(formatWithMask('a1b2', '****'), 'a1b2')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('does NOT double-emit literals already typed by the user', () => {
|
|
161
|
+
assert.equal(formatWithMask('(415)5551212', '(999) 999-9999'), '(415) 555-1212')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('returns input unchanged when mask is empty', () => {
|
|
165
|
+
assert.equal(formatWithMask('hello', ''), '')
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|