@aerogel/core 0.0.0-next.6c539d8e63b397d4bb6c3d61a7f20a4d108b1cdd → 0.0.0-next.7035064d9ec6a82a936ee8dfcc4b58ed2e25a399

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 (49) hide show
  1. package/dist/aerogel-core.cjs.js +1 -1
  2. package/dist/aerogel-core.cjs.js.map +1 -1
  3. package/dist/aerogel-core.d.ts +328 -72
  4. package/dist/aerogel-core.esm.js +1 -1
  5. package/dist/aerogel-core.esm.js.map +1 -1
  6. package/package.json +2 -2
  7. package/src/bootstrap/index.ts +12 -2
  8. package/src/components/headless/modals/AGHeadlessModal.ts +3 -1
  9. package/src/components/headless/modals/AGHeadlessModal.vue +10 -4
  10. package/src/components/headless/modals/AGHeadlessModalPanel.vue +10 -6
  11. package/src/components/headless/modals/AGHeadlessModalTitle.vue +14 -4
  12. package/src/components/lib/AGMarkdown.vue +14 -1
  13. package/src/components/lib/AGProgressBar.vue +30 -0
  14. package/src/components/lib/index.ts +1 -0
  15. package/src/components/modals/AGAlertModal.ts +5 -2
  16. package/src/components/modals/AGConfirmModal.ts +13 -5
  17. package/src/components/modals/AGConfirmModal.vue +1 -1
  18. package/src/components/modals/AGErrorReportModal.ts +5 -2
  19. package/src/components/modals/AGLoadingModal.ts +10 -4
  20. package/src/components/modals/AGModal.ts +1 -0
  21. package/src/components/modals/AGModalContext.vue +14 -4
  22. package/src/components/modals/AGPromptModal.ts +9 -4
  23. package/src/errors/JobCancelledError.ts +3 -0
  24. package/src/errors/utils.ts +16 -0
  25. package/src/forms/Form.ts +10 -3
  26. package/src/forms/index.ts +2 -1
  27. package/src/forms/utils.ts +20 -4
  28. package/src/forms/validation.ts +19 -0
  29. package/src/jobs/Job.ts +144 -2
  30. package/src/jobs/index.ts +4 -1
  31. package/src/jobs/listeners.ts +3 -0
  32. package/src/jobs/status.ts +4 -0
  33. package/src/services/App.state.ts +9 -1
  34. package/src/services/App.ts +5 -0
  35. package/src/services/Events.ts +13 -3
  36. package/src/services/Service.ts +107 -44
  37. package/src/services/Storage.ts +20 -0
  38. package/src/services/index.ts +7 -2
  39. package/src/services/utils.ts +18 -0
  40. package/src/testing/setup.ts +11 -3
  41. package/src/ui/UI.ts +108 -38
  42. package/src/utils/composition/persistent.test.ts +33 -0
  43. package/src/utils/composition/persistent.ts +11 -0
  44. package/src/utils/composition/state.test.ts +47 -0
  45. package/src/utils/composition/state.ts +24 -0
  46. package/src/utils/index.ts +1 -0
  47. package/src/utils/markdown.test.ts +50 -0
  48. package/src/utils/markdown.ts +17 -2
  49. package/src/utils/vue.ts +4 -1
package/src/ui/UI.ts CHANGED
@@ -1,9 +1,11 @@
1
- import { after, facade, fail, uuid } from '@noeldemartin/utils';
1
+ import { after, facade, fail, required, uuid } from '@noeldemartin/utils';
2
2
  import { markRaw, nextTick } from 'vue';
3
3
  import type { Component } from 'vue';
4
4
  import type { ObjectValues } from '@noeldemartin/utils';
5
5
 
6
+ import App from '@/services/App';
6
7
  import Events from '@/services/Events';
8
+ import type { AcceptRefs } from '@/utils';
7
9
  import type { Color } from '@/components/constants';
8
10
  import type { SnackbarAction, SnackbarColor } from '@/components/headless/snackbars';
9
11
  import type { AGAlertModalProps, AGConfirmModalProps, AGLoadingModalProps, AGPromptModalProps } from '@/components';
