@aerogel/core 0.0.0-next.6c539d8e63b397d4bb6c3d61a7f20a4d108b1cdd → 0.0.0-next.7035064d9ec6a82a936ee8dfcc4b58ed2e25a399

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/aerogel-core.cjs.js +1 -1
  2. package/dist/aerogel-core.cjs.js.map +1 -1
  3. package/dist/aerogel-core.d.ts +328 -72
  4. package/dist/aerogel-core.esm.js +1 -1
  5. package/dist/aerogel-core.esm.js.map +1 -1
  6. package/package.json +2 -2
  7. package/src/bootstrap/index.ts +12 -2
  8. package/src/components/headless/modals/AGHeadlessModal.ts +3 -1
  9. package/src/components/headless/modals/AGHeadlessModal.vue +10 -4
  10. package/src/components/headless/modals/AGHeadlessModalPanel.vue +10 -6
  11. package/src/components/headless/modals/AGHeadlessModalTitle.vue +14 -4
  12. package/src/components/lib/AGMarkdown.vue +14 -1
  13. package/src/components/lib/AGProgressBar.vue +30 -0
  14. package/src/components/lib/index.ts +1 -0
  15. package/src/components/modals/AGAlertModal.ts +5 -2
  16. package/src/components/modals/AGConfirmModal.ts +13 -5
  17. package/src/components/modals/AGConfirmModal.vue +1 -1
  18. package/src/components/modals/AGErrorReportModal.ts +5 -2
  19. package/src/components/modals/AGLoadingModal.ts +10 -4
  20. package/src/components/modals/AGModal.ts +1 -0
  21. package/src/components/modals/AGModalContext.vue +14 -4
  22. package/src/components/modals/AGPromptModal.ts +9 -4
  23. package/src/errors/JobCancelledError.ts +3 -0
  24. package/src/errors/utils.ts +16 -0
  25. package/src/forms/Form.ts +10 -3
  26. package/src/forms/index.ts +2 -1
  27. package/src/forms/utils.ts +20 -4
  28. package/src/forms/validation.ts +19 -0
  29. package/src/jobs/Job.ts +144 -2
  30. package/src/jobs/index.ts +4 -1
  31. package/src/jobs/listeners.ts +3 -0
  32. package/src/jobs/status.ts +4 -0
  33. package/src/services/App.state.ts +9 -1
  34. package/src/services/App.ts +5 -0
  35. package/src/services/Events.ts +13 -3
  36. package/src/services/Service.ts +107 -44
  37. package/src/services/Storage.ts +20 -0
  38. package/src/services/index.ts +7 -2
  39. package/src/services/utils.ts +18 -0
  40. package/src/testing/setup.ts +11 -3
  41. package/src/ui/UI.ts +108 -38
  42. package/src/utils/composition/persistent.test.ts +33 -0
  43. package/src/utils/composition/persistent.ts +11 -0
  44. package/src/utils/composition/state.test.ts +47 -0
  45. package/src/utils/composition/state.ts +24 -0
  46. package/src/utils/index.ts +1 -0
  47. package/src/utils/markdown.test.ts +50 -0
  48. package/src/utils/markdown.ts +17 -2
  49. package/src/utils/vue.ts +4 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aerogel/core",
3
3
  "description": "The Lightest Solid",
4
- "version": "0.0.0-next.6c539d8e63b397d4bb6c3d61a7f20a4d108b1cdd",
4
+ "version": "0.0.0-next.7035064d9ec6a82a936ee8dfcc4b58ed2e25a399",
5
5
  "main": "dist/aerogel-core.cjs.js",
6
6
  "module": "dist/aerogel-core.esm.js",
7
7
  "types": "dist/aerogel-core.d.ts",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@headlessui/vue": "^1.7.14",
38
- "@noeldemartin/utils": "0.5.1-next.4fd89de2cbde6c7e1cfa4d5f9bdac234e9cd3d98",
38
+ "@noeldemartin/utils": "0.6.0-next.036a147180df61c600c4599df30816ac860dbf06",
39
39
  "dompurify": "^3.0.3",
