@colixsystems/widget-sdk 0.246.0 → 0.247.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 +24 -2
- package/dist/chart-logic.js +575 -0
- package/dist/chart-tokens.js +131 -0
- package/dist/chart-view.js +262 -0
- package/dist/chart.js +21 -0
- package/dist/chart.native.js +10 -0
- package/dist/contract.cjs +50 -6
- package/dist/contract.js +50 -6
- package/dist/grid-logic.js +222 -0
- package/dist/grid-view.js +109 -0
- package/dist/grid.js +7 -0
- package/dist/grid.native.js +7 -0
- package/dist/host.d.ts +6 -0
- package/dist/host.js +2 -0
- package/dist/index.d.ts +234 -2
- package/dist/index.js +7 -0
- package/dist/index.native.js +7 -0
- package/dist/people-picker-logic.js +203 -0
- package/dist/people-picker-view.js +247 -0
- package/dist/people-picker.js +11 -0
- package/dist/people-picker.native.js +9 -0
- package/dist/select-search-logic.js +150 -0
- package/dist/select-view.js +244 -16
- package/dist/select.js +6 -2
- package/dist/select.native.js +5 -3
- package/package.json +1 -1
- package/src/contract.js +50 -6
package/dist/index.d.ts
CHANGED
|
@@ -2406,6 +2406,60 @@ export const WIDE_WIDTH_PX: number;
|
|
|
2406
2406
|
*/
|
|
2407
2407
|
export function widthTier(width: number): "narrow" | "medium" | "wide";
|
|
2408
2408
|
|
|
2409
|
+
/** How a `<Grid>` splits its cells into lines. */
|
|
2410
|
+
export type GridBalance = "cards" | "tiles";
|
|
2411
|
+
|
|
2412
|
+
/** A theme spacing name, or a number of pixels. */
|
|
2413
|
+
export type GridGap = "xs" | "sm" | "md" | "lg" | "xl" | number;
|
|
2414
|
+
|
|
2415
|
+
/**
|
|
2416
|
+
* Lays its children out in balanced lines of cells, sized from the grid's own
|
|
2417
|
+
* measured width, on the web Player and the native export alike. It measures
|
|
2418
|
+
* an inner box with no padding, so a `style` padding is not counted twice.
|
|
2419
|
+
*
|
|
2420
|
+
* - `balance="cards"` (default): as many cells per line as fit at
|
|
2421
|
+
* `minCellWidth` (240 when unset); every line but the last is full, and the
|
|
2422
|
+
* last is padded with empty cells so its cards keep the width of the lines
|
|
2423
|
+
* above. 6 that fit 5 are 3 + 3.
|
|
2424
|
+
* - `balance="tiles"`: the remainder spreads over every line and each line
|
|
2425
|
+
* stretches, so 5 tiles at 4 per line are 3 + 2, never 4 + 1. The per-line
|
|
2426
|
+
* ceiling is `maxColumns`, or what fits at `minCellWidth`, or else 4 / 3 / 2
|
|
2427
|
+
* for the wide / medium / narrow width tier.
|
|
2428
|
+
* - `columns`: exactly that many cells per line, the last line padded.
|
|
2429
|
+
* - `cell={{ min, max }}`: definite-size cells (calendar days, seats,
|
|
2430
|
+
* swatches) as large as fit up to `max`, never below `min` (32 when unset),
|
|
2431
|
+
* in a block centred in the grid (a block whose `min` cells cannot fit
|
|
2432
|
+
* starts at the leading edge, so only its far end is cut off); with
|
|
2433
|
+
* `columns` a fixed count per line. `square` makes each cell as tall as it
|
|
2434
|
+
* is wide.
|
|
2435
|
+
*
|
|
2436
|
+
* Each child is one cell and keeps its own look; a child with `flex: 1` fills
|
|
2437
|
+
* its cell's height, so a line of cards ends level. Nothing is drawn until the
|
|
2438
|
+
* width is measured when the layout depends on it. In a row, give the grid
|
|
2439
|
+
* `style={{ flex: 1, minWidth: 0 }}` so it has a width to measure.
|
|
2440
|
+
*
|
|
2441
|
+
* @example
|
|
2442
|
+
* <Grid minCellWidth={240}>{items.map((item) => <Card key={item.id}>…</Card>)}</Grid>
|
|
2443
|
+
* <Grid columns={7} cell={{ min: 32, max: 64 }} square gap="xs">{days}</Grid>
|
|
2444
|
+
*/
|
|
2445
|
+
export const Grid: (props: {
|
|
2446
|
+
children?: ReactNode;
|
|
2447
|
+
balance?: GridBalance;
|
|
2448
|
+
/** The narrowest a cell may be; the per-line count is derived from it. */
|
|
2449
|
+
minCellWidth?: number;
|
|
2450
|
+
maxColumns?: number;
|
|
2451
|
+
columns?: number;
|
|
2452
|
+
cell?: { min?: number; max: number };
|
|
2453
|
+
square?: boolean;
|
|
2454
|
+
/** Space between cells and lines; `"md"` when unset. */
|
|
2455
|
+
gap?: GridGap;
|
|
2456
|
+
/** The grid's outer box; padding here stays out of the measured width. */
|
|
2457
|
+
style?: any;
|
|
2458
|
+
testID?: string;
|
|
2459
|
+
/** Names the grid as one group to a screen reader. */
|
|
2460
|
+
accessibilityLabel?: string;
|
|
2461
|
+
}) => any;
|
|
2462
|
+
|
|
2409
2463
|
/** Pass-through options for `useGeolocation().getCurrentPosition(...)`. */
|
|
2410
2464
|
export interface GeolocationOptions {
|
|
2411
2465
|
enableHighAccuracy?: boolean;
|
|
@@ -3140,6 +3194,8 @@ export interface AppUserGroupRow {
|
|
|
3140
3194
|
|
|
3141
3195
|
export interface GroupsQuery {
|
|
3142
3196
|
q?: string;
|
|
3197
|
+
/** Only these groups (1 to 100 ids): name the groups behind ids you hold. */
|
|
3198
|
+
ids?: string[];
|
|
3143
3199
|
limit?: number;
|
|
3144
3200
|
offset?: number;
|
|
3145
3201
|
}
|
|
@@ -4187,12 +4243,53 @@ export interface SelectOption<V extends string | number = string | number> {
|
|
|
4187
4243
|
label: string;
|
|
4188
4244
|
disabled?: boolean;
|
|
4189
4245
|
testID?: string;
|
|
4246
|
+
/** Drawn before the label in the sheet: an `<Avatar>`, an icon. */
|
|
4247
|
+
leading?: ReactNode;
|
|
4248
|
+
}
|
|
4249
|
+
|
|
4250
|
+
/**
|
|
4251
|
+
* The searchable mode of `<Select>`: a `<SearchField>` heads the sheet. Turn
|
|
4252
|
+
* it on with `searchable` (the options are filtered where they are held),
|
|
4253
|
+
* `onSearch` (you fetch per query) or async `options` (Select fetches).
|
|
4254
|
+
*/
|
|
4255
|
+
export interface SelectSearchProps<V extends string | number = string | number> {
|
|
4256
|
+
/** Filter the given `options` by label as the person types. */
|
|
4257
|
+
searchable?: boolean;
|
|
4258
|
+
/**
|
|
4259
|
+
* Called with the trimmed query when the sheet opens ("") and once typing
|
|
4260
|
+
* pauses; answer by passing new `options` (and `loading` meanwhile).
|
|
4261
|
+
*/
|
|
4262
|
+
onSearch?: (query: string) => void;
|
|
4263
|
+
/** The pause, in ms, before a typed query is sent. Default 250. */
|
|
4264
|
+
searchDebounceMs?: number;
|
|
4265
|
+
/** The options for the last query are on their way. */
|
|
4266
|
+
loading?: boolean;
|
|
4267
|
+
/** The last query failed; a string replaces `loadErrorText`. */
|
|
4268
|
+
optionsError?: boolean | string;
|
|
4269
|
+
/**
|
|
4270
|
+
* The options behind picked values the current `options` may not hold (a
|
|
4271
|
+
* saved choice outside the first page), so the trigger names them.
|
|
4272
|
+
*/
|
|
4273
|
+
selectedOptions?: Array<SelectOption<V>>;
|
|
4274
|
+
/** Default "Search". */
|
|
4275
|
+
searchPlaceholder?: string;
|
|
4276
|
+
/** Shown when a query finds nothing. Default "No results". */
|
|
4277
|
+
noResultsText?: string;
|
|
4278
|
+
/** Default "Loading…". */
|
|
4279
|
+
loadingText?: string;
|
|
4280
|
+
/** Default "Could not load the options". */
|
|
4281
|
+
loadErrorText?: string;
|
|
4190
4282
|
}
|
|
4191
4283
|
|
|
4192
4284
|
/** What every `<Select>` takes, in either mode. */
|
|
4193
4285
|
export type SelectBaseProps<V extends string | number = string | number> = FormControlProps &
|
|
4194
|
-
FormFieldOverrides &
|
|
4195
|
-
|
|
4286
|
+
FormFieldOverrides &
|
|
4287
|
+
SelectSearchProps<V> & {
|
|
4288
|
+
/**
|
|
4289
|
+
* The options, or a function Select calls with each query (searchable
|
|
4290
|
+
* mode) that resolves to them; only its latest answer is shown.
|
|
4291
|
+
*/
|
|
4292
|
+
options: Array<SelectOption<V>> | ((query: string) => Promise<Array<SelectOption<V>>>);
|
|
4196
4293
|
/** Shown while nothing is chosen. Default "Select". */
|
|
4197
4294
|
placeholder?: string;
|
|
4198
4295
|
/** The sheet's heading. Defaults to a text `label`. */
|
|
@@ -4220,6 +4317,11 @@ export type SelectBaseProps<V extends string | number = string | number> = FormC
|
|
|
4220
4317
|
* trigger shows the picked labels as small chips while they fit on its line,
|
|
4221
4318
|
* else "N selected".
|
|
4222
4319
|
*
|
|
4320
|
+
* Searchable (`searchable`, `onSearch` or async `options`), the sheet leads
|
|
4321
|
+
* with a `<SearchField>`, says when it is loading and when nothing matches,
|
|
4322
|
+
* and keeps the label of every option it has shown; its rows take the theme's
|
|
4323
|
+
* Lists row height and padding when they are set.
|
|
4324
|
+
*
|
|
4223
4325
|
* @example
|
|
4224
4326
|
* <Select label="Status" value={status} onValueChange={setStatus}
|
|
4225
4327
|
* options={[{ value: "open", label: "Open" }, { value: "done", label: "Done" }]} />
|
|
@@ -4246,6 +4348,62 @@ export const Select: <V extends string | number = string | number>(
|
|
|
4246
4348
|
}),
|
|
4247
4349
|
) => any;
|
|
4248
4350
|
|
|
4351
|
+
/** Where `<PeoplePicker>` reads its options. */
|
|
4352
|
+
export type PeoplePickerSource = "users" | "groups" | "records";
|
|
4353
|
+
|
|
4354
|
+
/**
|
|
4355
|
+
* A searchable `<Select>` of the workspace's users (`useDirectory`, scope
|
|
4356
|
+
* `directory.read:users`), its groups (`useGroups`, `groups.read:*`) or one
|
|
4357
|
+
* table's records (`useDatastoreQuery`, the table's `datastore.read` scope).
|
|
4358
|
+
* Nothing loads until the sheet opens; each pause in typing searches on the
|
|
4359
|
+
* server. A saved value the page does not hold is looked up once by id, so the
|
|
4360
|
+
* trigger shows its name, `loadingText` meanwhile, or `unknownText` when it is
|
|
4361
|
+
* gone, never the id. Every other `<Select>` prop works.
|
|
4362
|
+
*
|
|
4363
|
+
* @example
|
|
4364
|
+
* <PeoplePicker label="Assignee" value={assignee} onValueChange={setAssignee} />
|
|
4365
|
+
* <PeoplePicker source="groups" multiple label="Teams" values={teams} onValuesChange={setTeams} />
|
|
4366
|
+
* <PeoplePicker source="records" table={tableId} label="Customer" value={customerId}
|
|
4367
|
+
* onValueChange={setCustomerId} />
|
|
4368
|
+
*/
|
|
4369
|
+
export const PeoplePicker: (
|
|
4370
|
+
props: Omit<SelectBaseProps<string>, "options" | "onSearch" | "selectedOptions" | "searchable"> & {
|
|
4371
|
+
/** Default "users". */
|
|
4372
|
+
source?: PeoplePickerSource;
|
|
4373
|
+
/** `source="records"`: the table id. */
|
|
4374
|
+
table?: string;
|
|
4375
|
+
/** `source="records"`: the column that names a record. Default the identification column. */
|
|
4376
|
+
labelColumn?: string;
|
|
4377
|
+
/** `source="records"`: a FILE column drawn as each row's avatar. */
|
|
4378
|
+
avatarColumn?: string;
|
|
4379
|
+
/** `source="users"`: only members of this group (a `useGroups()` row id). */
|
|
4380
|
+
groupId?: string;
|
|
4381
|
+
/** `source="records"`: narrows the rows, as `useDatastoreQuery`'s `filter`. */
|
|
4382
|
+
filter?: Query["filter"];
|
|
4383
|
+
/** Ids never offered (already members, the signed-in user). */
|
|
4384
|
+
excludeIds?: string[];
|
|
4385
|
+
/** Rows per search. Default 50, at most 200. */
|
|
4386
|
+
pageSize?: number;
|
|
4387
|
+
/** Draw an avatar before each user or group. Default true. */
|
|
4388
|
+
showAvatars?: boolean;
|
|
4389
|
+
/** A saved value whose row is gone, or a row with no name. Default "Unknown". */
|
|
4390
|
+
unknownText?: string;
|
|
4391
|
+
} & (
|
|
4392
|
+
| {
|
|
4393
|
+
multiple?: false;
|
|
4394
|
+
value?: string | null;
|
|
4395
|
+
onValueChange?: (value: string, option: SelectOption<string>) => void;
|
|
4396
|
+
}
|
|
4397
|
+
| {
|
|
4398
|
+
multiple: true;
|
|
4399
|
+
values?: string[] | null;
|
|
4400
|
+
onValuesChange?: (values: string[], options: Array<SelectOption<string>>) => void;
|
|
4401
|
+
selectedCountText?: (count: number) => string;
|
|
4402
|
+
doneText?: string;
|
|
4403
|
+
}
|
|
4404
|
+
),
|
|
4405
|
+
) => any;
|
|
4406
|
+
|
|
4249
4407
|
/**
|
|
4250
4408
|
* Space-key activation for a control whose role is not a button: on the web a
|
|
4251
4409
|
* `Pressable` answers Space only for a button. Native ignores `onKeyDown`.
|
|
@@ -4462,6 +4620,80 @@ export const ChipGroup: (props: {
|
|
|
4462
4620
|
testID?: string;
|
|
4463
4621
|
}) => any;
|
|
4464
4622
|
|
|
4623
|
+
export type ChartType = "bar" | "hbar" | "line" | "area" | "donut" | "sparkline";
|
|
4624
|
+
export type ChartValueFormat = "number" | "compact" | "percent" | ((value: number) => string);
|
|
4625
|
+
|
|
4626
|
+
/** One series of a multi-series `<Chart>`: the key each `data` item carries its value under. */
|
|
4627
|
+
export interface ChartSeries {
|
|
4628
|
+
key: string;
|
|
4629
|
+
label: string;
|
|
4630
|
+
/** A colour of the series' own; leave unset so the theme's chart look applies. */
|
|
4631
|
+
color?: string;
|
|
4632
|
+
}
|
|
4633
|
+
|
|
4634
|
+
/**
|
|
4635
|
+
* Themed chart drawn with react-native-svg. Its series colours, axis and label
|
|
4636
|
+
* colour, gridline colour and bar corners come from the theme's `chart` scope
|
|
4637
|
+
* (falling back to the theme's colours), so every chart in the app matches. It
|
|
4638
|
+
* measures its own width, shows a `<Skeleton>` while `loading` and an
|
|
4639
|
+
* `<EmptyState>` when there is nothing to plot, draws a legend for a donut and
|
|
4640
|
+
* for several series, and gives a screen reader a summary and the values.
|
|
4641
|
+
*
|
|
4642
|
+
* @example
|
|
4643
|
+
* const { groups, loading } = useDatastoreAggregate(tableId, { groupBy: "city", sumColumn: "visits" });
|
|
4644
|
+
* <Chart type="bar" loading={loading}
|
|
4645
|
+
* data={groups.map((g) => ({ label: String(g.group), value: Number(g.sum) || 0 }))} />
|
|
4646
|
+
*/
|
|
4647
|
+
export const Chart: (props: {
|
|
4648
|
+
/** Default "bar". "hbar" is horizontal bars; "sparkline" a small line with no axes. */
|
|
4649
|
+
type?: ChartType;
|
|
4650
|
+
/** `[{ label, value }]`, or with `series` one number per series key. */
|
|
4651
|
+
data: Array<{ label: string | number | { label: string }; [key: string]: unknown }>;
|
|
4652
|
+
/** Several series sharing `data`. A donut and a sparkline draw the first. */
|
|
4653
|
+
series?: ChartSeries[];
|
|
4654
|
+
/**
|
|
4655
|
+
* How values read on the axis, the legend and to a screen reader, grouped
|
|
4656
|
+
* for the app's language. Default "number". "percent" appends "%" to values
|
|
4657
|
+
* already in percent (42 reads "42%"); it does not multiply by 100.
|
|
4658
|
+
*/
|
|
4659
|
+
valueFormat?: ChartValueFormat;
|
|
4660
|
+
/** The plot's height in px. Default 220, or 48 for a sparkline. */
|
|
4661
|
+
height?: number;
|
|
4662
|
+
loading?: boolean;
|
|
4663
|
+
/** Defaults to the shared "No results" text. */
|
|
4664
|
+
emptyTitle?: string;
|
|
4665
|
+
emptyMessage?: string;
|
|
4666
|
+
/** Replaces the spoken summary sentence; the values are still read after it. */
|
|
4667
|
+
accessibilityLabel?: string;
|
|
4668
|
+
/** The tail of a spoken value list cut short; default `(count) => \`and ${count} more\``. */
|
|
4669
|
+
moreText?: (count: number) => string;
|
|
4670
|
+
/** For hosts rendering outside a widget context; widgets omit it. */
|
|
4671
|
+
theme?: any;
|
|
4672
|
+
style?: any;
|
|
4673
|
+
testID?: string;
|
|
4674
|
+
}) => any;
|
|
4675
|
+
|
|
4676
|
+
/**
|
|
4677
|
+
* The colours `<Chart>` paints with, for a chart a widget draws itself (a
|
|
4678
|
+
* scatter plot, a timeline) so it matches Design > Components > Charts.
|
|
4679
|
+
*
|
|
4680
|
+
* @example
|
|
4681
|
+
* const colors = chartColors(useTheme());
|
|
4682
|
+
* <Circle fill={colors.series(0)} fillOpacity={colors.seriesOpacity(0)} />
|
|
4683
|
+
*/
|
|
4684
|
+
export function chartColors(theme: any): {
|
|
4685
|
+
/** The six series colours, in order. */
|
|
4686
|
+
palette: string[];
|
|
4687
|
+
/** Axis line and label colour. */
|
|
4688
|
+
axis: string;
|
|
4689
|
+
/** Gridline colour. */
|
|
4690
|
+
grid: string;
|
|
4691
|
+
/** Series `index`'s colour; a seventh and later one is lighter. */
|
|
4692
|
+
series(index: number): string;
|
|
4693
|
+
/** Series `index`'s opacity: 1, below 1 for a repeat of a colour that cannot be lightened. */
|
|
4694
|
+
seriesOpacity(index: number): number;
|
|
4695
|
+
};
|
|
4696
|
+
|
|
4465
4697
|
/**
|
|
4466
4698
|
* A file as `<FilePicker>` and `<FileUpload>` hand it over: the browser's
|
|
4467
4699
|
* `File` on the web, expo-file-system's `File` on the Expo export. Pass it to
|
package/dist/index.js
CHANGED
|
@@ -112,6 +112,8 @@ export {
|
|
|
112
112
|
widthTier,
|
|
113
113
|
WIDE_WIDTH_PX,
|
|
114
114
|
} from "./container-width.js";
|
|
115
|
+
// Balanced lines of cells sized from the grid's own measured width.
|
|
116
|
+
export { Grid } from "./grid.js";
|
|
115
117
|
// REQ-WSDK-PLATFORM §6 — Tier A hooks. Each ships in a per-platform file
|
|
116
118
|
// (./clipboard.js / .native.js, ./toast.js / .native.js); index.js picks
|
|
117
119
|
// the web variant and index.native.js picks the native variant.
|
|
@@ -280,6 +282,8 @@ export { Checkbox } from "./checkbox.js";
|
|
|
280
282
|
export { Switch } from "./switch.js";
|
|
281
283
|
export { Select } from "./select.js";
|
|
282
284
|
export { SearchField } from "./search-field.js";
|
|
285
|
+
// A searchable `<Select>` of users, groups or one table's records.
|
|
286
|
+
export { PeoplePicker } from "./people-picker.js";
|
|
283
287
|
// Pick one of a short visible list; `muted` scope, or `tabs` when segmented.
|
|
284
288
|
export { RadioGroup } from "./radio-group.js";
|
|
285
289
|
// Space for a control whose role is not a button; a no-op on native.
|
|
@@ -296,6 +300,9 @@ export { Modal } from "./modal.js";
|
|
|
296
300
|
export { ConfirmDialog } from "./confirm-dialog.js";
|
|
297
301
|
// Themed filter chips, tags and pills; follow the theme's `chip` scope.
|
|
298
302
|
export { Chip, ChipGroup } from "./chip.js";
|
|
303
|
+
// Themed bar, line, area and donut charts; follow the theme's `chart` scope.
|
|
304
|
+
export { Chart } from "./chart.js";
|
|
305
|
+
export { chartColors } from "./chart-tokens.js";
|
|
299
306
|
// A themed file chooser on `<FilePicker>`; follows the theme's `muted` scope.
|
|
300
307
|
export { FileUpload } from "./file-upload.js";
|
|
301
308
|
// Anchored actions, content and labels; follow the theme's `popover` scope.
|
package/dist/index.native.js
CHANGED
|
@@ -112,6 +112,8 @@ export {
|
|
|
112
112
|
widthTier,
|
|
113
113
|
WIDE_WIDTH_PX,
|
|
114
114
|
} from "./container-width.js";
|
|
115
|
+
// Balanced lines of cells sized from the grid's own measured width.
|
|
116
|
+
export { Grid } from "./grid.native.js";
|
|
115
117
|
// REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
|
|
116
118
|
export { useClipboard, ClipboardError } from "./clipboard.native.js";
|
|
117
119
|
export { useToast } from "./toast.native.js";
|
|
@@ -273,6 +275,8 @@ export { Checkbox } from "./checkbox.native.js";
|
|
|
273
275
|
export { Switch } from "./switch.native.js";
|
|
274
276
|
export { Select } from "./select.native.js";
|
|
275
277
|
export { SearchField } from "./search-field.native.js";
|
|
278
|
+
// A searchable `<Select>` of users, groups or one table's records.
|
|
279
|
+
export { PeoplePicker } from "./people-picker.native.js";
|
|
276
280
|
// Pick one of a short visible list; `muted` scope, or `tabs` when segmented.
|
|
277
281
|
export { RadioGroup } from "./radio-group.native.js";
|
|
278
282
|
// Space for a control whose role is not a button; a no-op on native.
|
|
@@ -289,6 +293,9 @@ export { Modal } from "./modal.native.js";
|
|
|
289
293
|
export { ConfirmDialog } from "./confirm-dialog.native.js";
|
|
290
294
|
// Themed filter chips, tags and pills; follow the theme's `chip` scope.
|
|
291
295
|
export { Chip, ChipGroup } from "./chip.native.js";
|
|
296
|
+
// Themed bar, line, area and donut charts; follow the theme's `chart` scope.
|
|
297
|
+
export { Chart } from "./chart.native.js";
|
|
298
|
+
export { chartColors } from "./chart-tokens.js";
|
|
292
299
|
// A themed file chooser on `<FilePicker>`; follows the theme's `muted` scope.
|
|
293
300
|
export { FileUpload } from "./file-upload.native.js";
|
|
294
301
|
// Anchored actions, content and labels; follow the theme's `popover` scope.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// The `<PeoplePicker>` rules that need no renderer: which source it reads,
|
|
2
|
+
// the query each source is asked, how a row is named, and which saved values
|
|
3
|
+
// still need a name. Both hosts share them, and the node suite tests them.
|
|
4
|
+
|
|
5
|
+
export const PEOPLE_PICKER_SOURCES = Object.freeze(["users", "groups", "records"]);
|
|
6
|
+
export const DEFAULT_PEOPLE_PAGE_SIZE = 50;
|
|
7
|
+
// The directory resolves at most this many ids per request.
|
|
8
|
+
export const MAX_RESOLVE_IDS = 100;
|
|
9
|
+
|
|
10
|
+
/** A `source` prop as the picker reads it; anything unknown is "users". */
|
|
11
|
+
export function pickerSource(source) {
|
|
12
|
+
return PEOPLE_PICKER_SOURCES.includes(source) ? source : "users";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isId(value) {
|
|
16
|
+
return (typeof value === "string" && value !== "") || typeof value === "number";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function text(value) {
|
|
20
|
+
if (typeof value === "number") return String(value);
|
|
21
|
+
if (typeof value !== "string") return "";
|
|
22
|
+
return value.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The page query a source's hook is asked: the typed search sent to the
|
|
27
|
+
* server, never filtered from one page here.
|
|
28
|
+
*
|
|
29
|
+
* @param {"users"|"groups"|"records"} source
|
|
30
|
+
* @param {{query?: string, pageSize?: number, groupId?: string, filter?: object}} own
|
|
31
|
+
*/
|
|
32
|
+
export function pageQuery(source, { query, pageSize, groupId, filter } = {}) {
|
|
33
|
+
const q = text(query);
|
|
34
|
+
const limit =
|
|
35
|
+
Number.isInteger(pageSize) && pageSize > 0 ? Math.min(pageSize, 200) : DEFAULT_PEOPLE_PAGE_SIZE;
|
|
36
|
+
const out = { ...(q ? { q } : null), limit };
|
|
37
|
+
if (source === "users" && isId(groupId)) out.group_id = groupId;
|
|
38
|
+
if (source === "records" && filter && typeof filter === "object") out.filter = filter;
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Schema columns arrive as wire `data_type` / `is_identification`; a
|
|
43
|
+
// manifest-derived column carries `dataType` / `isIdentification`, and a
|
|
44
|
+
// column being written names the flag `is_identification_column`.
|
|
45
|
+
function columnType(column) {
|
|
46
|
+
return column.data_type ?? column.dataType;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isIdentificationColumn(column) {
|
|
50
|
+
return Boolean(column.is_identification ?? column.isIdentification ?? column.is_identification_column);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The column a record is named by: the one given, else the table's
|
|
55
|
+
* identification column, else its first text column, else none.
|
|
56
|
+
*
|
|
57
|
+
* @param {{columns?: Array<{name: string, data_type?: string, dataType?: string,
|
|
58
|
+
* is_identification?: boolean, isIdentification?: boolean}>}|null} schema
|
|
59
|
+
* @param {string} [own]
|
|
60
|
+
*/
|
|
61
|
+
export function recordLabelColumn(schema, own) {
|
|
62
|
+
if (typeof own === "string" && own !== "") return own;
|
|
63
|
+
const columns = (schema && Array.isArray(schema.columns) ? schema.columns : []).filter(Boolean);
|
|
64
|
+
const named = columns.find(isIdentificationColumn);
|
|
65
|
+
if (named) return named.name;
|
|
66
|
+
const textual = columns.find((column) => ["STRING", "TEXT"].includes(columnType(column)));
|
|
67
|
+
return textual ? textual.name : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A row's name: a user's name or email, a group's name, a record's label
|
|
72
|
+
* column. A row with none is `unknownText`, never its id.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} row
|
|
75
|
+
* @param {{source: string, labelColumn?: string|null, unknownText: string}} how
|
|
76
|
+
*/
|
|
77
|
+
export function rowLabel(row, { source, labelColumn, unknownText }) {
|
|
78
|
+
if (!row || typeof row !== "object") return unknownText;
|
|
79
|
+
if (source === "records") {
|
|
80
|
+
const cell = labelColumn ? row[labelColumn] : undefined;
|
|
81
|
+
// A relation cell is hydrated as `{ id, label }`.
|
|
82
|
+
const shown = cell && typeof cell === "object" ? text(cell.label) : text(cell);
|
|
83
|
+
return shown || unknownText;
|
|
84
|
+
}
|
|
85
|
+
if (source === "users") return text(row.name) || text(row.email) || unknownText;
|
|
86
|
+
return text(row.name) || unknownText;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The rows a page offers: each with an id, none of `excludeIds`. */
|
|
90
|
+
export function offeredRows(rows, excludeIds) {
|
|
91
|
+
const excluded = new Set(Array.isArray(excludeIds) ? excludeIds.map(String) : []);
|
|
92
|
+
return (Array.isArray(rows) ? rows : []).filter(
|
|
93
|
+
(row) => row && isId(row.id) && !excluded.has(String(row.id)),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A `value` or `values` prop as the list of picked ids. */
|
|
98
|
+
export function pickedIds({ multiple, value, values }) {
|
|
99
|
+
if (multiple) return (Array.isArray(values) ? values : []).filter(isId);
|
|
100
|
+
return isId(value) ? [value] : [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The picked ids no row has named and no lookup has tried yet: each is asked
|
|
105
|
+
* for once, so a deleted person is not looked up on every render.
|
|
106
|
+
*
|
|
107
|
+
* @param {Array<string|number>} ids
|
|
108
|
+
* @param {Map<string, object>} knownRows — id → row.
|
|
109
|
+
* @param {Set<string>} attempted — ids already looked up.
|
|
110
|
+
*/
|
|
111
|
+
export function unresolvedIds(ids, knownRows, attempted) {
|
|
112
|
+
return (Array.isArray(ids) ? ids : []).filter(
|
|
113
|
+
(id) => !knownRows.has(String(id)) && !attempted.has(String(id)),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Split saved ids into those a users or groups lookup can ask for and those
|
|
121
|
+
* it cannot: the directory refuses a whole batch over one id that is not a
|
|
122
|
+
* UUID, so such an id is named unknown at once instead. Record ids are each
|
|
123
|
+
* read alone, so every one is asked for.
|
|
124
|
+
*
|
|
125
|
+
* @param {Array<string|number>} ids
|
|
126
|
+
* @param {"users"|"groups"|"records"} source
|
|
127
|
+
*/
|
|
128
|
+
export function lookupableIds(ids, source) {
|
|
129
|
+
const list = Array.isArray(ids) ? ids : [];
|
|
130
|
+
if (source === "records") return { lookup: list, unknown: [] };
|
|
131
|
+
const lookup = [];
|
|
132
|
+
const unknown = [];
|
|
133
|
+
for (const id of list) (typeof id === "string" && UUID.test(id) ? lookup : unknown).push(id);
|
|
134
|
+
return { lookup, unknown };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The lookups in flight, with `ids` that none of them holds yet added as new
|
|
139
|
+
* batches. A batch keeps the key it was given, so an id resolving elsewhere
|
|
140
|
+
* never restarts a lookup still out. Returns `queue` itself when nothing is new.
|
|
141
|
+
*
|
|
142
|
+
* @param {{pending: Array<{key: number, ids: Array<string|number>}>, next: number}} queue
|
|
143
|
+
* @param {Array<string|number>} ids
|
|
144
|
+
* @param {number} [size]
|
|
145
|
+
*/
|
|
146
|
+
export function queueLookups(queue, ids, size = MAX_RESOLVE_IDS) {
|
|
147
|
+
const out = new Set(queue.pending.flatMap((batch) => batch.ids.map(String)));
|
|
148
|
+
const fresh = (Array.isArray(ids) ? ids : []).filter((id) => !out.has(String(id)));
|
|
149
|
+
if (fresh.length === 0) return queue;
|
|
150
|
+
const added = batches(fresh, size).map((batch, i) => ({ key: queue.next + i, ids: batch }));
|
|
151
|
+
return { pending: [...queue.pending, ...added], next: queue.next + added.length };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `queue` without the lookup `key`. */
|
|
155
|
+
export function settleLookup(queue, key) {
|
|
156
|
+
return { ...queue, pending: queue.pending.filter((batch) => batch.key !== key) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether a page hook's result answers the query it was last asked. After the
|
|
161
|
+
* query changes, the hook still holds the previous answer until it starts
|
|
162
|
+
* loading or returns new rows, so that answer is not reported as this one.
|
|
163
|
+
*
|
|
164
|
+
* @param {{queryKey: string|null, rows: unknown, error: unknown}|null} last — what was last reported.
|
|
165
|
+
* @param {{queryKey: string|null, rows: unknown, error: unknown, loading: boolean}} now
|
|
166
|
+
*/
|
|
167
|
+
export function answersQuery(last, now) {
|
|
168
|
+
if (!last || last.queryKey === now.queryKey) return true;
|
|
169
|
+
return now.loading || now.rows !== last.rows || now.error !== last.error;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `ids` in batches of at most `size`. */
|
|
173
|
+
export function batches(ids, size = MAX_RESOLVE_IDS) {
|
|
174
|
+
const list = Array.isArray(ids) ? ids : [];
|
|
175
|
+
const out = [];
|
|
176
|
+
for (let start = 0; start < list.length; start += size) out.push(list.slice(start, start + size));
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* What the trigger shows for each picked id: its row's name, "loading" while
|
|
182
|
+
* the lookup is out, `unknownText` once it found nothing. Never the raw id.
|
|
183
|
+
*
|
|
184
|
+
* @param {Array<string|number>} ids
|
|
185
|
+
* @param {{knownRows: Map<string, object>, attempted: Set<string>,
|
|
186
|
+
* nameOf: (row: object) => string, loadingText: string, unknownText: string}} how
|
|
187
|
+
*/
|
|
188
|
+
export function pickedLabels(ids, { knownRows, attempted, nameOf, loadingText, unknownText }) {
|
|
189
|
+
return (Array.isArray(ids) ? ids : []).map((id) => {
|
|
190
|
+
const row = knownRows.get(String(id));
|
|
191
|
+
if (row) return { value: id, label: nameOf(row), row };
|
|
192
|
+
return { value: id, label: attempted.has(String(id)) ? unknownText : loadingText };
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `knownRows` with every row in `rows` added by id. */
|
|
197
|
+
export function rememberRows(knownRows, rows) {
|
|
198
|
+
const next = new Map(knownRows);
|
|
199
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
200
|
+
if (row && isId(row.id)) next.set(String(row.id), row);
|
|
201
|
+
}
|
|
202
|
+
return next;
|
|
203
|
+
}
|