@aerogel/core 0.0.0-next.ea2e864c719d0a4d01b04729a9b681c0e9c85ea7 → 0.0.0-next.eb6fcafb87cdccbc72933f616799ca3124d1eca8
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.d.ts +209 -71
- package/dist/aerogel-core.js +1255 -1007
- package/dist/aerogel-core.js.map +1 -1
- package/package.json +1 -1
- package/src/components/contracts/AlertModal.ts +1 -1
- package/src/components/contracts/DropdownMenu.ts +2 -2
- package/src/components/contracts/Select.ts +2 -2
- package/src/components/headless/HeadlessInputInput.vue +14 -3
- package/src/components/ui/Input.vue +2 -2
- package/src/components/ui/Setting.vue +31 -0
- package/src/components/ui/StartupCrash.vue +51 -6
- package/src/components/ui/TextArea.vue +56 -0
- package/src/components/ui/index.ts +2 -0
- package/src/errors/Errors.state.ts +1 -0
- package/src/errors/Errors.ts +27 -6
- package/src/errors/settings/Debug.vue +8 -33
- package/src/forms/FormController.test.ts +3 -0
- package/src/forms/FormController.ts +25 -16
- package/src/forms/utils.ts +25 -0
- package/src/forms/validation.ts +31 -0
- package/src/index.css +3 -0
- package/src/services/Service.ts +11 -6
- package/src/testing/index.ts +4 -0
- package/src/ui/UI.ts +20 -4
- package/src/utils/app.ts +7 -0
- package/src/utils/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { computed } from 'vue';
|
|
2
2
|
|
|
3
|
-
import { translateWithDefault } from '@aerogel/core/lang';
|
|
3
|
+
import { translateWithDefault } from '@aerogel/core/lang/utils';
|
|
4
4
|
import type { ModalExpose } from '@aerogel/core/components/contracts/Modal';
|
|
5
5
|
|
|
6
6
|
export interface AlertModalProps {
|
|
@@ -15,11 +15,11 @@ export type DropdownMenuOptionData = {
|
|
|
15
15
|
export interface DropdownMenuProps {
|
|
16
16
|
align?: DropdownMenuContentProps['align'];
|
|
17
17
|
side?: DropdownMenuContentProps['side'];
|
|
18
|
-
options?: Falsifiable<DropdownMenuOptionData>[];
|
|
18
|
+
options?: readonly Falsifiable<DropdownMenuOptionData>[];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export interface DropdownMenuExpose {
|
|
22
22
|
align?: DropdownMenuContentProps['align'];
|
|
23
23
|
side?: DropdownMenuContentProps['side'];
|
|
24
|
-
options?: DropdownMenuOptionData[];
|
|
24
|
+
options?: readonly DropdownMenuOptionData[];
|
|
25
25
|
}
|
|
@@ -18,7 +18,7 @@ export interface HasSelectOptionLabel {
|
|
|
18
18
|
|
|
19
19
|
export interface SelectProps<T extends Nullable<FormFieldValue> = Nullable<FormFieldValue>> extends InputProps<T> {
|
|
20
20
|
as?: AsTag | Component;
|
|
21
|
-
options?: T[];
|
|
21
|
+
options?: readonly T[];
|
|
22
22
|
placeholder?: string;
|
|
23
23
|
renderOption?: (option: T) => string;
|
|
24
24
|
compareOptions?: (a: T, b: T) => boolean;
|
|
@@ -31,7 +31,7 @@ export interface SelectProps<T extends Nullable<FormFieldValue> = Nullable<FormF
|
|
|
31
31
|
export interface SelectEmits<T extends Nullable<FormFieldValue> = Nullable<FormFieldValue>> extends InputEmits<T> {}
|
|
32
32
|
|
|
33
33
|
export interface SelectExpose<T extends Nullable<FormFieldValue> = Nullable<FormFieldValue>> extends InputExpose<T> {
|
|
34
|
-
options: ComputedRef<Nullable<SelectOptionData[]>>;
|
|
34
|
+
options: ComputedRef<Nullable<readonly SelectOptionData[]>>;
|
|
35
35
|
selectedOption: ComputedRef<Nullable<SelectOptionData>>;
|
|
36
36
|
placeholder: ComputedRef<string>;
|
|
37
37
|
labelClass?: HTMLAttributes['class'];
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
:id="input.id"
|
|
4
4
|
ref="$inputRef"
|
|
5
5
|
:name
|
|
6
|
-
:type
|
|
7
6
|
:checked
|
|
7
|
+
:type="renderedType"
|
|
8
8
|
:required="input.required ?? undefined"
|
|
9
9
|
:aria-invalid="input.errors ? 'true' : 'false'"
|
|
10
10
|
:aria-describedby="
|
|
@@ -15,18 +15,29 @@
|
|
|
15
15
|
</template>
|
|
16
16
|
|
|
17
17
|
<script setup lang="ts">
|
|
18
|
-
import { computed, useTemplateRef, watchEffect } from 'vue';
|
|
18
|
+
import { computed, inject, useTemplateRef, watchEffect } from 'vue';
|
|
19
19
|
|
|
20
20
|
import { injectReactiveOrFail } from '@aerogel/core/utils/vue';
|
|
21
21
|
import { onFormFocus } from '@aerogel/core/utils/composition/forms';
|
|
22
|
+
import type FormController from '@aerogel/core/forms/FormController';
|
|
22
23
|
import type { FormFieldValue } from '@aerogel/core/forms/FormController';
|
|
23
24
|
import type { InputExpose } from '@aerogel/core/components/contracts/Input';
|
|
24
25
|
|
|
25
|
-
const { type
|
|
26
|
+
const { type } = defineProps<{ type?: string }>();
|
|
26
27
|
const $input = useTemplateRef('$inputRef');
|
|
27
28
|
const input = injectReactiveOrFail<InputExpose>('input', '<HeadlessInputInput> must be a child of a <HeadlessInput>');
|
|
29
|
+
const form = inject<FormController | null>('form', null);
|
|
28
30
|
const name = computed(() => input.name ?? undefined);
|
|
29
31
|
const value = computed(() => input.value);
|
|
32
|
+
const renderedType = computed(() => {
|
|
33
|
+
if (type) {
|
|
34
|
+
return type;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const fieldType = (name.value && form?.getFieldType(name.value)) ?? '';
|
|
38
|
+
|
|
39
|
+
return ['text', 'email', 'number', 'tel', 'url'].includes(fieldType) ? fieldType : 'text';
|
|
40
|
+
});
|
|
30
41
|
const checked = computed(() => {
|
|
31
42
|
if (type !== 'checkbox') {
|
|
32
43
|
return;
|
|
@@ -48,8 +48,8 @@ const renderedInputClasses = computed(() =>
|
|
|
48
48
|
'block w-full rounded-md border-0 py-1.5 ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6',
|
|
49
49
|
{
|
|
50
50
|
'focus:ring-primary-600': !$input.value?.errors,
|
|
51
|
-
'text-gray-900 shadow-2xs ring-gray-
|
|
52
|
-
'pr-10 text-red-900 ring-red-
|
|
51
|
+
'text-gray-900 shadow-2xs ring-gray-900/10 placeholder:text-gray-400': !$input.value?.errors,
|
|
52
|
+
'pr-10 text-red-900 ring-red-900/10 placeholder:text-red-300 focus:ring-red-500': $input.value?.errors,
|
|
53
53
|
},
|
|
54
54
|
inputClass,
|
|
55
55
|
));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="mt-4 flex" :class="{ 'flex-col': layout === 'vertical' }">
|
|
3
|
+
<div class="flex-grow">
|
|
4
|
+
<h3 :id="titleId" class="text-base font-semibold">
|
|
5
|
+
{{ title }}
|
|
6
|
+
</h3>
|
|
7
|
+
<Markdown v-if="description" :text="description" class="mt-1 text-sm text-gray-500" />
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div :class="renderedRootClass">
|
|
11
|
+
<slot />
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script setup lang="ts">
|
|
17
|
+
import { computed } from 'vue';
|
|
18
|
+
import type { HTMLAttributes } from 'vue';
|
|
19
|
+
|
|
20
|
+
import Markdown from '@aerogel/core/components/ui/Markdown.vue';
|
|
21
|
+
import { classes } from '@aerogel/core/utils';
|
|
22
|
+
|
|
23
|
+
const { layout = 'horizontal', class: rootClass } = defineProps<{
|
|
24
|
+
title: string;
|
|
25
|
+
titleId?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
class?: HTMLAttributes['class'];
|
|
28
|
+
layout?: 'vertical' | 'horizontal';
|
|
29
|
+
}>();
|
|
30
|
+
const renderedRootClass = computed(() => classes(rootClass, 'flex flex-col justify-center gap-1'));
|
|
31
|
+
</script>
|
|
@@ -1,31 +1,76 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="grid grow place-items-center">
|
|
3
|
-
<div class="flex flex-col items-center
|
|
4
|
-
<
|
|
5
|
-
|
|
3
|
+
<div class="flex flex-col items-center p-8">
|
|
4
|
+
<i-majesticons-exclamation class="size-20 text-red-600" />
|
|
5
|
+
<h1 class="mt-0 mb-0 text-center text-4xl font-medium text-red-600">
|
|
6
|
+
{{ $td('startupCrash.title', 'Oops, something went wrong!') }}
|
|
6
7
|
</h1>
|
|
7
8
|
<Markdown
|
|
8
9
|
:text="
|
|
9
10
|
$td(
|
|
10
11
|
'startupCrash.message',
|
|
11
|
-
'
|
|
12
|
+
'There was a problem starting the application, but here\'s some things you can do:'
|
|
12
13
|
)
|
|
13
14
|
"
|
|
14
15
|
class="mt-4 text-center"
|
|
15
16
|
/>
|
|
16
|
-
<div
|
|
17
|
+
<div
|
|
18
|
+
class="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 [&_button]:justify-start sm:[&_button]:size-32 sm:[&_button]:flex-col sm:[&_button]:justify-center [&_svg]:size-6 sm:[&_svg]:size-8"
|
|
19
|
+
>
|
|
17
20
|
<Button variant="danger" @click="$app.reload()">
|
|
21
|
+
<IconRefresh />
|
|
18
22
|
{{ $td('startupCrash.reload', 'Try again') }}
|
|
19
23
|
</Button>
|
|
20
24
|
<Button variant="danger" @click="$errors.inspect($errors.startupErrors)">
|
|
25
|
+
<IconBug />
|
|
21
26
|
{{ $td('startupCrash.inspect', 'View error details') }}
|
|
22
27
|
</Button>
|
|
28
|
+
<Button variant="danger" @click="purgeDevice()">
|
|
29
|
+
<IconDelete />
|
|
30
|
+
{{ $td('startupCrash.purge', 'Purge device') }}
|
|
31
|
+
</Button>
|
|
32
|
+
<Button variant="danger" @click="$errors.debug = !$errors.debug">
|
|
33
|
+
<IconFrameInspect />
|
|
34
|
+
{{ $td('startupCrash.debug', 'Toggle debugging') }}
|
|
35
|
+
</Button>
|
|
23
36
|
</div>
|
|
24
37
|
</div>
|
|
25
38
|
</div>
|
|
26
39
|
</template>
|
|
27
40
|
|
|
28
41
|
<script setup lang="ts">
|
|
29
|
-
import
|
|
42
|
+
import IconBug from '~icons/material-symbols/bug-report';
|
|
43
|
+
import IconDelete from '~icons/material-symbols/delete-forever-rounded';
|
|
44
|
+
import IconFrameInspect from '~icons/material-symbols/frame-inspect';
|
|
45
|
+
import IconRefresh from '~icons/material-symbols/refresh-rounded';
|
|
46
|
+
|
|
47
|
+
import App from '@aerogel/core/services/App';
|
|
30
48
|
import Button from '@aerogel/core/components/ui/Button.vue';
|
|
49
|
+
import Markdown from '@aerogel/core/components/ui/Markdown.vue';
|
|
50
|
+
import Storage from '@aerogel/core/services/Storage';
|
|
51
|
+
import UI from '@aerogel/core/ui/UI';
|
|
52
|
+
import { translateWithDefault } from '@aerogel/core/lang/utils';
|
|
53
|
+
|
|
54
|
+
async function purgeDevice() {
|
|
55
|
+
const confirmed = await UI.confirm(
|
|
56
|
+
translateWithDefault('startupCrash.purgeConfirmTitle', 'Delete everything?'),
|
|
57
|
+
translateWithDefault(
|
|
58
|
+
'startupCrash.purgeConfirmMessage',
|
|
59
|
+
'If the problem persists, one drastic solution may be to wipe the storage in this device ' +
|
|
60
|
+
'to start from scratch. However, keep in mind that **all the data that you haven\'t ' +
|
|
61
|
+
'synchronized will be deleted forever**.\n\nDo you still want to proceed?',
|
|
62
|
+
),
|
|
63
|
+
{
|
|
64
|
+
acceptVariant: 'danger',
|
|
65
|
+
acceptText: translateWithDefault('startupCrash.purgeConfirmAccept', 'Purge device'),
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (!confirmed) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await Storage.purge();
|
|
74
|
+
await App.reload();
|
|
75
|
+
}
|
|
31
76
|
</script>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<HeadlessInput
|
|
3
|
+
ref="$inputRef"
|
|
4
|
+
:label="label"
|
|
5
|
+
:class="rootClasses"
|
|
6
|
+
v-bind="props"
|
|
7
|
+
@update:model-value="$emit('update:modelValue', $event)"
|
|
8
|
+
>
|
|
9
|
+
<HeadlessInputLabel class="block text-sm leading-6 font-medium text-gray-900" />
|
|
10
|
+
<div :class="renderedWrapperClasses">
|
|
11
|
+
<HeadlessInputTextArea v-bind="inputAttrs" :class="renderedInputClasses" />
|
|
12
|
+
<div v-if="$input?.errors" class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
|
13
|
+
<IconExclamationSolid class="size-5 text-red-500" />
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
<HeadlessInputDescription class="mt-2 text-sm text-gray-600" />
|
|
17
|
+
<HeadlessInputError class="mt-2 text-sm text-red-600" />
|
|
18
|
+
</HeadlessInput>
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<script setup lang="ts">
|
|
22
|
+
import IconExclamationSolid from '~icons/zondicons/exclamation-solid';
|
|
23
|
+
|
|
24
|
+
import { computed, useTemplateRef } from 'vue';
|
|
25
|
+
import type { HTMLAttributes } from 'vue';
|
|
26
|
+
|
|
27
|
+
import HeadlessInput from '@aerogel/core/components/headless/HeadlessInput.vue';
|
|
28
|
+
import HeadlessInputLabel from '@aerogel/core/components/headless/HeadlessInputLabel.vue';
|
|
29
|
+
import HeadlessInputTextArea from '@aerogel/core/components/headless/HeadlessInputTextArea.vue';
|
|
30
|
+
import HeadlessInputDescription from '@aerogel/core/components/headless/HeadlessInputDescription.vue';
|
|
31
|
+
import HeadlessInputError from '@aerogel/core/components/headless/HeadlessInputError.vue';
|
|
32
|
+
import { classes } from '@aerogel/core/utils/classes';
|
|
33
|
+
import { useInputAttrs } from '@aerogel/core/utils/composition/forms';
|
|
34
|
+
import type { InputEmits, InputProps } from '@aerogel/core/components/contracts/Input';
|
|
35
|
+
|
|
36
|
+
defineOptions({ inheritAttrs: false });
|
|
37
|
+
defineEmits<InputEmits>();
|
|
38
|
+
const { label, inputClass, wrapperClass, ...props } = defineProps<
|
|
39
|
+
InputProps & { inputClass?: HTMLAttributes['class']; wrapperClass?: HTMLAttributes['class'] }
|
|
40
|
+
>();
|
|
41
|
+
const $input = useTemplateRef('$inputRef');
|
|
42
|
+
const [inputAttrs, rootClasses] = useInputAttrs();
|
|
43
|
+
const renderedWrapperClasses = computed(() =>
|
|
44
|
+
classes('relative rounded-md shadow-2xs', { 'mt-1': label }, wrapperClass));
|
|
45
|
+
const renderedInputClasses = computed(() =>
|
|
46
|
+
classes(
|
|
47
|
+
// eslint-disable-next-line vue/max-len
|
|
48
|
+
'block w-full rounded-md border-0 py-1.5 ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6',
|
|
49
|
+
{
|
|
50
|
+
'focus:ring-primary-600': !$input.value?.errors,
|
|
51
|
+
'text-gray-900 shadow-2xs ring-gray-900/10 placeholder:text-gray-400': !$input.value?.errors,
|
|
52
|
+
'pr-10 text-red-900 ring-red-900/10 placeholder:text-red-300 focus:ring-red-500': $input.value?.errors,
|
|
53
|
+
},
|
|
54
|
+
inputClass,
|
|
55
|
+
));
|
|
56
|
+
</script>
|
|
@@ -27,7 +27,9 @@ export { default as SelectLabel } from './SelectLabel.vue';
|
|
|
27
27
|
export { default as SelectOption } from './SelectOption.vue';
|
|
28
28
|
export { default as SelectOptions } from './SelectOptions.vue';
|
|
29
29
|
export { default as SelectTrigger } from './SelectTrigger.vue';
|
|
30
|
+
export { default as Setting } from './Setting.vue';
|
|
30
31
|
export { default as SettingsModal } from './SettingsModal.vue';
|
|
31
32
|
export { default as StartupCrash } from './StartupCrash.vue';
|
|
32
33
|
export { default as Switch } from './Switch.vue';
|
|
34
|
+
export { default as TextArea } from './TextArea.vue';
|
|
33
35
|
export { default as Toast } from './Toast.vue';
|
package/src/errors/Errors.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { JSError, facade, isDevelopment, isObject, isTesting, objectWithoutEmpty, toString } from '@noeldemartin/utils';
|
|
2
|
+
import { watchEffect } from 'vue';
|
|
3
|
+
import type Eruda from 'eruda';
|
|
2
4
|
|
|
3
5
|
import App from '@aerogel/core/services/App';
|
|
4
6
|
import ServiceBootError from '@aerogel/core/errors/ServiceBootError';
|
|
@@ -13,6 +15,7 @@ export class ErrorsService extends Service {
|
|
|
13
15
|
|
|
14
16
|
public forceReporting: boolean = false;
|
|
15
17
|
private enabled: boolean = true;
|
|
18
|
+
private eruda: typeof Eruda | null = null;
|
|
16
19
|
|
|
17
20
|
public enable(): void {
|
|
18
21
|
this.enabled = true;
|
|
@@ -91,6 +94,19 @@ export class ErrorsService extends Service {
|
|
|
91
94
|
this.setState({ logs: [log].concat(this.logs) });
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
public reportDevelopmentError(error: ErrorSource, message?: string): void {
|
|
98
|
+
if (!isDevelopment()) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (message) {
|
|
103
|
+
// eslint-disable-next-line no-console
|
|
104
|
+
console.warn(message);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.logError(error);
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
public see(report: ErrorReport): void {
|
|
95
111
|
this.setState({
|
|
96
112
|
logs: this.logs.map((log) => {
|
|
@@ -106,12 +122,17 @@ export class ErrorsService extends Service {
|
|
|
106
122
|
});
|
|
107
123
|
}
|
|
108
124
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
125
|
+
protected override async boot(): Promise<void> {
|
|
126
|
+
watchEffect(async () => {
|
|
127
|
+
if (!this.debug) {
|
|
128
|
+
this.eruda?.destroy();
|
|
129
|
+
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.eruda ??= (await import('eruda')).default;
|
|
134
|
+
|
|
135
|
+
this.eruda.init();
|
|
115
136
|
});
|
|
116
137
|
}
|
|
117
138
|
|
|
@@ -1,39 +1,14 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
lang-default="Enable debugging with [Eruda](https://eruda.liriliri.io/)."
|
|
10
|
-
class="mt-1 text-sm text-gray-500"
|
|
11
|
-
/>
|
|
12
|
-
</div>
|
|
13
|
-
|
|
14
|
-
<Switch v-model="enabled" aria-labelledby="debug-setting" />
|
|
15
|
-
</div>
|
|
2
|
+
<Setting
|
|
3
|
+
title-id="debug-setting"
|
|
4
|
+
:title="$td('settings.debug', 'Debugging')"
|
|
5
|
+
:description="$td('settings.debugDescription', 'Enable debugging with [Eruda](https://eruda.liriliri.io/).')"
|
|
6
|
+
>
|
|
7
|
+
<Switch v-model="$errors.debug" aria-labelledby="debug-setting" />
|
|
8
|
+
</Setting>
|
|
16
9
|
</template>
|
|
17
10
|
|
|
18
11
|
<script setup lang="ts">
|
|
19
|
-
import
|
|
20
|
-
import type Eruda from 'eruda';
|
|
21
|
-
|
|
12
|
+
import Setting from '@aerogel/core/components/ui/Setting.vue';
|
|
22
13
|
import Switch from '@aerogel/core/components/ui/Switch.vue';
|
|
23
|
-
import Markdown from '@aerogel/core/components/ui/Markdown.vue';
|
|
24
|
-
|
|
25
|
-
let eruda: typeof Eruda | null = null;
|
|
26
|
-
const enabled = ref(false);
|
|
27
|
-
|
|
28
|
-
watchEffect(async () => {
|
|
29
|
-
if (!enabled.value) {
|
|
30
|
-
eruda?.destroy();
|
|
31
|
-
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
eruda ??= (await import('eruda')).default;
|
|
36
|
-
|
|
37
|
-
eruda.init();
|
|
38
|
-
});
|
|
39
14
|
</script>
|
|
@@ -4,6 +4,7 @@ import type { Equals } from '@noeldemartin/utils';
|
|
|
4
4
|
import type { Expect } from '@noeldemartin/testing';
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
+
enumInput,
|
|
7
8
|
numberInput,
|
|
8
9
|
objectInput,
|
|
9
10
|
requiredObjectInput,
|
|
@@ -97,6 +98,7 @@ describe('FormController', () => {
|
|
|
97
98
|
two: requiredStringInput(),
|
|
98
99
|
three: objectInput(),
|
|
99
100
|
four: requiredObjectInput<{ foo: string; bar?: number }>(),
|
|
101
|
+
five: enumInput(['foo', 'bar']),
|
|
100
102
|
});
|
|
101
103
|
|
|
102
104
|
tt<
|
|
@@ -104,6 +106,7 @@ describe('FormController', () => {
|
|
|
104
106
|
| Expect<Equals<typeof form.two, string>>
|
|
105
107
|
| Expect<Equals<typeof form.three, object | null>>
|
|
106
108
|
| Expect<Equals<typeof form.four, { foo: string; bar?: number }>>
|
|
109
|
+
| Expect<Equals<typeof form.five, 'foo' | 'bar' | null>>
|
|
107
110
|
>();
|
|
108
111
|
});
|
|
109
112
|
|
|
@@ -2,31 +2,32 @@ import { computed, nextTick, reactive, readonly, ref } from 'vue';
|
|
|
2
2
|
import { MagicObject, arrayRemove, fail, toString } from '@noeldemartin/utils';
|
|
3
3
|
import type { ComputedRef, DeepReadonly, Ref, UnwrapNestedRefs } from 'vue';
|
|
4
4
|
|
|
5
|
-
import { validate } from './validation';
|
|
5
|
+
import { validate, validateType } from './validation';
|
|
6
6
|
|
|
7
|
-
export const
|
|
7
|
+
export const __valueType: unique symbol = Symbol();
|
|
8
8
|
|
|
9
9
|
export interface FormFieldDefinition<
|
|
10
10
|
TType extends FormFieldType = FormFieldType,
|
|
11
11
|
TRules extends string = string,
|
|
12
|
-
|
|
12
|
+
TValueType = unknown,
|
|
13
13
|
> {
|
|
14
14
|
type: TType;
|
|
15
15
|
trim?: boolean;
|
|
16
16
|
default?: GetFormFieldValue<TType>;
|
|
17
17
|
rules?: TRules;
|
|
18
|
-
[
|
|
18
|
+
values?: readonly TValueType[];
|
|
19
|
+
[__valueType]?: TValueType;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export type FormFieldType = 'string' | 'number' | 'boolean' | 'object' | 'date';
|
|
22
|
+
export type FormFieldType = 'string' | 'enum' | 'number' | 'boolean' | 'object' | 'date';
|
|
22
23
|
export type FormFieldValue = GetFormFieldValue<FormFieldType>;
|
|
23
24
|
export type FormFieldDefinitions = Record<string, FormFieldDefinition>;
|
|
24
25
|
|
|
25
26
|
export type FormData<T> = {
|
|
26
|
-
-readonly [k in keyof T]: T[k] extends FormFieldDefinition<infer TType, infer TRules, infer
|
|
27
|
+
-readonly [k in keyof T]: T[k] extends FormFieldDefinition<infer TType, infer TRules, infer TValueType>
|
|
27
28
|
? TRules extends 'required'
|
|
28
|
-
? GetFormFieldValue<TType,
|
|
29
|
-
: GetFormFieldValue<TType,
|
|
29
|
+
? GetFormFieldValue<TType, TValueType>
|
|
30
|
+
: GetFormFieldValue<TType, TValueType> | null
|
|
30
31
|
: never;
|
|
31
32
|
};
|
|
32
33
|
|
|
@@ -34,19 +35,21 @@ export type FormErrors<T> = {
|
|
|
34
35
|
[k in keyof T]: string[] | null;
|
|
35
36
|
};
|
|
36
37
|
|
|
37
|
-
export type GetFormFieldValue<TType,
|
|
38
|
+
export type GetFormFieldValue<TType, TValueType = unknown> = TType extends 'string'
|
|
38
39
|
? string
|
|
39
40
|
: TType extends 'number'
|
|
40
41
|
? number
|
|
41
42
|
: TType extends 'boolean'
|
|
42
43
|
? boolean
|
|
43
|
-
: TType extends '
|
|
44
|
-
?
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
:
|
|
44
|
+
: TType extends 'enum'
|
|
45
|
+
? TValueType
|
|
46
|
+
: TType extends 'object'
|
|
47
|
+
? TValueType extends object
|
|
48
|
+
? TValueType
|
|
49
|
+
: object
|
|
50
|
+
: TType extends 'date'
|
|
51
|
+
? Date
|
|
52
|
+
: never;
|
|
50
53
|
|
|
51
54
|
const validForms: WeakMap<FormController, ComputedRef<boolean>> = new WeakMap();
|
|
52
55
|
|
|
@@ -109,6 +112,10 @@ export default class FormController<Fields extends FormFieldDefinitions = FormFi
|
|
|
109
112
|
return this._fields[field]?.rules?.split('|') ?? [];
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
public getFieldType<T extends keyof Fields>(field: T): FormFieldType | null {
|
|
116
|
+
return this._fields[field]?.type ?? null;
|
|
117
|
+
}
|
|
118
|
+
|
|
112
119
|
public data(): FormData<Fields> {
|
|
113
120
|
return { ...this._data };
|
|
114
121
|
}
|
|
@@ -192,6 +199,8 @@ export default class FormController<Fields extends FormFieldDefinitions = FormFi
|
|
|
192
199
|
const value = this._data[name];
|
|
193
200
|
const rules = definition.rules?.split('|') ?? [];
|
|
194
201
|
|
|
202
|
+
errors.push(...validateType(value, definition));
|
|
203
|
+
|
|
195
204
|
for (const rule of rules) {
|
|
196
205
|
if (rule !== 'required' && (value === null || value === undefined)) {
|
|
197
206
|
continue;
|
package/src/forms/utils.ts
CHANGED
|
@@ -16,6 +16,19 @@ export function dateInput(defaultValue?: Date, options: { rules?: string } = {})
|
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function enumInput<const T extends string>(
|
|
20
|
+
values: readonly T[],
|
|
21
|
+
defaultValue?: T,
|
|
22
|
+
options: { rules?: string } = {},
|
|
23
|
+
): FormFieldDefinition<'enum', string, T> {
|
|
24
|
+
return {
|
|
25
|
+
default: defaultValue,
|
|
26
|
+
type: 'enum',
|
|
27
|
+
rules: options.rules,
|
|
28
|
+
values,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
export function requiredBooleanInput(defaultValue?: boolean): FormFieldDefinition<'boolean', 'required'> {
|
|
20
33
|
return {
|
|
21
34
|
default: defaultValue,
|
|
@@ -32,6 +45,18 @@ export function requiredDateInput(defaultValue?: Date): FormFieldDefinition<'dat
|
|
|
32
45
|
};
|
|
33
46
|
}
|
|
34
47
|
|
|
48
|
+
export function requiredEnumInput<const T extends string>(
|
|
49
|
+
values: readonly T[],
|
|
50
|
+
defaultValue?: T,
|
|
51
|
+
): FormFieldDefinition<'enum', 'required', T> {
|
|
52
|
+
return {
|
|
53
|
+
default: defaultValue,
|
|
54
|
+
type: 'enum',
|
|
55
|
+
rules: 'required',
|
|
56
|
+
values,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
35
60
|
export function requiredNumberInput(defaultValue?: number): FormFieldDefinition<'number', 'required'> {
|
|
36
61
|
return {
|
|
37
62
|
default: defaultValue,
|
package/src/forms/validation.ts
CHANGED
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
import { arrayFrom } from '@noeldemartin/utils';
|
|
2
2
|
|
|
3
|
+
import type { FormFieldDefinition } from '@aerogel/core/forms/FormController';
|
|
4
|
+
|
|
3
5
|
const builtInRules: Record<string, FormFieldValidator> = {
|
|
4
6
|
required: (value) => (value ? undefined : 'required'),
|
|
5
7
|
};
|
|
6
8
|
|
|
9
|
+
function isValidType(value: unknown, definition: FormFieldDefinition): boolean {
|
|
10
|
+
if (value === undefined || value === null) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
switch (definition.type) {
|
|
15
|
+
case 'string':
|
|
16
|
+
return typeof value === 'string';
|
|
17
|
+
case 'enum':
|
|
18
|
+
return !!definition.values?.includes(value);
|
|
19
|
+
case 'number':
|
|
20
|
+
return typeof value === 'number';
|
|
21
|
+
case 'boolean':
|
|
22
|
+
return typeof value === 'boolean';
|
|
23
|
+
case 'date':
|
|
24
|
+
return value instanceof Date;
|
|
25
|
+
case 'object':
|
|
26
|
+
return typeof value === 'object';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
7
30
|
export type FormFieldValidator<T = unknown> = (value: T) => string | string[] | undefined;
|
|
8
31
|
|
|
9
32
|
export const validators: Record<string, FormFieldValidator> = { ...builtInRules };
|
|
@@ -12,6 +35,14 @@ export function defineFormValidationRule<T>(rule: string, validator: FormFieldVa
|
|
|
12
35
|
validators[rule] = validator as FormFieldValidator;
|
|
13
36
|
}
|
|
14
37
|
|
|
38
|
+
export function validateType(value: unknown, definition: FormFieldDefinition): string[] {
|
|
39
|
+
if (isValidType(value, definition)) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return ['invalid_value'];
|
|
44
|
+
}
|
|
45
|
+
|
|
15
46
|
export function validate(value: unknown, rule: string): string[] {
|
|
16
47
|
const errors = validators[rule]?.(value);
|
|
17
48
|
|