@cccsaurora/howler-ui 2.18.0-dev.841 → 2.19.0-cases.1040

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 (171) hide show
  1. package/api/action/index.d.ts +5 -4
  2. package/api/action/index.js +8 -8
  3. package/api/analytic/index.d.ts +3 -2
  4. package/api/analytic/index.js +4 -4
  5. package/api/analytic/owner.d.ts +2 -1
  6. package/api/analytic/owner.js +2 -2
  7. package/api/analytic/rules.d.ts +2 -1
  8. package/api/analytic/rules.js +2 -2
  9. package/api/dossier/index.d.ts +4 -3
  10. package/api/dossier/index.js +6 -6
  11. package/api/hit/index.d.ts +3 -2
  12. package/api/hit/index.js +4 -4
  13. package/api/hit/labels.d.ts +3 -2
  14. package/api/hit/labels.js +4 -4
  15. package/api/hit/overwrite.d.ts +2 -1
  16. package/api/hit/overwrite.js +2 -2
  17. package/api/hit/transition.d.ts +2 -1
  18. package/api/hit/transition.js +2 -2
  19. package/api/index.d.ts +5 -4
  20. package/api/index.js +8 -8
  21. package/api/overview/index.d.ts +4 -3
  22. package/api/overview/index.js +6 -6
  23. package/api/search/overview.d.ts +2 -2
  24. package/api/template/index.d.ts +4 -3
  25. package/api/template/index.js +6 -6
  26. package/api/user/index.d.ts +2 -1
  27. package/api/user/index.js +2 -2
  28. package/api/view/index.d.ts +4 -3
  29. package/api/view/index.js +6 -6
  30. package/components/app/App.d.ts +1 -0
  31. package/components/app/App.js +24 -6
  32. package/components/app/providers/FavouritesProvider.js +0 -4
  33. package/components/app/providers/ModalProvider.d.ts +1 -0
  34. package/components/app/providers/ModalProvider.js +5 -1
  35. package/components/app/providers/RecordProvider.d.ts +1 -1
  36. package/components/app/providers/RecordProvider.js +1 -1
  37. package/components/app/providers/SearchResponseProvider.d.ts +21 -0
  38. package/components/app/providers/SearchResponseProvider.js +81 -0
  39. package/components/app/providers/SearchResponseProvider.test.d.ts +1 -0
  40. package/components/app/providers/SearchResponseProvider.test.js +406 -0
  41. package/components/app/providers/ViewProvider.test.js +4 -1
  42. package/components/elements/addons/buttons/CustomButton.test.d.ts +1 -0
  43. package/components/elements/addons/buttons/CustomButton.test.js +106 -0
  44. package/components/elements/addons/layout/FlexOne.test.d.ts +1 -0
  45. package/components/elements/addons/layout/FlexOne.test.js +30 -0
  46. package/components/elements/addons/lists/TuiListBase.js +4 -0
  47. package/components/elements/addons/lists/TuiListElement.js +6 -3
  48. package/components/elements/addons/lists/index.d.ts +1 -0
  49. package/components/elements/addons/search/SearchPagination.d.ts +2 -1
  50. package/components/elements/addons/search/SearchPagination.js +4 -4
  51. package/components/elements/addons/search/SearchPagination.test.d.ts +1 -0
  52. package/components/elements/addons/search/SearchPagination.test.js +80 -0
  53. package/components/elements/addons/search/SearchTotal.test.d.ts +1 -0
  54. package/components/elements/addons/search/SearchTotal.test.js +47 -0
  55. package/components/elements/display/DocumentationButton.test.d.ts +1 -0
  56. package/components/elements/display/DocumentationButton.test.js +84 -0
  57. package/components/elements/display/DynamicTabs.test.d.ts +1 -0
  58. package/components/elements/display/DynamicTabs.test.js +93 -0
  59. package/components/elements/display/HowlerAvatar.js +2 -1
  60. package/components/elements/display/HowlerCard.test.d.ts +1 -0
  61. package/components/elements/display/HowlerCard.test.js +51 -0
  62. package/components/elements/display/Image.test.d.ts +1 -0
  63. package/components/elements/display/Image.test.js +79 -0
  64. package/components/elements/display/ItemManager.d.ts +2 -2
  65. package/components/elements/display/ItemManager.js +1 -1
  66. package/components/elements/display/Markdown.js +9 -2
  67. package/components/elements/display/QueryResultText.test.d.ts +1 -0
  68. package/components/elements/display/QueryResultText.test.js +48 -0
  69. package/components/elements/display/TextDivider.test.d.ts +1 -0
  70. package/components/elements/display/TextDivider.test.js +43 -0
  71. package/components/elements/display/TypingIndicator.test.d.ts +1 -0
  72. package/components/elements/display/TypingIndicator.test.js +33 -0
  73. package/components/elements/display/modals/ConfirmDeleteModal.d.ts +4 -0
  74. package/components/elements/display/modals/ConfirmDeleteModal.js +4 -2
  75. package/components/elements/hit/HitBanner.js +6 -3
  76. package/components/elements/hit/HitLinks.js +1 -1
  77. package/components/elements/hit/HitSummary.js +3 -2
  78. package/components/elements/hit/elements/AnalyticLink.js +1 -1
  79. package/components/elements/hit/elements/Assigned.d.ts +2 -0
  80. package/components/elements/hit/elements/Assigned.js +15 -13
  81. package/components/elements/record/RecordContextMenu.js +15 -3
  82. package/components/elements/record/RecordContextMenu.test.js +96 -0
  83. package/components/hooks/useMyApi.d.ts +1 -1
  84. package/components/hooks/useMyPreferences.js +2 -1
  85. package/components/routes/403.d.ts +3 -0
  86. package/components/routes/403.js +10 -0
  87. package/components/routes/403.test.d.ts +1 -0
  88. package/components/routes/403.test.js +27 -0
  89. package/components/routes/404.test.d.ts +1 -0
  90. package/components/routes/404.test.js +26 -0
  91. package/components/routes/ErrorBoundary.test.d.ts +1 -0
  92. package/components/routes/ErrorBoundary.test.js +43 -0
  93. package/components/routes/ErrorOccured.test.d.ts +1 -0
  94. package/components/routes/ErrorOccured.test.js +35 -0
  95. package/components/routes/Logout.test.d.ts +1 -0
  96. package/components/routes/Logout.test.js +59 -0
  97. package/components/routes/action/edit/ActionEditor.js +1 -1
  98. package/components/routes/action/useMyActionFunctions.js +30 -6
  99. package/components/routes/action/view/ActionDetails.js +15 -2
  100. package/components/routes/action/view/ActionSearch.js +32 -20
  101. package/components/routes/action/view/markdown/integrations.en.md.js +1 -1
  102. package/components/routes/action/view/markdown/integrations.fr.md.js +1 -1
  103. package/components/routes/analytics/AnalyticDetails.js +10 -6
  104. package/components/routes/analytics/AnalyticSearch.js +6 -6
  105. package/components/routes/cases/Cases.js +5 -6
  106. package/components/routes/cases/modals/ResolveModal.js +14 -13
  107. package/components/routes/cases/modals/ResolveModal.test.js +85 -25
  108. package/components/routes/dossiers/Dossiers.js +25 -19
  109. package/components/routes/help/ActionIntroductionDocumentation.js +1 -1
  110. package/components/routes/help/markdown/en/client.md.js +1 -1
  111. package/components/routes/help/markdown/en/retention.md.js +1 -1
  112. package/components/routes/help/markdown/fr/client.md.js +1 -1
  113. package/components/routes/help/markdown/fr/retention.md.js +1 -1
  114. package/components/routes/hits/search/shared/CustomSpan.js +7 -7
  115. package/components/routes/hits/search/shared/CustomSpan.test.d.ts +1 -0
  116. package/components/routes/hits/search/shared/CustomSpan.test.js +170 -0
  117. package/components/routes/home/AddNewCard.js +1 -1
  118. package/components/routes/home/AddNewCard.test.d.ts +1 -0
  119. package/components/routes/home/AddNewCard.test.js +86 -0
  120. package/components/routes/home/ViewCard.js +1 -1
  121. package/components/routes/home/ViewRefresh.test.d.ts +1 -0
  122. package/components/routes/home/ViewRefresh.test.js +79 -0
  123. package/components/routes/overviews/OverviewCard.test.d.ts +1 -0
  124. package/components/routes/overviews/OverviewCard.test.js +108 -0
  125. package/components/routes/overviews/OverviewViewer.js +16 -8
  126. package/components/routes/overviews/Overviews.js +25 -19
  127. package/components/routes/overviews/template/en.md.js +1 -1
  128. package/components/routes/overviews/template/fr.md.js +1 -1
  129. package/components/routes/settings/LocalSection.js +2 -1
  130. package/components/routes/settings/SettingsSection.test.d.ts +1 -0
  131. package/components/routes/settings/SettingsSection.test.js +19 -0
  132. package/components/routes/templates/TemplateCard.d.ts +3 -1
  133. package/components/routes/templates/TemplateCard.js +8 -4
  134. package/components/routes/templates/TemplateCard.test.d.ts +1 -0
  135. package/components/routes/templates/TemplateCard.test.js +115 -0
  136. package/components/routes/templates/TemplateViewer.js +62 -16
  137. package/components/routes/templates/Templates.js +35 -13
  138. package/components/routes/views/Views.js +21 -14
  139. package/locales/en/translation.json +25 -4
  140. package/locales/fr/translation.json +25 -4
  141. package/models/entities/generated/ApiType.d.ts +1 -1
  142. package/models/entities/generated/Hit.d.ts +1 -0
  143. package/models/entities/generated/Howler.d.ts +1 -0
  144. package/models/entities/generated/UserUser.d.ts +2 -0
  145. package/package.json +143 -148
  146. package/rest/FetchClient.test.d.ts +1 -0
  147. package/rest/FetchClient.test.js +81 -0
  148. package/setupTests.js +5 -0
  149. package/utils/Throttler.test.d.ts +1 -0
  150. package/utils/Throttler.test.js +135 -0
  151. package/utils/actionUtils.test.d.ts +1 -0
  152. package/utils/actionUtils.test.js +140 -0
  153. package/utils/constants.d.ts +1 -0
  154. package/utils/constants.js +1 -0
  155. package/utils/hitFunctions.test.d.ts +1 -0
  156. package/utils/hitFunctions.test.js +52 -0
  157. package/utils/localStorage.test.d.ts +1 -0
  158. package/utils/localStorage.test.js +96 -0
  159. package/utils/menuUtils.js +1 -1
  160. package/utils/menuUtils.test.d.ts +1 -0
  161. package/utils/menuUtils.test.js +198 -0
  162. package/utils/sessionStorage.test.d.ts +1 -0
  163. package/utils/sessionStorage.test.js +81 -0
  164. package/utils/stringUtils.test.d.ts +1 -0
  165. package/utils/stringUtils.test.js +159 -0
  166. package/utils/utils.test.d.ts +1 -0
  167. package/utils/utils.test.js +292 -0
  168. package/utils/viewUtils.test.d.ts +1 -0
  169. package/utils/viewUtils.test.js +45 -0
  170. package/utils/xsrf.test.d.ts +1 -0
  171. package/utils/xsrf.test.js +41 -0
