@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +2 -2
- 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/pageData.d.ts +11 -0
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +31 -0
- package/dist/pageData.js.map +1 -1
- package/dist/react/FieldLabelSlotRegistry.d.ts +26 -0
- package/dist/react/FieldLabelSlotRegistry.d.ts.map +1 -0
- package/dist/react/FieldLabelSlotRegistry.js +16 -0
- package/dist/react/FieldLabelSlotRegistry.js.map +1 -0
- package/dist/react/SchemaRenderer.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.js +120 -9
- 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 +12 -1
- package/dist/react/fields/FieldShell.d.ts.map +1 -1
- package/dist/react/fields/FieldShell.js +5 -4
- 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/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +21 -1
- package/dist/routes.js.map +1 -1
- package/dist/schema/Alert.d.ts +58 -0
- package/dist/schema/Alert.d.ts.map +1 -1
- package/dist/schema/Alert.js +68 -1
- package/dist/schema/Alert.js.map +1 -1
- package/dist/schema/resolveSchema.d.ts.map +1 -1
- package/dist/schema/resolveSchema.js +32 -0
- package/dist/schema/resolveSchema.js.map +1 -1
- package/package.json +2 -1
- package/src/actions/Action.test.ts +47 -0
- package/src/actions/Action.ts +35 -0
- package/src/elements/dispatchForm.ts +28 -0
- package/src/fields/BuilderField.ts +38 -1
- package/src/fields/FileUploadField.test.ts +46 -0
- package/src/fields/FileUploadField.ts +90 -2
- package/src/fields/RepeaterField.ts +45 -1
- package/src/fields/RowButton.test.ts +70 -0
- package/src/fields/RowButton.ts +11 -1
- package/src/fields/TextField.test.ts +168 -0
- package/src/fields/TextField.ts +141 -1
- package/src/filters/QueryBuilderFilter.test.ts +18 -0
- package/src/filters/queryBuilder/Constraint.ts +1 -1
- package/src/filters/queryBuilder/TextConstraint.ts +5 -6
- package/src/orm/modelDefaults.ts +1 -1
- package/src/pageData.ts +33 -0
- package/src/react/FieldLabelSlotRegistry.ts +30 -0
- package/src/react/SchemaRenderer.tsx +238 -16
- package/src/react/fields/BuilderInput.tsx +37 -0
- package/src/react/fields/FieldShell.tsx +17 -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/react/index.ts +1 -0
- package/src/routes.ts +21 -1
- package/src/schema/Alert.test.ts +46 -0
- package/src/schema/Alert.ts +90 -8
- package/src/schema/resolveSchema.ts +32 -0
|
@@ -1,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
|
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
|
-
<
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
{
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
)}
|
|
2769
|
-
|
|
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
|
|
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
|