@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.
Files changed (108) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/CLAUDE.md +6 -5
  4. package/dist/Pilotiq.d.ts +20 -1
  5. package/dist/Pilotiq.d.ts.map +1 -1
  6. package/dist/Pilotiq.js.map +1 -1
  7. package/dist/actions/Action.d.ts +25 -0
  8. package/dist/actions/Action.d.ts.map +1 -1
  9. package/dist/actions/Action.js +25 -0
  10. package/dist/actions/Action.js.map +1 -1
  11. package/dist/elements/dispatchForm.d.ts +0 -14
  12. package/dist/elements/dispatchForm.d.ts.map +1 -1
  13. package/dist/elements/dispatchForm.js +28 -0
  14. package/dist/elements/dispatchForm.js.map +1 -1
  15. package/dist/fields/BuilderField.d.ts +27 -1
  16. package/dist/fields/BuilderField.d.ts.map +1 -1
  17. package/dist/fields/BuilderField.js +36 -1
  18. package/dist/fields/BuilderField.js.map +1 -1
  19. package/dist/fields/FileUploadField.d.ts +65 -0
  20. package/dist/fields/FileUploadField.d.ts.map +1 -1
  21. package/dist/fields/FileUploadField.js +72 -0
  22. package/dist/fields/FileUploadField.js.map +1 -1
  23. package/dist/fields/RepeaterField.d.ts +34 -1
  24. package/dist/fields/RepeaterField.d.ts.map +1 -1
  25. package/dist/fields/RepeaterField.js +43 -1
  26. package/dist/fields/RepeaterField.js.map +1 -1
  27. package/dist/fields/RowButton.d.ts +9 -2
  28. package/dist/fields/RowButton.d.ts.map +1 -1
  29. package/dist/fields/TextField.d.ts +106 -0
  30. package/dist/fields/TextField.d.ts.map +1 -1
  31. package/dist/fields/TextField.js +115 -0
  32. package/dist/fields/TextField.js.map +1 -1
  33. package/dist/filters/queryBuilder/Constraint.d.ts +1 -1
  34. package/dist/filters/queryBuilder/Constraint.d.ts.map +1 -1
  35. package/dist/filters/queryBuilder/TextConstraint.d.ts.map +1 -1
  36. package/dist/filters/queryBuilder/TextConstraint.js +2 -3
  37. package/dist/filters/queryBuilder/TextConstraint.js.map +1 -1
  38. package/dist/orm/modelDefaults.d.ts +1 -1
  39. package/dist/orm/modelDefaults.d.ts.map +1 -1
  40. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  41. package/dist/react/SchemaRenderer.js +108 -7
  42. package/dist/react/SchemaRenderer.js.map +1 -1
  43. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  44. package/dist/react/fields/BuilderInput.js +32 -3
  45. package/dist/react/fields/BuilderInput.js.map +1 -1
  46. package/dist/react/fields/FieldShell.d.ts +9 -1
  47. package/dist/react/fields/FieldShell.d.ts.map +1 -1
  48. package/dist/react/fields/FieldShell.js +4 -3
  49. package/dist/react/fields/FieldShell.js.map +1 -1
  50. package/dist/react/fields/FileUploadInput.d.ts +17 -4
  51. package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
  52. package/dist/react/fields/FileUploadInput.js +204 -25
  53. package/dist/react/fields/FileUploadInput.js.map +1 -1
  54. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  55. package/dist/react/fields/RepeaterInput.js +33 -2
  56. package/dist/react/fields/RepeaterInput.js.map +1 -1
  57. package/dist/react/fields/TextLikeInput.d.ts +5 -1
  58. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  59. package/dist/react/fields/TextLikeInput.js +17 -2
  60. package/dist/react/fields/TextLikeInput.js.map +1 -1
  61. package/dist/react/fields/rowChromeButton.d.ts +24 -5
  62. package/dist/react/fields/rowChromeButton.d.ts.map +1 -1
  63. package/dist/react/fields/rowChromeButton.js +51 -8
  64. package/dist/react/fields/rowChromeButton.js.map +1 -1
  65. package/dist/react/fields/textInputControls.d.ts +47 -0
  66. package/dist/react/fields/textInputControls.d.ts.map +1 -0
  67. package/dist/react/fields/textInputControls.js +134 -0
  68. package/dist/react/fields/textInputControls.js.map +1 -0
  69. package/dist/routes.d.ts.map +1 -1
  70. package/dist/routes.js +32 -1
  71. package/dist/routes.js.map +1 -1
  72. package/dist/schema/Alert.d.ts +58 -0
  73. package/dist/schema/Alert.d.ts.map +1 -1
  74. package/dist/schema/Alert.js +68 -1
  75. package/dist/schema/Alert.js.map +1 -1
  76. package/dist/schema/resolveSchema.d.ts.map +1 -1
  77. package/dist/schema/resolveSchema.js +32 -0
  78. package/dist/schema/resolveSchema.js.map +1 -1
  79. package/package.json +12 -11
  80. package/src/Pilotiq.test.ts +78 -0
  81. package/src/Pilotiq.ts +20 -1
  82. package/src/actions/Action.test.ts +47 -0
  83. package/src/actions/Action.ts +35 -0
  84. package/src/elements/dispatchForm.ts +28 -0
  85. package/src/fields/BuilderField.ts +38 -1
  86. package/src/fields/FileUploadField.test.ts +46 -0
  87. package/src/fields/FileUploadField.ts +90 -2
  88. package/src/fields/RepeaterField.ts +45 -1
  89. package/src/fields/RowButton.test.ts +70 -0
  90. package/src/fields/RowButton.ts +11 -1
  91. package/src/fields/TextField.test.ts +168 -0
  92. package/src/fields/TextField.ts +141 -1
  93. package/src/filters/QueryBuilderFilter.test.ts +18 -0
  94. package/src/filters/queryBuilder/Constraint.ts +1 -1
  95. package/src/filters/queryBuilder/TextConstraint.ts +5 -6
  96. package/src/orm/modelDefaults.ts +1 -1
  97. package/src/react/SchemaRenderer.tsx +222 -14
  98. package/src/react/fields/BuilderInput.tsx +37 -0
  99. package/src/react/fields/FieldShell.tsx +13 -2
  100. package/src/react/fields/FileUploadInput.tsx +516 -85
  101. package/src/react/fields/RepeaterInput.tsx +39 -0
  102. package/src/react/fields/TextLikeInput.tsx +22 -2
  103. package/src/react/fields/rowChromeButton.tsx +102 -6
  104. package/src/react/fields/textInputControls.tsx +238 -0
  105. package/src/routes.ts +33 -1
  106. package/src/schema/Alert.test.ts +46 -0
  107. package/src/schema/Alert.ts +90 -8
  108. package/src/schema/resolveSchema.ts +32 -0
@@ -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 !== undefined ? { maxLength: 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', `%${escapeLike(v)}%`)
42
- case 'startsWith': return query.where(this.name, 'LIKE', `${escapeLike(v)}%`)
43
- case 'endsWith': return query.where(this.name, 'LIKE', `%${escapeLike(v)}`)
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
@@ -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
- <div key={index} className={`rounded-lg border p-4 ${styles}`}>
2762
- {title && <p className="font-medium mb-1">{title}</p>}
2763
- <p className="text-sm">{String(el['content'] ?? '')}</p>
2764
- {footer.length > 0 && (
2765
- <div className="flex items-center gap-2 mt-3">
2766
- {footer.map((a, i) => renderActionLike(a, i))}
2767
- </div>
2768
- )}
2769
- </div>
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 input = (prefix || suffix)
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