@iyulab/flex-table 0.62.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.64.0] - 2026-10-08
4
+
5
+ ### Added
6
+
7
+ - **`useODataSource` and `useArraySource` return `source`** — the source the hook binds, the same object every render.
8
+ A React screen hands it to a list skeleton that binds its views itself (`<ListPage source={orders.source}>`,
9
+ `bindSource(orders.source, el)`) and keeps its options as props, instead of building the source with `useMemo`,
10
+ reading it with `useSyncExternalStore` and calling `update` from an effect. `useArraySource`'s `source` reads the
11
+ hook's state and notifies its subscribers after each commit.
12
+
13
+ ## [0.63.0] - 2026-10-08
14
+
15
+ ### Added
16
+
17
+ - **Dot-path column keys** — `{ key: 'Customer.Name' }` reads `row.Customer.Name`, so a referenced record's name is a
18
+ column. Display, `format`, sorting, filtering, copying, editing, import and export read and write the cell through the
19
+ same path (`getCellValue` / `setCellValue`, now exported); a property named by the whole key (`'@odata.etag'`) still
20
+ wins. The server sort of such a column is the OData path (`$orderby=Customer/Name`), and `parseOrderBy` reads it back
21
+ as the column key.
22
+ - **`createODataSource` / `useODataSource` `expand` and `select`** — `$expand` (a string or an array) and `$select`,
23
+ sent by the page reads and by `fetchAll` alike; changing either re-reads. `buildODataQuery` takes them too.
24
+
25
+ - **`importFromFile` reports what did not come in.** It returns an `ImportReport` — and `data-import` now carries the
26
+ same object — `{ count, unmatchedHeaders, missingColumns, coercionFailures }`: file headers no column matched (their
27
+ values were dropped without a trace before), columns the file lacks, and cells that are not their column's type
28
+ (`{ row, key, raw }` — the cell keeps its text). `null` for a file type it does not read.
29
+ - **`ColumnDefinition.importAliases`** — other headers a file may give the column (a template a person made says
30
+ «관리 번호» where the label is «관리번호»). Headers now also match ignoring surrounding spaces.
31
+ - `flexTableLocale` keys `booleanTrueWords` / `booleanFalseWords` — the words a boolean column reads as true and false
32
+ (`yes,y` / `no,n`; Korean `예,네` / `아니오,아니요`), besides `true`/`false`/`1`/`0` and the locale's labels.
33
+
34
+ ### Changed
35
+
36
+ - **A boolean column keeps text it cannot read** — on import and on paste — instead of turning it into `false`.
37
+ «예», «Y» or «yes» became «no»; now the words above are read and anything else stays as written (and is reported on
38
+ import), as `number` and `date` columns already did.
39
+
40
+ ### Fixed
41
+
42
+ - **A comma-separated `.csv` imports.** It was read as tab-separated, so each line became one cell under an unmatched
43
+ header and the whole file came in as empty rows. A CSV may now be separated by commas or semicolons (Excel on
44
+ comma-decimal locales), detected from the header line.
45
+ - A CSV that starts with a byte-order mark — as `exportToFile` writes it — reads back: the mark was part of the first
46
+ header, so that column did not match.
47
+ - Two file headers that match the same column no longer both write into it; the second is reported as unmatched.
48
+
49
+ - **A hidden `flex-table` is hidden.** Its host's `display: block` outranked the browser's `[hidden]` rule, so
50
+ `hidden` did nothing — a list that switches between a table and a card view (which hides the view it is not
51
+ showing) drew both.
52
+
3
53
  ## [0.62.0] - 2026-10-08
4
54
 
5
55
  ### Added
package/README.md CHANGED
@@ -134,7 +134,7 @@ guarantee about a *constrained* host. `height-model.browser.test.ts` pins both s
134
134
 
