@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
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'
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { Constraint, type ConstraintOperator, type ConstraintOperatorName } from './Constraint.js'
|
|
2
2
|
import type { ModelQuery } from '../../orm/modelDefaults.js'
|
|
3
3
|
|
|
4
|
-
// `notContains` is intentionally omitted in v1 — the rudder Prisma adapter
|
|
5
|
-
// doesn't translate `NOT LIKE` (only `LIKE`). Re-add once the adapter
|
|
6
|
-
// learns negation, mirrored on the pilotiq `ModelWhereOperator` union.
|
|
7
4
|
const OPERATORS: ConstraintOperator[] = [
|
|
8
5
|
{ name: 'contains', label: 'Contains', valueKind: 'text' },
|
|
6
|
+
{ name: 'notContains', label: 'Does not contain', valueKind: 'text' },
|
|
9
7
|
{ name: 'equals', label: 'Equals', valueKind: 'text' },
|
|
10
8
|
{ name: 'notEquals', label: 'Does not equal', valueKind: 'text' },
|
|
11
9
|
{ name: 'startsWith', label: 'Starts with', valueKind: 'text' },
|
|
@@ -38,9 +36,10 @@ export class TextConstraint extends Constraint {
|
|
|
38
36
|
if (v === '') return query
|
|
39
37
|
|
|
40
38
|
switch (operator) {
|
|
41
|
-
case 'contains': return query.where(this.name, 'LIKE',
|
|
42
|
-
case '
|
|
43
|
-
case '
|
|
39
|
+
case 'contains': return query.where(this.name, 'LIKE', `%${escapeLike(v)}%`)
|
|
40
|
+
case 'notContains': return query.where(this.name, 'NOT LIKE', `%${escapeLike(v)}%`)
|
|
41
|
+
case 'startsWith': return query.where(this.name, 'LIKE', `${escapeLike(v)}%`)
|
|
42
|
+
case 'endsWith': return query.where(this.name, 'LIKE', `%${escapeLike(v)}`)
|
|
44
43
|
case 'equals': return query.where(this.name, '=', v)
|
|
45
44
|
case 'notEquals': return query.where(this.name, '!=', v)
|
|
46
45
|
default: return query
|
package/src/orm/modelDefaults.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { SaveHandler, LoadRecordHandler, FormContext } from '../elements/Fo
|
|
|
8
8
|
* structurally assignable to `ModelLike` — but pilotiq doesn't import
|
|
9
9
|
* `@rudderjs/contracts` here to keep this file dependency-light.
|
|
10
10
|
*/
|
|
11
|
-
export type ModelWhereOperator = '=' | '!=' | '>' | '>=' | '<' | '<=' | 'LIKE' | 'IN' | 'NOT IN'
|
|
11
|
+
export type ModelWhereOperator = '=' | '!=' | '>' | '>=' | '<' | '<=' | 'LIKE' | 'NOT LIKE' | 'IN' | 'NOT IN'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Context passed into `Resource.query(ctx)`. Carries the resolved user so
|
|
@@ -7,6 +7,7 @@ import { Input } from './ui/input.js'
|
|
|
7
7
|
import { Popover, PopoverTrigger, PopoverContent } from './ui/popover.js'
|
|
8
8
|
import { FieldShell } from './fields/FieldShell.js'
|
|
9
9
|
import { TextLikeInput } from './fields/TextLikeInput.js'
|
|
10
|
+
import { useTextInputControls } from './fields/textInputControls.js'
|
|
10
11
|
import { SelectFieldInput } from './fields/SelectFieldInput.js'
|
|
11
12
|
import { ToggleFieldInput } from './fields/ToggleFieldInput.js'
|
|
12
13
|
import { DateFieldInput } from './fields/DateFieldInput.js'
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
CalendarIcon, FilterIcon, MoreHorizontalIcon,
|
|
66
67
|
CircleIcon, InboxIcon, GripVerticalIcon,
|
|
67
68
|
ChevronDownIcon, CopyIcon, CheckIcon, XIcon,
|
|
69
|
+
InfoIcon, TriangleAlertIcon, CircleCheckIcon, CircleAlertIcon,
|
|
68
70
|
} from 'lucide-react'
|
|
69
71
|
import type { ComponentType } from 'react'
|
|
70
72
|
import { useNavigate, type NavigateFn } from './navigate.js'
|
|
@@ -215,6 +217,23 @@ function renderField(el: ElementMeta, index: number): React.ReactNode {
|
|
|
215
217
|
)
|
|
216
218
|
}
|
|
217
219
|
|
|
220
|
+
// TextField (and slug) rich affordances live in a dedicated shell so
|
|
221
|
+
// `useTextInputControls` can hold reveal-toggle / mask state via React
|
|
222
|
+
// hooks (renderField itself is a plain function, hooks would violate
|
|
223
|
+
// rules-of-hooks here).
|
|
224
|
+
if (fieldType === 'text' || fieldType === 'slug') {
|
|
225
|
+
return (
|
|
226
|
+
<TextFieldShell
|
|
227
|
+
key={index}
|
|
228
|
+
el={el}
|
|
229
|
+
name={name}
|
|
230
|
+
label={label}
|
|
231
|
+
required={required}
|
|
232
|
+
common={common}
|
|
233
|
+
/>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
218
237
|
const input = renderFieldInput(fieldType, el, name, defaultValue, defaultStr, common, disabled, required, placeholder)
|
|
219
238
|
|
|
220
239
|
return (
|
|
@@ -224,6 +243,66 @@ function renderField(el: ElementMeta, index: number): React.ReactNode {
|
|
|
224
243
|
)
|
|
225
244
|
}
|
|
226
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Component-shape TextField renderer — wraps the input shell so we can
|
|
248
|
+
* use `useTextInputControls()` (which holds the eye-toggle / mask state).
|
|
249
|
+
* Keeps `renderField` itself hook-free.
|
|
250
|
+
*/
|
|
251
|
+
function TextFieldShell({
|
|
252
|
+
el, name, label, required, common,
|
|
253
|
+
}: {
|
|
254
|
+
el: ElementMeta
|
|
255
|
+
name: string
|
|
256
|
+
label: string
|
|
257
|
+
required: boolean
|
|
258
|
+
common: Record<string, unknown>
|
|
259
|
+
}): React.ReactElement {
|
|
260
|
+
const controls = useTextInputControls(el, name, (m) => renderElement(m, 0))
|
|
261
|
+
|
|
262
|
+
// Build the input with all the new HTML attrs (inputMode /
|
|
263
|
+
// autocapitalize / list / maxLength + the password/text type from
|
|
264
|
+
// the controls hook).
|
|
265
|
+
const textExtra: Record<string, unknown> = {}
|
|
266
|
+
if (el['maxLength'] !== undefined) textExtra['maxLength'] = Number(el['maxLength'])
|
|
267
|
+
if (el['inputMode'] !== undefined) textExtra['inputMode'] = String(el['inputMode'])
|
|
268
|
+
if (el['autocapitalize'] !== undefined) textExtra['autoCapitalize'] = String(el['autocapitalize'])
|
|
269
|
+
if (Array.isArray(el['datalist'])) textExtra['list'] = `${name}__datalist`
|
|
270
|
+
|
|
271
|
+
const datalist = Array.isArray(el['datalist']) ? (el['datalist'] as string[]) : undefined
|
|
272
|
+
|
|
273
|
+
const input = (
|
|
274
|
+
<>
|
|
275
|
+
<TextLikeInput
|
|
276
|
+
el={el}
|
|
277
|
+
name={name}
|
|
278
|
+
common={common}
|
|
279
|
+
type={controls.type}
|
|
280
|
+
extraProps={textExtra}
|
|
281
|
+
multiline={false}
|
|
282
|
+
applyMask={controls.applyMask}
|
|
283
|
+
/>
|
|
284
|
+
{datalist && (
|
|
285
|
+
<datalist id={`${name}__datalist`}>
|
|
286
|
+
{datalist.map((v, i) => <option key={i} value={v} />)}
|
|
287
|
+
</datalist>
|
|
288
|
+
)}
|
|
289
|
+
</>
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<FieldShell
|
|
294
|
+
el={el}
|
|
295
|
+
name={name}
|
|
296
|
+
label={label}
|
|
297
|
+
required={required}
|
|
298
|
+
before={controls.before}
|
|
299
|
+
after={controls.after}
|
|
300
|
+
>
|
|
301
|
+
{input}
|
|
302
|
+
</FieldShell>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
227
306
|
function renderFieldInput(
|
|
228
307
|
fieldType: string,
|
|
229
308
|
el: ElementMeta,
|
|
@@ -404,6 +483,24 @@ function renderFieldInput(
|
|
|
404
483
|
preview={el['preview'] !== false}
|
|
405
484
|
directory={typeof el['directory'] === 'string' ? el['directory'] : undefined}
|
|
406
485
|
uploadUrl={typeof el['uploadUrl'] === 'string' ? el['uploadUrl'] : undefined}
|
|
486
|
+
downloadable={Boolean(el['downloadable'])}
|
|
487
|
+
openable={Boolean(el['openable'])}
|
|
488
|
+
reorderable={Boolean(el['reorderable'])}
|
|
489
|
+
appendFiles={Boolean(el['appendFiles'])}
|
|
490
|
+
panelLayout={
|
|
491
|
+
el['panelLayout'] === 'grid' ? 'grid'
|
|
492
|
+
: el['panelLayout'] === 'integrated' ? 'integrated'
|
|
493
|
+
: 'list'
|
|
494
|
+
}
|
|
495
|
+
{...(el['automaticallyResize'] && typeof el['automaticallyResize'] === 'object'
|
|
496
|
+
? { automaticallyResize: el['automaticallyResize'] as { width: number; height: number } }
|
|
497
|
+
: {})}
|
|
498
|
+
imageEditor={Boolean(el['imageEditor'])}
|
|
499
|
+
circleCropper={Boolean(el['circleCropper'])}
|
|
500
|
+
automaticallyCropImagesToAspectRatio={Boolean(el['automaticallyCropImagesToAspectRatio'])}
|
|
501
|
+
{...(Array.isArray(el['imageEditorAspectRatioOptions'])
|
|
502
|
+
? { imageEditorAspectRatioOptions: el['imageEditorAspectRatioOptions'] as Array<{ ratio: number; label: string }> }
|
|
503
|
+
: {})}
|
|
407
504
|
/>
|
|
408
505
|
)
|
|
409
506
|
}
|
|
@@ -732,6 +829,9 @@ function ActionModalDialog({
|
|
|
732
829
|
const dispatchUrl = meta['dispatchUrl'] as string | undefined
|
|
733
830
|
const fields = (meta.children ?? []) as ElementMeta[]
|
|
734
831
|
const hasForm = fields.length > 0
|
|
832
|
+
// Filament v5 — auxiliary Elements stamped by the resolver between
|
|
833
|
+
// the body and the footer (Alert / Text / Heading / Action / …).
|
|
834
|
+
const contentFooter = (meta['modalContentFooter'] ?? []) as ElementMeta[]
|
|
735
835
|
|
|
736
836
|
const heading = modal?.heading ?? confirm?.title ?? (hasForm ? String(meta['label'] ?? 'Submit') : 'Are you sure?')
|
|
737
837
|
const description = modal?.description ?? confirm?.message
|
|
@@ -897,12 +997,13 @@ function ActionModalDialog({
|
|
|
897
997
|
</DialogTitle>
|
|
898
998
|
{description && <DialogDescription>{description}</DialogDescription>}
|
|
899
999
|
</DialogHeader>
|
|
900
|
-
{hasForm && (
|
|
1000
|
+
{(hasForm || contentFooter.length > 0) && (
|
|
901
1001
|
<div className={`flex flex-col gap-3 py-2 ${bodyCls}`.trim()}>
|
|
902
1002
|
{fields.map((f, i) => renderFormChild(f, i, initialValues, errors))}
|
|
1003
|
+
{contentFooter.map((c, i) => renderElement(c, fields.length + i))}
|
|
903
1004
|
</div>
|
|
904
1005
|
)}
|
|
905
|
-
{!hasForm && stickyMode && <div className={bodyCls} />}
|
|
1006
|
+
{!hasForm && contentFooter.length === 0 && stickyMode && <div className={bodyCls} />}
|
|
906
1007
|
{serverError && (
|
|
907
1008
|
<p className={`py-2 text-sm text-destructive ${stickyMode ? 'px-6' : ''}`.trim()}>{serverError}</p>
|
|
908
1009
|
)}
|
|
@@ -2663,6 +2764,114 @@ function EntryCopyButton({ text, label }: { text: string; label: string }): Reac
|
|
|
2663
2764
|
)
|
|
2664
2765
|
}
|
|
2665
2766
|
|
|
2767
|
+
// ─── Alert renderer ─────────────────────────────────────────
|
|
2768
|
+
//
|
|
2769
|
+
// Owns dismissal state (per-mount + optional localStorage persistence)
|
|
2770
|
+
// + icon dispatch + footer-actions alignment. Lifted out of the inline
|
|
2771
|
+
// `case 'alert'` branch when Alert gained `dismissible() / iconColor() /
|
|
2772
|
+
// footerActionsAlignment()` setters — those need component-local state
|
|
2773
|
+
// (the dismiss button, the persisted-dismissal hydration on mount), and
|
|
2774
|
+
// inlining the hooks under a switch arm is fragile.
|
|
2775
|
+
|
|
2776
|
+
const ALERT_TYPE_ICONS: Record<string, ComponentType<{ className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>> = {
|
|
2777
|
+
info: InfoIcon,
|
|
2778
|
+
warning: TriangleAlertIcon,
|
|
2779
|
+
success: CircleCheckIcon,
|
|
2780
|
+
danger: CircleAlertIcon,
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
const ALERT_TYPE_DEFAULT_ICON_COLOR: Record<string, string> = {
|
|
2784
|
+
info: 'info',
|
|
2785
|
+
warning: 'warning',
|
|
2786
|
+
success: 'success',
|
|
2787
|
+
danger: 'destructive',
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
const ALERT_ACTIONS_ALIGNMENT: Record<string, string> = {
|
|
2791
|
+
start: 'justify-start',
|
|
2792
|
+
center: 'justify-center',
|
|
2793
|
+
end: 'justify-end',
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
function alertPersistKey(persistKey: string): string {
|
|
2797
|
+
return `pilotiq.alert.${persistKey}`
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
function AlertRenderer(props: {
|
|
2801
|
+
alertType: string
|
|
2802
|
+
content: string
|
|
2803
|
+
title?: string
|
|
2804
|
+
dismissible?: boolean
|
|
2805
|
+
persistDismissal?: string
|
|
2806
|
+
iconColor?: string
|
|
2807
|
+
actionsAlignment?: string
|
|
2808
|
+
footer: React.ReactNode[]
|
|
2809
|
+
}): React.ReactNode {
|
|
2810
|
+
const {
|
|
2811
|
+
alertType, content, title, dismissible,
|
|
2812
|
+
persistDismissal, iconColor, actionsAlignment, footer,
|
|
2813
|
+
} = props
|
|
2814
|
+
const [dismissed, setDismissed] = useState(false)
|
|
2815
|
+
|
|
2816
|
+
// Hydrate persisted-dismissal on first paint. `useState(false)` keeps
|
|
2817
|
+
// SSR + first client paint identical (Hydration safe); the effect
|
|
2818
|
+
// flips to dismissed if localStorage has the flag set.
|
|
2819
|
+
useEffect(() => {
|
|
2820
|
+
if (!persistDismissal) return
|
|
2821
|
+
if (typeof window === 'undefined') return
|
|
2822
|
+
try {
|
|
2823
|
+
if (window.localStorage.getItem(alertPersistKey(persistDismissal)) === '1') {
|
|
2824
|
+
setDismissed(true)
|
|
2825
|
+
}
|
|
2826
|
+
} catch { /* localStorage blocked (Safari ITP / SSR) — render visible */ }
|
|
2827
|
+
}, [persistDismissal])
|
|
2828
|
+
|
|
2829
|
+
if (dismissed) return null
|
|
2830
|
+
|
|
2831
|
+
const styles = alertStyles[alertType] ?? alertStyles['info']!
|
|
2832
|
+
const Icon = ALERT_TYPE_ICONS[alertType] ?? InfoIcon
|
|
2833
|
+
const iconColorKey = iconColor ?? ALERT_TYPE_DEFAULT_ICON_COLOR[alertType] ?? 'info'
|
|
2834
|
+
const iconColorCls = TEXT_COLOR_CLASSES[iconColorKey] ?? ''
|
|
2835
|
+
const alignCls = ALERT_ACTIONS_ALIGNMENT[actionsAlignment ?? 'start'] ?? 'justify-start'
|
|
2836
|
+
|
|
2837
|
+
const handleDismiss = (): void => {
|
|
2838
|
+
setDismissed(true)
|
|
2839
|
+
if (persistDismissal && typeof window !== 'undefined') {
|
|
2840
|
+
try {
|
|
2841
|
+
window.localStorage.setItem(alertPersistKey(persistDismissal), '1')
|
|
2842
|
+
} catch { /* localStorage blocked — dismiss is per-mount only */ }
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
return (
|
|
2847
|
+
<div className={`relative rounded-lg border p-4 ${styles} ${dismissible ? 'pr-9' : ''}`}>
|
|
2848
|
+
<div className="flex gap-3">
|
|
2849
|
+
<Icon className={`size-5 shrink-0 mt-0.5 ${iconColorCls}`} aria-hidden="true" />
|
|
2850
|
+
<div className="flex-1 min-w-0">
|
|
2851
|
+
{title !== undefined && <p className="font-medium mb-1">{title}</p>}
|
|
2852
|
+
<p className="text-sm">{content}</p>
|
|
2853
|
+
{footer.length > 0 && (
|
|
2854
|
+
<div className={`flex items-center gap-2 mt-3 ${alignCls}`}>
|
|
2855
|
+
{footer}
|
|
2856
|
+
</div>
|
|
2857
|
+
)}
|
|
2858
|
+
</div>
|
|
2859
|
+
</div>
|
|
2860
|
+
{dismissible && (
|
|
2861
|
+
<button
|
|
2862
|
+
type="button"
|
|
2863
|
+
onClick={handleDismiss}
|
|
2864
|
+
aria-label="Dismiss"
|
|
2865
|
+
title="Dismiss"
|
|
2866
|
+
className="absolute top-3 right-3 inline-flex h-6 w-6 items-center justify-center rounded opacity-70 hover:opacity-100 transition-opacity"
|
|
2867
|
+
>
|
|
2868
|
+
<XIcon className="size-4" aria-hidden="true" />
|
|
2869
|
+
</button>
|
|
2870
|
+
)}
|
|
2871
|
+
</div>
|
|
2872
|
+
)
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2666
2875
|
function renderElement(el: ElementMeta, index: number): React.ReactNode {
|
|
2667
2876
|
switch (el.type) {
|
|
2668
2877
|
case 'text':
|
|
@@ -2753,20 +2962,19 @@ function renderElement(el: ElementMeta, index: number): React.ReactNode {
|
|
|
2753
2962
|
}
|
|
2754
2963
|
|
|
2755
2964
|
case 'alert': {
|
|
2756
|
-
const alertType = String(el['alertType'] ?? 'info')
|
|
2757
|
-
const styles = alertStyles[alertType] ?? alertStyles['info']
|
|
2758
|
-
const title = el['title'] ? String(el['title']) : undefined
|
|
2759
2965
|
const footer = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup')
|
|
2760
2966
|
return (
|
|
2761
|
-
<
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
{
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
)}
|
|
2769
|
-
|
|
2967
|
+
<AlertRenderer
|
|
2968
|
+
key={index}
|
|
2969
|
+
alertType={String(el['alertType'] ?? 'info')}
|
|
2970
|
+
content={String(el['content'] ?? '')}
|
|
2971
|
+
{...(el['title'] !== undefined ? { title: String(el['title']) } : {})}
|
|
2972
|
+
{...(el['dismissible'] ? { dismissible: Boolean(el['dismissible']) } : {})}
|
|
2973
|
+
{...(el['persistDismissal'] !== undefined ? { persistDismissal: String(el['persistDismissal']) } : {})}
|
|
2974
|
+
{...(el['iconColor'] !== undefined ? { iconColor: String(el['iconColor']) } : {})}
|
|
2975
|
+
{...(el['actionsAlignment'] !== undefined ? { actionsAlignment: String(el['actionsAlignment']) } : {})}
|
|
2976
|
+
footer={footer.map((a, i) => renderActionLike(a, i))}
|
|
2977
|
+
/>
|
|
2770
2978
|
)
|
|
2771
2979
|
}
|
|
2772
2980
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
RowChromeIconButton,
|
|
14
14
|
ReorderGrip,
|
|
15
15
|
CollapseChevron,
|
|
16
|
+
BulkCollapseHeader,
|
|
16
17
|
resolveRowChrome,
|
|
17
18
|
DEFAULT_MOVE_UP,
|
|
18
19
|
DEFAULT_MOVE_DOWN,
|
|
@@ -368,6 +369,35 @@ export function BuilderInput({
|
|
|
368
369
|
})
|
|
369
370
|
}
|
|
370
371
|
|
|
372
|
+
// Bulk expand / collapse — accordion preserves its "only one open"
|
|
373
|
+
// invariant by opening the first visible row on expandAll and clearing
|
|
374
|
+
// openId on collapseAll. Per-row mode iterates every row and writes
|
|
375
|
+
// the storage slot so reload restores the bulk state.
|
|
376
|
+
const expandAll = (): void => {
|
|
377
|
+
if (accordion) {
|
|
378
|
+
const firstVisible = rows.find(r => !r.hidden)
|
|
379
|
+
const next = firstVisible?.id ?? null
|
|
380
|
+
setAccordionOpenId(next)
|
|
381
|
+
writeAccordionToStorage(formId, name, next)
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
setCollapsed({})
|
|
385
|
+
for (const r of rows) writeCollapsedToStorage(formId, name, r.id, false)
|
|
386
|
+
}
|
|
387
|
+
const collapseAll = (): void => {
|
|
388
|
+
if (accordion) {
|
|
389
|
+
setAccordionOpenId(null)
|
|
390
|
+
writeAccordionToStorage(formId, name, null)
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
const next: Record<string, boolean> = {}
|
|
394
|
+
for (const r of rows) {
|
|
395
|
+
next[r.id] = true
|
|
396
|
+
writeCollapsedToStorage(formId, name, r.id, true)
|
|
397
|
+
}
|
|
398
|
+
setCollapsed(next)
|
|
399
|
+
}
|
|
400
|
+
|
|
371
401
|
const hasVisibleRow = rows.some(r => !r.hidden)
|
|
372
402
|
const firstVisibleIdx = rows.findIndex(r => !r.hidden)
|
|
373
403
|
const lastVisibleIdx = (() => {
|
|
@@ -388,6 +418,13 @@ export function BuilderInput({
|
|
|
388
418
|
onChange={onContainerChange}
|
|
389
419
|
onBlur={onContainerBlur}
|
|
390
420
|
>
|
|
421
|
+
<BulkCollapseHeader
|
|
422
|
+
buttons={buttons}
|
|
423
|
+
disabled={disabled || !hasVisibleRow}
|
|
424
|
+
onExpandAll={expandAll}
|
|
425
|
+
onCollapseAll={collapseAll}
|
|
426
|
+
/>
|
|
427
|
+
|
|
391
428
|
{!hasVisibleRow && (
|
|
392
429
|
<div className="rounded-md border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
|
|
393
430
|
No items yet. Click {addLabel} to start.
|
|
@@ -20,9 +20,17 @@ export interface FieldShellProps {
|
|
|
20
20
|
label: string
|
|
21
21
|
required: boolean
|
|
22
22
|
children: React.ReactNode
|
|
23
|
+
/** Optional ReactNode rendered to the left of the input, after the
|
|
24
|
+
* passive `prefix` decoration (when set). Used by `TextField`'s
|
|
25
|
+
* `prefixAction()` / mask / datalist / etc. — composes with the
|
|
26
|
+
* passive `prefix` slot rather than replacing it. */
|
|
27
|
+
before?: React.ReactNode
|
|
28
|
+
/** Right-of-input counterpart. Used by `revealable() / copyable() /
|
|
29
|
+
* suffixAction()`. Renders after the passive `suffix` decoration. */
|
|
30
|
+
after?: React.ReactNode
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
export function FieldShell({ el, name, label, required, children }: FieldShellProps): React.ReactElement {
|
|
33
|
+
export function FieldShell({ el, name, label, required, children, before, after }: FieldShellProps): React.ReactElement {
|
|
26
34
|
const prefix = el['prefix'] as string | { icon: string } | undefined
|
|
27
35
|
const suffix = el['suffix'] as string | { icon: string } | undefined
|
|
28
36
|
const helperText = el['helperText'] as string | undefined
|
|
@@ -39,12 +47,15 @@ export function FieldShell({ el, name, label, required, children }: FieldShellPr
|
|
|
39
47
|
</label>
|
|
40
48
|
) : null
|
|
41
49
|
|
|
42
|
-
const
|
|
50
|
+
const hasDecoration = !!(prefix || suffix || before || after)
|
|
51
|
+
const input = hasDecoration
|
|
43
52
|
? (
|
|
44
53
|
<div className="flex items-center gap-2">
|
|
54
|
+
{before}
|
|
45
55
|
{prefix && <Decoration content={prefix} side="prefix" />}
|
|
46
56
|
<div className="flex-1 min-w-0">{children}</div>
|
|
47
57
|
{suffix && <Decoration content={suffix} side="suffix" />}
|
|
58
|
+
{after}
|
|
48
59
|
</div>
|
|
49
60
|
)
|
|
50
61
|
: children
|