@aerogel/core 0.0.0-next.d547095ca85c86c1e41f5c7fa170d0ba856c3f4d → 0.0.0-next.d7394c3aa6aac799b0971e63819a8713d05a5123
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 +297 -38
- package/dist/aerogel-core.esm.js +1 -1
- package/dist/aerogel-core.esm.js.map +1 -1
- package/package.json +2 -2
- package/src/bootstrap/bootstrap.test.ts +0 -1
- package/src/components/AGAppSnackbars.vue +1 -1
- package/src/components/composition.ts +23 -0
- package/src/components/forms/AGForm.vue +9 -10
- package/src/components/forms/AGInput.vue +2 -0
- package/src/components/headless/forms/AGHeadlessButton.ts +3 -0
- package/src/components/headless/forms/AGHeadlessButton.vue +15 -4
- 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 +42 -5
- package/src/components/headless/forms/AGHeadlessInputTextArea.vue +43 -0
- 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/AGMarkdown.vue +9 -4
- package/src/components/lib/AGMeasured.vue +1 -0
- package/src/components/modals/AGConfirmModal.ts +9 -3
- package/src/components/modals/AGConfirmModal.vue +2 -2
- package/src/components/modals/AGPromptModal.ts +9 -3
- package/src/components/modals/AGPromptModal.vue +2 -2
- package/src/directives/measure.ts +24 -5
- package/src/forms/Form.test.ts +28 -0
- package/src/forms/Form.ts +56 -6
- package/src/forms/index.ts +1 -0
- package/src/forms/utils.ts +15 -0
- package/src/lang/DefaultLangProvider.ts +43 -0
- package/src/lang/Lang.state.ts +11 -0
- package/src/lang/Lang.ts +44 -21
- package/src/services/App.state.ts +14 -0
- package/src/services/App.ts +3 -0
- package/src/services/Cache.ts +43 -0
- package/src/services/Service.ts +10 -2
- package/src/services/index.ts +3 -1
- package/src/testing/setup.ts +25 -0
- package/src/ui/UI.state.ts +7 -0
- package/src/ui/UI.ts +25 -5
- package/src/ui/index.ts +1 -0
- package/src/ui/utils.ts +16 -0
- package/src/utils/vue.ts +4 -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,5 @@
|
|
|
1
|
-
import { MagicObject } from '@noeldemartin/utils';
|
|
2
|
-
import { computed, reactive, readonly, ref } from 'vue';
|
|
1
|
+
import { MagicObject, arrayRemove, fail, toString } from '@noeldemartin/utils';
|
|
2
|
+
import { computed, nextTick, reactive, readonly, ref } from 'vue';
|
|
3
3
|
import type { ObjectValues } from '@noeldemartin/utils';
|
|
4
4
|
import type { ComputedRef, DeepReadonly, Ref, UnwrapNestedRefs } from 'vue';
|
|
5
5
|
|
|
@@ -8,10 +8,12 @@ export const FormFieldTypes = {
|
|
|
8
8
|
Number: 'number',
|
|
9
9
|
Boolean: 'boolean',
|
|
10
10
|
Object: 'object',
|
|
11
|
+
Date: 'date',
|
|
11
12
|
} as const;
|
|
12
13
|
|
|
13
14
|
export interface FormFieldDefinition<TType extends FormFieldType = FormFieldType, TRules extends string = string> {
|
|
14
15
|
type: TType;
|
|
16
|
+
trim?: boolean;
|
|
15
17
|
default?: GetFormFieldValue<TType>;
|
|
16
18
|
rules?: TRules;
|
|
17
19
|
}
|
|
@@ -40,10 +42,15 @@ export type GetFormFieldValue<TType> = TType extends typeof FormFieldTypes.Strin
|
|
|
40
42
|
? boolean
|
|
41
43
|
: TType extends typeof FormFieldTypes.Object
|
|
42
44
|
? object
|
|
45
|
+
: TType extends typeof FormFieldTypes.Date
|
|
46
|
+
? Date
|
|
43
47
|
: never;
|
|
44
48
|
|
|
45
49
|
const validForms: WeakMap<Form, ComputedRef<boolean>> = new WeakMap();
|
|
46
50
|
|
|
51
|
+
export type SubmitFormListener = () => unknown;
|
|
52
|
+
export type FocusFormListener = (input: string) => unknown;
|
|
53
|
+
|
|
47
54
|
export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinitions> extends MagicObject {
|
|
48
55
|
|
|
49
56
|
public errors: DeepReadonly<UnwrapNestedRefs<FormErrors<Fields>>>;
|
|
@@ -52,6 +59,7 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
52
59
|
private _data: FormData<Fields>;
|
|
53
60
|
private _submitted: Ref<boolean>;
|
|
54
61
|
private _errors: FormErrors<Fields>;
|
|
62
|
+
private _listeners: { focus?: FocusFormListener[]; submit?: SubmitFormListener[] } = {};
|
|
55
63
|
|
|
56
64
|
constructor(fields: Fields) {
|
|
57
65
|
super();
|
|
@@ -78,7 +86,13 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
public setFieldValue<T extends keyof Fields>(field: T, value: FormData<Fields>[T]): void {
|
|
81
|
-
|
|
89
|
+
const definition =
|
|
90
|
+
this._fields[field] ?? fail<FormFieldDefinition>(`Trying to set undefined '${toString(field)}' field`);
|
|
91
|
+
|
|
92
|
+
this._data[field] =
|
|
93
|
+
definition.type === FormFieldTypes.String && (definition.trim ?? true)
|
|
94
|
+
? (toString(value).trim() as FormData<Fields>[T])
|
|
95
|
+
: value;
|
|
82
96
|
|
|
83
97
|
if (this._submitted.value) {
|
|
84
98
|
this.validate();
|
|
@@ -89,6 +103,14 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
89
103
|
return this._data[field] as unknown as GetFormFieldValue<Fields[T]['type']>;
|
|
90
104
|
}
|
|
91
105
|
|
|
106
|
+
public getFieldRules<T extends keyof Fields>(field: T): string[] {
|
|
107
|
+
return this._fields[field]?.rules?.split('|') ?? [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public data(): FormData<Fields> {
|
|
111
|
+
return { ...this._data };
|
|
112
|
+
}
|
|
113
|
+
|
|
92
114
|
public validate(): boolean {
|
|
93
115
|
const errors = Object.entries(this._fields).reduce((formErrors, [name, definition]) => {
|
|
94
116
|
formErrors[name] = this.getFieldErrors(name, definition);
|
|
@@ -111,7 +133,35 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
111
133
|
public submit(): boolean {
|
|
112
134
|
this._submitted.value = true;
|
|
113
135
|
|
|
114
|
-
|
|
136
|
+
const valid = this.validate();
|
|
137
|
+
|
|
138
|
+
valid && this._listeners['submit']?.forEach((listener) => listener());
|
|
139
|
+
|
|
140
|
+
return valid;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public on(event: 'focus', listener: FocusFormListener): () => void;
|
|
144
|
+
public on(event: 'submit', listener: SubmitFormListener): () => void;
|
|
145
|
+
public on(event: 'focus' | 'submit', listener: FocusFormListener | SubmitFormListener): () => void {
|
|
146
|
+
this._listeners[event] ??= [];
|
|
147
|
+
|
|
148
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
149
|
+
this._listeners[event]?.push(listener as any);
|
|
150
|
+
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
152
|
+
return () => this.off(event as any, listener);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
public off(event: 'focus', listener: FocusFormListener): void;
|
|
156
|
+
public off(event: 'submit', listener: SubmitFormListener): void;
|
|
157
|
+
public off(event: 'focus' | 'submit', listener: FocusFormListener | SubmitFormListener): void {
|
|
158
|
+
arrayRemove(this._listeners[event] ?? [], listener);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
public async focus(input: string): Promise<void> {
|
|
162
|
+
await nextTick();
|
|
163
|
+
|
|
164
|
+
this._listeners['focus']?.forEach((listener) => listener(input));
|
|
115
165
|
}
|
|
116
166
|
|
|
117
167
|
protected __get(property: string): unknown {
|
|
@@ -119,7 +169,7 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
119
169
|
return super.__get(property);
|
|
120
170
|
}
|
|
121
171
|
|
|
122
|
-
return this.
|
|
172
|
+
return this.getFieldValue(property);
|
|
123
173
|
}
|
|
124
174
|
|
|
125
175
|
protected __set(property: string, value: unknown): void {
|
|
@@ -129,7 +179,7 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
129
179
|
return;
|
|
130
180
|
}
|
|
131
181
|
|
|
132
|
-
|
|
182
|
+
this.setFieldValue(property, value as FormData<Fields>[string]);
|
|
133
183
|
}
|
|
134
184
|
|
|
135
185
|
private getFieldErrors(name: keyof Fields, definition: FormFieldDefinition): string[] | null {
|
package/src/forms/index.ts
CHANGED
package/src/forms/utils.ts
CHANGED
|
@@ -8,6 +8,13 @@ export function booleanInput(defaultValue?: boolean): FormFieldDefinition<typeof
|
|
|
8
8
|
};
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export function dateInput(defaultValue?: Date): FormFieldDefinition<typeof FormFieldTypes.Date> {
|
|
12
|
+
return {
|
|
13
|
+
default: defaultValue,
|
|
14
|
+
type: FormFieldTypes.Date,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
export function requiredBooleanInput(
|
|
12
19
|
defaultValue?: boolean,
|
|
13
20
|
): FormFieldDefinition<typeof FormFieldTypes.Boolean, 'required'> {
|
|
@@ -18,6 +25,14 @@ export function requiredBooleanInput(
|
|
|
18
25
|
};
|
|
19
26
|
}
|
|
20
27
|
|
|
28
|
+
export function requiredDateInput(defaultValue?: Date): FormFieldDefinition<typeof FormFieldTypes.Date> {
|
|
29
|
+
return {
|
|
30
|
+
default: defaultValue,
|
|
31
|
+
type: FormFieldTypes.Date,
|
|
32
|
+
rules: 'required',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
export function requiredNumberInput(
|
|
22
37
|
defaultValue?: number,
|
|
23
38
|
): FormFieldDefinition<typeof FormFieldTypes.Number, 'required'> {
|
|
@@ -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,11 +1,16 @@
|
|
|
1
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
|
-
|
|
8
|
-
|
|
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;
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
export class LangService extends Service {
|
|
@@ -15,34 +20,52 @@ export class LangService extends Service {
|
|
|
15
20
|
constructor() {
|
|
16
21
|
super();
|
|
17
22
|
|
|
18
|
-
this.provider =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return key;
|
|
24
|
-
},
|
|
25
|
-
translateWithDefault: (_, defaultMessage) => {
|
|
26
|
-
// eslint-disable-next-line no-console
|
|
27
|
-
App.development && console.warn('Lang provider is missing');
|
|
28
|
-
|
|
29
|
-
return defaultMessage;
|
|
30
|
-
},
|
|
31
|
-
};
|
|
23
|
+
this.provider = new DefaultLangProvider(
|
|
24
|
+
this.getState('locale') ?? this.getBrowserLocale(),
|
|
25
|
+
this.getState('fallbackLocale'),
|
|
26
|
+
);
|
|
32
27
|
}
|
|
33
28
|
|
|
34
|
-
public setProvider(provider: LangProvider): void {
|
|
29
|
+
public async setProvider(provider: LangProvider): Promise<void> {
|
|
35
30
|
this.provider = provider;
|
|
31
|
+
this.locales = provider.getLocales();
|
|
32
|
+
|
|
33
|
+
await provider.setLocale(this.locale ?? this.getBrowserLocale());
|
|
34
|
+
await provider.setFallbackLocale(this.fallbackLocale);
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
public translate(key: string, parameters?: Record<string, unknown>): string {
|
|
37
|
+
public translate(key: string, parameters?: Record<string, unknown> | number): string {
|
|
39
38
|
return this.provider.translate(key, parameters) ?? key;
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
public translateWithDefault(
|
|
41
|
+
public translateWithDefault(
|
|
42
|
+
key: string,
|
|
43
|
+
defaultMessage: string,
|
|
44
|
+
parameters: Record<string, unknown> | number = {},
|
|
45
|
+
): string {
|
|
43
46
|
return this.provider.translateWithDefault(key, defaultMessage, parameters);
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
public getBrowserLocale(): string {
|
|
50
|
+
const locales = this.getState('locales');
|
|
51
|
+
|
|
52
|
+
return navigator.languages.find((locale) => locales.includes(locale)) ?? 'en';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
protected async boot(): Promise<void> {
|
|
56
|
+
this.requireStore().$subscribe(
|
|
57
|
+
async () => {
|
|
58
|
+
await this.provider.setLocale(this.locale ?? this.getBrowserLocale());
|
|
59
|
+
await this.provider.setFallbackLocale(this.fallbackLocale);
|
|
60
|
+
|
|
61
|
+
this.locale
|
|
62
|
+
? document.querySelector('html')?.setAttribute('lang', this.locale)
|
|
63
|
+
: document.querySelector('html')?.removeAttribute('lang');
|
|
64
|
+
},
|
|
65
|
+
{ immediate: true },
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
46
69
|
}
|
|
47
70
|
|
|
48
71
|
export default facade(LangService);
|
|
@@ -8,10 +8,24 @@ export default defineServiceState({
|
|
|
8
8
|
initialState: {
|
|
9
9
|
plugins: {} as Record<string, Plugin>,
|
|
10
10
|
environment: Aerogel.environment,
|
|
11
|
+
version: Aerogel.version,
|
|
11
12
|
sourceUrl: Aerogel.sourceUrl,
|
|
12
13
|
},
|
|
13
14
|
computed: {
|
|
14
15
|
development: (state) => state.environment === 'development',
|
|
15
16
|
testing: (state) => state.environment === 'test' || state.environment === 'testing',
|
|
17
|
+
versionName(state): string {
|
|
18
|
+
if (this.development) {
|
|
19
|
+
return 'dev.' + Aerogel.sourceHash.toString().substring(0, 7);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return `v${state.version}`;
|
|
23
|
+
},
|
|
24
|
+
versionUrl(state): string {
|
|
25
|
+
return (
|
|
26
|
+
state.sourceUrl +
|
|
27
|
+
(this.development ? `/tree/${Aerogel.sourceHash}` : `/releases/tag/${this.versionName}`)
|
|
28
|
+
);
|
|
29
|
+
},
|
|
16
30
|
},
|
|
17
31
|
});
|
package/src/services/App.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import Aerogel from 'virtual:aerogel';
|
|
2
|
+
|
|
1
3
|
import { PromisedValue, facade, forever, updateLocationQueryParameters } from '@noeldemartin/utils';
|
|
2
4
|
|
|
3
5
|
import Events from '@/services/Events';
|
|
@@ -7,6 +9,7 @@ import Service from './App.state';
|
|
|
7
9
|
|
|
8
10
|
export class AppService extends Service {
|
|
9
11
|
|
|
12
|
+
public readonly name = Aerogel.name;
|
|
10
13
|
public readonly ready = new PromisedValue<void>();
|
|
11
14
|
public readonly mounted = new PromisedValue<void>();
|
|
12
15
|
|
|
@@ -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);
|
package/src/services/Service.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MagicObject, PromisedValue, Storage, isEmpty, objectDeepClone, objectOnly } from '@noeldemartin/utils';
|
|
1
|
+
import { MagicObject, PromisedValue, Storage, fail, isEmpty, objectDeepClone, objectOnly } from '@noeldemartin/utils';
|
|
2
2
|
import type { Constructor } from '@noeldemartin/utils';
|
|
3
3
|
import type { MaybeRef } from 'vue';
|
|
4
4
|
import type { Store } from 'pinia';
|
|
@@ -91,7 +91,7 @@ export default class Service<
|
|
|
91
91
|
protected _name: string;
|
|
92
92
|
private _booted: PromisedValue<void>;
|
|
93
93
|
private _computedStateKeys: Set<keyof State>;
|
|
94
|
-
private _store: Store | false;
|
|
94
|
+
private _store: Store<string, State, ComputedState, {}> | false;
|
|
95
95
|
|
|
96
96
|
constructor() {
|
|
97
97
|
super();
|
|
@@ -250,4 +250,12 @@ export default class Service<
|
|
|
250
250
|
Storage.set(this._name, objectOnly(this.getState(), persist));
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
protected requireStore(): Store<string, State, ComputedState, {}> {
|
|
254
|
+
if (!this._store) {
|
|
255
|
+
return fail(`Failed getting '${this._name}' store`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return this._store;
|
|
259
|
+
}
|
|
260
|
+
|
|
253
261
|
}
|
package/src/services/index.ts
CHANGED
|
@@ -3,16 +3,18 @@ import type { App as VueApp } from 'vue';
|
|
|
3
3
|
import { definePlugin } from '@/plugins';
|
|
4
4
|
|
|
5
5
|
import App from './App';
|
|
6
|
+
import Cache from './Cache';
|
|
6
7
|
import Events from './Events';
|
|
7
8
|
import Service from './Service';
|
|
8
9
|
import { getPiniaStore } from './store';
|
|
9
10
|
|
|
10
11
|
export * from './App';
|
|
12
|
+
export * from './Cache';
|
|
11
13
|
export * from './Events';
|
|
12
14
|
export * from './Service';
|
|
13
15
|
export * from './store';
|
|
14
16
|
|
|
15
|
-
export { App, Events, Service };
|
|
17
|
+
export { App, Cache, Events, Service };
|
|
16
18
|
|
|
17
19
|
const defaultServices = {
|
|
18
20
|
$app: App,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { mock, tap } from '@noeldemartin/utils';
|
|
2
|
+
import { beforeEach, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
+
tap(globalThis, (global: any) => {
|
|
6
|
+
global.jest = vi;
|
|
7
|
+
global.navigator = { languages: ['en'] };
|
|
8
|
+
global.window = mock<Window>({
|
|
9
|
+
matchMedia: () =>
|
|
10
|
+
mock<MediaQueryList>({
|
|
11
|
+
addEventListener: () => null,
|
|
12
|
+
}),
|
|
13
|
+
});
|
|
14
|
+
global.localStorage = mock<Storage>({
|
|
15
|
+
getItem: () => null,
|
|
16
|
+
setItem: () => null,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.stubGlobal('document', {
|
|
22
|
+
querySelector: () => null,
|
|
23
|
+
getElementById: () => null,
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/ui/UI.state.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { Component } from 'vue';
|
|
|
2
2
|
|
|
3
3
|
import { defineServiceState } from '@/services/Service';
|
|
4
4
|
|
|
5
|
+
import { Layouts, getCurrentLayout } from './utils';
|
|
6
|
+
|
|
5
7
|
export interface Modal<T = unknown> {
|
|
6
8
|
id: string;
|
|
7
9
|
properties: Record<string, unknown>;
|
|
@@ -28,5 +30,10 @@ export default defineServiceState({
|
|
|
28
30
|
initialState: {
|
|
29
31
|
modals: [] as Modal[],
|
|
30
32
|
snackbars: [] as Snackbar[],
|
|
33
|
+
layout: getCurrentLayout(),
|
|
34
|
+
},
|
|
35
|
+
computed: {
|
|
36
|
+
mobile: ({ layout }) => layout === Layouts.Mobile,
|
|
37
|
+
desktop: ({ layout }) => layout === Layouts.Desktop,
|
|
31
38
|
},
|
|
32
39
|
});
|
package/src/ui/UI.ts
CHANGED
|
@@ -4,10 +4,12 @@ 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 { Color } from '@/components/constants';
|
|
7
8
|
import type { SnackbarAction, SnackbarColor } from '@/components/headless/snackbars';
|
|
8
9
|
import type { AGAlertModalProps, AGConfirmModalProps, AGLoadingModalProps, AGPromptModalProps } from '@/components';
|
|
9
10
|
|
|
10
11
|
import Service from './UI.state';
|
|
12
|
+
import { MOBILE_BREAKPOINT, getCurrentLayout } from './utils';
|
|
11
13
|
import type { Modal, ModalComponent, Snackbar } from './UI.state';
|
|
12
14
|
|
|
13
15
|
interface ModalCallbacks<T = unknown> {
|
|
@@ -34,7 +36,9 @@ export type UIComponent = ObjectValues<typeof UIComponents>;
|
|
|
34
36
|
|
|
35
37
|
export interface ConfirmOptions {
|
|
36
38
|
acceptText?: string;
|
|
39
|
+
acceptColor?: Color;
|
|
37
40
|
cancelText?: string;
|
|
41
|
+
cancelColor?: Color;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
export interface PromptOptions {
|
|
@@ -42,7 +46,10 @@ export interface PromptOptions {
|
|
|
42
46
|
defaultValue?: string;
|
|
43
47
|
placeholder?: string;
|
|
44
48
|
acceptText?: string;
|
|
49
|
+
acceptColor?: Color;
|
|
45
50
|
cancelText?: string;
|
|
51
|
+
cancelColor?: Color;
|
|
52
|
+
trim?: boolean;
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
export interface ShowSnackbarOptions {
|
|
@@ -115,6 +122,7 @@ export class UIService extends Service {
|
|
|
115
122
|
messageOrOptions?: string | PromptOptions,
|
|
116
123
|
options?: PromptOptions,
|
|
117
124
|
): Promise<string | null> {
|
|
125
|
+
const trim = options?.trim ?? true;
|
|
118
126
|
const getProperties = (): AGPromptModalProps => {
|
|
119
127
|
if (typeof messageOrOptions !== 'string') {
|
|
120
128
|
return {
|
|
@@ -134,14 +142,18 @@ export class UIService extends Service {
|
|
|
134
142
|
this.requireComponent(UIComponents.PromptModal),
|
|
135
143
|
getProperties(),
|
|
136
144
|
);
|
|
137
|
-
const
|
|
145
|
+
const rawResult = await modal.beforeClose;
|
|
146
|
+
const result = trim && typeof rawResult === 'string' ? rawResult?.trim() : rawResult;
|
|
138
147
|
|
|
139
148
|
return result ?? null;
|
|
140
149
|
}
|
|
141
150
|
|
|
142
|
-
public async loading<T>(operation: Promise<T>): Promise<T>;
|
|
143
|
-
public async loading<T>(message: string, operation: Promise<T>): Promise<T>;
|
|
144
|
-
public async loading<T>(
|
|
151
|
+
public async loading<T>(operation: Promise<T> | (() => T)): Promise<T>;
|
|
152
|
+
public async loading<T>(message: string, operation: Promise<T> | (() => T)): Promise<T>;
|
|
153
|
+
public async loading<T>(
|
|
154
|
+
messageOrOperation: string | Promise<T> | (() => T),
|
|
155
|
+
operation?: Promise<T> | (() => T),
|
|
156
|
+
): Promise<T> {
|
|
145
157
|
const getProperties = (): AGLoadingModalProps => {
|
|
146
158
|
if (typeof messageOrOperation !== 'string') {
|
|
147
159
|
return {};
|
|
@@ -153,7 +165,8 @@ export class UIService extends Service {
|
|
|
153
165
|
const modal = await this.openModal(this.requireComponent(UIComponents.LoadingModal), getProperties());
|
|
154
166
|
|
|
155
167
|
try {
|
|
156
|
-
operation = typeof messageOrOperation === 'string' ? (operation as
|
|
168
|
+
operation = typeof messageOrOperation === 'string' ? (operation as () => T) : messageOrOperation;
|
|
169
|
+
operation = typeof operation === 'function' ? Promise.resolve(operation()) : operation;
|
|
157
170
|
|
|
158
171
|
const [result] = await Promise.all([operation, after({ seconds: 1 })]);
|
|
159
172
|
|
|
@@ -223,6 +236,7 @@ export class UIService extends Service {
|
|
|
223
236
|
protected async boot(): Promise<void> {
|
|
224
237
|
this.watchModalEvents();
|
|
225
238
|
this.watchMountedEvent();
|
|
239
|
+
this.watchWindowMedia();
|
|
226
240
|
}
|
|
227
241
|
|
|
228
242
|
private watchModalEvents(): void {
|
|
@@ -268,6 +282,12 @@ export class UIService extends Service {
|
|
|
268
282
|
});
|
|
269
283
|
}
|
|
270
284
|
|
|
285
|
+
private watchWindowMedia(): void {
|
|
286
|
+
const media = window.matchMedia(`(min-width: ${MOBILE_BREAKPOINT}px)`);
|
|
287
|
+
|
|
288
|
+
media.addEventListener('change', () => this.setState({ layout: getCurrentLayout() }));
|
|
289
|
+
}
|
|
290
|
+
|
|
271
291
|
}
|
|
272
292
|
|
|
273
293
|
export default facade(UIService);
|
package/src/ui/index.ts
CHANGED
package/src/ui/utils.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const MOBILE_BREAKPOINT = 768;
|
|
2
|
+
|
|
3
|
+
export const Layouts = {
|
|
4
|
+
Mobile: 'mobile',
|
|
5
|
+
Desktop: 'desktop',
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export type Layout = (typeof Layouts)[keyof typeof Layouts];
|
|
9
|
+
|
|
10
|
+
export function getCurrentLayout(): Layout {
|
|
11
|
+
if (window.innerWidth > MOBILE_BREAKPOINT) {
|
|
12
|
+
return Layouts.Desktop;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return Layouts.Mobile;
|
|
16
|
+
}
|
package/src/utils/vue.ts
CHANGED
|
@@ -73,10 +73,12 @@ export function injectOrFail<T>(key: InjectionKey<T> | string, errorMessage?: st
|
|
|
73
73
|
return inject(key) ?? fail(errorMessage ?? `Could not resolve '${key}' injection key`);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
export function mixedProp<T>(type?: PropType<T>): OptionalProp<T | null
|
|
76
|
+
export function mixedProp<T>(type?: PropType<T>): OptionalProp<T | null>;
|
|
77
|
+
export function mixedProp<T>(type: PropType<T>, defaultValue: T): OptionalProp<T>;
|
|
78
|
+
export function mixedProp<T>(type?: PropType<T>, defaultValue?: T): OptionalProp<T | null> {
|
|
77
79
|
return {
|
|
78
80
|
type,
|
|
79
|
-
default: null,
|
|
81
|
+
default: defaultValue ?? null,
|
|
80
82
|
};
|
|
81
83
|
}
|
|
82
84
|
|
package/vite.config.ts
CHANGED
|
@@ -4,7 +4,10 @@ import { defineConfig } from 'vitest/config';
|
|
|
4
4
|
import { resolve } from 'path';
|
|
5
5
|
|
|
6
6
|
export default defineConfig({
|
|
7
|
-
test: {
|
|
7
|
+
test: {
|
|
8
|
+
clearMocks: true,
|
|
9
|
+
setupFiles: ['./src/testing/setup.ts'],
|
|
10
|
+
},
|
|
8
11
|
plugins: [Aerogel(), Icons()],
|
|
9
12
|
resolve: {
|
|
10
13
|
alias: {
|