@aerogel/core 0.0.0-next.59bf5f7cc06e728d0cf6c00de28f1da48d7d6b8e → 0.0.0-next.88c59e62f64db70aedfbc4c31b5bbc287be44483

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 (66) 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 +498 -118
  4. package/dist/aerogel-core.esm.js +1 -1
  5. package/dist/aerogel-core.esm.js.map +1 -1
  6. package/dist/virtual.d.ts +11 -0
  7. package/noeldemartin.config.js +4 -1
  8. package/package.json +3 -3
  9. package/src/bootstrap/index.ts +4 -1
  10. package/src/components/AGAppModals.vue +15 -0
  11. package/src/components/AGAppOverlays.vue +5 -7
  12. package/src/components/AGAppSnackbars.vue +13 -0
  13. package/src/components/basic/AGErrorMessage.vue +16 -0
  14. package/src/components/basic/AGLink.vue +9 -0
  15. package/src/components/basic/AGMarkdown.vue +7 -6
  16. package/src/components/basic/index.ts +3 -1
  17. package/src/components/constants.ts +8 -0
  18. package/src/components/forms/AGButton.vue +25 -15
  19. package/src/components/forms/index.ts +4 -6
  20. package/src/components/headless/forms/AGHeadlessButton.vue +7 -7
  21. package/src/components/headless/forms/AGHeadlessInput.vue +1 -1
  22. package/src/components/headless/forms/AGHeadlessSelect.ts +31 -0
  23. package/src/components/headless/forms/AGHeadlessSelect.vue +45 -0
  24. package/src/components/headless/forms/AGHeadlessSelectButton.ts +3 -0
  25. package/src/components/headless/forms/AGHeadlessSelectLabel.ts +3 -0
  26. package/src/components/headless/forms/AGHeadlessSelectOption.ts +8 -0
  27. package/src/components/headless/forms/AGHeadlessSelectOptions.ts +3 -0
  28. package/src/components/headless/forms/index.ts +7 -1
  29. package/src/components/headless/index.ts +1 -0
  30. package/src/components/headless/snackbars/AGHeadlessSnackbar.vue +10 -0
  31. package/src/components/headless/snackbars/index.ts +25 -0
  32. package/src/components/index.ts +2 -0
  33. package/src/components/modals/AGAlertModal.vue +0 -1
  34. package/src/components/modals/AGConfirmModal.vue +3 -3
  35. package/src/components/modals/AGErrorReportModal.ts +20 -0
  36. package/src/components/modals/AGErrorReportModal.vue +62 -0
  37. package/src/components/modals/AGErrorReportModalButtons.vue +109 -0
  38. package/src/components/modals/AGErrorReportModalTitle.vue +25 -0
  39. package/src/components/modals/AGModal.ts +1 -1
  40. package/src/components/modals/AGModal.vue +3 -2
  41. package/src/components/modals/AGModalTitle.vue +9 -0
  42. package/src/components/modals/index.ts +17 -2
  43. package/src/components/snackbars/AGSnackbar.vue +42 -0
  44. package/src/components/snackbars/index.ts +3 -0
  45. package/src/directives/index.ts +16 -3
  46. package/src/errors/Errors.ts +60 -9
  47. package/src/errors/index.ts +40 -2
  48. package/src/forms/Form.ts +6 -3
  49. package/src/lang/Lang.ts +1 -1
  50. package/src/lang/index.ts +1 -1
  51. package/src/main.ts +0 -2
  52. package/src/plugins/Plugin.ts +1 -0
  53. package/src/plugins/index.ts +19 -0
  54. package/src/services/App.state.ts +8 -3
  55. package/src/services/App.ts +5 -2
  56. package/src/services/Service.ts +7 -2
  57. package/src/services/index.ts +6 -3
  58. package/src/types/virtual.d.ts +11 -0
  59. package/src/ui/UI.state.ts +10 -1
  60. package/src/ui/UI.ts +37 -8
  61. package/src/ui/index.ts +5 -1
  62. package/src/utils/markdown.ts +11 -2
  63. package/src/utils/vue.ts +4 -2
  64. package/tsconfig.json +1 -0
  65. package/vite.config.ts +2 -1
  66. package/src/globals.ts +0 -6
