Skip to content

Commit

Permalink
implement better input validation with validation scopes
Browse files Browse the repository at this point in the history
  • Loading branch information
joshuaboud committed Jun 7, 2024
1 parent 95e4957 commit b555d97
Show file tree
Hide file tree
Showing 8 changed files with 219 additions and 90 deletions.
54 changes: 0 additions & 54 deletions houston-common-ui/lib/components/InputFeedback.vue

This file was deleted.

34 changes: 1 addition & 33 deletions houston-common-ui/lib/components/InputField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,15 @@ import {
defineProps,
defineModel,
defineEmits,
computed,
defineExpose,
ref,
watchEffect,
onMounted,
} from "vue";
import InputFeedback from "@/components/InputFeedback.vue";
import { v4 as uuidv4 } from "uuid";
import { type Feedback } from "@/components/InputFeedback.vue";
export type InputValidator = (
value: string
) => Feedback | undefined | PromiseLike<Feedback | undefined>;
const [model, modifiers] = defineModel<string>({ default: "" });
const props = withDefaults(
defineProps<{
type?: HTMLInputElement["type"];
placeholder?: string;
validator?: InputValidator;
disabled?: boolean;
suggestions?: string[];
}>(),
Expand Down Expand Up @@ -56,19 +44,7 @@ const onChange = ({ target }: Event) => {
emit("change", model.value);
};
const feedback = ref<Feedback>();
const updateFeedback = async () =>
(feedback.value = await props.validator?.(model.value));
watchEffect(updateFeedback);
const suggestionListId = ref<string>();
onMounted(() => (suggestionListId.value = uuidv4()));
const valid = computed<boolean>(() => !(feedback.value?.type === "error"));
defineExpose({
valid,
});
const suggestionListId = uuidv4();
</script>

<template>
Expand All @@ -87,14 +63,6 @@ defineExpose({
<datalist v-if="suggestions" :id="suggestionListId">
<option v-for="suggestion in suggestions" :value="suggestion"></option>
</datalist>
<InputFeedback
v-if="feedback"
:type="feedback.type"
:actions="feedback.actions"
@feedbackAction="updateFeedback"
>
{{ feedback.message }}
</InputFeedback>
</template>

<style scoped>
Expand Down
52 changes: 52 additions & 0 deletions houston-common-ui/lib/components/ValidationResultView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { defineProps } from "vue";
import {
ExclamationCircleIcon,
ExclamationTriangleIcon,
} from "@heroicons/vue/20/solid";
import { type ValidationResult } from "@/composables/validation";
const props = defineProps<ValidationResult>();
</script>

<template>
<div
class="feedback-group text-feedback"
:class="
type === 'error'
? 'text-error'
: type === 'warning'
? 'text-warning'
: 'text-primary'
"
>
<ExclamationCircleIcon
v-if="type === 'error'"
class="size-icon icon-error"
/>
<ExclamationTriangleIcon
v-else-if="type === 'warning'"
class="size-icon icon-warning"
/>
<span v-if="message">
{{ message }}
</span>
<template v-if="actions !== undefined">
<template v-for="(action, index) in actions" :key="action.label">
<span v-if="actions.length > 1 && index === actions.length - 1"
>or</span
>
<span>
<button @click="() => action.callback()" class="underline">
{{ action.label }}
</button>
{{ actions.length > 1 && index < actions.length - 1 ? "," : "" }}
</span>
</template>
</template>
</div>
</template>

<style scoped>
@import "@45drives/houston-common-css/src/index.css";
</style>
3 changes: 1 addition & 2 deletions houston-common-ui/lib/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ export { default as HoustonAppContainer } from "./HoustonAppContainer.vue";
export { default as CardContainer } from "./CardContainer.vue";
export { default as CenteredCardColumn } from "./CenteredCardColumn.vue";
export { default as ParsedTextArea } from "./ParsedTextArea.vue";
export * from "./InputFeedback.vue";
export { default as InputFeedback } from "./InputFeedback.vue";
export * from "./InputField.vue";
export { default as InputField } from "./InputField.vue";
export { default as ToggleSwitch } from "./ToggleSwitch.vue";
Expand All @@ -24,6 +22,7 @@ export { default as UserSelector } from "./UserSelector.vue";
export { default as GroupSelector } from "./GroupSelector.vue";
export { default as ModeAndPermissionsEditor } from "./ModeAndPermissionsEditor.vue";
export { default as FileUploadButton } from "./FileUploadButton.vue";
export { default as ValidationResultView } from "./ValidationResultView.vue";

export * from "./tabs";
export * from "./modals";
1 change: 1 addition & 0 deletions houston-common-ui/lib/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export {
assertConfirm,
} from "./globalModalConfirm";
export * from "./computedResult";
export * from "./validation";
143 changes: 143 additions & 0 deletions houston-common-ui/lib/composables/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { z } from "zod";
import { fromError as ZodValidationErrorFromError } from "zod-validation-error";
import {
onBeforeMount,
onBeforeUnmount,
onMounted,
onUnmounted,
ref,
computed,
type Ref,
watchEffect,
} from "vue";

export type ValidationResultAction = {
label: string;
callback: () => void | PromiseLike<void>;
};

export type ValidationResult = (
| {
type: "success";
message?: string;
}
| {
type: "error";
message: string;
}
| {
type: "warning";
message: string;
}
) & {
actions?: ValidationResultAction[];
};

export type Validator = () => ValidationResult | PromiseLike<ValidationResult>;

export type ValidationScope = Ref<Ref<ValidationResult>[]>;

const validationScopeStack = ref<ValidationScope[]>([ref([])]);

const pushValidationScope = (scope: ValidationScope) => {
validationScopeStack.value = [...validationScopeStack.value, scope];
};
const removeValidationScope = (scope: ValidationScope) => {
validationScopeStack.value = validationScopeStack.value.filter(
(s) => s !== scope
);
};

const getCurrentValidationScope = () =>
validationScopeStack.value[validationScopeStack.value.length - 1];

export function useValidationScope() {
const scope: ValidationScope = ref([]);
onBeforeMount(() => {
pushValidationScope(scope);
});
onUnmounted(() => {
removeValidationScope(scope);
});
const scopeValid = computed<boolean>(() =>
scope.value.every((v) => v.value.type !== "error")
);
return { scope, scopeValid };
}

export function useValidator(validator: Validator, scope?: ValidationScope) {
const validationResult = ref<ValidationResult>({
type: "success",
});
const triggerUpdate = () => {
const result = validator();
Promise.resolve(result).then(
(result) =>
(validationResult.value = {
...result,
actions: result.actions?.map(({ label, callback }) => ({
label,
callback: () =>
Promise.resolve(callback()).then(() => triggerUpdate()),
})),
})
);
};
watchEffect(triggerUpdate);
onMounted(() => {
scope ??= getCurrentValidationScope();
scope.value = [...scope.value, validationResult];
});
onBeforeUnmount(() => {
scope ??= getCurrentValidationScope();
scope.value = scope.value.filter((r) => r !== validationResult);
});
return { validationResult, triggerUpdate };
}

export function useZodValidator<Z extends z.ZodTypeAny = z.ZodNever>(
schema: Z,
getter: () => z.infer<Z>,
scope?: ValidationScope
) {
const validator: Validator = () =>
schema
.safeParseAsync(getter())
.then((result) =>
result.success
? validationSuccess()
: validationError(ZodValidationErrorFromError(result.error).message)
);
return useValidator(validator, scope);
}

export function validationSuccess(
actions?: ValidationResultAction[]
): ValidationResult {
return {
type: "success",
actions,
};
}

export function validationWarning(
message: string,
actions?: ValidationResultAction[]
): ValidationResult {
return {
type: "warning",
message,
actions,
};
}

export function validationError(
message: string,
actions?: ValidationResultAction[]
): ValidationResult {
return {
type: "error",
message,
actions,
};
}
4 changes: 3 additions & 1 deletion houston-common-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
"deep-equal": "^2.2.3",
"neverthrow": "^6.2.1",
"uuid": "^9.0.1",
"vue": "^3.4.27"
"vue": "^3.4.27",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
},
"devDependencies": {
"@fontsource/red-hat-text": "^5.0.18",
Expand Down
18 changes: 18 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ __metadata:
vitest: "npm:^1.6.0"
vue: "npm:^3.4.27"
vue-tsc: "npm:^2.0.19"
zod: "npm:^3.23.8"
zod-validation-error: "npm:^3.3.0"
languageName: unknown
linkType: soft

Expand Down Expand Up @@ -6813,3 +6815,19 @@ __metadata:
checksum: 10/8ac2fa445f5a00e790d1f91a48aeff0ccfc340f84626771853e03f4d97cdc2f5f798cdb2e38418f7815ffc3aac3952c45caabcf077bf4f83fedf0cdef43b885b
languageName: node
linkType: hard

"zod-validation-error@npm:^3.3.0":
version: 3.3.0
resolution: "zod-validation-error@npm:3.3.0"
peerDependencies:
zod: ^3.18.0
checksum: 10/19574cbc453c7a41105de572546e95191958f459dd93440f541a42c0ff209b56f1cd54e8f8ab1899430dd7c183e11cd16e8cace0bd4fc5d356ef772645210792
languageName: node
linkType: hard

"zod@npm:^3.23.8":
version: 3.23.8
resolution: "zod@npm:3.23.8"
checksum: 10/846fd73e1af0def79c19d510ea9e4a795544a67d5b34b7e1c4d0425bf6bfd1c719446d94cdfa1721c1987d891321d61f779e8236fde517dc0e524aa851a6eff1
languageName: node
linkType: hard

0 comments on commit b555d97

Please sign in to comment.