@pilotiq/pilotiq 0.2.0 → 0.4.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 (118) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/CLAUDE.md +2 -2
  4. package/dist/actions/Action.d.ts +25 -0
  5. package/dist/actions/Action.d.ts.map +1 -1
  6. package/dist/actions/Action.js +25 -0
  7. package/dist/actions/Action.js.map +1 -1
  8. package/dist/elements/dispatchForm.d.ts +0 -14
  9. package/dist/elements/dispatchForm.d.ts.map +1 -1
  10. package/dist/elements/dispatchForm.js +28 -0
  11. package/dist/elements/dispatchForm.js.map +1 -1
  12. package/dist/fields/BuilderField.d.ts +27 -1
  13. package/dist/fields/BuilderField.d.ts.map +1 -1
  14. package/dist/fields/BuilderField.js +36 -1
  15. package/dist/fields/BuilderField.js.map +1 -1
  16. package/dist/fields/FileUploadField.d.ts +65 -0
  17. package/dist/fields/FileUploadField.d.ts.map +1 -1
  18. package/dist/fields/FileUploadField.js +72 -0
  19. package/dist/fields/FileUploadField.js.map +1 -1
  20. package/dist/fields/RepeaterField.d.ts +34 -1
  21. package/dist/fields/RepeaterField.d.ts.map +1 -1
  22. package/dist/fields/RepeaterField.js +43 -1
  23. package/dist/fields/RepeaterField.js.map +1 -1
  24. package/dist/fields/RowButton.d.ts +9 -2
  25. package/dist/fields/RowButton.d.ts.map +1 -1
  26. package/dist/fields/TextField.d.ts +106 -0
  27. package/dist/fields/TextField.d.ts.map +1 -1
  28. package/dist/fields/TextField.js +115 -0
  29. package/dist/fields/TextField.js.map +1 -1
  30. package/dist/filters/queryBuilder/Constraint.d.ts +1 -1
  31. package/dist/filters/queryBuilder/Constraint.d.ts.map +1 -1
  32. package/dist/filters/queryBuilder/TextConstraint.d.ts.map +1 -1
  33. package/dist/filters/queryBuilder/TextConstraint.js +2 -3
  34. package/dist/filters/queryBuilder/TextConstraint.js.map +1 -1
  35. package/dist/orm/modelDefaults.d.ts +1 -1
  36. package/dist/orm/modelDefaults.d.ts.map +1 -1
  37. package/dist/pageData.d.ts +11 -0
  38. package/dist/pageData.d.ts.map +1 -1
  39. package/dist/pageData.js +31 -0
  40. package/dist/pageData.js.map +1 -1
  41. package/dist/react/FieldLabelSlotRegistry.d.ts +26 -0
  42. package/dist/react/FieldLabelSlotRegistry.d.ts.map +1 -0
  43. package/dist/react/FieldLabelSlotRegistry.js +16 -0
  44. package/dist/react/FieldLabelSlotRegistry.js.map +1 -0
  45. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  46. package/dist/react/SchemaRenderer.js +120 -9
  47. package/dist/react/SchemaRenderer.js.map +1 -1
  48. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  49. package/dist/react/fields/BuilderInput.js +32 -3
  50. package/dist/react/fields/BuilderInput.js.map +1 -1
  51. package/dist/react/fields/FieldShell.d.ts +12 -1
  52. package/dist/react/fields/FieldShell.d.ts.map +1 -1
  53. package/dist/react/fields/FieldShell.js +5 -4
  54. package/dist/react/fields/FieldShell.js.map +1 -1
  55. package/dist/react/fields/FileUploadInput.d.ts +17 -4
  56. package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
  57. package/dist/react/fields/FileUploadInput.js +204 -25
  58. package/dist/react/fields/FileUploadInput.js.map +1 -1
  59. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  60. package/dist/react/fields/RepeaterInput.js +33 -2
  61. package/dist/react/fields/RepeaterInput.js.map +1 -1
  62. package/dist/react/fields/TextLikeInput.d.ts +5 -1
  63. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  64. package/dist/react/fields/TextLikeInput.js +17 -2
  65. package/dist/react/fields/TextLikeInput.js.map +1 -1
  66. package/dist/react/fields/rowChromeButton.d.ts +24 -5
  67. package/dist/react/fields/rowChromeButton.d.ts.map +1 -1
  68. package/dist/react/fields/rowChromeButton.js +51 -8
  69. package/dist/react/fields/rowChromeButton.js.map +1 -1
  70. package/dist/react/fields/textInputControls.d.ts +47 -0
  71. package/dist/react/fields/textInputControls.d.ts.map +1 -0
  72. package/dist/react/fields/textInputControls.js +134 -0
  73. package/dist/react/fields/textInputControls.js.map +1 -0
  74. package/dist/react/index.d.ts +1 -0
  75. package/dist/react/index.d.ts.map +1 -1
  76. package/dist/react/index.js +1 -0
  77. package/dist/react/index.js.map +1 -1
  78. package/dist/routes.d.ts.map +1 -1
  79. package/dist/routes.js +21 -1
  80. package/dist/routes.js.map +1 -1
  81. package/dist/schema/Alert.d.ts +58 -0
  82. package/dist/schema/Alert.d.ts.map +1 -1
  83. package/dist/schema/Alert.js +68 -1
  84. package/dist/schema/Alert.js.map +1 -1
  85. package/dist/schema/resolveSchema.d.ts.map +1 -1
  86. package/dist/schema/resolveSchema.js +32 -0
  87. package/dist/schema/resolveSchema.js.map +1 -1
  88. package/package.json +2 -1
  89. package/src/actions/Action.test.ts +47 -0
  90. package/src/actions/Action.ts +35 -0
  91. package/src/elements/dispatchForm.ts +28 -0
  92. package/src/fields/BuilderField.ts +38 -1
  93. package/src/fields/FileUploadField.test.ts +46 -0
  94. package/src/fields/FileUploadField.ts +90 -2
  95. package/src/fields/RepeaterField.ts +45 -1
  96. package/src/fields/RowButton.test.ts +70 -0
  97. package/src/fields/RowButton.ts +11 -1
  98. package/src/fields/TextField.test.ts +168 -0
  99. package/src/fields/TextField.ts +141 -1
  100. package/src/filters/QueryBuilderFilter.test.ts +18 -0
  101. package/src/filters/queryBuilder/Constraint.ts +1 -1
  102. package/src/filters/queryBuilder/TextConstraint.ts +5 -6
  103. package/src/orm/modelDefaults.ts +1 -1
  104. package/src/pageData.ts +33 -0
  105. package/src/react/FieldLabelSlotRegistry.ts +30 -0
  106. package/src/react/SchemaRenderer.tsx +238 -16
  107. package/src/react/fields/BuilderInput.tsx +37 -0
  108. package/src/react/fields/FieldShell.tsx +17 -2
  109. package/src/react/fields/FileUploadInput.tsx +516 -85
  110. package/src/react/fields/RepeaterInput.tsx +39 -0
  111. package/src/react/fields/TextLikeInput.tsx +22 -2
  112. package/src/react/fields/rowChromeButton.tsx +102 -6
  113. package/src/react/fields/textInputControls.tsx +238 -0
  114. package/src/react/index.ts +1 -0
  115. package/src/routes.ts +21 -1
  116. package/src/schema/Alert.test.ts +46 -0
  117. package/src/schema/Alert.ts +90 -8
  118. package/src/schema/resolveSchema.ts +32 -0