@@ -34,14 +36,27 @@ export const UIComponents = {
34
36
 
35
37
  export type UIComponent = ObjectValues<typeof UIComponents>;
36
38
 
37
- export interface ConfirmOptions {
39
+ export type ConfirmCheckboxes = Record<string, { label: string; default?: boolean; required?: boolean }>;
40
+
41
+ export type ConfirmOptions = AcceptRefs<{
38
42
  acceptText?: string;
39
43
  acceptColor?: Color;
40
44
  cancelText?: string;
41
45
  cancelColor?: Color;
46
+ actions?: Record<string, () => unknown>;
47
+ }>;
48
+
49
+ export type LoadingOptions = AcceptRefs<{
50
+ title?: string;
51
+ message?: string;
52
+ progress?: number;
53
+ }>;
54
+
55
+ export interface ConfirmOptionsWithCheckboxes<T extends ConfirmCheckboxes = ConfirmCheckboxes> extends ConfirmOptions {
56
+ checkboxes?: T;
42
57
  }
43
58
 
44
- export interface PromptOptions {
59
+ export type PromptOptions = AcceptRefs<{
45
60
  label?: string;
46
61
  defaultValue?: string;
47
62
  placeholder?: string;
@@ -50,7 +65,7 @@ export interface PromptOptions {
50
65
  cancelText?: string;
51
66
  cancelColor?: Color;
52
67
  trim?: boolean;
53
- }
68
+ }>;
54
69
 
55
70
  export interface ShowSnackbarOptions {
56
71
  component?: Component;
@@ -84,13 +99,18 @@ export class UIService extends Service {
84
99
  this.openModal(this.requireComponent(UIComponents.AlertModal), getProperties());
85
100
  }
86
101
 
102
+ /* eslint-disable max-len */
87
103
  public async confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
88
104
  public async confirm(title: string, message: string, options?: ConfirmOptions): Promise<boolean>;
105
+ public async confirm<T extends ConfirmCheckboxes>(message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
106
+ public async confirm<T extends ConfirmCheckboxes>(title: string, message: string, options?: ConfirmOptionsWithCheckboxes<T>): Promise<[boolean, Record<keyof T, boolean>]>; // prettier-ignore
107
+ /* eslint-enable max-len */
108
+
89
109
  public async confirm(
90
110
  messageOrTitle: string,
91
- messageOrOptions?: string | ConfirmOptions,
92
- options?: ConfirmOptions,
93
- ): Promise<boolean> {
111
+ messageOrOptions?: string | ConfirmOptions | ConfirmOptionsWithCheckboxes,
112
+ options?: ConfirmOptions | ConfirmOptionsWithCheckboxes,
113
+ ): Promise<boolean | [boolean, Record<string, boolean>]> {
94
114
  const getProperties = (): AGConfirmModalProps => {
95
115
  if (typeof messageOrOptions !== 'string') {
96
116
  return {
@@ -105,14 +125,38 @@ export class UIService extends Service {
105
125
  ...(options ?? {}),
106
126
  };
107
127
  };
108
-
109
- const modal = await this.openModal<ModalComponent<AGConfirmModalProps, boolean>>(
110
- this.requireComponent(UIComponents.ConfirmModal),
111
- getProperties(),
112
- );
128
+ const properties = getProperties();
129
+ const modal = await this.openModal<
130
+ ModalComponent<AGConfirmModalProps, boolean | [boolean, Record<string, boolean>]>
131
+ >(this.requireComponent(UIComponents.ConfirmModal), properties);
113
132
  const result = await modal.beforeClose;
114
133
 
115
- return result ?? false;
134
+ const confirmed = typeof result === 'object' ? result[0] : result ?? false;
135
+ const checkboxes =
136
+ typeof result === 'object'
137
+ ? result[1]
138
+ : Object.entries(properties.checkboxes ?? {}).reduce(
139
+ (values, [checkbox, { default: defaultValue }]) => ({
140
+ [checkbox]: defaultValue ?? false,
141
+ ...values,
142
+ }),
143
+ {} as Record<string, boolean>,
144
+ );
145
+
146
+ for (const [name, checkbox] of Object.entries(properties.checkboxes ?? {})) {
147
+ if (!checkbox.required || checkboxes[name]) {
148
+ continue;
149
+ }
150
+
151
+ if (confirmed && App.development) {
152
+ // eslint-disable-next-line no-console
153
+ console.warn(`Confirmed confirm modal was suppressed because required '${name}' checkbox was missing`);
154
+ }
155
+
156
+ return [false, checkboxes];
157
+ }
158
+
159
+ return 'checkboxes' in properties ? [confirmed, checkboxes] : confirmed;
116
160
  }
117
161
 
118
162
  public async prompt(message: string, options?: PromptOptions): Promise<string | null>;
@@ -128,14 +172,14 @@ export class UIService extends Service {
128
172
  return {
129
173
  message: messageOrTitle,
130
174
  ...(messageOrOptions ?? {}),
131
- };
175
+ } as AGPromptModalProps;
132
176
  }
133
177
 
134
178
  return {
135
179
  title: messageOrTitle,
136
180
  message: messageOrOptions,
137
181
  ...(options ?? {}),
138
- };
182
+ } as AGPromptModalProps;
139
183
  };
140
184
 
141
185
  const modal = await this.openModal<ModalComponent<AGPromptModalProps, string | null>>(
@@ -150,25 +194,35 @@ export class UIService extends Service {
150
194
 
151
195
  public async loading<T>(operation: Promise<T> | (() => T)): Promise<T>;
152
196
  public async loading<T>(message: string, operation: Promise<T> | (() => T)): Promise<T>;
197
+ public async loading<T>(options: LoadingOptions, operation: Promise<T> | (() => T)): Promise<T>;
153
198
  public async loading<T>(
154
- messageOrOperation: string | Promise<T> | (() => T),
199
+ operationOrMessageOrOptions: string | LoadingOptions | Promise<T> | (() => T),
155
200
  operation?: Promise<T> | (() => T),
156
201
  ): Promise<T> {
157
- const getProperties = (): AGLoadingModalProps => {
158
- if (typeof messageOrOperation !== 'string') {
159
- return {};
202
+ const processOperation = (o: Promise<T> | (() => T)) => (typeof o === 'function' ? Promise.resolve(o()) : o);
203
+ const processArgs = (): { operationPromise: Promise<T>; props?: AGLoadingModalProps } => {
204
+ if (typeof operationOrMessageOrOptions === 'string') {
205
+ return {
206
+ props: { message: operationOrMessageOrOptions },
207
+ operationPromise: processOperation(operation as Promise<T> | (() => T)),
208
+ };
209
+ }
210
+
211
+ if (typeof operationOrMessageOrOptions === 'function' || operationOrMessageOrOptions instanceof Promise) {
212
+ return { operationPromise: processOperation(operationOrMessageOrOptions) };
160
213
  }
161
214
 
162
- return { message: messageOrOperation };
215
+ return {
216
+ props: operationOrMessageOrOptions,
217
+ operationPromise: processOperation(operation as Promise<T> | (() => T)),
218
+ };
163
219
  };
164
220
 
165
- const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), getProperties());
221
+ const { operationPromise, props } = processArgs();
222
+ const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), props);
166
223
 
167
224
  try {
168
- operation = typeof messageOrOperation === 'string' ? (operation as () => T) : messageOrOperation;
169
- operation = typeof operation === 'function' ? Promise.resolve(operation()) : operation;
170
-
171
- const [result] = await Promise.all([operation, after({ seconds: 1 })]);
225
+ const [result] = await Promise.all([operationPromise, after({ seconds: 1 })]);
172
226
 
173
227
  return result;
174
228
  } finally {
@@ -230,15 +284,42 @@ export class UIService extends Service {
230
284
  }
231
285
 
232
286
  public async closeModal(id: string, result?: unknown): Promise<void> {
287
+ if (!App.isMounted()) {
288
+ await this.removeModal(id, result);
289
+
290
+ return;
291
+ }
292
+
233
293
  await Events.emit('close-modal', { id, result });
234
294
  }
235
295
 
296
+ public async closeAllModals(): Promise<void> {
297
+ while (this.modals.length > 0) {
298
+ await this.closeModal(required(this.modals[this.modals.length - 1]).id);
299
+ }
300
+ }
301
+
236
302
  protected async boot(): Promise<void> {
237
303
  this.watchModalEvents();
238
304
  this.watchMountedEvent();
239
305
  this.watchViewportBreakpoints();
240
306
  }
241
307
 
308
+ private async removeModal(id: string, result?: unknown): Promise<void> {
309
+ this.setState(
310
+ 'modals',
311
+ this.modals.filter((m) => m.id !== id),
312
+ );
313
+
314
+ this.modalCallbacks[id]?.closed?.(result);
315
+
316
+ delete this.modalCallbacks[id];
317
+
318
+ const activeModal = this.modals.at(-1);
319
+
320
+ await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
321
+ }
322
+
242
323
  private watchModalEvents(): void {
243
324
  Events.on('modal-will-close', ({ modal, result }) => {
244
325
  this.modalCallbacks[modal.id]?.willClose?.(result);
@@ -248,19 +329,8 @@ export class UIService extends Service {
248
329
  }
249
330
  });
250
331
 
251
- Events.on('modal-closed', async ({ modal, result }) => {
252
- this.setState(
253
- 'modals',
254
- this.modals.filter((m) => m.id !== modal.id),
255
- );
256
-
257
- this.modalCallbacks[modal.id]?.closed?.(result);
258
-
259
- delete this.modalCallbacks[modal.id];
260
-
261
- const activeModal = this.modals.at(-1);
262
-
263
- await (activeModal && Events.emit('show-modal', { id: activeModal.id }));
332
+ Events.on('modal-closed', async ({ modal: { id }, result }) => {
333
+ await this.removeModal(id, result);
264
334
  });
265
335
  }
266
336
 
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { nextTick } from 'vue';
3
+ import { Storage } from '@noeldemartin/utils';
4
+
5
+ import { persistent } from './persistent';
6
+
7
+ describe('Vue persistent helper', () => {
8
+
9
+ it('serializes to localStorage', async () => {
10
+ // Arrange
11
+ const store = persistent<{ foo?: string }>('foobar', {});
12
+
13
+ // Act
14
+ store.foo = 'bar';
15
+
16
+ await nextTick();
17
+
18
+ // Assert
19
+ expect(Storage.get('foobar')).toEqual({ foo: 'bar' });
20
+ });
21
+
22
+ it('reads from localStorage', async () => {
23
+ // Arrange
24
+ Storage.set('foobar', { foo: 'bar' });
25
+
26
+ // Act
27
+ const store = persistent<{ foo?: string }>('foobar', {});
28
+
29
+ // Assert
30
+ expect(store.foo).toEqual('bar');
31
+ });
32
+
33
+ });
@@ -0,0 +1,11 @@
1
+ import { reactive, toRaw, watch } from 'vue';
2
+ import { Storage } from '@noeldemartin/utils';
3
+ import type { UnwrapNestedRefs } from 'vue';
4
+
5
+ export function persistent<T extends object>(name: string, defaults: T): UnwrapNestedRefs<T> {
6
+ const store = reactive<T>(Storage.get<T>(name) ?? defaults);
7
+
8
+ watch(store, () => Storage.set(name, toRaw(store)));
9
+
10
+ return store;
11
+ }
@@ -0,0 +1,47 @@
1
+ import { after } from '@noeldemartin/utils';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { ref } from 'vue';
4
+
5
+ import { computedDebounce } from './state';
6
+
7
+ describe('Vue state helpers', () => {
8
+
9
+ it('computes debounced state', async () => {
10
+ // Initial
11
+ const state = ref(0);
12
+ const value = computedDebounce({ delay: 90 }, () => state.value);
13
+
14
+ expect(value.value).toBe(null);
15
+
16
+ await after({ ms: 100 });
17
+
18
+ expect(value.value).toBe(0);
19
+
20
+ // Update
21
+ state.value = 42;
22
+
23
+ expect(value.value).toBe(0);
24
+
25
+ await after({ ms: 100 });
26
+
27
+ expect(value.value).toBe(42);
28
+
29
+ // Debounced Update
30
+ state.value = 23;
31
+
32
+ expect(value.value).toBe(42);
33
+
34
+ await after({ ms: 50 });
35
+
36
+ state.value = 32;
37
+
38
+ await after({ ms: 50 });
39
+
40
+ expect(value.value).toBe(42);
41
+
42
+ await after({ ms: 100 });
43
+
44
+ expect(value.value).toBe(32);
45
+ });
46
+
47
+ });
@@ -0,0 +1,24 @@
1
+ import { debounce } from '@noeldemartin/utils';
2
+ import { ref, watchEffect } from 'vue';
3
+ import type { ComputedGetter, ComputedRef } from '@vue/runtime-core';
4
+
5
+ export interface ComputedDebounceOptions<T> {
6
+ initial?: T;
7
+ delay?: number;
8
+ }
9
+
10
+ export function computedDebounce<T>(options: ComputedDebounceOptions<T>, getter: ComputedGetter<T>): ComputedRef<T>;
11
+ export function computedDebounce<T>(getter: ComputedGetter<T>): ComputedRef<T | null>;
12
+ export function computedDebounce<T>(
13
+ optionsOrGetter: ComputedGetter<T> | ComputedDebounceOptions<T>,
14
+ inputGetter?: ComputedGetter<T>,
15
+ ): ComputedRef<T> {
16
+ const inputOptions = inputGetter ? (optionsOrGetter as ComputedDebounceOptions<T>) : {};
17
+ const getter = inputGetter ?? (optionsOrGetter as ComputedGetter<T>);
18
+ const state = ref(inputOptions.initial ?? null);
19
+ const update = debounce((value) => (state.value = value), inputOptions.delay ?? 300);
20
+
21
+ watchEffect(() => update(getter()));
22
+
23
+ return state as unknown as ComputedRef<T>;
24
+ }
@@ -1,5 +1,6 @@
1
1
  export * from './composition/events';
2
2
  export * from './composition/forms';
3
3
  export * from './composition/hooks';
4
+ export * from './composition/persistent';
4
5
  export * from './tailwindcss';
5
6
  export * from './vue';
@@ -0,0 +1,50 @@
1
+ /* eslint-disable max-len */
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { renderMarkdown } from './markdown';
5
+
6
+ describe('Markdown utils', () => {
7
+
8
+ it('renders basic markdown', () => {
9
+ // Arrange
10
+ const expectedHTML = `
11
+ <h1>Title</h1>
12
+ <p>body with <a target="_blank" href="https://example.com">link</a></p>
13
+ <ul>
14
+ <li>One</li>
15
+ <li>Two</li>
16
+ <li>Three</li>
17
+ </ul>
18
+ `;
19
+
20
+ // Act
21
+ const html = renderMarkdown(
22
+ ['# Title', 'body with [link](https://example.com)', '- One', '- Two', '- Three'].join('\n'),
23
+ );
24
+
25
+ // Assert
26
+ expect(normalizeHTML(html)).toMatch(new RegExp(normalizeHTML(expectedHTML)));
27
+ });
28
+
29
+ it('renders button links', () => {
30
+ // Arrange
31
+ const expectedHTML = `
32
+ <p><button type="button" data-markdown-action="do-something">link</button></p>
33
+ `;
34
+
35
+ // Act
36
+ const html = renderMarkdown('[link](#action:do-something)');
37
+
38
+ // Assert
39
+ expect(normalizeHTML(html)).toMatch(new RegExp(normalizeHTML(expectedHTML)));
40
+ });
41
+
42
+ });
43
+
44
+ function normalizeHTML(html: string): string {
45
+ return html
46
+ .split('\n')
47
+ .map((line) => line.trim())
48
+ .join('\n')
49
+ .trim();
50
+ }
@@ -1,5 +1,5 @@
1
- import { tap } from '@noeldemartin/utils';
2
1
  import DOMPurify from 'dompurify';
2
+ import { stringMatchAll, tap } from '@noeldemartin/utils';
3
3
  import { Renderer, marked } from 'marked';
4
4
 
5
5
  function makeRenderer(): Renderer {
@@ -10,8 +10,23 @@ function makeRenderer(): Renderer {
10
10
  });
11
11
  }
12
12
 
13
+ function renderActionLinks(html: string): string {
14
+ const matches = stringMatchAll<3>(html, /<a[^>]*href="#action:([^"]+)"[^>]*>([^<]+)<\/a>/g);
15
+
16
+ for (const [link, action, text] of matches) {
17
+ html = html.replace(link, `<button type="button" data-markdown-action="${action}">${text}</button>`);
18
+ }
19
+
20
+ return html;
21
+ }
22
+
13
23
  export function renderMarkdown(markdown: string): string {
14
- return safeHtml(marked(markdown, { mangle: false, headerIds: false, renderer: makeRenderer() }));
24
+ let html = marked(markdown, { mangle: false, headerIds: false, renderer: makeRenderer() });
25
+
26
+ html = safeHtml(html);
27
+ html = renderActionLinks(html);
28
+
29
+ return html;
15
30
  }
16
31
 
17
32
  export function safeHtml(html: string): string {
package/src/utils/vue.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { fail } from '@noeldemartin/utils';
2
2
  import { computed, inject, reactive, ref, watch } from 'vue';
3
- import type { Directive, InjectionKey, PropType, Ref, UnwrapNestedRefs } from 'vue';
3
+ import type { Directive, InjectionKey, MaybeRef, PropType, Ref, UnwrapNestedRefs } from 'vue';
4
4
 
5
5
  type BaseProp<T> = {
6
6
  type?: PropType<T>;
@@ -10,7 +10,10 @@ type BaseProp<T> = {
10
10
  type RequiredProp<T> = BaseProp<T> & { required: true };
11
11
  type OptionalProp<T> = BaseProp<T> & { default: T | (() => T) | null };
12
12
 
13
+ export type AcceptRefs<T> = { [K in keyof T]: T[K] | RefUnion<T[K]> };
13
14
  export type ComponentProps = Record<string, unknown>;
15
+ export type RefUnion<T> = T extends infer R ? Ref<R> : never;
16
+ export type Unref<T> = { [K in keyof T]: T[K] extends MaybeRef<infer Value> ? Value : T[K] };
14
17
 
15
18
  export function arrayProp<T>(defaultValue?: () => T[]): OptionalProp<T[]> {
16
19
  return {