DIY forms

This commit is contained in:
Evert Prants 2023-01-25 22:03:22 +02:00
parent 01fbae4fec
commit dfab6b8a18
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
10 changed files with 627 additions and 4 deletions

96
src/components/Modal.vue Normal file
View File

@ -0,0 +1,96 @@
<template>
<TransitionRoot appear :show="isOpen" as="template">
<Dialog as="div" @close="closeModal" class="relative z-10">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black bg-opacity-25" />
</TransitionChild>
<div class="fixed inset-0 overflow-y-auto">
<div
class="flex min-h-full items-center justify-center p-4 text-center"
>
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel
:class="`w-full ${maxWidth} transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all`"
>
<DialogTitle
as="h3"
class="text-lg font-bold leading-6 text-gray-900"
>
<div class="flex items-center justify-between">
<slot name="title" />
<button v-if="closeButton" @click="closeModal">
<XMarkIcon class="h-6 w-6" />
</button>
</div>
</DialogTitle>
<slot :closeModal="closeModal" />
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
TransitionRoot,
TransitionChild,
Dialog,
DialogPanel,
DialogTitle,
} from '@headlessui/vue';
import { XMarkIcon } from '@heroicons/vue/24/outline';
const isOpen = ref(false);
const props = withDefaults(
defineProps<{
closeButton?: boolean;
size?: 'sm' | 'md' | 'lg' | 'xl';
}>(),
{
closeButton: true,
size: 'sm',
}
);
function closeModal() {
isOpen.value = false;
}
function openModal() {
isOpen.value = true;
}
const maxWidth = computed(() => {
if (props.size === 'sm') return 'max-w-md';
if (props.size === 'md') return 'max-w-xl';
if (props.size === 'lg') return 'max-w-2xl';
return 'max-w-4xl';
});
defineExpose({
openModal,
closeModal,
isOpen,
});
</script>

View File

@ -0,0 +1,102 @@
<template>
<form @submit.prevent="onSubmit">
<div class="flex flex-col space-y-5">
<slot />
</div>
</form>
</template>
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/shared';
import { provide, ref, watch } from 'vue';
import deepUnref from '../../utils/deep-unref';
import { FormData, FormErrors, FormSubmit } from './form.types';
import { FormValidator } from './validator.types';
const props = withDefaults(
defineProps<{
modelValue?: FormData;
errors?: FormErrors;
validators?: FormValidator[];
}>(),
{
modelValue: () => ({}),
errors: () => ({}),
validators: () => [],
}
);
const isValid = ref(true);
const invalidFields = ref<string[]>([]);
const formData = ref<FormData>(props.modelValue);
const formErrors = ref<FormErrors>(props.errors);
const validateField = async (field: string) => {
let valid = true;
if (formErrors.value[field]) {
delete formErrors.value[field];
if (invalidFields.value.includes(field)) {
invalidFields.value = invalidFields.value.filter((x) => x !== field);
}
}
for (const validator of props.validators) {
if (validator.field !== field) continue;
for (const fn of validator.validators) {
const result = await fn(field, formData.value[field], formData.value);
if (!result.isValid) {
formErrors.value = {
...formErrors.value,
[field]: [...(formErrors.value[field] || []), result.message],
};
invalidFields.value.push(field);
valid = false;
}
}
}
if (!valid && isValid.value) {
isValid.value = false;
return;
}
isValid.value = !invalidFields.value.length;
};
const validateAll = async () => {
const fields = props.validators
.map((validator) => validator.field)
.filter((value, index, array) => array.indexOf(value) === index);
if (!fields.length) return;
return Promise.allSettled(fields.map((field) => validateField(field)));
};
const fieldChange = useDebounceFn(validateField, 300);
provide('formData', formData);
provide('formErrors', formErrors);
provide('fieldChange', fieldChange);
const emit = defineEmits<{
(e: 'submit', data: FormSubmit): void;
(e: 'update:modelValue', data: FormData): void;
(e: 'update:errors', errors: FormErrors): void;
(e: 'update:validity', validity: boolean): void;
}>();
watch(formData, (val) => emit('update:modelValue', val), { deep: true });
watch(formErrors, (val) => emit('update:errors', val));
watch(isValid, (val) => emit('update:validity', val));
const onSubmit = async () => {
await validateAll();
emit('submit', {
isValid: isValid.value,
formData: deepUnref(formData.value),
fieldErrors: deepUnref(formErrors.value),
});
};
defineExpose({
isValid,
});
</script>