@@ -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
package/src/pageData.ts CHANGED
@@ -955,6 +955,37 @@ export function tagFormWizardUrls(
955
955
  }
956
956
  }
957
957
 
958
+ /**
959
+ * Stamp `_agentRunBase` on every field element in the resolved
960
+ * `ElementMeta[]` tree that carries `aiActions`. Operates on the
961
+ * post-`resolveSchema` wire shape (plain objects) rather than on
962
+ * `Element` instances — `aiActions` is added by the `field-ai.ts`
963
+ * wrapper during `toMeta()`, so it isn't visible to pre-resolve walkers.
964
+ *
965
+ * Only called on edit pages where a `recordId` is known. Create pages
966
+ * deliberately skip it — field AI actions target existing content.
967
+ */
968
+ export function tagFieldAiUrls(
969
+ elements: ReadonlyArray<Record<string, unknown>>,
970
+ agentBase: string,
971
+ ): void {
972
+ for (const el of elements) {
973
+ if (Array.isArray(el['aiActions']) && (el['aiActions'] as unknown[]).length > 0) {
974
+ ;(el as Record<string, unknown>)['_agentRunBase'] = agentBase
975
+ }
976
+ const children = el['children']
977
+ if (Array.isArray(children)) tagFieldAiUrls(children as Record<string, unknown>[], agentBase)
978
+ // Repeater rows
979
+ const rows = el['rows']
980
+ if (Array.isArray(rows)) {
981
+ for (const row of rows as Record<string, unknown>[]) {
982
+ const rowChildren = row['children']
983
+ if (Array.isArray(rowChildren)) tagFieldAiUrls(rowChildren as Record<string, unknown>[], agentBase)
984
+ }
985
+ }
986
+ }
987
+ }
988
+
958
989
  /**
959
990
  * Audit row 2026-05-07 cont'd⁸ — stamp the inline-create-option endpoint
960
991
  * URL on every `SelectField` that has called `createOptionForm()`. Walks
@@ -1860,6 +1891,8 @@ export async function resourceEditData(
1860
1891
  editRoute,
1861
1892
  )
1862
1893
 
1894
+ tagFieldAiUrls(schemaData as Record<string, unknown>[], `${resourceBase}/${recordId}/_agents`)
1895
+
1863
1896
  return {
1864
1897
  panel: await panelInfo(pilotiq, req, editRoute),
1865
1898
  page: PageClass.toMeta(),
@@ -0,0 +1,30 @@
1
+ import type { ComponentType } from 'react'
2
+
3
+ /**
4
+ * Props the field label slot component receives from `SchemaRenderer`.
5
+ * For AI actions this carries the field name, action list, and the
6
+ * pre-composed agent-run base URL stamped by `tagFieldAiUrls`.
7
+ */
8
+ export interface FieldLabelSlotProps {
9
+ fieldName: string
10
+ actions: Array<{ slug: string; label: string; icon?: string }>
11
+ agentRunBase: string
12
+ }
13
+
14
+ let _component: ComponentType<FieldLabelSlotProps> | null = null
15
+
16
+ /**
17
+ * Register a component to render inline next to every field label that
18
+ * has `aiActions` stamped on its meta. Called once at boot by a plugin's
19
+ * `register(panel)` step (e.g. `@pilotiq-pro/ai`). No-op when no plugin
20
+ * registers — `getFieldLabelSlot()` returns `null` and `SchemaRenderer`
21
+ * skips the slot.
22
+ */
23
+ export function registerFieldLabelSlot(C: ComponentType<FieldLabelSlotProps>): void {
24
+ _component = C
25
+ }
26
+
27
+ /** Returns the registered field label slot component, or `null`. */
28
+ export function getFieldLabelSlot(): ComponentType<FieldLabelSlotProps> | null {
29
+ return _component
30
+ }
@@ -1,12 +1,14 @@
1
1
  import React, { useEffect, useRef, useState } from 'react'
