@aerogel/core 0.0.0-next.c8f032a868370824898e171969aec1bb6827688e → 0.0.0-next.f8cdd39997c56dcd46e07c26af8a84d04d610fce
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 +529 -87
- package/dist/aerogel-core.esm.js +1 -1
- package/dist/aerogel-core.esm.js.map +1 -1
- package/dist/virtual.d.ts +11 -0
- package/noeldemartin.config.js +4 -1
- package/package.json +3 -2
- package/src/bootstrap/index.ts +4 -1
- package/src/components/AGAppModals.vue +15 -0
- package/src/components/AGAppOverlays.vue +5 -7
- package/src/components/AGAppSnackbars.vue +13 -0
- package/src/components/basic/AGErrorMessage.vue +16 -0
- package/src/components/basic/AGLink.vue +9 -0
- package/src/components/basic/AGMarkdown.vue +10 -9
- package/src/components/basic/index.ts +3 -1
- package/src/components/constants.ts +8 -0
- package/src/components/forms/AGButton.vue +33 -10
- package/src/components/forms/AGCheckbox.vue +35 -0
- package/src/components/forms/AGInput.vue +8 -4
- package/src/components/forms/index.ts +2 -1
- package/src/components/headless/forms/AGHeadlessButton.vue +7 -7
- package/src/components/headless/forms/AGHeadlessInput.ts +2 -2
- package/src/components/headless/forms/AGHeadlessInput.vue +3 -3
- package/src/components/headless/forms/AGHeadlessInputError.vue +1 -1
- package/src/components/headless/forms/AGHeadlessInputInput.vue +15 -3
- package/src/components/headless/index.ts +1 -0
- package/src/components/headless/modals/AGHeadlessModalPanel.vue +5 -1
- package/src/components/headless/snackbars/AGHeadlessSnackbar.vue +10 -0
- package/src/components/headless/snackbars/index.ts +25 -0
- package/src/components/index.ts +2 -0
- package/src/components/modals/AGAlertModal.vue +0 -1
- package/src/components/modals/AGConfirmModal.vue +1 -1
- package/src/components/modals/AGErrorReportModal.ts +20 -0
- package/src/components/modals/AGErrorReportModal.vue +62 -0
- package/src/components/modals/AGErrorReportModalButtons.vue +109 -0
- package/src/components/modals/AGErrorReportModalTitle.vue +25 -0
- package/src/components/modals/AGLoadingModal.vue +19 -0
- package/src/components/modals/AGModal.vue +21 -3
- package/src/components/modals/index.ts +16 -2
- package/src/components/snackbars/AGSnackbar.vue +42 -0
- package/src/components/snackbars/index.ts +3 -0
- package/src/directives/index.ts +16 -3
- package/src/errors/Errors.state.ts +31 -0
- package/src/errors/Errors.ts +183 -0
- package/src/errors/index.ts +59 -0
- package/src/forms/Form.test.ts +21 -0
- package/src/forms/Form.ts +20 -10
- package/src/forms/utils.ts +17 -0
- package/src/lang/Lang.ts +12 -4
- package/src/lang/index.ts +3 -5
- package/src/lang/utils.ts +4 -0
- package/src/main.ts +1 -2
- package/src/services/App.state.ts +8 -2
- package/src/services/App.ts +9 -1
- package/src/services/Service.ts +132 -45
- package/src/services/index.ts +21 -4
- package/src/services/store.ts +27 -0
- package/src/types/virtual.d.ts +11 -0
- package/src/ui/UI.state.ts +11 -1
- package/src/ui/UI.ts +52 -8
- package/src/ui/index.ts +7 -1
- package/src/utils/composition/forms.ts +11 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/markdown.ts +11 -2
- package/src/utils/vue.ts +2 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +2 -1
- package/src/globals.ts +0 -6
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<AGHeadlessModal
|
|
3
|
-
|
|
2
|
+
<AGHeadlessModal
|
|
3
|
+
ref="$headlessModal"
|
|
4
|
+
v-slot="{ close }: IAGHeadlessModalDefaultSlotProps"
|
|
5
|
+
:cancellable="cancellable"
|
|
6
|
+
class="relative z-50"
|
|
7
|
+
>
|
|
8
|
+
<div class="fixed inset-0 flex items-center justify-center p-8">
|
|
4
9
|
<AGHeadlessModalPanel class="flex max-h-full max-w-full flex-col overflow-hidden bg-white">
|
|
5
10
|
<div class="flex max-h-full flex-col overflow-auto p-4">
|
|
6
11
|
<slot :close="close" />
|
|
@@ -11,8 +16,21 @@
|
|
|
11
16
|
</template>
|
|
12
17
|
|
|
13
18
|
<script setup lang="ts">
|
|
14
|
-
import
|
|
19
|
+
import { computed, ref } from 'vue';
|
|
20
|
+
|
|
21
|
+
import { booleanProp } from '@/utils';
|
|
22
|
+
import type { IAGHeadlessModal, IAGHeadlessModalDefaultSlotProps } from '@/components/headless/modals/AGHeadlessModal';
|
|
23
|
+
|
|
24
|
+
import type { IAGModal } from './AGModal';
|
|
15
25
|
|
|
16
26
|
import AGHeadlessModal from '../headless/modals/AGHeadlessModal.vue';
|
|
17
27
|
import AGHeadlessModalPanel from '../headless/modals/AGHeadlessModalPanel.vue';
|
|
28
|
+
|
|
29
|
+
const $headlessModal = ref<IAGHeadlessModal>();
|
|
30
|
+
|
|
31
|
+
defineProps({ cancellable: booleanProp(true) });
|
|
32
|
+
defineExpose<IAGModal>({
|
|
33
|
+
close: async () => $headlessModal.value?.close(),
|
|
34
|
+
cancellable: computed(() => !!$headlessModal.value?.cancellable),
|
|
35
|
+
});
|
|
18
36
|
</script>
|
|
@@ -1,7 +1,21 @@
|
|
|
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';
|
|
5
|
+
import AGLoadingModal from './AGLoadingModal.vue';
|
|
3
6
|
import AGModal from './AGModal.vue';
|
|
4
7
|
import AGModalContext from './AGModalContext.vue';
|
|
5
|
-
import { IAGModal } from './AGModal';
|
|
6
8
|
|
|
7
|
-
export
|
|
9
|
+
export * from './AGErrorReportModal';
|
|
10
|
+
export * from './AGModal';
|
|
11
|
+
export * from './AGModalContext';
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
AGAlertModal,
|
|
15
|
+
AGConfirmModal,
|
|
16
|
+
AGErrorReportModalButtons,
|
|
17
|
+
AGErrorReportModalTitle,
|
|
18
|
+
AGLoadingModal,
|
|
19
|
+
AGModal,
|
|
20
|
+
AGModalContext,
|
|
21
|
+
};
|
|
@@ -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>
|
package/src/directives/index.ts
CHANGED
|
@@ -4,12 +4,25 @@ import { definePlugin } from '@/plugins';
|
|
|
4
4
|
|
|
5
5
|
import initialFocus from './initial-focus';
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const builtInDirectives: Record<string, Directive> = {
|
|
8
8
|
'initial-focus': initialFocus,
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
export default definePlugin({
|
|
12
|
-
install(app) {
|
|
13
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { JSError } from '@noeldemartin/utils';
|
|
2
|
+
|
|
3
|
+
import { defineServiceState } from '@/services';
|
|
4
|
+
|
|
5
|
+
export type ErrorSource = string | Error | JSError | unknown;
|
|
6
|
+
|
|
7
|
+
export interface ErrorReport {
|
|
8
|
+
title: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
details?: string;
|
|
11
|
+
error?: Error | JSError | unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ErrorReportLog {
|
|
15
|
+
report: ErrorReport;
|
|
16
|
+
seen: boolean;
|
|
17
|
+
date: Date;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default defineServiceState({
|
|
21
|
+
name: 'errors',
|
|
22
|
+
initialState: {
|
|
23
|
+
logs: [] as ErrorReportLog[],
|
|
24
|
+
startupErrors: [] as ErrorReport[],
|
|
25
|
+
},
|
|
26
|
+
computed: {
|
|
27
|
+
hasErrors: ({ logs }) => logs.length > 0,
|
|
28
|
+
hasNewErrors: ({ logs }) => logs.some((error) => !error.seen),
|
|
29
|
+
hasStartupErrors: ({ startupErrors }) => startupErrors.length > 0,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { JSError, facade, isObject, objectWithoutEmpty, toString } from '@noeldemartin/utils';
|
|
2
|
+
|
|
3
|
+
import App from '@/services/App';
|
|
4
|
+
import ServiceBootError from '@/errors/ServiceBootError';
|
|
5
|
+
import UI, { UIComponents } from '@/ui/UI';
|
|
6
|
+
import { translateWithDefault } from '@/lang/utils';
|
|
7
|
+
|
|
8
|
+
import Service from './Errors.state';
|
|
9
|
+
import { Colors } from '@/components/constants';
|
|
10
|
+
import type { ErrorReport, ErrorReportLog, ErrorSource } from './Errors.state';
|
|
11
|
+
|
|
12
|
+
export class ErrorsService extends Service {
|
|
13
|
+
|
|
14
|
+
public forceReporting: boolean = false;
|
|
15
|
+
private enabled: boolean = true;
|
|
16
|
+
|
|
17
|
+
public enable(): void {
|
|
18
|
+
this.enabled = true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public disable(): void {
|
|
22
|
+
this.enabled = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public async inspect(error: ErrorSource | ErrorReport[]): Promise<void> {
|
|
26
|
+
const reports = Array.isArray(error) ? error : [await this.createErrorReport(error)];
|
|
27
|
+
|
|
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 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public async report(error: ErrorSource, message?: string): Promise<void> {
|
|
38
|
+
if (App.development || App.testing) {
|
|
39
|
+
this.logError(error);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!this.enabled) {
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!App.isMounted) {
|
|
47
|
+
const startupError = await this.createStartupErrorReport(error);
|
|
48
|
+
|
|
49
|
+
if (startupError) {
|
|
50
|
+
this.setState({ startupErrors: this.startupErrors.concat(startupError) });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const report = await this.createErrorReport(error);
|
|
57
|
+
const log: ErrorReportLog = {
|
|
58
|
+
report,
|
|
59
|
+
seen: false,
|
|
60
|
+
date: new Date(),
|
|
61
|
+
};
|
|
62
|
+
|
|
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
|
+
);
|
|
80
|
+
|
|
81
|
+
this.setState({ logs: [log].concat(this.logs) });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public see(report: ErrorReport): void {
|
|
85
|
+
this.setState({
|
|
86
|
+
logs: this.logs.map((log) => {
|
|
87
|
+
if (log.report !== report) {
|
|
88
|
+
return log;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
...log,
|
|
93
|
+
seen: true,
|
|
94
|
+
};
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public seeAll(): void {
|
|
100
|
+
this.setState({
|
|
101
|
+
logs: this.logs.map((log) => ({
|
|
102
|
+
...log,
|
|
103
|
+
seen: true,
|
|
104
|
+
})),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
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
|
+
|
|
124
|
+
private logError(error: unknown): void {
|
|
125
|
+
// eslint-disable-next-line no-console
|
|
126
|
+
console.error(error);
|
|
127
|
+
|
|
128
|
+
if (isObject(error) && error.cause) {
|
|
129
|
+
this.logError(error.cause);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async createErrorReport(error: ErrorSource): Promise<ErrorReport> {
|
|
134
|
+
if (typeof error === 'string') {
|
|
135
|
+
return { title: error };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (error instanceof Error || error instanceof JSError) {
|
|
139
|
+
return this.createErrorReportFromError(error);
|
|
140
|
+
}
|
|
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
|
+
|
|
156
|
+
return {
|
|
157
|
+
title: translateWithDefault('errors.unknown', 'Unknown Error'),
|
|
158
|
+
error,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async createStartupErrorReport(error: ErrorSource): Promise<ErrorReport | null> {
|
|
163
|
+
if (error instanceof ServiceBootError) {
|
|
164
|
+
// Ignore second-order boot errors in order to have a cleaner startup crash screen.
|
|
165
|
+
return error.cause instanceof ServiceBootError ? null : this.createErrorReport(error.cause);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return this.createErrorReport(error);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private createErrorReportFromError(error: Error | JSError, defaults: Partial<ErrorReport> = {}): ErrorReport {
|
|
172
|
+
return {
|
|
173
|
+
title: error.name,
|
|
174
|
+
description: error.message,
|
|
175
|
+
details: error.stack,
|
|
176
|
+
error,
|
|
177
|
+
...defaults,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default facade(new ErrorsService());
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { tap } from '@noeldemartin/utils';
|
|
2
|
+
|
|
3
|
+
import { bootServices } from '@/services';
|
|
4
|
+
import { definePlugin } from '@/plugins';
|
|
5
|
+
|
|
6
|
+
import Errors from './Errors';
|
|
7
|
+
import { ErrorReport, ErrorReportLog, ErrorSource } from './Errors.state';
|
|
8
|
+
|
|
9
|
+
export { Errors, ErrorSource, ErrorReport, ErrorReportLog };
|
|
10
|
+
|
|
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
|
+
}
|
|
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;
|
|
39
|
+
export type ErrorsServices = typeof services;
|
|
40
|
+
|
|
41
|
+
export default definePlugin({
|
|
42
|
+
async install(app, options) {
|
|
43
|
+
const errorHandler = setUpErrorHandler(options.handleError);
|
|
44
|
+
|
|
45
|
+
app.config.errorHandler = errorHandler;
|
|
46
|
+
|
|
47
|
+
await bootServices(app, services);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
declare module '@/bootstrap/options' {
|
|
52
|
+
interface AerogelOptions {
|
|
53
|
+
handleError?(error: ErrorSource): boolean;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
declare module '@/services' {
|
|
58
|
+
export interface Services extends ErrorsServices {}
|
|
59
|
+
}
|
package/src/forms/Form.test.ts
CHANGED
|
@@ -34,4 +34,25 @@ describe('Form', () => {
|
|
|
34
34
|
expect(form.errors.name).toEqual(['required']);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
it('resets form', () => {
|
|
38
|
+
// Arrange
|
|
39
|
+
const form = useForm({
|
|
40
|
+
name: {
|
|
41
|
+
type: FormFieldTypes.String,
|
|
42
|
+
rules: 'required',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
form.name = 'Foo bar';
|
|
47
|
+
form.submit();
|
|
48
|
+
|
|
49
|
+
// Act
|
|
50
|
+
form.reset();
|
|
51
|
+
|
|
52
|
+
// Assert
|
|
53
|
+
expect(form.valid).toBe(true);
|
|
54
|
+
expect(form.submitted).toBe(false);
|
|
55
|
+
expect(form.name).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
37
58
|
});
|
package/src/forms/Form.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { ComputedRef, DeepReadonly, Ref, UnwrapNestedRefs } from 'vue';
|
|
|
6
6
|
export const FormFieldTypes = {
|
|
7
7
|
String: 'string',
|
|
8
8
|
Number: 'number',
|
|
9
|
+
Boolean: 'boolean',
|
|
9
10
|
} as const;
|
|
10
11
|
|
|
11
12
|
export interface FormFieldDefinition<TType extends FormFieldType = FormFieldType, TRules extends string = string> {
|
|
@@ -18,7 +19,7 @@ export type FormFieldDefinitions = Record<string, FormFieldDefinition>;
|
|
|
18
19
|
export type FormFieldType = ObjectValues<typeof FormFieldTypes>;
|
|
19
20
|
|
|
20
21
|
export type FormData<T> = {
|
|
21
|
-
[k in keyof T]: T[k] extends FormFieldDefinition<infer TType, infer TRules>
|
|
22
|
+
-readonly [k in keyof T]: T[k] extends FormFieldDefinition<infer TType, infer TRules>
|
|
22
23
|
? TRules extends 'required'
|
|
23
24
|
? GetFormFieldValue<TType>
|
|
24
25
|
: GetFormFieldValue<TType> | null
|
|
@@ -33,6 +34,8 @@ export type GetFormFieldValue<TType> = TType extends typeof FormFieldTypes.Strin
|
|
|
33
34
|
? string
|
|
34
35
|
: TType extends typeof FormFieldTypes.Number
|
|
35
36
|
? number
|
|
37
|
+
: TType extends typeof FormFieldTypes.Boolean
|
|
38
|
+
? boolean
|
|
36
39
|
: never;
|
|
37
40
|
|
|
38
41
|
export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinitions> extends MagicObject {
|
|
@@ -78,10 +81,10 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
public validate(): boolean {
|
|
81
|
-
const errors = Object.entries(this._fields).reduce((
|
|
82
|
-
|
|
84
|
+
const errors = Object.entries(this._fields).reduce((formErrors, [name, definition]) => {
|
|
85
|
+
formErrors[name] = this.getFieldErrors(name, definition);
|
|
83
86
|
|
|
84
|
-
return
|
|
87
|
+
return formErrors;
|
|
85
88
|
}, {} as Record<string, string[] | null>);
|
|
86
89
|
|
|
87
90
|
this.resetErrors(errors);
|
|
@@ -92,6 +95,7 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
92
95
|
public reset(): void {
|
|
93
96
|
this._submitted.value = false;
|
|
94
97
|
|
|
98
|
+
this.resetData();
|
|
95
99
|
this.resetErrors();
|
|
96
100
|
}
|
|
97
101
|
|
|
@@ -134,10 +138,10 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
134
138
|
return {} as FormData<Fields>;
|
|
135
139
|
}
|
|
136
140
|
|
|
137
|
-
const data = Object.entries(fields).reduce((
|
|
138
|
-
|
|
141
|
+
const data = Object.entries(fields).reduce((formData, [name, definition]) => {
|
|
142
|
+
formData[name as keyof Fields] = (definition.default ?? null) as FormData<Fields>[keyof Fields];
|
|
139
143
|
|
|
140
|
-
return
|
|
144
|
+
return formData;
|
|
141
145
|
}, {} as FormData<Fields>);
|
|
142
146
|
|
|
143
147
|
return reactive(data) as FormData<Fields>;
|
|
@@ -148,15 +152,21 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
|
|
|
148
152
|
return {} as FormErrors<Fields>;
|
|
149
153
|
}
|
|
150
154
|
|
|
151
|
-
const errors = Object.keys(fields).reduce((
|
|
152
|
-
|
|
155
|
+
const errors = Object.keys(fields).reduce((formErrors, name) => {
|
|
156
|
+
formErrors[name as keyof Fields] = null;
|
|
153
157
|
|
|
154
|
-
return
|
|
158
|
+
return formErrors;
|
|
155
159
|
}, {} as FormErrors<Fields>);
|
|
156
160
|
|
|
157
161
|
return reactive(errors) as FormErrors<Fields>;
|
|
158
162
|
}
|
|
159
163
|
|
|
164
|
+
private resetData(): void {
|
|
165
|
+
for (const [name, field] of Object.entries(this._fields)) {
|
|
166
|
+
this._data[name as keyof Fields] = (field.default ?? null) as FormData<Fields>[keyof Fields];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
160
170
|
private resetErrors(errors?: Record<string, string[] | null>): void {
|
|
161
171
|
Object.keys(this._errors).forEach((key) => delete this._errors[key as keyof Fields]);
|
|
162
172
|
|
package/src/forms/utils.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { FormFieldTypes } from './Form';
|
|
2
2
|
import type { FormFieldDefinition } from './Form';
|
|
3
3
|
|
|
4
|
+
export function booleanInput(defaultValue?: boolean): FormFieldDefinition<typeof FormFieldTypes.Boolean> {
|
|
5
|
+
return {
|
|
6
|
+
default: defaultValue,
|
|
7
|
+
type: FormFieldTypes.Boolean,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function requiredBooleanInput(
|
|
12
|
+
defaultValue?: boolean,
|
|
13
|
+
): FormFieldDefinition<typeof FormFieldTypes.Boolean, 'required'> {
|
|
14
|
+
return {
|
|
15
|
+
default: defaultValue,
|
|
16
|
+
type: FormFieldTypes.Boolean,
|
|
17
|
+
rules: 'required',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
4
21
|
export function requiredNumberInput(
|
|
5
22
|
defaultValue?: number,
|
|
6
23
|
): FormFieldDefinition<typeof FormFieldTypes.Number, 'required'> {
|
package/src/lang/Lang.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { facade } from '@noeldemartin/utils';
|
|
1
|
+
import { facade, toString } from '@noeldemartin/utils';
|
|
2
2
|
|
|
3
3
|
import App from '@/services/App';
|
|
4
4
|
import Service from '@/services/Service';
|
|
@@ -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.
|
|
20
|
+
App.development && console.warn('Lang provider is missing');
|
|
21
21
|
|
|
22
22
|
return key;
|
|
23
23
|
},
|
|
@@ -41,10 +41,18 @@ export class LangService extends Service {
|
|
|
41
41
|
): string {
|
|
42
42
|
defaultMessage ??= defaultMessageOrParameters as string;
|
|
43
43
|
|
|
44
|
-
const parameters = typeof defaultMessageOrParameters === 'string' ? {} : defaultMessageOrParameters;
|
|
44
|
+
const parameters = typeof defaultMessageOrParameters === 'string' ? {} : defaultMessageOrParameters ?? {};
|
|
45
45
|
const message = this.provider.translate(key, parameters) ?? key;
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
if (message === key) {
|
|
48
|
+
return Object.entries(parameters).reduce(
|
|
49
|
+
(renderedMessage, [name, value]) =>
|
|
50
|
+
renderedMessage.replace(new RegExp(`\\{\\s*${name}\\s*\\}`, 'g'), toString(value)),
|
|
51
|
+
defaultMessage,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return message;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
}
|
package/src/lang/index.ts
CHANGED
|
@@ -2,16 +2,14 @@ import { bootServices } from '@/services';
|
|
|
2
2
|
import { definePlugin } from '@/plugins';
|
|
3
3
|
|
|
4
4
|
import Lang, { LangProvider } from './Lang';
|
|
5
|
+
import { translate, translateWithDefault } from './utils';
|
|
5
6
|
|
|
6
|
-
export { Lang, LangProvider };
|
|
7
|
+
export { Lang, LangProvider, translate, translateWithDefault };
|
|
7
8
|
|
|
8
9
|
const services = { $lang: Lang };
|
|
9
10
|
|
|
10
11
|
export type LangServices = typeof services;
|
|
11
12
|
|
|
12
|
-
export const translate = Lang.translate.bind(Lang);
|
|
13
|
-
export const translateWithDefault = Lang.translateWithDefault.bind(Lang);
|
|
14
|
-
|
|
15
13
|
export default definePlugin({
|
|
16
14
|
async install(app) {
|
|
17
15
|
app.config.globalProperties.$t ??= translate;
|
|
@@ -22,7 +20,7 @@ export default definePlugin({
|
|
|
22
20
|
});
|
|
23
21
|
|
|
24
22
|
declare module '@/services' {
|
|
25
|
-
interface Services extends LangServices {}
|
|
23
|
+
export interface Services extends LangServices {}
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
declare module '@vue/runtime-core' {
|
package/src/main.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
import Build from 'virtual:aerogel';
|
|
2
|
+
|
|
1
3
|
import { defineServiceState } from '@/services/Service';
|
|
2
4
|
|
|
3
5
|
export default defineServiceState({
|
|
6
|
+
name: 'app',
|
|
4
7
|
initialState: {
|
|
5
|
-
environment:
|
|
8
|
+
environment: Build.environment,
|
|
9
|
+
sourceUrl: Build.sourceUrl,
|
|
10
|
+
isMounted: false,
|
|
6
11
|
},
|
|
7
12
|
computed: {
|
|
8
|
-
|
|
13
|
+
development: (state) => state.environment === 'development',
|
|
14
|
+
testing: (state) => state.environment === 'testing',
|
|
9
15
|
},
|
|
10
16
|
});
|
package/src/services/App.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { facade } from '@noeldemartin/utils';
|
|
2
2
|
|
|
3
|
+
import Events from '@/services/Events';
|
|
4
|
+
|
|
3
5
|
import Service from './App.state';
|
|
4
6
|
|
|
5
|
-
export class AppService extends Service {
|
|
7
|
+
export class AppService extends Service {
|
|
8
|
+
|
|
9
|
+
protected async boot(): Promise<void> {
|
|
10
|
+
Events.once('application-mounted', () => this.setState({ isMounted: true }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
}
|
|
6
14
|
|
|
7
15
|
export default facade(new AppService());
|