@keenaioc/data-view 0.0.0-poc.1 → 0.2.0-alpha.11
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/CHANGELOG.md +138 -0
- package/DATA-PRIVACY.md +20 -0
- package/INTEGRATION.md +258 -0
- package/KNOWN-LIMITATIONS.md +63 -0
- package/LICENSE +41 -0
- package/README.md +73 -5
- package/dist/column-actions.d.ts +47 -0
- package/dist/column-actions.d.ts.map +1 -0
- package/dist/column-actions.js +92 -0
- package/dist/column-actions.js.map +1 -0
- package/dist/column-menu.d.ts +25 -0
- package/dist/column-menu.d.ts.map +1 -0
- package/dist/column-menu.js +64 -0
- package/dist/column-menu.js.map +1 -0
- package/dist/contracts.d.ts +33 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +84 -0
- package/dist/contracts.js.map +1 -0
- package/dist/data-view.d.ts +3 -0
- package/dist/data-view.d.ts.map +1 -0
- package/dist/data-view.js +883 -0
- package/dist/data-view.js.map +1 -0
- package/dist/engine.d.ts +35 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +315 -0
- package/dist/engine.js.map +1 -0
- package/dist/field-order.d.ts +8 -0
- package/dist/field-order.d.ts.map +1 -0
- package/dist/field-order.js +28 -0
- package/dist/field-order.js.map +1 -0
- package/dist/gantt-bar.d.ts +17 -0
- package/dist/gantt-bar.d.ts.map +1 -0
- package/dist/gantt-bar.js +142 -0
- package/dist/gantt-bar.js.map +1 -0
- package/dist/gantt-dependencies.d.ts +27 -0
- package/dist/gantt-dependencies.d.ts.map +1 -0
- package/dist/gantt-dependencies.js +218 -0
- package/dist/gantt-dependencies.js.map +1 -0
- package/dist/gantt-dependency-ui.d.ts +37 -0
- package/dist/gantt-dependency-ui.d.ts.map +1 -0
- package/dist/gantt-dependency-ui.js +79 -0
- package/dist/gantt-dependency-ui.js.map +1 -0
- package/dist/gantt-grid.d.ts +27 -0
- package/dist/gantt-grid.d.ts.map +1 -0
- package/dist/gantt-grid.js +72 -0
- package/dist/gantt-grid.js.map +1 -0
- package/dist/gantt-operations.d.ts +14 -0
- package/dist/gantt-operations.d.ts.map +1 -0
- package/dist/gantt-operations.js +103 -0
- package/dist/gantt-operations.js.map +1 -0
- package/dist/group-order.d.ts +8 -0
- package/dist/group-order.d.ts.map +1 -0
- package/dist/group-order.js +24 -0
- package/dist/group-order.js.map +1 -0
- package/dist/group-settings.d.ts +17 -0
- package/dist/group-settings.d.ts.map +1 -0
- package/dist/group-settings.js +44 -0
- package/dist/group-settings.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/operations.d.ts +11 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/operations.js +117 -0
- package/dist/operations.js.map +1 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +482 -0
- package/dist/registry.js.map +1 -0
- package/dist/remote-data.d.ts +19 -0
- package/dist/remote-data.d.ts.map +1 -0
- package/dist/remote-data.js +67 -0
- package/dist/remote-data.js.map +1 -0
- package/dist/row-move.d.ts +7 -0
- package/dist/row-move.d.ts.map +1 -0
- package/dist/row-move.js +27 -0
- package/dist/row-move.js.map +1 -0
- package/dist/schedule-view.d.ts +27 -0
- package/dist/schedule-view.d.ts.map +1 -0
- package/dist/schedule-view.js +122 -0
- package/dist/schedule-view.js.map +1 -0
- package/dist/schedule.d.ts +28 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +97 -0
- package/dist/schedule.js.map +1 -0
- package/dist/style.css +1028 -0
- package/dist/timeline.d.ts +25 -0
- package/dist/timeline.d.ts.map +1 -0
- package/dist/timeline.js +58 -0
- package/dist/timeline.js.map +1 -0
- package/dist/toolbar.d.ts +10 -0
- package/dist/toolbar.d.ts.map +1 -0
- package/dist/toolbar.js +31 -0
- package/dist/toolbar.js.map +1 -0
- package/dist/types.d.ts +474 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +22 -0
- package/dist/types.js.map +1 -0
- package/dist/use-remote-data.d.ts +18 -0
- package/dist/use-remote-data.d.ts.map +1 -0
- package/dist/use-remote-data.js +12 -0
- package/dist/use-remote-data.js.map +1 -0
- package/dist/use-row-move.d.ts +12 -0
- package/dist/use-row-move.d.ts.map +1 -0
- package/dist/use-row-move.js +151 -0
- package/dist/use-row-move.js.map +1 -0
- package/dist/v2-panels.d.ts +37 -0
- package/dist/v2-panels.d.ts.map +1 -0
- package/dist/v2-panels.js +118 -0
- package/dist/v2-panels.js.map +1 -0
- package/dist/view-state.d.ts +30 -0
- package/dist/view-state.d.ts.map +1 -0
- package/dist/view-state.js +94 -0
- package/dist/view-state.js.map +1 -0
- package/dist/view-workspace.d.ts +3 -0
- package/dist/view-workspace.d.ts.map +1 -0
- package/dist/view-workspace.js +95 -0
- package/dist/view-workspace.js.map +1 -0
- package/dist/virtual-list.d.ts +12 -0
- package/dist/virtual-list.d.ts.map +1 -0
- package/dist/virtual-list.js +46 -0
- package/dist/virtual-list.js.map +1 -0
- package/package.json +19 -16
- package/index.d.ts +0 -2
- package/index.js +0 -5
- package/react.d.ts +0 -2
- package/react.js +0 -8
- package/style.css +0 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { getFieldValue, validateValue } from "./engine.js";
|
|
2
|
+
import { getFieldTypeDefinition, parseClipboardValue } from "./registry.js";
|
|
3
|
+
export const MAX_BATCH_CELLS = 1000;
|
|
4
|
+
export function canWriteField(row, field, permissions) {
|
|
5
|
+
return !field.internal && !field.renderAction && getFieldTypeDefinition(field.type).editable && !field.readOnly && !field.system &&
|
|
6
|
+
field.canRead?.({ row, field }) !== false && field.canEdit?.({ row, field }) !== false && permissions?.canEditRow?.(row) !== false;
|
|
7
|
+
}
|
|
8
|
+
export function validateChanges(changes, permissions) {
|
|
9
|
+
if (!changes.length)
|
|
10
|
+
return "沒有可修改的儲存格";
|
|
11
|
+
if (changes.length > MAX_BATCH_CELLS)
|
|
12
|
+
return `一次最多 ${MAX_BATCH_CELLS} 格,請縮小範圍`;
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
for (const c of changes) {
|
|
15
|
+
const key = JSON.stringify([c.rowId, c.field.id]);
|
|
16
|
+
if (seen.has(key))
|
|
17
|
+
return "同一批次不可重複修改同一格";
|
|
18
|
+
seen.add(key);
|
|
19
|
+
if (!canWriteField(c.row, c.field, permissions))
|
|
20
|
+
return `${c.rowId}/${c.field.label}:無修改權限`;
|
|
21
|
+
const error = validateValue(c.nextValue, c.field, c.row);
|
|
22
|
+
if (error)
|
|
23
|
+
return `${c.rowId}/${c.field.label}:${error}`;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
/** Quoted TSV supports tabs/newlines inside text. No formulas are evaluated. */
|
|
28
|
+
export function parseTSV(text) {
|
|
29
|
+
if (text.length > 1_000_000)
|
|
30
|
+
throw new Error("貼上內容過大");
|
|
31
|
+
const result = [];
|
|
32
|
+
let row = [];
|
|
33
|
+
let cell = "";
|
|
34
|
+
let quoted = false;
|
|
35
|
+
const input = text.replace(/\r\n?/g, "\n");
|
|
36
|
+
for (let i = 0; i < input.length; i++) {
|
|
37
|
+
const c = input[i];
|
|
38
|
+
if (c === '"') {
|
|
39
|
+
if (quoted && input[i + 1] === '"') {
|
|
40
|
+
cell += '"';
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
else if (quoted || cell === "")
|
|
44
|
+
quoted = !quoted;
|
|
45
|
+
else
|
|
46
|
+
cell += c;
|
|
47
|
+
}
|
|
48
|
+
else if (!quoted && (c === "\t" || c === "\n")) {
|
|
49
|
+
row.push(cell);
|
|
50
|
+
cell = "";
|
|
51
|
+
if (c === "\n") {
|
|
52
|
+
result.push(row);
|
|
53
|
+
row = [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else
|
|
57
|
+
cell += c;
|
|
58
|
+
}
|
|
59
|
+
if (quoted)
|
|
60
|
+
throw new Error("貼上內容的引號未關閉");
|
|
61
|
+
if (cell || row.length || !input.endsWith("\n")) {
|
|
62
|
+
row.push(cell);
|
|
63
|
+
result.push(row);
|
|
64
|
+
}
|
|
65
|
+
if (result.reduce((n, r) => n + r.length, 0) > MAX_BATCH_CELLS)
|
|
66
|
+
throw new Error(`一次最多 ${MAX_BATCH_CELLS} 格`);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
export function encodeTSV(rows) {
|
|
70
|
+
return rows.map((r) => r.map((value) => {
|
|
71
|
+
// Spreadsheet-safe export: prefix formula-leading text, never execute it.
|
|
72
|
+
const safe = /^[=+@\-]/.test(value) && !/^-?\d+(\.\d+)?$/.test(value) ? `'${value}` : value;
|
|
73
|
+
return /["\t\n\r]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
|
|
74
|
+
}).join("\t")).join("\n");
|
|
75
|
+
}
|
|
76
|
+
export function createPasteChanges(text, rows, fields, getRowId, rowIndex, columnIndex) {
|
|
77
|
+
const grid = parseTSV(text);
|
|
78
|
+
if (rowIndex < 0 || columnIndex < 0 || rowIndex + grid.length > rows.length || grid.some((r) => columnIndex + r.length > fields.length))
|
|
79
|
+
throw new Error("貼上範圍超出目前顯示的資料列或欄位");
|
|
80
|
+
return grid.flatMap((r, offset) => r.map((textValue, x) => {
|
|
81
|
+
const row = rows[rowIndex + offset];
|
|
82
|
+
const field = fields[columnIndex + x];
|
|
83
|
+
return { row, rowId: getRowId(row), field, previousValue: getFieldValue(row, field), nextValue: parseClipboardValue(textValue, field) };
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
export function validateFieldChange(fields, change, permissions) {
|
|
87
|
+
if (permissions?.canManageFields !== true)
|
|
88
|
+
return "沒有管理欄位的權限";
|
|
89
|
+
const id = "fieldId" in change ? change.fieldId : change.action === "copy" ? change.sourceFieldId : null;
|
|
90
|
+
const existing = fields.find((f) => f.id === id);
|
|
91
|
+
if (change.action !== "add") {
|
|
92
|
+
if (!existing)
|
|
93
|
+
return "欄位不存在";
|
|
94
|
+
if (existing.internal || existing.renderAction || existing.system || !getFieldTypeDefinition(existing.type).deletable || existing.deletable === false)
|
|
95
|
+
return "系統或受保護欄位不能修改結構";
|
|
96
|
+
if (permissions?.canChangeField?.(existing, change.action) === false)
|
|
97
|
+
return "沒有變更此欄位的權限";
|
|
98
|
+
}
|
|
99
|
+
if (change.action === "rename")
|
|
100
|
+
return change.label.trim() ? null : "請填欄位名稱";
|
|
101
|
+
if (change.action === "delete")
|
|
102
|
+
return fields.length > 1 ? null : "至少保留一個欄位";
|
|
103
|
+
const field = change.field;
|
|
104
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_\-]{0,79}$/.test(field.id) || ["__proto__", "constructor", "prototype"].includes(field.id))
|
|
105
|
+
return "欄位 ID 格式不正確";
|
|
106
|
+
if (fields.some((f) => f.id === field.id))
|
|
107
|
+
return "欄位 ID 已存在";
|
|
108
|
+
if (!field.label.trim())
|
|
109
|
+
return "請填欄位名稱";
|
|
110
|
+
if (!getFieldTypeDefinition(field.type)?.editable || field.internal || field.system || field.readOnly || field.renderAction)
|
|
111
|
+
return "系統欄位只能由接入專案建立";
|
|
112
|
+
if (change.action === "copy" && existing?.canRead)
|
|
113
|
+
return "有逐列讀取限制的欄位,請由專案管理員處理複製";
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
export function newOperationId() { return globalThis.crypto?.randomUUID?.() ?? `hy-${Date.now()}-${Math.random().toString(36).slice(2)}`; }
|
|
117
|
+
//# sourceMappingURL=operations.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"operations.js","sourceRoot":"","sources":["../src/operations.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { FieldDefinition, FieldEditorProps, FieldType, FieldTypeDefinition, FilterOperator } from "./types.js";
|
|
3
|
+
export declare const FILTER_OPERATOR_LABELS: Record<FilterOperator, string>;
|
|
4
|
+
/** Single relations use the browser picker, which escapes clipped/virtualized cells. */
|
|
5
|
+
export declare function RelationSelect<Row>({ value, field, onCommit, disabled }: FieldEditorProps<Row> & {
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export declare function ReferenceEditor<Row>({ value, field, onCommit, onCancel }: FieldEditorProps<Row>): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export declare const FIELD_TYPE_REGISTRY: Record<FieldType, FieldTypeDefinition>;
|
|
10
|
+
export declare function getFieldTypeDefinition(type: FieldType): FieldTypeDefinition;
|
|
11
|
+
export declare function renderFieldValue<Row>(value: unknown, field: FieldDefinition<Row>, row: Row): ReactNode;
|
|
12
|
+
/** The registry owns clipboard conversion too, so every view uses the same types. */
|
|
13
|
+
export declare function formatClipboardValue<Row>(value: unknown, field: FieldDefinition<Row>): string;
|
|
14
|
+
export declare function parseClipboardValue<Row>(text: string, field: FieldDefinition<Row>): unknown;
|
|
15
|
+
export declare function formatReadableValue<Row>(value: unknown, field: FieldDefinition<Row>, row: Row): string;
|
|
16
|
+
export declare function formatReadableRow<Row>(row: Row, fields: FieldDefinition<Row>[]): string;
|
|
17
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA0D,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAC/F,OAAO,KAAK,EAEV,eAAe,EACf,gBAAgB,EAChB,SAAS,EACT,mBAAmB,EACnB,cAAc,EAIf,MAAM,YAAY,CAAC;AAOpB,eAAO,MAAM,sBAAsB,EAAE,MAAM,CAAC,cAAc,EAAE,MAAM,CAgBjE,CAAC;AAwNF,wFAAwF;AACxF,wBAAgB,cAAc,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAgB,EAAE,EAAE,gBAAgB,CAAC,GAAG,CAAC,GAAG;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,2CAe/H;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,gBAAgB,CAAC,GAAG,CAAC,2CAe/F;AAgCD,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,SAAS,EAAE,mBAAmB,CAsDtE,CAAC;AAcF,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,SAAS,uBAErD;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,eAAe,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,GAAG,GAAG,SAAS,CAMtG;AAED,qFAAqF;AACrF,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,eAAe,CAAC,GAAG,CAAC,GAAG,MAAM,CAI7F;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,CAAC,GAAG,CAAC,GAAG,OAAO,CAiC3F;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,eAAe,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,GAAG,GAAG,MAAM,CAMtG;AACD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,eAAe,CAAC,GAAG,CAAC,EAAE,UAE9E"}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState } from "react";
|
|
3
|
+
const TEXT_OPERATORS = ["contains", "notContains", "equals", "notEquals", "isEmpty", "isNotEmpty"];
|
|
4
|
+
const NUMBER_OPERATORS = ["equals", "notEquals", "greaterThan", "greaterThanOrEqual", "lessThan", "lessThanOrEqual", "isEmpty", "isNotEmpty"];
|
|
5
|
+
const DATE_OPERATORS = ["equals", "before", "after", "isEmpty", "isNotEmpty"];
|
|
6
|
+
const OPTION_OPERATORS = ["equals", "notEquals", "containsAny", "isEmpty", "isNotEmpty"];
|
|
7
|
+
export const FILTER_OPERATOR_LABELS = {
|
|
8
|
+
contains: "包含",
|
|
9
|
+
notContains: "不包含",
|
|
10
|
+
equals: "等於",
|
|
11
|
+
notEquals: "不等於",
|
|
12
|
+
greaterThan: "大於",
|
|
13
|
+
greaterThanOrEqual: "大於或等於",
|
|
14
|
+
lessThan: "小於",
|
|
15
|
+
lessThanOrEqual: "小於或等於",
|
|
16
|
+
before: "早於",
|
|
17
|
+
after: "晚於",
|
|
18
|
+
isEmpty: "為空白",
|
|
19
|
+
isNotEmpty: "不是空白",
|
|
20
|
+
isTrue: "已打勾",
|
|
21
|
+
isFalse: "未打勾",
|
|
22
|
+
containsAny: "包含其中一個",
|
|
23
|
+
};
|
|
24
|
+
function isEmpty(value) {
|
|
25
|
+
return value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
|
|
26
|
+
}
|
|
27
|
+
function textOf(value) {
|
|
28
|
+
if (isEmpty(value))
|
|
29
|
+
return "";
|
|
30
|
+
if (Array.isArray(value))
|
|
31
|
+
return value.map(textOf).join("、");
|
|
32
|
+
if (typeof value === "object") {
|
|
33
|
+
const record = value;
|
|
34
|
+
return String(record.label ?? record.name ?? record.id ?? "");
|
|
35
|
+
}
|
|
36
|
+
return String(value);
|
|
37
|
+
}
|
|
38
|
+
function compareText(left, right) {
|
|
39
|
+
return textOf(left).localeCompare(textOf(right), "zh-Hant", { numeric: true });
|
|
40
|
+
}
|
|
41
|
+
function compareNumber(left, right) {
|
|
42
|
+
const a = Number(left);
|
|
43
|
+
const b = Number(right);
|
|
44
|
+
if (!Number.isFinite(a) && !Number.isFinite(b))
|
|
45
|
+
return 0;
|
|
46
|
+
if (!Number.isFinite(a))
|
|
47
|
+
return 1;
|
|
48
|
+
if (!Number.isFinite(b))
|
|
49
|
+
return -1;
|
|
50
|
+
return a - b;
|
|
51
|
+
}
|
|
52
|
+
function compareDate(left, right) {
|
|
53
|
+
const a = Date.parse(String(left ?? ""));
|
|
54
|
+
const b = Date.parse(String(right ?? ""));
|
|
55
|
+
if (Number.isNaN(a) && Number.isNaN(b))
|
|
56
|
+
return 0;
|
|
57
|
+
if (Number.isNaN(a))
|
|
58
|
+
return 1;
|
|
59
|
+
if (Number.isNaN(b))
|
|
60
|
+
return -1;
|
|
61
|
+
return a - b;
|
|
62
|
+
}
|
|
63
|
+
function commonFilter(value, rule) {
|
|
64
|
+
const left = textOf(value).toLocaleLowerCase("zh-Hant");
|
|
65
|
+
const right = textOf(rule.value).toLocaleLowerCase("zh-Hant");
|
|
66
|
+
if (rule.operator === "isEmpty")
|
|
67
|
+
return isEmpty(value);
|
|
68
|
+
if (rule.operator === "isNotEmpty")
|
|
69
|
+
return !isEmpty(value);
|
|
70
|
+
if (rule.operator === "contains")
|
|
71
|
+
return left.includes(right);
|
|
72
|
+
if (rule.operator === "notContains")
|
|
73
|
+
return !left.includes(right);
|
|
74
|
+
if (rule.operator === "notEquals")
|
|
75
|
+
return left !== right;
|
|
76
|
+
if (rule.operator === "containsAny") {
|
|
77
|
+
const values = Array.isArray(value) ? value.map((item) => textOf(item).toLocaleLowerCase("zh-Hant")) : [left];
|
|
78
|
+
const targets = Array.isArray(rule.value) ? rule.value.map((item) => textOf(item).toLocaleLowerCase("zh-Hant")) : [right];
|
|
79
|
+
return targets.some((target) => values.includes(target));
|
|
80
|
+
}
|
|
81
|
+
return left === right;
|
|
82
|
+
}
|
|
83
|
+
/** References compare permanent IDs, never translated or duplicate display names. */
|
|
84
|
+
function referenceFilter(value, rule) {
|
|
85
|
+
if (rule.operator === "isEmpty" || rule.operator === "isNotEmpty")
|
|
86
|
+
return commonFilter(value, rule);
|
|
87
|
+
const ids = (v) => [...new Set((Array.isArray(v) ? v : isEmpty(v) ? [] : [v]).map((x) => typeof x === "object" && x ? String(x.id) : String(x)))].sort();
|
|
88
|
+
const values = ids(value), targets = ids(rule.value);
|
|
89
|
+
if (rule.operator === "containsAny")
|
|
90
|
+
return targets.some((id) => values.includes(id));
|
|
91
|
+
const equal = JSON.stringify(values) === JSON.stringify(targets);
|
|
92
|
+
return rule.operator === "notEquals" ? !equal : equal;
|
|
93
|
+
}
|
|
94
|
+
function numberFilter(value, rule) {
|
|
95
|
+
if (rule.operator === "isEmpty" || rule.operator === "isNotEmpty")
|
|
96
|
+
return commonFilter(value, rule);
|
|
97
|
+
if (isEmpty(value) || isEmpty(rule.value))
|
|
98
|
+
return false;
|
|
99
|
+
const left = Number(value);
|
|
100
|
+
const right = Number(rule.value);
|
|
101
|
+
if (!Number.isFinite(left) || !Number.isFinite(right))
|
|
102
|
+
return false;
|
|
103
|
+
if (rule.operator === "notEquals")
|
|
104
|
+
return left !== right;
|
|
105
|
+
if (rule.operator === "greaterThan")
|
|
106
|
+
return left > right;
|
|
107
|
+
if (rule.operator === "greaterThanOrEqual")
|
|
108
|
+
return left >= right;
|
|
109
|
+
if (rule.operator === "lessThan")
|
|
110
|
+
return left < right;
|
|
111
|
+
if (rule.operator === "lessThanOrEqual")
|
|
112
|
+
return left <= right;
|
|
113
|
+
return left === right;
|
|
114
|
+
}
|
|
115
|
+
function dateFilter(value, rule) {
|
|
116
|
+
if (rule.operator === "isEmpty" || rule.operator === "isNotEmpty")
|
|
117
|
+
return commonFilter(value, rule);
|
|
118
|
+
const left = Date.parse(String(value ?? ""));
|
|
119
|
+
const right = Date.parse(String(rule.value ?? ""));
|
|
120
|
+
if (Number.isNaN(left) || Number.isNaN(right))
|
|
121
|
+
return false;
|
|
122
|
+
if (rule.operator === "before")
|
|
123
|
+
return left < right;
|
|
124
|
+
if (rule.operator === "after")
|
|
125
|
+
return left > right;
|
|
126
|
+
return new Date(left).toISOString().slice(0, 10) === new Date(right).toISOString().slice(0, 10);
|
|
127
|
+
}
|
|
128
|
+
function group(value, field) {
|
|
129
|
+
if (isEmpty(value))
|
|
130
|
+
return { key: "__empty__", label: "空白" };
|
|
131
|
+
const items = Array.isArray(value) ? value : [value];
|
|
132
|
+
const ids = items.map((v) => typeof v === "object" && v ? String(v.id ?? textOf(v)) : String(v));
|
|
133
|
+
const normalized = ids.length === 1 ? ids[0] : JSON.stringify([...new Set(ids)].sort());
|
|
134
|
+
const option = field.options?.find((item) => item.value === String(value));
|
|
135
|
+
return { key: encodeURIComponent(normalized), label: option?.label ?? textOf(Array.isArray(value) ? [...value].sort((a, b) => textOf(a).localeCompare(textOf(b), "zh-Hant")) : value) };
|
|
136
|
+
}
|
|
137
|
+
function optionFor(value, field) {
|
|
138
|
+
return field.options?.find((item) => item.value === String(value));
|
|
139
|
+
}
|
|
140
|
+
function renderOption(value, field) {
|
|
141
|
+
const option = optionFor(value, field);
|
|
142
|
+
const label = option?.label ?? textOf(value);
|
|
143
|
+
if (!label)
|
|
144
|
+
return _jsx("span", { className: "hy-dv-empty", children: "\u2014" });
|
|
145
|
+
return _jsx("span", { className: "hy-dv-tag", style: { backgroundColor: option?.color, color: option?.textColor }, children: label });
|
|
146
|
+
}
|
|
147
|
+
function renderMultiOptions(value, field) {
|
|
148
|
+
const values = Array.isArray(value) ? value : isEmpty(value) ? [] : [value];
|
|
149
|
+
if (values.length === 0)
|
|
150
|
+
return _jsx("span", { className: "hy-dv-empty", children: "\u2014" });
|
|
151
|
+
return _jsx("span", { className: "hy-dv-tags", children: values.map((item, index) => _jsx("span", { children: renderOption(item, field) }, `${textOf(item)}-${index}`)) });
|
|
152
|
+
}
|
|
153
|
+
function renderPeople(value, field) {
|
|
154
|
+
const values = (Array.isArray(value) ? value : isEmpty(value) ? [] : [value]).map((item) => typeof item === "object" && item ? item : { id: String(item), label: String(item) });
|
|
155
|
+
if (!values.length)
|
|
156
|
+
return null;
|
|
157
|
+
const label = values.map((v) => v.label).join("、");
|
|
158
|
+
if (field.appearance?.people === "combined") {
|
|
159
|
+
const option = values.length === 1 ? field.options?.find((o) => o.value === values[0].id) : undefined;
|
|
160
|
+
return _jsx("span", { className: "hy-dv-person", title: label, style: { backgroundColor: option?.color, color: option?.textColor }, children: label });
|
|
161
|
+
}
|
|
162
|
+
return _jsx("span", { className: "hy-dv-tags", title: label, children: values.map((person) => {
|
|
163
|
+
const option = field.options?.find((o) => o.value === person.id);
|
|
164
|
+
return _jsxs("span", { className: "hy-dv-person", style: { backgroundColor: option?.color, color: option?.textColor }, children: [field.appearance?.avatar === true && _jsx("span", { className: "hy-dv-avatar", children: person.label.slice(0, 1) }), _jsx("span", { className: "hy-dv-pill-label", children: person.label })] }, person.id);
|
|
165
|
+
}) });
|
|
166
|
+
}
|
|
167
|
+
function renderRelations(value, field) {
|
|
168
|
+
if (field.appearance?.relation === "text")
|
|
169
|
+
return textOf(value);
|
|
170
|
+
const values = Array.isArray(value) ? value : isEmpty(value) ? [] : [value];
|
|
171
|
+
return _jsx("span", { className: "hy-dv-tags", children: values.map((item, index) => {
|
|
172
|
+
const relation = typeof item === "object" ? item : { id: String(item), label: String(item) };
|
|
173
|
+
return _jsx("span", { className: "hy-dv-relation", children: relation.label }, `${relation.id}-${index}`);
|
|
174
|
+
}) });
|
|
175
|
+
}
|
|
176
|
+
function renderAttachments(value) {
|
|
177
|
+
const values = Array.isArray(value) ? value : isEmpty(value) ? [] : [value];
|
|
178
|
+
if (values.length === 0)
|
|
179
|
+
return _jsx("span", { className: "hy-dv-empty", children: "\u2014" });
|
|
180
|
+
return _jsx("span", { className: "hy-dv-attachments", children: values.map((item, index) => {
|
|
181
|
+
const file = typeof item === "object" ? item : { id: String(item), name: String(item) };
|
|
182
|
+
return file.url && /^(https?:\/\/|\/(?!\/))/.test(file.url) ? _jsx("a", { href: file.url, target: "_blank", rel: "noreferrer", children: file.name }, `${file.id}-${index}`) : _jsx("span", { children: file.name }, `${file.id}-${index}`);
|
|
183
|
+
}) });
|
|
184
|
+
}
|
|
185
|
+
function formatNumber(value, field, style) {
|
|
186
|
+
if (isEmpty(value) || !Number.isFinite(Number(value)))
|
|
187
|
+
return _jsx("span", { className: "hy-dv-empty", children: "\u2014" });
|
|
188
|
+
const number = Number(value);
|
|
189
|
+
const precision = field.precision ?? (style === "currency" ? 0 : 2);
|
|
190
|
+
if (style === "currency")
|
|
191
|
+
return new Intl.NumberFormat("zh-TW", { style: "currency", currency: field.currency ?? "TWD", maximumFractionDigits: precision }).format(number);
|
|
192
|
+
if (style === "percent")
|
|
193
|
+
return new Intl.NumberFormat("zh-TW", { style: "percent", maximumFractionDigits: precision }).format(number);
|
|
194
|
+
return new Intl.NumberFormat("zh-TW", { maximumFractionDigits: precision }).format(number);
|
|
195
|
+
}
|
|
196
|
+
function TextEditor({ value, field, onCommit, onCancel }) {
|
|
197
|
+
const [draft, setDraft] = useState(textOf(value));
|
|
198
|
+
const done = useRef(false);
|
|
199
|
+
const commit = () => { if (!done.current) {
|
|
200
|
+
done.current = true;
|
|
201
|
+
onCommit(draft);
|
|
202
|
+
} };
|
|
203
|
+
const multiline = field.type === "multiLineText";
|
|
204
|
+
function keyDown(event) {
|
|
205
|
+
if (event.key === "Escape") {
|
|
206
|
+
done.current = true;
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
onCancel();
|
|
209
|
+
}
|
|
210
|
+
if (event.key === "Enter" && (!multiline || event.metaKey || event.ctrlKey)) {
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
commit();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const common = { autoFocus: true, value: draft, placeholder: field.placeholder, onChange: (event) => setDraft(event.target.value), onKeyDown: keyDown };
|
|
216
|
+
return multiline
|
|
217
|
+
? _jsx("textarea", { className: "hy-dv-editor hy-dv-textarea", ...common, onBlur: commit })
|
|
218
|
+
: _jsx("input", { className: "hy-dv-editor", ...common, onBlur: commit });
|
|
219
|
+
}
|
|
220
|
+
function NumberEditor({ value, field, onCommit, onCancel }) {
|
|
221
|
+
const [draft, setDraft] = useState(isEmpty(value) ? "" : String(value));
|
|
222
|
+
const done = useRef(false);
|
|
223
|
+
function commit() { if (!done.current) {
|
|
224
|
+
done.current = true;
|
|
225
|
+
onCommit(draft === "" ? null : Number(draft));
|
|
226
|
+
} }
|
|
227
|
+
return _jsx("input", { autoFocus: true, className: "hy-dv-editor", type: "number", step: field.type === "percentage" ? "0.01" : "any", value: draft, onChange: (event) => setDraft(event.target.value), onBlur: commit, onKeyDown: (event) => {
|
|
228
|
+
if (event.key === "Escape") {
|
|
229
|
+
done.current = true;
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
onCancel();
|
|
232
|
+
}
|
|
233
|
+
if (event.key === "Enter")
|
|
234
|
+
commit();
|
|
235
|
+
} });
|
|
236
|
+
}
|
|
237
|
+
function CheckboxEditor({ value, onCommit }) {
|
|
238
|
+
return _jsx("input", { autoFocus: true, className: "hy-dv-check", type: "checkbox", checked: Boolean(value), onChange: (event) => onCommit(event.target.checked) });
|
|
239
|
+
}
|
|
240
|
+
function DateEditor({ value, field, onCommit, onCancel }) {
|
|
241
|
+
const type = field.type === "date" ? "date" : "datetime-local";
|
|
242
|
+
const initial = isEmpty(value) ? "" : String(value).slice(0, type === "date" ? 10 : 16);
|
|
243
|
+
const [draft, setDraft] = useState(initial);
|
|
244
|
+
const done = useRef(false);
|
|
245
|
+
const commit = () => { if (!done.current) {
|
|
246
|
+
done.current = true;
|
|
247
|
+
onCommit(draft || null);
|
|
248
|
+
} };
|
|
249
|
+
return _jsx("input", { autoFocus: true, className: "hy-dv-editor", type: type, value: draft, onChange: (event) => setDraft(event.target.value), onBlur: commit, onKeyDown: (event) => {
|
|
250
|
+
if (event.key === "Escape") {
|
|
251
|
+
done.current = true;
|
|
252
|
+
event.preventDefault();
|
|
253
|
+
onCancel();
|
|
254
|
+
}
|
|
255
|
+
if (event.key === "Enter")
|
|
256
|
+
commit();
|
|
257
|
+
} });
|
|
258
|
+
}
|
|
259
|
+
function SelectEditor({ value, field, onCommit, onCancel }) {
|
|
260
|
+
return _jsxs("select", { autoFocus: true, className: "hy-dv-editor", value: isEmpty(value) ? "" : String(value), onChange: (event) => onCommit(event.target.value || null), onBlur: onCancel, children: [_jsx("option", { value: "", children: "\u7A7A\u767D" }), (field.options ?? []).map((option) => _jsx("option", { value: option.value, children: option.label }, option.value))] });
|
|
261
|
+
}
|
|
262
|
+
function MultiSelectEditor({ value, field, onCommit, onCancel }) {
|
|
263
|
+
const [draft, setDraft] = useState((Array.isArray(value) ? value : []).map(String));
|
|
264
|
+
return _jsx("select", { autoFocus: true, multiple: true, className: "hy-dv-editor hy-dv-multi-select", value: draft, onChange: (event) => setDraft([...event.target.selectedOptions].map((option) => option.value)), onBlur: () => onCommit(draft), onKeyDown: (event) => {
|
|
265
|
+
if (event.key === "Escape")
|
|
266
|
+
onCancel();
|
|
267
|
+
if (event.key === "Enter" && (event.metaKey || event.ctrlKey))
|
|
268
|
+
onCommit(draft);
|
|
269
|
+
}, children: (field.options ?? []).map((option) => _jsx("option", { value: option.value, children: option.label }, option.value)) });
|
|
270
|
+
}
|
|
271
|
+
/** Single relations use the browser picker, which escapes clipped/virtualized cells. */
|
|
272
|
+
export function RelationSelect({ value, field, onCommit, disabled = false }) {
|
|
273
|
+
const current = (Array.isArray(value) ? value[0] : value);
|
|
274
|
+
const selected = typeof current === "object" ? current?.id ?? "" : String(current ?? "");
|
|
275
|
+
const options = field.options ?? [];
|
|
276
|
+
const unavailable = selected && !options.some((option) => option.value === selected);
|
|
277
|
+
return _jsxs("select", { className: "hy-dv-relation-select", "aria-label": `選擇${field.label}`, disabled: disabled, value: selected, onChange: (event) => {
|
|
278
|
+
const id = event.target.value;
|
|
279
|
+
if (!id) {
|
|
280
|
+
onCommit(null);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const option = options.find((item) => item.value === id);
|
|
284
|
+
if (option)
|
|
285
|
+
onCommit({ id: option.value, label: option.label });
|
|
286
|
+
}, children: [_jsx("option", { value: "", children: field.placeholder ?? "未選擇" }), unavailable && _jsx("option", { value: selected, disabled: true, children: typeof current === "object" ? current?.label || "目前關聯(無可選項目)" : "目前關聯(無可選項目)" }), options.map((option) => _jsx("option", { value: option.value, children: option.label }, option.value))] });
|
|
287
|
+
}
|
|
288
|
+
export function ReferenceEditor({ value, field, onCommit, onCancel }) {
|
|
289
|
+
const multiple = field.type === "person" && field.multiple !== false;
|
|
290
|
+
const original = (Array.isArray(value) ? value : isEmpty(value) ? [] : [value]);
|
|
291
|
+
const [draft, setDraft] = useState(original.map((v) => v.id));
|
|
292
|
+
const [query, setQuery] = useState("");
|
|
293
|
+
function commit(ids) {
|
|
294
|
+
const values = ids.map((id) => ({ id, label: field.options?.find((o) => o.value === id)?.label ?? original.find((v) => v.id === id)?.label ?? "未命名" }));
|
|
295
|
+
onCommit(field.type === "person" ? values : values[0] ?? null);
|
|
296
|
+
}
|
|
297
|
+
const options = (field.options ?? []).filter((o) => o.label.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
|
|
298
|
+
return _jsxs("div", { className: "hy-dv-reference-editor", onKeyDown: (e) => { if (e.key === "Escape") {
|
|
299
|
+
e.stopPropagation();
|
|
300
|
+
onCancel();
|
|
301
|
+
} }, children: [_jsx("input", { autoFocus: true, "aria-label": `搜尋${field.label}`, placeholder: "\u641C\u5C0B", value: query, onChange: (e) => setQuery(e.target.value) }), _jsx("div", { role: "group", "aria-label": field.label, children: options.map((option) => _jsxs("button", { type: "button", "aria-pressed": draft.includes(option.value), onClick: () => { const ids = multiple ? draft.includes(option.value) ? draft.filter((id) => id !== option.value) : [...draft, option.value] : [option.value]; setDraft(ids); if (!multiple)
|
|
302
|
+
commit(ids); }, children: [option.label, draft.includes(option.value) ? " ✓" : ""] }, option.value)) }), _jsxs("footer", { children: [_jsx("button", { type: "button", onClick: () => commit([]), children: "\u7A7A\u767D" }), _jsx("button", { type: "button", onClick: onCancel, children: "\u53D6\u6D88" }), multiple && _jsx("button", { type: "button", onClick: () => commit(draft), children: "\u78BA\u8A8D" })] })] });
|
|
303
|
+
}
|
|
304
|
+
function AttachmentEditor({ onRequestAttachment, onCancel }) {
|
|
305
|
+
return _jsxs("label", { className: "hy-dv-upload", children: ["\u9078\u64C7\u9644\u4EF6", _jsx("input", { autoFocus: true, type: "file", multiple: true, onChange: (event) => {
|
|
306
|
+
if (event.target.files?.length)
|
|
307
|
+
onRequestAttachment?.([...event.target.files]);
|
|
308
|
+
onCancel();
|
|
309
|
+
} })] });
|
|
310
|
+
}
|
|
311
|
+
const textSpec = (type, label, Editor = TextEditor) => ({
|
|
312
|
+
type, label, editable: true, deletable: true, operators: TEXT_OPERATORS, isEmpty,
|
|
313
|
+
validate: (value) => value === null || value === undefined || typeof value === "string" ? null : "請輸入文字",
|
|
314
|
+
compare: compareText, filter: commonFilter, group,
|
|
315
|
+
render: (value) => isEmpty(value) ? _jsx("span", { className: "hy-dv-empty", children: "\u2014" }) : _jsx("span", { className: type === "multiLineText" ? "hy-dv-prewrap" : undefined, children: textOf(value) }),
|
|
316
|
+
Editor,
|
|
317
|
+
});
|
|
318
|
+
const numberSpec = (type, label, style) => ({
|
|
319
|
+
type, label, editable: true, deletable: true, operators: NUMBER_OPERATORS, isEmpty,
|
|
320
|
+
validate: (value) => isEmpty(value) || (typeof value === "number" && Number.isFinite(value)) ? null : "請輸入有效數字",
|
|
321
|
+
compare: compareNumber, filter: numberFilter, group,
|
|
322
|
+
render: (value, field) => formatNumber(value, field, style), Editor: NumberEditor,
|
|
323
|
+
});
|
|
324
|
+
const dateSpec = (type, label, editable = true) => ({
|
|
325
|
+
type, label, editable, deletable: editable, operators: DATE_OPERATORS, isEmpty,
|
|
326
|
+
validate: (value) => isEmpty(value) || validDateValue(value, type === "date") ? null : "請輸入有效日期",
|
|
327
|
+
compare: compareDate, filter: dateFilter, group,
|
|
328
|
+
render: (value) => isEmpty(value) ? _jsx("span", { className: "hy-dv-empty", children: "\u2014" }) : !validDateValue(value, type === "date") ? _jsx("span", { className: "hy-dv-no-access", children: "\u65E5\u671F\u683C\u5F0F\u932F\u8AA4" }) : new Intl.DateTimeFormat("zh-TW", type === "date" ? { year: "numeric", month: "2-digit", day: "2-digit", timeZone: "UTC" } : { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(String(value))),
|
|
329
|
+
Editor: editable ? DateEditor : undefined,
|
|
330
|
+
});
|
|
331
|
+
export const FIELD_TYPE_REGISTRY = {
|
|
332
|
+
singleLineText: textSpec("singleLineText", "單行文字"),
|
|
333
|
+
multiLineText: textSpec("multiLineText", "多行文字"),
|
|
334
|
+
number: numberSpec("number", "數字"),
|
|
335
|
+
currency: numberSpec("currency", "金額", "currency"),
|
|
336
|
+
percentage: numberSpec("percentage", "百分比", "percent"),
|
|
337
|
+
checkbox: {
|
|
338
|
+
type: "checkbox", label: "打勾框", editable: true, deletable: true, operators: ["isTrue", "isFalse"], isEmpty: () => false,
|
|
339
|
+
validate: (value) => typeof value === "boolean" ? null : "請使用打勾或取消",
|
|
340
|
+
compare: (left, right) => Number(Boolean(left)) - Number(Boolean(right)),
|
|
341
|
+
filter: (value, rule) => rule.operator === "isTrue" ? Boolean(value) : !Boolean(value),
|
|
342
|
+
group: (value) => ({ key: value ? "checked" : "unchecked", label: value ? "已打勾" : "未打勾" }),
|
|
343
|
+
render: (value) => _jsx("span", { "aria-label": value ? "已打勾" : "未打勾", className: `hy-dv-checkbox ${value ? "is-checked" : ""}`, children: value ? "✓" : "" }),
|
|
344
|
+
toggleValue: (value) => !Boolean(value),
|
|
345
|
+
Editor: CheckboxEditor,
|
|
346
|
+
},
|
|
347
|
+
date: dateSpec("date", "日期"),
|
|
348
|
+
dateTime: dateSpec("dateTime", "日期+時間"),
|
|
349
|
+
singleSelect: {
|
|
350
|
+
type: "singleSelect", label: "單選項", editable: true, deletable: true, operators: OPTION_OPERATORS, isEmpty,
|
|
351
|
+
validate: (value, field) => isEmpty(value) || field.options?.some((option) => option.value === String(value)) ? null : "選項不存在",
|
|
352
|
+
compare: (left, right, field) => {
|
|
353
|
+
const order = field.options?.map((option) => option.value) ?? [];
|
|
354
|
+
const a = order.indexOf(String(left));
|
|
355
|
+
const b = order.indexOf(String(right));
|
|
356
|
+
return a >= 0 && b >= 0 ? a - b : compareText(left, right);
|
|
357
|
+
},
|
|
358
|
+
filter: commonFilter, group, render: renderOption, Editor: SelectEditor,
|
|
359
|
+
},
|
|
360
|
+
multiSelect: {
|
|
361
|
+
type: "multiSelect", label: "多選項", editable: true, deletable: true, operators: OPTION_OPERATORS, isEmpty,
|
|
362
|
+
validate: (value, field) => isEmpty(value) || (Array.isArray(value) && value.every((v) => typeof v === "string" && field.options?.some((o) => o.value === v))) ? null : "多選值包含不存在的選項",
|
|
363
|
+
compare: compareText, filter: commonFilter, group, render: renderMultiOptions, Editor: MultiSelectEditor,
|
|
364
|
+
},
|
|
365
|
+
person: {
|
|
366
|
+
type: "person", label: "人員", editable: true, deletable: true, operators: OPTION_OPERATORS, isEmpty,
|
|
367
|
+
validate: (value, field) => isEmpty(value) || (Array.isArray(value) ? value : [value]).every((v) => validReference(v, field)) ? null : "人員格式或 ID 不正確",
|
|
368
|
+
compare: compareText, filter: referenceFilter, group, render: renderPeople, Editor: ReferenceEditor,
|
|
369
|
+
},
|
|
370
|
+
relation: {
|
|
371
|
+
type: "relation", label: "關聯資料", editable: true, deletable: true, operators: OPTION_OPERATORS, isEmpty,
|
|
372
|
+
validate: (value, field) => isEmpty(value) || validReference(value, field) ? null : "關聯資料格式或 ID 不正確",
|
|
373
|
+
compare: compareText, filter: referenceFilter, group, render: renderRelations, Editor: ReferenceEditor,
|
|
374
|
+
},
|
|
375
|
+
attachment: {
|
|
376
|
+
type: "attachment", label: "附件", editable: true, deletable: true, operators: ["isEmpty", "isNotEmpty"], isEmpty,
|
|
377
|
+
validate: (value) => isEmpty(value) || Array.isArray(value) ? null : "附件格式不正確",
|
|
378
|
+
compare: compareText, filter: commonFilter, group, render: renderAttachments, Editor: AttachmentEditor,
|
|
379
|
+
},
|
|
380
|
+
createdAt: dateSpec("createdAt", "建立時間", false),
|
|
381
|
+
updatedAt: dateSpec("updatedAt", "最後更新時間", false),
|
|
382
|
+
createdBy: { ...textSpec("createdBy", "建立者"), editable: false, deletable: false, Editor: undefined, filter: referenceFilter, render: renderPeople },
|
|
383
|
+
updatedBy: { ...textSpec("updatedBy", "最後修改者"), editable: false, deletable: false, Editor: undefined, filter: referenceFilter, render: renderPeople },
|
|
384
|
+
autoNumber: { ...numberSpec("autoNumber", "自動編號"), editable: false, deletable: false, Editor: undefined },
|
|
385
|
+
systemReadOnly: { ...textSpec("systemReadOnly", "唯讀系統欄位"), editable: false, deletable: false, Editor: undefined },
|
|
386
|
+
};
|
|
387
|
+
function validReference(value, field) {
|
|
388
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
389
|
+
return false;
|
|
390
|
+
const record = value;
|
|
391
|
+
return typeof record.id === "string" && record.id.length > 0 && typeof record.label === "string" &&
|
|
392
|
+
(!field.options || field.options.some((o) => o.value === record.id));
|
|
393
|
+
}
|
|
394
|
+
function validDateValue(value, dateOnly) {
|
|
395
|
+
if (typeof value !== "string" || !(dateOnly ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,3})?)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)?$/).test(value))
|
|
396
|
+
return false;
|
|
397
|
+
const day = value.slice(0, 10);
|
|
398
|
+
const date = Date.parse(`${day}T00:00:00Z`);
|
|
399
|
+
return Number.isFinite(date) && new Date(date).toISOString().slice(0, 10) === day && Number.isFinite(Date.parse(value));
|
|
400
|
+
}
|
|
401
|
+
export function getFieldTypeDefinition(type) {
|
|
402
|
+
return FIELD_TYPE_REGISTRY[type];
|
|
403
|
+
}
|
|
404
|
+
export function renderFieldValue(value, field, row) {
|
|
405
|
+
if (field.internal || field.canRead?.({ row, field }) === false)
|
|
406
|
+
return null;
|
|
407
|
+
if (field.render)
|
|
408
|
+
return field.render(value, row);
|
|
409
|
+
if (isEmpty(value) && field.type !== "checkbox" && field.appearance?.empty !== "dash")
|
|
410
|
+
return null;
|
|
411
|
+
if (field.appearance?.systemValue || ["createdBy", "updatedBy", "systemReadOnly"].includes(field.type))
|
|
412
|
+
return _jsx("span", { className: "hy-dv-system-value", title: textOf(value), children: textOf(value) });
|
|
413
|
+
return getFieldTypeDefinition(field.type).render(value, field);
|
|
414
|
+
}
|
|
415
|
+
/** The registry owns clipboard conversion too, so every view uses the same types. */
|
|
416
|
+
export function formatClipboardValue(value, field) {
|
|
417
|
+
if (isEmpty(value))
|
|
418
|
+
return "";
|
|
419
|
+
if (["multiSelect", "person", "relation", "attachment"].includes(field.type))
|
|
420
|
+
return JSON.stringify(value);
|
|
421
|
+
return String(value);
|
|
422
|
+
}
|
|
423
|
+
export function parseClipboardValue(text, field) {
|
|
424
|
+
const value = text.trim();
|
|
425
|
+
if (field.type === "attachment")
|
|
426
|
+
throw new Error("附件請使用上傳介面,不能透過貼上修改");
|
|
427
|
+
if (["number", "currency", "percentage"].includes(field.type)) {
|
|
428
|
+
if (!value)
|
|
429
|
+
return null;
|
|
430
|
+
const percent = field.type === "percentage" && value.endsWith("%");
|
|
431
|
+
const number = Number(percent ? value.slice(0, -1) : value);
|
|
432
|
+
if (!Number.isFinite(number))
|
|
433
|
+
throw new Error(`${field.label}需要有效數字`);
|
|
434
|
+
return percent ? number / 100 : number;
|
|
435
|
+
}
|
|
436
|
+
if (field.type === "checkbox") {
|
|
437
|
+
if (["true", "1", "是", "已打勾"].includes(value.toLowerCase()))
|
|
438
|
+
return true;
|
|
439
|
+
if (["", "false", "0", "否", "未打勾"].includes(value.toLowerCase()))
|
|
440
|
+
return false;
|
|
441
|
+
throw new Error(`${field.label}請填 true 或 false`);
|
|
442
|
+
}
|
|
443
|
+
if (["person", "relation", "multiSelect"].includes(field.type)) {
|
|
444
|
+
if (!value)
|
|
445
|
+
return field.type === "relation" ? null : [];
|
|
446
|
+
if (value.startsWith("[") || value.startsWith("{"))
|
|
447
|
+
return JSON.parse(value);
|
|
448
|
+
const options = value.split(/[、,]/).map((label) => {
|
|
449
|
+
const option = field.options?.find((o) => o.value === label.trim() || o.label === label.trim());
|
|
450
|
+
if (!option)
|
|
451
|
+
throw new Error(`${field.label}的選項不存在:${label}`);
|
|
452
|
+
return option;
|
|
453
|
+
});
|
|
454
|
+
if (field.type === "multiSelect")
|
|
455
|
+
return options.map((o) => o.value);
|
|
456
|
+
if (field.type === "relation") {
|
|
457
|
+
if (options.length !== 1)
|
|
458
|
+
throw new Error("關聯資料請使用單一值");
|
|
459
|
+
return { id: options[0].value, label: options[0].label };
|
|
460
|
+
}
|
|
461
|
+
return options.map((o) => ({ id: o.value, label: o.label }));
|
|
462
|
+
}
|
|
463
|
+
if (field.type === "singleSelect")
|
|
464
|
+
return value ? field.options?.find((o) => o.value === value || o.label === value)?.value ?? value : null;
|
|
465
|
+
if (field.type === "date" || field.type === "dateTime")
|
|
466
|
+
return value || null;
|
|
467
|
+
return text;
|
|
468
|
+
}
|
|
469
|
+
export function formatReadableValue(value, field, row) {
|
|
470
|
+
if (field.internal || field.renderAction || field.canRead?.({ row, field }) === false)
|
|
471
|
+
return "";
|
|
472
|
+
if (field.formatText)
|
|
473
|
+
return field.formatText(value, row);
|
|
474
|
+
if (field.type === "checkbox")
|
|
475
|
+
return value ? "已打勾" : "未打勾";
|
|
476
|
+
const items = Array.isArray(value) ? value : [value];
|
|
477
|
+
return items.map((v) => field.options?.find((o) => o.value === String(typeof v === "object" && v ? v.id : v))?.label ?? textOf(v)).join("、");
|
|
478
|
+
}
|
|
479
|
+
export function formatReadableRow(row, fields) {
|
|
480
|
+
return fields.filter((f) => !f.internal && !f.renderAction && f.canRead?.({ row, field: f }) !== false).map((f) => `${f.label}:${formatReadableValue(f.getValue ? f.getValue(row) : row[f.id], f, row)}`).join("\n");
|
|
481
|
+
}
|
|
482
|
+
//# sourceMappingURL=registry.js.map
|