135
135
  ```typescript
136
136
  interface ColumnDefinition {
137
- key: string; // Unique key matching data property names
137
+ key: string; // The row property, or a dot path into a nested record ('Customer.Name' — see below)
138
138
  label: string; // Column header text
139
139
  type?: ColumnType; // 'text' | 'number' | 'boolean' | 'date' | 'datetime' | 'select' (any other string falls back to 'text')
140
140
  width?: number; // Column width in pixels (default: auto)
@@ -166,6 +166,23 @@ A `number` column's built-in editor reads numbers the way people type them in th
166
166
 
167
167
  A `date` column's built-in editor is `u-date-picker` from `@iyulab/components`: a text box that shows and takes `YYYY-MM-DD` in every browser language (the native date input would show the browser's UI language, e.g. `10/02/2026`), with a calendar beside it — click the box or press ArrowDown, and a picked day is the new value. It also reads `2026/10/2`, `20261002` and `10-02` (this year), stores the ISO date string, and rejects text that is not a date (`error` is the localized "Enter a date as YYYY-MM-DD"). Pasted dates are read the same way. A `datetime` column's editor works the same with a time: it shows `YYYY-MM-DD HH:mm` in local time, reads `2026-10-02 14:05` (a date alone is midnight), and stores the local `YYYY-MM-DDTHH:mm` string; in its calendar a day and a time are applied together with Apply. While the calendar is open, Escape closes the calendar; the next Escape cancels the edit.
168
168
 
169
+ ### Nested values — dot-path keys
170
+
171
+ A `key` may be a path into a nested record: `{ key: 'Customer.Name', label: 'Customer' }` reads `row.Customer.Name` —
172
+ what a server list gets from OData `$expand` (the source's `expand` option). Display, `format`, sorting, filtering,
173
+ copying, editing, import and export all read and write the cell through the same path, and the server sort of that
174
+ column is `$orderby=Customer/Name`. A row that has a property named by the whole key (`'@odata.etag'`) gives that
175
+ property; the path is read only when the name is not there. `getCellValue(row, key)` / `setCellValue(row, key, value)`
176
+ (root entry) are the functions the table uses.
177
+
178
+ ```typescript
179
+ const source = createODataSource('/api/orders', { expand: 'Customer($select=Name)' });
180
+ const columns: ColumnDefinition[] = [
181
+ { key: 'Number', label: 'Order' },
182
+ { key: 'Customer.Name', label: 'Customer' }, // sortable on the server, exported as shown
183
+ ];
184
+ ```
185
+
169
186
  ### `format` vs `render`
170
187
 
171
188
  Both control how a cell's raw value is displayed, but they differ in what they replace:
@@ -323,6 +340,32 @@ try {
323
340
  Without a table, `exportDataBlob(rows, columns, format)` and `downloadBlob(blob, filename)` (root entry) do the same
324
341
  — `exportData` stays synchronous and writes XLSX uncompressed.
325
342
 
343
+ ### Import
344
+
345
+ `importFromFile(file)` reads an `.xlsx`, `.csv` or `.tsv` file into `data` (undoable) — also what dropping a file on a
346
+ table with `import-enabled` does. The first row is the header: each header goes to the column whose `label` — or one
347
+ of its `importAliases` — it matches, exactly first, then ignoring case and surrounding spaces. Each cell is read as its
348
+ column's type, the same way pasting reads it. A CSV may be separated by commas or semicolons; the byte-order mark an
349
+ export starts with is not part of the first header, so an exported file reads back as it was.
350
+
351
+ Nothing is dropped silently. The returned report — the same object `data-import` carries — says what did not come in:
352
+
353
+ ```ts
354
+ const report = await table.importFromFile(file); // null for a file type it does not read
355
+ // { count: 120,
356
+ // unmatchedHeaders: ['비고2'], // their values were not imported
357
+ // missingColumns: ['since'], // empty in every imported row
358
+ // coercionFailures: [{ row: 4, key: 'qty', raw: 'many' }] } // the cell keeps its text
359
+ ```
360
+
361
+ A boolean column reads `true`/`false`/`1`/`0`, the locale's true/false labels and its words (`flexTableLocale` keys
362
+ `booleanTrueWords`/`booleanFalseWords` — `yes,y`/`no,n` in English, `예,네`/`아니오,아니요` in Korean); any other text
363
+ stays text and is reported, rather than becoming `false`.
364
+
365
+ ```ts
366
+ { key: 'code', label: '관리번호', importAliases: ['관리 번호', 'Asset no.'] } // a template a person made
367
+ ```
368
+
326
369
  ## Events
327
370
 
328
371
  All events use `CustomEvent` with `bubbles: true, composed: true`. They are typed: `FlexTableEventMap` maps
@@ -349,7 +392,7 @@ type-checks without a cast. The React wrapper's `on*` props carry the same types
349
392
  | `row-activate` | `{ row, id, via, index, col, key }` | "Open this row" (e.g. navigate to a detail view): a plain click on a body cell (`via: 'click'`), or Enter on a non-editable cell (`via: 'keyboard'`). Not a Shift / Ctrl / Cmd click (those extend the selection), the click that ends a drag, or a click on a control the cell renders (a link, a button). The grid's own contract — its Enter handler keeps the keystroke from reliably reaching a listener the host attaches to the same element |
350
393
  | `batch-update` | `{ changes: [{ row, key, oldValue, newValue }] }` | Batch update applied |
351
394
  | `row-reorder` | `{ from, to }` | Row dragged to a new place (data indices) |
352
- | `data-import` | `{ count }` | Rows imported from a file |
395
+ | `data-import` | `ImportReport` — `{ count, unmatchedHeaders, missingColumns, coercionFailures }` | Rows imported from a file (see Import) |
353
396
  | `fill-handle-apply` | `{ sourceRange, targetRange, cells }` | Fill handle wrote `cells` (`{ dataRow, key, oldValue, newValue }`) |
354
397
  | `find-replace` | `{ type, cells }` | Replace (`type: 'replace'`) or replace-all from the find panel; `cells` are `{ row, col, oldValue, newValue }` with `col` the column key |
355
398
  | `comment-change` | `{ dataIndex, id, colKey, text }` | Cell comment set, changed or removed (`text: null`). Comments stay on their rows (`id`) when rows move |
@@ -796,6 +839,8 @@ const source = useODataSource('/api/orders', {
796
839
  | `initialSearch` | `''` | Initial search term |
797
840
  | `initialSort` | — | Initial sort as `SortCriteria[]`. Takes precedence over `defaultOrderBy` — it is the shape `onSortChange` hands you, so a stored sort round-trips without re-serializing it |
798
841
  | `fixedFilter` | — | Filter always applied in addition to search. Changing it resets the page to 0 — see below |
842
+ | `expand` | — | `$expand` — referenced records to fetch with each row: `'Customer($select=Name),Owner'` or `['Customer', 'Owner']`. A column reads one with a dot-path key (`key: 'Customer.Name'`), and its server sort is `$orderby=Customer/Name`. The page reads and `fetchAll` send the same value |
843
+ | `select` | — | `$select` — the properties to fetch; all when omitted |
799
844
  | `baseUrl` | `window.location.origin` | Override the request origin (proxy/BFF setups) |
800
845
  | `fetcher` | global `fetch` | Custom transport — pass a wrapper that injects auth headers |
801
846
  | `onUnauthorized` | — | Called on `401` responses, before the generic error is set. A `403` (signed in, not permitted) does not call it — it surfaces as `error` |
@@ -866,6 +911,7 @@ The hook returns:
866
911
  | `search` / `setSearch` | Current search term and its setter (resets to page 0) |
867
912
  | `refresh` | Re-run the current request |
868
913
  | `fetchAll` | The whole result of the current conditions — the source's `fetchAll` (below), for exporting what the list shows |
914
+ | `source` | The source the hook binds (the same object every render) — hand it to a list skeleton that binds views itself: `<ListPage source={orders.source}>` (`@iyulab/enterprise/react`) or `bindSource(orders.source, element)`. `useArraySource` returns one too, over its own state |
869
915
 
870
916
  #### Search semantics
871
917
 
@@ -1,5 +1,5 @@
1
- import { n as e, t, u as n } from "../fetch-all-J37bOtgc.js";
2
- import { n as r, t as i } from "../view-BtoXdUGF.js";
1
+ import { n as e, t, u as n } from "../fetch-all-DkdNzWrF.js";
2
+ import { n as r, t as i } from "../view-DuDIMoXV.js";
3
3
  //#region src/array/source.ts
4
4
  function a(e, t = {}) {
5
5
  let a = e, o = t, s = n({
@@ -1,6 +1,7 @@
1
1
  import type { SortCriteria } from '../core/sorting.js';
2
2
  import type { SourceError } from '../core/source-error.js';
3
3
  import type { FetchAllOptions } from '../core/fetch-all.js';
4
+ import type { ArraySource } from './source.js';
4
5
  export interface UseArraySourceOptions<T> {
5
6
  /** 페이지당 행 수. `useODataSource`와 동일 기본값. */
6
7
  pageSize?: number;
@@ -30,7 +31,11 @@ export interface UseArraySourceOptions<T> {
30
31
  */
31
32
  searchFields?: (row: T) => Array<string | number | boolean | null | undefined>;
32
33
  }
34
+ /** `useArraySource` 의 `source` — 목록 골격(`ListPage` · `bindSource`)에 넘기는 소스. 이 훅의 상태를 읽고 조작은 훅으로 간다. */
35
+ export type HookArraySource<T> = Omit<ArraySource<T>, 'update'>;
33
36
  export interface UseArraySourceResult<T> {
37
+ /** 이 훅의 상태를 소스 모양으로 — `<ListPage source={orders.source}>` · `bindSource(orders.source, el)`. 늘 같은 객체다. */
38
+ source: HookArraySource<T>;
34
39
  /** 현재 페이지의 행(검색+정렬 적용 후 slice). */
35
40
  data: T[];
36
41
  /** 검색+정렬 적용 후, 페이지 나누기 전의 총 건수(`useODataSource`의 `@odata.count`와 동일 의미). */
@@ -6,6 +6,17 @@ import type { ColumnDefinition, DataRow } from '../models/types.js';
6
6
  */
7
7
  export declare function copyToClipboard(data: DataRow[], columns: ColumnDefinition[], range: CellRange): string;
8
8
  export declare function parseValueForColumn(raw: string, col: ColumnDefinition): unknown;
9
+ /**
10
+ * {@link parseValueForColumn} with the outcome: `failed` is true when the column has a type (`number`, `boolean`,
11
+ * `date`, `datetime`) and the text is not one — the value is then the text itself, unchanged. An importer reports
12
+ * those cells instead of losing them.
13
+ */
14
+ export declare function parseCellForColumn(raw: string, col: ColumnDefinition): {
15
+ value: unknown;
16
+ failed: boolean;
17
+ };
18
+ /** A boolean as a spreadsheet writes it, or `null` when the text is not one. */
19
+ export declare function parseBoolean(raw: string): boolean | null;
9
20
  /**
10
21
  * A number as a person edits it: the active locale's decimal separator, no grouping, every digit
11
22
  * kept (`1234.5` → `1234,5` on a German page). The inverse is {@link parseValueForColumn}.
@@ -0,0 +1,13 @@
1
+ import type { DataRow } from '../models/types.js';
2
+ /**
3
+ * A column's value in a row. `key` is a property name, or a dot path into nested objects — `'Customer.Name'` reads
4
+ * `row.Customer.Name` (an OData `$expand`, a joined record). A row that has a property by the whole name (`'a.b'`,
5
+ * `'@odata.etag'`) gives that property: the path is only read when the name is not there. Every place the table reads
6
+ * a cell — display, format, sort, filter, copy, export — goes through this, so they agree.
7
+ */
8
+ export declare function getCellValue(row: DataRow | null | undefined, key: string): unknown;
9
+ /**
10
+ * Writes a column's value — the inverse of {@link getCellValue}: the property by the whole name when the row has it
11
+ * (or the key has no dot), otherwise the path, creating the objects along it that are missing.
12
+ */
13
+ export declare function setCellValue(row: DataRow, key: string, value: unknown): void;
package/dist/events.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ImportReport } from './export/import.js';
1
2
  import type { CellPosition, CellRange } from './core/selection.js';
2
3
  import type { SortCriteria } from './core/sorting.js';
3
4
  import type { ColumnDefinition, DataRow } from './models/types.js';
@@ -143,10 +144,8 @@ export interface FlexTableEventMap {
143
144
  colKey: string;
144
145
  text: string | null;
145
146
  }>;
146
- /** Rows were imported from a file; `count` is how many. */
147
- 'data-import': CustomEvent<{
148
- count: number;
149
- }>;
147
+ /** Rows were imported from a file — the same report `importFromFile` returns: `count`, and what did not come in. */
148
+ 'data-import': CustomEvent<ImportReport>;
150
149
  'sort-change': CustomEvent<{
151
150
  criteria: SortCriteria[];
152
151
  }>;
@@ -0,0 +1,11 @@
1
+ import type { ImportedSheet } from './xlsx-reader.js';
2
+ /**
3
+ * The separator a CSV file uses — `,` or, as Excel writes it on locales whose decimal mark is a comma, `;` (a tab
4
+ * also counts). Counted on the first line outside quotes; the most frequent wins, and `,` when none appears.
5
+ */
6
+ export declare function detectDelimiter(text: string): string;
7
+ /**
8
+ * A CSV or TSV file as a header row and data rows. The byte-order mark a spreadsheet-bound export starts with
9
+ * (`exportToFile` writes one) is not part of the first header.
10
+ */
11
+ export declare function readDelimited(text: string, kind: 'csv' | 'tsv'): ImportedSheet;
@@ -0,0 +1,30 @@
1
+ import type { ColumnDefinition, DataRow } from '../models/types.js';
2
+ import type { ImportedSheet } from './xlsx-reader.js';
3
+ /**
4
+ * What an import took in and what it could not — `importFromFile` returns it and `data-import` carries it, so a
5
+ * preview can show «what will not come in» before anything is saved.
6
+ */
7
+ export interface ImportReport {
8
+ /** Rows imported. */
9
+ count: number;
10
+ /** File headers no column matched (by `label` or `importAliases`) — their values were not imported. */
11
+ unmatchedHeaders: string[];
12
+ /** Keys of the columns no file header matched — those cells are empty in every imported row. */
13
+ missingColumns: string[];
14
+ /**
15
+ * Cells whose text is not a value of the column's type (`number`, `boolean`, `date`, `datetime`). The cell keeps the
16
+ * text as it was. `row` is the 0-based index among the imported rows.
17
+ */
18
+ coercionFailures: {
19
+ row: number;
20
+ key: string;
21
+ raw: string;
22
+ }[];
23
+ }
24
+ /** File header → column key: the label or an alias, exactly first, then ignoring case and surrounding spaces. */
25
+ export declare function matchHeaders(headers: string[], columns: ColumnDefinition[]): Map<number, string>;
26
+ /** A parsed sheet as table rows, with the report of what did not come in. */
27
+ export declare function buildImport(sheet: ImportedSheet, columns: ColumnDefinition[]): {
28
+ rows: DataRow[];
29
+ report: ImportReport;
30
+ };
@@ -1,4 +1,4 @@
1
- import { n as e } from "./locale-D9g0ksXq.js";
1
+ import { n as e } from "./locale-Covm8PfH.js";
2
2
  import t from "odata-query";
3
3
  //#region src/odata/query.ts
4
4
  function n(e) {
@@ -17,26 +17,29 @@ function i(e) {
17
17
  return e.split(",").map((e) => {
18
18
  let t = e.trim().split(/\s+/);
19
19
  return {
20
- key: t[0],
20
+ key: t[0].replace(/\//g, "."),
21
21
  direction: t[1]?.toLowerCase() === "desc" ? "desc" : "asc"
22
22
  };
23
23
  });
24
24
  }
25
25
  function a(e) {
26
- let { page: r = 0, pageSize: i, sortCriteria: a = [], defaultOrderBy: o, search: s, fixedFilter: c } = e, l = a.length > 0 ? a.map((e) => `${e.key} ${e.direction}`).join(", ") : o, u = i === void 0 ? { count: !0 } : {
26
+ return e.replace(/\./g, "/");
27
+ }
28
+ function o(e) {
29
+ let { page: r = 0, pageSize: i, sortCriteria: o = [], defaultOrderBy: s, search: c, fixedFilter: l, expand: u, select: d } = e, f = o.length > 0 ? o.map((e) => `${a(e.key)} ${e.direction}`).join(", ") : s, p = i === void 0 ? { count: !0 } : {
27
30
  top: i,
28
31
  skip: r * i,
29
32
  count: !0
30
33
  };
31
- if (l && (u.orderBy = l), c && (u.filter = c), s) {
32
- let e = n(s);
33
- e && (u.search = e);
34
+ if (f && (p.orderBy = f), l && (p.filter = l), u && u.length > 0 && (p.expand = u), d && d.length > 0 && (p.select = d), c) {
35
+ let e = n(c);
36
+ e && (p.search = e);
34
37
  }
35
- return t(u);
38
+ return t(p);
36
39
  }
37
40
  //#endregion
38
41
  //#region src/core/source-error.ts
39
- async function o(t) {
42
+ async function s(t) {
40
43
  let n = await t.text().catch(() => ""), r = {
41
44
  message: e("requestFailed", { status: t.status }),
42
45
  status: t.status
@@ -49,10 +52,10 @@ async function o(t) {
49
52
  if (r.body = i, !i || typeof i != "object") return r;
50
53
  let a = i, o = a.error && typeof a.error == "object" ? a.error : a;
51
54
  typeof o.message == "string" && o.message ? r.message = o.message : typeof a.message == "string" && a.message && (r.message = a.message), typeof o.code == "string" && o.code && (r.code = o.code);
52
- let c = s(o.details);
53
- return c && (r.details = c), r;
55
+ let s = c(o.details);
56
+ return s && (r.details = s), r;
54
57
  }
55
- function s(e) {
58
+ function c(e) {
56
59
  if (!Array.isArray(e)) return;
57
60
  let t = e.filter((e) => {
58
61
  if (!e || typeof e != "object") return !1;
@@ -61,26 +64,26 @@ function s(e) {
61
64
  });
62
65
  return t.length > 0 ? t : void 0;
63
66
  }
64
- var c = class extends Error {
67
+ var l = class extends Error {
65
68
  constructor(e) {
66
69
  super(e.message), this.name = "SourceRequestError", this.failure = e;
67
70
  }
68
- }, l = class extends c {
71
+ }, u = class extends l {
69
72
  constructor(t, n) {
70
73
  super({ message: e("tooManyRows", { maxRows: t }) }), this.name = "RowLimitError", this.total = n, this.maxRows = t;
71
74
  }
72
75
  };
73
- function u(t) {
74
- return new c({
76
+ function d(t) {
77
+ return new l({
75
78
  message: e("networkFailed"),
76
79
  cause: t
77
80
  });
78
81
  }
79
- function d(e) {
80
- return e instanceof c ? e.failure : e instanceof Error ? { message: e.message } : { message: String(e) };
82
+ function f(e) {
83
+ return e instanceof l ? e.failure : e instanceof Error ? { message: e.message } : { message: String(e) };
81
84
  }
82
85
  //#endregion
83
86
  //#region src/core/fetch-all.ts
84
- var f = 1e5;
87
+ var p = 1e5;
85
88
  //#endregion
86
- export { o as a, n as c, u as i, i as l, l as n, d as o, c as r, a as s, f as t, r as u };
89
+ export { s as a, n as c, d as i, i as l, u as n, f as o, l as r, o as s, p as t, r as u };