@@ -0,0 +1,9 @@
1
+ <template>
2
+ <AGHeadlessModalTitle class="mb-2 font-semibold">
3
+ <slot />
4
+ </AGHeadlessModalTitle>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import AGHeadlessModalTitle from '../headless/modals/AGHeadlessModalTitle.vue';
9
+ </script>
@@ -1,8 +1,23 @@
1
1
  import AGAlertModal from './AGAlertModal.vue';
2
2
  import AGConfirmModal from './AGConfirmModal.vue';
3
+ import AGErrorReportModalButtons from './AGErrorReportModalButtons.vue';
4
+ import AGErrorReportModalTitle from './AGErrorReportModalTitle.vue';
3
5
  import AGLoadingModal from './AGLoadingModal.vue';
4
6
  import AGModal from './AGModal.vue';
7
+ import AGModalTitle from './AGModalTitle.vue';
5
8
  import AGModalContext from './AGModalContext.vue';
6
- import { IAGModal } from './AGModal';
7
9
 
8
- export { AGAlertModal, AGConfirmModal, AGModal, AGModalContext, AGLoadingModal, IAGModal };
10
+ export * from './AGErrorReportModal';
11
+ export * from './AGModal';
12
+ export * from './AGModalContext';
13
+
14
+ export {
15
+ AGAlertModal,
16
+ AGConfirmModal,
17
+ AGErrorReportModalButtons,
18
+ AGErrorReportModalTitle,
19
+ AGLoadingModal,
20
+ AGModal,
21
+ AGModalTitle,
22
+ AGModalContext,
23
+ };
@@ -0,0 +1,42 @@
1
+ <template>
2
+ <AGHeadlessSnackbar class="flex flex-row items-center justify-center gap-3 p-4" :class="styleClasses">
3
+ <AGMarkdown :text="message" inline />
4
+ <AGButton
5
+ v-for="(action, i) of actions"
6
+ :key="i"
7
+ :color="color"
8
+ @click="activate(action)"
9
+ >
10
+ {{ action.text }}
11
+ </AGButton>
12
+ </AGHeadlessSnackbar>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ import { computed } from 'vue';
17
+
18
+ import UI from '@/ui/UI';
19
+ import { Colors } from '@/components/constants';
20
+ import { useSnackbarProps } from '@/components/headless';
21
+ import type { SnackbarAction } from '@/components/headless';
22
+
23
+ import AGButton from '../forms/AGButton.vue';
24
+ import AGHeadlessSnackbar from '../headless/snackbars/AGHeadlessSnackbar.vue';
25
+ import AGMarkdown from '../basic/AGMarkdown.vue';
26
+
27
+ const props = defineProps(useSnackbarProps());
28
+ const styleClasses = computed(() => {
29
+ switch (props.color) {
30
+ case Colors.Danger:
31
+ return 'bg-red-200 text-red-900';
32
+ default:
33
+ case Colors.Secondary:
34
+ return 'bg-gray-900 text-white';
35
+ }
36
+ });
37
+
38
+ function activate(action: SnackbarAction): void {
39
+ action.handler?.();
40
+ action.dismiss && UI.hideSnackbar(props.id);
41
+ }
42
+ </script>
@@ -0,0 +1,3 @@
1
+ import AGSnackbar from './AGSnackbar.vue';
2
+
3
+ export { AGSnackbar };
@@ -4,12 +4,25 @@ import { definePlugin } from '@/plugins';
4
4
 
5
5
  import initialFocus from './initial-focus';
6
6
 