2
2
  import type { ElementMeta } from '../schema/Element.js'
3
3
  import { getFieldRenderer } from './registry.js'
4
+ import { getFieldLabelSlot } from './FieldLabelSlotRegistry.js'
4
5
  import { FormStateProvider, useFormState, FormIdContext } from './FormStateContext.js'
5
6
  import { Checkbox } from './ui/checkbox.js'
6
7
  import { Input } from './ui/input.js'
7
8
  import { Popover, PopoverTrigger, PopoverContent } from './ui/popover.js'
8
9
  import { FieldShell } from './fields/FieldShell.js'
9
10
  import { TextLikeInput } from './fields/TextLikeInput.js'
11
+ import { useTextInputControls } from './fields/textInputControls.js'
10
12
  import { SelectFieldInput } from './fields/SelectFieldInput.js'
11
13
  import { ToggleFieldInput } from './fields/ToggleFieldInput.js'
12
14
  import { DateFieldInput } from './fields/DateFieldInput.js'
@@ -65,6 +67,7 @@ import {
65
67
  CalendarIcon, FilterIcon, MoreHorizontalIcon,
66
68
  CircleIcon, InboxIcon, GripVerticalIcon,
67
69
  ChevronDownIcon, CopyIcon, CheckIcon, XIcon,
70
+ InfoIcon, TriangleAlertIcon, CircleCheckIcon, CircleAlertIcon,
68
71
  } from 'lucide-react'
