@aerogel/core 0.0.0-next.f1f5a990033d966dc0bb12d251110fbc9350dcc7 → 0.0.0-next.f86b4b09f066c4aef21796a37dbc8417b7dce3cd
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.
- package/dist/aerogel-core.cjs.js +1 -1
- package/dist/aerogel-core.cjs.js.map +1 -1
- package/dist/aerogel-core.d.ts +730 -139
- package/dist/aerogel-core.esm.js +1 -1
- package/dist/aerogel-core.esm.js.map +1 -1
- package/package.json +3 -3
- package/src/bootstrap/bootstrap.test.ts +0 -1
- package/src/bootstrap/index.ts +18 -4
- package/src/bootstrap/options.ts +3 -0
- package/src/components/AGAppSnackbars.vue +1 -1
- package/src/components/composition.ts +23 -0
- package/src/components/forms/AGCheckbox.vue +7 -1
- package/src/components/forms/AGForm.vue +9 -10
- package/src/components/forms/AGInput.vue +10 -6
- package/src/components/forms/AGSelect.story.vue +21 -3
- package/src/components/forms/AGSelect.vue +10 -3
- package/src/components/headless/forms/AGHeadlessButton.ts +3 -0
- package/src/components/headless/forms/AGHeadlessButton.vue +23 -12
- package/src/components/headless/forms/AGHeadlessInput.ts +10 -4
- package/src/components/headless/forms/AGHeadlessInput.vue +18 -5
- package/src/components/headless/forms/AGHeadlessInputDescription.vue +28 -0
- package/src/components/headless/forms/AGHeadlessInputInput.vue +44 -5
- package/src/components/headless/forms/AGHeadlessInputTextArea.vue +43 -0
- package/src/components/headless/forms/AGHeadlessSelect.ts +15 -12
- package/src/components/headless/forms/AGHeadlessSelect.vue +23 -22
- package/src/components/headless/forms/AGHeadlessSelectOption.vue +6 -6
- package/src/components/headless/forms/composition.ts +10 -0
- package/src/components/headless/forms/index.ts +4 -0
- package/src/components/index.ts +2 -0
- package/src/components/interfaces.ts +24 -0
- package/src/components/lib/AGErrorMessage.vue +2 -2
- package/src/components/lib/AGMarkdown.vue +9 -4
- package/src/components/lib/AGMeasured.vue +1 -0
- package/src/components/modals/AGConfirmModal.ts +11 -3
- package/src/components/modals/AGConfirmModal.vue +2 -2
- package/src/components/modals/AGPromptModal.ts +36 -0
- package/src/components/modals/AGPromptModal.vue +34 -0
- package/src/components/modals/index.ts +10 -19
- package/src/directives/index.ts +2 -0
- package/src/directives/measure.ts +33 -5
- package/src/errors/Errors.ts +16 -19
- package/src/errors/index.ts +1 -10
- package/src/errors/utils.ts +35 -0
- package/src/forms/Form.test.ts +28 -0
- package/src/forms/Form.ts +66 -8
- package/src/forms/index.ts +3 -1
- package/src/forms/utils.ts +34 -3
- package/src/forms/validation.ts +19 -0
- package/src/jobs/Job.ts +5 -0
- package/src/jobs/index.ts +7 -0
- package/src/lang/DefaultLangProvider.ts +43 -0
- package/src/lang/Lang.state.ts +11 -0
- package/src/lang/Lang.ts +44 -29
- package/src/main.ts +3 -0
- package/src/services/App.state.ts +18 -2
- package/src/services/App.ts +29 -3
- package/src/services/Cache.ts +43 -0
- package/src/services/Events.test.ts +39 -0
- package/src/services/Events.ts +100 -30
- package/src/services/Service.ts +51 -13
- package/src/services/index.ts +4 -1
- package/src/services/store.ts +8 -5
- package/src/testing/index.ts +25 -0
- package/src/testing/setup.ts +19 -0
- package/src/ui/UI.state.ts +7 -0
- package/src/ui/UI.ts +124 -18
- package/src/ui/index.ts +3 -0
- package/src/ui/utils.ts +16 -0
- package/src/utils/composition/state.test.ts +47 -0
- package/src/utils/composition/state.ts +24 -0
- package/src/utils/vue.ts +11 -2
- package/vite.config.ts +4 -1
package/src/forms/Form.test.ts
CHANGED
|
@@ -55,4 +55,32 @@ describe('Form', () => {
|
|
|
55
55
|
expect(form.name).toBeNull();
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
it('trims values', () => {
|
|
59
|
+
// Arrange
|
|
60
|
+
const form = useForm({
|
|
61
|
+
trimmed: {
|
|
62
|
+
type: FormFieldTypes.String,
|
|
63
|
+
rules: 'required',
|
|
64
|
+
},
|
|
65
|
+
untrimmed: {
|
|
66
|
+
type: FormFieldTypes.String,
|
|
67
|
+
rules: 'required',
|
|
68
|
+
trim: false,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Act
|
|
73
|
+
form.trimmed = ' ';
|
|
74
|
+
form.untrimmed = ' ';
|
|
75
|
+
|
|
76
|
+
form.submit();
|
|
77
|
+
|
|
78
|
+
// Assert
|
|
79
|
+
expect(form.valid).toBe(false);
|
|
80
|
+
expect(form.submitted).toBe(true);
|
|
81
|
+
expect(form.trimmed).toEqual('');
|
|
82
|
+
expect(form.untrimmed).toEqual(' ');
|
|
83
|
+
expect(form.errors).toEqual({ trimmed: ['required'], untrimmed: null });
|
|
84
|
+
});
|
|
85
|
+
|
|
58
86
|
});
|
package/src/forms/Form.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { computed, nextTick, reactive, readonly, ref } from 'vue';
|
|
2
|
+
import { MagicObject, arrayRemove, fail, toString } from '@noeldemartin/utils';
|
|
3
|
+
import { validate } from './validation';
|
|
3
4
|
import type { ObjectValues } from '@noeldemartin/utils';
|
|
4
5
|
import type { ComputedRef, DeepReadonly, Ref, UnwrapNestedRefs } from 'vue';
|
|
5
6
|
|
|
@@ -8,16 +9,19 @@ export const FormFieldTypes = {
|
|
|
8
9
|
Number: 'number',
|
|
9
10
|
Boolean: 'boolean',
|
|
10
11
|
Object: 'object',
|
|
12
|
+
Date: 'date',
|
|
11
13
|
} as const;
|
|
12
14
|
|
|
13
15
|
export interface FormFieldDefinition<TType extends FormFieldType = FormFieldType, TRules extends string = string> {
|
|
14
16
|
type: TType;
|
|
17
|
+
trim?: boolean;
|
|
15
18
|
default?: GetFormFieldValue<TType>;
|
|
16
19
|
rules?: TRules;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export type FormFieldDefinitions = Record<string, FormFieldDefinition>;
|
|
20
23
|
export type FormFieldType = ObjectValues<typeof FormFieldTypes>;
|
|
24
|
+
export type FormFieldValue = GetFormFieldValue<FormFieldType>;
|
|
21
25
|
|
|
22
26
|
export type FormData<T> = {
|
|
23
27
|
-readonly [k in keyof T]: T[k] extends FormFieldDefinition<infer TType, infer TRules>
|
|
@@ -39,10 +43,15 @@ export type GetFormFieldValue<TType> = TType extends typeof FormFieldTypes.Strin
|
|
|
39
43
|
? boolean
|
|
40
44
|
: TType extends typeof FormFieldTypes.Object
|
|
41
45
|
? object
|
|
46
|
+
: TType extends typeof FormFieldTypes.Date
|
|
47
|
+
? Date
|
|
42
48
|
: never;
|
|
43
49
|
|
|
44
50
|
const validForms: WeakMap<Form, ComputedRef<boolean>> = new WeakMap();
|
|
45
51
|
|
|
52
|
+
export type SubmitFormListener = () => unknown;
|
|
53
|
+
export type FocusFormListener = (input: string) => unknown;
|
|
54
|
+
|
|
46
55
|
export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinitions> extends MagicObject {
|
|
47
56
|
|
|
48
57
|
public errors: DeepReadonly<UnwrapNestedRefs<FormErrors<Fields>>>;
|
|
@@ -51,6 +60,7 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
51
60
|
private _data: FormData<Fields>;
|
|
52
61
|
private _submitted: Ref<boolean>;
|
|
53
62
|
private _errors: FormErrors<Fields>;
|
|
63
|
+
private _listeners: { focus?: FocusFormListener[]; submit?: SubmitFormListener[] } = {};
|
|
54
64
|
|
|
55
65
|
constructor(fields: Fields) {
|
|
56
66
|
super();
|
|
@@ -77,7 +87,13 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
public setFieldValue<T extends keyof Fields>(field: T, value: FormData<Fields>[T]): void {
|
|
80
|
-
|
|
90
|
+
const definition =
|
|
91
|
+
this._fields[field] ?? fail<FormFieldDefinition>(`Trying to set undefined '${toString(field)}' field`);
|
|
92
|
+
|
|
93
|
+
this._data[field] =
|
|
94
|
+
definition.type === FormFieldTypes.String && (definition.trim ?? true)
|
|
95
|
+
? (toString(value).trim() as FormData<Fields>[T])
|
|
96
|
+
: value;
|
|
81
97
|
|
|
82
98
|
if (this._submitted.value) {
|
|
83
99
|
this.validate();
|
|
@@ -88,6 +104,14 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
88
104
|
return this._data[field] as unknown as GetFormFieldValue<Fields[T]['type']>;
|
|
89
105
|
}
|
|
90
106
|
|
|
107
|
+
public getFieldRules<T extends keyof Fields>(field: T): string[] {
|
|
108
|
+
return this._fields[field]?.rules?.split('|') ?? [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public data(): FormData<Fields> {
|
|
112
|
+
return { ...this._data };
|
|
113
|
+
}
|
|
114
|
+
|
|
91
115
|
public validate(): boolean {
|
|
92
116
|
const errors = Object.entries(this._fields).reduce((formErrors, [name, definition]) => {
|
|
93
117
|
formErrors[name] = this.getFieldErrors(name, definition);
|
|
@@ -110,7 +134,35 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
110
134
|
public submit(): boolean {
|
|
111
135
|
this._submitted.value = true;
|
|
112
136
|
|
|
113
|
-
|
|
137
|
+
const valid = this.validate();
|
|
138
|
+
|
|
139
|
+
valid && this._listeners['submit']?.forEach((listener) => listener());
|
|
140
|
+
|
|
141
|
+
return valid;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
public on(event: 'focus', listener: FocusFormListener): () => void;
|
|
145
|
+
public on(event: 'submit', listener: SubmitFormListener): () => void;
|
|
146
|
+
public on(event: 'focus' | 'submit', listener: FocusFormListener | SubmitFormListener): () => void {
|
|
147
|
+
this._listeners[event] ??= [];
|
|
148
|
+
|
|
149
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
150
|
+
this._listeners[event]?.push(listener as any);
|
|
151
|
+
|
|
152
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
153
|
+
return () => this.off(event as any, listener);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
public off(event: 'focus', listener: FocusFormListener): void;
|
|
157
|
+
public off(event: 'submit', listener: SubmitFormListener): void;
|
|
158
|
+
public off(event: 'focus' | 'submit', listener: FocusFormListener | SubmitFormListener): void {
|
|
159
|
+
arrayRemove(this._listeners[event] ?? [], listener);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
public async focus(input: string): Promise<void> {
|
|
163
|
+
await nextTick();
|
|
164
|
+
|
|
165
|
+
this._listeners['focus']?.forEach((listener) => listener(input));
|
|
114
166
|
}
|
|
115
167
|
|
|
116
168
|
protected __get(property: string): unknown {
|
|
@@ -118,7 +170,7 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
118
170
|
return super.__get(property);
|
|
119
171
|
}
|
|
120
172
|
|
|
121
|
-
return this.
|
|
173
|
+
return this.getFieldValue(property);
|
|
122
174
|
}
|
|
123
175
|
|
|
124
176
|
protected __set(property: string, value: unknown): void {
|
|
@@ -128,14 +180,20 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
128
180
|
return;
|
|
129
181
|
}
|
|
130
182
|
|
|
131
|
-
|
|
183
|
+
this.setFieldValue(property, value as FormData<Fields>[string]);
|
|
132
184
|
}
|
|
133
185
|
|
|
134
186
|
private getFieldErrors(name: keyof Fields, definition: FormFieldDefinition): string[] | null {
|
|
135
187
|
const errors = [];
|
|
188
|
+
const value = this._data[name];
|
|
189
|
+
const rules = definition.rules?.split('|') ?? [];
|
|
190
|
+
|
|
191
|
+
for (const rule of rules) {
|
|
192
|
+
if (rule !== 'required' && (value === null || value === undefined)) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
136
195
|
|
|
137
|
-
|
|
138
|
-
errors.push('required');
|
|
196
|
+
errors.push(...validate(value, rule));
|
|
139
197
|
}
|
|
140
198
|
|
|
141
199
|
return errors.length > 0 ? errors : null;
|
package/src/forms/index.ts
CHANGED
package/src/forms/utils.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { FormFieldTypes } from './Form';
|
|
2
2
|
import type { FormFieldDefinition } from './Form';
|
|
3
3
|
|
|
4
|
-
export function booleanInput(
|
|
4
|
+
export function booleanInput(
|
|
5
|
+
defaultValue?: boolean,
|
|
6
|
+
options: { rules?: string } = {},
|
|
7
|
+
): FormFieldDefinition<typeof FormFieldTypes.Boolean> {
|
|
5
8
|
return {
|
|
6
9
|
default: defaultValue,
|
|
7
10
|
type: FormFieldTypes.Boolean,
|
|
11
|
+
rules: options.rules,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function dateInput(
|
|
16
|
+
defaultValue?: Date,
|
|
17
|
+
options: { rules?: string } = {},
|
|
18
|
+
): FormFieldDefinition<typeof FormFieldTypes.Date> {
|
|
19
|
+
return {
|
|
20
|
+
default: defaultValue,
|
|
21
|
+
type: FormFieldTypes.Date,
|
|
22
|
+
rules: options.rules,
|
|
8
23
|
};
|
|
9
24
|
}
|
|
10
25
|
|
|
@@ -18,6 +33,14 @@ export function requiredBooleanInput(
|
|
|
18
33
|
};
|
|
19
34
|
}
|
|
20
35
|
|
|
36
|
+
export function requiredDateInput(defaultValue?: Date): FormFieldDefinition<typeof FormFieldTypes.Date> {
|
|
37
|
+
return {
|
|
38
|
+
default: defaultValue,
|
|
39
|
+
type: FormFieldTypes.Date,
|
|
40
|
+
rules: 'required',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
21
44
|
export function requiredNumberInput(
|
|
22
45
|
defaultValue?: number,
|
|
23
46
|
): FormFieldDefinition<typeof FormFieldTypes.Number, 'required'> {
|
|
@@ -38,16 +61,24 @@ export function requiredStringInput(
|
|
|
38
61
|
};
|
|
39
62
|
}
|
|
40
63
|
|
|
41
|
-
export function numberInput(
|
|
64
|
+
export function numberInput(
|
|
65
|
+
defaultValue?: number,
|
|
66
|
+
options: { rules?: string } = {},
|
|
67
|
+
): FormFieldDefinition<typeof FormFieldTypes.Number> {
|
|
42
68
|
return {
|
|
43
69
|
default: defaultValue,
|
|
44
70
|
type: FormFieldTypes.Number,
|
|
71
|
+
rules: options.rules,
|
|
45
72
|
};
|
|
46
73
|
}
|
|
47
74
|
|
|
48
|
-
export function stringInput(
|
|
75
|
+
export function stringInput(
|
|
76
|
+
defaultValue?: string,
|
|
77
|
+
options: { rules?: string } = {},
|
|
78
|
+
): FormFieldDefinition<typeof FormFieldTypes.String> {
|
|
49
79
|
return {
|
|
50
80
|
default: defaultValue,
|
|
51
81
|
type: FormFieldTypes.String,
|
|
82
|
+
rules: options.rules,
|
|
52
83
|
};
|
|
53
84
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { arrayFrom } from '@noeldemartin/utils';
|
|
2
|
+
|
|
3
|
+
const builtInRules: Record<string, FormFieldValidator> = {
|
|
4
|
+
required: (value) => (value ? undefined : 'required'),
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type FormFieldValidator<T = unknown> = (value: T) => string | string[] | undefined;
|
|
8
|
+
|
|
9
|
+
export const validators: Record<string, FormFieldValidator> = { ...builtInRules };
|
|
10
|
+
|
|
11
|
+
export function defineFormValidationRule<T>(rule: string, validator: FormFieldValidator<T>): void {
|
|
12
|
+
validators[rule] = validator as FormFieldValidator;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validate(value: unknown, rule: string): string[] {
|
|
16
|
+
const errors = validators[rule]?.(value);
|
|
17
|
+
|
|
18
|
+
return errors ? arrayFrom(errors) : [];
|
|
19
|
+
}
|
package/src/jobs/Job.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import App from '@/services/App';
|
|
2
|
+
|
|
3
|
+
import type { LangProvider } from './Lang';
|
|
4
|
+
|
|
5
|
+
export default class DefaultLangProvider implements LangProvider {
|
|
6
|
+
|
|
7
|
+
constructor(private locale: string, private fallbackLocale: string) {}
|
|
8
|
+
|
|
9
|
+
public getLocale(): string {
|
|
10
|
+
return this.locale;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
public async setLocale(locale: string): Promise<void> {
|
|
14
|
+
this.locale = locale;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public getFallbackLocale(): string {
|
|
18
|
+
return this.fallbackLocale;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public async setFallbackLocale(fallbackLocale: string): Promise<void> {
|
|
22
|
+
this.fallbackLocale = fallbackLocale;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public getLocales(): string[] {
|
|
26
|
+
return ['en'];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public translate(key: string): string {
|
|
30
|
+
// eslint-disable-next-line no-console
|
|
31
|
+
App.development && console.warn('Lang provider is missing');
|
|
32
|
+
|
|
33
|
+
return key;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public translateWithDefault(_: string, defaultMessage: string): string {
|
|
37
|
+
// eslint-disable-next-line no-console
|
|
38
|
+
App.development && console.warn('Lang provider is missing');
|
|
39
|
+
|
|
40
|
+
return defaultMessage;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineServiceState } from '@/services/Service';
|
|
2
|
+
|
|
3
|
+
export default defineServiceState({
|
|
4
|
+
name: 'lang',
|
|
5
|
+
persist: ['locale', 'fallbackLocale'],
|
|
6
|
+
initialState: {
|
|
7
|
+
locale: null as string | null,
|
|
8
|
+
locales: ['en'],
|
|
9
|
+
fallbackLocale: 'en',
|
|
10
|
+
},
|
|
11
|
+
});
|
package/src/lang/Lang.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { facade
|
|
1
|
+
import { facade } from '@noeldemartin/utils';
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import Service from '
|
|
3
|
+
import DefaultLangProvider from './DefaultLangProvider';
|
|
4
|
+
import Service from './Lang.state';
|
|
5
5
|
|
|
6
6
|
export interface LangProvider {
|
|
7
|
-
|
|
7
|
+
getLocale(): string;
|
|
8
|
+
setLocale(locale: string): Promise<void>;
|
|
9
|
+
getFallbackLocale(): string;
|
|
10
|
+
setFallbackLocale(fallbackLocale: string): Promise<void>;
|
|
11
|
+
getLocales(): string[];
|
|
12
|
+
translate(key: string, parameters?: Record<string, unknown> | number): string;
|
|
13
|
+
translateWithDefault(key: string, defaultMessage: string, parameters?: Record<string, unknown> | number): string;
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
export class LangService extends Service {
|
|
@@ -14,47 +20,56 @@ export class LangService extends Service {
|
|
|
14
20
|
constructor() {
|
|
15
21
|
super();
|
|
16
22
|
|
|
17
|
-
this.provider =
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return key;
|
|
23
|
-
},
|
|
24
|
-
};
|
|
23
|
+
this.provider = new DefaultLangProvider(
|
|
24
|
+
this.getState('locale') ?? this.getBrowserLocale(),
|
|
25
|
+
this.getState('fallbackLocale'),
|
|
26
|
+
);
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
public setProvider(provider: LangProvider): void {
|
|
29
|
+
public async setProvider(provider: LangProvider): Promise<void> {
|
|
28
30
|
this.provider = provider;
|
|
31
|
+
this.locales = provider.getLocales();
|
|
32
|
+
|
|
33
|
+
await provider.setLocale(this.locale ?? this.getBrowserLocale());
|
|
34
|
+
await provider.setFallbackLocale(this.fallbackLocale);
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
public translate(key: string, parameters?: Record<string, unknown>): string {
|
|
37
|
+
public translate(key: string, parameters?: Record<string, unknown> | number): string {
|
|
32
38
|
return this.provider.translate(key, parameters) ?? key;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
|
-
public translateWithDefault(key: string, defaultMessage: string): string;
|
|
36
|
-
public translateWithDefault(key: string, parameters: Record<string, unknown>, defaultMessage: string): string;
|
|
37
41
|
public translateWithDefault(
|
|
38
42
|
key: string,
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
defaultMessage: string,
|
|
44
|
+
parameters: Record<string, unknown> | number = {},
|
|
41
45
|
): string {
|
|
42
|
-
|
|
46
|
+
return this.provider.translateWithDefault(key, defaultMessage, parameters);
|
|
47
|
+
}
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
const
|
|
49
|
+
public getBrowserLocale(): string {
|
|
50
|
+
const locales = this.getState('locales');
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
return navigator.languages.find((locale) => locales.includes(locale)) ?? 'en';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
protected async boot(): Promise<void> {
|
|
56
|
+
if (!globalThis.document) {
|
|
57
|
+
return;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
|
|
60
|
+
this.requireStore().$subscribe(
|
|
61
|
+
async () => {
|
|
62
|
+
await this.provider.setLocale(this.locale ?? this.getBrowserLocale());
|
|
63
|
+
await this.provider.setFallbackLocale(this.fallbackLocale);
|
|
64
|
+
|
|
65
|
+
this.locale
|
|
66
|
+
? document.querySelector('html')?.setAttribute('lang', this.locale)
|
|
67
|
+
: document.querySelector('html')?.removeAttribute('lang');
|
|
68
|
+
},
|
|
69
|
+
{ immediate: true },
|
|
70
|
+
);
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
}
|
|
59
74
|
|
|
60
|
-
export default facade(
|
|
75
|
+
export default facade(LangService);
|
package/src/main.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export * from './bootstrap';
|
|
2
2
|
export * from './components';
|
|
3
|
+
export * from './directives';
|
|
3
4
|
export * from './errors';
|
|
4
5
|
export * from './forms';
|
|
6
|
+
export * from './jobs';
|
|
5
7
|
export * from './lang';
|
|
6
8
|
export * from './plugins';
|
|
7
9
|
export * from './services';
|
|
10
|
+
export * from './testing';
|
|
8
11
|
export * from './ui';
|
|
9
12
|
export * from './utils';
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import Aerogel from 'virtual:aerogel';
|
|
2
2
|
|
|
3
|
+
import type { App } from 'vue';
|
|
4
|
+
|
|
3
5
|
import { defineServiceState } from '@/services/Service';
|
|
4
6
|
import type { Plugin } from '@/plugins/Plugin';
|
|
5
7
|
|
|
@@ -7,12 +9,26 @@ export default defineServiceState({
|
|
|
7
9
|
name: 'app',
|
|
8
10
|
initialState: {
|
|
9
11
|
plugins: {} as Record<string, Plugin>,
|
|
12
|
+
instance: null as App | null,
|
|
10
13
|
environment: Aerogel.environment,
|
|
14
|
+
version: Aerogel.version,
|
|
11
15
|
sourceUrl: Aerogel.sourceUrl,
|
|
12
|
-
isMounted: false,
|
|
13
16
|
},
|
|
14
17
|
computed: {
|
|
15
18
|
development: (state) => state.environment === 'development',
|
|
16
|
-
testing: (state) => state.environment === 'testing',
|
|
19
|
+
testing: (state) => state.environment === 'test' || state.environment === 'testing',
|
|
20
|
+
versionName(state): string {
|
|
21
|
+
if (this.development) {
|
|
22
|
+
return 'dev.' + Aerogel.sourceHash.toString().substring(0, 7);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return `v${state.version}`;
|
|
26
|
+
},
|
|
27
|
+
versionUrl(state): string {
|
|
28
|
+
return (
|
|
29
|
+
state.sourceUrl +
|
|
30
|
+
(this.development ? `/tree/${Aerogel.sourceHash}` : `/releases/tag/${this.versionName}`)
|
|
31
|
+
);
|
|
32
|
+
},
|
|
17
33
|
},
|
|
18
34
|
});
|
package/src/services/App.ts
CHANGED
|
@@ -1,12 +1,33 @@
|
|
|
1
|
-
import
|
|
1
|
+
import Aerogel from 'virtual:aerogel';
|
|
2
|
+
|
|
3
|
+
import { PromisedValue, facade, forever, updateLocationQueryParameters } from '@noeldemartin/utils';
|
|
2
4
|
|
|
3
5
|
import Events from '@/services/Events';
|
|
4
6
|
import type { Plugin } from '@/plugins';
|
|
7
|
+
import type { Services } from '@/services';
|
|
5
8
|
|
|
6
9
|
import Service from './App.state';
|
|
7
10
|
|
|
8
11
|
export class AppService extends Service {
|
|
9
12
|
|
|
13
|
+
public readonly name = Aerogel.name;
|
|
14
|
+
public readonly ready = new PromisedValue<void>();
|
|
15
|
+
public readonly mounted = new PromisedValue<void>();
|
|
16
|
+
|
|
17
|
+
public isReady(): boolean {
|
|
18
|
+
return this.ready.isResolved();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public isMounted(): boolean {
|
|
22
|
+
return this.mounted.isResolved();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public async whenReady<T>(callback: () => T): Promise<T> {
|
|
26
|
+
const result = await this.ready.then(callback);
|
|
27
|
+
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
10
31
|
public async reload(queryParameters?: Record<string, string | undefined>): Promise<void> {
|
|
11
32
|
queryParameters && updateLocationQueryParameters(queryParameters);
|
|
12
33
|
|
|
@@ -20,10 +41,15 @@ export class AppService extends Service {
|
|
|
20
41
|
return (this.plugins[name] as T) ?? null;
|
|
21
42
|
}
|
|
22
43
|
|
|
44
|
+
public service<T extends keyof Services>(name: T): Services[T] | null {
|
|
45
|
+
return this.instance?.config.globalProperties[name] ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
23
48
|
protected async boot(): Promise<void> {
|
|
24
|
-
Events.once('application-
|
|
49
|
+
Events.once('application-ready', () => this.ready.resolve());
|
|
50
|
+
Events.once('application-mounted', () => this.mounted.resolve());
|
|
25
51
|
}
|
|
26
52
|
|
|
27
53
|
}
|
|
28
54
|
|
|
29
|
-
export default facade(
|
|
55
|
+
export default facade(AppService);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { PromisedValue, facade, tap } from '@noeldemartin/utils';
|
|
2
|
+
|
|
3
|
+
import Service from '@/services/Service';
|
|
4
|
+
|
|
5
|
+
export class CacheService extends Service {
|
|
6
|
+
|
|
7
|
+
private cache?: PromisedValue<Cache> = undefined;
|
|
8
|
+
|
|
9
|
+
public async get(url: string): Promise<Response | null> {
|
|
10
|
+
const cache = await this.open();
|
|
11
|
+
const response = await cache.match(url);
|
|
12
|
+
|
|
13
|
+
return response ?? null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public async store(url: string, response: Response): Promise<void> {
|
|
17
|
+
const cache = await this.open();
|
|
18
|
+
|
|
19
|
+
await cache.put(url, response);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public async replace(url: string, response: Response): Promise<void> {
|
|
23
|
+
const cache = await this.open();
|
|
24
|
+
const keys = await cache.keys(url);
|
|
25
|
+
|
|
26
|
+
if (keys.length === 0) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await cache.put(url, response);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
protected async open(): Promise<Cache> {
|
|
34
|
+
return (this.cache =
|
|
35
|
+
this.cache ??
|
|
36
|
+
tap(new PromisedValue<Cache>(), (cache) => {
|
|
37
|
+
caches.open('app').then((instance) => cache.resolve(instance));
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default facade(CacheService);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import Events, { EventListenerPriorities } from './Events';
|
|
4
|
+
|
|
5
|
+
describe('Events', () => {
|
|
6
|
+
|
|
7
|
+
beforeEach(() => void Events.reset());
|
|
8
|
+
|
|
9
|
+
it('registers listeners', async () => {
|
|
10
|
+
// Arrange
|
|
11
|
+
let counter = 0;
|
|
12
|
+
|
|
13
|
+
Events.on('trigger', () => counter++);
|
|
14
|
+
|
|
15
|
+
// Act
|
|
16
|
+
await Events.emit('trigger');
|
|
17
|
+
await Events.emit('trigger');
|
|
18
|
+
await Events.emit('trigger');
|
|
19
|
+
|
|
20
|
+
// Assert
|
|
21
|
+
expect(counter).toEqual(3);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('triggers listeners by priority', async () => {
|
|
25
|
+
// Arrange
|
|
26
|
+
const storage: string[] = [];
|
|
27
|
+
|
|
28
|
+
Events.on('trigger', () => storage.push('second'));
|
|
29
|
+
Events.on('trigger', { priority: EventListenerPriorities.Low }, () => storage.push('third'));
|
|
30
|
+
Events.on('trigger', { priority: EventListenerPriorities.High }, () => storage.push('first'));
|
|
31
|
+
|
|
32
|
+
// Act
|
|
33
|
+
await Events.emit('trigger');
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(storage).toEqual(['first', 'second', 'third']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
});
|