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