7
- const directives: Record<string, Directive> = {
7
+ const builtInDirectives: Record<string, Directive> = {
8
8
  'initial-focus': initialFocus,
9
9
  };
10
10
 
11
11
  export default definePlugin({
12
- install(app) {
13
- Object.entries(directives).forEach(([name, directive]) => app.directive(name, directive));
12
+ install(app, options) {
13
+ const directives = {
14
+ ...builtInDirectives,
15
+ ...options.directives,
16
+ };
17
+
18
+ for (const [name, directive] of Object.entries(directives)) {
19
+ app.directive(name, directive);
20
+ }
14
21
  },
15
22
  });
23
+
24
+ declare module '@/bootstrap/options' {
25
+ interface AerogelOptions {
26
+ directives?: Record<string, Directive>;
27
+ }
28
+ }
@@ -1,11 +1,12 @@
1
- import { JSError, facade, isObject } from '@noeldemartin/utils';
1
+ import { JSError, facade, isObject, objectWithoutEmpty, toString } from '@noeldemartin/utils';
2
2
 
3
3
  import App from '@/services/App';
4
4
  import ServiceBootError from '@/errors/ServiceBootError';
5
- import UI from '@/ui/UI';
6
- import { translate } from '@/lang/utils';
5
+ import UI, { UIComponents } from '@/ui/UI';
6
+ import { translateWithDefault } from '@/lang/utils';
7
7
 
8
8
  import Service from './Errors.state';
9
+ import { Colors } from '@/components/constants';
9
10
  import type { ErrorReport, ErrorReportLog, ErrorSource } from './Errors.state';
10
11
 
11
12
  export class ErrorsService extends Service {
@@ -24,12 +25,17 @@ export class ErrorsService extends Service {
24
25
  public async inspect(error: ErrorSource | ErrorReport[]): Promise<void> {
25
26
  const reports = Array.isArray(error) ? error : [await this.createErrorReport(error)];
26
27
 
27
- // TODO open errors modal
28
- reports;
28
+ if (reports.length === 0) {
29
+ UI.alert(translateWithDefault('errors.inspectEmpty', 'Nothing to inspect!'));
30
+
31
+ return;
32
+ }
33
+
34
+ UI.openModal(UI.requireComponent(UIComponents.ErrorReportModal), { reports });
29
35
  }
30
36
 
31
37
  public async report(error: ErrorSource, message?: string): Promise<void> {
32
- if (App.isDevelopment || App.isTesting) {
38
+ if (App.development || App.testing) {
33
39
  this.logError(error);
34
40
  }
35
41
 
@@ -54,8 +60,23 @@ export class ErrorsService extends Service {
54
60
  date: new Date(),
55
61
  };
56
62
 
57
- // TODO open error snackbar
58
- UI.alert(message ?? 'Something went wrong, but it\'s not your fault! (look at the console for details)');
63
+ UI.showSnackbar(
64
+ message ??
65
+ translateWithDefault('errors.notice', 'Something went wrong, but it\'s not your fault. Try again!'),
66
+ {
67
+ color: Colors.Danger,
68
+ actions: [
69
+ {
70
+ text: translateWithDefault('errors.viewDetails', 'View details'),
71
+ dismiss: true,
72
+ handler: () =>
73
+ UI.openModal(UI.requireComponent(UIComponents.ErrorReportModal), {
74
+ reports: [report],
75
+ }),
76
+ },
77
+ ],
78
+ },
79
+ );
59
80
 
60
81
  this.setState({ logs: [log].concat(this.logs) });
61
82
  }
@@ -84,6 +105,22 @@ export class ErrorsService extends Service {
84
105
  });
85
106
  }
86
107
 
108
+ public getErrorMessage(error: ErrorSource): string {
109
+ if (typeof error === 'string') {
110
+ return error;
111
+ }
112
+
113
+ if (error instanceof Error || error instanceof JSError) {
114
+ return error.message;
115
+ }
116
+
117
+ if (isObject(error)) {
118
+ return toString(error['message'] ?? error['description'] ?? 'Unknown error object');
119
+ }
120
+
121
+ return translateWithDefault('errors.unknown', 'Unknown Error');
122
+ }
123
+
87
124
  private logError(error: unknown): void {
88
125
  // eslint-disable-next-line no-console
89
126
  console.error(error);
@@ -102,8 +139,22 @@ export class ErrorsService extends Service {
102
139
  return this.createErrorReportFromError(error);
103
140
  }
104
141
 
142
+ if (isObject(error)) {
143
+ return objectWithoutEmpty({
144
+ title: toString(
145
+ error['name'] ?? error['title'] ?? translateWithDefault('errors.unknown', 'Unknown Error'),
146
+ ),
147
+ description: toString(
148
+ error['message'] ??
149
+ error['description'] ??
150
+ translateWithDefault('errors.unknownDescription', 'Unknown error object'),
151
+ ),
152
+ error,
153
+ });
154
+ }
155
+
105
156
  return {
106
- title: translate('errors.unknown'),
157
+ title: translateWithDefault('errors.unknown', 'Unknown Error'),
107
158
  error,
108
159
  };
109
160
  }
@@ -1,3 +1,5 @@
1
+ import { tap } from '@noeldemartin/utils';
2
+
1
3
  import { bootServices } from '@/services';
2
4
  import { definePlugin } from '@/plugins';
3
5
 
@@ -7,15 +9,51 @@ import { ErrorReport, ErrorReportLog, ErrorSource } from './Errors.state';
7
9
  export { Errors, ErrorSource, ErrorReport, ErrorReportLog };
8
10
 
9
11
  const services = { $errors: Errors };
12
+ const frameworkHandler: ErrorHandler = (error) => {
13
+ if (!Errors.instance) {
14
+ // eslint-disable-next-line no-console
15
+ console.warn('Errors service hasn\'t been initialized properly!');
16
+
17
+ // eslint-disable-next-line no-console
18
+ console.error(error);
19
+
20
+ return true;
21
+ }
10
22
 
23
+ Errors.report(error);
24
+
25
+ return true;
26
+ };
27
+
28
+ function setUpErrorHandler(baseHandler: ErrorHandler = () => false): ErrorHandler {
29
+ return tap(
30
+ (error) => baseHandler(error) || frameworkHandler(error),
31
+ (errorHandler) => {
32
+ globalThis.onerror = (message, _, __, ___, error) => errorHandler(error ?? message);
33
+ globalThis.onunhandledrejection = (event) => errorHandler(event.reason);
34
+ },
35
+ );
36
+ }
37
+
38
+ export type ErrorHandler = (error: ErrorSource) => boolean;
11
39
  export type ErrorsServices = typeof services;
12
40
 
13
41
  export default definePlugin({
14
- async install(app) {
42
+ async install(app, options) {
43
+ const errorHandler = setUpErrorHandler(options.handleError);
44
+
45
+ app.config.errorHandler = errorHandler;
46
+
15
47
  await bootServices(app, services);
16
48
  },
17
49
  });
18
50
 
51
+ declare module '@/bootstrap/options' {
52
+ interface AerogelOptions {
53
+ handleError?(error: ErrorSource): boolean;
54
+ }
55
+ }
56
+
19
57
  declare module '@/services' {
20
- interface Services extends ErrorsServices {}
58
+ export interface Services extends ErrorsServices {}
21
59
  }
package/src/forms/Form.ts CHANGED
@@ -7,6 +7,7 @@ export const FormFieldTypes = {
7
7
  String: 'string',
8
8
  Number: 'number',
9
9
  Boolean: 'boolean',
10
+ Object: 'object',
10
11
  } as const;
11
12
 
12
13
  export interface FormFieldDefinition<TType extends FormFieldType = FormFieldType, TRules extends string = string> {
@@ -36,6 +37,8 @@ export type GetFormFieldValue<TType> = TType extends typeof FormFieldTypes.Strin
36
37
  ? number
37
38
  : TType extends typeof FormFieldTypes.Boolean
38
39
  ? boolean
40
+ : TType extends typeof FormFieldTypes.Object
41
+ ? object
39
42
  : never;
40
43
 
41
44
  export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinitions> extends MagicObject {
@@ -92,11 +95,11 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
92
95
  return this.valid;
93
96
  }
94
97
 
95
- public reset(): void {
98
+ public reset(options: { keepData?: boolean; keepErrors?: boolean } = {}): void {
96
99
  this._submitted.value = false;
97
100
 
98
- this.resetData();
99
- this.resetErrors();
101
+ options.keepData || this.resetData();
102
+ options.keepErrors || this.resetErrors();
100
103
  }
101
104
 
102
105
  public submit(): boolean {
package/src/lang/Lang.ts CHANGED
@@ -17,7 +17,7 @@ export class LangService extends Service {
17
17
  this.provider = {
18
18
  translate: (key) => {
19
19
  // eslint-disable-next-line no-console
20
- App.isDevelopment && console.warn('Lang provider is missing');
20
+ App.development && console.warn('Lang provider is missing');
21
21
 
22
22
  return key;
23
23
  },
package/src/lang/index.ts CHANGED
@@ -20,7 +20,7 @@ export default definePlugin({
20
20
  });
21
21
 
22
22
  declare module '@/services' {
23
- interface Services extends LangServices {}
23
+ export interface Services extends LangServices {}
24
24
  }
25
25
 
26
26
  declare module '@vue/runtime-core' {
package/src/main.ts CHANGED
@@ -1,5 +1,3 @@
1
- import './globals';
2
-
3
1
  export * from './bootstrap';
4
2
  export * from './components';
5
3
  export * from './errors';
@@ -3,5 +3,6 @@ import type { App } from 'vue';
3
3
  import type { AerogelOptions } from '@/bootstrap/options';
4
4
 
5
5
  export interface Plugin {
6
+ name?: string;
6
7
  install(app: App, options: AerogelOptions): void | Promise<void>;
7
8
  }
@@ -1,3 +1,7 @@
1
+ import type { GetClosureArgs } from '@noeldemartin/utils';
2
+
3
+ import App from '@/services/App';
4
+
1
5
  import type { Plugin } from './Plugin';
2
6
 
3
7
  export * from './Plugin';
@@ -5,3 +9,18 @@ export * from './Plugin';
5
9
  export function definePlugin<T extends Plugin>(plugin: T): T {
6
10
  return plugin;
7
11
  }
12
+
13
+ export async function installPlugins(plugins: Plugin[], ...args: GetClosureArgs<Plugin['install']>): Promise<void> {
14
+ App.setState(
15
+ 'plugins',
16
+ plugins.reduce((pluginsMap, plugin) => {
17
+ if (plugin.name) {
18
+ pluginsMap[plugin.name] = plugin;
19
+ }
20
+
21
+ return pluginsMap;
22
+ }, {} as Record<string, Plugin>),
23
+ );
24
+
25
+ await Promise.all(plugins.map((plugin) => plugin.install(...args)) ?? []);
26
+ }
@@ -1,13 +1,18 @@
1
+ import Build from 'virtual:aerogel';
2
+
1
3
  import { defineServiceState } from '@/services/Service';
4
+ import type { Plugin } from '@/plugins/Plugin';
2
5
 
3
6
  export default defineServiceState({
4
7
  name: 'app',
5
8
  initialState: {
6
- environment: __AG_ENV,
9
+ plugins: {} as Record<string, Plugin>,
10
+ environment: Build.environment,
11
+ sourceUrl: Build.sourceUrl,
7
12
  isMounted: false,
8
13
  },
9
14
  computed: {
10
- isDevelopment: (state) => state.environment === 'development',
11
- isTesting: (state) => state.environment === 'testing',
15
+ development: (state) => state.environment === 'development',
16
+ testing: (state) => state.environment === 'testing',
12
17
  },
13
18
  });
@@ -1,14 +1,17 @@
1
1
  import { facade } from '@noeldemartin/utils';
2
2
 
3
3
  import Events from '@/services/Events';
4
+ import type { Plugin } from '@/plugins';
4
5
 
5
6
  import Service from './App.state';
6
7
 
7
8
  export class AppService extends Service {
8
9
 
9
- protected async boot(): Promise<void> {
10
- await super.boot();
10
+ public plugin<T extends Plugin = Plugin>(name: string): T | null {
11
+ return (this.plugins[name] as T) ?? null;
12
+ }
11
13
 
14
+ protected async boot(): Promise<void> {
12
15
  Events.once('application-mounted', () => this.setState({ isMounted: true }));
13
16
  }
14
17
 
@@ -93,7 +93,8 @@ export default class Service<
93
93
  const handleError = (error: unknown) => this._booted.reject(new ServiceBootError(this._name, error));
94
94
 
95
95
  try {
96
- this.boot()
96
+ this.frameworkBoot()
97
+ .then(() => this.boot())
97
98
  .then(() => this._booted.resolve())
98
99
  .catch(handleError);
99
100
  } catch (error) {
@@ -189,10 +190,14 @@ export default class Service<
189
190
  return state;
190
191
  }
191
192
 
192
- protected async boot(): Promise<void> {
193
+ protected async frameworkBoot(): Promise<void> {
193
194
  this.restorePersistedState();
194
195
  }
195
196
 
197
+ protected async boot(): Promise<void> {
198
+ // Override.
199
+ }
200
+
196
201
  protected restorePersistedState(): void {
197
202
  // TODO fix this.static()
198
203
  const persist = (this.constructor as unknown as { persist: string[] }).persist;
@@ -24,13 +24,16 @@ export interface Services extends DefaultServices {}
24
24
 
25
25
  export async function bootServices(app: VueApp, services: Record<string, Service>): Promise<void> {
26
26
  await Promise.all(
27
- Object.entries(services).map(async ([_, service]) => {
28
- // eslint-disable-next-line no-console
29
- await service.launch().catch((error) => console.error(error));
27
+ Object.entries(services).map(async ([name, service]) => {
28
+ await service
29
+ .launch()
30
+ .catch((error) => app.config.errorHandler?.(error, null, `Failed launching ${name}.`));
30
31
  }),
31
32
  );
32
33
 
33
34
  Object.assign(app.config.globalProperties, services);
35
+
36
+ App.development && Object.assign(window, services);
34
37
  }
35
38
 
36
39
  export default definePlugin({
@@ -0,0 +1,11 @@
1
+ declare module 'virtual:aerogel' {
2
+ interface AerogelBuild {
3
+ environment: 'production' | 'development' | 'testing';
4
+ basePath?: string;
5
+ sourceUrl?: string;
6
+ }
7
+
8
+ const build: AerogelBuild;
9
+
10
+ export default build;
11
+ }
@@ -17,7 +17,16 @@ export interface ModalComponent<
17
17
  Result = unknown
18
18
  > {}
19
19
 
20
+ export interface Snackbar {
21
+ id: string;
22
+ component: Component;
23
+ properties: Record<string, unknown>;
24
+ }
25
+
20
26
  export default defineServiceState({
21
27
  name: 'ui',
22
- initialState: { modals: [] as Modal[] },
28
+ initialState: {
29
+ modals: [] as Modal[],
30
+ snackbars: [] as Snackbar[],
31
+ },
23
32
  });
package/src/ui/UI.ts CHANGED
@@ -4,9 +4,10 @@ import type { Component } from 'vue';
4
4
  import type { ObjectValues } from '@noeldemartin/utils';
5
5
 
6
6
  import Events from '@/services/Events';
7
+ import type { SnackbarAction, SnackbarColor } from '@/components/headless/snackbars';
7
8
 
8
9
  import Service from './UI.state';
9
- import type { Modal, ModalComponent } from './UI.state';
10
+ import type { Modal, ModalComponent, Snackbar } from './UI.state';
10
11
 
11
12
  interface ModalCallbacks<T = unknown> {
12
13
  willClose(result: T | undefined): void;
@@ -21,16 +22,28 @@ type ModalResult<TComponent> = TComponent extends ModalComponent<Record<string,
21
22
  export const UIComponents = {
22
23
  AlertModal: 'alert-modal',
23
24
  ConfirmModal: 'confirm-modal',
25
+ ErrorReportModal: 'error-report-modal',
24
26
  LoadingModal: 'loading-modal',
27
+ Snackbar: 'snackbar',
25
28
  } as const;
26
29
 
27
30
  export type UIComponent = ObjectValues<typeof UIComponents>;
28
31
 
32
+ export interface ShowSnackbarOptions {
33
+ component?: Component;
34
+ color?: SnackbarColor;
35
+ actions?: SnackbarAction[];
36
+ }
37
+
29
38
  export class UIService extends Service {
30
39
 
31
40
  private modalCallbacks: Record<string, Partial<ModalCallbacks>> = {};
32
41
  private components: Partial<Record<UIComponent, Component>> = {};
33
42
 
43
+ public requireComponent(name: UIComponent): Component {
44
+ return this.components[name] ?? fail(`UI Component '${name}' is not defined!`);
45
+ }
46
+
34
47
  public alert(message: string): void;
35
48
  public alert(title: string, message: string): void;
36
49
  public alert(messageOrTitle: string, message?: string): void {
@@ -66,6 +79,25 @@ export class UIService extends Service {
66
79
  return result;
67
80
  }
68
81
 
82
+ public showSnackbar(message: string, options: ShowSnackbarOptions = {}): void {
83
+ const snackbar: Snackbar = {
84
+ id: uuid(),
85
+ properties: { message, ...options },
86
+ component: options.component ?? markRaw(this.requireComponent(UIComponents.Snackbar)),
87
+ };
88
+
89
+ this.setState('snackbars', this.snackbars.concat(snackbar));
90
+
91
+ setTimeout(() => this.hideSnackbar(snackbar.id), 5000);
92
+ }
93
+
94
+ public hideSnackbar(id: string): void {
95
+ this.setState(
96
+ 'snackbars',
97
+ this.snackbars.filter((snackbar) => snackbar.id !== id),
98
+ );
99
+ }
100
+
69
101
  public registerComponent(name: UIComponent, component: Component): void {
70
102
  this.components[name] = component;
71
103
  }
@@ -105,15 +137,9 @@ export class UIService extends Service {
105
137
  }
106
138
 
107
139
  protected async boot(): Promise<void> {
108
- await super.boot();
109
-
110
140
  this.watchModalEvents();
111
141
  }
112
142
 
113
- private requireComponent(name: UIComponent): Component {
114
- return this.components[name] ?? fail(`UI Component '${name}' is not defined!`);
115
- }
116
-
117
143
  private watchModalEvents(): void {
118
144
  Events.on('modal-will-close', ({ modal, result }) => {
119
145
  this.modalCallbacks[modal.id]?.willClose?.(result);
@@ -124,7 +150,10 @@ export class UIService extends Service {
124
150
  });
125
151
 
126
152
  Events.on('modal-closed', async ({ modal, result }) => {
127
- this.setState({ modals: this.modals.filter((m) => m.id !== modal.id) });
153
+ this.setState(
154
+ 'modals',
155
+ this.modals.filter((m) => m.id !== modal.id),
156
+ );
128
157
 
129
158
  this.modalCallbacks[modal.id]?.closed?.(result);
130
159
 
package/src/ui/index.ts CHANGED
@@ -6,7 +6,9 @@ import { definePlugin } from '@/plugins';
6
6
  import UI, { UIComponents } from './UI';
7
7
  import AGAlertModal from '../components/modals/AGAlertModal.vue';
8
8
  import AGConfirmModal from '../components/modals/AGConfirmModal.vue';
9
+ import AGErrorReportModal from '../components/modals/AGErrorReportModal.vue';
9
10
  import AGLoadingModal from '../components/modals/AGLoadingModal.vue';
11
+ import AGSnackbar from '../components/snackbars/AGSnackbar.vue';
10
12
  import type { UIComponent } from './UI';
11
13
 
12
14
  export { UI, UIComponents, UIComponent };
@@ -20,7 +22,9 @@ export default definePlugin({
20
22
  const defaultComponents = {
21
23
  [UIComponents.AlertModal]: AGAlertModal,
22
24
  [UIComponents.ConfirmModal]: AGConfirmModal,
25
+ [UIComponents.ErrorReportModal]: AGErrorReportModal,
23
26
  [UIComponents.LoadingModal]: AGLoadingModal,
27
+ [UIComponents.Snackbar]: AGSnackbar,
24
28
  };
25
29
 
26
30
  Object.entries({
@@ -39,5 +43,5 @@ declare module '@/bootstrap/options' {
39
43
  }
40
44
 
41
45
  declare module '@/services' {
42
- interface Services extends UIServices {}
46
+ export interface Services extends UIServices {}
43
47
  }
@@ -1,8 +1,17 @@
1
+ import { tap } from '@noeldemartin/utils';
1
2
  import DOMPurify from 'dompurify';
2
- import { marked } from 'marked';
3
+ import { Renderer, marked } from 'marked';
4
+
5
+ function makeRenderer(): Renderer {
6
+ return tap(new Renderer(), (renderer) => {
7
+ renderer.link = function(href, title, text) {
8
+ return Renderer.prototype.link.apply(this, [href, title, text]).replace('<a', '<a target="_blank"');
9
+ };
10
+ });
11
+ }
3
12
 
4
13
  export function renderMarkdown(markdown: string): string {
5
- return safeHtml(marked(markdown, { mangle: false, headerIds: false }));
14
+ return safeHtml(marked(markdown, { mangle: false, headerIds: false, renderer: makeRenderer() }));
6
15
  }
7
16
 
8
17
  export function safeHtml(html: string): string {
package/src/utils/vue.ts CHANGED
@@ -3,13 +3,15 @@ import { inject, reactive, ref } from 'vue';
3
3
  import type { Directive, InjectionKey, PropType, Ref, UnwrapNestedRefs } from 'vue';
4
4
 
5
5
  type BaseProp<T> = {
6
- type: PropType<T>;
6
+ type?: PropType<T>;
7
7
  validator?(value: unknown): boolean;
8
8
  };
9
9
 
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 ComponentProps = Record<string, unknown>;
14
+
13
15
  export function arrayProp<T>(defaultValue?: () => T[]): OptionalProp<T[]> {
14
16
  return {
15
17
  type: Array as PropType<T[]>,
@@ -62,7 +64,7 @@ export function injectOrFail<T>(key: InjectionKey<T> | string, errorMessage?: st
62
64
  return inject(key) ?? fail(errorMessage ?? `Could not resolve '${key}' injection key`);
63
65
  }
64
66
 
65
- export function mixedProp<T>(type: PropType<T>): OptionalProp<T | null> {
67
+ export function mixedProp<T>(type?: PropType<T>): OptionalProp<T | null> {
66
68
  return {
67
69
  type,
68
70
  default: null,