View File

@ -0,0 +1,103 @@
<template>
<div class="flex flex-col space-y-1">
<label
:for="forId"
:class="[
'block text-sm font-medium transition-colors duration-200',
errors.length ? 'text-red-500' : 'text-gray-700',
]"
>{{ label }}</label
>
<input
:type="type"
:id="forId"
:name="name"
:value="value"
:class="inputClass"
:placeholder="placeholder"
@input="onInput($event)"
@change="onChange($event)"
@focus="onFocus"
@blur="onBlur"
/>
<slot />
<span
class="text-sm text-red-500"
v-for="error of errors"
aria-live="assertive"
>{{ error }}</span
>
</div>
</template>
<script setup lang="ts">
import { computed, Ref } from 'vue';
import { inject } from 'vue';
import { FormData, FormErrors } from './form.types';
const props = withDefaults(
defineProps<{
type?:
| 'text'
| 'number'
| 'password'
| 'color'
| 'email'
| 'checkbox'
| 'radio';
label: string;
name: string;
placeholder?: string;
}>(),
{
type: 'text',
}
);
const emit = defineEmits<{
(e: 'change', value: unknown): void;
(e: 'focus'): void;
(e: 'blur'): void;
}>();
const formData = inject<Ref<FormData>>('formData');
const formErrors = inject<Ref<FormErrors>>('formErrors');
const fieldChange = inject<(field: string) => void>('fieldChange');
const forId = computed(() => `form-${props.name}`);
const value = computed(() => formData?.value[props.name]);
const errors = computed(() => formErrors?.value[props.name] || []);
const inputClass = computed(() => {
return [
errors.value.length
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
`mt-1 block w-full rounded-md shadow-sm sm:text-sm transition-colors duration-200`,
];
});
const onInput = (ev: Event) => {
if (!formData) return;
const value = (ev.target as HTMLInputElement).value;
const cleanValue = props.type === 'number' ? parseFloat(value) : value;
formData.value[props.name] = cleanValue;
emit('change', cleanValue);
fieldChange?.(props.name);
};
const onChange = (ev: Event) => {
if (!formData) return;
if (props.type === 'checkbox' || props.type === 'radio') {
const cleanValue = (ev.target as HTMLInputElement).checked;
formData.value[props.name] = cleanValue;
emit('change', cleanValue);
fieldChange?.(props.name);
}
};
const onFocus = () => emit('focus');
const onBlur = () => {
emit('blur');
fieldChange?.(props.name);
};
</script>

View File

@ -0,0 +1,7 @@
export type FormData = Record<string, unknown>;
export type FormErrors = Record<string, string[]>;
export interface FormSubmit {
isValid: boolean;
formData: FormData;
fieldErrors: FormErrors;
}

View File

@ -0,0 +1,15 @@
export interface ValidState {
isValid: boolean;
message: string;
}
export type FormValidatorFn = (
field: string,
value: unknown,
formData: Record<string, unknown>
) => ValidState | Promise<ValidState>;
export interface FormValidator {
field: string;
validators: FormValidatorFn[];
}

View File

