@kronor/dtv 0.2.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.
Files changed (97) hide show
  1. package/.editorconfig +12 -0
  2. package/.github/copilot-instructions.md +64 -0
  3. package/.github/workflows/ci.yml +51 -0
  4. package/.husky/pre-commit +8 -0
  5. package/README.md +63 -0
  6. package/docs/api/README.md +32 -0
  7. package/docs/api/cell-renderers.md +121 -0
  8. package/docs/api/no-rows-component.md +71 -0
  9. package/docs/api/runtime.md +78 -0
  10. package/e2e/app.spec.ts +6 -0
  11. package/e2e/cell-renderer-setfilterstate.spec.ts +63 -0
  12. package/e2e/filter-sharing.spec.ts +113 -0
  13. package/e2e/filter-url-persistence.spec.ts +36 -0
  14. package/e2e/graphqlMock.ts +144 -0
  15. package/e2e/multi-field-filters.spec.ts +95 -0
  16. package/e2e/pagination.spec.ts +38 -0
  17. package/e2e/payment-request-email-filter.spec.ts +67 -0
  18. package/e2e/save-filter-splitbutton.spec.ts +68 -0
  19. package/e2e/simple-view-email-filter.spec.ts +67 -0
  20. package/e2e/simple-view-transforms.spec.ts +171 -0
  21. package/e2e/simple-view.spec.ts +104 -0
  22. package/e2e/transform-regression.spec.ts +108 -0
  23. package/eslint.config.js +30 -0
  24. package/index.html +17 -0
  25. package/jest.config.js +10 -0
  26. package/package.json +45 -0
  27. package/playwright.config.ts +54 -0
  28. package/public/vite.svg +1 -0
  29. package/src/App.externalRuntime.test.ts +190 -0
  30. package/src/App.tsx +540 -0
  31. package/src/assets/react.svg +1 -0
  32. package/src/components/AIAssistantForm.tsx +241 -0
  33. package/src/components/FilterForm.test.ts +82 -0
  34. package/src/components/FilterForm.tsx +375 -0
  35. package/src/components/PhoneNumberFilter.tsx +102 -0
  36. package/src/components/SavedFilterList.tsx +181 -0
  37. package/src/components/SpeechInput.tsx +67 -0
  38. package/src/components/Table.tsx +119 -0
  39. package/src/components/TablePagination.tsx +40 -0
  40. package/src/components/aiAssistant.test.ts +270 -0
  41. package/src/components/aiAssistant.ts +291 -0
  42. package/src/framework/cell-renderer-components/CurrencyAmount.tsx +30 -0
  43. package/src/framework/cell-renderer-components/LayoutHelpers.tsx +74 -0
  44. package/src/framework/cell-renderer-components/Link.tsx +28 -0
  45. package/src/framework/cell-renderer-components/Mapping.tsx +11 -0
  46. package/src/framework/cell-renderer-components.test.ts +353 -0
  47. package/src/framework/column-definition.tsx +85 -0
  48. package/src/framework/currency.test.ts +46 -0
  49. package/src/framework/currency.ts +62 -0
  50. package/src/framework/data.staticConditions.test.ts +46 -0
  51. package/src/framework/data.test.ts +167 -0
  52. package/src/framework/data.ts +162 -0
  53. package/src/framework/filter-form-state.test.ts +189 -0
  54. package/src/framework/filter-form-state.ts +185 -0
  55. package/src/framework/filter-sharing.test.ts +135 -0
  56. package/src/framework/filter-sharing.ts +118 -0
  57. package/src/framework/filters.ts +194 -0
  58. package/src/framework/graphql.buildHasuraConditions.test.ts +473 -0
  59. package/src/framework/graphql.paginationKey.test.ts +29 -0
  60. package/src/framework/graphql.test.ts +286 -0
  61. package/src/framework/graphql.ts +462 -0
  62. package/src/framework/native-runtime/index.tsx +33 -0
  63. package/src/framework/native-runtime/nativeComponents.test.ts +108 -0
  64. package/src/framework/runtime-reference.test.ts +172 -0
  65. package/src/framework/runtime.ts +15 -0
  66. package/src/framework/saved-filters.test.ts +422 -0
  67. package/src/framework/saved-filters.ts +293 -0
  68. package/src/framework/state.test.ts +86 -0
  69. package/src/framework/state.ts +148 -0
  70. package/src/framework/transform.test.ts +51 -0
  71. package/src/framework/view-parser-initialvalues.test.ts +228 -0
  72. package/src/framework/view-parser.ts +714 -0
  73. package/src/framework/view.test.ts +1805 -0
  74. package/src/framework/view.ts +38 -0
  75. package/src/index.css +6 -0
  76. package/src/main.tsx +99 -0
  77. package/src/views/index.ts +12 -0
  78. package/src/views/payment-requests/components/NoRowsExtendDateRange.tsx +37 -0
  79. package/src/views/payment-requests/components/PaymentMethod.tsx +184 -0
  80. package/src/views/payment-requests/components/PaymentStatusTag.tsx +61 -0
  81. package/src/views/payment-requests/index.ts +1 -0
  82. package/src/views/payment-requests/runtime.tsx +145 -0
  83. package/src/views/payment-requests/view.json +692 -0
  84. package/src/views/payment-requests-initial-values.test.ts +73 -0
  85. package/src/views/request-log/index.ts +2 -0
  86. package/src/views/request-log/runtime.tsx +47 -0
  87. package/src/views/request-log/view.json +123 -0
  88. package/src/views/simple-test-view/index.ts +3 -0
  89. package/src/views/simple-test-view/runtime.tsx +85 -0
  90. package/src/views/simple-test-view/view.json +191 -0
  91. package/src/vite-env.d.ts +1 -0
  92. package/tailwind.config.js +7 -0
  93. package/tsconfig.app.json +26 -0
  94. package/tsconfig.jest.json +6 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +24 -0
  97. package/vite.config.ts +11 -0