69
72
  import type { ComponentType } from 'react'
70
73
  import { useNavigate, type NavigateFn } from './navigate.js'
@@ -183,6 +186,16 @@ function renderField(el: ElementMeta, index: number): React.ReactNode {
183
186
  return <HiddenInput key={index} name={name} defaultValue={defaultValue} />
184
187
  }
185
188
 
189
+ // Field label slot — rendered next to the label when a plugin registered
190
+ // a component via registerFieldLabelSlot() and the field has aiActions +
191
+ // _agentRunBase stamped on its meta (set by tagFieldAiUrls in pageData).
192
+ const LabelSlot = getFieldLabelSlot()
193
+ const aiActions = Array.isArray(el['aiActions']) ? el['aiActions'] as Array<{ slug: string; label: string; icon?: string }> : undefined
194
+ const agentRunBase = typeof el['_agentRunBase'] === 'string' ? el['_agentRunBase'] : undefined
195
+ const labelSlot = (LabelSlot && aiActions?.length && agentRunBase)
196
+ ? <LabelSlot fieldName={name} actions={aiActions} agentRunBase={agentRunBase} />
197
+ : undefined
198
+
186
199
  const autofocus = el['autofocus'] === true
187
200
  const extraInput = el['extraInputAttributes'] as Record<string, string | number | boolean> | undefined
