hs-uix 2.1.0 → 2.2.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/README.md +3 -1
- package/common-components.d.ts +319 -68
- package/dist/calendar.js +397 -119
- package/dist/calendar.mjs +399 -119
- package/dist/common-components.js +3546 -88
- package/dist/common-components.mjs +3530 -84
- package/dist/datatable.js +108 -18
- package/dist/datatable.mjs +108 -18
- package/dist/experimental.js +2876 -0
- package/dist/experimental.mjs +2883 -0
- package/dist/feed.js +267 -38
- package/dist/feed.mjs +260 -37
- package/dist/filter.js +1379 -0
- package/dist/filter.mjs +1334 -0
- package/dist/form.js +222 -26
- package/dist/form.mjs +227 -27
- package/dist/index.js +3255 -353
- package/dist/index.mjs +3199 -344
- package/dist/kanban.js +282 -62
- package/dist/kanban.mjs +273 -61
- package/dist/safe.js +9207 -0
- package/dist/safe.mjs +9298 -0
- package/dist/utils.js +491 -75
- package/dist/utils.mjs +491 -75
- package/experimental.d.ts +1 -0
- package/filter.d.ts +1 -0
- package/index.d.ts +45 -3
- package/package.json +19 -1
- package/safe.d.ts +1 -0
- package/src/calendar/README.md +76 -5
- package/src/calendar/index.d.ts +108 -1
- package/src/common-components/README.md +140 -1
- package/src/datatable/README.md +0 -2
- package/src/experimental/README.md +126 -0
- package/src/experimental/index.d.ts +346 -0
- package/src/feed/README.md +69 -0
- package/src/feed/index.d.ts +103 -0
- package/src/filter/README.md +148 -0
- package/src/filter/index.d.ts +221 -0
- package/src/form/README.md +132 -4
- package/src/form/index.d.ts +82 -1
- package/src/kanban/README.md +119 -6
- package/src/kanban/index.d.ts +153 -2
- package/src/safe/README.md +108 -0
- package/src/safe/index.d.ts +158 -0
- package/src/utils/README.md +39 -0
- package/src/wizard/README.md +158 -0
- package/src/wizard/index.d.ts +138 -0
- package/utils.d.ts +17 -0
package/src/form/README.md
CHANGED
|
@@ -555,18 +555,61 @@ For centralized alert config:
|
|
|
555
555
|
/>
|
|
556
556
|
```
|
|
557
557
|
|
|
558
|
-
## Dirty Tracking
|
|
558
|
+
## Dirty Tracking & Unsaved-Changes Guard
|
|
559
|
+
|
|
560
|
+
FormBuilder deep-compares current values against the initial snapshot (the
|
|
561
|
+
resolved `initialValues` / first controlled `values`). `onDirtyChange` fires on
|
|
562
|
+
transitions only — once when the form becomes dirty, once when it goes clean
|
|
563
|
+
again (reset, submit-reset, or values edited back to their originals).
|
|
559
564
|
|
|
560
565
|
```jsx
|
|
561
566
|
<FormBuilder
|
|
562
567
|
fields={fields}
|
|
563
568
|
onSubmit={save}
|
|
564
569
|
onDirtyChange={(isDirty) => {
|
|
565
|
-
// e.g.,
|
|
570
|
+
// e.g., flip a "you have unsaved changes" banner outside the form
|
|
566
571
|
}}
|
|
567
572
|
/>
|
|
568
573
|
```
|
|
569
574
|
|
|
575
|
+
The ref exposes the same state imperatively: `formRef.current.isDirty()` and
|
|
576
|
+
`formRef.current.getDirtyFields()` (names of changed fields).
|
|
577
|
+
|
|
578
|
+
### Confirm Before Discarding (`confirmDiscard`)
|
|
579
|
+
|
|
580
|
+
When `confirmDiscard` is set, the built-in Cancel button stops firing
|
|
581
|
+
`onCancel` directly while the form is dirty. Instead it opens a native Modal
|
|
582
|
+
confirmation — "Keep editing" closes the modal, the destructive confirm
|
|
583
|
+
closes it and then calls `onCancel`. A clean form cancels immediately, no
|
|
584
|
+
modal.
|
|
585
|
+
|
|
586
|
+
```jsx
|
|
587
|
+
<FormBuilder
|
|
588
|
+
fields={fields}
|
|
589
|
+
onSubmit={save}
|
|
590
|
+
showCancel={true}
|
|
591
|
+
onCancel={() => actions.closeOverlay("edit-panel")}
|
|
592
|
+
confirmDiscard={true} // or customize:
|
|
593
|
+
// confirmDiscard={{
|
|
594
|
+
// title: "Discard this deal?",
|
|
595
|
+
// message: "Your edits to this deal will be lost.",
|
|
596
|
+
// confirmLabel: "Discard edits",
|
|
597
|
+
// cancelLabel: "Keep editing",
|
|
598
|
+
// }}
|
|
599
|
+
/>
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
The confirmation modal closes itself via `actions.closeOverlay` (FormBuilder
|
|
603
|
+
calls `useExtensionActions` internally — nothing to thread in).
|
|
604
|
+
|
|
605
|
+
**Host-close caveat.** UI extensions cannot intercept the host panel/modal
|
|
606
|
+
close — the X button, ESC, and outside clicks discard silently and there is no
|
|
607
|
+
`beforeunload` equivalent. `confirmDiscard` only guards FormBuilder's own
|
|
608
|
+
Cancel button (and the imperative `ref.reset()` is intentionally unguarded —
|
|
609
|
+
it's your code calling it). For everything else, `onDirtyChange` is your hook:
|
|
610
|
+
track dirty state in the parent and surface your own warning UI (a banner, a
|
|
611
|
+
disabled close affordance, an alert on reopen).
|
|
612
|
+
|
|
570
613
|
## Custom Render Escape Hatch
|
|
571
614
|
|
|
572
615
|
For fields that need custom rendering:
|
|
@@ -1059,6 +1102,89 @@ const initialValues = useFormPrefill(properties, {
|
|
|
1059
1102
|
<FormBuilder fields={fields} initialValues={initialValues} onSubmit={save} />
|
|
1060
1103
|
```
|
|
1061
1104
|
|
|
1105
|
+
## Fields from HubSpot Properties
|
|
1106
|
+
|
|
1107
|
+
`fieldsFromHubSpotProperties` turns HubSpot property definitions (the objects
|
|
1108
|
+
returned by `GET /crm/v3/properties/{objectType}`) into FormBuilder field
|
|
1109
|
+
configs, so a CRM edit form is one fetch + one function call instead of a
|
|
1110
|
+
hand-maintained field list that drifts from the portal.
|
|
1111
|
+
|
|
1112
|
+
```jsx
|
|
1113
|
+
import { FormBuilder, fieldsFromHubSpotProperties } from "hs-uix/form";
|
|
1114
|
+
|
|
1115
|
+
// properties = the `results` array from the properties API (via hubspot.fetch
|
|
1116
|
+
// or a serverless function)
|
|
1117
|
+
const fields = fieldsFromHubSpotProperties(properties, {
|
|
1118
|
+
include: ["dealname", "dealstage", "amount", "closedate"], // also sets order
|
|
1119
|
+
requiredOverrides: ["dealname", "dealstage"],
|
|
1120
|
+
overrides: {
|
|
1121
|
+
amount: { min: 0, description: "USD" },
|
|
1122
|
+
},
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
<FormBuilder fields={fields} onSubmit={save} />
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
Type mapping (HubSpot `fieldType` first, storage `type` as tie-breaker):
|
|
1129
|
+
|
|
1130
|
+
| HubSpot | FormBuilder field type |
|
|
1131
|
+
|---|---|
|
|
1132
|
+
| `select` (enumeration) | `select` — options from property options, `hidden: true` options filtered |
|
|
1133
|
+
| `radio` | `radioGroup` |
|
|
1134
|
+
| `checkbox` (multi-enum) | `multiselect` |
|
|
1135
|
+
| `booleancheckbox` | `toggle` — with `transformIn`/`transformOut` normalizing HubSpot's `"true"`/`"false"` strings |
|
|
1136
|
+
| `date` (type `date`) | `date` |
|
|
1137
|
+
| `date` (type `datetime`) | `datetime` |
|
|
1138
|
+
| `number` | `number` — with `transformIn` parsing HubSpot's numeric strings |
|
|
1139
|
+
| `textarea` | `textarea` |
|
|
1140
|
+
| `text` / `phonenumber` | `text` |
|
|
1141
|
+
| anything else | falls back on storage type (`enumeration` → `select`, `bool` → `toggle`, `number` → `number`, else `text`) |
|
|
1142
|
+
|
|
1143
|
+
Options:
|
|
1144
|
+
|
|
1145
|
+
| Option | Type | Default | Notes |
|
|
1146
|
+
|---|---|---|---|
|
|
1147
|
+
| `include` | `string[]` | - | Property names to keep. Also sets the output field order. |
|
|
1148
|
+
| `exclude` | `string[]` | - | Property names to drop. |
|
|
1149
|
+
| `overrides` | `Record<string, Partial<Field>>` | - | Partial field configs merged over the generated config per property. |
|
|
1150
|
+
| `requiredOverrides` | `string[] \| Record<string, boolean>` | - | Mark properties required (property definitions don't carry required-ness — that's per-form in HubSpot). |
|
|
1151
|
+
| `includeDescriptions` | `boolean` | `false` | Copy property descriptions into field `description` help text (off by default — portal descriptions are often internal notes). |
|
|
1152
|
+
|
|
1153
|
+
Hard-earned defaults: `hidden: true` properties are skipped unless explicitly
|
|
1154
|
+
listed in `include`; `calculated` and `modificationMetadata.readOnlyValue`
|
|
1155
|
+
properties come back `readOnly: true` (an editable input for a value HubSpot
|
|
1156
|
+
will refuse to save is a silent-fail trap). Date/datetime **values** are not
|
|
1157
|
+
transformed — epoch-ms ↔ `{ year, month, date }` conversion is timezone
|
|
1158
|
+
sensitive, so wire `transformIn`/`transformOut` yourself (see `dateToTimestamp`
|
|
1159
|
+
in `hs-uix/utils`).
|
|
1160
|
+
|
|
1161
|
+
## Field-Level Loading
|
|
1162
|
+
|
|
1163
|
+
Set `loading: true` on a field while its options (or any backing data) are
|
|
1164
|
+
still in flight: the input is disabled, and `select` / `multiselect` fields
|
|
1165
|
+
render an inline `LoadingSpinner` (`size="xs"`) beside the control so the
|
|
1166
|
+
empty dropdown reads as "still fetching" rather than broken.
|
|
1167
|
+
|
|
1168
|
+
```jsx
|
|
1169
|
+
const { options, loading } = useCrmSearchOptions({ objectType: "companies", query });
|
|
1170
|
+
|
|
1171
|
+
const fields = [
|
|
1172
|
+
{ name: "company", type: "select", label: "Company", options, loading },
|
|
1173
|
+
];
|
|
1174
|
+
```
|
|
1175
|
+
|
|
1176
|
+
The CRM search adapters in `hs-uix/utils` set exactly this key, so the two
|
|
1177
|
+
compose with zero glue:
|
|
1178
|
+
|
|
1179
|
+
```jsx
|
|
1180
|
+
import { useCrmSearchOptions, makeCrmSearchSelectField } from "hs-uix/utils";
|
|
1181
|
+
|
|
1182
|
+
const search = useCrmSearchOptions({ objectType: "companies" });
|
|
1183
|
+
const fields = [
|
|
1184
|
+
makeCrmSearchSelectField({ name: "company", label: "Company" }, search),
|
|
1185
|
+
];
|
|
1186
|
+
```
|
|
1187
|
+
|
|
1062
1188
|
## Auto-Save
|
|
1063
1189
|
|
|
1064
1190
|
Debounced auto-save on field changes:
|
|
@@ -1121,6 +1247,7 @@ try {
|
|
|
1121
1247
|
| `submitVariant` | `"primary" \| "secondary"` | `"primary"` | Button variant |
|
|
1122
1248
|
| `showCancel` | `boolean` | `false` | Show cancel button |
|
|
1123
1249
|
| `onCancel` | `() => void` | - | Cancel callback |
|
|
1250
|
+
| `confirmDiscard` | `boolean \| { title?, message?, confirmLabel?, cancelLabel? }` | - | When set, the built-in Cancel button opens a native Modal confirmation before discarding dirty changes. Cannot intercept the host panel/modal close — use `onDirtyChange` for that |
|
|
1124
1251
|
| `submitPosition` | `"bottom" \| "none"` | `"bottom"` | Button placement |
|
|
1125
1252
|
| `submitAlign` | `"start" \| "end" \| "between"` | auto | Default single-step button-row alignment. Defaults to `"between"` when `showCancel` is true, otherwise `"start"` |
|
|
1126
1253
|
| `loading` | `boolean` | - | Controlled loading state |
|
|
@@ -1156,7 +1283,7 @@ try {
|
|
|
1156
1283
|
| `onSubmitError` | `(error, helpers) => void` | - | Post-submit error |
|
|
1157
1284
|
| `resetOnSuccess` | `boolean` | `false` | Auto-reset after success |
|
|
1158
1285
|
| `autoSave` | `{ debounce?, onAutoSave }` | - | Debounced auto-save |
|
|
1159
|
-
| `onDirtyChange` | `(isDirty) => void` | - | Dirty state callback |
|
|
1286
|
+
| `onDirtyChange` | `(isDirty) => void` | - | Dirty state callback (fires on transitions only) |
|
|
1160
1287
|
| `ref` | `Ref<FormBuilderRef>` | - | Imperative ref |
|
|
1161
1288
|
|
|
1162
1289
|
### Field Props
|
|
@@ -1183,7 +1310,7 @@ try {
|
|
|
1183
1310
|
| `useDefaultValidators` | `boolean` | All | Enable/disable built-in type/shape validation (default `true`) |
|
|
1184
1311
|
| `validateDebounce` | `number` | All | Debounce async validation (ms) |
|
|
1185
1312
|
| `debounce` | `number` | All | Debounce onChange callback (ms) |
|
|
1186
|
-
| `loading` | `boolean` | All | Field-level loading
|
|
1313
|
+
| `loading` | `boolean` | All | Field-level loading: disables the input; select/multiselect also render an inline `LoadingSpinner` (set automatically by `makeCrmSearchSelectField` / `makeCrmSearchMultiSelectField`) |
|
|
1187
1314
|
| `group` | `string` | All | Divider-based field grouping |
|
|
1188
1315
|
| `onFieldChange` | `(value, allValues, helpers) => void` | All | Cross-field side effects |
|
|
1189
1316
|
| `transformIn` | `(rawValue) => displayValue` | All | Storage → display transform (on load) |
|
|
@@ -1233,6 +1360,7 @@ try {
|
|
|
1233
1360
|
| `reset()` | `void` | Reset to initial values |
|
|
1234
1361
|
| `getValues()` | `Record<string, unknown>` | Current form values |
|
|
1235
1362
|
| `isDirty()` | `boolean` | Whether values differ from initial |
|
|
1363
|
+
| `getDirtyFields()` | `string[]` | Names of fields whose value differs from initial |
|
|
1236
1364
|
| `setFieldValue(name, value)` | `void` | Set a field value programmatically |
|
|
1237
1365
|
| `setFieldError(name, message)` | `void` | Set a field error programmatically |
|
|
1238
1366
|
| `setErrors(errors)` | `void` | Batch set field errors (server-side validation) |
|
package/src/form/index.d.ts
CHANGED
|
@@ -106,6 +106,17 @@ export interface FormBuilderAlertConfig {
|
|
|
106
106
|
successTitle?: string;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
export interface FormBuilderConfirmDiscardConfig {
|
|
110
|
+
/** Modal title. Default "Discard changes?". */
|
|
111
|
+
title?: string;
|
|
112
|
+
/** Modal body text. Default "You have unsaved changes. If you discard now, they will be lost.". */
|
|
113
|
+
message?: string;
|
|
114
|
+
/** Destructive confirm button label. Default "Discard changes". */
|
|
115
|
+
confirmLabel?: string;
|
|
116
|
+
/** Keep-editing button label. Default "Keep editing". */
|
|
117
|
+
cancelLabel?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
109
120
|
export interface FormBuilderButtonsRenderContext {
|
|
110
121
|
isMultiStep: boolean;
|
|
111
122
|
isFirstStep: boolean;
|
|
@@ -114,6 +125,8 @@ export interface FormBuilderButtonsRenderContext {
|
|
|
114
125
|
totalSteps: number;
|
|
115
126
|
disabled: boolean;
|
|
116
127
|
loading: boolean;
|
|
128
|
+
/** True when current values deep-differ from the initial snapshot. */
|
|
129
|
+
isDirty: boolean;
|
|
117
130
|
labels: Required<Pick<FormBuilderLabels, "submit" | "cancel" | "back" | "next">>;
|
|
118
131
|
onBack: () => void;
|
|
119
132
|
onNext: () => void;
|
|
@@ -164,7 +177,12 @@ export interface FormBuilderField {
|
|
|
164
177
|
minValidationMessage?: string;
|
|
165
178
|
maxValidationMessage?: string;
|
|
166
179
|
|
|
167
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Field-level loading indicator. While true the input is disabled and
|
|
182
|
+
* select/multiselect fields render an inline LoadingSpinner beside the
|
|
183
|
+
* control — `makeCrmSearchSelectField` / `makeCrmSearchMultiSelectField`
|
|
184
|
+
* (hs-uix/utils) set this automatically while CRM search options load.
|
|
185
|
+
*/
|
|
168
186
|
loading?: boolean;
|
|
169
187
|
|
|
170
188
|
// Conditional visibility
|
|
@@ -357,6 +375,8 @@ export interface FormBuilderRef {
|
|
|
357
375
|
reset: () => void;
|
|
358
376
|
getValues: () => Record<string, unknown>;
|
|
359
377
|
isDirty: () => boolean;
|
|
378
|
+
/** Names of fields whose current value deep-differs from the initial snapshot. */
|
|
379
|
+
getDirtyFields: () => string[];
|
|
360
380
|
setFieldValue: (name: string, value: unknown) => void;
|
|
361
381
|
setFieldError: (name: string, message: string) => void;
|
|
362
382
|
setErrors: (errors: Record<string, string>) => void;
|
|
@@ -424,6 +444,14 @@ export interface FormBuilderProps {
|
|
|
424
444
|
submitVariant?: "primary" | "secondary";
|
|
425
445
|
showCancel?: boolean;
|
|
426
446
|
onCancel?: () => void;
|
|
447
|
+
/**
|
|
448
|
+
* Guard the built-in Cancel button while the form is dirty: clicking it
|
|
449
|
+
* opens a native Modal confirmation ("Keep editing" / destructive confirm)
|
|
450
|
+
* before onCancel fires. Pass `true` for default copy or an object to
|
|
451
|
+
* customize it. Note: extensions cannot intercept the host panel/modal
|
|
452
|
+
* close (the X button) — pair this with onDirtyChange for those cases.
|
|
453
|
+
*/
|
|
454
|
+
confirmDiscard?: boolean | FormBuilderConfirmDiscardConfig;
|
|
427
455
|
submitPosition?: "bottom" | "none";
|
|
428
456
|
/**
|
|
429
457
|
* Controls the default single-step action-row alignment.
|
|
@@ -496,3 +524,56 @@ export declare function useFormPrefill(
|
|
|
496
524
|
properties: Record<string, unknown> | undefined,
|
|
497
525
|
mapping?: Record<string, string>
|
|
498
526
|
): Record<string, unknown>;
|
|
527
|
+
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// HubSpot property schema mapping
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
/** One enumeration option on a HubSpot property definition. */
|
|
533
|
+
export interface FormBuilderHubSpotPropertyOption {
|
|
534
|
+
label?: string;
|
|
535
|
+
value: string | number | boolean;
|
|
536
|
+
description?: string;
|
|
537
|
+
displayOrder?: number;
|
|
538
|
+
hidden?: boolean;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** A HubSpot property definition (GET /crm/v3/properties/{objectType}). Extra API fields are tolerated. */
|
|
542
|
+
export interface FormBuilderHubSpotProperty {
|
|
543
|
+
name: string;
|
|
544
|
+
label?: string;
|
|
545
|
+
type?: string; // "string" | "number" | "date" | "datetime" | "enumeration" | "bool" | ...
|
|
546
|
+
fieldType?: string; // "text" | "textarea" | "select" | "radio" | "checkbox" | "booleancheckbox" | "number" | "date" | "phonenumber" | ...
|
|
547
|
+
description?: string;
|
|
548
|
+
options?: FormBuilderHubSpotPropertyOption[];
|
|
549
|
+
hidden?: boolean;
|
|
550
|
+
calculated?: boolean;
|
|
551
|
+
modificationMetadata?: { readOnlyValue?: boolean; [k: string]: unknown };
|
|
552
|
+
[k: string]: unknown;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export interface FormBuilderHubSpotSchemaOptions {
|
|
556
|
+
/** Property names to keep. Also sets the output field order. */
|
|
557
|
+
include?: string[];
|
|
558
|
+
/** Property names to drop. */
|
|
559
|
+
exclude?: string[];
|
|
560
|
+
/** Per-property partial field configs merged over the generated config. */
|
|
561
|
+
overrides?: Record<string, Partial<FormBuilderField>>;
|
|
562
|
+
/** Property names to mark required — an array of names or a name → required map. */
|
|
563
|
+
requiredOverrides?: string[] | Record<string, boolean>;
|
|
564
|
+
/** Copy property descriptions into field `description` help text. Default false. */
|
|
565
|
+
includeDescriptions?: boolean;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Maps HubSpot property definitions to FormBuilder field configs.
|
|
570
|
+
* select → select · radio → radioGroup · checkbox → multiselect ·
|
|
571
|
+
* booleancheckbox → toggle (with "true"/"false" string normalization) ·
|
|
572
|
+
* date → date · datetime → datetime · number → number (string parsing) ·
|
|
573
|
+
* textarea → textarea · text/phonenumber → text. Hidden enumeration options
|
|
574
|
+
* are filtered; calculated / readOnlyValue properties come back readOnly.
|
|
575
|
+
*/
|
|
576
|
+
export declare function fieldsFromHubSpotProperties(
|
|
577
|
+
properties: FormBuilderHubSpotProperty[] | null | undefined,
|
|
578
|
+
options?: FormBuilderHubSpotSchemaOptions
|
|
579
|
+
): FormBuilderField[];
|
package/src/kanban/README.md
CHANGED
|
@@ -33,6 +33,8 @@ That's a filterable, sortable, stage-bucketed board with per-stage footers and i
|
|
|
33
33
|
- Full-text search across any combination of fields, with optional fuzzy matching via Fuse.js
|
|
34
34
|
- Headline metrics panel rendered above the board (`<Statistics>` under the hood) via a `metrics` prop
|
|
35
35
|
- Stage transition prompts — async confirmation or extra-property capture before committing a stage change, declared per-stage via `stage.onEnterRequired.render`
|
|
36
|
+
- WIP limits — per-stage `wipLimit` (or a top-level `wipLimits` override) renders `count / limit` headers, an "Over WIP" warning tag on over-limit stages, and fires `onWipExceeded` once per crossing
|
|
37
|
+
- Swimlanes — `swimlaneBy` groups the board vertically into stacked lane sections (each with its own header, count, and row of stage columns), with explicit ordering, custom labels, collapsible lanes, and optional per-lane metrics
|
|
36
38
|
- Per-card selection with a bulk action bar, plus `KanbanCardActions` for per-card actions
|
|
37
39
|
- Per-stage pagination via `stageMeta` + `onLoadMore` — mix client-side, server-load-more, and pre-bucketed server data column-by-column
|
|
38
40
|
- Empty / loading / error render slots that mirror DataTable's override API
|
|
@@ -329,6 +331,91 @@ Invalid target stages are disabled in the inline control; no callback fires.
|
|
|
329
331
|
|
|
330
332
|
---
|
|
331
333
|
|
|
334
|
+
### WIP limits
|
|
335
|
+
|
|
336
|
+
Declare a limit per stage (or override centrally with `wipLimits`) and the stage header switches from a plain count to `count / limit`. When the count goes **over** the limit — at the limit is full, not over — the header grows a warning `StatusTag` ("Over WIP") and `onWipExceeded` fires.
|
|
337
|
+
|
|
338
|
+
```jsx
|
|
339
|
+
const STAGES = [
|
|
340
|
+
{ value: "qualified", label: "Qualified", variant: "info" },
|
|
341
|
+
{ value: "in_progress", label: "In Progress", variant: "info", wipLimit: 5 },
|
|
342
|
+
{ value: "review", label: "Review", variant: "warning", wipLimit: 3 },
|
|
343
|
+
{ value: "done", label: "Done", variant: "success", terminal: true },
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
<Kanban
|
|
347
|
+
data={tickets}
|
|
348
|
+
stages={STAGES}
|
|
349
|
+
groupBy="status"
|
|
350
|
+
cardFields={CARD_FIELDS}
|
|
351
|
+
// Optional central override — beats stage.wipLimit per stage. Useful when
|
|
352
|
+
// limits come from team settings rather than the stage config.
|
|
353
|
+
wipLimits={{ in_progress: teamSettings.wipLimit }}
|
|
354
|
+
onWipExceeded={(stageId, count, limit) => {
|
|
355
|
+
notify(`${stageId} is over its WIP limit (${count}/${limit})`);
|
|
356
|
+
}}
|
|
357
|
+
onStageChange={handleStageChange}
|
|
358
|
+
/>
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Semantics worth knowing:
|
|
362
|
+
|
|
363
|
+
- **Limits never block transitions.** A move that pushes a stage over its limit still completes and `onStageChange` still fires. The component is stateless on the write path — your server is the source of truth — so blocking client-side would only desync the board from reality and prevent legitimate over-limit moves (expedites, bulk reassignment). WIP limits are a signal, not a gate. Enforce hard caps server-side if you need them.
|
|
364
|
+
- **`onWipExceeded` fires on the transition into exceeded**, not on every render: once when a stage crosses its limit (including a board that mounts already over a limit — that reports once on mount), and again only after the stage recovers below the limit and re-crosses.
|
|
365
|
+
- **Counts follow the header.** The number checked against the limit is the same one the column header shows: `stageMeta[stage].totalCount` when present (server truth), otherwise the loaded, filtered bucket size.
|
|
366
|
+
- **`wipLimit: 0` is valid** ("this stage should stay empty"); negative or non-numeric limits are ignored.
|
|
367
|
+
- Customize the header strings via `labels.wipCount` (`(count, limit) => string`, default `"5 / 4"` style) and `labels.overWip` (default `"Over WIP"`).
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
### Swimlanes
|
|
372
|
+
|
|
373
|
+
`swimlaneBy` (field name or `(row) => key` accessor) splits the board vertically into stacked lane sections — by owner, priority, team, SLA tier. Each lane renders a header (label + count + collapse chevron) above its own row of stage columns.
|
|
374
|
+
|
|
375
|
+
```jsx
|
|
376
|
+
<Kanban
|
|
377
|
+
data={deals}
|
|
378
|
+
stages={STAGES}
|
|
379
|
+
groupBy="stage"
|
|
380
|
+
cardFields={CARD_FIELDS}
|
|
381
|
+
swimlaneBy="priority"
|
|
382
|
+
swimlaneOrder={["high", "medium", "low"]}
|
|
383
|
+
swimlaneLabels={{ high: "High priority", medium: "Medium", low: "Low" }}
|
|
384
|
+
defaultCollapsedLanes={["low"]}
|
|
385
|
+
onStageChange={handleStageChange}
|
|
386
|
+
/>
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Behavior:
|
|
390
|
+
|
|
391
|
+
- **Lane order**: keys in `swimlaneOrder` render first, in that order — and hold their slot even when empty (an explicit order doubles as an explicit lane list). Lanes not listed append in first-seen data order. Without `swimlaneOrder`, all lanes render first-seen.
|
|
392
|
+
- **Labels**: `swimlaneLabels` is a `{ key: label }` map or a `(laneKey, rows) => ReactNode` function. Unlabeled lanes show their key. Rows whose lane value is `null`/`undefined`/`""` collect in a shared lane keyed `"__unassigned"` (exported as `UNASSIGNED_LANE_KEY`), labeled via `labels.unassignedLane` (default `"Unassigned"`).
|
|
393
|
+
- **Collapsible lanes**: on by default; collapse state is the usual controlled/uncontrolled trio — `collapsedLanes` + `onCollapsedLanesChange` (controlled) or `defaultCollapsedLanes` (uncontrolled). Set `collapseLanes={false}` to remove the affordance. Collapsed lane keys are also included in `onParamsChange` payloads.
|
|
394
|
+
- **Empty lane×stage cells render compactly** — a single header row with the empty placeholder instead of a full column shell, so sparse boards don't drown in empty columns.
|
|
395
|
+
- **WIP limits stay per-stage** (a limit applies to the stage total across all lanes, not to each lane's slice). Lane cells show lane-local counts; every cell of an over-limit stage carries the "Over WIP" tag, while the `count / limit` fraction renders on the flat (non-swimlane) board where the header count *is* the stage count.
|
|
396
|
+
- **Per-stage pagination (`stageMeta` / `onLoadMore`) is flat-board only.** In swimlane mode the per-cell footers describe lane slices, so load-more / totalCount displays are omitted; WIP evaluation still honors `stageMeta.totalCount`.
|
|
397
|
+
- **Stage collapse/expand stays global** — collapsing a stage collapses it in every lane.
|
|
398
|
+
|
|
399
|
+
#### Per-lane metrics
|
|
400
|
+
|
|
401
|
+
The headline metrics row stays global by default. For per-lane summaries, pass `metricsPerLane={true}` and make `metrics` a *function* — it's called per lane with `(laneRows, laneKey)` and rendered under each lane header while the toolbar's Metrics toggle is on:
|
|
402
|
+
|
|
403
|
+
```jsx
|
|
404
|
+
<Kanban
|
|
405
|
+
{...rest}
|
|
406
|
+
swimlaneBy="owner"
|
|
407
|
+
metricsPerLane
|
|
408
|
+
metrics={(rows, laneKey) => [
|
|
409
|
+
{ label: "Pipeline", number: formatCurrencyCompact(sumBy(rows, "amount")) },
|
|
410
|
+
{ label: "Deals", number: rows.length },
|
|
411
|
+
]}
|
|
412
|
+
/>
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Without `metricsPerLane` (or without lanes), a metrics function is called once with all filtered rows — so the same function serves both modes. Arrays and ReactNodes keep rendering globally regardless of `metricsPerLane`.
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
332
419
|
### Row selection with bulk actions
|
|
333
420
|
|
|
334
421
|
Add `selectable={true}` and checkboxes appear on each card (top-right of the title row). When any card is selected, a compact selection bar appears above the board with selected count, "Select all", "Deselect all", and any custom action buttons.
|
|
@@ -497,7 +584,7 @@ If your data comes from an API or you have too many records to load up-front, dr
|
|
|
497
584
|
searchValue={params.search}
|
|
498
585
|
filterValues={params.filters}
|
|
499
586
|
sort={params.sort}
|
|
500
|
-
onParamsChange={fetchBoard} // { search, filters, sort, collapsedStages }
|
|
587
|
+
onParamsChange={fetchBoard} // { search, filters, sort, collapsedStages, collapsedLanes }
|
|
501
588
|
|
|
502
589
|
stageMeta={stageMeta} // per-column totals / hasMore / loading
|
|
503
590
|
onLoadMore={loadMoreForStage}
|
|
@@ -506,7 +593,7 @@ If your data comes from an API or you have too many records to load up-front, dr
|
|
|
506
593
|
/>
|
|
507
594
|
```
|
|
508
595
|
|
|
509
|
-
`onParamsChange` fires on any toolbar change with a unified `{ search, filters, sort, collapsedStages }` object so you can avoid wiring
|
|
596
|
+
`onParamsChange` fires on any toolbar change with a unified `{ search, filters, sort, collapsedStages, collapsedLanes }` object so you can avoid wiring five separate callbacks. The component never mutates `data` — updating the board after a stage change or load-more is the caller's job, same as DataTable's `onRowEdit`.
|
|
510
597
|
|
|
511
598
|
---
|
|
512
599
|
|
|
@@ -533,6 +620,16 @@ If your data comes from an API or you have too many records to load up-front, dr
|
|
|
533
620
|
| `onExpandedStagesChange` | `(stages) => void` | — | Controlled-expansion callback |
|
|
534
621
|
| `stageMeta` | `Record<string, KanbanStageMeta>` | — | Per-stage `hasMore` / `totalCount` / `loading` / `error` |
|
|
535
622
|
| `onLoadMore` | `(stage) => void` | — | Per-column "Load more" callback |
|
|
623
|
+
| `wipLimits` | `Record<string, number>` | — | Top-level per-stage WIP limit overrides (`{ [stageId]: n }`). Beats `stage.wipLimit`. |
|
|
624
|
+
| `onWipExceeded` | `(stageId, count, limit) => void` | — | Fires once when a stage transitions over its limit (incl. mounting already over). Never blocks the move. |
|
|
625
|
+
| `swimlaneBy` | `string \| (row) => key` | — | Groups the board vertically into stacked lane sections |
|
|
626
|
+
| `swimlaneLabels` | `Record<string, string> \| (laneKey, rows) => ReactNode` | — | Lane header labels. Unlabeled lanes show their key. |
|
|
627
|
+
| `swimlaneOrder` | string[] | first-seen | Explicit lane order; listed keys render first and persist even when empty |
|
|
628
|
+
| `collapseLanes` | boolean | `true` | Show the collapse chevron on lane headers |
|
|
629
|
+
| `collapsedLanes` | string[] | — | Controlled list of collapsed lane keys |
|
|
630
|
+
| `defaultCollapsedLanes` | string[] | `[]` | Initial collapsed lane keys (uncontrolled) |
|
|
631
|
+
| `onCollapsedLanesChange` | `(laneKeys) => void` | — | Lane collapse callback |
|
|
632
|
+
| `metricsPerLane` | boolean | `false` | Render the metrics panel inside each lane. Requires `metrics` to be a function. |
|
|
536
633
|
| `selectable` | boolean | `false` | Show a selection checkbox on each card |
|
|
537
634
|
| `selectedIds` | `Id[]` | — | Controlled selection — array of row IDs |
|
|
538
635
|
| `onSelectionChange` | `(ids) => void` | — | Called when selection changes |
|
|
@@ -565,14 +662,14 @@ If your data comes from an API or you have too many records to load up-front, dr
|
|
|
565
662
|
| `columnWidth` | number | `350` | Min per-column width in px (AutoGrid `columnWidth`). Clamped to a 350px minimum. |
|
|
566
663
|
| `collapsedStages` | string[] | — | Controlled list of collapsed stage values |
|
|
567
664
|
| `onCollapsedStagesChange` | `(stages) => void` | — | Controlled-collapse callback |
|
|
568
|
-
| `metrics` | `KanbanMetricItem[] \| ReactNode` | — | Headline metrics panel. Array → `<StatisticsItem>` shorthand; ReactNode → full custom render. |
|
|
665
|
+
| `metrics` | `KanbanMetricItem[] \| ReactNode \| (rows, laneKey) => items \| node` | — | Headline metrics panel. Array → `<StatisticsItem>` shorthand; ReactNode → full custom render; function → computed from the filtered rows (and per lane with `metricsPerLane`). |
|
|
569
666
|
| `showMetrics` | boolean | — | Controlled visibility of the metrics panel |
|
|
570
667
|
| `onMetricsToggle` | `(visible) => void` | — | Called when the toolbar Metrics button is clicked |
|
|
571
668
|
| `searchValue` | string | — | Controlled search term |
|
|
572
669
|
| `onSearchChange` | `(term) => void` | — | Search callback |
|
|
573
670
|
| `filterValues` | `Record<string, unknown>` | — | Controlled filter values |
|
|
574
671
|
| `onFilterChange` | `(values) => void` | — | Filter callback |
|
|
575
|
-
| `onParamsChange` | `({ search, filters, sort, collapsedStages }) => void` | — | Unified callback fired on any toolbar change |
|
|
672
|
+
| `onParamsChange` | `({ search, filters, sort, collapsedStages, collapsedLanes }) => void` | — | Unified callback fired on any toolbar change |
|
|
576
673
|
| `loading` | boolean | `false` | Show a loading skeleton in place of the board |
|
|
577
674
|
| `error` | `string \| boolean` | — | Show an error state. String value is used as the title. |
|
|
578
675
|
| `labels` | `KanbanLabels` | — | Override hardcoded UI strings for i18n |
|
|
@@ -592,6 +689,7 @@ If your data comes from an API or you have too many records to load up-front, dr
|
|
|
592
689
|
| `color` | string | Optional dot color for the header |
|
|
593
690
|
| `icon` | string | Optional HubSpot `Icon` name for the header |
|
|
594
691
|
| `terminal` | boolean | Mark as a "closed" stage (hidden behind a "Show closed" toggle) |
|
|
692
|
+
| `wipLimit` | number | WIP limit. Header shows `count / limit` plus an "Over WIP" tag when exceeded. `0` is valid; overridden per stage by the `wipLimits` prop. Advisory only — never blocks transitions. |
|
|
595
693
|
| `order` | number | Explicit order override (otherwise array order wins) |
|
|
596
694
|
| `footer` | `(rows) => ReactNode` | Per-stage footer content (overrides `columnFooter`) |
|
|
597
695
|
| `canEnter` | `(row) => boolean` | Gate whether a row can move *into* this stage |
|
|
@@ -679,7 +777,23 @@ If your data comes from an API or you have too many records to load up-front, dr
|
|
|
679
777
|
|
|
680
778
|
### Labels
|
|
681
779
|
|
|
682
|
-
`labels` accepts overrides for every hardcoded UI string. See `KanbanLabels` in `kanban.d.ts` for the full list — the most common overrides are `search`, `filtersButton`, `sortButton`, `loadMore`, `loadingMore`, `showMore`, `emptyTitle`, `emptyMessage`, `selected`, `selectAll`, `deselectAll`, `moveTo`, `metricsButton`.
|
|
780
|
+
`labels` accepts overrides for every hardcoded UI string. See `KanbanLabels` in `kanban.d.ts` for the full list — the most common overrides are `search`, `filtersButton`, `sortButton`, `loadMore`, `loadingMore`, `showMore`, `emptyTitle`, `emptyMessage`, `selected`, `selectAll`, `deselectAll`, `moveTo`, `metricsButton`, plus the WIP/swimlane strings `wipCount` (`(count, limit) => string`), `overWip`, `laneCount`, and `unassignedLane`.
|
|
781
|
+
|
|
782
|
+
### Lane & WIP helper functions
|
|
783
|
+
|
|
784
|
+
The pure logic behind swimlanes and WIP limits is exported for reuse (custom lane summaries, server-side WIP alerting, tests):
|
|
785
|
+
|
|
786
|
+
| Export | Signature | Description |
|
|
787
|
+
|---|---|---|
|
|
788
|
+
| `UNASSIGNED_LANE_KEY` | `"__unassigned"` | Lane key for rows with a null/undefined/empty swimlane value |
|
|
789
|
+
| `getLaneKey` | `(row, swimlaneBy) => string` | Resolve a row's lane key (String-coerced) |
|
|
790
|
+
| `orderLaneKeys` | `(seenKeys, swimlaneOrder?) => string[]` | Explicit order first (kept even when empty), then first-seen |
|
|
791
|
+
| `partitionLanes` | `(rows, { swimlaneBy, swimlaneOrder }) => { laneKeys, rowsByLane }` | Full lane partition in render order |
|
|
792
|
+
| `resolveLaneLabel` | `(laneKey, swimlaneLabels?, rows?, unassignedLabel?) => label` | Lane display label with fallbacks |
|
|
793
|
+
| `resolveWipLimit` | `(stage, wipLimits?) => number \| null` | Effective limit (override beats `stage.wipLimit`; invalid → null) |
|
|
794
|
+
| `computeStageCounts` | `(stages, buckets, stageMeta?) => Record<string, number>` | Header-consistent per-stage counts (`totalCount` else bucket size) |
|
|
795
|
+
| `evaluateWip` | `(stages, counts, wipLimits?) => Record<string, { count, limit, exceeded }>` | Per-stage WIP status (`exceeded` = strictly over) |
|
|
796
|
+
| `findNewlyExceededWip` | `(prev, next) => { stageId, count, limit }[]` | Stages that newly crossed into exceeded — the `onWipExceeded` contract |
|
|
683
797
|
|
|
684
798
|
---
|
|
685
799
|
|
|
@@ -695,7 +809,6 @@ These come from HubSpot UI Extensions itself, not Kanban:
|
|
|
695
809
|
| Rotated column labels | Collapsed columns stack each character vertically in its own `Text` (no CSS transforms available). Long stage names become tall — use `shortLabel` for those. |
|
|
696
810
|
| External-link glyph on title links | HubSpot's `Link` primitive always shows the external-link glyph when `href.external === true`. To get a title link *without* the glyph, omit `external: true`. New-tab + no-glyph is not possible today. |
|
|
697
811
|
| No row expansion | Cards are read-mostly; expandable detail rows aren't supported. Route to the CRM record for full edits. |
|
|
698
|
-
| No swimlanes | Secondary grouping (e.g. by owner within stage) isn't supported. On the roadmap. |
|
|
699
812
|
| No export | No built-in CSV/Excel export. Pair with a serverless function. |
|
|
700
813
|
|
|
701
814
|
See [`src/kanban/SPEC.md`](./SPEC.md) for the full design doc, decision log, and roadmap.
|