@@ -0,0 +1,81 @@
1
+ /// <reference types="vitest" />
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { StorageKey } from './constants';
4
+ import { getAxiosCache, getStored, removeStored, setAxiosCache, setStored } from './sessionStorage';
5
+ // The sessionStorage module uses a debounced write (3 000 ms). Tests that need to verify
6
+ // persistence to sessionStorage use fake timers and advance past the debounce delay.
7
+ // afterEach flushes any pending changes so module-level state is clean for the next test.
8
+ const DEBOUNCE_MS = 3100; // slightly past the 3 000 ms throttle
9
+ const SESSION_PREFIX = 'howler.ui.cache';
10
+ describe('sessionStorage utilities', () => {
11
+ beforeEach(() => {
12
+ vi.useFakeTimers();
13
+ });
14
+ afterEach(() => {
15
+ // Flush pending debounced writes – this also resets the internal `changes` map.
16
+ vi.advanceTimersByTime(DEBOUNCE_MS);
17
+ vi.useRealTimers();
18
+ sessionStorage.clear();
19
+ });
20
+ // -------------------------------------------------------------------------
21
+ // getAxiosCache / setAxiosCache
22
+ // -------------------------------------------------------------------------
23
+ describe('getAxiosCache / setAxiosCache', () => {
24
+ it('returns an empty object when no cache has been stored', () => {
25
+ expect(getAxiosCache()).toEqual({});
26
+ });
27
+ it('stores and retrieves a cached entry after the debounce fires', () => {
28
+ setAxiosCache('etag-1', { data: 'value' });
29
+ vi.advanceTimersByTime(DEBOUNCE_MS);
30
+ expect(getAxiosCache()['etag-1']).toEqual({ data: 'value' });
31
+ });
32
+ it('accumulates multiple distinct cache entries', () => {
33
+ setAxiosCache('etag-a', { data: 'a' });
34
+ setAxiosCache('etag-b', { data: 'b' });
35
+ vi.advanceTimersByTime(DEBOUNCE_MS);
36
+ const cache = getAxiosCache();
37
+ expect(cache['etag-a']).toEqual({ data: 'a' });
38
+ expect(cache['etag-b']).toEqual({ data: 'b' });
39
+ });
40
+ it('overwrites an earlier entry for the same etag', () => {
41
+ setAxiosCache('etag-1', { data: 'old' });
42
+ setAxiosCache('etag-1', { data: 'new' });
43
+ vi.advanceTimersByTime(DEBOUNCE_MS);
44
+ expect(getAxiosCache()['etag-1']).toEqual({ data: 'new' });
45
+ });
46
+ });
47
+ // -------------------------------------------------------------------------
48
+ // setStored / getStored (object values)
49
+ // -------------------------------------------------------------------------
50
+ describe('setStored / getStored', () => {
51
+ it('makes an object value immediately readable via getStored (in-memory, before flush)', () => {
52
+ setStored(StorageKey.AXIOS_CACHE, { immediate: true });
53
+ // Timer has NOT advanced yet – data is in the in-memory `changes` map.
54
+ const result = getStored(StorageKey.AXIOS_CACHE);
55
+ expect(result.immediate).toBe(true);
56
+ });
57
+ it('persists an object value to sessionStorage after the debounce delay', () => {
58
+ const key = `${SESSION_PREFIX}.${StorageKey.AXIOS_CACHE}`;
59
+ setStored(StorageKey.AXIOS_CACHE, { persisted: true });
60
+ // Before flush: sessionStorage is still empty.
61
+ expect(sessionStorage.getItem(key)).toBeNull();
62
+ vi.advanceTimersByTime(DEBOUNCE_MS);
63
+ expect(JSON.parse(sessionStorage.getItem(key))).toEqual({ persisted: true });
64
+ });
65
+ });
66
+ // -------------------------------------------------------------------------
67
+ // removeStored
68
+ // -------------------------------------------------------------------------
69
+ describe('removeStored', () => {
70
+ it('removes a key from sessionStorage', () => {
71
+ setStored(StorageKey.AXIOS_CACHE, { val: 1 });
72
+ vi.advanceTimersByTime(DEBOUNCE_MS);
73
+ removeStored(StorageKey.AXIOS_CACHE);
74
+ const key = `${SESSION_PREFIX}.${StorageKey.AXIOS_CACHE}`;
75
+ expect(sessionStorage.getItem(key)).toBeNull();
76
+ });
77
+ it('does not throw when removing a key that was never set', () => {
78
+ expect(() => removeStored(StorageKey.AXIOS_CACHE)).not.toThrow();
79
+ });
80
+ });
81
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,159 @@
1
+ /// <reference types="vitest" />
2
+ import { describe, expect, it } from 'vitest';
3
+ import { maxLenStr, nameToInitials, safeFieldValue, safeFieldValueURI, safeStringPropertyCompare, sanitizeLuceneQuery, sanitizeMultilineLucene, validateRegex } from './stringUtils';
4
+ describe('nameToInitials', () => {
5
+ it('returns initials from a standard first-last name', () => {
6
+ expect(nameToInitials('John Doe')).toEqual(['J', 'D']);
7
+ });
8
+ it('returns a single initial when there is only one word', () => {
9
+ expect(nameToInitials('John')).toEqual(['J']);
10
+ });
11
+ it('uses only the first two words even when there are more', () => {
12
+ expect(nameToInitials('John Middle Doe')).toEqual(['J', 'M']);
13
+ });
14
+ it('reverses order when the name is in "last, first" comma format', () => {
15
+ // "Smith, John" → parts = ["Smith,", "John"] → reversed → ["John", "Smith,"] → initials ["J", "S"]
16
+ expect(nameToInitials('Smith, John')).toEqual(['J', 'S']);
17
+ });
18
+ it('returns uppercase initials regardless of input case', () => {
19
+ expect(nameToInitials('alice bob')).toEqual(['A', 'B']);
20
+ });
21
+ });
22
+ describe('maxLenStr', () => {
23
+ it('returns the string unchanged when shorter than the limit', () => {
24
+ expect(maxLenStr('hello', 10)).toBe('hello');
25
+ });
26
+ it('returns the string unchanged when length equals the limit', () => {
27
+ expect(maxLenStr('hello', 5)).toBe('hello');
28
+ });
29
+ it('truncates and appends "..." when string exceeds the limit', () => {
30
+ expect(maxLenStr('hello world', 8)).toBe('hello...');
31
+ });
32
+ it('handles an empty string', () => {
33
+ expect(maxLenStr('', 5)).toBe('');
34
+ });
35
+ it('truncation accounts for the three-character ellipsis', () => {
36
+ // len=6 → keeps first 3 chars then "..."
37
+ expect(maxLenStr('abcdefgh', 6)).toBe('abc...');
38
+ });
39
+ });
40
+ describe('safeFieldValue', () => {
41
+ it('wraps a plain string in double quotes', () => {
42
+ expect(safeFieldValue('test')).toBe('"test"');
43
+ });
44
+ it('escapes backslashes', () => {
45
+ expect(safeFieldValue('back\\slash')).toBe('"back\\\\slash"');
46
+ });
47
+ it('escapes embedded double quotes', () => {
48
+ expect(safeFieldValue('say "hello"')).toBe('"say \\"hello\\""');
49
+ });
50
+ it('converts a number to a quoted string', () => {
51
+ expect(safeFieldValue(42)).toBe('"42"');
52
+ });
53
+ it('converts a boolean to a quoted string', () => {
54
+ expect(safeFieldValue(true)).toBe('"true"');
55
+ });
56
+ it('escapes backslash before escaping quotes (order matters)', () => {
57
+ expect(safeFieldValue('\\"')).toBe('"\\\\\\"\"');
58
+ });
59
+ });
60
+ describe('safeFieldValueURI', () => {
61
+ it('URI-encodes the safe field value of a plain string', () => {
62
+ expect(safeFieldValueURI('hello')).toBe(encodeURIComponent('"hello"'));
63
+ });
64
+ it('URI-encodes special characters', () => {
65
+ expect(safeFieldValueURI('hello world')).toBe(encodeURIComponent('"hello world"'));
66
+ });
67
+ it('handles strings that already contain lucene special chars', () => {
68
+ const input = 'field:value';
69
+ expect(safeFieldValueURI(input)).toBe(encodeURIComponent(safeFieldValue(input)));
70
+ });
71
+ });
72
+ describe('sanitizeLuceneQuery', () => {
73
+ it('escapes colons', () => {
74
+ expect(sanitizeLuceneQuery('field:value')).toBe('field\\:value');
75
+ });
76
+ it('escapes forward slashes', () => {
77
+ expect(sanitizeLuceneQuery('path/to')).toBe('path\\/to');
78
+ });
79
+ it('escapes opening parenthesis', () => {
80
+ expect(sanitizeLuceneQuery('(term)')).toBe('\\(term\\)');
81
+ });
82
+ it('escapes square brackets', () => {
83
+ expect(sanitizeLuceneQuery('[a TO b]')).toBe('\\[a TO b\\]');
84
+ });
85
+ it('escapes curly braces', () => {
86
+ expect(sanitizeLuceneQuery('{a TO b}')).toBe('\\{a TO b\\}');
87
+ });
88
+ it('escapes carets', () => {
89
+ expect(sanitizeLuceneQuery('term^2')).toBe('term\\^2');
90
+ });
91
+ it('escapes double-ampersand (&&)', () => {
92
+ expect(sanitizeLuceneQuery('a && b')).toBe('a \\&& b');
93
+ });
94
+ it('escapes double-pipe (||)', () => {
95
+ expect(sanitizeLuceneQuery('a || b')).toBe('a \\|| b');
96
+ });
97
+ it('leaves a plain alphanumeric string unchanged', () => {
98
+ expect(sanitizeLuceneQuery('plainterm')).toBe('plainterm');
99
+ });
100
+ });
101
+ describe('safeStringPropertyCompare', () => {
102
+ const compare = safeStringPropertyCompare('name');
103
+ it('returns a negative number when a sorts before b', () => {
104
+ expect(compare({ name: 'Alice' }, { name: 'Bob' })).toBeLessThan(0);
105
+ });
106
+ it('returns a positive number when a sorts after b', () => {
107
+ expect(compare({ name: 'Bob' }, { name: 'Alice' })).toBeGreaterThan(0);
108
+ });
109
+ it('returns 0 for equal strings', () => {
110
+ expect(compare({ name: 'Alice' }, { name: 'Alice' })).toBe(0);
111
+ });
112
+ it('returns 1 when only a has the property', () => {
113
+ expect(compare({ name: 'Alice' }, {})).toBe(1);
114
+ });
115
+ it('returns 0 when only b has the property', () => {
116
+ expect(compare({}, { name: 'Bob' })).toBe(0);
117
+ });
118
+ it('returns 0 when neither object has the property', () => {
119
+ expect(compare({}, {})).toBe(0);
120
+ });
121
+ it('supports nested property paths', () => {
122
+ const nestedCompare = safeStringPropertyCompare('user.name');
123
+ expect(nestedCompare({ user: { name: 'Alice' } }, { user: { name: 'Bob' } })).toBeLessThan(0);
124
+ });
125
+ });
126
+ describe('sanitizeMultilineLucene', () => {
127
+ it('removes a trailing inline comment', () => {
128
+ expect(sanitizeMultilineLucene('query # comment')).toBe('query ');
129
+ });
130
+ it('removes a full-line comment', () => {
131
+ expect(sanitizeMultilineLucene('# full line\nquery')).toBe('\nquery');
132
+ });
133
+ it('collapses two or more consecutive newlines into one', () => {
134
+ expect(sanitizeMultilineLucene('a\n\n\nb')).toBe('a\nb');
135
+ });
136
+ it('collapses exactly two consecutive newlines', () => {
137
+ expect(sanitizeMultilineLucene('a\n\nb')).toBe('a\nb');
138
+ });
139
+ it('leaves a clean single-line query unchanged', () => {
140
+ expect(sanitizeMultilineLucene('howler.status:open')).toBe('howler.status:open');
141
+ });
142
+ });
143
+ describe('validateRegex', () => {
144
+ it('returns true for a valid regex pattern', () => {
145
+ expect(validateRegex('[a-z]+')).toBe(true);
146
+ });
147
+ it('returns true for an empty string (valid zero-length pattern)', () => {
148
+ expect(validateRegex('')).toBe(true);
149
+ });
150
+ it('returns false for an invalid regex pattern', () => {
151
+ expect(validateRegex('[unclosed')).toBe(false);
152
+ });
153
+ it('returns true for a complex but valid regex', () => {
154
+ expect(validateRegex('^(\\d{4})-(\\d{2})-(\\d{2})$')).toBe(true);
155
+ });
156
+ it('returns true for a pattern with quantifiers', () => {
157
+ expect(validateRegex('a{2,5}')).toBe(true);
158
+ });
159
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,292 @@
1
+ /// <reference types="vitest" />
2
+ import { describe, expect, it } from 'vitest';
3
+ import { bytesToSize, compareTimestamp, convertCustomDateRangeToLucene, convertDateToLucene, convertLuceneToDate, flattenDeep, formatDate, getTimeRange, hashCode, humanReadableNumber, removeEmpty, searchObject, sortByTimestamp, tryParse, twitterShort } from './utils';
4
+ describe('bytesToSize', () => {
5
+ it('returns "0 B" for 0', () => {
6
+ expect(bytesToSize(0)).toBe('0 B');
7
+ });
8
+ it('returns "0 B" for null', () => {
9
+ expect(bytesToSize(null)).toBe('0 B');
10
+ });
11
+ it('formats bytes', () => {
12
+ expect(bytesToSize(512)).toBe('512 B');
13
+ });
14
+ it('formats kilobytes', () => {
15
+ expect(bytesToSize(1024)).toBe('1 KB');
16
+ });
17
+ it('formats megabytes', () => {
18
+ expect(bytesToSize(1024 * 1024)).toBe('1 MB');
19
+ });
20
+ it('formats gigabytes', () => {
21
+ expect(bytesToSize(1024 * 1024 * 1024)).toBe('1 GB');
22
+ });
23
+ });
24
+ describe('humanReadableNumber', () => {
25
+ it('returns "0 " for 0', () => {
26
+ expect(humanReadableNumber(0)).toBe('0 ');
27
+ });
28
+ it('returns "0 " for null', () => {
29
+ expect(humanReadableNumber(null)).toBe('0 ');
30
+ });
31
+ it('returns the number with a trailing space for values below 1000', () => {
32
+ expect(humanReadableNumber(500)).toBe('500 ');
33
+ });
34
+ it('formats thousands with "k"', () => {
35
+ expect(humanReadableNumber(1000)).toBe('1k ');
36
+ });
37
+ it('formats millions with "m"', () => {
38
+ expect(humanReadableNumber(1_000_000)).toBe('1m ');
39
+ });
40
+ });
41
+ describe('compareTimestamp', () => {
42
+ it('returns a negative number when a is earlier than b', () => {
43
+ expect(compareTimestamp('2021-01-01T00:00:00Z', '2021-01-02T00:00:00Z')).toBeLessThan(0);
44
+ });
45
+ it('returns a positive number when a is later than b', () => {
46
+ expect(compareTimestamp('2021-01-02T00:00:00Z', '2021-01-01T00:00:00Z')).toBeGreaterThan(0);
47
+ });
48
+ it('returns 0 for identical timestamps', () => {
49
+ expect(compareTimestamp('2021-01-01T00:00:00Z', '2021-01-01T00:00:00Z')).toBe(0);
50
+ });
51
+ it('returns a difference in seconds', () => {
52
+ // 86400 seconds = 1 day
53
+ expect(compareTimestamp('2021-01-02T00:00:00Z', '2021-01-01T00:00:00Z')).toBe(86400);
54
+ });
55
+ });
56
+ describe('hashCode', () => {
57
+ it('returns a number', () => {
58
+ expect(typeof hashCode('hello')).toBe('number');
59
+ });
60
+ it('returns the same value for the same input', () => {
61
+ expect(hashCode('hello')).toBe(hashCode('hello'));
62
+ });
63
+ it('returns different values for different inputs', () => {
64
+ expect(hashCode('hello')).not.toBe(hashCode('world'));
65
+ });
66
+ it('returns 0 for an empty string', () => {
67
+ expect(hashCode('')).toBe(0);
68
+ });
69
+ });
70
+ describe('sortByTimestamp', () => {
71
+ it('returns an empty array for an empty input', () => {
72
+ expect(sortByTimestamp([])).toEqual([]);
73
+ });
74
+ it('sorts items in descending timestamp order (most recent first)', () => {
75
+ const items = [
76
+ { timestamp: '2021-01-01T00:00:00Z' },
77
+ { timestamp: '2021-03-01T00:00:00Z' },
78
+ { timestamp: '2021-02-01T00:00:00Z' }
79
+ ];
80
+ const sorted = sortByTimestamp(items);
81
+ expect(sorted[0].timestamp).toBe('2021-03-01T00:00:00Z');
82
+ expect(sorted[2].timestamp).toBe('2021-01-01T00:00:00Z');
83
+ });
84
+ it('does not mutate the original array', () => {
85
+ const original = [{ timestamp: '2021-01-01T00:00:00Z' }, { timestamp: '2021-03-01T00:00:00Z' }];
86
+ const copy = [...original];
87
+ sortByTimestamp(original);
88
+ expect(original).toEqual(copy);
89
+ });
90
+ it('handles items with missing timestamps', () => {
91
+ const items = [{ timestamp: '2021-01-01T00:00:00Z' }, {}];
92
+ expect(() => sortByTimestamp(items)).not.toThrow();
93
+ });
94
+ });
95
+ describe('getTimeRange', () => {
96
+ it('returns [earliest, latest] from an array of timestamps', () => {
97
+ const timestamps = ['2021-03-01T00:00:00Z', '2021-01-01T00:00:00Z', '2021-02-01T00:00:00Z'];
98
+ const [start, end] = getTimeRange(timestamps);
99
+ expect(start).toBe('2021-01-01T00:00:00Z');
100
+ expect(end).toBe('2021-03-01T00:00:00Z');
101
+ });
102
+ it('returns the same value for both when given a single timestamp', () => {
103
+ const [start, end] = getTimeRange(['2021-01-01T00:00:00Z']);
104
+ expect(start).toBe(end);
105
+ });
106
+ });
107
+ describe('removeEmpty', () => {
108
+ it('removes null values from a flat object', () => {
109
+ expect(removeEmpty({ a: null, b: 'val' })).toEqual({ b: 'val' });
110
+ });
111
+ it('removes undefined values from a flat object', () => {
112
+ expect(removeEmpty({ a: undefined, b: 'val' })).toEqual({ b: 'val' });
113
+ });
114
+ it('recursively removes null values from nested objects', () => {
115
+ expect(removeEmpty({ nested: { a: null, b: 'val' } })).toEqual({ nested: { b: 'val' } });
116
+ });
117
+ it('handles an empty object', () => {
118
+ expect(removeEmpty({})).toEqual({});
119
+ });
120
+ it('handles null input gracefully', () => {
121
+ expect(removeEmpty(null)).toEqual({});
122
+ });
123
+ it('keeps arrays as-is', () => {
124
+ const result = removeEmpty({ arr: [1, 2, 3] });
125
+ expect(result.arr).toEqual([1, 2, 3]);
126
+ });
127
+ });
128
+ describe('searchObject', () => {
129
+ const obj = { name: 'Alice', role: 'admin', nested: { city: 'Ottawa' } };
130
+ it('returns the full object when query is empty', () => {
131
+ const result = searchObject(obj, '');
132
+ expect(result).toMatchObject(obj);
133
+ });
134
+ it('returns matching entries for a key match', () => {
135
+ const result = searchObject(obj, 'name');
136
+ expect(result.name).toBe('Alice');
137
+ });
138
+ it('returns matching entries for a value match', () => {
139
+ const result = searchObject(obj, 'Alice');
140
+ expect(result.name).toBe('Alice');
141
+ });
142
+ it('returns an empty object when nothing matches', () => {
143
+ const result = searchObject(obj, 'zzznomatch');
144
+ expect(result).toEqual({});
145
+ });
146
+ it('returns flat result when returnFlat=true', () => {
147
+ const result = searchObject(obj, 'city', true);
148
+ expect(result['nested.city']).toBe('Ottawa');
149
+ });
150
+ it('returns full flat object when query is empty and returnFlat=true', () => {
151
+ const result = searchObject({ a: 1 }, '', true);
152
+ expect(result.a).toBe(1);
153
+ });
154
+ it('handles an invalid regex gracefully by returning the full object', () => {
155
+ const result = searchObject(obj, '[invalid');
156
+ expect(result).toMatchObject(obj);
157
+ });
158
+ });
159
+ describe('convertDateToLucene', () => {
160
+ it('returns "[now-1d TO now]" for a 1-day range', () => {
161
+ expect(convertDateToLucene('date.range.1.day')).toBe('[now-1d TO now]');
162
+ });
163
+ it('returns "[now-1w TO now]" for a 1-week range', () => {
164
+ expect(convertDateToLucene('date.range.1.week')).toBe('[now-1w TO now]');
165
+ });
166
+ it('returns "[now-1M TO now]" for a 1-month range', () => {
167
+ expect(convertDateToLucene('date.range.1.month')).toBe('[now-1M TO now]');
168
+ });
169
+ it('returns "[now-1y TO now]" for a 1-year range', () => {
170
+ expect(convertDateToLucene('date.range.1.year')).toBe('[now-1y TO now]');
171
+ });
172
+ it('returns "*" for the "all" range', () => {
173
+ expect(convertDateToLucene('date.range.all')).toBe('*');
174
+ });
175
+ it('returns the default 1-day range when input does not start with "date.range."', () => {
176
+ expect(convertDateToLucene('something.else')).toBe('[now-1d TO now]');
177
+ });
178
+ it('uses the day unit as fallback for an unknown period type', () => {
179
+ expect(convertDateToLucene('date.range.3.unknown')).toBe('[now-3d TO now]');
180
+ });
181
+ it('handles multi-unit amounts', () => {
182
+ expect(convertDateToLucene('date.range.3.day')).toBe('[now-3d TO now]');
183
+ });
184
+ });
185
+ describe('convertCustomDateRangeToLucene', () => {
186
+ it('formats a custom date range', () => {
187
+ expect(convertCustomDateRangeToLucene('2021-01-01', '2021-12-31')).toBe('[2021-01-01 TO 2021-12-31]');
188
+ });
189
+ it('works with ISO datetime strings', () => {
190
+ expect(convertCustomDateRangeToLucene('2021-01-01T00:00:00Z', '2021-12-31T23:59:59Z')).toBe('[2021-01-01T00:00:00Z TO 2021-12-31T23:59:59Z]');
191
+ });
192
+ });
193
+ describe('convertLuceneToDate', () => {
194
+ it('converts a 1-day lucene range back to "date.range.1.day"', () => {
195
+ expect(convertLuceneToDate('event.created:[now-1d TO now]')).toBe('date.range.1.day');
196
+ });
197
+ it('converts a 1-week lucene range back to "date.range.1.week"', () => {
198
+ expect(convertLuceneToDate('event.created:[now-1w TO now]')).toBe('date.range.1.week');
199
+ });
200
+ it('converts a 1-month lucene range back to "date.range.1.month"', () => {
201
+ expect(convertLuceneToDate('event.created:[now-1M TO now]')).toBe('date.range.1.month');
202
+ });
203
+ it('returns the input unchanged when there is no colon (not a field query)', () => {
204
+ expect(convertLuceneToDate('*')).toBe('*');
205
+ });
206
+ it('falls back to "day" for an unrecognised unit suffix', () => {
207
+ expect(convertLuceneToDate('event.created:[now-5z TO now]')).toBe('date.range.5.day');
208
+ });
209
+ });
210
+ describe('tryParse', () => {
211
+ it('parses valid JSON and returns the value', () => {
212
+ expect(tryParse('{"a":1}')).toEqual({ a: 1 });
213
+ });
214
+ it('parses a JSON array', () => {
215
+ expect(tryParse('[1,2,3]')).toEqual([1, 2, 3]);
216
+ });
217
+ it('returns the raw string when JSON is invalid', () => {
218
+ expect(tryParse('not json')).toBe('not json');
219
+ });
220
+ it('parses a quoted JSON string', () => {
221
+ expect(tryParse('"hello"')).toBe('hello');
222
+ });
223
+ it('returns the raw string for partially-valid JSON', () => {
224
+ expect(tryParse('{invalid}')).toBe('{invalid}');
225
+ });
226
+ });
227
+ describe('flattenDeep', () => {
228
+ it('flattens a simple nested object', () => {
229
+ const result = flattenDeep({ a: { b: 1 } });
230
+ expect(result).toEqual({ 'a.b': 1 });
231
+ });
232
+ it('leaves a flat object unchanged', () => {
233
+ const result = flattenDeep({ a: 1, b: 2 });
234
+ expect(result).toEqual({ a: 1, b: 2 });
235
+ });
236
+ it('flattens arrays of objects by merging values under a common key', () => {
237
+ const result = flattenDeep({ items: [{ id: 'x' }, { id: 'y' }] });
238
+ expect(result['items.id']).toEqual(['x', 'y']);
239
+ });
240
+ it('keeps a primitive array as-is', () => {
241
+ const result = flattenDeep({ tags: ['a', 'b', 'c'] });
242
+ expect(result.tags).toEqual(['a', 'b', 'c']);
243
+ });
244
+ it('handles an empty object', () => {
245
+ expect(flattenDeep({})).toEqual({});
246
+ });
247
+ });
248
+ describe('formatDate', () => {
249
+ it('returns "?" for a falsy value', () => {
250
+ expect(formatDate(null)).toBe('?');
251
+ });
252
+ it('returns "?" for an empty string', () => {
253
+ expect(formatDate('')).toBe('?');
254
+ });
255
+ it('formats an ISO string as UTC in YYYY/MM/DD HH:mm:ss format', () => {
256
+ // 2021-06-15T12:30:45Z → UTC → "2021/06/15 12:30:45"
257
+ expect(formatDate('2021-06-15T12:30:45Z')).toBe('2021/06/15 12:30:45');
258
+ });
259
+ it('formats a Date object correctly', () => {
260
+ const date = new Date('2023-01-01T00:00:00Z');
261
+ expect(formatDate(date)).toBe('2023/01/01 00:00:00');
262
+ });
263
+ it('formats a unix timestamp (ms) correctly', () => {
264
+ // 1000ms = 1970-01-01T00:00:01Z
265
+ expect(formatDate(1000)).toBe('1970/01/01 00:00:01');
266
+ });
267
+ it('returns "?" for a numeric 0 (treated as falsy by the guard)', () => {
268
+ expect(formatDate(0)).toBe('?');
269
+ });
270
+ });
271
+ describe('twitterShort', () => {
272
+ it('returns "?" for a falsy value', () => {
273
+ expect(twitterShort(null)).toBe('?');
274
+ });
275
+ it('returns "?" for the literal string "?"', () => {
276
+ expect(twitterShort('?')).toBe('?');
277
+ });
278
+ it('returns a non-empty relative string for a recent date', () => {
279
+ const result = twitterShort(new Date().toISOString());
280
+ expect(result).toBeTruthy();
281
+ expect(typeof result).toBe('string');
282
+ });
283
+ it('returns "a few seconds ago" for a date just in the past', () => {
284
+ const recent = new Date(Date.now() - 2000).toISOString();
285
+ expect(twitterShort(recent)).toBe('a few seconds ago');
286
+ });
287
+ it('returns a sensible relative string for a date one year in the past', () => {
288
+ const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString();
289
+ const result = twitterShort(oneYearAgo);
290
+ expect(result).toMatch(/year/);
291
+ });
292
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildViewUrl } from './viewUtils';
3
+ const makeView = (overrides = {}) => ({
4
+ view_id: 'view-1',
5
+ title: 'Test View',
6
+ query: 'howler.status:open',
7
+ sort: 'event.created desc',
8
+ span: 'date.range.1.month',
9
+ type: 'personal',
10
+ owner: 'testuser',
11
+ ...overrides
12
+ });
13
+ describe('buildViewUrl', () => {
14
+ it('includes the view_id as the "view" query param', () => {
15
+ const url = buildViewUrl(makeView({ view_id: 'abc-123' }));
16
+ expect(url).toContain('view=abc-123');
17
+ });
18
+ it('starts with /search', () => {
19
+ const url = buildViewUrl(makeView());
20
+ expect(url.startsWith('/search?')).toBe(true);
21
+ });
22
+ it('includes the span param when provided', () => {
23
+ const url = buildViewUrl(makeView({ span: 'date.range.1.week' }));
24
+ expect(url).toContain('span=date.range.1.week');
25
+ });
26
+ it('omits the span param when span is undefined', () => {
27
+ const url = buildViewUrl(makeView({ span: undefined }));
28
+ expect(url).not.toContain('span=');
29
+ });
30
+ it('includes the sort param when provided', () => {
31
+ const url = buildViewUrl(makeView({ sort: 'event.created asc' }));
32
+ expect(url).toContain('sort=event.created+asc');
33
+ });
34
+ it('omits the sort param when sort is undefined', () => {
35
+ const url = buildViewUrl(makeView({ sort: undefined }));
36
+ expect(url).not.toContain('sort=');
37
+ });
38
+ it('builds a complete URL with all fields present', () => {
39
+ const url = buildViewUrl(makeView({ view_id: 'v1', span: 'date.range.1.day', sort: 'event.created desc' }));
40
+ const parsed = new URL(url, 'http://localhost');
41
+ expect(parsed.searchParams.get('view')).toBe('v1');
42
+ expect(parsed.searchParams.get('span')).toBe('date.range.1.day');
43
+ expect(parsed.searchParams.get('sort')).toBe('event.created desc');
44
+ });
45
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ /// <reference types="vitest" />
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import getXSRFCookie from './xsrf';
4
+ describe('getXSRFCookie', () => {
5
+ const originalDescriptor = Object.getOwnPropertyDescriptor(document, 'cookie');
6
+ const setCookie = (value) => {
7
+ Object.defineProperty(document, 'cookie', {
8
+ get: () => value,
9
+ configurable: true
10
+ });
11
+ };
12
+ afterEach(() => {
13
+ if (originalDescriptor) {
14
+ Object.defineProperty(document, 'cookie', originalDescriptor);
15
+ }
16
+ });
17
+ it('returns null when document.cookie is empty', () => {
18
+ setCookie('');
19
+ expect(getXSRFCookie()).toBeNull();
20
+ });
21
+ it('returns null when XSRF-TOKEN is not among the cookies', () => {
22
+ setCookie('session=abc; theme=dark');
23
+ expect(getXSRFCookie()).toBeNull();
24
+ });
25
+ it('returns the XSRF-TOKEN value when it is the only cookie', () => {
26
+ setCookie('XSRF-TOKEN=my-token');
27
+ expect(getXSRFCookie()).toBe('my-token');
28
+ });
29
+ it('returns the XSRF-TOKEN value when it appears among other cookies', () => {
30
+ setCookie('session=abc; XSRF-TOKEN=csrf-value; theme=dark');
31
+ expect(getXSRFCookie()).toBe('csrf-value');
32
+ });
33
+ it('returns the XSRF-TOKEN value when it appears first', () => {
34
+ setCookie('XSRF-TOKEN=first-token; session=abc');
35
+ expect(getXSRFCookie()).toBe('first-token');
36
+ });
37
+ it('returns the XSRF-TOKEN value when it appears last', () => {
38
+ setCookie('session=abc; XSRF-TOKEN=last-token');
39
+ expect(getXSRFCookie()).toBe('last-token');
40
+ });
41
+ });