188
201
  const common = {
@@ -202,7 +215,7 @@ function renderField(el: ElementMeta, index: number): React.ReactNode {
202
215
  const Custom = getFieldRenderer(fieldType)
203
216
  if (Custom) {
204
217
  return (
205
- <FieldShell key={index} el={el} name={name} label={label} required={required}>
218
+ <FieldShell key={index} el={el} name={name} label={label} required={required} labelSlot={labelSlot}>
206
219
  <Custom
207
220
  el={el}
208
221
  name={name}
@@ -215,10 +228,90 @@ function renderField(el: ElementMeta, index: number): React.ReactNode {
215
228
  )
216
229
  }
217
230
 
231
+ // TextField (and slug) rich affordances live in a dedicated shell so
232
+ // `useTextInputControls` can hold reveal-toggle / mask state via React
233
+ // hooks (renderField itself is a plain function, hooks would violate
234
+ // rules-of-hooks here).
235
+ if (fieldType === 'text' || fieldType === 'slug') {
236
+ return (
237
+ <TextFieldShell
238
+ key={index}
239
+ el={el}
240
+ name={name}
241
+ label={label}
242
+ required={required}
243
+ common={common}
244
+ labelSlot={labelSlot}
245
+ />
246
+ )
247
+ }
248
+
218
249
  const input = renderFieldInput(fieldType, el, name, defaultValue, defaultStr, common, disabled, required, placeholder)
219
250
 
220
251
  return (
221
- <FieldShell key={index} el={el} name={name} label={label} required={required}>
252
+ <FieldShell key={index} el={el} name={name} label={label} required={required} labelSlot={labelSlot}>
253
+ {input}
254
+ </FieldShell>
255
+ )
256
+ }
257
+
258
+ /**
259
+ * Component-shape TextField renderer — wraps the input shell so we can
260
+ * use `useTextInputControls()` (which holds the eye-toggle / mask state).
261
+ * Keeps `renderField` itself hook-free.
262
+ */
263
+ function TextFieldShell({
264
+ el, name, label, required, common, labelSlot,
265
+ }: {
266
+ el: ElementMeta
267
+ name: string
268
+ label: string
269
+ required: boolean
270
+ common: Record<string, unknown>
271
+ labelSlot?: React.ReactNode
272
+ }): React.ReactElement {
273
+ const controls = useTextInputControls(el, name, (m) => renderElement(m, 0))
274
+
275
+ // Build the input with all the new HTML attrs (inputMode /
276
+ // autocapitalize / list / maxLength + the password/text type from
277
+ // the controls hook).
278
+ const textExtra: Record<string, unknown> = {}
279
+ if (el['maxLength'] !== undefined) textExtra['maxLength'] = Number(el['maxLength'])
280
+ if (el['inputMode'] !== undefined) textExtra['inputMode'] = String(el['inputMode'])
281
+ if (el['autocapitalize'] !== undefined) textExtra['autoCapitalize'] = String(el['autocapitalize'])
282
+ if (Array.isArray(el['datalist'])) textExtra['list'] = `${name}__datalist`
283
+
284
+ const datalist = Array.isArray(el['datalist']) ? (el['datalist'] as string[]) : undefined
285
+
286
+ const input = (
287
+ <>
288
+ <TextLikeInput
289
+ el={el}
290
+ name={name}
291
+ common={common}
292
+ type={controls.type}
293
+ extraProps={textExtra}
294
+ multiline={false}
295
+ applyMask={controls.applyMask}
296
+ />
297
+ {datalist && (
298
+ <datalist id={`${name}__datalist`}>
299
+ {datalist.map((v, i) => <option key={i} value={v} />)}
300
+ </datalist>
301
+ )}
302
+ </>
303
+ )
304
+
305
+ return (
306
+ <FieldShell
307
+ el={el}
308
+ name={name}
309
+ label={label}
310
+ required={required}
311
+ before={controls.before}
312
+ after={controls.after}
313
+ labelSlot={labelSlot}
314
+ >
222
315
  {input}
223
316
  </FieldShell>
224
317
  )
@@ -404,6 +497,24 @@ function renderFieldInput(
404
497
  preview={el['preview'] !== false}
405
498
  directory={typeof el['directory'] === 'string' ? el['directory'] : undefined}
406
499
  uploadUrl={typeof el['uploadUrl'] === 'string' ? el['uploadUrl'] : undefined}
500
+ downloadable={Boolean(el['downloadable'])}
501
+ openable={Boolean(el['openable'])}
502
+ reorderable={Boolean(el['reorderable'])}
503
+ appendFiles={Boolean(el['appendFiles'])}
504
+ panelLayout={
505
+ el['panelLayout'] === 'grid' ? 'grid'
506
+ : el['panelLayout'] === 'integrated' ? 'integrated'
507
+ : 'list'
508
+ }
509
+ {...(el['automaticallyResize'] && typeof el['automaticallyResize'] === 'object'
510
+ ? { automaticallyResize: el['automaticallyResize'] as { width: number; height: number } }
511
+ : {})}
512
+ imageEditor={Boolean(el['imageEditor'])}
513
+ circleCropper={Boolean(el['circleCropper'])}
514
+ automaticallyCropImagesToAspectRatio={Boolean(el['automaticallyCropImagesToAspectRatio'])}
515
+ {...(Array.isArray(el['imageEditorAspectRatioOptions'])
516
+ ? { imageEditorAspectRatioOptions: el['imageEditorAspectRatioOptions'] as Array<{ ratio: number; label: string }> }
517
+ : {})}
407
518
  />
408
519
  )
409
520
  }
@@ -732,6 +843,9 @@ function ActionModalDialog({
732
843
  const dispatchUrl = meta['dispatchUrl'] as string | undefined
733
844
  const fields = (meta.children ?? []) as ElementMeta[]
734
845
  const hasForm = fields.length > 0
846
+ // Filament v5 — auxiliary Elements stamped by the resolver between
847
+ // the body and the footer (Alert / Text / Heading / Action / …).
848
+ const contentFooter = (meta['modalContentFooter'] ?? []) as ElementMeta[]
735
849
 
736
850
  const heading = modal?.heading ?? confirm?.title ?? (hasForm ? String(meta['label'] ?? 'Submit') : 'Are you sure?')
737
851
  const description = modal?.description ?? confirm?.message
@@ -897,12 +1011,13 @@ function ActionModalDialog({
897
1011
  </DialogTitle>
898
1012
  {description && <DialogDescription>{description}</DialogDescription>}
899
1013
  </DialogHeader>
900
- {hasForm && (
1014
+ {(hasForm || contentFooter.length > 0) && (
901
1015
  <div className={`flex flex-col gap-3 py-2 ${bodyCls}`.trim()}>
902
1016
  {fields.map((f, i) => renderFormChild(f, i, initialValues, errors))}
1017
+ {contentFooter.map((c, i) => renderElement(c, fields.length + i))}
903
1018
  </div>
904
1019
  )}
905
- {!hasForm && stickyMode && <div className={bodyCls} />}
1020
+ {!hasForm && contentFooter.length === 0 && stickyMode && <div className={bodyCls} />}
906
1021
  {serverError && (
907
1022
  <p className={`py-2 text-sm text-destructive ${stickyMode ? 'px-6' : ''}`.trim()}>{serverError}</p>
908
1023
  )}
@@ -2663,6 +2778,114 @@ function EntryCopyButton({ text, label }: { text: string; label: string }): Reac
2663
2778
  )
2664
2779
  }
2665
2780
 
2781
+ // ─── Alert renderer ─────────────────────────────────────────
2782
+ //
2783
+ // Owns dismissal state (per-mount + optional localStorage persistence)
2784
+ // + icon dispatch + footer-actions alignment. Lifted out of the inline
2785
+ // `case 'alert'` branch when Alert gained `dismissible() / iconColor() /
2786
+ // footerActionsAlignment()` setters — those need component-local state
2787
+ // (the dismiss button, the persisted-dismissal hydration on mount), and
2788
+ // inlining the hooks under a switch arm is fragile.
2789
+
2790
+ const ALERT_TYPE_ICONS: Record<string, ComponentType<{ className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>> = {
2791
+ info: InfoIcon,
2792
+ warning: TriangleAlertIcon,
2793
+ success: CircleCheckIcon,
2794
+ danger: CircleAlertIcon,
2795
+ }
2796
+
2797
+ const ALERT_TYPE_DEFAULT_ICON_COLOR: Record<string, string> = {
2798
+ info: 'info',
2799
+ warning: 'warning',
2800
+ success: 'success',
2801
+ danger: 'destructive',
2802
+ }
2803
+
2804
+ const ALERT_ACTIONS_ALIGNMENT: Record<string, string> = {
2805
+ start: 'justify-start',
2806
+ center: 'justify-center',
2807
+ end: 'justify-end',
2808
+ }
2809
+
2810
+ function alertPersistKey(persistKey: string): string {
2811
+ return `pilotiq.alert.${persistKey}`
2812
+ }
2813
+
2814
+ function AlertRenderer(props: {
2815
+ alertType: string
2816
+ content: string
2817
+ title?: string
2818
+ dismissible?: boolean
2819
+ persistDismissal?: string
2820
+ iconColor?: string
2821
+ actionsAlignment?: string
2822
+ footer: React.ReactNode[]
2823
+ }): React.ReactNode {
2824
+ const {
2825
+ alertType, content, title, dismissible,
2826
+ persistDismissal, iconColor, actionsAlignment, footer,
2827
+ } = props
2828
+ const [dismissed, setDismissed] = useState(false)
2829
+
2830
+ // Hydrate persisted-dismissal on first paint. `useState(false)` keeps
2831
+ // SSR + first client paint identical (Hydration safe); the effect
2832
+ // flips to dismissed if localStorage has the flag set.
2833
+ useEffect(() => {
2834
+ if (!persistDismissal) return
2835
+ if (typeof window === 'undefined') return
2836
+ try {
2837
+ if (window.localStorage.getItem(alertPersistKey(persistDismissal)) === '1') {
2838
+ setDismissed(true)
2839
+ }
2840
+ } catch { /* localStorage blocked (Safari ITP / SSR) — render visible */ }
2841
+ }, [persistDismissal])
2842
+
2843
+ if (dismissed) return null
2844
+
2845
+ const styles = alertStyles[alertType] ?? alertStyles['info']!
2846
+ const Icon = ALERT_TYPE_ICONS[alertType] ?? InfoIcon
2847
+ const iconColorKey = iconColor ?? ALERT_TYPE_DEFAULT_ICON_COLOR[alertType] ?? 'info'
2848
+ const iconColorCls = TEXT_COLOR_CLASSES[iconColorKey] ?? ''
2849
+ const alignCls = ALERT_ACTIONS_ALIGNMENT[actionsAlignment ?? 'start'] ?? 'justify-start'
2850
+
2851
+ const handleDismiss = (): void => {
2852
+ setDismissed(true)
2853
+ if (persistDismissal && typeof window !== 'undefined') {
2854
+ try {
2855
+ window.localStorage.setItem(alertPersistKey(persistDismissal), '1')
2856
+ } catch { /* localStorage blocked — dismiss is per-mount only */ }
2857
+ }
2858
+ }
2859
+
2860
+ return (
2861
+ <div className={`relative rounded-lg border p-4 ${styles} ${dismissible ? 'pr-9' : ''}`}>
2862
+ <div className="flex gap-3">
2863
+ <Icon className={`size-5 shrink-0 mt-0.5 ${iconColorCls}`} aria-hidden="true" />
2864
+ <div className="flex-1 min-w-0">
2865
+ {title !== undefined && <p className="font-medium mb-1">{title}</p>}
2866
+ <p className="text-sm">{content}</p>
2867
+ {footer.length > 0 && (
2868
+ <div className={`flex items-center gap-2 mt-3 ${alignCls}`}>
2869
+ {footer}
2870
+ </div>
2871
+ )}
2872
+ </div>
2873
+ </div>
2874
+ {dismissible && (
2875
+ <button
2876
+ type="button"
2877
+ onClick={handleDismiss}
2878
+ aria-label="Dismiss"
2879
+ title="Dismiss"
2880
+ className="absolute top-3 right-3 inline-flex h-6 w-6 items-center justify-center rounded opacity-70 hover:opacity-100 transition-opacity"
2881
+ >
2882
+ <XIcon className="size-4" aria-hidden="true" />
2883
+ </button>
2884
+ )}
2885
+ </div>
2886
+ )
2887
+ }
2888
+
2666
2889
  function renderElement(el: ElementMeta, index: number): React.ReactNode {
2667
2890
  switch (el.type) {
2668
2891
  case 'text':
@@ -2753,20 +2976,19 @@ function renderElement(el: ElementMeta, index: number): React.ReactNode {
2753
2976
  }
2754
2977
 
2755
2978
  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
2979
  const footer = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup')
2760
2980
  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>
2981
+ <AlertRenderer
2982
+ key={index}
2983
+ alertType={String(el['alertType'] ?? 'info')}
2984
+ content={String(el['content'] ?? '')}
2985
+ {...(el['title'] !== undefined ? { title: String(el['title']) } : {})}
2986
+ {...(el['dismissible'] ? { dismissible: Boolean(el['dismissible']) } : {})}
2987
+ {...(el['persistDismissal'] !== undefined ? { persistDismissal: String(el['persistDismissal']) } : {})}
2988
+ {...(el['iconColor'] !== undefined ? { iconColor: String(el['iconColor']) } : {})}
2989
+ {...(el['actionsAlignment'] !== undefined ? { actionsAlignment: String(el['actionsAlignment']) } : {})}
2990
+ footer={footer.map((a, i) => renderActionLike(a, i))}
2991
+ />
2770
2992
  )
2771
2993
  }
2772
2994
 
@@ -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,20 @@ 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
31
+ /** Optional ReactNode rendered inline next to the label — used by
32
+ * plugins that register via `registerFieldLabelSlot()`. */
33
+ labelSlot?: React.ReactNode
23
34
  }
24
35
 
25
- export function FieldShell({ el, name, label, required, children }: FieldShellProps): React.ReactElement {
36
+ export function FieldShell({ el, name, label, required, children, before, after, labelSlot }: FieldShellProps): React.ReactElement {
26
37
  const prefix = el['prefix'] as string | { icon: string } | undefined
27
38
  const suffix = el['suffix'] as string | { icon: string } | undefined
28
39
  const helperText = el['helperText'] as string | undefined
@@ -36,15 +47,19 @@ export function FieldShell({ el, name, label, required, children }: FieldShellPr
36
47
  const labelEl = label !== '' ? (
37
48
  <label htmlFor={name} className={labelClass}>
38
49
  {label}{required && <span className="text-destructive ml-0.5">*</span>}
50
+ {labelSlot}
39
51
  </label>
40
52
  ) : null
41
53
 
42
- const input = (prefix || suffix)
54
+ const hasDecoration = !!(prefix || suffix || before || after)
55
+ const input = hasDecoration
43
56
  ? (
44
57
  <div className="flex items-center gap-2">
58
+ {before}
45
59
  {prefix && <Decoration content={prefix} side="prefix" />}
46
60
  <div className="flex-1 min-w-0">{children}</div>
47
61
  {suffix && <Decoration content={suffix} side="suffix" />}
62
+ {after}
48
63
  </div>
49
64
  )
50
65
  : children