@svgrid/enterprise 3.1.7 → 3.1.9
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 +1 -0
- package/dist/SvAlertRuleEditor.svelte +16 -5
- package/dist/SvGridAlerts.svelte +8 -0
- package/dist/SvGridAlerts.svelte.d.ts +4 -0
- package/dist/SvGridBoard.svelte +69 -10
- package/dist/SvGridBulkEditDrawer.svelte +13 -5
- package/dist/SvGridGallery.svelte +286 -0
- package/dist/SvGridGallery.svelte.d.ts +30 -0
- package/dist/SvGridScheduler.svelte +33 -11
- package/dist/SvGridSelectionBar.svelte +47 -0
- package/dist/advanced-filter/expr-columns-from-grid.d.ts +1 -0
- package/dist/advanced-filter/expr-columns-from-grid.js +11 -7
- package/dist/advanced-filter-enable.d.ts +6 -0
- package/dist/advanced-filter-enable.js +8 -0
- package/dist/alerts/alert-engine.d.ts +7 -0
- package/dist/alerts/alert-engine.js +5 -2
- package/dist/alerts/condition-editor.d.ts +26 -0
- package/dist/alerts/condition-editor.js +17 -0
- package/dist/base/index.d.ts +20 -0
- package/dist/base/index.js +20 -0
- package/dist/bulk-edit.d.ts +11 -0
- package/dist/bulk-edit.js +18 -1
- package/dist/cdn/svgrid-enterprise.svelte-external.js +11375 -10805
- package/dist/designer/assets/GridFooter-Cu8_yBlQ.js +2 -0
- package/dist/designer/assets/GridMenus-BUjkkAU2.js +6 -0
- package/dist/designer/assets/PinSplitter-BgTpgh1d.css +1 -0
- package/dist/designer/assets/PinSplitter-C5AwA1xV.js +1 -0
- package/dist/designer/assets/SvGrid.helpers-CIrmIo9U.js +1 -0
- package/dist/designer/assets/SvGridCellEditor-CJxThKL_.js +4 -0
- package/dist/designer/assets/SvListBox-CS7oUAiX.js +2 -0
- package/dist/designer/assets/conditional-formatting-MDWbF8_Y.js +1 -0
- package/dist/designer/assets/grid-extension.svelte-BZ7AqyJI.js +1 -0
- package/dist/designer/assets/{index-DtBIaQno.js → index-BPU2KNc5.js} +396 -395
- package/dist/designer/assets/index-BmvG99kR.css +1 -0
- package/dist/designer/assets/sparkline-C-NcnB2i.js +1 -0
- package/dist/designer/assets/src-C_U1NKOY.js +58 -0
- package/dist/designer/assets/sv-grid-scrollbar-B9N6Wuj0.js +109 -0
- package/dist/designer/assets/validate-H5u2HEQh.js +1 -0
- package/dist/designer/index.html +11 -7
- package/dist/expressions/compile.js +10 -0
- package/dist/expressions/evaluate.js +18 -0
- package/dist/expressions/expression-types.d.ts +10 -0
- package/dist/expressions/kinds.d.ts +28 -0
- package/dist/expressions/kinds.js +19 -0
- package/dist/expressions/parse.js +9 -1
- package/dist/fields/AttachmentCell.svelte +242 -0
- package/dist/fields/AttachmentCell.svelte.d.ts +5 -0
- package/dist/fields/AttachmentDialog.svelte +357 -0
- package/dist/fields/AttachmentDialog.svelte.d.ts +13 -0
- package/dist/fields/AttachmentEditor.svelte +41 -0
- package/dist/fields/AttachmentEditor.svelte.d.ts +11 -0
- package/dist/fields/AttachmentPreview.svelte +72 -0
- package/dist/fields/AttachmentPreview.svelte.d.ts +8 -0
- package/dist/fields/BarcodeCell.svelte +20 -0
- package/dist/fields/BarcodeCell.svelte.d.ts +5 -0
- package/dist/fields/BaseAutomations.svelte +558 -0
- package/dist/fields/BaseAutomations.svelte.d.ts +18 -0
- package/dist/fields/BaseChartView.svelte +47 -0
- package/dist/fields/BaseChartView.svelte.d.ts +8 -0
- package/dist/fields/BaseTableView.svelte +185 -0
- package/dist/fields/BaseTableView.svelte.d.ts +37 -0
- package/dist/fields/BaseTabs.svelte +308 -0
- package/dist/fields/BaseTabs.svelte.d.ts +21 -0
- package/dist/fields/ButtonCell.svelte +47 -0
- package/dist/fields/ButtonCell.svelte.d.ts +20 -0
- package/dist/fields/CheckboxCell.svelte +46 -0
- package/dist/fields/CheckboxCell.svelte.d.ts +14 -0
- package/dist/fields/DeleteFieldConfirm.svelte +80 -0
- package/dist/fields/DeleteFieldConfirm.svelte.d.ts +8 -0
- package/dist/fields/DeleteRecordConfirm.svelte +48 -0
- package/dist/fields/DeleteRecordConfirm.svelte.d.ts +9 -0
- package/dist/fields/FieldCell.svelte +80 -0
- package/dist/fields/FieldCell.svelte.d.ts +9 -0
- package/dist/fields/FieldEditor.svelte +321 -0
- package/dist/fields/FieldEditor.svelte.d.ts +10 -0
- package/dist/fields/FieldInput.svelte +188 -0
- package/dist/fields/FieldInput.svelte.d.ts +29 -0
- package/dist/fields/FieldsHeaderEnd.svelte +146 -0
- package/dist/fields/FieldsHeaderEnd.svelte.d.ts +6 -0
- package/dist/fields/FootRowAdder.svelte +8 -0
- package/dist/fields/FootRowAdder.svelte.d.ts +6 -0
- package/dist/fields/GroupHeader.svelte +127 -0
- package/dist/fields/GroupHeader.svelte.d.ts +24 -0
- package/dist/fields/LinkCell.svelte +46 -0
- package/dist/fields/LinkCell.svelte.d.ts +9 -0
- package/dist/fields/LinkPickerEditor.svelte +172 -0
- package/dist/fields/LinkPickerEditor.svelte.d.ts +9 -0
- package/dist/fields/LinkPreview.svelte +60 -0
- package/dist/fields/LinkPreview.svelte.d.ts +7 -0
- package/dist/fields/LinkedRecordView.svelte +94 -0
- package/dist/fields/LinkedRecordView.svelte.d.ts +9 -0
- package/dist/fields/LinkedRecordsCell.svelte +59 -0
- package/dist/fields/LinkedRecordsCell.svelte.d.ts +11 -0
- package/dist/fields/LongTextEditor.svelte +157 -0
- package/dist/fields/LongTextEditor.svelte.d.ts +11 -0
- package/dist/fields/NumberEditor.svelte +309 -0
- package/dist/fields/NumberEditor.svelte.d.ts +12 -0
- package/dist/fields/OptionsEditor.svelte +117 -0
- package/dist/fields/OptionsEditor.svelte.d.ts +11 -0
- package/dist/fields/PercentCell.svelte +21 -0
- package/dist/fields/PercentCell.svelte.d.ts +8 -0
- package/dist/fields/PropertyInput.svelte +204 -0
- package/dist/fields/PropertyInput.svelte.d.ts +11 -0
- package/dist/fields/RatingCell.svelte +75 -0
- package/dist/fields/RatingCell.svelte.d.ts +16 -0
- package/dist/fields/RatingEditor.svelte +82 -0
- package/dist/fields/RatingEditor.svelte.d.ts +9 -0
- package/dist/fields/RecordView.svelte +310 -0
- package/dist/fields/RecordView.svelte.d.ts +11 -0
- package/dist/fields/RichTextCell.svelte +30 -0
- package/dist/fields/RichTextCell.svelte.d.ts +5 -0
- package/dist/fields/RowAdder.svelte +93 -0
- package/dist/fields/RowAdder.svelte.d.ts +7 -0
- package/dist/fields/RowHeader.svelte +99 -0
- package/dist/fields/RowHeader.svelte.d.ts +11 -0
- package/dist/fields/SelectCell.svelte +35 -0
- package/dist/fields/SelectCell.svelte.d.ts +15 -0
- package/dist/fields/SelectEditor.svelte +350 -0
- package/dist/fields/SelectEditor.svelte.d.ts +17 -0
- package/dist/fields/SummaryCell.svelte +160 -0
- package/dist/fields/SummaryCell.svelte.d.ts +11 -0
- package/dist/fields/SvBase.svelte +545 -0
- package/dist/fields/SvBase.svelte.d.ts +30 -0
- package/dist/fields/SvBaseForm.svelte +295 -0
- package/dist/fields/SvBaseForm.svelte.d.ts +22 -0
- package/dist/fields/SvBaseViewBar.svelte +644 -0
- package/dist/fields/SvBaseViewBar.svelte.d.ts +26 -0
- package/dist/fields/UserCell.svelte +39 -0
- package/dist/fields/UserCell.svelte.d.ts +9 -0
- package/dist/fields/UserFace.svelte +30 -0
- package/dist/fields/UserFace.svelte.d.ts +12 -0
- package/dist/fields/activity.svelte.d.ts +50 -0
- package/dist/fields/activity.svelte.js +110 -0
- package/dist/fields/attachments.d.ts +95 -0
- package/dist/fields/attachments.js +145 -0
- package/dist/fields/automation-outbound.d.ts +46 -0
- package/dist/fields/automation-outbound.js +61 -0
- package/dist/fields/automation-server.d.ts +53 -0
- package/dist/fields/automation-server.js +333 -0
- package/dist/fields/automations.svelte.d.ts +208 -0
- package/dist/fields/automations.svelte.js +437 -0
- package/dist/fields/bar-request.svelte.d.ts +16 -0
- package/dist/fields/bar-request.svelte.js +15 -0
- package/dist/fields/base-alerts.d.ts +24 -0
- package/dist/fields/base-alerts.js +64 -0
- package/dist/fields/base-connect.d.ts +82 -0
- package/dist/fields/base-connect.js +271 -0
- package/dist/fields/base-schema.d.ts +104 -0
- package/dist/fields/base-schema.js +266 -0
- package/dist/fields/base-sql.d.ts +62 -0
- package/dist/fields/base-sql.js +251 -0
- package/dist/fields/base-sync.d.ts +142 -0
- package/dist/fields/base-sync.js +499 -0
- package/dist/fields/base-tables.d.ts +89 -0
- package/dist/fields/base-tables.js +349 -0
- package/dist/fields/builtin-types.d.ts +13 -0
- package/dist/fields/builtin-types.js +773 -0
- package/dist/fields/button.d.ts +2 -0
- package/dist/fields/button.js +17 -0
- package/dist/fields/chart-view.d.ts +35 -0
- package/dist/fields/chart-view.js +154 -0
- package/dist/fields/column-order.d.ts +6 -0
- package/dist/fields/column-order.js +12 -0
- package/dist/fields/convert.d.ts +16 -0
- package/dist/fields/convert.js +176 -0
- package/dist/fields/copy-text.d.ts +13 -0
- package/dist/fields/copy-text.js +45 -0
- package/dist/fields/editor-request.svelte.d.ts +71 -0
- package/dist/fields/editor-request.svelte.js +52 -0
- package/dist/fields/field-ops.d.ts +72 -0
- package/dist/fields/field-ops.js +334 -0
- package/dist/fields/filter-model.d.ts +84 -0
- package/dist/fields/filter-model.js +446 -0
- package/dist/fields/format.d.ts +109 -0
- package/dist/fields/format.js +288 -0
- package/dist/fields/formula.d.ts +50 -0
- package/dist/fields/formula.js +530 -0
- package/dist/fields/grid-access.d.ts +60 -0
- package/dist/fields/grid-access.js +120 -0
- package/dist/fields/index.d.ts +34 -0
- package/dist/fields/index.js +312 -0
- package/dist/fields/linked-fields.d.ts +8 -0
- package/dist/fields/linked-fields.js +40 -0
- package/dist/fields/links.d.ts +10 -0
- package/dist/fields/links.js +46 -0
- package/dist/fields/name-list.d.ts +16 -0
- package/dist/fields/name-list.js +49 -0
- package/dist/fields/palette.d.ts +47 -0
- package/dist/fields/palette.js +69 -0
- package/dist/fields/record-delete.svelte.d.ts +12 -0
- package/dist/fields/record-delete.svelte.js +5 -0
- package/dist/fields/record-menu.d.ts +9 -0
- package/dist/fields/record-menu.js +78 -0
- package/dist/fields/registry.svelte.d.ts +176 -0
- package/dist/fields/registry.svelte.js +51 -0
- package/dist/fields/rich-text.d.ts +23 -0
- package/dist/fields/rich-text.js +48 -0
- package/dist/fields/row-color-choice.svelte.d.ts +31 -0
- package/dist/fields/row-color-choice.svelte.js +25 -0
- package/dist/fields/row-colors.d.ts +36 -0
- package/dist/fields/row-colors.js +90 -0
- package/dist/fields/row-height.d.ts +28 -0
- package/dist/fields/row-height.js +50 -0
- package/dist/fields/sources.svelte.d.ts +80 -0
- package/dist/fields/sources.svelte.js +131 -0
- package/dist/fields/summaries.d.ts +18 -0
- package/dist/fields/summaries.js +138 -0
- package/dist/fields/view-model.d.ts +129 -0
- package/dist/fields/view-model.js +492 -0
- package/dist/fields/viewbar/ChartPanel.svelte +61 -0
- package/dist/fields/viewbar/ChartPanel.svelte.d.ts +9 -0
- package/dist/fields/viewbar/ColorPanel.svelte +112 -0
- package/dist/fields/viewbar/ColorPanel.svelte.d.ts +9 -0
- package/dist/fields/viewbar/ConditionValue.svelte +153 -0
- package/dist/fields/viewbar/ConditionValue.svelte.d.ts +15 -0
- package/dist/fields/viewbar/FieldIcon.svelte +18 -0
- package/dist/fields/viewbar/FieldIcon.svelte.d.ts +9 -0
- package/dist/fields/viewbar/FilterPanel.svelte +192 -0
- package/dist/fields/viewbar/FilterPanel.svelte.d.ts +13 -0
- package/dist/fields/viewbar/GanttPanel.svelte +60 -0
- package/dist/fields/viewbar/GanttPanel.svelte.d.ts +10 -0
- package/dist/fields/viewbar/HideFieldsPanel.svelte +104 -0
- package/dist/fields/viewbar/HideFieldsPanel.svelte.d.ts +7 -0
- package/dist/fields/viewbar/SortPanel.svelte +85 -0
- package/dist/fields/viewbar/SortPanel.svelte.d.ts +8 -0
- package/dist/fields/viewbar/ViewsMenu.svelte +250 -0
- package/dist/fields/viewbar/ViewsMenu.svelte.d.ts +15 -0
- package/dist/fields/viewbar/panel-focus.d.ts +3 -0
- package/dist/fields/viewbar/panel-focus.js +22 -0
- package/dist/fields/viewbar/sort-state.d.ts +20 -0
- package/dist/fields/viewbar/sort-state.js +24 -0
- package/dist/gallery.d.ts +7 -0
- package/dist/gallery.js +36 -0
- package/dist/gantt/SvGridGantt.svelte +88 -16
- package/dist/gantt/gantt-config.d.ts +28 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/install.js +3 -0
- package/dist/license-core.d.ts +3 -3
- package/dist/license.js +1 -0
- package/dist/node/studio.js +141 -20
- package/dist/scheduler-config.d.ts +11 -0
- package/dist/sources/introspect-supabase.js +4 -1
- package/dist/sources/rest.js +6 -2
- package/dist/studio/emit-project.js +25 -6
- package/dist/studio/sveltekit3.js +13 -7
- package/dist/studio/ui-components.generated.js +94 -2
- package/dist/upgrade-prompt.d.ts +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/wc/GridFooter-f5K0aLe_.js +145 -0
- package/dist/wc/GridMenus-FtSN_8t9.js +1369 -0
- package/dist/wc/PinSplitter-D-qtDhSM.js +132 -0
- package/dist/wc/{SvGrid.helpers-zBkDTryR.js → SvGrid.helpers-B2GEKrxS.js} +84 -36
- package/dist/wc/{SvGridCellEditor-CzUKEbkI.js → SvGridCellEditor-B_cLJrsp.js} +20 -9
- package/dist/wc/conditional-formatting-BXis_oP1.js +189 -0
- package/dist/wc/grid-extension.svelte-Bufq4B4C.js +20 -0
- package/dist/wc/sv-sheet-element.js +7561 -7618
- package/dist/wc/svelte-virtualizer.svelte-ehwOupl-.js +360 -0
- package/dist/wc/{validate-43C03DLP.js → validate-BaMO8UPE.js} +1 -1
- package/package.json +14 -3
- package/dist/designer/assets/GridMenus-CvcASlKv.js +0 -8
- package/dist/designer/assets/SvGrid.helpers-DE5mmORW.js +0 -1
- package/dist/designer/assets/SvGridCellEditor-C7NWcnNC.js +0 -4
- package/dist/designer/assets/SvListBox-BlmmzYcU.js +0 -110
- package/dist/designer/assets/index-KmSEY7Lb.css +0 -1
- package/dist/designer/assets/src-DqHCqori.js +0 -58
- package/dist/designer/assets/validate-B71dE1Cs.js +0 -1
- package/dist/wc/GridMenus-fWbBUiRH.js +0 -1088
- package/dist/wc/svelte-virtualizer.svelte-Dntdh8Ms.js +0 -581
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
import { fillFieldColumn } from './registry.svelte.js';
|
|
2
|
+
import RatingCell from './RatingCell.svelte';
|
|
3
|
+
import LinkCell from './LinkCell.svelte';
|
|
4
|
+
import AttachmentCell from './AttachmentCell.svelte';
|
|
5
|
+
import CheckboxCell from './CheckboxCell.svelte';
|
|
6
|
+
import PercentCell from './PercentCell.svelte';
|
|
7
|
+
import UserCell from './UserCell.svelte';
|
|
8
|
+
import ButtonCell from './ButtonCell.svelte';
|
|
9
|
+
import BarcodeCell from './BarcodeCell.svelte';
|
|
10
|
+
import LinkedRecordsCell from './LinkedRecordsCell.svelte';
|
|
11
|
+
import RichTextCell from './RichTextCell.svelte';
|
|
12
|
+
import SelectCell from './SelectCell.svelte';
|
|
13
|
+
import { attachmentsText, parseAttachments } from './attachments.js';
|
|
14
|
+
import { dateEditTokens, formatCurrency, formatDate, formatDuration, formatNumber, formatPercent, numberFieldShape, numberFromText, numberKind, parseDuration, toNumber } from './format.js';
|
|
15
|
+
import { BRIGHT, CHECKBOX_ICONS, RATING_ICONS } from './palette.js';
|
|
16
|
+
import { compileFormula, isoDate, selfReferenceError } from './formula.js';
|
|
17
|
+
import { findFieldUser, getCurrentUser, getFieldUsers, getLinkedTable, linkedRows, listLinkedTables, resolveLinkIds } from './sources.svelte.js';
|
|
18
|
+
import { splitNames } from './name-list.js';
|
|
19
|
+
import { linkedFields } from './linked-fields.js';
|
|
20
|
+
/**
|
|
21
|
+
* A field of a linked table's records, read the way that table reads it: a
|
|
22
|
+
* formula, lookup, rollup or count there is worked out, not taken from the
|
|
23
|
+
* stored record (which has no value for it). A rollup of rollups that point
|
|
24
|
+
* back at each other stops after a few steps instead of looping.
|
|
25
|
+
*/
|
|
26
|
+
const linkedReaders = new WeakMap();
|
|
27
|
+
let readingDepth = 0;
|
|
28
|
+
/** Field types whose value is worked out from the row, never stored. */
|
|
29
|
+
const COMPUTED_TYPES = new Set(['formula', 'lookup', 'rollup', 'count']);
|
|
30
|
+
function readLinked(table, field) {
|
|
31
|
+
const raw = (r) => r?.[field];
|
|
32
|
+
if (!table?.fields?.length)
|
|
33
|
+
return raw;
|
|
34
|
+
let byField = linkedReaders.get(table.fields);
|
|
35
|
+
if (!byField)
|
|
36
|
+
linkedReaders.set(table.fields, (byField = new Map()));
|
|
37
|
+
if (!byField.has(field)) {
|
|
38
|
+
const f = linkedFields(table).find((x) => x.def.field === field);
|
|
39
|
+
byField.set(field, f?.def.fieldFn ?? null);
|
|
40
|
+
}
|
|
41
|
+
const fn = byField.get(field);
|
|
42
|
+
if (!fn)
|
|
43
|
+
return raw;
|
|
44
|
+
return (r) => {
|
|
45
|
+
if (readingDepth > 3)
|
|
46
|
+
return raw(r);
|
|
47
|
+
readingDepth += 1;
|
|
48
|
+
try {
|
|
49
|
+
return fn(r);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
readingDepth -= 1;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// 24x24 path data for the picker and the header (stroke icons).
|
|
57
|
+
const ICON = {
|
|
58
|
+
text: 'M4 6h16M4 12h10M4 18h7',
|
|
59
|
+
longText: 'M4 6h16M4 10h16M4 14h16M4 18h10',
|
|
60
|
+
number: 'M9 4 7 20M17 4l-2 16M4 9h16M3 15h16',
|
|
61
|
+
currency: 'M12 3v18M16 7.5C16 5.6 14.2 4.5 12 4.5S8 5.6 8 7.5s1.8 2.7 4 3.5 4 1.6 4 3.5-1.8 3-4 3-4-1.1-4-3',
|
|
62
|
+
percent: 'M19 5 5 19M7 7h.01M17 17h.01',
|
|
63
|
+
date: 'M4 6h16v14H4zM4 10h16M8 3v4M16 3v4',
|
|
64
|
+
duration: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM12 8v4l3 2',
|
|
65
|
+
checkbox: 'M4 4h16v16H4zM8 12l3 3 5-6',
|
|
66
|
+
singleSelect: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM8 11l4 4 4-4',
|
|
67
|
+
multiSelect: 'M3 7h11M3 12h11M3 17h11M17 6l2 2 3-3M17 16l2 2 3-3',
|
|
68
|
+
rating: 'M12 3l2.8 5.7 6.2.9-4.5 4.4 1 6.2L12 17.3 6.5 20.2l1-6.2L3 9.6l6.2-.9z',
|
|
69
|
+
url: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1',
|
|
70
|
+
email: 'M3 6h18v12H3zM3 7l9 6 9-6',
|
|
71
|
+
phone: 'M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2',
|
|
72
|
+
attachment: 'M20 11.5 12.3 19a5 5 0 0 1-7.1-7.1l7.8-7.8a3.3 3.3 0 0 1 4.7 4.7L9.9 16.6a1.7 1.7 0 0 1-2.4-2.4l7.1-7',
|
|
73
|
+
user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 16 0',
|
|
74
|
+
createdTime: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM12 8v4h4',
|
|
75
|
+
modifiedTime: 'M4 12a8 8 0 1 0 2.3-5.7M4 4v4h4M12 8v4l3 2',
|
|
76
|
+
createdBy: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 11-7.5M19 15v6M16 18h6',
|
|
77
|
+
modifiedBy: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 11-7.5M16 21l5-5-2-2-5 5v2z',
|
|
78
|
+
autoNumber: 'M5 7h3v10M11 9a2 2 0 1 1 4 0c0 2-4 3-4 6h4M18 7h2v10',
|
|
79
|
+
barcode: 'M4 5v14M7 5v14M10 5v14M13 5v14M15 5v14M18 5v14M20 5v14',
|
|
80
|
+
button: 'M4 7h16v10H4zM9 12h6',
|
|
81
|
+
formula: 'M7 20c2 0 2.5-2 3-5l2-10c.5-2 1.5-2 3-2M8 9h7',
|
|
82
|
+
link: 'M9 7H6a5 5 0 0 0 0 10h3M15 7h3a5 5 0 0 1 0 10h-3M8 12h8',
|
|
83
|
+
lookup: 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M8 11h6',
|
|
84
|
+
rollup: 'M6 4h12L12 12l6 8H6',
|
|
85
|
+
count: 'M5 5h14v14H5zM9 9v6M12 9v6M15 9v6',
|
|
86
|
+
};
|
|
87
|
+
// ---- shared properties ------------------------------------------------------
|
|
88
|
+
const SEPARATORS = {
|
|
89
|
+
key: 'separators', label: 'Separators', kind: 'choice',
|
|
90
|
+
choices: [
|
|
91
|
+
{ value: 'local', label: 'Local' },
|
|
92
|
+
{ value: 'comma-period', label: '1,000,000.00' },
|
|
93
|
+
{ value: 'period-comma', label: '1.000.000,00' },
|
|
94
|
+
{ value: 'space-comma', label: '1 000 000,00' },
|
|
95
|
+
{ value: 'space-period', label: '1 000 000.00' },
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
const SHOW_THOUSANDS = { key: 'showThousands', label: 'Show thousands separator', kind: 'checkbox' };
|
|
99
|
+
const ABBREVIATION = {
|
|
100
|
+
key: 'abbreviation', label: 'Large number abbreviation', kind: 'choice',
|
|
101
|
+
choices: [
|
|
102
|
+
{ value: 'none', label: 'None' },
|
|
103
|
+
{ value: 'thousand', label: 'Thousand (K)' },
|
|
104
|
+
{ value: 'million', label: 'Million (M)' },
|
|
105
|
+
{ value: 'billion', label: 'Billion (B)' },
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
const NEGATIVE = { key: 'allowNegative', label: 'Allow negative numbers', kind: 'checkbox' };
|
|
109
|
+
const DEFAULT = { key: 'default', label: 'Default value', kind: 'default', hint: 'New rows start with this value.' };
|
|
110
|
+
const precisionChoices = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => {
|
|
111
|
+
const d = from + i;
|
|
112
|
+
return { value: String(d), label: d === 0 ? '1' : `1.${'0'.repeat(d)}` };
|
|
113
|
+
});
|
|
114
|
+
const DATE_FORMAT = {
|
|
115
|
+
key: 'dateFormat', label: 'Date format', kind: 'choice',
|
|
116
|
+
choices: [
|
|
117
|
+
{ value: 'local', label: 'Local' },
|
|
118
|
+
{ value: 'friendly', label: 'Friendly (October 14, 2026)' },
|
|
119
|
+
{ value: 'us', label: 'US (10/14/2026)' },
|
|
120
|
+
{ value: 'european', label: 'European (14/10/2026)' },
|
|
121
|
+
{ value: 'iso', label: 'ISO (2026-10-14)' },
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
const TIME_PROPS = [
|
|
125
|
+
{ key: 'time', label: 'Include time', kind: 'checkbox' },
|
|
126
|
+
{ key: 'timeFormat', label: 'Time format', kind: 'segmented', choices: [{ value: '12hour', label: '12 hour' }, { value: '24hour', label: '24 hour' }], visible: (p) => !!p.time },
|
|
127
|
+
{ key: 'gmt', label: 'Use the same time zone (GMT) for everyone', kind: 'checkbox', visible: (p) => !!p.time },
|
|
128
|
+
{ key: 'showTimeZone', label: 'Display time zone', kind: 'checkbox', visible: (p) => !!p.time },
|
|
129
|
+
];
|
|
130
|
+
const linkColumns = (ctx) => ctx.columns.filter((c) => c.fieldType === 'link').map((c) => ({ value: String(c.id ?? c.field), label: String(c.header ?? c.field) }));
|
|
131
|
+
const LINK_FIELD = { key: 'linkField', label: 'Link field', kind: 'choice', choices: linkColumns, hint: 'A link-to-records field of this table.' };
|
|
132
|
+
const fieldsOfLinked = (ctx) => {
|
|
133
|
+
const link = ctx.columns.find((c) => String(c.id ?? c.field) === ctx.props.linkField);
|
|
134
|
+
const table = getLinkedTable(link?.fieldProps?.table);
|
|
135
|
+
if (!table)
|
|
136
|
+
return [];
|
|
137
|
+
const fields = table.fields ?? [{ field: table.primaryField, header: table.primaryField }];
|
|
138
|
+
return fields.map((f) => ({ value: f.field, label: f.header }));
|
|
139
|
+
};
|
|
140
|
+
const LINKED_FIELD = { key: 'field', label: 'Field in the linked table', kind: 'choice', choices: fieldsOfLinked, visible: (p) => !!p.linkField };
|
|
141
|
+
// ---- helpers ------------------------------------------------------------------
|
|
142
|
+
const digits = (p, fallback = 0) => {
|
|
143
|
+
const n = Number(p.precision);
|
|
144
|
+
return Number.isInteger(n) && n >= 0 && n <= 8 ? n : fallback;
|
|
145
|
+
};
|
|
146
|
+
const numberProps = (p) => ({
|
|
147
|
+
precision: p.format === 'integer' ? 0 : digits(p, p.format === 'decimal' ? 1 : 0),
|
|
148
|
+
separators: p.separators,
|
|
149
|
+
showThousands: p.showThousands !== false,
|
|
150
|
+
abbreviation: p.abbreviation,
|
|
151
|
+
});
|
|
152
|
+
/**
|
|
153
|
+
* Store what a paste or fill brings into a number, currency or percent cell
|
|
154
|
+
* as the field reads it ("$1,200", "75%", "2.5k"); a number (the editor's own
|
|
155
|
+
* commit) passes as is, and text that is not a number keeps the old value.
|
|
156
|
+
*/
|
|
157
|
+
const numberParser = (kind, p) => (x) => {
|
|
158
|
+
// The pasted text itself: the grid's own number coercion has already
|
|
159
|
+
// turned "$1,200" into null by the time newValue arrives.
|
|
160
|
+
const v = typeof x.rawInput === 'string' ? x.rawInput : x.newValue;
|
|
161
|
+
if (typeof v === 'number' || v == null)
|
|
162
|
+
return v;
|
|
163
|
+
if (String(v).trim() === '')
|
|
164
|
+
return null;
|
|
165
|
+
const n = numberFromText(String(v), kind, p);
|
|
166
|
+
return n == null ? x.oldValue : n;
|
|
167
|
+
};
|
|
168
|
+
/** Refuse a negative value when the field says so (the grid's rejectInvalid). */
|
|
169
|
+
const negativeRule = (d, p) => p.allowNegative === false && !d.validate
|
|
170
|
+
? { rejectInvalid: true, validate: (ctx) => (Number(ctx.value) < 0 ? 'Negative numbers are not allowed' : null) }
|
|
171
|
+
: {};
|
|
172
|
+
/** A number as its field shows it: places, separators, abbreviation, symbol or percent sign. */
|
|
173
|
+
const numberText = (kind, p) => {
|
|
174
|
+
const base = {
|
|
175
|
+
precision: numberFieldShape(kind, p).places,
|
|
176
|
+
separators: p.separators,
|
|
177
|
+
showThousands: p.showThousands !== false,
|
|
178
|
+
abbreviation: kind === 'percent' ? undefined : p.abbreviation,
|
|
179
|
+
};
|
|
180
|
+
return (ctx) => {
|
|
181
|
+
const n = toNumber(ctx.value);
|
|
182
|
+
if (n == null)
|
|
183
|
+
return '';
|
|
184
|
+
if (kind === 'currency')
|
|
185
|
+
return formatCurrency(n, { ...base, symbol: p.symbol, symbolPosition: p.symbolPosition });
|
|
186
|
+
if (kind === 'percent')
|
|
187
|
+
return formatPercent(n, { ...base, points: p.points });
|
|
188
|
+
return formatNumber(n, base);
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* The number field in each format (and the older currency and percent
|
|
193
|
+
* types): a number column (sorting, filters, alignment) edited in the
|
|
194
|
+
* number editor; a column can name another editorType and stay a number.
|
|
195
|
+
*/
|
|
196
|
+
const fillNumber = (d, p, kind) => ({
|
|
197
|
+
...d,
|
|
198
|
+
cellDataType: d.cellDataType ?? 'number',
|
|
199
|
+
editorType: d.editorType ?? 'fieldNumber',
|
|
200
|
+
align: d.align ?? (kind === 'percent' && p.showBar ? 'left' : 'right'),
|
|
201
|
+
...negativeRule(d, p),
|
|
202
|
+
valueParser: d.valueParser ?? numberParser(kind, p),
|
|
203
|
+
formatter: d.formatter ?? numberText(kind, p),
|
|
204
|
+
});
|
|
205
|
+
const options = (p) => Array.isArray(p.options)
|
|
206
|
+
? p.options.map((o) => ({ value: o.value, label: o.label ?? String(o.value), ...(o.color ? { color: o.color } : {}) }))
|
|
207
|
+
: [];
|
|
208
|
+
/**
|
|
209
|
+
* A select's pasted or filled text as its values: a label or value per
|
|
210
|
+
* option, comma-separated for a multiple select. A value from the editor
|
|
211
|
+
* (already a value, or a list of them) passes as is.
|
|
212
|
+
*/
|
|
213
|
+
const optionParser = (p, multiple) => {
|
|
214
|
+
const byText = new Map();
|
|
215
|
+
for (const o of options(p)) {
|
|
216
|
+
byText.set(String(o.value).toLowerCase(), o.value);
|
|
217
|
+
byText.set(String(o.label).toLowerCase(), o.value);
|
|
218
|
+
}
|
|
219
|
+
const one = (t) => byText.get(t.toLowerCase()) ?? t;
|
|
220
|
+
return (x) => {
|
|
221
|
+
if (typeof x.rawInput !== 'string')
|
|
222
|
+
return x.newValue;
|
|
223
|
+
const parts = x.rawInput.split(multiple ? ',' : /\n/).map((s) => s.trim()).filter(Boolean);
|
|
224
|
+
return multiple ? [...new Set(parts.map(one))] : parts.length ? one(parts[0]) : null;
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* A select value as text, by option label: the column's formatter, which
|
|
229
|
+
* lookups and summaries read. A copy (Ctrl+C) takes the stored value, not this
|
|
230
|
+
* (in a base, people, links and files copy as shown: copy-text.ts).
|
|
231
|
+
*/
|
|
232
|
+
const optionText = (p) => {
|
|
233
|
+
const labels = new Map(options(p).map((o) => [String(o.value), o.label]));
|
|
234
|
+
return (ctx) => (Array.isArray(ctx.value) ? ctx.value : ctx.value == null || ctx.value === '' ? [] : [ctx.value]).map((v) => labels.get(String(v)) ?? String(v)).join(', ');
|
|
235
|
+
};
|
|
236
|
+
/** A select sorts in the order of its options, not by their text. */
|
|
237
|
+
const optionRank = (p) => {
|
|
238
|
+
const rank = new Map(options(p).map((o, i) => [String(o.value), i]));
|
|
239
|
+
return (value) => (value == null || value === '' ? null : rank.get(String(value)) ?? rank.size);
|
|
240
|
+
};
|
|
241
|
+
/** Row key per field name (header, lower-cased) and per key, for `{Field name}`. */
|
|
242
|
+
const fieldNames = (columns) => {
|
|
243
|
+
const map = {};
|
|
244
|
+
for (const c of columns) {
|
|
245
|
+
const key = String(c.field ?? c.id ?? '');
|
|
246
|
+
if (!key)
|
|
247
|
+
continue;
|
|
248
|
+
map[key.toLowerCase()] = key;
|
|
249
|
+
if (typeof c.header === 'string')
|
|
250
|
+
map[c.header.trim().toLowerCase()] = key;
|
|
251
|
+
}
|
|
252
|
+
return map;
|
|
253
|
+
};
|
|
254
|
+
const readOnly = (d) => ({ editable: d.editable ?? false });
|
|
255
|
+
const dateText = (p) => (ctx) => formatDate(ctx.value, p);
|
|
256
|
+
const userNames = (v) => (Array.isArray(v) ? v : v == null || v === '' ? [] : [v]).map((x) => findFieldUser(x)?.name ?? String(x)).join(', ');
|
|
257
|
+
/**
|
|
258
|
+
* Pasted or filled names (or emails, or ids) as user ids, comma-separated
|
|
259
|
+
* for a field with several people. A name nobody has stays as typed.
|
|
260
|
+
*/
|
|
261
|
+
const userParser = (multiple) => (x) => {
|
|
262
|
+
if (typeof x.rawInput !== 'string')
|
|
263
|
+
return x.newValue;
|
|
264
|
+
const people = getFieldUsers();
|
|
265
|
+
const one = (t) => {
|
|
266
|
+
const q = t.toLowerCase();
|
|
267
|
+
return people.find((u) => u.id.toLowerCase() === q || u.name.toLowerCase() === q || u.email?.toLowerCase() === q)?.id ?? t;
|
|
268
|
+
};
|
|
269
|
+
// Several people split at commas outside quotes, as a copy writes them ("Lee, Ann", Bo).
|
|
270
|
+
const parts = multiple ? splitNames(x.rawInput) : x.rawInput.split(/\n/).map((s) => s.trim()).filter(Boolean);
|
|
271
|
+
return multiple ? [...new Set(parts.map(one))] : parts.length ? one(parts[0]) : null;
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* Pasted or filled text as link ids: each comma-separated part is a linked
|
|
275
|
+
* record's id or its name (the primary field). A name with a comma comes
|
|
276
|
+
* quoted, as a copy writes it ("Acme, Inc."). A part no record has is dropped.
|
|
277
|
+
*/
|
|
278
|
+
const linkParser = (p) => (x) => {
|
|
279
|
+
if (typeof x.rawInput !== 'string')
|
|
280
|
+
return x.newValue;
|
|
281
|
+
const t = getLinkedTable(p.table);
|
|
282
|
+
if (!t)
|
|
283
|
+
return x.newValue;
|
|
284
|
+
const ids = resolveLinkIds(t, splitNames(x.rawInput));
|
|
285
|
+
return p.multiple === false ? ids.slice(0, 1) : ids;
|
|
286
|
+
};
|
|
287
|
+
function formatFormulaResult(v, p) {
|
|
288
|
+
if (v == null)
|
|
289
|
+
return '';
|
|
290
|
+
if (Array.isArray(v))
|
|
291
|
+
return v.map((x) => formatFormulaResult(x, p)).join(', ');
|
|
292
|
+
if (v instanceof Date)
|
|
293
|
+
return formatDate(v, p);
|
|
294
|
+
if (typeof v === 'boolean')
|
|
295
|
+
return v ? 'true' : 'false';
|
|
296
|
+
if (typeof v === 'number') {
|
|
297
|
+
if (p.result === 'currency' || p.result === 'percent' || p.result === 'number') {
|
|
298
|
+
// Without a precision, the number field's own: 1 place, 2 for currency, 0 for percent.
|
|
299
|
+
const kind = p.result;
|
|
300
|
+
const shown = { ...numberProps(p), precision: numberFieldShape(kind, { precision: p.precision }).places };
|
|
301
|
+
if (kind === 'currency')
|
|
302
|
+
return formatCurrency(v, { ...shown, symbol: p.symbol });
|
|
303
|
+
if (kind === 'percent')
|
|
304
|
+
return formatPercent(v, shown);
|
|
305
|
+
return formatNumber(v, shown);
|
|
306
|
+
}
|
|
307
|
+
return String(Math.round(v * 1e10) / 1e10);
|
|
308
|
+
}
|
|
309
|
+
// Under `date`, text that is a stored date (2026-12-04) shows as a date;
|
|
310
|
+
// other text ("Count now", "#ERROR") shows as it is, never blanked.
|
|
311
|
+
if (p.result === 'date') {
|
|
312
|
+
const d = isoDate(String(v));
|
|
313
|
+
if (d)
|
|
314
|
+
return formatDate(d, p);
|
|
315
|
+
}
|
|
316
|
+
return String(v);
|
|
317
|
+
}
|
|
318
|
+
const ROLLUPS = {
|
|
319
|
+
SUM: (v) => v.reduce((s, x) => s + (toNumber(x) ?? 0), 0),
|
|
320
|
+
AVERAGE: (v) => { const n = v.map(toNumber).filter((x) => x != null); return n.length ? n.reduce((s, x) => s + x, 0) / n.length : null; },
|
|
321
|
+
MIN: (v) => { const n = v.map(toNumber).filter((x) => x != null); return n.length ? Math.min(...n) : null; },
|
|
322
|
+
MAX: (v) => { const n = v.map(toNumber).filter((x) => x != null); return n.length ? Math.max(...n) : null; },
|
|
323
|
+
COUNT: (v) => v.filter((x) => typeof x === 'number').length,
|
|
324
|
+
COUNTA: (v) => v.filter((x) => x != null && x !== '').length,
|
|
325
|
+
COUNTALL: (v) => v.length,
|
|
326
|
+
ARRAYJOIN: (v) => v.filter((x) => x != null && x !== '').map(String).join(', '),
|
|
327
|
+
ARRAYUNIQUE: (v) => [...new Set(v.filter((x) => x != null && x !== '').map(String))].join(', '),
|
|
328
|
+
CONCATENATE: (v) => v.map((x) => (x == null ? '' : String(x))).join(''),
|
|
329
|
+
AND: (v) => v.every(Boolean),
|
|
330
|
+
OR: (v) => v.some(Boolean),
|
|
331
|
+
};
|
|
332
|
+
/**
|
|
333
|
+
* The link field a lookup, rollup or count reads, and its table. The table is
|
|
334
|
+
* looked up each time it is read: a base registers its tables after the
|
|
335
|
+
* columns of the first one are filled.
|
|
336
|
+
*/
|
|
337
|
+
const linkKeyOf = (p, ctx) => {
|
|
338
|
+
const link = ctx.columns.find((c) => String(c.id ?? c.field) === p.linkField);
|
|
339
|
+
if (!link)
|
|
340
|
+
return null;
|
|
341
|
+
const tableId = link.fieldProps?.table;
|
|
342
|
+
return {
|
|
343
|
+
key: String(link.field ?? link.id),
|
|
344
|
+
get table() {
|
|
345
|
+
return getLinkedTable(tableId);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
};
|
|
349
|
+
// Per linked table's field list (a new list when its fields change): field -> formatter.
|
|
350
|
+
const linkedFormatters = new WeakMap();
|
|
351
|
+
/** How a field of the linked table shows one value, once the table says what the field is. */
|
|
352
|
+
function linkedFormatter(link, field) {
|
|
353
|
+
const fields = link?.table?.fields;
|
|
354
|
+
if (!fields)
|
|
355
|
+
return undefined;
|
|
356
|
+
let byField = linkedFormatters.get(fields);
|
|
357
|
+
if (!byField)
|
|
358
|
+
linkedFormatters.set(fields, (byField = new Map()));
|
|
359
|
+
const key = String(field);
|
|
360
|
+
if (byField.has(key))
|
|
361
|
+
return byField.get(key);
|
|
362
|
+
const source = fields.find((f) => f.field === key);
|
|
363
|
+
const fmt = source?.fieldType
|
|
364
|
+
? fillFieldColumn({ field: source.field, fieldType: source.fieldType, fieldProps: source.fieldProps }).formatter
|
|
365
|
+
: undefined;
|
|
366
|
+
byField.set(key, fmt);
|
|
367
|
+
return fmt;
|
|
368
|
+
}
|
|
369
|
+
const nowIso = () => new Date().toISOString();
|
|
370
|
+
const watchProps = [
|
|
371
|
+
{ key: 'watch', label: 'Changes to', kind: 'segmented', choices: [{ value: 'all', label: 'All fields' }, { value: 'specific', label: 'Specific fields' }] },
|
|
372
|
+
{ key: 'watchFields', label: 'Fields', kind: 'columns', visible: (p) => p.watch === 'specific' },
|
|
373
|
+
];
|
|
374
|
+
export const BUILTIN_FIELD_TYPES = [
|
|
375
|
+
// ---- basic -----------------------------------------------------------------
|
|
376
|
+
{
|
|
377
|
+
id: 'text', label: 'Single line text', description: 'A short line of text, like a name or a title.', icon: ICON.text, group: 'basic',
|
|
378
|
+
properties: [DEFAULT],
|
|
379
|
+
fill: (d) => ({ ...d, editorType: d.editorType ?? 'text' }),
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
id: 'longText', label: 'Long text', description: 'Several lines of text; Alt+Enter starts a new line.', icon: ICON.longText, group: 'basic', width: 260,
|
|
383
|
+
properties: [{ key: 'richText', label: 'Enable rich text formatting', kind: 'checkbox', hint: '**bold**, *italic*, `code`, [links](https://...) and lists.' }],
|
|
384
|
+
// Paragraphs: a panel with room, Enter for a new line, Ctrl+Enter to save.
|
|
385
|
+
fill: (d) => ({ ...d, editorType: d.editorType ?? 'fieldLongText' }),
|
|
386
|
+
cell: RichTextCell,
|
|
387
|
+
hasCell: (p) => !!p.richText,
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
id: 'attachment', label: 'Attachment', description: 'Images and files: thumbnails in the cell, a dialog to add more.', icon: ICON.attachment, group: 'basic', width: 180,
|
|
391
|
+
defaults: { maxFiles: 20 },
|
|
392
|
+
properties: [
|
|
393
|
+
{ key: 'accept', label: 'Accepted types', kind: 'text', placeholder: 'image/*,.pdf (any when empty)' },
|
|
394
|
+
{ key: 'maxFiles', label: 'Most files per cell', kind: 'number', min: 1, max: 100 },
|
|
395
|
+
],
|
|
396
|
+
fill: (d, p) => ({
|
|
397
|
+
...d,
|
|
398
|
+
editorType: d.editorType ?? 'attachment',
|
|
399
|
+
formatter: d.formatter ?? ((ctx) => attachmentsText(ctx.value)),
|
|
400
|
+
// A paste or a copied cell keeps to accept, maxSize and maxFiles, as the dialog does.
|
|
401
|
+
valueParser: d.valueParser ?? ((e) => parseAttachments(e.newValue, p)),
|
|
402
|
+
}),
|
|
403
|
+
cell: AttachmentCell,
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
id: 'checkbox', label: 'Checkbox', description: 'Done or not done: one click to toggle.', icon: ICON.checkbox, group: 'basic', width: 90,
|
|
407
|
+
defaults: { icon: 'check', color: '#20c933' },
|
|
408
|
+
properties: [
|
|
409
|
+
{ key: 'icon', label: 'Icon', kind: 'icon', icons: CHECKBOX_ICONS },
|
|
410
|
+
{ key: 'color', label: 'Color', kind: 'color', palette: BRIGHT },
|
|
411
|
+
DEFAULT,
|
|
412
|
+
],
|
|
413
|
+
fill: (d) => ({ ...d, editorType: d.editorType ?? 'checkbox', align: d.align ?? 'center' }),
|
|
414
|
+
cell: CheckboxCell,
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
id: 'singleSelect', label: 'Single select', description: 'One choice from a list of colored options.', icon: ICON.singleSelect, group: 'basic', width: 140,
|
|
418
|
+
defaults: { options: [] },
|
|
419
|
+
properties: [{ key: 'options', label: 'Options', kind: 'options' }, DEFAULT],
|
|
420
|
+
fill: (d, p) => ({ ...d, editorType: d.editorType ?? 'fieldSelect', editorOptions: d.editorOptions ?? options(p), formatter: d.formatter ?? optionText(p), valueParser: d.valueParser ?? optionParser(p, false), sortValue: d.sortValue ?? optionRank(p) }),
|
|
421
|
+
cell: SelectCell,
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
id: 'multiSelect', label: 'Multiple select', description: 'Any number of choices from a list of colored options.', icon: ICON.multiSelect, group: 'basic', width: 200,
|
|
425
|
+
defaults: { options: [] },
|
|
426
|
+
properties: [{ key: 'options', label: 'Options', kind: 'options' }, DEFAULT],
|
|
427
|
+
fill: (d, p) => ({ ...d, editorType: d.editorType ?? 'fieldSelect', editorMultiple: d.editorMultiple ?? true, editorOptions: d.editorOptions ?? options(p), formatter: d.formatter ?? optionText(p), valueParser: d.valueParser ?? optionParser(p, true) }),
|
|
428
|
+
cell: SelectCell,
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
id: 'user', label: 'User', description: 'A person (or several) from the people the app lists.', icon: ICON.user, group: 'basic', width: 170,
|
|
432
|
+
properties: [{ key: 'multiple', label: 'Allow adding multiple users', kind: 'checkbox' }, DEFAULT],
|
|
433
|
+
fill: (d, p) => ({
|
|
434
|
+
...d,
|
|
435
|
+
// The select editor, drawing people with their avatars as the cell does.
|
|
436
|
+
editorType: d.editorType ?? 'fieldSelect',
|
|
437
|
+
valueParser: d.valueParser ?? userParser(!!p.multiple),
|
|
438
|
+
editorMultiple: d.editorMultiple ?? !!p.multiple,
|
|
439
|
+
editorOptions: d.editorOptions ?? getFieldUsers().map((u) => ({ value: u.id, label: u.name })),
|
|
440
|
+
formatter: d.formatter ?? ((ctx) => userNames(ctx.value)),
|
|
441
|
+
}),
|
|
442
|
+
cell: UserCell,
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
id: 'date', label: 'Date', description: 'A calendar date, with or without the time.', icon: ICON.date, group: 'basic', width: 150,
|
|
446
|
+
defaults: { dateFormat: 'local', timeFormat: '12hour' },
|
|
447
|
+
properties: [DATE_FORMAT, ...TIME_PROPS, { key: 'defaultToday', label: 'Default to today', kind: 'checkbox' }],
|
|
448
|
+
fill: (d, p) => ({
|
|
449
|
+
...d,
|
|
450
|
+
// A date without time is a calendar date ('2026-10-24'): stored as such,
|
|
451
|
+
// never shifted through UTC; the editor shows it as the cell does.
|
|
452
|
+
cellDataType: d.cellDataType ?? (p.time ? 'date' : 'dateString'),
|
|
453
|
+
editorType: d.editorType ?? (p.time ? 'datetime' : 'date'),
|
|
454
|
+
editorFormatString: d.editorFormatString ?? dateEditTokens(p),
|
|
455
|
+
formatter: d.formatter ?? dateText(p),
|
|
456
|
+
}),
|
|
457
|
+
newRowValue: ({ props }) => (props.defaultToday ? (props.time ? nowIso() : new Date().toISOString().slice(0, 10)) : undefined),
|
|
458
|
+
},
|
|
459
|
+
{ id: 'phone', label: 'Phone number', description: 'A phone number, shown as a tel link.', icon: ICON.phone, group: 'basic', fill: (d) => ({ ...d, editorType: d.editorType ?? 'text' }), cell: LinkCell },
|
|
460
|
+
{ id: 'email', label: 'Email', description: 'An email address, shown as a mailto link.', icon: ICON.email, group: 'basic', width: 200, fill: (d) => ({ ...d, editorType: d.editorType ?? 'text' }), cell: LinkCell },
|
|
461
|
+
{ id: 'url', label: 'URL', description: 'A web address, shown as a link.', icon: ICON.url, group: 'basic', width: 200, fill: (d) => ({ ...d, editorType: d.editorType ?? 'text' }), cell: LinkCell },
|
|
462
|
+
{
|
|
463
|
+
id: 'barcode', label: 'Barcode', description: 'A barcode\'s text, from a scanner or typed.', icon: ICON.barcode, group: 'basic', width: 160,
|
|
464
|
+
fill: (d) => ({
|
|
465
|
+
...d,
|
|
466
|
+
editorType: d.editorType ?? 'text',
|
|
467
|
+
formatter: d.formatter ?? ((ctx) => (ctx.value && typeof ctx.value === 'object' ? String(ctx.value.text ?? '') : String(ctx.value ?? ''))),
|
|
468
|
+
}),
|
|
469
|
+
cell: BarcodeCell,
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
id: 'button', label: 'Button', description: 'A button in every row that opens a link made from the row.', icon: ICON.button, group: 'basic', width: 120,
|
|
473
|
+
defaults: { label: 'Open', color: '#2d7ff9' },
|
|
474
|
+
properties: [
|
|
475
|
+
{ key: 'label', label: 'Label', kind: 'text', placeholder: 'Open' },
|
|
476
|
+
{ key: 'color', label: 'Style', kind: 'color', palette: BRIGHT },
|
|
477
|
+
{ key: 'url', label: 'URL', kind: 'text', placeholder: 'https://example.com/orders/{Order id}', hint: 'Use {Field name} for that field\'s value in the row. An app can set fieldProps.onClick instead.' },
|
|
478
|
+
],
|
|
479
|
+
fill: (d, _p, ctx) => ({ ...d, ...readOnly(d), sortable: d.sortable ?? false, __fieldNames: fieldNames(ctx.columns) }),
|
|
480
|
+
cell: ButtonCell,
|
|
481
|
+
},
|
|
482
|
+
// ---- numbers -----------------------------------------------------------------
|
|
483
|
+
// One number field, in four formats: integer, decimal, currency, percent.
|
|
484
|
+
// Currency and percent columns from before keep working and open in the
|
|
485
|
+
// field editor as a number in that format.
|
|
486
|
+
{
|
|
487
|
+
id: 'number', label: 'Number', description: 'A number: whole, decimal, an amount of money or a percentage.', icon: ICON.number, group: 'number', width: 120,
|
|
488
|
+
defaults: {
|
|
489
|
+
format: 'decimal', precision: 1, separators: 'local', showThousands: true, abbreviation: 'none', allowNegative: true,
|
|
490
|
+
symbol: '$', symbolPosition: 'before', showBar: false, points: false, step: 1,
|
|
491
|
+
},
|
|
492
|
+
properties: [
|
|
493
|
+
{
|
|
494
|
+
key: 'format', label: 'Format', kind: 'segmented',
|
|
495
|
+
choices: [
|
|
496
|
+
{ value: 'integer', label: 'Integer' },
|
|
497
|
+
{ value: 'decimal', label: 'Decimal' },
|
|
498
|
+
{ value: 'currency', label: 'Currency' },
|
|
499
|
+
{ value: 'percent', label: 'Percent' },
|
|
500
|
+
],
|
|
501
|
+
// Each format starts at its usual number of decimal places.
|
|
502
|
+
alsoSets: (v) => ({ precision: v === 'decimal' ? 1 : v === 'currency' ? 2 : 0 }),
|
|
503
|
+
},
|
|
504
|
+
{ key: 'symbol', label: 'Currency symbol', kind: 'text', placeholder: '$, €, £, ¥, CHF, kr', visible: (p) => p.format === 'currency' },
|
|
505
|
+
{
|
|
506
|
+
key: 'symbolPosition', label: 'Symbol position', kind: 'segmented', visible: (p) => p.format === 'currency',
|
|
507
|
+
choices: [{ value: 'before', label: 'Before ($1.00)' }, { value: 'after', label: 'After (1.00 kr)' }],
|
|
508
|
+
},
|
|
509
|
+
{ key: 'precision', label: 'Precision', kind: 'choice', choices: precisionChoices(0, 8), visible: (p) => p.format !== 'integer' },
|
|
510
|
+
{ key: 'step', label: 'Spinner step', kind: 'number', min: 0, hint: 'What the editor\'s arrows and Up / Down add or take away; Shift for ten times as much.' },
|
|
511
|
+
SEPARATORS,
|
|
512
|
+
SHOW_THOUSANDS,
|
|
513
|
+
{ ...ABBREVIATION, visible: (p) => p.format !== 'percent' },
|
|
514
|
+
NEGATIVE,
|
|
515
|
+
{ key: 'showBar', label: 'Show as a progress bar', kind: 'checkbox', visible: (p) => p.format === 'percent' },
|
|
516
|
+
{ key: 'points', label: 'Values are 0 to 100 (not 0 to 1)', kind: 'checkbox', visible: (p) => p.format === 'percent' },
|
|
517
|
+
DEFAULT,
|
|
518
|
+
],
|
|
519
|
+
fill: (d, p) => fillNumber(d, p, numberKind('number', p)),
|
|
520
|
+
cell: PercentCell,
|
|
521
|
+
hasCell: (p) => p.format === 'percent' && !!p.showBar,
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
id: 'currency', label: 'Currency', description: 'An amount, with a currency symbol.', icon: ICON.currency, group: 'number', width: 120,
|
|
525
|
+
defaults: { symbol: '$', symbolPosition: 'before', precision: 2, separators: 'local', showThousands: true, abbreviation: 'none', allowNegative: true },
|
|
526
|
+
editAs: { type: 'number', props: { format: 'currency' } },
|
|
527
|
+
fill: (d, p) => fillNumber(d, p, 'currency'),
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
id: 'percent', label: 'Percent', description: 'A share, shown with a percent sign.', icon: ICON.percent, group: 'number', width: 110,
|
|
531
|
+
defaults: { precision: 0, separators: 'local', showThousands: true, allowNegative: true, points: false },
|
|
532
|
+
editAs: { type: 'number', props: { format: 'percent' } },
|
|
533
|
+
fill: (d, p) => fillNumber(d, p, 'percent'),
|
|
534
|
+
cell: PercentCell,
|
|
535
|
+
hasCell: (p) => !!p.showBar,
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
id: 'duration', label: 'Duration', description: 'A length of time, typed as 1:30 or 1h 30m.', icon: ICON.duration, group: 'number', width: 110,
|
|
539
|
+
defaults: { durationFormat: 'h:mm' },
|
|
540
|
+
properties: [{
|
|
541
|
+
key: 'durationFormat', label: 'Duration format', kind: 'choice',
|
|
542
|
+
choices: [
|
|
543
|
+
{ value: 'h:mm', label: 'h:mm' },
|
|
544
|
+
{ value: 'h:mm:ss', label: 'h:mm:ss' },
|
|
545
|
+
{ value: 'h:mm:ss.S', label: 'h:mm:ss.S' },
|
|
546
|
+
{ value: 'h:mm:ss.SS', label: 'h:mm:ss.SS' },
|
|
547
|
+
{ value: 'h:mm:ss.SSS', label: 'h:mm:ss.SSS' },
|
|
548
|
+
],
|
|
549
|
+
}],
|
|
550
|
+
fill: (d, p) => ({
|
|
551
|
+
...d,
|
|
552
|
+
// Seconds: a number column, typed as text ("1:30", "1h 30m").
|
|
553
|
+
cellDataType: d.cellDataType ?? 'number',
|
|
554
|
+
// The number editor in duration mode: "1:30" in the box, a minute a step.
|
|
555
|
+
editorType: d.editorType ?? 'fieldNumber',
|
|
556
|
+
formatter: d.formatter ?? ((ctx) => { const n = toNumber(ctx.value); return n == null ? '' : formatDuration(n, p.durationFormat); }),
|
|
557
|
+
// Pasted text ("1:30", "1h 30m") is read; the editor's seconds pass as is.
|
|
558
|
+
valueParser: d.valueParser ?? ((x) => typeof x.rawInput === 'string' ? (x.rawInput.trim() ? (parseDuration(x.rawInput) ?? x.oldValue) : null) : typeof x.newValue === 'number' ? x.newValue : parseDuration(x.newValue)),
|
|
559
|
+
}),
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
id: 'rating', label: 'Rating', description: 'One to ten stars, hearts, thumbs, flags or dots.', icon: ICON.rating, group: 'number', width: 130,
|
|
563
|
+
defaults: { max: 5, icon: 'star', color: '#fcb400' },
|
|
564
|
+
properties: [
|
|
565
|
+
{ key: 'icon', label: 'Icon', kind: 'icon', icons: RATING_ICONS },
|
|
566
|
+
{ key: 'color', label: 'Color', kind: 'color', palette: BRIGHT },
|
|
567
|
+
{ key: 'max', label: 'Maximum', kind: 'choice', choices: Array.from({ length: 10 }, (_, i) => ({ value: String(i + 1), label: String(i + 1) })) },
|
|
568
|
+
],
|
|
569
|
+
fill: (d) => ({ ...d, cellDataType: d.cellDataType ?? 'number', align: d.align ?? 'left', editorType: d.editorType ?? 'fieldRating' }),
|
|
570
|
+
cell: RatingCell,
|
|
571
|
+
},
|
|
572
|
+
// ---- computed and automatic ----------------------------------------------------
|
|
573
|
+
{
|
|
574
|
+
id: 'formula', label: 'Formula', description: 'A value computed from the row\'s other fields.', icon: ICON.formula, group: 'computed', width: 160,
|
|
575
|
+
defaults: { formula: '', result: 'auto' },
|
|
576
|
+
properties: [
|
|
577
|
+
{ key: 'formula', label: 'Formula', kind: 'formula' },
|
|
578
|
+
{
|
|
579
|
+
key: 'result', label: 'Format the result as', kind: 'choice',
|
|
580
|
+
choices: [{ value: 'auto', label: 'As computed' }, { value: 'number', label: 'Number' }, { value: 'currency', label: 'Currency' }, { value: 'percent', label: 'Percent' }, { value: 'date', label: 'Date' }],
|
|
581
|
+
},
|
|
582
|
+
{ key: 'precision', label: 'Precision', kind: 'choice', choices: precisionChoices(0, 8), visible: (p) => ['number', 'currency', 'percent'].includes(p.result) },
|
|
583
|
+
{ key: 'symbol', label: 'Currency symbol', kind: 'text', placeholder: '$', visible: (p) => p.result === 'currency' },
|
|
584
|
+
{ ...DATE_FORMAT, visible: (p) => p.result === 'date' },
|
|
585
|
+
],
|
|
586
|
+
fill: (d, p, ctx) => {
|
|
587
|
+
const names = fieldNames(ctx.columns);
|
|
588
|
+
const src = String(p.formula ?? '');
|
|
589
|
+
const self = String(d.field ?? d.id);
|
|
590
|
+
const ownNames = new Set([self.toLowerCase(), typeof d.header === 'string' ? d.header.trim().toLowerCase() : ''].filter(Boolean));
|
|
591
|
+
// Its own field is an error, as in the field editor: the cells show #ERROR.
|
|
592
|
+
const resolve = (name) => {
|
|
593
|
+
const n = name.trim().toLowerCase();
|
|
594
|
+
const key = names[n] ?? null;
|
|
595
|
+
if (key === self || (key == null && ownNames.has(n)))
|
|
596
|
+
throw selfReferenceError(name);
|
|
597
|
+
return key;
|
|
598
|
+
};
|
|
599
|
+
let fn = null;
|
|
600
|
+
try {
|
|
601
|
+
// A formula not written yet leaves its cells empty, not #ERROR.
|
|
602
|
+
fn = src.trim() ? compileFormula(src, resolve).fn : () => null;
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
fn = null;
|
|
606
|
+
}
|
|
607
|
+
// A field it names that is worked out too (a rollup, a count, another
|
|
608
|
+
// formula) is worked out when the formula reads it, not taken from the
|
|
609
|
+
// stored row, which has no value for it.
|
|
610
|
+
let computed = null;
|
|
611
|
+
const computedFns = () => {
|
|
612
|
+
if (computed)
|
|
613
|
+
return computed;
|
|
614
|
+
computed = new Map();
|
|
615
|
+
for (const c of ctx.columns) {
|
|
616
|
+
if (!COMPUTED_TYPES.has(String(c.fieldType)) || String(c.field ?? c.id) === self)
|
|
617
|
+
continue;
|
|
618
|
+
const filled = fillFieldColumn(c, ctx.columns);
|
|
619
|
+
if (typeof filled.fieldFn === 'function')
|
|
620
|
+
computed.set(String(c.field ?? c.id), filled.fieldFn);
|
|
621
|
+
}
|
|
622
|
+
return computed;
|
|
623
|
+
};
|
|
624
|
+
const withComputed = (row) => {
|
|
625
|
+
const fns = computedFns();
|
|
626
|
+
if (!fns.size || !row)
|
|
627
|
+
return row;
|
|
628
|
+
return new Proxy(row, {
|
|
629
|
+
get: (target, key) => {
|
|
630
|
+
const f = typeof key === 'string' ? fns.get(key) : undefined;
|
|
631
|
+
if (!f || readingDepth > 3)
|
|
632
|
+
return target[key];
|
|
633
|
+
readingDepth += 1;
|
|
634
|
+
try {
|
|
635
|
+
return f(target);
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
readingDepth -= 1;
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
};
|
|
643
|
+
return {
|
|
644
|
+
...d,
|
|
645
|
+
...readOnly(d),
|
|
646
|
+
fieldFn: (row) => (fn ? fn(withComputed(row)) : '#ERROR'),
|
|
647
|
+
align: d.align ?? (['number', 'currency', 'percent'].includes(p.result) ? 'right' : undefined),
|
|
648
|
+
formatter: d.formatter ?? ((c) => formatFormulaResult(c.value, p)),
|
|
649
|
+
};
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
id: 'createdTime', label: 'Created time', description: 'When the row was added. Set automatically.', icon: ICON.createdTime, group: 'computed', width: 170,
|
|
654
|
+
defaults: { dateFormat: 'local', time: true, timeFormat: '12hour' },
|
|
655
|
+
properties: [DATE_FORMAT, ...TIME_PROPS],
|
|
656
|
+
fill: (d, p) => ({ ...d, ...readOnly(d), formatter: d.formatter ?? dateText(p) }),
|
|
657
|
+
newRowValue: () => nowIso(),
|
|
658
|
+
onCreate: ({ rows }) => rows.map(() => nowIso()),
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
id: 'lastModifiedTime', label: 'Last modified time', description: 'When the row last changed. Set automatically.', icon: ICON.modifiedTime, group: 'computed', width: 170,
|
|
662
|
+
defaults: { dateFormat: 'local', time: true, timeFormat: '12hour', watch: 'all' },
|
|
663
|
+
properties: [...watchProps, DATE_FORMAT, ...TIME_PROPS],
|
|
664
|
+
fill: (d, p) => ({ ...d, ...readOnly(d), formatter: d.formatter ?? dateText(p) }),
|
|
665
|
+
newRowValue: () => nowIso(),
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
id: 'createdBy', label: 'Created by', description: 'Who added the row. Set automatically.', icon: ICON.createdBy, group: 'computed', width: 160,
|
|
669
|
+
fill: (d) => ({ ...d, ...readOnly(d), formatter: d.formatter ?? ((c) => userNames(c.value)) }),
|
|
670
|
+
newRowValue: () => getCurrentUser()?.id ?? null,
|
|
671
|
+
cell: UserCell,
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
id: 'lastModifiedBy', label: 'Last modified by', description: 'Who last changed the row. Set automatically.', icon: ICON.modifiedBy, group: 'computed', width: 160,
|
|
675
|
+
defaults: { watch: 'all' },
|
|
676
|
+
properties: watchProps,
|
|
677
|
+
fill: (d) => ({ ...d, ...readOnly(d), formatter: d.formatter ?? ((c) => userNames(c.value)) }),
|
|
678
|
+
newRowValue: () => getCurrentUser()?.id ?? null,
|
|
679
|
+
cell: UserCell,
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
id: 'autoNumber', label: 'Autonumber', description: 'A number every row gets, counting up. Set automatically.', icon: ICON.autoNumber, group: 'computed', width: 100,
|
|
683
|
+
fill: (d) => ({ ...d, ...readOnly(d), cellDataType: d.cellDataType ?? 'number' }),
|
|
684
|
+
newRowValue: ({ rows, field }) => rows.reduce((m, r) => Math.max(m, Number(r?.[field]) || 0), 0) + 1,
|
|
685
|
+
onCreate: ({ rows }) => rows.map((_, i) => i + 1),
|
|
686
|
+
},
|
|
687
|
+
// ---- relations -------------------------------------------------------------------
|
|
688
|
+
{
|
|
689
|
+
id: 'link', label: 'Link to records', description: 'Rows of another table this row points at.', icon: ICON.link, group: 'relation', width: 200,
|
|
690
|
+
defaults: { multiple: true },
|
|
691
|
+
properties: [
|
|
692
|
+
{ key: 'table', label: 'Table', kind: 'choice', choices: () => listLinkedTables().map((t) => ({ value: t.id, label: t.name })), hint: 'The table gets a field that links back to this one.' },
|
|
693
|
+
{ key: 'multiple', label: 'Allow linking to multiple records', kind: 'checkbox' },
|
|
694
|
+
],
|
|
695
|
+
fill: (d, p) => ({
|
|
696
|
+
...d,
|
|
697
|
+
editorType: d.editorType ?? 'fieldLink',
|
|
698
|
+
formatter: d.formatter ?? ((c) => {
|
|
699
|
+
const t = getLinkedTable(p.table);
|
|
700
|
+
return linkedRows(t, c.value).map((r) => String(r[t.primaryField] ?? '')).join(', ');
|
|
701
|
+
}),
|
|
702
|
+
valueParser: d.valueParser ?? linkParser(p),
|
|
703
|
+
}),
|
|
704
|
+
cell: LinkedRecordsCell,
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
id: 'lookup', label: 'Lookup', description: 'A field of the linked rows, shown here.', icon: ICON.lookup, group: 'relation', width: 180,
|
|
708
|
+
properties: [LINK_FIELD, LINKED_FIELD],
|
|
709
|
+
fill: (d, p, ctx) => {
|
|
710
|
+
const link = linkKeyOf(p, ctx);
|
|
711
|
+
// Each value shown as its own field shows it, when the linked table
|
|
712
|
+
// says what that field is (a date, an amount).
|
|
713
|
+
const text = (x) => {
|
|
714
|
+
const one = linkedFormatter(link, p.field);
|
|
715
|
+
return one ? String(one({ value: x }) ?? '') : String(x);
|
|
716
|
+
};
|
|
717
|
+
return {
|
|
718
|
+
...d,
|
|
719
|
+
...readOnly(d),
|
|
720
|
+
fieldFn: (row) => {
|
|
721
|
+
if (!link)
|
|
722
|
+
return [];
|
|
723
|
+
const read = readLinked(link.table, String(p.field));
|
|
724
|
+
return linkedRows(link.table, row?.[link.key]).map(read);
|
|
725
|
+
},
|
|
726
|
+
formatter: d.formatter ?? ((c) => (Array.isArray(c.value) ? c.value.filter((x) => x != null && x !== '').map(text).join(', ') : c.value == null ? '' : text(c.value))),
|
|
727
|
+
};
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
id: 'rollup', label: 'Rollup', description: 'A summary of a field over the linked rows (a sum, a count, a list).', icon: ICON.rollup, group: 'relation', width: 140,
|
|
732
|
+
defaults: { aggregation: 'SUM' },
|
|
733
|
+
properties: [
|
|
734
|
+
LINK_FIELD, LINKED_FIELD,
|
|
735
|
+
{ key: 'aggregation', label: 'Aggregation', kind: 'choice', choices: Object.keys(ROLLUPS).map((k) => ({ value: k, label: k })) },
|
|
736
|
+
{ key: 'precision', label: 'Precision', kind: 'choice', choices: precisionChoices(0, 8), visible: (p) => ['SUM', 'AVERAGE', 'MIN', 'MAX'].includes(p.aggregation) },
|
|
737
|
+
],
|
|
738
|
+
fill: (d, p, ctx) => {
|
|
739
|
+
const link = linkKeyOf(p, ctx);
|
|
740
|
+
const agg = ROLLUPS[String(p.aggregation)] ?? ROLLUPS.SUM;
|
|
741
|
+
const numeric = ['SUM', 'AVERAGE', 'MIN', 'MAX', 'COUNT', 'COUNTA', 'COUNTALL'].includes(String(p.aggregation));
|
|
742
|
+
// A sum, average, min or max of an amount is an amount: it shows as the
|
|
743
|
+
// linked field shows it ($482,000), unless a precision is set here.
|
|
744
|
+
const sameUnit = ['SUM', 'AVERAGE', 'MIN', 'MAX'].includes(String(p.aggregation)) && p.precision == null;
|
|
745
|
+
const show = (n) => {
|
|
746
|
+
const asSource = sameUnit ? linkedFormatter(link, p.field) : undefined;
|
|
747
|
+
return asSource ? String(asSource({ value: n }) ?? '') : formatNumber(n, { precision: digits(p, 0) });
|
|
748
|
+
};
|
|
749
|
+
return {
|
|
750
|
+
...d,
|
|
751
|
+
...readOnly(d),
|
|
752
|
+
// A number column, like count: the footer offers Sum and Average, the filter number operators.
|
|
753
|
+
...(numeric ? { cellDataType: d.cellDataType ?? 'number' } : {}),
|
|
754
|
+
align: d.align ?? (numeric ? 'right' : undefined),
|
|
755
|
+
fieldFn: (row) => {
|
|
756
|
+
if (!link)
|
|
757
|
+
return null;
|
|
758
|
+
const read = readLinked(link.table, String(p.field));
|
|
759
|
+
return agg(linkedRows(link.table, row?.[link.key]).map(read));
|
|
760
|
+
},
|
|
761
|
+
formatter: d.formatter ?? ((c) => (typeof c.value === 'number' ? show(c.value) : String(c.value ?? ''))),
|
|
762
|
+
};
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
id: 'count', label: 'Count', description: 'How many rows a link field points at.', icon: ICON.count, group: 'relation', width: 90,
|
|
767
|
+
properties: [LINK_FIELD],
|
|
768
|
+
fill: (d, p, ctx) => {
|
|
769
|
+
const link = linkKeyOf(p, ctx);
|
|
770
|
+
return { ...d, ...readOnly(d), cellDataType: d.cellDataType ?? 'number', fieldFn: (row) => (link ? linkedRows(link.table, row?.[link.key]).length : 0) };
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
];
|