@pilotiq/pilotiq 0.2.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 +8 -0
- package/CLAUDE.md +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 +21 -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 +2 -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 +21 -1
- package/src/schema/Alert.test.ts +46 -0
- package/src/schema/Alert.ts +90 -8
- package/src/schema/resolveSchema.ts +32 -0
|
@@ -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
|
+
|
package/src/fields/TextField.ts
CHANGED
|
@@ -1,8 +1,47 @@
|
|
|
1
1
|
import { Field, type FieldMeta } from './Field.js'
|
|
2
2
|
import type { RenderContext } from '../schema/resolveSchema.js'
|
|
3
|
+
import type { Action } from '../actions/Action.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HTML5 `inputmode` values — drive the on-screen keyboard mobile browsers
|
|
7
|
+
* pop up. `'text'` is the default; the others map 1:1 to spec values.
|
|
8
|
+
*/
|
|
9
|
+
export type TextInputMode =
|
|
10
|
+
| 'none' | 'text' | 'numeric' | 'tel' | 'email' | 'decimal' | 'search' | 'url'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* HTML5 `autocapitalize` values — control which characters mobile virtual
|
|
14
|
+
* keyboards capitalize automatically. `'off'` and `'none'` are aliases per
|
|
15
|
+
* spec; we surface only `'off'` for clarity.
|
|
16
|
+
*/
|
|
17
|
+
export type TextAutocapitalize = 'off' | 'sentences' | 'words' | 'characters'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Mask alphabet documented for `mask(pattern)`:
|
|
21
|
+
* `9` — digit (0-9)
|
|
22
|
+
* `a` — alpha (A-Za-z)
|
|
23
|
+
* `*` — alphanumeric or any character
|
|
24
|
+
* anything else — literal (rendered verbatim, skipped on input)
|
|
25
|
+
*
|
|
26
|
+
* Examples: `'(999) 999-9999'` (US phone), `'9999-9999-9999-9999'` (card),
|
|
27
|
+
* `'aaa-9999'` (custom). The renderer formats values keystroke-by-keystroke
|
|
28
|
+
* and strips literals from the submitted value so the persisted column
|
|
29
|
+
* stores the raw digits/letters.
|
|
30
|
+
*/
|
|
3
31
|
|
|
4
32
|
export class TextField extends Field {
|
|
5
33
|
private _maxLength?: number
|
|
34
|
+
private _password = false
|
|
35
|
+
private _revealable = false
|
|
36
|
+
private _copyable = false
|
|
37
|
+
private _copyMessage?: string
|
|
38
|
+
private _mask?: string
|
|
39
|
+
private _datalist?: string[]
|
|
40
|
+
private _stripCharacters?: string[]
|
|
41
|
+
private _inputMode?: TextInputMode
|
|
42
|
+
private _autocapitalize?: TextAutocapitalize
|
|
43
|
+
private _prefixAction?: Action
|
|
44
|
+
private _suffixAction?: Action
|
|
6
45
|
|
|
7
46
|
private constructor(name: string) {
|
|
8
47
|
super(name, 'text')
|
|
@@ -15,10 +54,111 @@ export class TextField extends Field {
|
|
|
15
54
|
maxLength(n: number): this { this._maxLength = n; return this }
|
|
16
55
|
getMaxLength(): number | undefined { return this._maxLength }
|
|
17
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Render the input as `type="password"`. Pair with `revealable()` to
|
|
59
|
+
* surface an eye-icon toggle. Pure presentation — the column type +
|
|
60
|
+
* value handling stays string-shaped.
|
|
61
|
+
*/
|
|
62
|
+
password(v: boolean = true): this { this._password = v; return this }
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Mount an eye-icon toggle in the suffix slot that flips the input
|
|
66
|
+
* type between `password` and `text`. No-op when the field is not in
|
|
67
|
+
* `password()` mode (the toggle hides itself client-side so a stray
|
|
68
|
+
* `revealable()` on a non-password input doesn't render a useless
|
|
69
|
+
* button).
|
|
70
|
+
*/
|
|
71
|
+
revealable(v: boolean = true): this { this._revealable = v; return this }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Mount a copy button in the suffix slot that writes the current input
|
|
75
|
+
* value to the clipboard. The optional message overrides the default
|
|
76
|
+
* `'Copied!'` toast text — same convention as
|
|
77
|
+
* `Column.copyMessage()`.
|
|
78
|
+
*/
|
|
79
|
+
copyable(message?: string): this {
|
|
80
|
+
this._copyable = true
|
|
81
|
+
if (message !== undefined) this._copyMessage = message
|
|
82
|
+
return this
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format the input keystroke-by-keystroke against the supplied mask
|
|
87
|
+
* pattern. See `TextField` doc-block for the alphabet (`9` = digit,
|
|
88
|
+
* `a` = alpha, `*` = any, literals passthrough). Submitted values are
|
|
89
|
+
* stripped of literal characters before the form body lands on the
|
|
90
|
+
* server — the persisted column stores the raw chars only.
|
|
91
|
+
*/
|
|
92
|
+
mask(pattern: string): this { this._mask = pattern; return this }
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Suggest values via a native HTML5 `<datalist>` attached to the
|
|
96
|
+
* input. Browsers render an autocomplete dropdown; users can still
|
|
97
|
+
* type a value not on the list. Useful for canonical-but-not-exclusive
|
|
98
|
+
* sets (countries, departments, common email domains).
|
|
99
|
+
*/
|
|
100
|
+
datalist(values: string[]): this { this._datalist = values.slice(); return this }
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Strip the listed characters from the submitted value before
|
|
104
|
+
* validation runs. Pass a single string (each char becomes a strip
|
|
105
|
+
* token) or an array of strings. Useful for input-mask-style fields
|
|
106
|
+
* where the persisted column should not carry the mask literals
|
|
107
|
+
* (`'(' / ')' / '-' / ' '` for phone numbers, `' '` for credit cards).
|
|
108
|
+
* The strip applies on both create and edit — server-side authority,
|
|
109
|
+
* so a tampered client still gets cleaned values.
|
|
110
|
+
*/
|
|
111
|
+
stripCharacters(chars: string | string[]): this {
|
|
112
|
+
const list = typeof chars === 'string' ? Array.from(chars) : [...chars]
|
|
113
|
+
if (list.length === 0) delete this._stripCharacters
|
|
114
|
+
else this._stripCharacters = list
|
|
115
|
+
return this
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
getStripCharacters(): string[] | undefined { return this._stripCharacters }
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Set the HTML `inputmode` attribute — drives the virtual-keyboard
|
|
122
|
+
* layout on mobile. Distinct from `type=` (a `text` field with
|
|
123
|
+
* `inputMode('numeric')` still accepts non-digit pastes; for strict
|
|
124
|
+
* numeric-only, use `NumberField` instead).
|
|
125
|
+
*/
|
|
126
|
+
inputMode(mode: TextInputMode): this { this._inputMode = mode; return this }
|
|
127
|
+
|
|
128
|
+
/** Set the HTML `autocapitalize` attribute. */
|
|
129
|
+
autocapitalize(mode: TextAutocapitalize): this { this._autocapitalize = mode; return this }
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Mount a clickable Action button in the prefix slot of the input
|
|
133
|
+
* shell. Distinct from the passive `prefix()` decoration (which is a
|
|
134
|
+
* string or icon descriptor only). Use this for an in-input affordance
|
|
135
|
+
* — e.g. an OAuth-style "Generate" button next to an API key field.
|
|
136
|
+
* The Action retains its full chrome (`.icon() / .color() / .visible()
|
|
137
|
+
* / .modal*()`); visibility rules evaluate through the standard schema
|
|
138
|
+
* walker the same way they do anywhere else.
|
|
139
|
+
*/
|
|
140
|
+
prefixAction(action: Action): this { this._prefixAction = action; return this }
|
|
141
|
+
|
|
142
|
+
/** Suffix-slot variant of `prefixAction`. Same semantics. */
|
|
143
|
+
suffixAction(action: Action): this { this._suffixAction = action; return this }
|
|
144
|
+
|
|
145
|
+
/** Read-only access for the resolver. */
|
|
146
|
+
getPrefixAction(): Action | undefined { return this._prefixAction }
|
|
147
|
+
getSuffixAction(): Action | undefined { return this._suffixAction }
|
|
148
|
+
|
|
18
149
|
override toMeta(ctx?: RenderContext): FieldMeta {
|
|
19
150
|
return {
|
|
20
151
|
...this.buildMeta(ctx),
|
|
21
|
-
...(this._maxLength
|
|
152
|
+
...(this._maxLength !== undefined ? { maxLength: this._maxLength } : {}),
|
|
153
|
+
...(this._password ? { password: true } : {}),
|
|
154
|
+
...(this._revealable ? { revealable: true } : {}),
|
|
155
|
+
...(this._copyable ? { copyable: true } : {}),
|
|
156
|
+
...(this._copyMessage !== undefined ? { copyMessage: this._copyMessage } : {}),
|
|
157
|
+
...(this._mask !== undefined ? { mask: this._mask } : {}),
|
|
158
|
+
...(this._datalist !== undefined ? { datalist: this._datalist } : {}),
|
|
159
|
+
...(this._stripCharacters!== undefined ? { stripCharacters:this._stripCharacters} : {}),
|
|
160
|
+
...(this._inputMode !== undefined ? { inputMode: this._inputMode } : {}),
|
|
161
|
+
...(this._autocapitalize !== undefined ? { autocapitalize: this._autocapitalize } : {}),
|
|
22
162
|
}
|
|
23
163
|
}
|
|
24
164
|
}
|
|
@@ -140,6 +140,24 @@ describe('TextConstraint', () => {
|
|
|
140
140
|
assert.deepEqual(q.ops[0]!.args, ['title', 'LIKE', '%hello%'])
|
|
141
141
|
})
|
|
142
142
|
|
|
143
|
+
it('notContains → NOT LIKE %v%', () => {
|
|
144
|
+
const q = new FakeQuery()
|
|
145
|
+
TextConstraint.make('title').apply(q, 'notContains', 'spam')
|
|
146
|
+
assert.deepEqual(q.ops[0]!.args, ['title', 'NOT LIKE', '%spam%'])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('notContains escapes LIKE wildcards', () => {
|
|
150
|
+
const q = new FakeQuery()
|
|
151
|
+
TextConstraint.make('title').apply(q, 'notContains', '50% off')
|
|
152
|
+
assert.deepEqual(q.ops[0]!.args, ['title', 'NOT LIKE', '%50\\% off%'])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('notContains drops empty string', () => {
|
|
156
|
+
const q = new FakeQuery()
|
|
157
|
+
TextConstraint.make('title').apply(q, 'notContains', '')
|
|
158
|
+
assert.equal(q.ops.length, 0)
|
|
159
|
+
})
|
|
160
|
+
|
|
143
161
|
it('startsWith / endsWith / equals / notEquals', () => {
|
|
144
162
|
const checks: Array<[string, string, string]> = [
|
|
145
163
|
['startsWith', 'LIKE', 'foo%'],
|
|
@@ -26,7 +26,7 @@ export type ConstraintValueKind =
|
|
|
26
26
|
*/
|
|
27
27
|
export type ConstraintOperatorName =
|
|
28
28
|
| 'equals' | 'notEquals'
|
|
29
|
-
| 'contains' | 'startsWith' | 'endsWith'
|
|
29
|
+
| 'contains' | 'notContains' | 'startsWith' | 'endsWith'
|
|
30
30
|
| 'lt' | 'lte' | 'gt' | 'gte' | 'between'
|
|
31
31
|
| 'before' | 'after' | 'dateBetween'
|
|
32
32
|
| 'in' | 'notIn'
|