@ -0,0 +1,44 @@
import { FormValidatorFn } from './validator.types';
export function MinLength(length: number, message?: string): FormValidatorFn {
return (_: string, value: unknown) => {
let isValid = true;
if (value != null && typeof value === 'string' && value.length < length)
isValid = false;
return {
isValid,
message: message || `Must be at least ${length} characters long`,
};
};
}
export function MaxLength(length: number, message?: string): FormValidatorFn {
return (_: string, value: unknown) => {
let isValid = true;
if (value != null && typeof value === 'string' && value.length >= length)
isValid = false;
return {
isValid,
message: message || `Must be less than ${length} characters long`,
};
};
}
export function IsRequired(message?: string): FormValidatorFn {
return (_: string, value: unknown) => {
return {
isValid: !!value,
message: message || `Field is required`,
};
};
}
export function IsEmail(message?: string): FormValidatorFn {
return (_: string, value: unknown) => {
let isValid = value != null && /\S+@\S+\.\S+/.test(value as string);
return {
isValid,
message: message || `Must be a valid email address`,
};
};
}

View File

@ -0,0 +1,61 @@
<template>
<Modal ref="modalRef" size="md">
<template #title>
Create a new storage {{ set === true ? 'set' : '' }}
</template>
<template #default="{ closeModal }">
<Form @submit="onSubmit" v-model="data" :validators="validators">
<FormField name="displayName" label="Display Name" />
<FormField name="type" label="Type" />
<button type="submit">Submit</button>
</Form>
</template>
</Modal>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BuildingListItem } from '../../interfaces/building.interfaces';
import { RoomListItem } from '../../interfaces/room.interfaces';
import { StorageSetListItem } from '../../interfaces/storage.interfaces';
import { FormSubmit } from '../form/form.types';
import Form from '../form/Form.vue';
import FormField from '../form/FormField.vue';
import { IsRequired, MinLength } from '../form/validators';
import Modal from '../Modal.vue';
const modalRef = ref<InstanceType<typeof Modal>>();
const room = ref<RoomListItem>();
const building = ref<BuildingListItem>();
const set = ref<StorageSetListItem | boolean>();
const data = ref({
displayName: '',
});
const validators = ref([
{
field: 'displayName',
validators: [MinLength(3), IsRequired()],
},
{
field: 'type',
validators: [IsRequired()],
},
]);
defineExpose({
openModal: (
useBuilding: BuildingListItem,
useRoom: RoomListItem,
useSet?: StorageSetListItem | boolean
) => {
room.value = useRoom;
building.value = useBuilding;
set.value = useSet;
modalRef.value?.openModal();
},
});
const onSubmit = (value: FormSubmit) => console.log(value);
</script>

View File

