@truenas/ui-components 0.7.5 → 0.7.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truenas/ui-components",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public"
@@ -1,17 +1,21 @@
1
1
  import type { Config } from 'jest';
2
2
 
3
3
  /**
4
- * `yarn test:scripts` — every build script under this directory that has tests.
4
+ * `yarn test:scripts` — the repo's node-environment tests: every build script under this
5
+ * directory that has tests, plus `release-config`, which tests repo-root configuration
6
+ * rather than a script and lives here because this is where a node-environment Jest
7
+ * project already runs.
5
8
  *
6
- * One Jest project per script package rather than one config rooted here, because
7
- * `<rootDir>` inside a project config resolves to that project's own directory: each
8
- * package keeps pointing ts-jest at its own `tsconfig.json`, and adding the next one
9
- * costs a line here rather than a merge of compiler options.
9
+ * One Jest project per package rather than one config rooted here, because `<rootDir>`
10
+ * inside a project config resolves to that project's own directory: each package keeps
11
+ * pointing ts-jest at its own `tsconfig.json`, and adding the next one costs a line here
12
+ * rather than a merge of compiler options.
10
13
  */
11
14
  const config: Config = {
12
15
  projects: [
13
16
  '<rootDir>/harness-docs/jest.config.ts',
14
17
  '<rootDir>/icon-sprite/jest.config.ts',
18
+ '<rootDir>/release-config/jest.config.ts',
15
19
  ],
16
20
  };
