@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,135 @@
1
+ /// <reference types="vitest" />
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import Throttler from './Throttler';
4
+ describe('Throttler', () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ });
8
+ afterEach(() => {
9
+ vi.useRealTimers();
10
+ });
11
+ // -------------------------------------------------------------------------
12
+ // throttle
13
+ // -------------------------------------------------------------------------
14
+ describe('throttle', () => {
15
+ it('calls fn immediately on the first invocation', () => {
16
+ const fn = vi.fn();
17
+ const t = new Throttler(100);
18
+ t.throttle(fn);
19
+ expect(fn).toHaveBeenCalledTimes(1);
20
+ });
21
+ it('suppresses subsequent calls within the throttle window', () => {
22
+ const fn = vi.fn();
23
+ const t = new Throttler(100);
24
+ t.throttle(fn);
25
+ t.throttle(fn);
26
+ t.throttle(fn);
27
+ expect(fn).toHaveBeenCalledTimes(1);
28
+ });
29
+ it('allows another call after the throttle interval expires', () => {
30
+ const fn = vi.fn();
31
+ const t = new Throttler(100);
32
+ t.throttle(fn);
33
+ vi.advanceTimersByTime(150);
34
+ t.throttle(fn);
35
+ expect(fn).toHaveBeenCalledTimes(2);
36
+ });
37
+ it('does not fire a second call if the interval has not yet elapsed', () => {
38
+ const fn = vi.fn();
39
+ const t = new Throttler(200);
40
+ t.throttle(fn);
41
+ vi.advanceTimersByTime(100);
42
+ t.throttle(fn);
43
+ expect(fn).toHaveBeenCalledTimes(1);
44
+ });
45
+ });
46
+ // -------------------------------------------------------------------------
47
+ // debounce
48
+ // -------------------------------------------------------------------------
49
+ describe('debounce', () => {
50
+ it('does not call fn immediately', () => {
51
+ const fn = vi.fn();
52
+ const t = new Throttler(100);
53
+ t.debounce(fn);
54
+ expect(fn).not.toHaveBeenCalled();
55
+ });
56
+ it('calls fn after the delay elapses', () => {
57
+ const fn = vi.fn();
58
+ const t = new Throttler(100);
59
+ t.debounce(fn);
60
+ vi.advanceTimersByTime(100);
61
+ expect(fn).toHaveBeenCalledTimes(1);
62
+ });
63
+ it('resets the delay when called again before it fires', () => {
64
+ const fn = vi.fn();
65
+ const t = new Throttler(100);
66
+ t.debounce(fn);
67
+ vi.advanceTimersByTime(80);
68
+ t.debounce(fn);
69
+ vi.advanceTimersByTime(80);
70
+ expect(fn).not.toHaveBeenCalled();
71
+ vi.advanceTimersByTime(30);
72
+ expect(fn).toHaveBeenCalledTimes(1);
73
+ });
74
+ it('calls fn only once after many rapid invocations', () => {
75
+ const fn = vi.fn();
76
+ const t = new Throttler(100);
77
+ for (let i = 0; i < 10; i++) {
78
+ t.debounce(fn);
79
+ }
80
+ vi.advanceTimersByTime(200);
81
+ expect(fn).toHaveBeenCalledTimes(1);
82
+ });
83
+ it('fires a second call correctly after the first has resolved', () => {
84
+ const fn = vi.fn();
85
+ const t = new Throttler(100);
86
+ t.debounce(fn);
87
+ vi.advanceTimersByTime(150);
88
+ t.debounce(fn);
89
+ vi.advanceTimersByTime(150);
90
+ expect(fn).toHaveBeenCalledTimes(2);
91
+ });
92
+ });
93
+ // -------------------------------------------------------------------------
94
+ // delayAsync
95
+ // -------------------------------------------------------------------------
96
+ describe('delayAsync', () => {
97
+ it('does not invoke fn before the delay', async () => {
98
+ const fn = vi.fn().mockResolvedValue('x');
99
+ const t = new Throttler(100);
100
+ t.delayAsync(fn);
101
+ expect(fn).not.toHaveBeenCalled();
102
+ });
103
+ it('resolves with the return value of fn', async () => {
104
+ const fn = vi.fn().mockResolvedValue('result');
105
+ const t = new Throttler(100);
106
+ const promise = t.delayAsync(fn);
107
+ vi.advanceTimersByTime(100);
108
+ await expect(promise).resolves.toBe('result');
109
+ });
110
+ it('passes variadic arguments through to fn', async () => {
111
+ const fn = vi.fn().mockImplementation(async (a, b) => a + b);
112
+ const t = new Throttler(100);
113
+ const promise = t.delayAsync(fn, 3, 4);
114
+ vi.advanceTimersByTime(100);
115
+ await expect(promise).resolves.toBe(7);
116
+ });
117
+ it('rejects when fn throws', async () => {
118
+ const fn = vi.fn().mockRejectedValue(new Error('boom'));
119
+ const t = new Throttler(100);
120
+ const promise = t.delayAsync(fn);
121
+ vi.advanceTimersByTime(100);
122
+ await expect(promise).rejects.toThrow('boom');
123
+ });
124
+ it('cancels the previous pending call when invoked again', async () => {
125
+ const fn = vi.fn().mockResolvedValue(42);
126
+ const t = new Throttler(100);
127
+ t.delayAsync(fn);
128
+ vi.advanceTimersByTime(50);
129
+ const second = t.delayAsync(fn);
130
+ vi.advanceTimersByTime(100);
131
+ await expect(second).resolves.toBe(42);
132
+ expect(fn).toHaveBeenCalledTimes(1);
133
+ });
134
+ });
135
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { checkArgsAreFilled, getArgsByContext, getOptionsByContext, operationReady } from './actionUtils';
3
+ // ---------------------------------------------------------------------------
4
+ // Helpers
5
+ // ---------------------------------------------------------------------------
6
+ const makeStep = (args, options = {}) => ({
7
+ args,
8
+ options,
9
+ validation: {}
10
+ });
11
+ const makeAction = (steps) => ({
12
+ id: 'test-action',
13
+ title: 'Test Action',
14
+ i18nKey: 'test.action',
15
+ description: { short: '', long: '' },
16
+ roles: [],
17
+ steps,
18
+ triggers: []
19
+ });
20
+ // ---------------------------------------------------------------------------
21
+ // checkArgsAreFilled
22
+ // ---------------------------------------------------------------------------
23
+ describe('checkArgsAreFilled', () => {
24
+ it('returns false when values is empty/falsy', () => {
25
+ const step = makeStep({ status: [] });
26
+ expect(checkArgsAreFilled(step, '')).toBe(false);
27
+ });
28
+ it('returns true when all required args are present and truthy', () => {
29
+ const step = makeStep({ status: [] });
30
+ expect(checkArgsAreFilled(step, JSON.stringify({ status: 'open' }))).toBe(true);
31
+ });
32
+ it('returns false when a required arg is missing from values', () => {
33
+ const step = makeStep({ status: [], assignment: [] });
34
+ expect(checkArgsAreFilled(step, JSON.stringify({ status: 'open' }))).toBe(false);
35
+ });
36
+ it('returns false when a required arg is present but falsy (empty string)', () => {
37
+ const step = makeStep({ status: [] });
38
+ expect(checkArgsAreFilled(step, JSON.stringify({ status: '' }))).toBe(false);
39
+ });
40
+ it('returns true when there are no required args', () => {
41
+ const step = makeStep({});
42
+ expect(checkArgsAreFilled(step, JSON.stringify({}))).toBe(true);
43
+ });
44
+ });
45
+ // ---------------------------------------------------------------------------
46
+ // getOptionsByContext
47
+ // ---------------------------------------------------------------------------
48
+ describe('getOptionsByContext', () => {
49
+ it('returns an empty array when the arg is not in options', () => {
50
+ const options = {};
51
+ expect(getOptionsByContext(options, 'status', '{}')).toEqual([]);
52
+ });
53
+ it('returns the full list when the arg maps to a plain array', () => {
54
+ const options = { status: ['open', 'in-progress', 'resolved'] };
55
+ expect(getOptionsByContext(options, 'status', '{}')).toEqual(['open', 'in-progress', 'resolved']);
56
+ });
57
+ it('returns options matching the current context when arg has conditional options', () => {
58
+ const options = {
59
+ assessment: {
60
+ 'status:open': ['false_positive', 'legitimate'],
61
+ 'status:resolved': ['correct']
62
+ }
63
+ };
64
+ const context = JSON.stringify({ status: 'open' });
65
+ expect(getOptionsByContext(options, 'assessment', context)).toEqual(['false_positive', 'legitimate']);
66
+ });
67
+ it('returns an empty array when context does not match any conditional key', () => {
68
+ const options = {
69
+ assessment: {
70
+ 'status:open': ['false_positive']
71
+ }
72
+ };
73
+ const context = JSON.stringify({ status: 'resolved' });
74
+ expect(getOptionsByContext(options, 'assessment', context)).toEqual([]);
75
+ });
76
+ });
77
+ // ---------------------------------------------------------------------------
78
+ // getArgsByContext
79
+ // ---------------------------------------------------------------------------
80
+ describe('getArgsByContext', () => {
81
+ it('always returns an arg with an empty conditions array', () => {
82
+ const args = { status: [] };
83
+ expect(getArgsByContext(args, '{}')).toContain('status');
84
+ });
85
+ it('includes an arg whose condition matches the current context', () => {
86
+ const args = { assessment: ['status:open'] };
87
+ const values = JSON.stringify({ status: 'open' });
88
+ expect(getArgsByContext(args, values)).toContain('assessment');
89
+ });
90
+ it('excludes an arg whose condition does not match the current context', () => {
91
+ const args = { assessment: ['status:open'] };
92
+ const values = JSON.stringify({ status: 'resolved' });
93
+ expect(getArgsByContext(args, values)).not.toContain('assessment');
94
+ });
95
+ it('includes an arg when any one of multiple conditions matches (OR logic)', () => {
96
+ const args = { rationale: ['status:open', 'status:in-progress'] };
97
+ const values = JSON.stringify({ status: 'in-progress' });
98
+ expect(getArgsByContext(args, values)).toContain('rationale');
99
+ });
100
+ it('returns an empty array when no args match', () => {
101
+ const args = { assessment: ['status:open'] };
102
+ const values = JSON.stringify({ status: 'resolved' });
103
+ expect(getArgsByContext(args, values)).toEqual([]);
104
+ });
105
+ });
106
+ // ---------------------------------------------------------------------------
107
+ // operationReady
108
+ // ---------------------------------------------------------------------------
109
+ describe('operationReady', () => {
110
+ it('returns false when data is null/falsy', () => {
111
+ const action = makeAction([makeStep({ status: [] })]);
112
+ expect(operationReady(null, action)).toBe(false);
113
+ });
114
+ it('returns falsy when action is falsy', () => {
115
+ expect(operationReady(JSON.stringify({ status: 'open' }), null)).toBeFalsy();
116
+ });
117
+ it('returns true when all unconditional args are present', () => {
118
+ const action = makeAction([makeStep({ status: [] })]);
119
+ const data = JSON.stringify({ status: 'open' });
120
+ expect(operationReady(data, action)).toBe(true);
121
+ });
122
+ it('returns false when a required unconditional arg is missing', () => {
123
+ const action = makeAction([makeStep({ status: [], assessment: [] })]);
124
+ const data = JSON.stringify({ status: 'open' });
125
+ expect(operationReady(data, action)).toBe(false);
126
+ });
127
+ it('ignores conditional args that do not apply in the current context', () => {
128
+ // assessment only required when status=open; here status=resolved so assessment is not required
129
+ const action = makeAction([makeStep({ status: [], assessment: ['status:open'] })]);
130
+ const data = JSON.stringify({ status: 'resolved' });
131
+ expect(operationReady(data, action)).toBe(true);
132
+ });
133
+ it('merges args across multiple steps', () => {
134
+ const step1 = makeStep({ status: [] });
135
+ const step2 = makeStep({ assignment: [] });
136
+ const action = makeAction([step1, step2]);
137
+ const data = JSON.stringify({ status: 'open', assignment: 'alice' });
138
+ expect(operationReady(data, action)).toBe(true);
139
+ });
140
+ });
@@ -49,6 +49,7 @@ export declare enum StorageKey {
49
49
  COMPACT_JSON = "compact_json_view",
50
50
  FLATTEN_JSON = "flatten_json_view",
51
51
  FORCE_DRAWER = "force_drawer",
52
+ SHOW_HIT_SUMMARY_GRAPH = "show_hit_summary_graph",
52
53
  LAST_VIEW = "last_view",
53
54
  ONLY_RULES = "only_rules",
54
55
  PAGE_COUNT = "page_count",
@@ -54,6 +54,7 @@ export var StorageKey;
54
54
  StorageKey["COMPACT_JSON"] = "compact_json_view";
55
55
  StorageKey["FLATTEN_JSON"] = "flatten_json_view";
56
56
  StorageKey["FORCE_DRAWER"] = "force_drawer";
57
+ StorageKey["SHOW_HIT_SUMMARY_GRAPH"] = "show_hit_summary_graph";
57
58
  StorageKey["LAST_VIEW"] = "last_view";
58
59
  StorageKey["ONLY_RULES"] = "only_rules";
59
60
  StorageKey["PAGE_COUNT"] = "page_count";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getUserList } from './hitFunctions';