@ -0,0 +1,129 @@
<template>
<div class="flex justify-between">
<div class="flex space-x-2">
<div
:class="[
imageClasses,
'relative mt-2 flex items-center justify-center overflow-hidden rounded-md bg-gray-100 ring-1 ring-black ring-opacity-5',
]"
>
<img
class="object-contain"
v-if="storedItem.item.image"
:src="storedItem.item.image"
:alt="storedItem.item.displayName"
/>
<CubeIcon :class="iconClasses" />
</div>
<div class="flex flex-col">
<div class="flex items-center space-x-1">
<span :class="`text-${size} font-bold`">{{
storedItem.item.displayName
}}</span>
<span :class="`mt-0.5 ${fontSize} text-gray-500`"
>· Added
{{
dateToLocaleString(storedItem.acquiredAt || storedItem.createdAt)
}}
by {{ storedItem.addedBy.name }}</span
>
</div>
<div
:class="`flex items-center space-x-1 ${fontSize} text-gray-500`"
title="Type"
>
<InformationCircleIcon class="h-4 w-4" />
<span>{{ storedItem.item.type }}</span>
<span v-if="storedItem.item.consumable">· Consumable</span>
<span v-if="storedItem.consumedAt"
>· Consumed at {{ dateToLocaleString(storedItem.consumedAt) }}</span
>
</div>
<div
:class="[
'flex items-center space-x-1',
fontSize,
expiresSoon ? 'font-bold text-red-600' : '',
hasExpired ? 'uppercase' : '',
]"
v-if="storedItem.expiresAt"
>
<ExclamationTriangleIcon class="h-4 w-4" />
<span
>{{ hasExpired ? 'Expired' : 'Expires' }} at
{{ dateToLocaleString(storedItem.expiresAt) }}</span
>
</div>
<div
:class="`flex items-center space-x-1 ${fontSize} text-gray-500`"
title="Barcode"
v-if="storedItem.item.barcode"
>
<QrCodeIcon class="h-4 w-4" />
<span>{{ storedItem.item.barcode }}</span>
</div>
</div>
</div>
<div class="flex flex-col items-end">
<slot name="actions"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import {
CubeIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
QrCodeIcon,
} from '@heroicons/vue/24/outline';
import { computed } from 'vue';
import { StoredItem } from '../../interfaces/storage.interfaces';
const props = defineProps<{
storedItem: StoredItem;
size?: 'sm' | 'md' | 'lg';
hideExtras?: boolean;
hideCost?: boolean;
}>();
const imageClasses = computed(() => {
if (props.size === 'sm') return 'h-8 w-8';
if (props.size === 'md') return 'h-12 w-12';
return 'h-16 w-16';
});
const iconClasses = computed(() => {
if (props.size === 'sm') return 'h-4 w-4';
if (props.size === 'md') return 'h-8 w-8';
return 'h-12 w-12';
});
const fontSize = computed(() => {
if (props.size === 'sm') return 'text-[0.75rem]';
return 'text-sm';
});
const expiresSoon = computed(
() =>
(!!props.storedItem.expiresAt &&
!props.storedItem.consumedAt &&
new Date(props.storedItem.expiresAt).getTime() <
Date.now() + 604800000) ??
false
);
const hasExpired = computed(
() =>
(!!props.storedItem.expiresAt &&
!props.storedItem.consumedAt &&
new Date(props.storedItem.expiresAt).getTime() < Date.now()) ??
false
);
const dateToLocaleString = (dateField: string) => {
const converted = new Date(dateField);
return `${converted.toLocaleString('en-UK')}`;
};
</script>

View File