17
21
 
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The PR title check and the release step both decide what a commit header means, from
3
+ * two patterns written in two files, and nothing made them agree. A scope naming two
4
+ * components with a comma — `feat(autocomplete,chip-input): …` — passed the title check,
5
+ * merged, and then matched no header pattern at all, so the commit analyzer read it as
6
+ * having no type: it triggered no release and appeared in no changelog (#315).
7
+ *
8
+ * These tests read both files rather than restating either pattern, so drift in either
9
+ * one fails here instead of silently dropping a merged commit from the next release.
10
+ *
11
+ * They live under the library's script tests because `yarn test:scripts` is where this
12
+ * repo runs Jest in a node environment; what they cover is repo-root release
13
+ * configuration, which is why they reach four directories up.
14
+ */
15
+ import { readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ const repoRoot = join(__dirname, '..', '..', '..', '..');
19
+
20
+ interface ParserOpts {
21
+ headerPattern: string;
22
+ headerCorrespondence: string[];
23
+ breakingHeaderPattern: string;
24
+ }
25
+
26
+ type PluginEntry = string | [string, { parserOpts?: ParserOpts }];
27
+
28
+ const releaseConfig = JSON.parse(
29
+ readFileSync(join(repoRoot, '.releaserc.json'), 'utf8')
30
+ ) as { plugins: PluginEntry[] };
31
+
32
+ /** Every semantic-release plugin that parses commit headers, with its plugin name. */
33
+ const parsingPlugins: [string, ParserOpts][] = [];
34
+
35
+ for (const plugin of releaseConfig.plugins) {
36
+ if (Array.isArray(plugin)) {
37
+ const [name, options] = plugin;
38
+
39
+ if (options.parserOpts) {
40
+ parsingPlugins.push([name, options.parserOpts]);
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * The Bash pattern the PR title check runs, lifted from the workflow that runs it.
47
+ *
48
+ * `[[ =~ ]]` and `RegExp` both leave the end unanchored and the pattern anchors its own
49
+ * start, so a JS `RegExp` accepts what the check accepts for the constructs it uses:
50
+ * groups, alternation, character classes and `?`.
51
+ */
52
+ function readPrTitlePattern(): RegExp {
53
+ const workflow = readFileSync(join(repoRoot, '.github/workflows/pr-title.yml'), 'utf8');
54
+ const assignment = /^\s*pattern='([^']+)'\s*$/m.exec(workflow);
55
+
56
+ if (!assignment) {
57
+ throw new Error('no `pattern=<...>` assignment found in .github/workflows/pr-title.yml');
58
+ }
59
+
60
+ return new RegExp(assignment[1]);
61
+ }
62
+
63
+ const prTitleCheck = readPrTitlePattern();
64
+
65
+ /** What the release step reads out of a header, or null when it matches nothing. */
66
+ function parseHeader(opts: ParserOpts, header: string): Record<string, string | undefined> | null {
67
+ const match = new RegExp(opts.headerPattern).exec(header);
68
+
69
+ if (!match) {
70
+ return null;
71
+ }
72
+
73
+ return Object.fromEntries(
74
+ opts.headerCorrespondence.map((field, index) => [field, match[index + 1]])
75
+ );
76
+ }
77
+
78
+ const commaScopeHeader =
79
+ 'NAS-142381 / 27.0.0-BETA.1 / feat(autocomplete,chip-input): drive options from an async [dataSource] (#312)';
80
+
81
+ /**
82
+ * Headers the PR title check accepts. Every one is asserted to pass the check as well as
83
+ * to parse, so a case that stops being realistic fails rather than passing vacuously.
84
+ */
85
+ const acceptedHeaders: { name: string; header: string; type: string; scope?: string }[] = [
86
+ {
87
+ name: 'a single-word scope',
88
+ header: 'feat(table): let a selection survive a dataSource change',
89
+ type: 'feat',
90
+ scope: 'table',
91
+ },
92
+ {
93
+ name: 'the ticket and version prefix a squash commit carries',
94
+ header: 'NAS-143108 / 27.0.0-BETA.1 / fix(table): stop the pager aliasing pagination',
95
+ type: 'fix',
96
+ scope: 'table',
97
+ },
98
+ {
99
+ name: 'no scope at all',
100
+ header: 'chore: bump the toolchain',
101
+ type: 'chore',
102
+ scope: undefined,
103
+ },
104
+ {
105
+ name: 'two components separated by a comma (#315)',
106
+ header: commaScopeHeader,
107
+ type: 'feat',
108
+ scope: 'autocomplete,chip-input',
109
+ },
110
+ {
111
+ name: 'a comma and a space between components',
112
+ header: 'feat(autocomplete, chip-input): drive options from an async [dataSource]',
113
+ type: 'feat',
114
+ scope: 'autocomplete, chip-input',
115
+ },
116
+ {
117
+ name: 'a scope with a slash',
118
+ header: 'refactor(a11y/labels): fold the fallback into one helper',
119
+ type: 'refactor',
120
+ scope: 'a11y/labels',
121
+ },
122
+ ];
123
+
124
+ describe('.releaserc.json', () => {
125
+ it('has more than one plugin parsing commit headers, one of them the commit analyzer', () => {
126
+ expect(parsingPlugins.length).toBeGreaterThan(1);
127
+ expect(parsingPlugins.map(([name]) => name)).toContain('@semantic-release/commit-analyzer');
128
+ });
129
+
130
+ it('gives all of them the same parser options, so notes and version bumps agree', () => {
131
+ const [[, first]] = parsingPlugins;
132
+
133
+ // The plugin name rides along in the assertion so a failure says which one drifted.
134
+ for (const [name, opts] of parsingPlugins) {
135
+ expect([name, opts]).toEqual([name, first]);
136
+ }
137
+ });
138
+ });
139
+
140
+ describe.each(parsingPlugins)('%s', (_pluginName, opts) => {
141
+ it.each(acceptedHeaders)('parses $name', ({ header, type, scope }) => {
142
+ expect(parseHeader(opts, header)).toMatchObject({ type, scope, subject: expect.any(String) });
143
+ });
144
+
145
+ it('reads a comma scope as one scope, leaving the subject whole', () => {
146
+ expect(parseHeader(opts, commaScopeHeader)?.subject).toBe(
147
+ 'drive options from an async [dataSource] (#312)'
148
+ );
149
+ });
150
+
151
+ it('recognises a breaking change whose scope contains a comma', () => {
152
+ const breaking = new RegExp(opts.breakingHeaderPattern).exec(
153
+ 'feat(autocomplete,chip-input)!: options are now a dataSource'
154
+ );
155
+
156
+ expect(breaking?.[1]).toBe('feat');
157
+ expect(breaking?.[2]).toBe('autocomplete,chip-input');
158
+ });
159
+ });
160
+
161
+ describe('the PR title check and the release step', () => {
162
+ it.each(acceptedHeaders)('agree on $name', ({ header, type }) => {
163
+ expect(prTitleCheck.test(header)).toBe(true);
164
+
165
+ for (const [, opts] of parsingPlugins) {
166
+ expect(parseHeader(opts, header)).toMatchObject({ type });
167
+ }
168
+ });
169
+
170
+ it('agree on every scope character the check allows, not a narrower set', () => {
171
+ // The check's scope is `\([^)]+\)`. A release pattern with a narrower scope class is
172
+ // what produced #315: the title passed, the commit merged, the release dropped it.
173
+ const scopes = ['a,b', 'a, b', 'a/b', 'a.b', 'a-b', 'a b', 'a:b', 'a+b', 'a&b', 'a#b', '@scope/pkg'];
174
+
175
+ for (const scope of scopes) {
176
+ const header = `feat(${scope}): a subject`;
177
+
178
+ expect(prTitleCheck.test(header)).toBe(true);
179
+
180
+ for (const [, opts] of parsingPlugins) {
181
+ expect(parseHeader(opts, header)).toEqual({
182
+ type: 'feat',
183
+ scope,
184
+ subject: 'a subject',
185
+ });
186
+ }
187
+ }
188
+ });
189
+ });
@@ -0,0 +1,14 @@
1
+ import type { Config } from 'jest';
2
+
3
+ const config: Config = {
4
+ testEnvironment: 'node',
5
+ transform: {
6
+ '^.+\\.tsx?$': ['ts-jest', {
7
+ tsconfig: '<rootDir>/tsconfig.json',
8
+ }],
9
+ },
10
+ testMatch: ['<rootDir>/**/*.spec.ts'],
11
+ moduleFileExtensions: ['ts', 'js', 'json'],
12
+ };
13
+
14
+ export default config;
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "esModuleInterop": true,
7
+ "isolatedModules": true,
8
+ "strict": true,
9
+ "types": ["jest", "node"],
10
+ "rootDir": ".",
11
+ "outDir": "../../dist/out-tsc/scripts"
12
+ },
13
+ "include": ["**/*.ts"]
14
+ }
@@ -9686,6 +9686,33 @@ declare class TnTableComponent<T = unknown> implements OnInit {
9686
9686
  * full list it holds, and call {@link clearSelection} once a batch action is done.
9687
9687
  */
9688
9688
  selectionKey: _angular_core.InputSignal<((row: T) => unknown) | undefined>;
9689
+ /**
9690
+ * Identifies a row for EXPANSION, so an open detail row survives a `dataSource` change.
9691
+ *
9692
+ * Detail rows are held by object identity, and a consumer that re-fetches hands back new
9693
+ * objects for the same rows — so without a key a background reload closes the row the
9694
+ * user opened, which is usually the moment its contents are worth watching (a running
9695
+ * job's logs, say). With a key set, the table re-points the open rows at the new objects
9696
+ * instead of collapsing them.
9697
+ *
9698
+ * A row the new `data()` does not carry — another page, another tab, filtered out — is
9699
+ * kept, not dropped, and re-opens on its own once that row is listed again. Retention is
9700
+ * unconditional for the same reason it is for {@link selectionKey}: the table sees one
9701
+ * page and cannot tell a row that moved off-screen from one that is gone for good. Call
9702
+ * {@link clearExpansion} when the consumer knows it is gone.
9703
+ *
9704
+ * Without it the table keeps its historical behaviour — any `dataSource` reference change
9705
+ * collapses every open row.
9706
+ *
9707
+ * With a key set, drive expansion through {@link toggleRowExpansion}, {@link expandRow}
9708
+ * and {@link clearExpansion} rather than writing {@link expandedRows} directly, or the
9709
+ * retained set and the visible set drift apart.
9710
+ *
9711
+ * Pass a key that identifies the ROW, not its position, and bind a stable member rather
9712
+ * than writing the function in the template: `[expansionKey]="rowKey"` for a
9713
+ * `rowKey = (row: Job) => row.id` on the host.
9714
+ */
9715
+ expansionKey: _angular_core.InputSignal<((row: T) => unknown) | undefined>;
9689
9716
  emptyMessage: _angular_core.InputSignal<string>;
9690
9717
  /**
9691
9718
  * Optional second line under `emptyMessage`, for empty states that carry both a headline and an
@@ -9897,12 +9924,25 @@ declare class TnTableComponent<T = unknown> implements OnInit {
9897
9924
  sortColumn: _angular_core.ModelSignal<string>;
9898
9925
  sortDirection: _angular_core.ModelSignal<"" | "desc" | "asc">;
9899
9926
  /**
9900
- * Set of currently expanded row references.
9901
- * Note: uses object identity. If the consumer replaces the data array
9902
- * (e.g. after sorting), expanded state is lost. A future key-based
9903
- * approach could address this.
9927
+ * The rows of the CURRENT `data()` whose detail row is open, by object identity.
9928
+ *
9929
+ * Replacing the data array collapses everything unless {@link expansionKey} is set, in
9930
+ * which case this is the visible slice of {@link expandedByKey} and survives the swap.
9931
+ *
9932
+ * Writable for consumers that predate {@link expansionKey}. **Adding a key to a consumer
9933
+ * that writes this signal directly is a migration, not a drop-in**: a write here closes
9934
+ * what is on screen but never touches the retained map, so every row whose key is still in
9935
+ * it re-opens on the next reconcile — the one it just closed included. Move those writes to
9936
+ * {@link expandRow}, {@link toggleRowExpansion} and {@link clearExpansion}, which keep the
9937
+ * two in step.
9904
9938
  */
9905
9939
  expandedRows: _angular_core.WritableSignal<Set<unknown>>;
9940
+ /**
9941
+ * Every open detail row keyed by {@link expansionKey}, including rows no longer in
9942
+ * `data()`. Empty (and unused) when no key is set. Holds the row objects so a row that
9943
+ * comes back can be re-pointed at the object the new data carries.
9944
+ */
9945
+ private expandedByKey;
9906
9946
  private static instanceCount;
9907
9947
  private readonly instanceId;
9908
9948
  /**
@@ -9999,6 +10039,26 @@ declare class TnTableComponent<T = unknown> implements OnInit {
9999
10039
  */
10000
10040
  canExpandRow(row: T): boolean;
10001
10041
  toggleRowExpansion(row: T): void;
10042
+ /**
10043
+ * Whether `row`'s detail row counts as open for a toggle. With {@link expansionKey} that is
10044
+ * the retained map, which includes rows this page is not showing; without one it is object
10045
+ * identity against the visible set. Distinct from {@link isRowExpanded}, which drives
10046
+ * rendering and must therefore stay about the row in front of the user.
10047
+ */
10048
+ private isExpansionOpen;
10049
+ /**
10050
+ * Opens `row`'s detail row programmatically — the same thing a chevron click does, for a
10051
+ * consumer restoring expansion from somewhere else (a `?jobId=` query parameter, say).
10052
+ * Honours `singleExpand` and keeps {@link expansionKey}'s retained set in step, which
10053
+ * writing {@link expandedRows} directly does not.
10054
+ */
10055
+ expandRow(row: T): void;
10056
+ /**
10057
+ * Collapses every detail row, including rows retained under {@link expansionKey} that are
10058
+ * not on screen. `expandedRows.set(new Set())` only closes what is visible, so with a key
10059
+ * set the off-screen rows would re-open on the next reconcile.
10060
+ */
10061
+ clearExpansion(): void;
10002
10062
  isRowExpanded(row: T): boolean;
10003
10063
  /**
10004
10064
  * Whether the row element itself acts as the expand/collapse control. Requires `clickable` —
@@ -10226,7 +10286,7 @@ declare class TnTableComponent<T = unknown> implements OnInit {
10226
10286
  getCardLabel(column: string): string;
10227
10287
  getCellValue(row: T, column: string): unknown;
10228
10288
  static ɵfac: _angular_core.ɵɵFactoryDeclaration<TnTableComponent<any>, never>;
10229
- static ɵcmp: _angular_core.ɵɵComponentDeclaration<TnTableComponent<any>, "tn-table", never, { "dataSource": { "alias": "dataSource"; "required": false; "isSignal": true; }; "displayedColumns": { "alias": "displayedColumns"; "required": false; "isSignal": true; }; "trackBy": { "alias": "trackBy"; "required": false; "isSignal": true; }; "selectionKey": { "alias": "selectionKey"; "required": false; "isSignal": true; }; "emptyMessage": { "alias": "emptyMessage"; "required": false; "isSignal": true; }; "emptyDescription": { "alias": "emptyDescription"; "required": false; "isSignal": true; }; "emptyIcon": { "alias": "emptyIcon"; "required": false; "isSignal": true; }; "scrollRegionAriaLabel": { "alias": "scrollRegionAriaLabel"; "required": false; "isSignal": true; }; "selectable": { "alias": "selectable"; "required": false; "isSignal": true; }; "expandable": { "alias": "expandable"; "required": false; "isSignal": true; }; "bordered": { "alias": "bordered"; "required": false; "isSignal": true; }; "isRowExpandable": { "alias": "isRowExpandable"; "required": false; "isSignal": true; }; "activeRow": { "alias": "activeRow"; "required": false; "isSignal": true; }; "activeWhen": { "alias": "activeWhen"; "required": false; "isSignal": true; }; "activeBg": { "alias": "activeBg"; "required": false; "isSignal": true; }; "activeIndicator": { "alias": "activeIndicator"; "required": false; "isSignal": true; }; "loading": { "alias": "loading"; "required": false; "isSignal": true; }; "loadingMessage": { "alias": "loadingMessage"; "required": false; "isSignal": true; }; "clickable": { "alias": "clickable"; "required": false; "isSignal": true; }; "expandOnRowClick": { "alias": "expandOnRowClick"; "required": false; "isSignal": true; }; "singleExpand": { "alias": "singleExpand"; "required": false; "isSignal": true; }; "fixedLayout": { "alias": "fixedLayout"; "required": false; "isSignal": true; }; "minColumnWidth": { "alias": "minColumnWidth"; "required": false; "isSignal": true; }; "minWidth": { "alias": "minWidth"; "required": false; "isSignal": true; }; "mobileLayout": { "alias": "mobileLayout"; "required": false; "isSignal": true; }; "cardBreakpoint": { "alias": "cardBreakpoint"; "required": false; "isSignal": true; }; "cardPrimaryCount": { "alias": "cardPrimaryCount"; "required": false; "isSignal": true; }; "sortColumn": { "alias": "sortColumn"; "required": false; "isSignal": true; }; "sortDirection": { "alias": "sortDirection"; "required": false; "isSignal": true; }; }, { "sortChange": "sortChange"; "selectionChange": "selectionChange"; "rowClick": "rowClick"; "rowDoubleClick": "rowDoubleClick"; "sortColumn": "sortColumnChange"; "sortDirection": "sortDirectionChange"; }, ["columnDefs", "detailRowDef", "rowActionsDef"], never, true, [{ directive: typeof TnTestIdDirective; inputs: { "tnTestId": "testId"; }; outputs: {}; }]>;
10289
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<TnTableComponent<any>, "tn-table", never, { "dataSource": { "alias": "dataSource"; "required": false; "isSignal": true; }; "displayedColumns": { "alias": "displayedColumns"; "required": false; "isSignal": true; }; "trackBy": { "alias": "trackBy"; "required": false; "isSignal": true; }; "selectionKey": { "alias": "selectionKey"; "required": false; "isSignal": true; }; "expansionKey": { "alias": "expansionKey"; "required": false; "isSignal": true; }; "emptyMessage": { "alias": "emptyMessage"; "required": false; "isSignal": true; }; "emptyDescription": { "alias": "emptyDescription"; "required": false; "isSignal": true; }; "emptyIcon": { "alias": "emptyIcon"; "required": false; "isSignal": true; }; "scrollRegionAriaLabel": { "alias": "scrollRegionAriaLabel"; "required": false; "isSignal": true; }; "selectable": { "alias": "selectable"; "required": false; "isSignal": true; }; "expandable": { "alias": "expandable"; "required": false; "isSignal": true; }; "bordered": { "alias": "bordered"; "required": false; "isSignal": true; }; "isRowExpandable": { "alias": "isRowExpandable"; "required": false; "isSignal": true; }; "activeRow": { "alias": "activeRow"; "required": false; "isSignal": true; }; "activeWhen": { "alias": "activeWhen"; "required": false; "isSignal": true; }; "activeBg": { "alias": "activeBg"; "required": false; "isSignal": true; }; "activeIndicator": { "alias": "activeIndicator"; "required": false; "isSignal": true; }; "loading": { "alias": "loading"; "required": false; "isSignal": true; }; "loadingMessage": { "alias": "loadingMessage"; "required": false; "isSignal": true; }; "clickable": { "alias": "clickable"; "required": false; "isSignal": true; }; "expandOnRowClick": { "alias": "expandOnRowClick"; "required": false; "isSignal": true; }; "singleExpand": { "alias": "singleExpand"; "required": false; "isSignal": true; }; "fixedLayout": { "alias": "fixedLayout"; "required": false; "isSignal": true; }; "minColumnWidth": { "alias": "minColumnWidth"; "required": false; "isSignal": true; }; "minWidth": { "alias": "minWidth"; "required": false; "isSignal": true; }; "mobileLayout": { "alias": "mobileLayout"; "required": false; "isSignal": true; }; "cardBreakpoint": { "alias": "cardBreakpoint"; "required": false; "isSignal": true; }; "cardPrimaryCount": { "alias": "cardPrimaryCount"; "required": false; "isSignal": true; }; "sortColumn": { "alias": "sortColumn"; "required": false; "isSignal": true; }; "sortDirection": { "alias": "sortDirection"; "required": false; "isSignal": true; }; }, { "sortChange": "sortChange"; "selectionChange": "selectionChange"; "rowClick": "rowClick"; "rowDoubleClick": "rowDoubleClick"; "sortColumn": "sortColumnChange"; "sortDirection": "sortDirectionChange"; }, ["columnDefs", "detailRowDef", "rowActionsDef"], never, true, [{ directive: typeof TnTestIdDirective; inputs: { "tnTestId": "testId"; }; outputs: {}; }]>;
10230
10290
  }
10231
10291
 
10232
10292
  /**