@@ -0,0 +1,353 @@
1
+ import * as React from 'react';
2
+ import { Tag } from 'primereact/tag';
3
+ import { CellRenderer } from './column-definition';
4
+ import { FlexRow, FlexColumn, DateTime } from './cell-renderer-components/LayoutHelpers';
5
+ import { CurrencyAmount } from './cell-renderer-components/CurrencyAmount';
6
+ import { Mapping } from './cell-renderer-components/Mapping';
7
+ import { Link } from './cell-renderer-components/Link';
8
+
9
+ describe('Cell Renderer Components', () => {
10
+ it('should provide Badge component to cell renderers', () => {
11
+ // Create a test cell renderer that uses the Badge component
12
+ const testCellRenderer: CellRenderer = ({ data, components, createElement }) => {
13
+ const { Badge } = components;
14
+ return createElement(Badge, {
15
+ value: `Test: ${data.value}`,
16
+ severity: 'success' as any,
17
+ style: { fontSize: '.8rem' }
18
+ });
19
+ };
20
+
21
+ // Create mock props with the components property
22
+ const mockProps = {
23
+ data: { value: 'Hello' },
24
+ setFilterState: jest.fn(),
25
+ applyFilters: jest.fn(),
26
+ updateFilterById: jest.fn(),
27
+ createElement: React.createElement,
28
+ components: {
29
+ Badge: Tag,
30
+ FlexRow,
31
+ FlexColumn,
32
+ Mapping,
33
+ DateTime,
34
+ CurrencyAmount,
35
+ Link
36
+ },
37
+ currency: { majorToMinor: jest.fn(), minorToMajor: jest.fn() }
38
+ };
39
+
40
+ // Test that the cell renderer can access and use the Badge component
41
+ expect(() => {
42
+ const result = testCellRenderer(mockProps);
43
+ expect(result).toBeDefined();
44
+ }).not.toThrow();
45
+ });
46
+
47
+ it('should provide the correct Badge component type', () => {
48
+ const testCellRenderer: CellRenderer = ({ components }) => {
49
+ const { Badge } = components;
50
+ // Verify Badge is the PrimeReact Tag component
51
+ expect(Badge).toBe(Tag);
52
+ return null;
53
+ };
54
+
55
+ const mockProps = {
56
+ data: {},
57
+ setFilterState: jest.fn(),
58
+ applyFilters: jest.fn(),
59
+ updateFilterById: jest.fn(),
60
+ createElement: React.createElement,
61
+ components: {
62
+ Badge: Tag,
63
+ FlexRow,
64
+ FlexColumn,
65
+ Mapping,
66
+ DateTime,
67
+ CurrencyAmount,
68
+ Link
69
+ },
70
+ currency: { majorToMinor: jest.fn(), minorToMajor: jest.fn() }
71
+ };
72
+
73
+ testCellRenderer(mockProps);
74
+ });
75
+
76
+ it('should allow creating Badge elements with typical PrimeReact Tag props', () => {
77
+ const testCellRenderer: CellRenderer = ({ data, components, createElement }) => {
78
+ const { Badge } = components;
79
+
80
+ // Create a Badge using React.createElement (similar to JSX)
81
+ return createElement(Badge, {
82
+ value: data.status,
83
+ severity: 'warning' as any,
84
+ style: { fontSize: '0.8rem', padding: '0.3em 1em' }
85
+ });
86
+ };
87
+
88
+ const mockProps = {
89
+ data: { status: 'Pending' },
90
+ setFilterState: jest.fn(),
91
+ applyFilters: jest.fn(),
92
+ updateFilterById: jest.fn(),
93
+ createElement: React.createElement,
94
+ components: {
95
+ Badge: Tag,
96
+ FlexRow,
97
+ FlexColumn,
98
+ Mapping,
99
+ DateTime,
100
+ CurrencyAmount,
101
+ Link
102
+ },
103
+ currency: { majorToMinor: jest.fn(), minorToMajor: jest.fn() }
104
+ };
105
+
106
+ expect(() => {
107
+ const result = testCellRenderer(mockProps);
108
+ expect(result).toBeDefined();
109
+ }).not.toThrow();
110
+ });
111
+
112
+ it('should provide FlexRow and FlexColumn components to cell renderers', () => {
113
+ // Create a test cell renderer that uses FlexRow and FlexColumn components
114
+ const testCellRenderer: CellRenderer = ({ components, createElement }) => {
115
+ const { FlexRow, FlexColumn } = components;
116
+ return createElement(FlexRow, {
117
+ children: [
118
+ createElement(FlexColumn, { children: 'Vertical Layout' }),
119
+ createElement(FlexColumn, { children: 'Another Column' })
120
+ ]
121
+ });
122
+ };
123
+
124
+ const mockProps = {
125
+ data: { value: 'Layout Test' },
126
+ setFilterState: jest.fn(),
127
+ applyFilters: jest.fn(),
128
+ updateFilterById: jest.fn(),
129
+ createElement: React.createElement,
130
+ components: {
131
+ Badge: Tag,
132
+ FlexRow,
133
+ FlexColumn,
134
+ Mapping,
135
+ DateTime,
136
+ CurrencyAmount,
137
+ Link
138
+ },
139
+ currency: { majorToMinor: jest.fn(), minorToMajor: jest.fn() }
140
+ };
141
+
142
+ // Test that the cell renderer can access and use FlexRow/FlexColumn components
143
+ expect(() => {
144
+ const result = testCellRenderer(mockProps);
145
+ expect(result).toBeDefined();
146
+ }).not.toThrow();
147
+ });
148
+
149
+ it('should support flex-wrap property on FlexRow component', () => {
150
+ // Create a test cell renderer that uses FlexRow with wrap property
151
+ const testCellRenderer: CellRenderer = ({ components, createElement }) => {
152
+ const { FlexRow } = components;
153
+ return createElement(FlexRow, {
154
+ wrap: true,
155
+ children: ['Item 1', 'Item 2', 'Item 3']
156
+ });
157
+ };
158
+
159
+ const mockProps = {
160
+ data: { value: 'Wrap Test' },
161
+ setFilterState: jest.fn(),
162
+ applyFilters: jest.fn(),
163
+ updateFilterById: jest.fn(),
164
+ createElement: React.createElement,
165
+ components: {
166
+ Badge: Tag,
167
+ FlexRow,
168
+ FlexColumn,
169
+ Mapping,
170
+ DateTime,
171
+ CurrencyAmount,
172
+ Link
173
+ },
174
+ currency: { majorToMinor: jest.fn(), minorToMajor: jest.fn() }
175
+ };
176
+
177
+ // Test that FlexRow can handle wrap property
178
+ expect(() => {
179
+ const result = testCellRenderer(mockProps);
180
+ expect(result).toBeDefined();
181
+ }).not.toThrow();
182
+ });
183
+
184
+ it('should support different wrap values on FlexRow component', () => {
185
+ const wrapValues = ['wrap', 'nowrap', 'wrap-reverse'];
186
+
187
+ wrapValues.forEach(wrapValue => {
188
+ const testCellRenderer: CellRenderer = ({ components, createElement }) => {
189
+ const { FlexRow } = components;
190
+ return createElement(FlexRow, {
191
+ wrap: wrapValue,
192
+ children: ['Test Item']
193
+ });
194
+ };
195
+
196
+ const mockProps = {
197
+ data: { value: `Wrap Test ${wrapValue}` },
198
+ setFilterState: jest.fn(),
199
+ applyFilters: jest.fn(),
200
+ updateFilterById: jest.fn(),
201
+ createElement: React.createElement,
202
+ components: {
203
+ Badge: Tag,
204
+ FlexRow,
205
+ FlexColumn,
206
+ Mapping,
207
+ DateTime,
208
+ CurrencyAmount,
209
+ Link
210
+ },
211
+ currency: { majorToMinor: jest.fn(), minorToMajor: jest.fn() }
212
+ };
213
+
214
+ expect(() => {
215
+ const result = testCellRenderer(mockProps);
216
+ expect(result).toBeDefined();
217
+ }).not.toThrow();
218
+ });
219
+ });
220
+
221
+ it('should provide Mapping component to cell renderers', () => {
222
+ const testCellRenderer: CellRenderer = ({ data, components, createElement }) => {
223
+ const { Mapping } = components;
224
+ const statusMap = { 'pending': 'Pending', 'approved': 'Approved', 'rejected': 'Rejected' };
225
+ return createElement(Mapping, { value: data.status, map: statusMap });
226
+ };
227
+
228
+ const mockProps = {
229
+ data: { status: 'pending' },
230
+ setFilterState: jest.fn(),
231
+ applyFilters: jest.fn(),
232
+ updateFilterById: jest.fn(),
233
+ createElement: React.createElement,
234
+ components: {
235
+ Badge: Tag,
236
+ FlexRow,
237
+ FlexColumn,
238
+ Mapping,
239
+ DateTime,
240
+ CurrencyAmount,
241
+ Link
242
+ },
243
+ currency: { majorToMinor: jest.fn(), minorToMajor: jest.fn() }
244
+ };
245
+
246
+ // Test that the cell renderer can access and use the Mapping component
247
+ expect(() => {
248
+ const result = testCellRenderer(mockProps);
249
+ expect(result).toBeDefined();
250
+ }).not.toThrow();
251
+ });
252
+
253
+ it('should provide DateTime component to cell renderers', () => {
254
+ const testCellRenderer: CellRenderer = ({ data, components, createElement }) => {
255
+ const { DateTime } = components;
256
+ return createElement(DateTime, { date: data.createdAt, options: { dateStyle: 'short' } });
257
+ };
258
+
259
+ const mockProps = {
260
+ data: { createdAt: '2023-01-01T12:00:00Z' },
261
+ setFilterState: jest.fn(),
262
+ applyFilters: jest.fn(),
263
+ updateFilterById: jest.fn(),
264
+ createElement: React.createElement,
265
+ components: {
266
+ Badge: Tag,
267
+ FlexRow,
268
+ FlexColumn,
269
+ Mapping,
270
+ DateTime,
271
+ CurrencyAmount,
272
+ Link
273
+ },
274
+ currency: { majorToMinor: jest.fn(), minorToMajor: jest.fn() }
275
+ };
276
+
277
+ // Test that the cell renderer can access and use the DateTime component
278
+ expect(() => {
279
+ const result = testCellRenderer(mockProps);
280
+ expect(result).toBeDefined();
281
+ }).not.toThrow();
282
+ });
283
+
284
+ it('should provide CurrencyAmount component to cell renderers', () => {
285
+ const testCellRenderer: CellRenderer = ({ data, components, createElement }) => {
286
+ const { CurrencyAmount } = components;
287
+ return createElement(CurrencyAmount, {
288
+ amount: data.amount,
289
+ currency: data.currency || 'USD',
290
+ options: { minimumFractionDigits: 2 }
291
+ });
292
+ };
293
+
294
+ const mockProps = {
295
+ data: { amount: 12345, currency: 'EUR' },
296
+ setFilterState: jest.fn(),
297
+ applyFilters: jest.fn(),
298
+ updateFilterById: jest.fn(),
299
+ createElement: React.createElement,
300
+ components: {
301
+ Badge: Tag,
302
+ FlexRow,
303
+ FlexColumn,
304
+ Mapping,
305
+ DateTime,
306
+ CurrencyAmount,
307
+ Link
308
+ },
309
+ currency: { majorToMinor: jest.fn(), minorToMajor: jest.fn() }
310
+ };
311
+
312
+ // Test that the cell renderer can access and use the CurrencyAmount component
313
+ expect(() => {
314
+ const result = testCellRenderer(mockProps);
315
+ expect(result).toBeDefined();
316
+ }).not.toThrow();
317
+ });
318
+
319
+ it('should provide Link component to cell renderers', () => {
320
+ const testCellRenderer: CellRenderer = ({ data, components, createElement }) => {
321
+ const { Link } = components;
322
+ return createElement(Link, {
323
+ text: data.linkText || 'Click here',
324
+ href: data.url || '#',
325
+ className: 'custom-link-class'
326
+ });
327
+ };
328
+
329
+ const mockProps = {
330
+ data: { linkText: 'Visit Example', url: 'https://example.com' },
331
+ setFilterState: jest.fn(),
332
+ applyFilters: jest.fn(),
333
+ updateFilterById: jest.fn(),
334
+ createElement: React.createElement,
335
+ components: {
336
+ Badge: Tag,
337
+ FlexRow,
338
+ FlexColumn,
339
+ Mapping,
340
+ DateTime,
341
+ CurrencyAmount,
342
+ Link
343
+ },
344
+ currency: { majorToMinor: jest.fn(), minorToMajor: jest.fn() }
345
+ };
346
+
347
+ // Test that the cell renderer can access and use the Link component
348
+ expect(() => {
349
+ const result = testCellRenderer(mockProps);
350
+ expect(result).toBeDefined();
351
+ }).not.toThrow();
352
+ });
353
+ });
@@ -0,0 +1,85 @@
1
+ import { ReactNode, createElement } from "react";
2
+ import { FlexRow, FlexColumn, DateTime } from "./cell-renderer-components/LayoutHelpers";
3
+ import { CurrencyAmount } from './cell-renderer-components/CurrencyAmount';
4
+ import { majorToMinor, minorToMajor } from './currency';
5
+ import { Mapping } from "./cell-renderer-components/Mapping";
6
+ import { Link } from "./cell-renderer-components/Link";
7
+ import { Tag } from 'primereact/tag';
8
+ import { FilterState } from "./state";
9
+ import { FilterId } from "./filters";
10
+
11
+ export type CellRendererProps = {
12
+ data: Record<string, any>;
13
+ setFilterState: (updater: (currentState: FilterState) => FilterState) => void; // Function to update filter state
14
+ applyFilters: () => void; // Function to trigger data fetch with current filter state
15
+ updateFilterById: (filterId: FilterId, updater: (currentValue: any) => any) => void; // Narrow helper to update a specific filter by id
16
+ createElement: typeof createElement; // React createElement function
17
+ components: {
18
+ Badge: typeof Tag; // PrimeReact Tag component exposed as Badge for user convenience
19
+ FlexRow: typeof FlexRow; // Horizontal layout component
20
+ FlexColumn: typeof FlexColumn; // Vertical layout component
21
+ Mapping: typeof Mapping; // Generic mapping component for displaying mapped values
22
+ DateTime: typeof DateTime; // Date formatting component
23
+ CurrencyAmount: typeof CurrencyAmount; // Currency formatting component
24
+ Link: typeof Link; // Link component for creating hyperlinks
25
+ };
26
+ currency: {
27
+ minorToMajor: typeof minorToMajor;
28
+ majorToMinor: typeof majorToMinor;
29
+ }
30
+ };
31
+
32
+ export type CellRenderer = (props: CellRendererProps) => ReactNode;
33
+
34
+ export type OrderByConfig = {
35
+ key: string; // data key to order by
36
+ direction: 'ASC' | 'DESC';
37
+ };
38
+
39
+ // Tagged ADT for QueryConfig: either a path or a config group
40
+ export type Field = {
41
+ type: 'field';
42
+ path: string; // dot-separated data path
43
+ };
44
+
45
+ export type QueryConfig = {
46
+ field: string
47
+ path?: string; // path for querying inside JSON columns
48
+ orderBy?: OrderByConfig | OrderByConfig[];
49
+ limit?: number;
50
+ }
51
+
52
+ export type QueryConfigs = {
53
+ type: 'queryConfigs'
54
+ configs: QueryConfig[]
55
+ };
56
+
57
+ // Field alias support - wraps any FieldQuery with an alias name
58
+ export type FieldAlias = {
59
+ type: 'fieldAlias';
60
+ alias: string; // the alias name to use in GraphQL
61
+ field: FieldQuery; // the underlying field query
62
+ };
63
+
64
+ export type FieldQuery = Field | QueryConfigs | FieldAlias;
65
+
66
+ // Helper to create a Field
67
+ export function field(path: string): FieldQuery {
68
+ return { type: 'field', path };
69
+ }
70
+
71
+ // Helper to create QueryConfigs
72
+ export function queryConfigs(configs: QueryConfig[]): FieldQuery {
73
+ return { type: 'queryConfigs', configs };
74
+ }
75
+
76
+ // Helper to create a FieldAlias
77
+ export function fieldAlias(alias: string, fieldQuery: FieldQuery): FieldQuery {
78
+ return { type: 'fieldAlias', alias, field: fieldQuery };
79
+ }
80
+
81
+ export type ColumnDefinition = {
82
+ data: FieldQuery[];
83
+ name: string; // column display name
84
+ cellRenderer: CellRenderer;
85
+ };
@@ -0,0 +1,46 @@
1
+ import { majorToMinor, minorToMajor, getCurrencyMajorUnitScale } from './currency';
2
+
3
+ describe('currency unit conversion', () => {
4
+ it('converts USD major to minor and back (2 decimals)', () => {
5
+ const scale = getCurrencyMajorUnitScale('USD');
6
+ expect(scale).toBe(100);
7
+ const minor = majorToMinor(12.34, 'USD');
8
+ expect(minor).toBe(1234);
9
+ const major = minorToMajor(minor, 'USD');
10
+ expect(major).toBeCloseTo(12.34);
11
+ });
12
+
13
+ it('handles rounding correctly (USD)', () => {
14
+ // 0.015 * 100 = 1.5 -> rounds to 2 cents
15
+ const minor = majorToMinor(0.015, 'USD');
16
+ expect(minor).toBe(2);
17
+ expect(minorToMajor(minor, 'USD')).toBeCloseTo(0.02);
18
+ });
19
+
20
+ it('supports zero-decimal currency (JPY)', () => {
21
+ const scale = getCurrencyMajorUnitScale('JPY');
22
+ expect(scale).toBe(1);
23
+ const minor = majorToMinor(1234, 'JPY');
24
+ expect(minor).toBe(1234);
25
+ const major = minorToMajor(minor, 'JPY');
26
+ expect(major).toBe(1234);
27
+ });
28
+
29
+ it('supports three-decimal currency (KWD)', () => {
30
+ const scale = getCurrencyMajorUnitScale('KWD');
31
+ expect(scale).toBe(1000);
32
+ const minor = majorToMinor(1.234, 'KWD');
33
+ expect(minor).toBe(1234);
34
+ const major = minorToMajor(minor, 'KWD');
35
+ expect(major).toBeCloseTo(1.234);
36
+ });
37
+
38
+ it('throws on non-finite major', () => {
39
+ expect(() => majorToMinor(Number.NaN, 'USD')).toThrow();
40
+ expect(() => majorToMinor(Number.POSITIVE_INFINITY, 'USD')).toThrow();
41
+ });
42
+
43
+ it('throws on non-integer minor', () => {
44
+ expect(() => minorToMajor(12.34 as unknown as number, 'USD')).toThrow();
45
+ });
46
+ });
@@ -0,0 +1,62 @@
1
+ // Currency helper utilities extracted from the former Currency component file.
2
+ // These functions provide locale resolution, fraction digit discovery, and
3
+ // conversion helpers between major (display) and minor (integer) currency units.
4
+
5
+ // Cache for currency fraction digit lookups
6
+ const currencyFractionDigitCache: Map<string, number> = new Map();
7
+
8
+ /**
9
+ * Resolve a locale to use for currency formatting.
10
+ * Priority:
11
+ * 1. Explicitly provided locale argument
12
+ * 2. navigator.language (browser default)
13
+ * 3. First entry in navigator.languages
14
+ * 4. undefined (lets Intl use implementation default)
15
+ */
16
+ export function resolveLocale(locale?: string): string | undefined {
17
+ if (locale) return locale;
18
+ if (typeof navigator !== 'undefined') {
19
+ const lang = (navigator as any).language || (Array.isArray((navigator as any).languages) && (navigator as any).languages[0]);
20
+ return typeof lang === 'string' ? lang : undefined;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ export function getCurrencyFractionDigits(currency: string, locale: string | undefined = undefined): number {
26
+ const code = currency.toUpperCase();
27
+ if (currencyFractionDigitCache.has(code)) return currencyFractionDigitCache.get(code)!;
28
+ try {
29
+ const { maximumFractionDigits } = new Intl.NumberFormat(locale, { style: 'currency', currency: code }).resolvedOptions();
30
+ const value: number = typeof maximumFractionDigits === 'number' ? maximumFractionDigits : 2;
31
+ currencyFractionDigitCache.set(code, value);
32
+ return value;
33
+ } catch {
34
+ currencyFractionDigitCache.set(code, 2);
35
+ return 2;
36
+ }
37
+ }
38
+
39
+ export function getCurrencyMajorUnitScale(currency: string, locale: string | undefined = undefined): number {
40
+ return Math.pow(10, getCurrencyFractionDigits(currency, locale));
41
+ }
42
+
43
+ export function majorToMinor(major: number, currency: string, locale?: string): number {
44
+ if (typeof major !== 'number' || !isFinite(major)) throw new Error('major must be a finite number');
45
+ const scale = getCurrencyMajorUnitScale(currency, locale);
46
+ // Use rounding to nearest minor unit to avoid FP drift
47
+ return Math.round(major * scale);
48
+ }
49
+
50
+ export function minorToMajor(minor: number, currency: string, locale?: string): number {
51
+ if (typeof minor !== 'number' || !Number.isInteger(minor)) throw new Error('minor must be an integer number of minor units');
52
+ const scale = getCurrencyMajorUnitScale(currency, locale);
53
+ return minor / scale;
54
+ }
55
+
56
+ export default {
57
+ resolveLocale,
58
+ getCurrencyFractionDigits,
59
+ getCurrencyMajorUnitScale,
60
+ majorToMinor,
61
+ minorToMajor
62
+ };
@@ -0,0 +1,46 @@
1
+ import { fetchData } from './data';
2
+ import { View } from './view';
3
+ import { ColumnDefinition } from './column-definition';
4
+ import { FilterSchemasAndGroups } from './filters';
5
+
6
+ // We only test merging logic; GraphQL call will be mocked.
7
+
8
+ describe('fetchData staticConditions merging', () => {
9
+ const mockClient: any = {
10
+ request: jest.fn()
11
+ };
12
+ let capturedVariables: any = null;
13
+ const requestSpy = mockClient.request.mockImplementation((_query: string, _vars: any) => {
14
+ capturedVariables = _vars;
15
+ return Promise.resolve({ testCollection: [] });
16
+ });
17
+
18
+ const view: View = {
19
+ title: 'Test',
20
+ id: 'test',
21
+ collectionName: 'testCollection',
22
+ columnDefinitions: [{ data: [{ type: 'field', path: 'id' }] } as ColumnDefinition],
23
+ filterSchema: { groups: [], filters: [] } as FilterSchemasAndGroups,
24
+ boolExpType: 'BoolExp',
25
+ orderByType: '[OrderBy!]',
26
+ paginationKey: 'id',
27
+ staticConditions: [{ status: { _eq: 'ACTIVE' } }, { deleted_at: { _is_null: true } }]
28
+ };
29
+
30
+ afterEach(() => {
31
+ jest.clearAllMocks();
32
+ });
33
+
34
+ it('merges staticConditions when no user filters', async () => {
35
+ const result = await fetchData({ client: mockClient, view, query: 'query', filterState: new Map(), rows: 10, cursor: null });
36
+ expect(requestSpy).toHaveBeenCalled();
37
+ expect(capturedVariables.conditions).toEqual({ _and: [{}, { status: { _eq: 'ACTIVE' } }, { deleted_at: { _is_null: true } }] });
38
+ expect(result.rows).toEqual([]);
39
+ });
40
+
41
+ it('merges pagination condition efficiently by appending', async () => {
42
+ await fetchData({ client: mockClient, view, query: 'query', filterState: new Map(), rows: 10, cursor: 50 });
43
+ expect(capturedVariables.conditions._and.length).toBe(4); // {}, two static, pagination
44
+ expect(capturedVariables.conditions._and[3]).toEqual({ id: { _lt: 50 } });
45
+ });
46
+ });