@ -70,7 +70,10 @@
v-if="!movingBubble || storage.id === movingBubble?.id"
:storage="storage"
:class="{
'z-20': storage.id === hoveredBubble?.id,
'z-20':
storage.id === hoveredBubble?.id ||
storage.id === selectedStorage?.id ||
(isSet(storage) && storage.id === selectedSet?.id),
'pointer-events-none': !!movingBubble,
}"
@mouseenter="() => (hoveredBubble = storage)"
@ -107,9 +110,19 @@
:storages="storages"
:selected-room="selectedRoom"
:selected-storage="selectedStorage"
:selected-set="selectedSet"
@select-room="(room) => selectRoomFromList(room)"
@select-storage="(storage) => selectStorage(storage)"
@new-storage="(set) => addNewStorage(set)"
/>
<StoredItemCard
v-for="stored of selectedStorage?.items || []"
size="lg"
:stored-item="stored"
/>
<NewStorageModal ref="newStorage" />
</template>
<script setup lang="ts">
@ -122,7 +135,10 @@ import { computed, ref } from '@vue/reactivity';
import HousePlanner from '../../../components/house-planner/HousePlanner.vue';
import { boundingBox } from '../../../modules/house-planner/utils';
import { Vec2 } from '../../../modules/house-planner/interfaces';
import { RoomLayoutObject } from '../../../interfaces/room.interfaces';
import {
RoomLayoutObject,
RoomListItem,
} from '../../../interfaces/room.interfaces';
import jfetch from '../../../utils/jfetch';
import { BACKEND_URL } from '../../../constants';
import { useAccessToken } from '../../../composables/useAccessToken';
@ -136,6 +152,8 @@ import RoomPolygon from './RoomPolygon.vue';
import { useLocalStorage } from '@vueuse/core';
import isSet from '../../../utils/is-storage-set';
import ItemSelector from './ItemSelector.vue';
import StoredItemCard from '../../../components/item/StoredItemCard.vue';
import NewStorageModal from '../../../components/item/NewStorageModal.vue';
const route = useRoute();
const buildingStore = useBuildingStore();
@ -151,6 +169,8 @@ const movingBubble = ref<StorageSharedType>();
const selectedSet = ref<StorageSetListItem>();
const selectedStorage = ref<StorageListItem>();
const newStorage = ref<InstanceType<typeof NewStorageModal>>();
const floor = computed(() =>
building.value?.floors.find(
(floor) => floor.number === Number(route.params.number)
@ -208,6 +228,15 @@ const setBubbleLocation = async (x: number, y: number) => {
movingBubble.value = undefined;
};
const addNewStorage = (set: boolean | StorageSetListItem) => {
if (!building.value || !selectedRoom.value) return;
newStorage.value?.openModal(
building.value,
selectedRoom.value as unknown as RoomListItem,
set
);
};
const selectRoomFromList = (room: RoomLayoutObject) => {
canvas.value?.setViewRectangle(room?.boundingBox);
selectedStorage.value = undefined;

View File

@ -20,7 +20,8 @@
v-for="storage of storages"
@click="emit('selectStorage', storage)"
:class="[
selectedSet?.id === storage.id || selectedStorage?.id === storage.id
(isSet(storage) && selectedSet?.id === storage.id) ||
selectedStorage?.id === storage.id
? 'bg-blue-100 hover:bg-blue-200'
: 'hover:bg-blue-100',
'flex items-center justify-between border-b-2 border-gray-100 py-2 px-2',
@ -38,6 +39,22 @@
</span>
<ChevronRightIcon class="h-4 w-4" />
</button>
<div class="mx-auto flex space-x-2 py-2">
<button
@click="emit('newStorage', false)"
class="flex items-center space-x-1 text-blue-500 hover:text-blue-600 hover:underline"
>
<PlusIcon class="h-4 w-4" /> <span>Add Storage</span>
</button>
<span>·</span>
<button
@click="emit('newStorage', true)"
class="flex items-center space-x-1 text-blue-500 hover:text-blue-600 hover:underline"
>
<PlusIcon class="h-4 w-4" /> <span>Add Storage Set</span>
</button>
</div>
</div>
<div class="flex flex-col" v-if="selectedSet">
@ -52,6 +69,15 @@
<span>{{ storage.displayName }} ({{ storage.itemCount }})</span>
<ChevronRightIcon class="h-4 w-4" />
</button>
<div class="mx-auto flex space-x-2 py-2">
<button
@click="emit('newStorage', selectedSet!)"
class="flex items-center space-x-1 text-blue-500 hover:text-blue-600 hover:underline"
>
<PlusIcon class="h-4 w-4" /> <span>Add Storage</span>
</button>
</div>
</div>
<div class="flex flex-col" v-if="selectedStorage?.items">
@ -65,12 +91,21 @@
<span>{{ item.item.displayName }}</span>
<ChevronRightIcon class="h-4 w-4" />
</button>
<div class="mx-auto flex space-x-2 py-2">
<button
@click="emit('addItem', selectedStorage!)"
class="flex items-center space-x-1 text-blue-500 hover:text-blue-600 hover:underline"
>
<PlusIcon class="h-4 w-4" /> <span>Add Items</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon } from '@heroicons/vue/24/outline';
import { ChevronRightIcon, PlusIcon } from '@heroicons/vue/24/outline';
import isSet from '../../../utils/is-storage-set';
import {
StorageListItem,
@ -90,5 +125,7 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'selectStorage', storage: StorageSharedType): void;
(e: 'selectRoom', room: RoomLayoutObject): void;
(e: 'newStorage', set: boolean | StorageSetListItem): void;
(e: 'addItem', storage: StorageListItem): void;
}>();
</script>