40
40
  "marked": "^5.0.4",
41
41
  "pinia": "^2.1.6",
@@ -1,6 +1,9 @@
1
+ import Aerogel from 'virtual:aerogel';
2
+
1
3
  import { createApp } from 'vue';
2
- import type { App, Component } from 'vue';
4
+ import type { App as AppInstance, Component } from 'vue';
3
5
 
6
+ import App from '@/services/App';
4
7
  import directives from '@/directives';
5
8
  import errors from '@/errors';
6
9
  import Events from '@/services/Events';
@@ -13,9 +16,11 @@ import type { AerogelOptions } from '@/bootstrap/options';
13
16
 
14
17
  export { AerogelOptions };
15
18
 
16
- export async function bootstrapApplication(app: App, options: AerogelOptions = {}): Promise<void> {
19
+ export async function bootstrapApplication(app: AppInstance, options: AerogelOptions = {}): Promise<void> {
17
20
  const plugins = [testing, directives, errors, lang, services, ui, ...(options.plugins ?? [])];
18
21
 
22
+ App.instance = app;
23
+
19
24
  await installPlugins(plugins, app, options);
20
25
  await options.install?.(app);
21
26
  await Events.emit('application-ready');
@@ -24,6 +29,11 @@ export async function bootstrapApplication(app: App, options: AerogelOptions = {
24
29
  export async function bootstrap(rootComponent: Component, options: AerogelOptions = {}): Promise<void> {
25
30
  const app = createApp(rootComponent);
26
31
 
32
+ if (Aerogel.environment === 'development') {
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ (window as any).$aerogel = app;
35
+ }
36
+
27
37
  await bootstrapApplication(app, options);
28
38
 
29
39
  app.mount('#app');
@@ -13,6 +13,7 @@ export interface IAGHeadlessModalDefaultSlotProps {
13
13
 
14
14
  export const modalProps = {
15
15
  cancellable: booleanProp(true),
16
+ inline: booleanProp(),
16
17
  title: stringProp(),
17
18
  };
18
19
 
@@ -28,7 +29,8 @@ export function extractModalProps<T extends ExtractPropTypes<typeof modalProps>>
28
29
 
29
30
  export function useModalExpose($modal: Ref<IAGHeadlessModal | undefined>): IAGModal {
30
31
  return {
31
- close: async () => $modal.value?.close(),
32
+ inline: computed(() => !!$modal.value?.inline),
32
33
  cancellable: computed(() => !!$modal.value?.cancellable),
34
+ close: async () => $modal.value?.close(),
33
35
  };
34
36
  }
@@ -1,11 +1,16 @@
1
1
  <template>
2
- <Dialog ref="$root" :open="true" @close="cancellable && close()">
2
+ <component
3
+ :is="rootComponent"
4
+ ref="$root"
5
+ :open="true"
6
+ @close="cancellable && close()"
7
+ >
3
8
  <slot :close="close" />
4
- </Dialog>
9
+ </component>
5
10
  </template>
6
11
 
7
12
  <script setup lang="ts">
8
- import { ref, toRef } from 'vue';
13
+ import { computed, ref, toRef } from 'vue';
9
14
  import { Dialog } from '@headlessui/vue';
10
15
  import type { VNode } from 'vue';
11
16
 
@@ -26,6 +31,7 @@ const { modal } = injectReactiveOrFail<IAGModalContext>(
26
31
  'could not obtain modal reference from <AGHeadlessModal>, ' +
27
32
  'did you render this component manually? Show it using $ui.openModal() instead',
28
33
  );
34
+ const rootComponent = computed(() => (modal.properties.inline ? 'div' : Dialog));
29
35
 
30
36
  async function hide(): Promise<void> {
31
37
  if (!$root.value?.$el) {
@@ -82,5 +88,5 @@ useEvent('show-modal', async ({ id }) => {
82
88
  });
83
89
 
84
90
  defineSlots<{ default(props: IAGHeadlessModalDefaultSlotProps): VNode[] }>();
85
- defineExpose<IAGHeadlessModal>({ close, cancellable: toRef(props, 'cancellable') });
91
+ defineExpose<IAGHeadlessModal>({ close, cancellable: toRef(props, 'cancellable'), inline: toRef(props, 'inline') });
86
92
  </script>
@@ -1,12 +1,15 @@
1
1
  <template>
2
- <DialogPanel>
2
+ <component :is="rootComponent">
3
3
  <slot />
4
4
 
5
5
  <template v-if="childModal">
6
- <div class="pointer-events-none fixed inset-0 z-50 bg-black/30" />
7
- <AGModalContext :child-index="modal.childIndex + 1" :modal="childModal" />
6
+ <div
7
+ class="pointer-events-none inset-0 z-50 bg-black/30"
8
+ :class="childModal.properties.inline ? 'absolute' : 'fixed'"
9
+ />
10
+ <AGModalContext :child-index="childIndex + 1" :modal="childModal" />
8
11
  </template>
9
- </DialogPanel>
12
+ </component>
10
13
  </template>
11
14
 
12
15
  <script setup lang="ts">
@@ -19,10 +22,11 @@ import type { IAGModalContext } from '@/components/modals/AGModalContext';
19
22
 
20
23
  import AGModalContext from '../../modals/AGModalContext.vue';
21
24
 
22
- const modal = injectReactiveOrFail<IAGModalContext>(
25
+ const { modal, childIndex } = injectReactiveOrFail<IAGModalContext>(
23
26
  'modal',
24
27
  'could not obtain modal reference from <AGHeadlessModalPanel>, ' +
25
28
  'did you render this component manually? Show it using $ui.openModal() instead',
26
29
  );
27
- const childModal = computed(() => UI.modals[modal.childIndex] ?? null);
30
+ const rootComponent = computed(() => (modal.properties.inline ? 'div' : DialogPanel));
31
+ const childModal = computed(() => UI.modals[childIndex] ?? null);
28
32
  </script>
@@ -1,13 +1,23 @@
1
1
  <template>
2
- <DialogTitle :as="as">
2
+ <component :is="rootComponent" v-bind="rootProps">
3
3
  <slot />
4
- </DialogTitle>
4
+ </component>
5
5
  </template>
6
6
 
7
7
  <script setup lang="ts">
8
+ import { computed } from 'vue';
8
9
  import { DialogTitle } from '@headlessui/vue';
9
10
 
10
- import { stringProp } from '@/utils/vue';
11
+ import { injectReactiveOrFail, stringProp } from '@/utils/vue';
12
+ import type { IAGModalContext } from '@/components/modals/AGModalContext';
11
13
 
12
- defineProps({ as: stringProp('h2') });
14
+ const props = defineProps({ as: stringProp('h2') });
15
+
16
+ const { modal } = injectReactiveOrFail<IAGModalContext>(
17
+ 'modal',
18
+ 'could not obtain modal reference from <AGHeadlessModalPanel>, ' +
19
+ 'did you render this component manually? Show it using $ui.openModal() instead',
20
+ );
21
+ const rootComponent = computed(() => (modal.properties.inline ? 'div' : DialogTitle));
22
+ const rootProps = computed(() => (modal.properties.inline ? {} : { as: props.as }));
13
23
  </script>
@@ -4,9 +4,10 @@
4
4
 
5
5
  <script setup lang="ts">
6
6
  import { computed, h, useAttrs } from 'vue';
7
+ import { isInstanceOf } from '@noeldemartin/utils';
7
8
 
8
9
  import { renderMarkdown } from '@/utils/markdown';
9
- import { booleanProp, mixedProp, stringProp } from '@/utils/vue';
10
+ import { booleanProp, mixedProp, objectProp, stringProp } from '@/utils/vue';
10
11
  import { translate } from '@/lang';
11
12
 
12
13
  const props = defineProps({
@@ -15,6 +16,7 @@ const props = defineProps({
15
16
  langKey: stringProp(),
16
17
  langParams: mixedProp<number | Record<string, unknown>>(),
17
18
  text: stringProp(),
19
+ actions: objectProp<Record<string, () => unknown>>(),
18
20
  });
19
21
 
20
22
  const attrs = useAttrs();
@@ -35,7 +37,18 @@ const html = computed(() => {
35
37
  const root = () =>
36
38
  h(props.as ?? (props.inline ? 'span' : 'div'), {
37
39
  innerHTML: html.value,
40
+ onClick,
38
41
  ...attrs,
39
42
  class: `${attrs.class ?? ''} ${props.inline ? '' : 'prose'}`,
40
43
  });
44
+
45
+ async function onClick(event: Event) {
46
+ const { target } = event;
47
+
48
+ if (isInstanceOf(target, HTMLElement) && target.dataset.markdownAction) {
49
+ props.actions?.[target.dataset.markdownAction]?.();
50
+
51
+ return;
52
+ }
53
+ }
41
54
  </script>
@@ -0,0 +1,30 @@
1
+ <template>
2
+ <div class="mt-1 h-2 w-full min-w-[min(400px,80vw)] overflow-hidden rounded-full bg-gray-200">
3
+ <div :class="barClasses" :style="`transform:translateX(-${(1 - progress) * 100}%)`" />
4
+ <span class="sr-only">
5
+ {{
6
+ $td('ui.progress', '{progress}% complete', {
7
+ progress: progress * 100,
8
+ })
9
+ }}
10
+ </span>
11
+ </div>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ import { computed } from 'vue';
16
+
17
+ import { requiredNumberProp, stringProp } from '@/utils/vue';
18
+
19
+ const props = defineProps({
20
+ progress: requiredNumberProp(),
21
+ class: stringProp(''),
22
+ });
23
+ const barClasses = computed(() => {
24
+ const classes = props.class ?? '';
25
+
26
+ return `h-full w-full transition-transform duration-500 ease-linear ${
27
+ classes.includes('bg-') ? classes : `${classes} bg-gray-700`
28
+ }`;
29
+ });
30
+ </script>
@@ -2,4 +2,5 @@ export { default as AGErrorMessage } from './AGErrorMessage.vue';
2
2
  export { default as AGLink } from './AGLink.vue';
3
3
  export { default as AGMarkdown } from './AGMarkdown.vue';
4
4
  export { default as AGMeasured } from './AGMeasured.vue';
5
+ export { default as AGProgressBar } from './AGProgressBar.vue';
5
6
  export { default as AGStartupCrash } from './AGStartupCrash.vue';
@@ -1,14 +1,17 @@
1
1
  import type { ExtractPropTypes } from 'vue';
2
- import type { ObjectWithoutEmpty } from '@noeldemartin/utils';
2
+ import type { ObjectWithout, Pretty } from '@noeldemartin/utils';
3
3
 
4
4
  import { requiredStringProp, stringProp } from '@/utils';
5
+ import type { AcceptRefs } from '@/utils';
5
6
 
6
7
  export const alertModalProps = {
7
8
  title: stringProp(),
8
9
  message: requiredStringProp(),
9
10
  };
10
11
 
11
- export type AGAlertModalProps = ObjectWithoutEmpty<ExtractPropTypes<typeof alertModalProps>>;
12
+ export type AGAlertModalProps = Pretty<
13
+ AcceptRefs<ObjectWithout<ExtractPropTypes<typeof alertModalProps>, null | undefined>>
14
+ >;
12
15
 
13
16
  export function useAlertModalProps(): typeof alertModalProps {
14
17
  return alertModalProps;
@@ -1,10 +1,12 @@
1
1
  import { computed } from 'vue';
2
2
  import type { ExtractPropTypes } from 'vue';
3
- import type { ObjectWithoutEmpty, SubPartial } from '@noeldemartin/utils';
3
+ import type { ObjectWithout, Pretty, SubPartial } from '@noeldemartin/utils';
4
4
 
5
5
  import { Colors } from '@/components/constants';
6
- import { enumProp, requiredStringProp, stringProp } from '@/utils';
6
+ import { enumProp, objectProp, requiredStringProp, stringProp } from '@/utils';
7
7
  import { translateWithDefault } from '@/lang';
8
+ import type { AcceptRefs } from '@/utils';
9
+ import type { ConfirmCheckboxes } from '@/ui';
8
10
 
9
11
  export const confirmModalProps = {
10
12
  title: stringProp(),
@@ -13,11 +15,17 @@ export const confirmModalProps = {
13
15
  acceptColor: enumProp(Colors, Colors.Primary),
14
16
  cancelText: stringProp(),
15
17
  cancelColor: enumProp(Colors, Colors.Clear),
18
+ checkboxes: objectProp<ConfirmCheckboxes>(),
19
+ actions: objectProp<Record<string, () => unknown>>(),
16
20
  };
17
21
 
18
- export type AGConfirmModalProps = SubPartial<
19
- ObjectWithoutEmpty<ExtractPropTypes<typeof confirmModalProps>>,
20
- 'acceptColor' | 'cancelColor'
22
+ export type AGConfirmModalProps = Pretty<
23
+ AcceptRefs<
24
+ SubPartial<
25
+ ObjectWithout<ExtractPropTypes<typeof confirmModalProps>, null | undefined>,
26
+ 'acceptColor' | 'cancelColor'
27
+ >
28
+ >
21
29
  >;
22
30
 
23
31
  export function useConfirmModalProps(): typeof confirmModalProps {
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <AGModal v-slot="{ close }: IAGModalDefaultSlotProps" :cancellable="false" :title="title">
3
- <AGMarkdown :text="message" />
3
+ <AGMarkdown :text="message" :actions="actions" />
4
4
 
5
5
  <div class="mt-2 flex flex-row-reverse gap-2">
6
6
  <AGButton :color="acceptColor" @click="close(true)">
@@ -1,9 +1,10 @@
1
1
  import { computed, ref } from 'vue';
2
2
  import type { Component, ExtractPropTypes } from 'vue';
3
- import type { ObjectWithoutEmpty } from '@noeldemartin/utils';
3
+ import type { ObjectWithout, Pretty } from '@noeldemartin/utils';
4
4
 
5
5
  import { requiredArrayProp } from '@/utils/vue';
6
6
  import { translateWithDefault } from '@/lang';
7
+ import type { AcceptRefs } from '@/utils/vue';
7
8
  import type { ErrorReport } from '@/errors';
8
9
 
9
10
  export interface IAGErrorReportModalButtonsDefaultSlotProps {
@@ -18,7 +19,9 @@ export const errorReportModalProps = {
18
19
  reports: requiredArrayProp<ErrorReport>(),
19
20
  };
20
21
 
21
- export type AGErrorReportModalProps = ObjectWithoutEmpty<ExtractPropTypes<typeof errorReportModalProps>>;
22
+ export type AGErrorReportModalProps = Pretty<
23
+ AcceptRefs<ObjectWithout<ExtractPropTypes<typeof errorReportModalProps>, null | undefined>>
24
+ >;
22
25
 
23
26
  export function useErrorReportModalProps(): typeof errorReportModalProps {
24
27
  return errorReportModalProps;
@@ -1,15 +1,20 @@
1
1
  import { computed } from 'vue';
2
2
  import type { ExtractPropTypes } from 'vue';
3
- import type { ObjectWithoutEmpty } from '@noeldemartin/utils';
3
+ import type { ObjectWithout } from '@noeldemartin/utils';
4
4
 
5
- import { stringProp } from '@/utils';
5
+ import { numberProp, stringProp } from '@/utils';
6
6
  import { translateWithDefault } from '@/lang';
7
+ import type { AcceptRefs } from '@/utils';
7
8
 
8
9
  export const loadingModalProps = {
10
+ title: stringProp(),
9
11
  message: stringProp(),
12
+ progress: numberProp(),
10
13
  };
11
14
 
12
- export type AGLoadingModalProps = ObjectWithoutEmpty<ExtractPropTypes<typeof loadingModalProps>>;
15
+ export type AGLoadingModalProps = AcceptRefs<
16
+ ObjectWithout<ExtractPropTypes<typeof loadingModalProps>, null | undefined>
17
+ >;
13
18
 
14
19
  export function useLoadingModalProps(): typeof loadingModalProps {
15
20
  return loadingModalProps;
@@ -18,6 +23,7 @@ export function useLoadingModalProps(): typeof loadingModalProps {
18
23
  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
19
24
  export function useLoadingModal(props: ExtractPropTypes<typeof loadingModalProps>) {
20
25
  const renderedMessage = computed(() => props.message ?? translateWithDefault('ui.loading', 'Loading...'));
26
+ const showProgress = computed(() => typeof props.progress === 'number');
21
27
 
22
- return { renderedMessage };
28
+ return { renderedMessage, showProgress };
23
29
  }
@@ -1,6 +1,7 @@
1
1
  import type { Ref } from 'vue';
2
2
 
3
3
  export interface IAGModal {
4
+ inline: Ref<boolean>;
4
5
  cancellable: Ref<boolean>;
5
6
  close(result?: unknown): Promise<void>;
6
7
  }
@@ -1,18 +1,28 @@
1
1
  <template>
2
- <component :is="modal.component" v-bind="modal.properties" />
2
+ <component :is="modal.component" v-bind="modalProperties" />
3
3
  </template>
4
4
 
5
5
  <script setup lang="ts">
6
- import { provide, toRef } from 'vue';
6
+ import { computed, provide, toRef, unref } from 'vue';
7
7
 
8
- import { requiredNumberProp, requiredObjectProp } from '@/utils/vue';
8
+ import { numberProp, requiredObjectProp } from '@/utils/vue';
9
9
  import type { Modal } from '@/ui/UI.state';
10
10
 
11
11
  import type { IAGModalContext } from './AGModalContext';
12
12
 
13
13
  const props = defineProps({
14
14
  modal: requiredObjectProp<Modal>(),
15
- childIndex: requiredNumberProp(),
15
+ childIndex: numberProp(0),
16
+ });
17
+
18
+ const modalProperties = computed(() => {
19
+ const properties = {} as typeof props.modal.properties;
20
+
21
+ for (const property in props.modal.properties) {
22
+ properties[property] = unref(props.modal.properties[property]);
23
+ }
24
+
25
+ return properties;
16
26
  });
17
27
 
18
28
  provide<IAGModalContext>('modal', {
@@ -1,10 +1,11 @@
1
1
  import { computed } from 'vue';
2
2
  import type { ExtractPropTypes } from 'vue';
3
- import type { ObjectWithoutEmpty, SubPartial } from '@noeldemartin/utils';
3
+ import type { ObjectWithout, Pretty, SubPartial } from '@noeldemartin/utils';
4
4
 
5
5
  import { Colors } from '@/components/constants';
6
6
  import { enumProp, requiredStringProp, stringProp } from '@/utils';
7
7
  import { translateWithDefault } from '@/lang';
8
+ import type { AcceptRefs } from '@/utils';
8
9
 
9
10
  export const promptModalProps = {
10
11
  title: stringProp(),
@@ -18,9 +19,13 @@ export const promptModalProps = {
18
19
  cancelColor: enumProp(Colors, Colors.Clear),
19
20
  };
20
21
 
21
- export type AGPromptModalProps = SubPartial<
22
- ObjectWithoutEmpty<ExtractPropTypes<typeof promptModalProps>>,
23
- 'acceptColor' | 'cancelColor'
22
+ export type AGPromptModalProps = Pretty<
23
+ AcceptRefs<
24
+ SubPartial<
25
+ ObjectWithout<ExtractPropTypes<typeof promptModalProps>, null | undefined>,
26
+ 'acceptColor' | 'cancelColor'
27
+ >
28
+ >
24
29
  >;
25
30
 
26
31
  export function usePromptModalProps(): typeof promptModalProps {
@@ -0,0 +1,3 @@
1
+ import { JSError } from '@noeldemartin/utils';
2
+
3
+ export default class JobCancelledError extends JSError {}
@@ -2,7 +2,23 @@ import { JSError, isObject, toString } from '@noeldemartin/utils';
2
2
  import { translateWithDefault } from '@/lang/utils';
3
3
  import type { ErrorSource } from './Errors.state';
4
4
 
5
+ const handlers: ErrorHandler[] = [];
6
+
7
+ export type ErrorHandler = (error: ErrorSource) => string | undefined;
8
+
9
+ export function registerErrorHandler(handler: ErrorHandler): void {
10
+ handlers.push(handler);
11
+ }
12
+
5
13
  export function getErrorMessage(error: ErrorSource): string {
14
+ for (const handler of handlers) {
15
+ const result = handler(error);
16
+
17
+ if (result) {
18
+ return result;
19
+ }
20
+ }
21
+
6
22
  if (typeof error === 'string') {
7
23
  return error;
8
24
  }
package/src/forms/Form.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { MagicObject, arrayRemove, fail, toString } from '@noeldemartin/utils';
2
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
 
@@ -184,9 +185,15 @@ export default class Form<Fields extends FormFieldDefinitions = FormFieldDefinit
184
185
 
185
186
  private getFieldErrors(name: keyof Fields, definition: FormFieldDefinition): string[] | null {
186
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
+ }
187
195
 
188
- if (definition.rules?.includes('required') && !this._data[name]) {
189
- errors.push('required');
196
+ errors.push(...validate(value, rule));
190
197
  }
191
198
 
192
199
  return errors.length > 0 ? errors : null;
@@ -1,4 +1,5 @@
1
- export * from './Form';
2
1
  export * from './composition';
2
+ export * from './Form';
3
3
  export * from './utils';
4
+ export * from './validation';
4
5
  export { default as Form } from './Form';
@@ -1,17 +1,25 @@
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> {
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,
8
12
  };
9
13
  }
10
14
 
11
- export function dateInput(defaultValue?: Date): FormFieldDefinition<typeof FormFieldTypes.Date> {
15
+ export function dateInput(
16
+ defaultValue?: Date,
17
+ options: { rules?: string } = {},
18
+ ): FormFieldDefinition<typeof FormFieldTypes.Date> {
12
19
  return {
13
20
  default: defaultValue,
14
21
  type: FormFieldTypes.Date,
22
+ rules: options.rules,
15
23
  };
16
24
  }
17
25
 
@@ -53,16 +61,24 @@ export function requiredStringInput(
53
61
  };
54
62
  }
55
63
 
56
- export function numberInput(defaultValue?: number): FormFieldDefinition<typeof FormFieldTypes.Number> {
64
+ export function numberInput(
65
+ defaultValue?: number,
66
+ options: { rules?: string } = {},
67
+ ): FormFieldDefinition<typeof FormFieldTypes.Number> {
57
68
  return {
58
69
  default: defaultValue,
59
70
  type: FormFieldTypes.Number,
71
+ rules: options.rules,
60
72
  };
61
73
  }
62
74
 
63
- export function stringInput(defaultValue?: string): FormFieldDefinition<typeof FormFieldTypes.String> {
75
+ export function stringInput(
76
+ defaultValue?: string,
77
+ options: { rules?: string } = {},
78
+ ): FormFieldDefinition<typeof FormFieldTypes.String> {
64
79
  return {
65
80
  default: defaultValue,
66
81
  type: FormFieldTypes.String,
82
+ rules: options.rules,
67
83
  };
68
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
+ }