@pilotiq/pilotiq 0.6.2 → 0.7.1
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 +6 -2
- package/CHANGELOG.md +614 -0
- package/CLAUDE.md +6 -5
- package/dist/Column.d.ts +35 -0
- package/dist/Column.d.ts.map +1 -1
- package/dist/Column.js +41 -0
- package/dist/Column.js.map +1 -1
- package/dist/Page.d.ts +13 -4
- package/dist/Page.d.ts.map +1 -1
- package/dist/Page.js +9 -2
- package/dist/Page.js.map +1 -1
- package/dist/Pilotiq.d.ts +84 -0
- package/dist/Pilotiq.d.ts.map +1 -1
- package/dist/Pilotiq.js +66 -0
- package/dist/Pilotiq.js.map +1 -1
- package/dist/Resource.d.ts +26 -0
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +9 -0
- package/dist/Resource.js.map +1 -1
- package/dist/actions/exportFactory.js +1 -1
- package/dist/actions/exportFactory.js.map +1 -1
- package/dist/columns/SelectColumn.d.ts +32 -5
- package/dist/columns/SelectColumn.d.ts.map +1 -1
- package/dist/columns/SelectColumn.js +37 -7
- package/dist/columns/SelectColumn.js.map +1 -1
- package/dist/defaultPages.d.ts.map +1 -1
- package/dist/defaultPages.js +3 -0
- package/dist/defaultPages.js.map +1 -1
- package/dist/elements/Form.d.ts +17 -0
- package/dist/elements/Form.d.ts.map +1 -1
- package/dist/elements/Form.js +17 -0
- package/dist/elements/Form.js.map +1 -1
- package/dist/elements/Table.d.ts +26 -0
- package/dist/elements/Table.d.ts.map +1 -1
- package/dist/elements/Table.js +15 -1
- package/dist/elements/Table.js.map +1 -1
- package/dist/elements/TableGroup.d.ts +84 -0
- package/dist/elements/TableGroup.d.ts.map +1 -1
- package/dist/elements/TableGroup.js +103 -0
- package/dist/elements/TableGroup.js.map +1 -1
- package/dist/elements/dispatchForm.d.ts.map +1 -1
- package/dist/elements/dispatchForm.js +36 -6
- package/dist/elements/dispatchForm.js.map +1 -1
- package/dist/elements/dispatchTable.d.ts +12 -0
- package/dist/elements/dispatchTable.d.ts.map +1 -1
- package/dist/elements/dispatchTable.js +103 -28
- package/dist/elements/dispatchTable.js.map +1 -1
- package/dist/fields/Field.d.ts +7 -2
- package/dist/fields/Field.d.ts.map +1 -1
- package/dist/fields/Field.js +8 -3
- package/dist/fields/Field.js.map +1 -1
- package/dist/fields/RepeaterField.d.ts +65 -0
- package/dist/fields/RepeaterField.d.ts.map +1 -1
- package/dist/fields/RepeaterField.js +48 -0
- package/dist/fields/RepeaterField.js.map +1 -1
- package/dist/orm/modelDefaults.d.ts.map +1 -1
- package/dist/orm/modelDefaults.js +19 -0
- package/dist/orm/modelDefaults.js.map +1 -1
- package/dist/pageData.d.ts +20 -0
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +242 -34
- package/dist/pageData.js.map +1 -1
- package/dist/react/AppShell.d.ts +17 -1
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js +34 -3
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/PendingSuggestionApplierRegistry.d.ts +34 -0
- package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -0
- package/dist/react/PendingSuggestionApplierRegistry.js +51 -0
- package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -0
- package/dist/react/PendingSuggestionOverlayRegistry.d.ts +46 -0
- package/dist/react/PendingSuggestionOverlayRegistry.d.ts.map +1 -0
- package/dist/react/PendingSuggestionOverlayRegistry.js +16 -0
- package/dist/react/PendingSuggestionOverlayRegistry.js.map +1 -0
- package/dist/react/PendingSuggestionsContext.d.ts +153 -0
- package/dist/react/PendingSuggestionsContext.d.ts.map +1 -0
- package/dist/react/PendingSuggestionsContext.js +46 -0
- package/dist/react/PendingSuggestionsContext.js.map +1 -0
- package/dist/react/SchemaRenderer.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.js +312 -39
- package/dist/react/SchemaRenderer.js.map +1 -1
- package/dist/react/cells/EditableCell.d.ts +8 -0
- package/dist/react/cells/EditableCell.d.ts.map +1 -1
- package/dist/react/cells/EditableCell.js +6 -2
- package/dist/react/cells/EditableCell.js.map +1 -1
- package/dist/react/fields/CheckboxListInput.d.ts.map +1 -1
- package/dist/react/fields/CheckboxListInput.js +29 -2
- package/dist/react/fields/CheckboxListInput.js.map +1 -1
- package/dist/react/fields/ColorInput.d.ts.map +1 -1
- package/dist/react/fields/ColorInput.js +28 -2
- package/dist/react/fields/ColorInput.js.map +1 -1
- package/dist/react/fields/DateTimeInput.d.ts.map +1 -1
- package/dist/react/fields/DateTimeInput.js +28 -2
- package/dist/react/fields/DateTimeInput.js.map +1 -1
- package/dist/react/fields/FieldShell.d.ts.map +1 -1
- package/dist/react/fields/FieldShell.js +161 -3
- package/dist/react/fields/FieldShell.js.map +1 -1
- package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
- package/dist/react/fields/FileUploadInput.js +27 -2
- package/dist/react/fields/FileUploadInput.js.map +1 -1
- package/dist/react/fields/KeyValueInput.d.ts.map +1 -1
- package/dist/react/fields/KeyValueInput.js +33 -2
- package/dist/react/fields/KeyValueInput.js.map +1 -1
- package/dist/react/fields/RadioInput.d.ts.map +1 -1
- package/dist/react/fields/RadioInput.js +28 -2
- package/dist/react/fields/RadioInput.js.map +1 -1
- package/dist/react/fields/SelectFieldInput.d.ts.map +1 -1
- package/dist/react/fields/SelectFieldInput.js +31 -2
- package/dist/react/fields/SelectFieldInput.js.map +1 -1
- package/dist/react/fields/SliderInput.d.ts.map +1 -1
- package/dist/react/fields/SliderInput.js +26 -2
- package/dist/react/fields/SliderInput.js.map +1 -1
- package/dist/react/fields/TagsInput.d.ts.map +1 -1
- package/dist/react/fields/TagsInput.js +26 -2
- package/dist/react/fields/TagsInput.js.map +1 -1
- package/dist/react/fields/ToggleFieldInput.d.ts.map +1 -1
- package/dist/react/fields/ToggleFieldInput.js +29 -2
- package/dist/react/fields/ToggleFieldInput.js.map +1 -1
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +55 -2
- package/dist/routes.js.map +1 -1
- package/dist/schema/Section.d.ts +16 -0
- package/dist/schema/Section.d.ts.map +1 -1
- package/dist/schema/Section.js +16 -0
- package/dist/schema/Section.js.map +1 -1
- package/dist/schema/Wizard.d.ts +45 -0
- package/dist/schema/Wizard.d.ts.map +1 -1
- package/dist/schema/Wizard.js +50 -0
- package/dist/schema/Wizard.js.map +1 -1
- package/dist/schema/resolveSchema.d.ts +8 -0
- package/dist/schema/resolveSchema.d.ts.map +1 -1
- package/dist/schema/resolveSchema.js +70 -1
- package/dist/schema/resolveSchema.js.map +1 -1
- package/dist/sessionFilters.d.ts.map +1 -1
- package/dist/sessionFilters.js +12 -1
- package/dist/sessionFilters.js.map +1 -1
- package/dist/styles/file-upload.css +13 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +19 -12
- package/dist/vite.js.map +1 -1
- package/package.json +6 -4
- package/src/Column.test.ts +36 -0
- package/src/Column.ts +54 -0
- package/src/Page.ts +13 -4
- package/src/Pilotiq.ts +109 -0
- package/src/Resource.ts +29 -0
- package/src/actions/exportFactory.ts +1 -1
- package/src/columns/SelectColumn.ts +46 -8
- package/src/columns/editableColumns.test.ts +45 -0
- package/src/defaultPages.ts +3 -0
- package/src/elements/Form.ts +19 -0
- package/src/elements/Table.ts +35 -1
- package/src/elements/TableGroup.test.ts +111 -0
- package/src/elements/TableGroup.ts +135 -0
- package/src/elements/dispatchForm.ts +34 -7
- package/src/elements/dispatchTable.test.ts +267 -0
- package/src/elements/dispatchTable.ts +111 -32
- package/src/fields/Field.test.ts +15 -0
- package/src/fields/Field.ts +8 -3
- package/src/fields/RepeaterField.ts +104 -0
- package/src/fields/RepeaterRelationship.test.ts +173 -0
- package/src/nestedRelationManagerData.test.ts +21 -0
- package/src/orm/modelDefaults.ts +21 -0
- package/src/pageData.ts +267 -47
- package/src/react/AppShell.tsx +55 -4
- package/src/react/PendingSuggestionApplierRegistry.ts +80 -0
- package/src/react/PendingSuggestionOverlayRegistry.ts +54 -0
- package/src/react/PendingSuggestionsContext.tsx +172 -0
- package/src/react/SchemaRenderer.tsx +504 -95
- package/src/react/cells/EditableCell.tsx +11 -2
- package/src/react/fields/CheckboxListInput.tsx +23 -2
- package/src/react/fields/ColorInput.tsx +22 -2
- package/src/react/fields/DateTimeInput.tsx +22 -2
- package/src/react/fields/FieldShell.tsx +167 -3
- package/src/react/fields/FileUploadInput.tsx +21 -2
- package/src/react/fields/KeyValueInput.tsx +32 -2
- package/src/react/fields/RadioInput.tsx +23 -2
- package/src/react/fields/SelectFieldInput.tsx +25 -2
- package/src/react/fields/SliderInput.tsx +20 -2
- package/src/react/fields/TagsInput.tsx +20 -2
- package/src/react/fields/ToggleFieldInput.tsx +23 -2
- package/src/react/index.ts +18 -0
- package/src/relationManagerData.test.ts +451 -2
- package/src/routes.ts +58 -2
- package/src/schema/Section.ts +17 -0
- package/src/schema/Wizard.ts +67 -0
- package/src/schema/containers.test.ts +90 -0
- package/src/schema/resolveSchema.test.ts +50 -0
- package/src/schema/resolveSchema.ts +79 -1
- package/src/sessionFilters.test.ts +23 -0
- package/src/sessionFilters.ts +11 -1
- package/src/styles/file-upload.css +13 -0
- package/src/vite.ts +19 -12
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
CircleIcon, InboxIcon, GripVerticalIcon,
|
|
69
69
|
ChevronDownIcon, CopyIcon, CheckIcon, XIcon,
|
|
70
70
|
InfoIcon, TriangleAlertIcon, CircleCheckIcon, CircleAlertIcon,
|
|
71
|
+
Columns3Icon,
|
|
71
72
|
} from 'lucide-react'
|
|
72
73
|
import type { ComponentType } from 'react'
|
|
73
74
|
import { useNavigate, type NavigateFn } from './navigate.js'
|
|
@@ -2058,16 +2059,60 @@ function SectionRenderer({ el, index }: { el: ElementMeta; index: number }) {
|
|
|
2058
2059
|
|
|
2059
2060
|
// ─── Wizard (Plan #8) ───────────────────────────────────────
|
|
2060
2061
|
|
|
2062
|
+
/**
|
|
2063
|
+
* Resolve the initial active step for `WizardRenderer`. Priority:
|
|
2064
|
+
* 1. URL `?<queryKey>=N` (1-based — wizards expose human-friendly indexes
|
|
2065
|
+
* when `Wizard.persistStepInQueryString()` is enabled).
|
|
2066
|
+
* 2. `localStorage[<storageKey>]` (0-based, set by the persist effect).
|
|
2067
|
+
* 3. `startOnStep` configured on the Wizard.
|
|
2068
|
+
*
|
|
2069
|
+
* SSR-safe: returns `startOnStep` when `window` is undefined.
|
|
2070
|
+
*/
|
|
2071
|
+
function readInitialWizardStep(
|
|
2072
|
+
total: number,
|
|
2073
|
+
startOnStep: number,
|
|
2074
|
+
storageKey: string | undefined,
|
|
2075
|
+
queryKey: string | undefined,
|
|
2076
|
+
): number {
|
|
2077
|
+
if (typeof window === 'undefined') return startOnStep
|
|
2078
|
+
if (queryKey) {
|
|
2079
|
+
try {
|
|
2080
|
+
const raw = new URL(window.location.href).searchParams.get(queryKey)
|
|
2081
|
+
if (raw !== null && raw !== '') {
|
|
2082
|
+
const n = Number(raw) - 1
|
|
2083
|
+
if (Number.isFinite(n) && n >= 0 && n < total) return n
|
|
2084
|
+
}
|
|
2085
|
+
} catch { /* ignore */ }
|
|
2086
|
+
}
|
|
2087
|
+
if (storageKey) {
|
|
2088
|
+
try {
|
|
2089
|
+
const stored = window.localStorage.getItem(storageKey)
|
|
2090
|
+
if (stored !== null) {
|
|
2091
|
+
const n = Number(stored)
|
|
2092
|
+
if (Number.isFinite(n) && n >= 0 && n < total) return n
|
|
2093
|
+
}
|
|
2094
|
+
} catch { /* ignore */ }
|
|
2095
|
+
}
|
|
2096
|
+
return startOnStep
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2061
2099
|
/**
|
|
2062
2100
|
* Multi-step form layout. Tracks active step in `useState`, optionally
|
|
2063
|
-
* persisted to localStorage. On Next click,
|
|
2064
|
-
* the form's `wizardUrl` (stamped by the
|
|
2065
|
-
* has a Wizard descendant). 200 → advance;
|
|
2066
|
-
* absent `wizardUrl` → advance immediately
|
|
2101
|
+
* persisted to localStorage and/or the URL query string. On Next click,
|
|
2102
|
+
* POSTs `{ step, values }` to the form's `wizardUrl` (stamped by the
|
|
2103
|
+
* route handler when the form has a Wizard descendant). 200 → advance;
|
|
2104
|
+
* 422 → stamp inline errors; absent `wizardUrl` → advance immediately
|
|
2105
|
+
* (no validation).
|
|
2067
2106
|
*
|
|
2068
2107
|
* Inactive steps render hidden (display:none) rather than unmounted so
|
|
2069
2108
|
* controlled inputs preserve their values across step transitions and
|
|
2070
2109
|
* cross-step `$get` works on the resolved meta.
|
|
2110
|
+
*
|
|
2111
|
+
* Nav buttons honor `Wizard.submitAction() / nextAction() / previousAction()`
|
|
2112
|
+
* — chrome (label / icon / color / size / outlined / iconOnly / tooltip /
|
|
2113
|
+
* disabled rules) carries through to the rendered button while the click
|
|
2114
|
+
* behavior stays hardwired (advance / recede / submit-form). Bare wizards
|
|
2115
|
+
* keep the built-in defaults.
|
|
2071
2116
|
*/
|
|
2072
2117
|
function WizardRenderer({ el, index }: {
|
|
2073
2118
|
el: ElementMeta
|
|
@@ -2082,26 +2127,22 @@ function WizardRenderer({ el, index }: {
|
|
|
2082
2127
|
const startOnStep = Math.max(0, Math.min(Math.max(0, steps.length - 1), Number(el['startOnStep'] ?? 0)))
|
|
2083
2128
|
const persist = el['persist'] !== false
|
|
2084
2129
|
const storageKey = persist && formId ? `pilotiq.wizard.${formId}.step` : undefined
|
|
2130
|
+
const queryKey = typeof el['persistStepInQueryString'] === 'string'
|
|
2131
|
+
? String(el['persistStepInQueryString'])
|
|
2132
|
+
: undefined
|
|
2085
2133
|
|
|
2086
|
-
const [
|
|
2134
|
+
const submitActionMeta = el['submitAction'] as ElementMeta | undefined
|
|
2135
|
+
const nextActionMeta = el['nextAction'] as ElementMeta | undefined
|
|
2136
|
+
const previousActionMeta = el['previousAction'] as ElementMeta | undefined
|
|
2137
|
+
|
|
2138
|
+
// Initial-step resolution priority: URL (?<key>=N, 1-based) > localStorage >
|
|
2139
|
+
// startOnStep. URL wins on first paint so deep links land on the right step
|
|
2140
|
+
// before localStorage can override. Lazy initializer — resolution runs once.
|
|
2141
|
+
const [active, setActive] = useState(() => readInitialWizardStep(steps.length, startOnStep, storageKey, queryKey))
|
|
2087
2142
|
const [advancing, setAdvancing] = useState(false)
|
|
2088
2143
|
const [advanceError, setAdvanceError] = useState<string | null>(null)
|
|
2089
2144
|
|
|
2090
|
-
//
|
|
2091
|
-
useEffect(() => {
|
|
2092
|
-
if (!storageKey) return
|
|
2093
|
-
if (typeof window === 'undefined') return
|
|
2094
|
-
try {
|
|
2095
|
-
const stored = window.localStorage.getItem(storageKey)
|
|
2096
|
-
if (stored !== null) {
|
|
2097
|
-
const n = Number(stored)
|
|
2098
|
-
if (Number.isFinite(n) && n >= 0 && n < steps.length) setActive(n)
|
|
2099
|
-
}
|
|
2100
|
-
} catch { /* ignore */ }
|
|
2101
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2102
|
-
}, [storageKey])
|
|
2103
|
-
|
|
2104
|
-
// Persist active step changes.
|
|
2145
|
+
// Persist active step changes to localStorage (when enabled).
|
|
2105
2146
|
useEffect(() => {
|
|
2106
2147
|
if (!storageKey) return
|
|
2107
2148
|
if (typeof window === 'undefined') return
|
|
@@ -2109,6 +2150,20 @@ function WizardRenderer({ el, index }: {
|
|
|
2109
2150
|
catch { /* ignore */ }
|
|
2110
2151
|
}, [storageKey, active])
|
|
2111
2152
|
|
|
2153
|
+
// Mirror active step to the URL via replaceState — purely client-side state
|
|
2154
|
+
// sync, no SPA re-fetch. 1-based externally; cleared when on the first step
|
|
2155
|
+
// so bare URLs don't grow ?step=1 noise.
|
|
2156
|
+
useEffect(() => {
|
|
2157
|
+
if (!queryKey) return
|
|
2158
|
+
if (typeof window === 'undefined') return
|
|
2159
|
+
try {
|
|
2160
|
+
const url = new URL(window.location.href)
|
|
2161
|
+
if (active === 0) url.searchParams.delete(queryKey)
|
|
2162
|
+
else url.searchParams.set(queryKey, String(active + 1))
|
|
2163
|
+
window.history.replaceState(window.history.state, '', url.toString())
|
|
2164
|
+
} catch { /* ignore */ }
|
|
2165
|
+
}, [queryKey, active])
|
|
2166
|
+
|
|
2112
2167
|
if (steps.length === 0) {
|
|
2113
2168
|
return (
|
|
2114
2169
|
<div key={index} className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
|
|
@@ -2223,34 +2278,106 @@ function WizardRenderer({ el, index }: {
|
|
|
2223
2278
|
)}
|
|
2224
2279
|
|
|
2225
2280
|
{/* Step nav. The Form's Save button (in Heading actions or page chrome)
|
|
2226
|
-
stays as-is — only the final step's submit goes through
|
|
2227
|
-
|
|
2228
|
-
|
|
2281
|
+
stays as-is by default — only the final step's submit goes through
|
|
2282
|
+
the form. `Wizard.submitAction()` opts into a wizard-owned submit
|
|
2283
|
+
button on the final step (use this when the wizard is the entire
|
|
2284
|
+
form and there's no page chrome). `nextAction() / previousAction()`
|
|
2285
|
+
customize the chrome of the built-in Back / Next buttons. */}
|
|
2229
2286
|
<div className="flex items-center justify-between gap-2">
|
|
2230
|
-
<
|
|
2231
|
-
|
|
2287
|
+
<WizardNavButton
|
|
2288
|
+
actionMeta={previousActionMeta}
|
|
2289
|
+
fallbackLabel="Back"
|
|
2232
2290
|
disabled={isFirst || advancing}
|
|
2233
2291
|
onClick={() => advance(active - 1)}
|
|
2234
|
-
|
|
2235
|
-
>
|
|
2236
|
-
Back
|
|
2237
|
-
</button>
|
|
2292
|
+
/>
|
|
2238
2293
|
{isLast
|
|
2239
|
-
?
|
|
2240
|
-
|
|
2241
|
-
|
|
2294
|
+
? (submitActionMeta
|
|
2295
|
+
? <WizardNavButton
|
|
2296
|
+
actionMeta={submitActionMeta}
|
|
2297
|
+
fallbackLabel="Submit"
|
|
2298
|
+
type="submit"
|
|
2299
|
+
disabled={advancing}
|
|
2300
|
+
/>
|
|
2301
|
+
: <span className="text-xs text-muted-foreground">Submit the form to finish.</span>)
|
|
2302
|
+
: <WizardNavButton
|
|
2303
|
+
actionMeta={nextActionMeta}
|
|
2304
|
+
fallbackLabel={advancing ? 'Validating…' : 'Next'}
|
|
2242
2305
|
disabled={advancing}
|
|
2243
2306
|
onClick={() => advance(active + 1)}
|
|
2244
|
-
|
|
2245
|
-
>
|
|
2246
|
-
{advancing ? 'Validating…' : 'Next'}
|
|
2247
|
-
</button>
|
|
2307
|
+
/>
|
|
2248
2308
|
}
|
|
2249
2309
|
</div>
|
|
2250
2310
|
</div>
|
|
2251
2311
|
)
|
|
2252
2312
|
}
|
|
2253
2313
|
|
|
2314
|
+
/**
|
|
2315
|
+
* Renders one wizard nav slot (Back / Next / Submit). Falls back to plain
|
|
2316
|
+
* built-in chrome (border button for Back, primary button for Next/Submit)
|
|
2317
|
+
* when no `actionMeta` is supplied; otherwise reads the resolved Action's
|
|
2318
|
+
* chrome (`label / icon / color / size / outlined / iconOnly / tooltip /
|
|
2319
|
+
* disabled`) and applies it to a button whose click is hardwired by the
|
|
2320
|
+
* surrounding wizard. `type="submit"` lets the Submit slot trigger the
|
|
2321
|
+
* surrounding form's onSubmit dispatcher (no `onClick` needed).
|
|
2322
|
+
*
|
|
2323
|
+
* Hidden actions (`.visible(false)` resolved-away) drop the slot entirely
|
|
2324
|
+
* — the resolver returns `undefined` for hidden Action elements, which
|
|
2325
|
+
* arrives here as `actionMeta == null` so we fall through to the default
|
|
2326
|
+
* chrome. Use `Wizard.skippable()` semantics to hide nav buttons when
|
|
2327
|
+
* appropriate; for permanent removal subclass the wizard.
|
|
2328
|
+
*/
|
|
2329
|
+
function WizardNavButton({
|
|
2330
|
+
actionMeta,
|
|
2331
|
+
fallbackLabel,
|
|
2332
|
+
type = 'button',
|
|
2333
|
+
disabled,
|
|
2334
|
+
onClick,
|
|
2335
|
+
}: {
|
|
2336
|
+
actionMeta: ElementMeta | undefined
|
|
2337
|
+
fallbackLabel: string
|
|
2338
|
+
type?: 'button' | 'submit'
|
|
2339
|
+
disabled?: boolean
|
|
2340
|
+
onClick?: () => void
|
|
2341
|
+
}) {
|
|
2342
|
+
// Bare default — keep historical chrome for back-compat (un-customized
|
|
2343
|
+
// wizards look identical to before this change).
|
|
2344
|
+
if (!actionMeta) {
|
|
2345
|
+
const isPrimary = type === 'submit' || fallbackLabel !== 'Back'
|
|
2346
|
+
return (
|
|
2347
|
+
<button
|
|
2348
|
+
type={type}
|
|
2349
|
+
disabled={disabled}
|
|
2350
|
+
onClick={onClick}
|
|
2351
|
+
className={isPrimary
|
|
2352
|
+
? 'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed'
|
|
2353
|
+
: 'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed'}
|
|
2354
|
+
>
|
|
2355
|
+
{fallbackLabel}
|
|
2356
|
+
</button>
|
|
2357
|
+
)
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
const ownDisabled = Boolean(actionMeta['disabled'])
|
|
2361
|
+
const label = String(actionMeta['label'] ?? fallbackLabel)
|
|
2362
|
+
const tooltip = actionMeta['tooltip'] ? String(actionMeta['tooltip']) : undefined
|
|
2363
|
+
const iconOnly = Boolean(actionMeta['iconOnly'])
|
|
2364
|
+
const className = actionButtonClass(actionMeta, {})
|
|
2365
|
+
const node = (
|
|
2366
|
+
<button
|
|
2367
|
+
type={type}
|
|
2368
|
+
disabled={disabled || ownDisabled}
|
|
2369
|
+
onClick={onClick}
|
|
2370
|
+
className={`${className} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
2371
|
+
aria-label={iconOnly ? label : undefined}
|
|
2372
|
+
>
|
|
2373
|
+
{renderActionIcon(actionMeta)}
|
|
2374
|
+
{!iconOnly && <span>{label}</span>}
|
|
2375
|
+
{renderActionBadge(actionMeta)}
|
|
2376
|
+
</button>
|
|
2377
|
+
)
|
|
2378
|
+
return <>{withTooltip(node, tooltip)}</>
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2254
2381
|
// ─── Top-level dispatch ─────────────────────────────────────
|
|
2255
2382
|
|
|
2256
2383
|
const TEXT_COLOR_CLASSES: Record<string, string> = {
|
|
@@ -3477,6 +3604,11 @@ interface TableUrlState {
|
|
|
3477
3604
|
* in the dropdown to override `defaultGroup`); `undefined` omits the
|
|
3478
3605
|
* key entirely so the configured default takes over. */
|
|
3479
3606
|
group?: string
|
|
3607
|
+
/** Drilled-in group key for `?groupKey=`. `undefined` omits — the
|
|
3608
|
+
* heading is banded (or no group at all); empty string explicitly
|
|
3609
|
+
* clears (used by the chip's × so a stale URL value doesn't return
|
|
3610
|
+
* via foreign-param round-trip). */
|
|
3611
|
+
groupKey?: string
|
|
3480
3612
|
}
|
|
3481
3613
|
|
|
3482
3614
|
// Mirror of `prefixedKey` in `elements/dispatchTable.ts`. Kept inline so
|
|
@@ -3531,6 +3663,7 @@ function buildTableQuery(
|
|
|
3531
3663
|
prefixK(prefix, 'page'),
|
|
3532
3664
|
prefixK(prefix, 'perPage'),
|
|
3533
3665
|
prefixK(prefix, 'group'),
|
|
3666
|
+
prefixK(prefix, 'groupKey'),
|
|
3534
3667
|
...Object.keys(filterValues).map(n => prefixK(prefix, n)),
|
|
3535
3668
|
])
|
|
3536
3669
|
for (const [k, v] of currentParams) {
|
|
@@ -3548,6 +3681,11 @@ function buildTableQuery(
|
|
|
3548
3681
|
if (merged.sort) params.set(prefixK(prefix, 'sort'), `${merged.sort.column}:${merged.sort.direction}`)
|
|
3549
3682
|
if (merged.page && merged.page > 1) params.set(prefixK(prefix, 'page'), String(merged.page))
|
|
3550
3683
|
if (merged.group !== undefined) params.set(prefixK(prefix, 'group'), merged.group)
|
|
3684
|
+
// groupKey is sparse — only writes when the override sets a non-empty
|
|
3685
|
+
// value. Drill-out (chip ×) passes `''` to clear; the foreign-param
|
|
3686
|
+
// dedupe set above already filtered the stale value out, so an empty
|
|
3687
|
+
// override produces a URL without the key.
|
|
3688
|
+
if (merged.groupKey) params.set(prefixK(prefix, 'groupKey'), merged.groupKey)
|
|
3551
3689
|
const qs = params.toString()
|
|
3552
3690
|
// Always anchor to a real pathname — Vike's client-side router treats
|
|
3553
3691
|
// a bare `?qs` href as a fresh URL with empty pathname, which routes
|
|
@@ -4882,6 +5020,78 @@ function RecordCellLink({
|
|
|
4882
5020
|
)
|
|
4883
5021
|
}
|
|
4884
5022
|
|
|
5023
|
+
/**
|
|
5024
|
+
* "Drilled into <Label>: <Value>" chip above the table when a group
|
|
5025
|
+
* heading has been clicked. The × clears `?<prefix>groupKey=`, returning
|
|
5026
|
+
* the table to its banded view. Real `<a href>` with `useNavigate()`
|
|
5027
|
+
* intercept on plain left-click so cmd-click / middle-click open a
|
|
5028
|
+
* fresh tab (rare but valid for sharing the banded view URL).
|
|
5029
|
+
*/
|
|
5030
|
+
function ActiveGroupKeyChip({
|
|
5031
|
+
label, value, displayValue, clearHref, navigate,
|
|
5032
|
+
}: {
|
|
5033
|
+
label: string
|
|
5034
|
+
value: string
|
|
5035
|
+
displayValue: string
|
|
5036
|
+
clearHref: string
|
|
5037
|
+
navigate: NavigateFn
|
|
5038
|
+
}) {
|
|
5039
|
+
const onClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
5040
|
+
if (e.button !== 0) return
|
|
5041
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
5042
|
+
e.preventDefault()
|
|
5043
|
+
void navigate(clearHref)
|
|
5044
|
+
}
|
|
5045
|
+
return (
|
|
5046
|
+
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm">
|
|
5047
|
+
<span className="text-muted-foreground">Drilled into</span>
|
|
5048
|
+
<span className="font-medium text-foreground">
|
|
5049
|
+
{label ? `${label}: ` : ''}{displayValue || value}
|
|
5050
|
+
</span>
|
|
5051
|
+
<a
|
|
5052
|
+
href={clearHref}
|
|
5053
|
+
onClick={onClick}
|
|
5054
|
+
aria-label="Clear drill-in"
|
|
5055
|
+
className="ms-auto text-muted-foreground hover:text-foreground"
|
|
5056
|
+
>
|
|
5057
|
+
×
|
|
5058
|
+
</a>
|
|
5059
|
+
</div>
|
|
5060
|
+
)
|
|
5061
|
+
}
|
|
5062
|
+
|
|
5063
|
+
/**
|
|
5064
|
+
* Group-heading text wrapped in a real `<a href>` that SPA-navs into the
|
|
5065
|
+
* drilled-in URL. Plain left-click intercepts for `useNavigate()`;
|
|
5066
|
+
* cmd/ctrl/shift-click + middle-click fall through to the browser so
|
|
5067
|
+
* "open in new tab" semantics work. Visually inherits the heading
|
|
5068
|
+
* styling — the link adds underline-on-hover affordance without
|
|
5069
|
+
* disturbing the surrounding text-transform / size.
|
|
5070
|
+
*/
|
|
5071
|
+
function GroupHeadingLink({
|
|
5072
|
+
href, navigate, children,
|
|
5073
|
+
}: {
|
|
5074
|
+
href: string
|
|
5075
|
+
navigate: NavigateFn
|
|
5076
|
+
children: React.ReactNode
|
|
5077
|
+
}) {
|
|
5078
|
+
const onClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
5079
|
+
if (e.button !== 0) return
|
|
5080
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
5081
|
+
e.preventDefault()
|
|
5082
|
+
void navigate(href)
|
|
5083
|
+
}
|
|
5084
|
+
return (
|
|
5085
|
+
<a
|
|
5086
|
+
href={href}
|
|
5087
|
+
onClick={onClick}
|
|
5088
|
+
className="inline-flex items-center gap-1 text-inherit no-underline hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
|
5089
|
+
>
|
|
5090
|
+
{children}
|
|
5091
|
+
</a>
|
|
5092
|
+
)
|
|
5093
|
+
}
|
|
5094
|
+
|
|
4885
5095
|
/**
|
|
4886
5096
|
* List-page tab strip — Filament-style query shortcuts above the table
|
|
4887
5097
|
* ("All / Drafts / Published / Archived"). Each trigger is a real `<a>`
|
|
@@ -5121,6 +5331,61 @@ function SortByPicker({
|
|
|
5121
5331
|
)
|
|
5122
5332
|
}
|
|
5123
5333
|
|
|
5334
|
+
/**
|
|
5335
|
+
* Toolbar dropdown for `Column.toggleable()` columns. Lists every
|
|
5336
|
+
* toggleable column with a checkbox; toggling writes through to a
|
|
5337
|
+
* caller-supplied `onToggle` (the `TableRendererBody` owns the state
|
|
5338
|
+
* + the localStorage round-trip). Mounted only when at least one
|
|
5339
|
+
* column is toggleable.
|
|
5340
|
+
*/
|
|
5341
|
+
function ColumnsToggleDropdown({
|
|
5342
|
+
columns, hidden, onToggle,
|
|
5343
|
+
}: {
|
|
5344
|
+
columns: ElementMeta[]
|
|
5345
|
+
hidden: Set<string>
|
|
5346
|
+
onToggle: (name: string, nextHidden: boolean) => void
|
|
5347
|
+
}) {
|
|
5348
|
+
if (columns.length === 0) return null
|
|
5349
|
+
return (
|
|
5350
|
+
<DropdownMenu>
|
|
5351
|
+
<DropdownMenuTrigger
|
|
5352
|
+
render={(props) => (
|
|
5353
|
+
<button
|
|
5354
|
+
{...props}
|
|
5355
|
+
type="button"
|
|
5356
|
+
className="inline-flex h-9 items-center gap-1.5 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground hover:bg-accent"
|
|
5357
|
+
aria-label="Show or hide columns"
|
|
5358
|
+
>
|
|
5359
|
+
<Columns3Icon className="h-4 w-4" aria-hidden="true" />
|
|
5360
|
+
<span>Columns</span>
|
|
5361
|
+
</button>
|
|
5362
|
+
)}
|
|
5363
|
+
/>
|
|
5364
|
+
<DropdownMenuContent align="end" className="min-w-[12rem]">
|
|
5365
|
+
{columns.map((col, i) => {
|
|
5366
|
+
const name = String(col['name'] ?? '')
|
|
5367
|
+
const label = String(col['label'] ?? name)
|
|
5368
|
+
const isHidden = hidden.has(name)
|
|
5369
|
+
return (
|
|
5370
|
+
<DropdownMenuItem
|
|
5371
|
+
key={i}
|
|
5372
|
+
// Suppress menu-close so users can toggle multiple columns
|
|
5373
|
+
// without re-opening the dropdown.
|
|
5374
|
+
closeOnClick={false}
|
|
5375
|
+
onClick={() => onToggle(name, !isHidden)}
|
|
5376
|
+
>
|
|
5377
|
+
<span className="inline-flex w-4 items-center justify-center">
|
|
5378
|
+
{!isHidden && <CheckIcon className="h-4 w-4" aria-hidden="true" />}
|
|
5379
|
+
</span>
|
|
5380
|
+
<span>{label}</span>
|
|
5381
|
+
</DropdownMenuItem>
|
|
5382
|
+
)
|
|
5383
|
+
})}
|
|
5384
|
+
</DropdownMenuContent>
|
|
5385
|
+
</DropdownMenu>
|
|
5386
|
+
)
|
|
5387
|
+
}
|
|
5388
|
+
|
|
5124
5389
|
/**
|
|
5125
5390
|
* Lookup tables for responsive grid column-counts in `contentLayout:
|
|
5126
5391
|
* 'cards'`. Tailwind's JIT scanner needs **literal** class strings; we
|
|
@@ -5318,6 +5583,11 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5318
5583
|
const navigate = useNavigate()
|
|
5319
5584
|
const children = el.children ?? []
|
|
5320
5585
|
const columns = children.filter(c => c.type === 'column')
|
|
5586
|
+
// `Column.toggleable()` columns — sourced from the resolved meta. The
|
|
5587
|
+
// user's per-table visibility map is owned + persisted below; the full
|
|
5588
|
+
// `columns` list stays available for the toolbar dropdown so hidden
|
|
5589
|
+
// columns can be re-shown without a roundtrip.
|
|
5590
|
+
const toggleableColumns = columns.filter(c => c['toggleable'] !== undefined)
|
|
5321
5591
|
// Actions and ActionGroups share placement — both show up in the
|
|
5322
5592
|
// header/bulk/row toolbars depending on their `placement` field.
|
|
5323
5593
|
const actionLike = children.filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent')
|
|
@@ -5326,6 +5596,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5326
5596
|
const hasRecordClasses = Boolean(el['recordClasses'])
|
|
5327
5597
|
const pollInterval = typeof el['pollInterval'] === 'number' ? el['pollInterval'] as number : undefined
|
|
5328
5598
|
const defaultGroup = typeof el['defaultGroup'] === 'string' ? el['defaultGroup'] as string : undefined
|
|
5599
|
+
const activeGroupKey = typeof el['activeGroupKey'] === 'string' ? el['activeGroupKey'] as string : undefined
|
|
5329
5600
|
const summaries = el['summaries'] as Record<string, Array<{ kind: string; value: string; label?: string }>> | undefined
|
|
5330
5601
|
const groupSummaries = el['groupSummaries'] as
|
|
5331
5602
|
Record<string, Record<string, Array<{ kind: string; value: string; label?: string }>>> | undefined
|
|
@@ -5335,6 +5606,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5335
5606
|
collapsible?: true
|
|
5336
5607
|
collapsed?: true
|
|
5337
5608
|
date?: true
|
|
5609
|
+
scopable?: true
|
|
5338
5610
|
}> | undefined) ?? []
|
|
5339
5611
|
// Active group's registered metadata (if any). Falls back to a synth
|
|
5340
5612
|
// for the bare-column form so the heading row still has a label.
|
|
@@ -5348,6 +5620,11 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5348
5620
|
})
|
|
5349
5621
|
: undefined
|
|
5350
5622
|
const groupColumnLabel = activeGroupMeta?.label
|
|
5623
|
+
// Heading text becomes a real `<a href>` when the active group opts in
|
|
5624
|
+
// via `.scopable()`. Synthesized bare-column groups can't be scopable
|
|
5625
|
+
// (no builder call ran).
|
|
5626
|
+
const groupHeadingScopable = activeGroupMeta !== undefined
|
|
5627
|
+
&& (activeGroupMeta as { scopable?: true }).scopable === true
|
|
5351
5628
|
|
|
5352
5629
|
// Auto-refresh: re-visit current URL on a timer so sort/filter/pagination
|
|
5353
5630
|
// state survives. Pause while the document is hidden — background tabs
|
|
@@ -5393,6 +5670,59 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5393
5670
|
const perPage = el['perPage'] as number | undefined
|
|
5394
5671
|
const searchable = Boolean(el['searchable'])
|
|
5395
5672
|
const currentPath = (el['currentPath'] as string | undefined) ?? ''
|
|
5673
|
+
|
|
5674
|
+
// `Column.toggleable()` user-visibility map. Persisted per-table at
|
|
5675
|
+
// `pilotiq.table.<currentPath>.columns.<name>` ('1' = hidden,
|
|
5676
|
+
// '0' = visible). On first paint, fall back to `meta.toggleable.initiallyHidden`.
|
|
5677
|
+
// SSR returns the meta default — the localStorage hydrate happens
|
|
5678
|
+
// inside the effect so server + first client render match.
|
|
5679
|
+
const columnsVisibilityKey = (name: string): string =>
|
|
5680
|
+
`pilotiq.table.${currentPath}.columns.${name}`
|
|
5681
|
+
const initialHidden = (): Set<string> => {
|
|
5682
|
+
const out = new Set<string>()
|
|
5683
|
+
for (const col of toggleableColumns) {
|
|
5684
|
+
const cfg = col['toggleable'] as { initiallyHidden?: boolean } | undefined
|
|
5685
|
+
if (cfg?.initiallyHidden) out.add(String(col['name']))
|
|
5686
|
+
}
|
|
5687
|
+
return out
|
|
5688
|
+
}
|
|
5689
|
+
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(initialHidden)
|
|
5690
|
+
useEffect(() => {
|
|
5691
|
+
if (typeof window === 'undefined') return
|
|
5692
|
+
if (toggleableColumns.length === 0) return
|
|
5693
|
+
const next = new Set<string>()
|
|
5694
|
+
for (const col of toggleableColumns) {
|
|
5695
|
+
const name = String(col['name'])
|
|
5696
|
+
const cfg = col['toggleable'] as { initiallyHidden?: boolean } | undefined
|
|
5697
|
+
try {
|
|
5698
|
+
const stored = window.localStorage.getItem(columnsVisibilityKey(name))
|
|
5699
|
+
if (stored === '1') next.add(name)
|
|
5700
|
+
else if (stored === '0') { /* visible */ }
|
|
5701
|
+
else if (cfg?.initiallyHidden) next.add(name)
|
|
5702
|
+
} catch {
|
|
5703
|
+
if (cfg?.initiallyHidden) next.add(name)
|
|
5704
|
+
}
|
|
5705
|
+
}
|
|
5706
|
+
setHiddenColumns(next)
|
|
5707
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5708
|
+
}, [currentPath, toggleableColumns.length])
|
|
5709
|
+
const toggleColumnHidden = (name: string, nextHidden: boolean): void => {
|
|
5710
|
+
setHiddenColumns(prev => {
|
|
5711
|
+
const next = new Set(prev)
|
|
5712
|
+
if (nextHidden) next.add(name)
|
|
5713
|
+
else next.delete(name)
|
|
5714
|
+
if (typeof window !== 'undefined') {
|
|
5715
|
+
try { window.localStorage.setItem(columnsVisibilityKey(name), nextHidden ? '1' : '0') }
|
|
5716
|
+
catch { /* private mode / quota — silent */ }
|
|
5717
|
+
}
|
|
5718
|
+
return next
|
|
5719
|
+
})
|
|
5720
|
+
}
|
|
5721
|
+
// Filtered column list used by every render path (header, body cells,
|
|
5722
|
+
// group + footer summaries, empty-state colSpan). Non-toggleable
|
|
5723
|
+
// columns always survive.
|
|
5724
|
+
const visibleColumns = columns.filter(c => !hiddenColumns.has(String(c['name'])))
|
|
5725
|
+
|
|
5396
5726
|
// Tier-3 — when the table opts into `Table.queryStringIdentifier(...)`,
|
|
5397
5727
|
// every URL key (search / sort / page / perPage / group / filter names)
|
|
5398
5728
|
// gets prefixed with `${id}_` so multiple tables on one page don't
|
|
@@ -5474,6 +5804,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5474
5804
|
...(urlGroup !== undefined ? { group: urlGroup }
|
|
5475
5805
|
: defaultGroup !== undefined ? { group: defaultGroup }
|
|
5476
5806
|
: {}),
|
|
5807
|
+
...(activeGroupKey !== undefined ? { groupKey: activeGroupKey } : {}),
|
|
5477
5808
|
}
|
|
5478
5809
|
|
|
5479
5810
|
// Snapshot active filter values for sort/pagination href construction.
|
|
@@ -5485,6 +5816,17 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5485
5816
|
if (typeof v === 'string' && v !== '') activeFilters[String(f['name'])] = v
|
|
5486
5817
|
}
|
|
5487
5818
|
|
|
5819
|
+
// Drill-in / drill-out URL builders for the group heading link and the
|
|
5820
|
+
// active-key chip's clear button. Drill-in sets `?<prefix>groupKey=v`
|
|
5821
|
+
// and resets `page`; drill-out clears it. Both round-trip foreign
|
|
5822
|
+
// params (other tables' state) through `buildTableQuery`.
|
|
5823
|
+
const buildGroupKeyHref = (value: string): string => buildTableQuery(
|
|
5824
|
+
state, { groupKey: value, page: 1 }, currentPath, activeFilters, queryPrefix,
|
|
5825
|
+
)
|
|
5826
|
+
const drillOutHref = (): string => buildTableQuery(
|
|
5827
|
+
state, { groupKey: '', page: 1 }, currentPath, activeFilters, queryPrefix,
|
|
5828
|
+
)
|
|
5829
|
+
|
|
5488
5830
|
// Track which row ids are currently checked. Keyed by id (string), not
|
|
5489
5831
|
// by index, so pagination and re-renders don't drop selection state.
|
|
5490
5832
|
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
|
@@ -5634,7 +5976,8 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5634
5976
|
// Only modal + collapsible mount a toolbar widget; the always-visible
|
|
5635
5977
|
// strip modes don't add anything to the header bar.
|
|
5636
5978
|
const showFiltersInToolbar = hasFilters && (filtersInModal || filtersCollapsible)
|
|
5637
|
-
const
|
|
5979
|
+
const hasColumnsToggle = toggleableColumns.length > 0
|
|
5980
|
+
const showHeaderBar = searchable || headerActions.length > 0 || showFiltersInToolbar || hasGroupPicker || hasSortPicker || hasColumnsToggle
|
|
5638
5981
|
const hasBulkActions = bulkActions.length > 0
|
|
5639
5982
|
const hasRowActions = rowActions.length > 0
|
|
5640
5983
|
|
|
@@ -5657,7 +6000,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5657
6000
|
!searchActive &&
|
|
5658
6001
|
currentPage === 1
|
|
5659
6002
|
const reorderColumnVisible = reorderableColumn !== undefined
|
|
5660
|
-
const totalCols =
|
|
6003
|
+
const totalCols = visibleColumns.length
|
|
5661
6004
|
+ (hasBulkActions ? 1 : 0)
|
|
5662
6005
|
+ (hasRowActions ? 1 : 0)
|
|
5663
6006
|
+ (reorderColumnVisible ? 1 : 0)
|
|
@@ -5686,7 +6029,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5686
6029
|
)}
|
|
5687
6030
|
{showHeaderBar && (
|
|
5688
6031
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
5689
|
-
{(searchable || showFiltersInToolbar || hasGroupPicker || hasSortPicker) ? (
|
|
6032
|
+
{(searchable || showFiltersInToolbar || hasGroupPicker || hasSortPicker || hasColumnsToggle) ? (
|
|
5690
6033
|
<div className="flex items-center gap-2">
|
|
5691
6034
|
{searchable && (
|
|
5692
6035
|
<form method="get" action={currentPath || undefined} className="flex items-end gap-2">
|
|
@@ -5753,6 +6096,13 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5753
6096
|
}}
|
|
5754
6097
|
/>
|
|
5755
6098
|
)}
|
|
6099
|
+
{toggleableColumns.length > 0 && (
|
|
6100
|
+
<ColumnsToggleDropdown
|
|
6101
|
+
columns={toggleableColumns}
|
|
6102
|
+
hidden={hiddenColumns}
|
|
6103
|
+
onToggle={toggleColumnHidden}
|
|
6104
|
+
/>
|
|
6105
|
+
)}
|
|
5756
6106
|
</div>
|
|
5757
6107
|
) : <span />}
|
|
5758
6108
|
{headerActions.length > 0 && (
|
|
@@ -5766,6 +6116,29 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5766
6116
|
{hasFilters && filtersAbove && filtersOpen && (
|
|
5767
6117
|
<FilterStrip filters={filters} prefix={queryPrefix} />
|
|
5768
6118
|
)}
|
|
6119
|
+
{activeGroupKey !== undefined && (
|
|
6120
|
+
<ActiveGroupKeyChip
|
|
6121
|
+
label={groupColumnLabel ?? defaultGroup ?? ''}
|
|
6122
|
+
value={activeGroupKey}
|
|
6123
|
+
displayValue={(() => {
|
|
6124
|
+
// Prefer a row-resolved `_groupTitle` (server stamped via
|
|
6125
|
+
// `getTitleFromRecordUsing`) so the chip reads the same as
|
|
6126
|
+
// a banded heading. Falls back to the raw bucket key when
|
|
6127
|
+
// no row matched — empty drilled-in pages still show what
|
|
6128
|
+
// they're drilled into.
|
|
6129
|
+
for (const r of rows) {
|
|
6130
|
+
const obj = r as Record<string, unknown>
|
|
6131
|
+
if (String(obj['_groupValue'] ?? '') !== activeGroupKey) continue
|
|
6132
|
+
const t = obj['_groupTitle']
|
|
6133
|
+
if (typeof t === 'string' && t !== '') return t
|
|
6134
|
+
break
|
|
6135
|
+
}
|
|
6136
|
+
return activeGroupKey
|
|
6137
|
+
})()}
|
|
6138
|
+
clearHref={drillOutHref()}
|
|
6139
|
+
navigate={navigate}
|
|
6140
|
+
/>
|
|
6141
|
+
)}
|
|
5769
6142
|
{hasBulkActions && someChecked && (
|
|
5770
6143
|
<div className="flex items-center justify-between gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm">
|
|
5771
6144
|
<span className="text-muted-foreground">
|
|
@@ -5809,6 +6182,8 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5809
6182
|
toggleGroupCollapsed={toggleGroupCollapsed}
|
|
5810
6183
|
cardsPerRow={cardsPerRow}
|
|
5811
6184
|
navigate={navigate}
|
|
6185
|
+
groupHeadingScopable={groupHeadingScopable}
|
|
6186
|
+
buildGroupKeyHref={buildGroupKeyHref}
|
|
5812
6187
|
/>
|
|
5813
6188
|
) : (
|
|
5814
6189
|
<div className="rounded-xl border bg-card overflow-hidden">
|
|
@@ -5827,7 +6202,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5827
6202
|
/>
|
|
5828
6203
|
</TableHead>
|
|
5829
6204
|
)}
|
|
5830
|
-
{
|
|
6205
|
+
{visibleColumns.map((col, i) => {
|
|
5831
6206
|
const name = String(col['name'] ?? '')
|
|
5832
6207
|
const label = String(col['label'] ?? name)
|
|
5833
6208
|
const sortable = Boolean(col['sortable'])
|
|
@@ -5931,34 +6306,44 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5931
6306
|
colSpan={totalCols}
|
|
5932
6307
|
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground"
|
|
5933
6308
|
>
|
|
5934
|
-
{
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
aria-expanded={!isInCollapsedGroup}
|
|
5940
|
-
>
|
|
5941
|
-
<ChevronDownIcon
|
|
5942
|
-
className={[
|
|
5943
|
-
'size-4 transition-transform',
|
|
5944
|
-
isInCollapsedGroup ? '-rotate-90' : '',
|
|
5945
|
-
].filter(Boolean).join(' ')}
|
|
5946
|
-
/>
|
|
6309
|
+
{(() => {
|
|
6310
|
+
const drillable = groupHeadingScopable
|
|
6311
|
+
&& groupValue !== undefined
|
|
6312
|
+
&& groupValue !== ''
|
|
6313
|
+
const headingText = (
|
|
5947
6314
|
<GroupHeaderText
|
|
5948
6315
|
label={groupColumnLabel}
|
|
5949
6316
|
value={groupValue}
|
|
5950
6317
|
title={groupTitle}
|
|
5951
6318
|
description={groupDescription}
|
|
5952
6319
|
/>
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
6320
|
+
)
|
|
6321
|
+
const headingNode = drillable
|
|
6322
|
+
? <GroupHeadingLink href={buildGroupKeyHref(groupValue!)} navigate={navigate}>{headingText}</GroupHeadingLink>
|
|
6323
|
+
: headingText
|
|
6324
|
+
if (groupCollapsible) {
|
|
6325
|
+
return (
|
|
6326
|
+
<div className="flex w-full items-center gap-2">
|
|
6327
|
+
<button
|
|
6328
|
+
type="button"
|
|
6329
|
+
className="inline-flex items-center"
|
|
6330
|
+
onClick={() => toggleGroupCollapsed(groupValue!)}
|
|
6331
|
+
aria-expanded={!isInCollapsedGroup}
|
|
6332
|
+
aria-label={isInCollapsedGroup ? 'Expand group' : 'Collapse group'}
|
|
6333
|
+
>
|
|
6334
|
+
<ChevronDownIcon
|
|
6335
|
+
className={[
|
|
6336
|
+
'size-4 transition-transform',
|
|
6337
|
+
isInCollapsedGroup ? '-rotate-90' : '',
|
|
6338
|
+
].filter(Boolean).join(' ')}
|
|
6339
|
+
/>
|
|
6340
|
+
</button>
|
|
6341
|
+
{headingNode}
|
|
6342
|
+
</div>
|
|
6343
|
+
)
|
|
6344
|
+
}
|
|
6345
|
+
return headingNode
|
|
6346
|
+
})()}
|
|
5962
6347
|
</TableCell>
|
|
5963
6348
|
</TableRow>
|
|
5964
6349
|
)}
|
|
@@ -5999,7 +6384,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
5999
6384
|
/>
|
|
6000
6385
|
</TableCell>
|
|
6001
6386
|
)}
|
|
6002
|
-
{
|
|
6387
|
+
{visibleColumns.map((col, ci) => {
|
|
6003
6388
|
const name = String(col['name'] ?? '')
|
|
6004
6389
|
const value = recordObj[name]
|
|
6005
6390
|
const align = col['alignment'] === 'center' ? 'text-center'
|
|
@@ -6022,9 +6407,18 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
6022
6407
|
: null
|
|
6023
6408
|
if (EditableComp && editUrl !== undefined) {
|
|
6024
6409
|
const cellDisabled = col['disabled'] === true || cellDisabledMap?.[name] === true
|
|
6410
|
+
const cellSelectOptionsMap = recordObj['_cellSelectOptions'] as
|
|
6411
|
+
Record<string, Array<{ value: string; label: string }>> | undefined
|
|
6412
|
+
const rowOptions = cellSelectOptionsMap?.[name]
|
|
6025
6413
|
return (
|
|
6026
6414
|
<TableCell key={ci} className={`text-sm text-foreground ${align} p-0`} style={widthStyle}>
|
|
6027
|
-
<EditableComp
|
|
6415
|
+
<EditableComp
|
|
6416
|
+
url={editUrl}
|
|
6417
|
+
col={col}
|
|
6418
|
+
value={value}
|
|
6419
|
+
disabled={cellDisabled}
|
|
6420
|
+
{...(rowOptions ? { rowOptions } : {})}
|
|
6421
|
+
/>
|
|
6028
6422
|
</TableCell>
|
|
6029
6423
|
)
|
|
6030
6424
|
}
|
|
@@ -6064,7 +6458,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
6064
6458
|
<TableRow key={`group-summary-${id}`} className="bg-muted/20 hover:bg-muted/20">
|
|
6065
6459
|
{reorderColumnVisible && <TableCell />}
|
|
6066
6460
|
{hasBulkActions && <TableCell />}
|
|
6067
|
-
{
|
|
6461
|
+
{visibleColumns.map((col, ci) => {
|
|
6068
6462
|
const name = String(col['name'] ?? '')
|
|
6069
6463
|
const align = col['alignment'] === 'center' ? 'text-center'
|
|
6070
6464
|
: col['alignment'] === 'end' ? 'text-right'
|
|
@@ -6094,7 +6488,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
|
|
|
6094
6488
|
<TableRow>
|
|
6095
6489
|
{reorderColumnVisible && <TableCell />}
|
|
6096
6490
|
{hasBulkActions && <TableCell />}
|
|
6097
|
-
{
|
|
6491
|
+
{visibleColumns.map((col, ci) => {
|
|
6098
6492
|
const name = String(col['name'] ?? '')
|
|
6099
6493
|
const align = col['alignment'] === 'center' ? 'text-center'
|
|
6100
6494
|
: col['alignment'] === 'end' ? 'text-right'
|
|
@@ -6174,6 +6568,7 @@ function CardsLayoutBody({
|
|
|
6174
6568
|
striped, activeEmpty, EmptyIcon, hasFilterOrSearch,
|
|
6175
6569
|
defaultGroup, groupColumnLabel, groupCollapsible, collapsedGroups, toggleGroupCollapsed,
|
|
6176
6570
|
cardsPerRow, navigate,
|
|
6571
|
+
groupHeadingScopable, buildGroupKeyHref,
|
|
6177
6572
|
}: {
|
|
6178
6573
|
el: ElementMeta
|
|
6179
6574
|
columns: ElementMeta[]
|
|
@@ -6197,6 +6592,10 @@ function CardsLayoutBody({
|
|
|
6197
6592
|
toggleGroupCollapsed: (groupValue: string) => void
|
|
6198
6593
|
cardsPerRow: Record<string, number> | undefined
|
|
6199
6594
|
navigate: NavigateFn
|
|
6595
|
+
// Drill-in affordances. Sparse: when `groupHeadingScopable` is false,
|
|
6596
|
+
// the heading renders as before; `buildGroupKeyHref` is unused.
|
|
6597
|
+
groupHeadingScopable?: boolean
|
|
6598
|
+
buildGroupKeyHref?: (value: string) => string
|
|
6200
6599
|
}) {
|
|
6201
6600
|
void el // keep prop for future telemetry; silences unused-prop lint
|
|
6202
6601
|
void columns
|
|
@@ -6260,38 +6659,48 @@ function CardsLayoutBody({
|
|
|
6260
6659
|
&& collapsedGroups[section.groupValue] === true
|
|
6261
6660
|
return (
|
|
6262
6661
|
<div key={si} className="flex flex-col gap-3">
|
|
6263
|
-
{section.groupValue !== undefined && (
|
|
6264
|
-
|
|
6265
|
-
|
|
6266
|
-
|
|
6267
|
-
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6662
|
+
{section.groupValue !== undefined && (() => {
|
|
6663
|
+
const drillable = groupHeadingScopable === true
|
|
6664
|
+
&& buildGroupKeyHref !== undefined
|
|
6665
|
+
&& section.groupValue !== ''
|
|
6666
|
+
const headingText = (
|
|
6667
|
+
<GroupHeaderText
|
|
6668
|
+
label={groupColumnLabel}
|
|
6669
|
+
value={section.groupValue}
|
|
6670
|
+
title={section.title}
|
|
6671
|
+
description={section.description}
|
|
6672
|
+
/>
|
|
6673
|
+
)
|
|
6674
|
+
const headingNode = drillable
|
|
6675
|
+
? <GroupHeadingLink href={buildGroupKeyHref!(section.groupValue!)} navigate={navigate}>{headingText}</GroupHeadingLink>
|
|
6676
|
+
: headingText
|
|
6677
|
+
if (groupCollapsible) {
|
|
6678
|
+
return (
|
|
6679
|
+
<div className="flex w-full items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
6680
|
+
<button
|
|
6681
|
+
type="button"
|
|
6682
|
+
className="inline-flex items-center"
|
|
6683
|
+
onClick={() => toggleGroupCollapsed(section.groupValue!)}
|
|
6684
|
+
aria-expanded={!collapsed}
|
|
6685
|
+
aria-label={collapsed ? 'Expand group' : 'Collapse group'}
|
|
6686
|
+
>
|
|
6687
|
+
<ChevronDownIcon
|
|
6688
|
+
className={[
|
|
6689
|
+
'size-4 transition-transform',
|
|
6690
|
+
collapsed ? '-rotate-90' : '',
|
|
6691
|
+
].filter(Boolean).join(' ')}
|
|
6692
|
+
/>
|
|
6693
|
+
</button>
|
|
6694
|
+
{headingNode}
|
|
6695
|
+
</div>
|
|
6696
|
+
)
|
|
6697
|
+
}
|
|
6698
|
+
return (
|
|
6285
6699
|
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
6286
|
-
|
|
6287
|
-
label={groupColumnLabel}
|
|
6288
|
-
value={section.groupValue}
|
|
6289
|
-
title={section.title}
|
|
6290
|
-
description={section.description}
|
|
6291
|
-
/>
|
|
6700
|
+
{headingNode}
|
|
6292
6701
|
</div>
|
|
6293
6702
|
)
|
|
6294
|
-
)}
|
|
6703
|
+
})()}
|
|
6295
6704
|
{!collapsed && (
|
|
6296
6705
|
<div className={gridClass}>
|
|
6297
6706
|
{section.indices.map((ri) => {
|