@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
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
RowChromeIconButton,
|
|
13
13
|
ReorderGrip,
|
|
14
14
|
CollapseChevron,
|
|
15
|
+
BulkCollapseHeader,
|
|
15
16
|
resolveRowChrome,
|
|
16
17
|
DEFAULT_MOVE_UP,
|
|
17
18
|
DEFAULT_MOVE_DOWN,
|
|
@@ -428,6 +429,37 @@ export function RepeaterInput({
|
|
|
428
429
|
})
|
|
429
430
|
}
|
|
430
431
|
|
|
432
|
+
// ── Bulk expand / collapse ──────────────────────────────
|
|
433
|
+
// Accordion's "only one open" invariant survives both bulk actions:
|
|
434
|
+
// expandAll opens the first visible row (matches the implicit
|
|
435
|
+
// "always show something" mental model); collapseAll sets null.
|
|
436
|
+
// Per-row mode iterates every row id and writes the storage slot
|
|
437
|
+
// alongside the in-memory map so reload restores the bulk action.
|
|
438
|
+
const expandAll = (): void => {
|
|
439
|
+
if (accordion) {
|
|
440
|
+
const firstVisible = rows.find(r => !r.hidden)
|
|
441
|
+
const next = firstVisible?.id ?? null
|
|
442
|
+
setAccordionOpenId(next)
|
|
443
|
+
writeAccordionToStorage(formId, name, next)
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
setCollapsed({})
|
|
447
|
+
for (const r of rows) writeCollapsedToStorage(formId, name, r.id, false)
|
|
448
|
+
}
|
|
449
|
+
const collapseAll = (): void => {
|
|
450
|
+
if (accordion) {
|
|
451
|
+
setAccordionOpenId(null)
|
|
452
|
+
writeAccordionToStorage(formId, name, null)
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
const next: Record<string, boolean> = {}
|
|
456
|
+
for (const r of rows) {
|
|
457
|
+
next[r.id] = true
|
|
458
|
+
writeCollapsedToStorage(formId, name, r.id, true)
|
|
459
|
+
}
|
|
460
|
+
setCollapsed(next)
|
|
461
|
+
}
|
|
462
|
+
|
|
431
463
|
// Visibility computed each render — hidden rows still occupy slots in
|
|
432
464
|
// `rows` (so values round-trip + reorder-around math stays simple), but
|
|
433
465
|
// they don't count for the user-facing empty state, drop indicator, or
|
|
@@ -494,6 +526,13 @@ export function RepeaterInput({
|
|
|
494
526
|
onChange={onContainerChange}
|
|
495
527
|
onBlur={onContainerBlur}
|
|
496
528
|
>
|
|
529
|
+
<BulkCollapseHeader
|
|
530
|
+
buttons={buttons}
|
|
531
|
+
disabled={disabled || !hasVisibleRow}
|
|
532
|
+
onExpandAll={expandAll}
|
|
533
|
+
onCollapseAll={collapseAll}
|
|
534
|
+
/>
|
|
535
|
+
|
|
497
536
|
{!hasVisibleRow && (
|
|
498
537
|
<div className="rounded-md border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
|
|
499
538
|
No items yet. Click {addLabel} to start.
|
|
@@ -12,7 +12,7 @@ import { Textarea } from '../ui/textarea.js'
|
|
|
12
12
|
* config. Outside a controlled form, falls back to plain `defaultValue`.
|
|
13
13
|
*/
|
|
14
14
|
export function TextLikeInput({
|
|
15
|
-
el, name, common, type, extraProps, multiline,
|
|
15
|
+
el, name, common, type, extraProps, multiline, applyMask,
|
|
16
16
|
}: {
|
|
17
17
|
el: ElementMeta
|
|
18
18
|
name: string
|
|
@@ -20,6 +20,10 @@ export function TextLikeInput({
|
|
|
20
20
|
type: string
|
|
21
21
|
extraProps: Record<string, unknown>
|
|
22
22
|
multiline: boolean
|
|
23
|
+
/** Optional keystroke formatter — `TextField.mask(pattern)`. When
|
|
24
|
+
* set, every change runs the value through this fn before it lands
|
|
25
|
+
* in state / the DOM. Defaults to identity. */
|
|
26
|
+
applyMask?: (value: string) => string
|
|
23
27
|
}): React.ReactElement {
|
|
24
28
|
const fs = useFieldState(name)
|
|
25
29
|
const liveCfg = el['live']
|
|
@@ -27,11 +31,13 @@ export function TextLikeInput({
|
|
|
27
31
|
? liveCfg as { onBlur?: boolean; debounce?: number }
|
|
28
32
|
: {})
|
|
29
33
|
const onBlurMode = liveOpts.onBlur === true
|
|
34
|
+
const mask = applyMask ?? identity
|
|
30
35
|
|
|
31
36
|
if (fs.controlled) {
|
|
32
37
|
const ctxValue = fs.value !== undefined && fs.value !== null ? String(fs.value) : ''
|
|
33
38
|
const onChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
|
34
|
-
|
|
39
|
+
const formatted = mask(e.target.value)
|
|
40
|
+
fs.setValue(formatted)
|
|
35
41
|
if (!onBlurMode) fs.triggerLive()
|
|
36
42
|
}
|
|
37
43
|
const onBlur = (): void => {
|
|
@@ -49,6 +55,20 @@ export function TextLikeInput({
|
|
|
49
55
|
return <Input {...(props as React.ComponentProps<typeof Input>)} type={type} />
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
// Uncontrolled path with mask: wire onInput so the user sees the
|
|
59
|
+
// formatted value as they type. Without `applyMask`, fall through to
|
|
60
|
+
// the legacy bare-defaultValue render so the DOM stays unchanged.
|
|
61
|
+
if (applyMask) {
|
|
62
|
+
const onInput = (e: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
|
63
|
+
const target = e.currentTarget
|
|
64
|
+
target.value = mask(target.value)
|
|
65
|
+
}
|
|
66
|
+
if (multiline) return <Textarea {...(common as React.ComponentProps<typeof Textarea>)} {...extraProps} onInput={onInput} />
|
|
67
|
+
return <Input {...(common as React.ComponentProps<typeof Input>)} type={type} {...extraProps} onInput={onInput} />
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
if (multiline) return <Textarea {...(common as React.ComponentProps<typeof Textarea>)} {...extraProps} />
|
|
53
71
|
return <Input {...(common as React.ComponentProps<typeof Input>)} type={type} {...extraProps} />
|
|
54
72
|
}
|
|
73
|
+
|
|
74
|
+
function identity(v: string): string { return v }
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
ArrowUpIcon,
|
|
5
5
|
ChevronDownIcon,
|
|
6
6
|
ChevronRightIcon,
|
|
7
|
+
ChevronsDownIcon,
|
|
8
|
+
ChevronsUpIcon,
|
|
7
9
|
CopyIcon,
|
|
8
10
|
GripVerticalIcon,
|
|
9
11
|
Trash2Icon,
|
|
@@ -154,11 +156,14 @@ export function ReorderGrip({
|
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
/**
|
|
157
|
-
* Collapse chevron — picks the open/closed glyph from state, then lets
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* the
|
|
159
|
+
* Collapse chevron — picks the open/closed glyph from state, then lets
|
|
160
|
+
* customizer overrides take over. When the row is currently collapsed,
|
|
161
|
+
* `expand` (from `expandAction(...)`) wins, falling back to `collapse`
|
|
162
|
+
* (from `collapseAction(...)`) for back-compat with single-override
|
|
163
|
+
* setups. When the row is open, `collapse` is used directly. Authors
|
|
164
|
+
* who want different chrome per state set both `collapseAction` and
|
|
165
|
+
* `expandAction`; authors who want a single uniform override keep
|
|
166
|
+
* setting just `collapseAction`.
|
|
162
167
|
*/
|
|
163
168
|
export function CollapseChevron({
|
|
164
169
|
isCollapsed,
|
|
@@ -177,10 +182,13 @@ export function CollapseChevron({
|
|
|
177
182
|
tooltip: isCollapsed ? 'Expand' : 'Collapse',
|
|
178
183
|
colorClass: 'text-muted-foreground hover:text-foreground',
|
|
179
184
|
}
|
|
185
|
+
const override = isCollapsed
|
|
186
|
+
? (buttons?.expand ?? buttons?.collapse)
|
|
187
|
+
: buttons?.collapse
|
|
180
188
|
return (
|
|
181
189
|
<RowChromeIconButton
|
|
182
190
|
defaults={defaults}
|
|
183
|
-
override={
|
|
191
|
+
override={override}
|
|
184
192
|
disabled={disabled}
|
|
185
193
|
onClick={onToggle}
|
|
186
194
|
ariaExpanded={!isCollapsed}
|
|
@@ -223,3 +231,91 @@ export const DEFAULT_REORDER: ButtonDefaults = {
|
|
|
223
231
|
tooltip: 'Drag to reorder',
|
|
224
232
|
colorClass: 'text-muted-foreground',
|
|
225
233
|
}
|
|
234
|
+
export const DEFAULT_EXPAND_ALL: ButtonDefaults = {
|
|
235
|
+
Icon: ChevronsDownIcon,
|
|
236
|
+
label: 'Expand all',
|
|
237
|
+
tooltip: 'Expand all',
|
|
238
|
+
colorClass: 'text-muted-foreground hover:text-foreground',
|
|
239
|
+
}
|
|
240
|
+
export const DEFAULT_COLLAPSE_ALL: ButtonDefaults = {
|
|
241
|
+
Icon: ChevronsUpIcon,
|
|
242
|
+
label: 'Collapse all',
|
|
243
|
+
tooltip: 'Collapse all',
|
|
244
|
+
colorClass: 'text-muted-foreground hover:text-foreground',
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Field-header strip with bulk Expand-all / Collapse-all chips. Renders
|
|
249
|
+
* only the buttons whose customizer slot is set on `buttons` — the
|
|
250
|
+
* presence of `buttons.expandAll` / `buttons.collapseAll` is what flips
|
|
251
|
+
* the matching button into existence (different from per-row chrome,
|
|
252
|
+
* which always renders when collapsible). Returns `null` when neither
|
|
253
|
+
* slot is configured so callers can mount the strip unconditionally.
|
|
254
|
+
*/
|
|
255
|
+
export function BulkCollapseHeader({
|
|
256
|
+
buttons,
|
|
257
|
+
disabled,
|
|
258
|
+
onExpandAll,
|
|
259
|
+
onCollapseAll,
|
|
260
|
+
}: {
|
|
261
|
+
buttons: RowButtonsMeta | undefined
|
|
262
|
+
disabled: boolean
|
|
263
|
+
onExpandAll: () => void
|
|
264
|
+
onCollapseAll: () => void
|
|
265
|
+
}): React.ReactElement | null {
|
|
266
|
+
const expandAllOverride = buttons?.expandAll
|
|
267
|
+
const collapseAllOverride = buttons?.collapseAll
|
|
268
|
+
if (!expandAllOverride && !collapseAllOverride) return null
|
|
269
|
+
return (
|
|
270
|
+
<div className="flex items-center justify-end gap-1">
|
|
271
|
+
{expandAllOverride && (
|
|
272
|
+
<BulkChromeButton
|
|
273
|
+
defaults={DEFAULT_EXPAND_ALL}
|
|
274
|
+
override={expandAllOverride}
|
|
275
|
+
disabled={disabled}
|
|
276
|
+
onClick={onExpandAll}
|
|
277
|
+
/>
|
|
278
|
+
)}
|
|
279
|
+
{collapseAllOverride && (
|
|
280
|
+
<BulkChromeButton
|
|
281
|
+
defaults={DEFAULT_COLLAPSE_ALL}
|
|
282
|
+
override={collapseAllOverride}
|
|
283
|
+
disabled={disabled}
|
|
284
|
+
onClick={onCollapseAll}
|
|
285
|
+
/>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Compact button used by `BulkCollapseHeader` — icon + label, sized to
|
|
293
|
+
* read as a header chip (smaller than a full Action button, larger than
|
|
294
|
+
* the icon-only per-row chrome). Picks up the same `RowButton`
|
|
295
|
+
* override surface as every other slot.
|
|
296
|
+
*/
|
|
297
|
+
function BulkChromeButton({
|
|
298
|
+
defaults,
|
|
299
|
+
override,
|
|
300
|
+
disabled,
|
|
301
|
+
onClick,
|
|
302
|
+
}: {
|
|
303
|
+
defaults: ButtonDefaults
|
|
304
|
+
override: RowButtonMeta | undefined
|
|
305
|
+
disabled: boolean
|
|
306
|
+
onClick: () => void
|
|
307
|
+
}): React.ReactElement {
|
|
308
|
+
const { Icon, label, tooltip, colorClass } = resolveRowChrome(defaults, override)
|
|
309
|
+
return (
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
onClick={onClick}
|
|
313
|
+
disabled={disabled}
|
|
314
|
+
title={tooltip}
|
|
315
|
+
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs ${colorClass} disabled:opacity-30`}
|
|
316
|
+
>
|
|
317
|
+
<Icon className="size-3.5" />
|
|
318
|
+
{label}
|
|
319
|
+
</button>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react'
|
|
2
|
+
import type { ElementMeta } from '../../schema/Element.js'
|
|
3
|
+
import { useToast } from '../Toaster.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TextField rich affordances live in this file (audit gap #3):
|
|
7
|
+
* - `revealable()` — eye-icon toggle that flips `type="password"` ↔ `text`
|
|
8
|
+
* - `copyable(message?)` — copy-to-clipboard button + toast
|
|
9
|
+
* - `prefixAction(Action) / suffixAction(Action)` — embedded Action buttons
|
|
10
|
+
* - `mask(pattern)` — keystroke-by-keystroke pattern formatter
|
|
11
|
+
*
|
|
12
|
+
* The components export a `useTextInputControls(el, name)` hook that
|
|
13
|
+
* returns `{ before, after, type, applyMask }` so the calling renderer
|
|
14
|
+
* can mount the controls inside `FieldShell`'s `before / after` slots
|
|
15
|
+
* and feed the masked value back through the controlled-input bridge.
|
|
16
|
+
*
|
|
17
|
+
* Mask alphabet:
|
|
18
|
+
* `9` — digit (0-9)
|
|
19
|
+
* `a` — alpha (A-Za-z)
|
|
20
|
+
* `*` — alphanumeric or any character
|
|
21
|
+
* anything else — literal (rendered verbatim)
|
|
22
|
+
*
|
|
23
|
+
* Submitted values are stripped of mask literals via `stripCharacters`
|
|
24
|
+
* (server-side coerce); the client also strips on input so what the user
|
|
25
|
+
* sees in the DOM matches what gets posted.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export interface TextInputControlsResult {
|
|
29
|
+
before: React.ReactNode
|
|
30
|
+
after: React.ReactNode
|
|
31
|
+
/** Effective `type` attribute — `'password'` when password+!revealed,
|
|
32
|
+
* `'text'` otherwise. Pass through to the rendered `<input>`. */
|
|
33
|
+
type: string
|
|
34
|
+
/** When the field has `mask(pattern)` set, format `value` against the
|
|
35
|
+
* pattern. Returns the formatted string. Skips when no mask. */
|
|
36
|
+
applyMask: (value: string) => string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `renderAction(meta)` is supplied by the caller (SchemaRenderer's
|
|
40
|
+
* `renderElement`) to avoid an import cycle between the controls
|
|
41
|
+
* module and the main schema renderer. */
|
|
42
|
+
export function useTextInputControls(
|
|
43
|
+
el: ElementMeta,
|
|
44
|
+
name: string,
|
|
45
|
+
renderAction: (meta: ElementMeta) => React.ReactNode,
|
|
46
|
+
): TextInputControlsResult {
|
|
47
|
+
const isPassword = el['password'] === true
|
|
48
|
+
const isRevealable= el['revealable'] === true
|
|
49
|
+
const isCopyable = el['copyable'] === true
|
|
50
|
+
const copyMessage = (el['copyMessage'] as string | undefined) ?? 'Copied!'
|
|
51
|
+
const mask = el['mask'] as string | undefined
|
|
52
|
+
const prefixAct = el['prefixAction'] as ElementMeta | undefined
|
|
53
|
+
const suffixAct = el['suffixAction'] as ElementMeta | undefined
|
|
54
|
+
|
|
55
|
+
const [revealed, setRevealed] = useState(false)
|
|
56
|
+
const inputId = name
|
|
57
|
+
|
|
58
|
+
const onCopy = useCopyHandler(inputId, copyMessage)
|
|
59
|
+
|
|
60
|
+
const type = isPassword && !revealed ? 'password' : 'text'
|
|
61
|
+
|
|
62
|
+
const beforeNodes: React.ReactNode[] = []
|
|
63
|
+
if (prefixAct) beforeNodes.push(
|
|
64
|
+
<div key="pre-act" className="shrink-0">{renderAction(prefixAct)}</div>
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const afterNodes: React.ReactNode[] = []
|
|
68
|
+
if (isCopyable) afterNodes.push(<CopyButton key="copy" onCopy={onCopy} />)
|
|
69
|
+
if (isRevealable && isPassword) {
|
|
70
|
+
afterNodes.push(
|
|
71
|
+
<RevealToggle
|
|
72
|
+
key="reveal"
|
|
73
|
+
revealed={revealed}
|
|
74
|
+
onToggle={() => setRevealed(v => !v)}
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
if (suffixAct) afterNodes.push(
|
|
79
|
+
<div key="suf-act" className="shrink-0">{renderAction(suffixAct)}</div>
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const applyMask = useCallback((value: string): string => {
|
|
83
|
+
if (!mask) return value
|
|
84
|
+
return formatWithMask(value, mask)
|
|
85
|
+
}, [mask])
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
before: beforeNodes.length > 0 ? <>{beforeNodes}</> : null,
|
|
89
|
+
after: afterNodes.length > 0 ? <>{afterNodes}</> : null,
|
|
90
|
+
type,
|
|
91
|
+
applyMask,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Format `raw` against `mask` using the documented alphabet. Literals
|
|
97
|
+
* in the mask render verbatim and consume no source character; pattern
|
|
98
|
+
* tokens consume the next matching source character (skipping
|
|
99
|
+
* mismatches so the user can paste an already-formatted value and have
|
|
100
|
+
* it re-format cleanly).
|
|
101
|
+
*/
|
|
102
|
+
export function formatWithMask(raw: string, mask: string): string {
|
|
103
|
+
let out = ''
|
|
104
|
+
let r = 0
|
|
105
|
+
for (let m = 0; m < mask.length; m++) {
|
|
106
|
+
const token = mask[m]!
|
|
107
|
+
const isPattern = token === '9' || token === 'a' || token === '*'
|
|
108
|
+
if (isPattern) {
|
|
109
|
+
if (r >= raw.length) break
|
|
110
|
+
// Advance through `raw` until we find a matching character.
|
|
111
|
+
while (r < raw.length) {
|
|
112
|
+
const ch = raw[r]!
|
|
113
|
+
const ok = token === '9'
|
|
114
|
+
? /[0-9]/.test(ch)
|
|
115
|
+
: token === 'a'
|
|
116
|
+
? /[A-Za-z]/.test(ch)
|
|
117
|
+
: true
|
|
118
|
+
r++
|
|
119
|
+
if (ok) { out += ch; break }
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
// Literal character — emit even when raw is exhausted, so a
|
|
123
|
+
// partially-typed value still shows the upcoming chrome
|
|
124
|
+
// (e.g. `(415) ` after the user types three digits). Consume the
|
|
125
|
+
// matching raw char if present so a paste of the already-formatted
|
|
126
|
+
// value doesn't double up.
|
|
127
|
+
out += token
|
|
128
|
+
if (raw[r] === token) r++
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function useCopyHandler(
|
|
135
|
+
inputId: string,
|
|
136
|
+
message: string,
|
|
137
|
+
): () => void {
|
|
138
|
+
const { notify } = useToast()
|
|
139
|
+
return () => {
|
|
140
|
+
if (typeof document === 'undefined') return
|
|
141
|
+
const el = document.getElementById(inputId) as HTMLInputElement | HTMLTextAreaElement | null
|
|
142
|
+
if (!el) return
|
|
143
|
+
const value = el.value
|
|
144
|
+
const writeText = navigator?.clipboard?.writeText?.bind(navigator.clipboard)
|
|
145
|
+
if (writeText) {
|
|
146
|
+
writeText(value).then(
|
|
147
|
+
() => notify({ type: 'success', title: message, duration: 2000 }),
|
|
148
|
+
() => notify({ type: 'error', title: 'Copy failed' }),
|
|
149
|
+
)
|
|
150
|
+
} else {
|
|
151
|
+
// Fallback: select + execCommand. Modern browsers all expose
|
|
152
|
+
// navigator.clipboard, so this is rare-path.
|
|
153
|
+
el.select()
|
|
154
|
+
try {
|
|
155
|
+
document.execCommand('copy')
|
|
156
|
+
notify({ type: 'success', title: message, duration: 2000 })
|
|
157
|
+
} catch {
|
|
158
|
+
notify({ type: 'error', title: 'Copy failed' })
|
|
159
|
+
}
|
|
160
|
+
el.setSelectionRange?.(0, 0)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ChromeButton({ onClick, ariaLabel, children, autoFocus }: {
|
|
166
|
+
onClick: (e: React.MouseEvent) => void
|
|
167
|
+
ariaLabel: string
|
|
168
|
+
children: React.ReactNode
|
|
169
|
+
autoFocus?: boolean
|
|
170
|
+
}): React.ReactElement {
|
|
171
|
+
return (
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
tabIndex={-1}
|
|
175
|
+
onClick={(e) => { e.preventDefault(); onClick(e) }}
|
|
176
|
+
aria-label={ariaLabel}
|
|
177
|
+
className="inline-flex items-center justify-center rounded-md size-8 text-muted-foreground hover:bg-accent hover:text-accent-foreground shrink-0"
|
|
178
|
+
autoFocus={autoFocus}
|
|
179
|
+
>
|
|
180
|
+
{children}
|
|
181
|
+
</button>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function CopyButton({ onCopy }: { onCopy: () => void }): React.ReactElement {
|
|
186
|
+
return (
|
|
187
|
+
<ChromeButton onClick={onCopy} ariaLabel="Copy">
|
|
188
|
+
<CopyIcon className="size-4" />
|
|
189
|
+
</ChromeButton>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function RevealToggle({ revealed, onToggle }: {
|
|
194
|
+
revealed: boolean
|
|
195
|
+
onToggle: () => void
|
|
196
|
+
}): React.ReactElement {
|
|
197
|
+
return (
|
|
198
|
+
<ChromeButton
|
|
199
|
+
onClick={onToggle}
|
|
200
|
+
ariaLabel={revealed ? 'Hide value' : 'Show value'}
|
|
201
|
+
>
|
|
202
|
+
{revealed ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
|
|
203
|
+
</ChromeButton>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Inline icon SVGs (avoids pulling lucide-react import into this file
|
|
208
|
+
// without checking it's already a dep — keeps the bundle stable). ────
|
|
209
|
+
|
|
210
|
+
function CopyIcon(props: React.SVGProps<SVGSVGElement>): React.ReactElement {
|
|
211
|
+
return (
|
|
212
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
|
213
|
+
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
|
214
|
+
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
|
215
|
+
</svg>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function EyeIcon(props: React.SVGProps<SVGSVGElement>): React.ReactElement {
|
|
220
|
+
return (
|
|
221
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
|
222
|
+
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
|
223
|
+
<circle cx="12" cy="12" r="3" />
|
|
224
|
+
</svg>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function EyeOffIcon(props: React.SVGProps<SVGSVGElement>): React.ReactElement {
|
|
229
|
+
return (
|
|
230
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
|
231
|
+
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
|
232
|
+
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
|
|
233
|
+
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
|
|
234
|
+
<line x1="2" x2="22" y1="2" y2="22" />
|
|
235
|
+
</svg>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
package/src/react/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export {
|
|
|
13
13
|
type FormFieldsProps,
|
|
14
14
|
} from './SchemaRenderer.js'
|
|
15
15
|
export { registerFieldRenderer, getFieldRenderer, type FieldRendererProps } from './registry.js'
|
|
16
|
+
export { registerFieldLabelSlot, getFieldLabelSlot, type FieldLabelSlotProps } from './FieldLabelSlotRegistry.js'
|
|
16
17
|
export {
|
|
17
18
|
registerWidgetRenderer,
|
|
18
19
|
getWidgetRenderer,
|
package/src/routes.ts
CHANGED
|
@@ -547,9 +547,29 @@ async function handleUploadRequest(
|
|
|
547
547
|
}
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
+
// Server-side resize via @rudderjs/image (optional peer dep)
|
|
551
|
+
const resizeWidthStr = typeof body['resize_width'] === 'string' ? body['resize_width'] : ''
|
|
552
|
+
const resizeHeightStr = typeof body['resize_height'] === 'string' ? body['resize_height'] : ''
|
|
553
|
+
let uploadFile: File = file
|
|
554
|
+
if (resizeWidthStr && resizeHeightStr) {
|
|
555
|
+
const w = Number(resizeWidthStr)
|
|
556
|
+
const h = Number(resizeHeightStr)
|
|
557
|
+
if (Number.isFinite(w) && w > 0 && Number.isFinite(h) && h > 0) {
|
|
558
|
+
try {
|
|
559
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
560
|
+
const pkg = await import('@rudderjs/image' as string) as { image: (input: unknown) => { resize(w: number, h: number): { format(f: string): { toBuffer(): Promise<Buffer> } } } }
|
|
561
|
+
const buf = await pkg.image(file).resize(w, h).format('webp').toBuffer()
|
|
562
|
+
const baseName = file.name.replace(/\.[^.]+$/, '')
|
|
563
|
+
uploadFile = new File([buf.buffer as ArrayBuffer], `${baseName}.webp`, { type: 'image/webp' })
|
|
564
|
+
} catch {
|
|
565
|
+
// @rudderjs/image not installed or resize failed — fall through with original file
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
550
570
|
try {
|
|
551
571
|
const result = await cfg.uploads.adapter.put({
|
|
552
|
-
file,
|
|
572
|
+
file: uploadFile,
|
|
553
573
|
...(directory ? { directory } : {}),
|
|
554
574
|
fieldName,
|
|
555
575
|
})
|
package/src/schema/Alert.test.ts
CHANGED
|
@@ -60,4 +60,50 @@ describe('Alert schema primitive', () => {
|
|
|
60
60
|
const out = await resolveSchema(tree)
|
|
61
61
|
assert.equal('children' in out[0]!, false)
|
|
62
62
|
})
|
|
63
|
+
|
|
64
|
+
// ─── Filament v5 chrome polish (2026-05-08, audit gap #8) ──
|
|
65
|
+
|
|
66
|
+
it('controls(...) is an alias for actions(...)', async () => {
|
|
67
|
+
const a = Action.make('upgrade').label('Upgrade').url('/billing')
|
|
68
|
+
const tree = [Alert.make('hi').controls([a])]
|
|
69
|
+
const out = await resolveSchema(tree)
|
|
70
|
+
const children = (out[0]!.children ?? []) as Array<{ type: string; name?: string }>
|
|
71
|
+
assert.equal(children.length, 1)
|
|
72
|
+
assert.equal(children[0]!.name, 'upgrade')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('controlActions(...) is the variadic spread alias', async () => {
|
|
76
|
+
const a = Action.make('upgrade').label('Upgrade').url('/billing')
|
|
77
|
+
const b = Action.make('learnMore').label('Read more').url('/docs')
|
|
78
|
+
const tree = [Alert.make('hi').controlActions(a, b)]
|
|
79
|
+
const out = await resolveSchema(tree)
|
|
80
|
+
const children = (out[0]!.children ?? []) as Array<{ name?: string }>
|
|
81
|
+
assert.equal(children.length, 2)
|
|
82
|
+
assert.equal(children[0]!.name, 'upgrade')
|
|
83
|
+
assert.equal(children[1]!.name, 'learnMore')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('dismissible() emits dismissible: true on meta; absent by default', () => {
|
|
87
|
+
assert.equal('dismissible' in Alert.make('a').toMeta(), false)
|
|
88
|
+
assert.equal(Alert.make('a').dismissible().toMeta().dismissible, true)
|
|
89
|
+
assert.equal('dismissible' in Alert.make('a').dismissible(false).toMeta(), false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('persistDismissal(key) auto-arms dismissible() and stamps the key', () => {
|
|
93
|
+
const meta = Alert.make('a').persistDismissal('billing-2026-q2').toMeta()
|
|
94
|
+
assert.equal(meta.dismissible, true)
|
|
95
|
+
assert.equal(meta.persistDismissal, 'billing-2026-q2')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('iconColor() emits the override only when set', () => {
|
|
99
|
+
assert.equal('iconColor' in Alert.make('a').toMeta(), false)
|
|
100
|
+
assert.equal(Alert.make('a').iconColor('destructive').toMeta().iconColor, 'destructive')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('footerActionsAlignment() emits only when non-default', () => {
|
|
104
|
+
assert.equal('actionsAlignment' in Alert.make('a').toMeta(), false)
|
|
105
|
+
assert.equal('actionsAlignment' in Alert.make('a').footerActionsAlignment('start').toMeta(), false)
|
|
106
|
+
assert.equal(Alert.make('a').footerActionsAlignment('center').toMeta().actionsAlignment, 'center')
|
|
107
|
+
assert.equal(Alert.make('a').footerActionsAlignment('end').toMeta().actionsAlignment, 'end')
|
|
108
|
+
})
|
|
63
109
|
})
|