3
+ const makeHit = (howlerOverrides = {}) => ({
4
+ howler: {
5
+ log: [],
6
+ comment: [],
7
+ ...howlerOverrides
8
+ }
9
+ });
10
+ describe('getUserList', () => {
11
+ it('returns an empty Set when the hit is null', () => {
12
+ expect(getUserList(null)).toEqual(new Set());
13
+ });
14
+ it('returns an empty Set when the hit is undefined', () => {
15
+ expect(getUserList(undefined)).toEqual(new Set());
16
+ });
17
+ it('returns an empty Set when both log and comment are empty', () => {
18
+ expect(getUserList(makeHit())).toEqual(new Set());
19
+ });
20
+ it('collects users from the log array', () => {
21
+ const hit = makeHit({ log: [{ user: 'alice' }, { user: 'bob' }] });
22
+ expect(getUserList(hit)).toEqual(new Set(['alice', 'bob']));
23
+ });
24
+ it('collects users from the comment array', () => {
25
+ const hit = makeHit({ comment: [{ user: 'carol' }] });
26
+ expect(getUserList(hit)).toEqual(new Set(['carol']));
27
+ });
28
+ it('collects users from both log and comment', () => {
29
+ const hit = makeHit({
30
+ log: [{ user: 'alice' }],
31
+ comment: [{ user: 'bob' }]
32
+ });
33
+ expect(getUserList(hit)).toEqual(new Set(['alice', 'bob']));
34
+ });
35
+ it('deduplicates users that appear in both arrays', () => {
36
+ const hit = makeHit({
37
+ log: [{ user: 'alice' }],
38
+ comment: [{ user: 'alice' }]
39
+ });
40
+ const result = getUserList(hit);
41
+ expect(result).toEqual(new Set(['alice']));
42
+ expect(result.size).toBe(1);
43
+ });
44
+ it('deduplicates users that appear multiple times in the same array', () => {
45
+ const hit = makeHit({ log: [{ user: 'alice' }, { user: 'alice' }] });
46
+ expect(getUserList(hit).size).toBe(1);
47
+ });
48
+ it('handles a hit with no howler field gracefully', () => {
49
+ const hit = {};
50
+ expect(getUserList(hit)).toEqual(new Set());
51
+ });
52
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,96 @@
1
+ /// <reference types="vitest" />
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { StorageKey } from './constants';
4
+ import { getStored, removeStored, saveLoginCredential, setStored } from './localStorage';
5
+ const PREFIX = 'howler.ui';
6
+ describe('localStorage utilities', () => {
7
+ afterEach(() => {
8
+ localStorage.clear();
9
+ });
10
+ // -------------------------------------------------------------------------
11
+ // setStored / getStored
12
+ // -------------------------------------------------------------------------
13
+ describe('setStored / getStored', () => {
14
+ it('stores and retrieves a string value', () => {
15
+ setStored(StorageKey.USERNAME, 'alice');
16
+ expect(getStored(StorageKey.USERNAME)).toBe('alice');
17
+ });
18
+ it('stores and retrieves an object value', () => {
19
+ const value = { token: 'abc', expires: 123 };
20
+ setStored(StorageKey.APP_TOKEN, value);
21
+ expect(getStored(StorageKey.APP_TOKEN)).toEqual(value);
22
+ });
23
+ it('uses the prefixed key in the underlying localStorage', () => {
24
+ setStored(StorageKey.USERNAME, 'bob');
25
+ expect(localStorage.getItem(`${PREFIX}.${StorageKey.USERNAME}`)).toBe(JSON.stringify('bob'));
26
+ });
27
+ it('returns null for a key that has not been set', () => {
28
+ expect(getStored(StorageKey.USERNAME)).toBeNull();
29
+ });
30
+ it('overwrites an existing value', () => {
31
+ setStored(StorageKey.USERNAME, 'alice');
32
+ setStored(StorageKey.USERNAME, 'bob');
33
+ expect(getStored(StorageKey.USERNAME)).toBe('bob');
34
+ });
35
+ it('stores a number value', () => {
36
+ setStored(StorageKey.PAGE_COUNT, 25);
37
+ expect(getStored(StorageKey.PAGE_COUNT)).toBe(25);
38
+ });
39
+ });
40
+ // -------------------------------------------------------------------------
41
+ // removeStored
42
+ // -------------------------------------------------------------------------
43
+ describe('removeStored', () => {
44
+ it('removes an existing key so it returns null on subsequent reads', () => {
45
+ setStored(StorageKey.USERNAME, 'alice');
46
+ removeStored(StorageKey.USERNAME);
47
+ expect(getStored(StorageKey.USERNAME)).toBeNull();
48
+ });
49
+ it('does not throw when removing a key that was never stored', () => {
50
+ expect(() => removeStored(StorageKey.USERNAME)).not.toThrow();
51
+ });
52
+ it('removes only the targeted key and leaves others intact', () => {
53
+ setStored(StorageKey.USERNAME, 'alice');
54
+ setStored(StorageKey.PROVIDER, 'howler');
55
+ removeStored(StorageKey.USERNAME);
56
+ expect(getStored(StorageKey.USERNAME)).toBeNull();
57
+ expect(getStored(StorageKey.PROVIDER)).toBe('howler');
58
+ });
59
+ });
60
+ // -------------------------------------------------------------------------
61
+ // saveLoginCredential
62
+ // -------------------------------------------------------------------------
63
+ describe('saveLoginCredential', () => {
64
+ it('returns true and stores app_token when app_token is present', () => {
65
+ const result = saveLoginCredential({ app_token: 'tok123' });
66
+ expect(result).toBe(true);
67
+ expect(getStored(StorageKey.APP_TOKEN)).toBe('tok123');
68
+ });
69
+ it('also stores refresh_token and provider when they are present', () => {
70
+ saveLoginCredential({ app_token: 'tok', refresh_token: 'ref', provider: 'howler' });
71
+ expect(getStored(StorageKey.REFRESH_TOKEN)).toBe('ref');
72
+ expect(getStored(StorageKey.PROVIDER)).toBe('howler');
73
+ });
74
+ it('does not store refresh_token when it is absent', () => {
75
+ saveLoginCredential({ app_token: 'tok' });
76
+ expect(getStored(StorageKey.REFRESH_TOKEN)).toBeNull();
77
+ });
78
+ it('returns false and clears stored tokens when app_token is absent', () => {
79
+ setStored(StorageKey.APP_TOKEN, 'old-token');
80
+ setStored(StorageKey.REFRESH_TOKEN, 'old-refresh');
81
+ const result = saveLoginCredential({});
82
+ expect(result).toBe(false);
83
+ expect(getStored(StorageKey.APP_TOKEN)).toBeNull();
84
+ expect(getStored(StorageKey.REFRESH_TOKEN)).toBeNull();
85
+ });
86
+ it('clears APP_TOKEN, REFRESH_TOKEN, and PROVIDER on empty credential', () => {
87
+ setStored(StorageKey.APP_TOKEN, 't');
88
+ setStored(StorageKey.REFRESH_TOKEN, 'r');
89
+ setStored(StorageKey.PROVIDER, 'p');
90
+ saveLoginCredential({});
91
+ expect(getStored(StorageKey.APP_TOKEN)).toBeNull();
92
+ expect(getStored(StorageKey.REFRESH_TOKEN)).toBeNull();
93
+ expect(getStored(StorageKey.PROVIDER)).toBeNull();
94
+ });
95
+ });
96
+ });
@@ -1,5 +1,5 @@
1
- import { MainMenuInsertOperation } from '../plugins/store';
2
1
  import { isNil } from 'lodash-es';
2
+ import { MainMenuInsertOperation } from '../plugins/store';
3
3
  class AppMenuBuilder {
4
4
  items;
5
5
  indexMap;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,198 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import AppMenuBuilder from './menuUtils';
3
+ // Avoid pulling in react-pluggable (and its transitive React deps) by mocking the store module.
4
+ // vi.mock is hoisted by Vitest before any imports, so the mock is applied even though this call appears below the imports.
5
+ vi.mock('plugins/store', () => ({
6
+ MainMenuInsertOperation: {
7
+ Insert: 'INSERT',
8
+ InsertAfter: 'AFTER',
9
+ InsertBefore: 'BEFORE'
10
+ }
11
+ }));
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+ const makeItem = (id, overrides = {}) => ({
16
+ type: 'item',
17
+ element: { id, i18nKey: `key.${id}`, route: `/${id}`, ...overrides }
18
+ });
19
+ const makeGroup = (id, items = []) => ({
20
+ type: 'group',
21
+ element: { id, i18nKey: `key.${id}`, items }
22
+ });
23
+ const makeDivider = () => ({ type: 'divider', element: null });
24
+ // ---------------------------------------------------------------------------
25
+ // Tests
26
+ // ---------------------------------------------------------------------------
27
+ describe('AppMenuBuilder', () => {
28
+ let initial;
29
+ let builder;
30
+ beforeEach(() => {
31
+ initial = [makeItem('home'), makeItem('search'), makeGroup('tools', [{ id: 'view', route: '/view' }])];
32
+ builder = new AppMenuBuilder(initial);
33
+ });
34
+ // -------------------------------------------------------------------------
35
+ // menu getter
36
+ // -------------------------------------------------------------------------
37
+ describe('menu getter', () => {
38
+ it('returns the initial menu unchanged when no operations are applied', () => {
39
+ expect(builder.menu).toHaveLength(3);
40
+ });
41
+ });
42
+ // -------------------------------------------------------------------------
43
+ // indexOfMenuId
44
+ // -------------------------------------------------------------------------
45
+ describe('indexOfMenuId', () => {
46
+ it('returns index -1 for the "root" pseudo-target', () => {
47
+ expect(builder.indexOfMenuId('root')).toEqual({ index: -1 });
48
+ });
49
+ it('returns the correct top-level index for a root item', () => {
50
+ expect(builder.indexOfMenuId('home')).toEqual({ index: 0 });
51
+ });
52
+ it('returns both index and subIndex for a nested item', () => {
53
+ expect(builder.indexOfMenuId('view')).toEqual({ index: 2, subIndex: 0 });
54
+ });
55
+ it('throws for an unknown id', () => {
56
+ expect(() => builder.indexOfMenuId('unknown-id')).toThrow();
57
+ });
58
+ });
59
+ // -------------------------------------------------------------------------
60
+ // insert
61
+ // -------------------------------------------------------------------------
62
+ describe('insert', () => {
63
+ it('appends a new item to the root when targetId is "root"', () => {
64
+ builder.insert('root', makeItem('new'));
65
+ expect(builder.menu).toHaveLength(4);
66
+ expect(builder.menu[3].element.id).toBe('new');
67
+ });
68
+ it('appends a new item to an existing group', () => {
69
+ builder.insert('tools', makeItem('extra'));
70
+ const group = builder.menu.find(el => el.type === 'group');
71
+ expect(group.element.items).toHaveLength(2);
72
+ expect(group.element.items[1].id).toBe('extra');
73
+ });
74
+ it('converts a root-level item into a group when inserting into it', () => {
75
+ builder.insert('home', makeItem('sub-home'));
76
+ const converted = builder.menu.find(el => el.type === 'group');
77
+ // The first group found should be the newly converted one.
78
+ expect(converted).toBeDefined();
79
+ expect(converted.element.items.some((i) => i.id === 'sub-home')).toBe(true);
80
+ });
81
+ it('marks nested items added to a group as nested:true', () => {
82
+ builder.insert('tools', makeItem('nested-item'));
83
+ const group = builder.menu.find(el => el.type === 'group');
84
+ const last = group.element.items[group.element.items.length - 1];
85
+ expect(last.nested).toBe(true);
86
+ });
87
+ it('does not insert a divider into a group (logs warning and returns)', () => {
88
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
89
+ const before = builder.menu.find(el => el.type === 'group').element.items.length;
90
+ builder.insert('tools', makeDivider());
91
+ const after = builder.menu.find(el => el.type === 'group').element.items.length;
92
+ expect(after).toBe(before);
93
+ warnSpy.mockRestore();
94
+ });
95
+ });
96
+ // -------------------------------------------------------------------------
97
+ // insertBefore
98
+ // -------------------------------------------------------------------------
99
+ describe('insertBefore', () => {
100
+ it('inserts a new root-level item before the target', () => {
101
+ builder.insertBefore('search', makeItem('before-search'));
102
+ // 'search' was at index 1; the new item should now be at index 1.
103
+ expect(builder.menu[1].element.id).toBe('before-search');
104
+ expect(builder.menu[2].element.id).toBe('search');
105
+ });
106
+ it('inserts before the first root item (index 0)', () => {
107
+ builder.insertBefore('home', makeItem('new-first'));
108
+ expect(builder.menu[0].element.id).toBe('new-first');
109
+ });
110
+ it('inserts a new nested item before a sub-item within its group', () => {
111
+ builder.insertBefore('view', makeItem('before-view'));
112
+ const group = builder.menu.find(el => el.type === 'group');
113
+ expect(group.element.items[0].id).toBe('before-view');
114
+ expect(group.element.items[1].id).toBe('view');
115
+ });
116
+ it('marks items inserted into a group as nested:true', () => {
117
+ builder.insertBefore('view', makeItem('nested-before'));
118
+ const group = builder.menu.find(el => el.type === 'group');
119
+ expect(group.element.items[0].nested).toBe(true);
120
+ });
121
+ it('does not insert a divider before a sub-item (logs warning)', () => {
122
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
123
+ const before = builder.menu.find(el => el.type === 'group').element.items.length;
124
+ builder.insertBefore('view', makeDivider());
125
+ const after = builder.menu.find(el => el.type === 'group').element.items.length;
126
+ expect(after).toBe(before);
127
+ warnSpy.mockRestore();
128
+ });
129
+ });
130
+ // -------------------------------------------------------------------------
131
+ // insertAfter
132
+ // -------------------------------------------------------------------------
133
+ describe('insertAfter', () => {
134
+ it('inserts a new root-level item after the target', () => {
135
+ builder.insertAfter('home', makeItem('after-home'));
136
+ expect(builder.menu[1].element.id).toBe('after-home');
137
+ expect(builder.menu[2].element.id).toBe('search');
138
+ });
139
+ it('inserts after the last root item', () => {
140
+ builder.insertAfter('tools', makeItem('very-last'));
141
+ expect(builder.menu[builder.menu.length - 1].element.id).toBe('very-last');
142
+ });
143
+ it('inserts a new nested item after a sub-item within its group', () => {
144
+ builder.insertAfter('view', makeItem('after-view'));
145
+ const group = builder.menu.find(el => el.type === 'group');
146
+ expect(group.element.items[0].id).toBe('view');
147
+ expect(group.element.items[1].id).toBe('after-view');
148
+ });
149
+ it('marks items inserted into a group as nested:true', () => {
150
+ builder.insertAfter('view', makeItem('nested-after'));
151
+ const group = builder.menu.find(el => el.type === 'group');
152
+ expect(group.element.items[1].nested).toBe(true);
153
+ });
154
+ it('does not insert a divider after a sub-item (logs warning)', () => {
155
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
156
+ const before = builder.menu.find(el => el.type === 'group').element.items.length;
157
+ builder.insertAfter('view', makeDivider());
158
+ const after = builder.menu.find(el => el.type === 'group').element.items.length;
159
+ expect(after).toBe(before);
160
+ warnSpy.mockRestore();
161
+ });
162
+ });
163
+ // -------------------------------------------------------------------------
164
+ // applyOperations
165
+ // -------------------------------------------------------------------------
166
+ describe('applyOperations', () => {
167
+ it('applies an INSERT operation to the root', () => {
168
+ builder.applyOperations([{ operation: 'INSERT', targetId: 'root', item: makeItem('op-item') }]);
169
+ expect(builder.menu.some((el) => el.element?.id === 'op-item')).toBe(true);
170
+ });
171
+ it('applies a BEFORE operation', () => {
172
+ builder.applyOperations([{ operation: 'BEFORE', targetId: 'search', item: makeItem('before-op') }]);
173
+ const idx = builder.menu.findIndex((el) => el.element?.id === 'before-op');
174
+ const searchIdx = builder.menu.findIndex((el) => el.element?.id === 'search');
175
+ expect(idx).toBeLessThan(searchIdx);
176
+ });
177
+ it('applies an AFTER operation', () => {
178
+ builder.applyOperations([{ operation: 'AFTER', targetId: 'home', item: makeItem('after-op') }]);
179
+ const homeIdx = builder.menu.findIndex((el) => el.element?.id === 'home');
180
+ const afterIdx = builder.menu.findIndex((el) => el.element?.id === 'after-op');
181
+ expect(afterIdx).toBe(homeIdx + 1);
182
+ });
183
+ it('applies multiple operations in order', () => {
184
+ builder.applyOperations([
185
+ { operation: 'INSERT', targetId: 'root', item: makeItem('first') },
186
+ { operation: 'INSERT', targetId: 'root', item: makeItem('second') }
187
+ ]);
188
+ const ids = builder.menu.map((el) => el.element?.id).filter(Boolean);
189
+ expect(ids).toContain('first');
190
+ expect(ids).toContain('second');
191
+ });
192
+ it('ignores operations with unknown operation strings', () => {
193
+ const lenBefore = builder.menu.length;
194
+ builder.applyOperations([{ operation: 'UNKNOWN', targetId: 'root', item: makeItem('x') }]);
195
+ expect(builder.menu.length).toBe(lenBefore);
196
+ });
197
+ });
198
+ });
@@ -0,0 +1 @@
1
+ export {};