more form stuff, add storages

This commit is contained in:
Evert Prants 2023-01-26 18:15:59 +02:00
parent dfab6b8a18
commit c76db68e5b
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
13 changed files with 403 additions and 125 deletions

View File

@ -1,6 +1,7 @@
<template> <template>
<button <button
class="relative" class="relative"
type="button"
@click="inputRef.click()" @click="inputRef.click()"
:class="[ :class="[
'flex items-center space-x-1 rounded-full bg-gray-100 py-1 hover:bg-gray-50 focus:ring-2 focus:ring-blue-200', 'flex items-center space-x-1 rounded-full bg-gray-100 py-1 hover:bg-gray-50 focus:ring-2 focus:ring-blue-200',

View File

@ -8,6 +8,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDebounceFn } from '@vueuse/shared'; import { useDebounceFn } from '@vueuse/shared';
import get from 'lodash.get';
import { provide, ref, watch } from 'vue'; import { provide, ref, watch } from 'vue';
import deepUnref from '../../utils/deep-unref'; import deepUnref from '../../utils/deep-unref';
import { FormData, FormErrors, FormSubmit } from './form.types'; import { FormData, FormErrors, FormSubmit } from './form.types';
@ -42,7 +43,11 @@ const validateField = async (field: string) => {
for (const validator of props.validators) { for (const validator of props.validators) {
if (validator.field !== field) continue; if (validator.field !== field) continue;
for (const fn of validator.validators) { for (const fn of validator.validators) {
const result = await fn(field, formData.value[field], formData.value); const result = await fn(
field,
get(formData.value, field),
formData.value
);
if (!result.isValid) { if (!result.isValid) {
formErrors.value = { formErrors.value = {
...formErrors.value, ...formErrors.value,

View File

@ -0,0 +1,23 @@
<template>
<Transition
enter-active-class="transition-height ease-out duration-500 origin-bottom overflow-hidden"
enter-from-class="h-0"
enter-to-class="h-10"
>
<div class="mb-4 rounded" v-if="message">
<div
class="rounded bg-red-100 px-4 py-2 text-center font-bold text-red-700"
role="alert"
aria-live="assertive"
>
{{ message }}
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
const props = defineProps<{
message?: string;
}>();
</script>

View File

@ -8,18 +8,36 @@
]" ]"
>{{ label }}</label >{{ label }}</label
> >
<input <slot
name="input"
:for-id="forId"
:type="type"
:id="forId"
:fieldName="name"
:value="value"
:placeholder="placeholder"
:invalid="!!errors?.length"
:onInput="onInput"
:onChange="onChange"
:onFocus="onFocus"
:onBlur="onBlur"
:setValue="setValue"
>
<FormInput
:for-id="forId"
:type="type" :type="type"
:id="forId" :id="forId"
:name="name" :name="name"
:value="value" :value="value"
:class="inputClass"
:placeholder="placeholder" :placeholder="placeholder"
@input="onInput($event)" :invalid="!!errors?.length"
@change="onChange($event)" @input="onInput"
@change="onChange"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@set-value="setValue"
/> />
</slot>
<slot /> <slot />
<span <span
class="text-sm text-red-500" class="text-sm text-red-500"
@ -31,9 +49,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, Ref } from 'vue'; import set from 'lodash.set';
import get from 'lodash.get';
import { computed, ref, Ref } from 'vue';
import { inject } from 'vue'; import { inject } from 'vue';
import { FormData, FormErrors } from './form.types'; import { FormData, FormErrors } from './form.types';
import FormInput from './FormInput.vue';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -62,42 +83,43 @@ const emit = defineEmits<{
const formData = inject<Ref<FormData>>('formData'); const formData = inject<Ref<FormData>>('formData');
const formErrors = inject<Ref<FormErrors>>('formErrors'); const formErrors = inject<Ref<FormErrors>>('formErrors');
const formGroup = inject<Ref<string>>('formGroup', ref(''));
const fieldChange = inject<(field: string) => void>('fieldChange'); const fieldChange = inject<(field: string) => void>('fieldChange');
const forId = computed(() => `form-${props.name}`);
const value = computed(() => formData?.value[props.name]); const fieldName = computed(() =>
const errors = computed(() => formErrors?.value[props.name] || []); formGroup?.value ? `${formGroup.value}.${props.name}` : props.name
const inputClass = computed(() => { );
return [
errors.value.length const forId = computed(() => `form-${fieldName}`);
? 'border-red-300 focus:border-red-500 focus:ring-red-500' const value = computed(() => get(formData?.value, fieldName.value));
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500', const errors = computed(() => formErrors?.value[fieldName.value] || []);
`mt-1 block w-full rounded-md shadow-sm sm:text-sm transition-colors duration-200`,
];
});
const onInput = (ev: Event) => { const onInput = (ev: Event) => {
if (!formData) return; if (!formData) return;
const value = (ev.target as HTMLInputElement).value; const value = (ev.target as HTMLInputElement).value;
const cleanValue = props.type === 'number' ? parseFloat(value) : value; const cleanValue = props.type === 'number' ? parseFloat(value) : value;
formData.value[props.name] = cleanValue; setValue(cleanValue);
emit('change', cleanValue);
fieldChange?.(props.name);
}; };
const onChange = (ev: Event) => { const onChange = (ev: Event) => {
if (!formData) return; if (!formData) return;
if (props.type === 'checkbox' || props.type === 'radio') { if (props.type === 'checkbox' || props.type === 'radio') {
const cleanValue = (ev.target as HTMLInputElement).checked; const cleanValue = (ev.target as HTMLInputElement).checked;
formData.value[props.name] = cleanValue; setValue(cleanValue);
emit('change', cleanValue);
fieldChange?.(props.name);
} }
}; };
const setValue = (value: unknown) => {
if (!formData) return;
set(formData.value, fieldName.value, value);
emit('change', value);
fieldChange?.(fieldName.value);
};
const onFocus = () => emit('focus'); const onFocus = () => emit('focus');
const onBlur = () => { const onBlur = () => {
emit('blur'); emit('blur');
fieldChange?.(props.name); fieldChange?.(fieldName.value);
}; };
</script> </script>

View File

@ -0,0 +1,20 @@
<template>
<slot />
</template>
<script setup lang="ts">
import { inject, provide, Ref, ref } from 'vue';
const props = defineProps<{
name: string;
}>();
const name = ref(props.name);
const prevGroup = inject<Ref<string>>('formGroup', ref(''));
if (prevGroup?.value) {
name.value = `${prevGroup.value}.${props.name}`;
}
provide('formGroup', name);
</script>

View File

@ -0,0 +1,65 @@
<template>
<ColorInput
v-if="type === 'color'"
:for-id="forId"
:model-value="(value as string)"
@update:model-value="(newValue: string) => emit('setValue', newValue)"
/>
<input
v-else
:type="type"
:id="forId"
:name="name"
:value="value"
:class="inputClass"
:placeholder="placeholder"
@input="emit('input', $event)"
@change="emit('change', $event)"
@focus="emit('focus', $event)"
@blur="emit('blur', $event)"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import ColorInput from '../ColorInput.vue';
const props = withDefaults(
defineProps<{
type?:
| 'text'
| 'number'
| 'password'
| 'color'
| 'email'
| 'checkbox'
| 'radio';
forId: string;
name: string;
value: unknown;
invalid?: boolean;
placeholder?: string;
}>(),
{
type: 'text',
invalid: false,
}
);
const emit = defineEmits<{
(e: 'setValue', value: unknown): void;
(e: 'input', ev: Event): void;
(e: 'change', ev: Event): void;
(e: 'focus', ev: Event): void;
(e: 'blur', ev: Event): void;
}>();
const inputClass = computed(() => {
return [
props.invalid
? '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`,
];
});
</script>

View File

@ -15,7 +15,7 @@ export function MinLength(length: number, message?: string): FormValidatorFn {
export function MaxLength(length: number, message?: string): FormValidatorFn { export function MaxLength(length: number, message?: string): FormValidatorFn {
return (_: string, value: unknown) => { return (_: string, value: unknown) => {
let isValid = true; let isValid = true;
if (value != null && typeof value === 'string' && value.length >= length) if (value != null && typeof value === 'string' && value.length > length)
isValid = false; isValid = false;
return { return {
isValid, isValid,

View File

@ -5,9 +5,14 @@
</template> </template>
<template #default="{ closeModal }"> <template #default="{ closeModal }">
<FormAlert :message="error" />
<Form @submit="onSubmit" v-model="data" :validators="validators"> <Form @submit="onSubmit" v-model="data" :validators="validators">
<FormField name="displayName" label="Display Name" /> <FormField name="displayName" label="Display Name" />
<FormField name="type" label="Type" /> <FormField name="type" label="Type" />
<FormField name="locationDescription" label="Location description">
<span class="text-sm">Describe the location of this storage</span>
</FormField>
<FormField type="color" name="color" label="Color" />
<button type="submit">Submit</button> <button type="submit">Submit</button>
</Form> </Form>
</template> </template>
@ -16,22 +21,37 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useAccessToken } from '../../composables/useAccessToken';
import { BACKEND_URL } from '../../constants';
import { BuildingListItem } from '../../interfaces/building.interfaces'; import { BuildingListItem } from '../../interfaces/building.interfaces';
import { RoomListItem } from '../../interfaces/room.interfaces'; import { RoomListItem } from '../../interfaces/room.interfaces';
import { StorageSetListItem } from '../../interfaces/storage.interfaces'; import {
StorageSetListItem,
StorageSharedType,
} from '../../interfaces/storage.interfaces';
import jfetch from '../../utils/jfetch';
import takeError from '../../utils/take-error';
import { FormSubmit } from '../form/form.types'; import { FormSubmit } from '../form/form.types';
import Form from '../form/Form.vue'; import Form from '../form/Form.vue';
import FormAlert from '../form/FormAlert.vue';
import FormField from '../form/FormField.vue'; import FormField from '../form/FormField.vue';
import { IsRequired, MinLength } from '../form/validators'; import { IsRequired, MinLength } from '../form/validators';
import Modal from '../Modal.vue'; import Modal from '../Modal.vue';
const { authHeader } = useAccessToken();
const defaults = {
displayName: '',
type: '',
locationDescription: '',
color: '#000000',
};
const modalRef = ref<InstanceType<typeof Modal>>(); const modalRef = ref<InstanceType<typeof Modal>>();
const room = ref<RoomListItem>(); const room = ref<RoomListItem>();
const building = ref<BuildingListItem>(); const building = ref<BuildingListItem>();
const set = ref<StorageSetListItem | boolean>(); const set = ref<StorageSetListItem | boolean>();
const data = ref({ const data = ref({ ...defaults });
displayName: '', const error = ref('');
});
const validators = ref([ const validators = ref([
{ {
@ -44,6 +64,10 @@ const validators = ref([
}, },
]); ]);
const emit = defineEmits<{
(e: 'added', storage: StorageSharedType): void;
}>();
defineExpose({ defineExpose({
openModal: ( openModal: (
useBuilding: BuildingListItem, useBuilding: BuildingListItem,
@ -53,9 +77,49 @@ defineExpose({
room.value = useRoom; room.value = useRoom;
building.value = useBuilding; building.value = useBuilding;
set.value = useSet; set.value = useSet;
data.value = { ...defaults };
modalRef.value?.openModal(); modalRef.value?.openModal();
}, },
}); });
const onSubmit = (value: FormSubmit) => console.log(value); const onSubmit = async (value: FormSubmit) => {
error.value = '';
if (!value.isValid || !room.value) return;
const createURL = `${BACKEND_URL}/storage${
set.value === true ? '/set' : ''
}/room/${room.value.id}`;
let createdStorage: StorageSharedType;
try {
const response = await jfetch(createURL, {
method: 'POST',
body: {
...value.formData,
location: '0,0',
},
headers: authHeader.value,
});
createdStorage = response.data;
} catch (e) {
error.value = takeError(e);
return;
}
// Add created storage to set
if (set.value && typeof set.value !== 'boolean') {
await jfetch(
`${BACKEND_URL}/storage/set/${set.value.id}/${createdStorage.id}`,
{
method: 'POST',
headers: authHeader.value,
}
);
}
modalRef.value?.closeModal();
emit('added', createdStorage);
};
</script> </script>

View File

@ -479,7 +479,7 @@ export class HousePlannerCanvas {
private calculateViewport(box: Vec2Box): [number, Vec2] { private calculateViewport(box: Vec2Box): [number, Vec2] {
let [min, max] = box; let [min, max] = box;
const gap = this.headless ? 10 : 80; const gap = this.headless ? 50 : 80;
min = vec2Sub(min, [gap, gap]); min = vec2Sub(min, [gap, gap]);
max = vec2Add(max, [gap, gap]); max = vec2Add(max, [gap, gap]);

15
src/utils/take-error.ts Normal file
View File

@ -0,0 +1,15 @@
import { JFetchError } from './jfetch';
export default function takeError(thrown: unknown): string {
if ((thrown as JFetchError).data) {
const message = (thrown as JFetchError).data.message;
if (message) {
if (Array.isArray(message)) {
return message.join('\n');
}
return message;
}
}
if ((thrown as Error).message) return (thrown as Error).message;
return 'An unexpected error occured';
}

View File

@ -67,7 +67,11 @@
<template v-if="selectedRoom?.id === room.id"> <template v-if="selectedRoom?.id === room.id">
<template v-for="storage of storages"> <template v-for="storage of storages">
<StorageBubble <StorageBubble
v-if="!movingBubble || storage.id === movingBubble?.id" v-if="
!movingBubble ||
(storage.id === movingBubble?.id &&
isSet(storage) === isSet(movingBubble))
"
:storage="storage" :storage="storage"
:class="{ :class="{
'z-20': 'z-20':
@ -111,9 +115,9 @@
:selected-room="selectedRoom" :selected-room="selectedRoom"
:selected-storage="selectedStorage" :selected-storage="selectedStorage"
:selected-set="selectedSet" :selected-set="selectedSet"
@select-room="(room) => selectRoomFromList(room)" @select-room="selectRoomFromList"
@select-storage="(storage) => selectStorage(storage)" @select-storage="selectStorage"
@new-storage="(set) => addNewStorage(set)" @new-storage="addNewStorage"
/> />
<StoredItemCard <StoredItemCard
@ -122,7 +126,7 @@
:stored-item="stored" :stored-item="stored"
/> />
<NewStorageModal ref="newStorage" /> <NewStorageModal ref="newStorage" @added="storageAdded" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -186,6 +190,7 @@ const clickOnRoom = (room?: RoomLayoutObject, x?: number, y?: number) => {
selectedStorage.value = undefined; selectedStorage.value = undefined;
selectedSet.value = undefined; selectedSet.value = undefined;
if (room && (!selectedRoom.value || room.id !== selectedRoom.value?.id)) { if (room && (!selectedRoom.value || room.id !== selectedRoom.value?.id)) {
movingBubble.value = undefined;
getRoomStorages(room); getRoomStorages(room);
} else if (room?.id == selectedRoom.value?.id) { } else if (room?.id == selectedRoom.value?.id) {
if (movingBubble.value && x != null && y != null) { if (movingBubble.value && x != null && y != null) {
@ -198,13 +203,8 @@ const clickOnRoom = (room?: RoomLayoutObject, x?: number, y?: number) => {
}; };
const mouseMovedInRoom = (room: RoomLayoutObject, x: number, y: number) => { const mouseMovedInRoom = (room: RoomLayoutObject, x: number, y: number) => {
if (movingBubble.value) { if (!movingBubble.value) return;
const storage = storages.value.find( movingBubble.value.location = `${x},${y}`;
(item) => item.id === movingBubble.value?.id
);
if (!storage) return;
storage.location = `${x},${y}`;
}
}; };
const moveBubble = (bubble: StorageSharedType) => { const moveBubble = (bubble: StorageSharedType) => {
@ -245,12 +245,17 @@ const selectRoomFromList = (room: RoomLayoutObject) => {
selectedRoom.value = room; selectedRoom.value = room;
}; };
const selectStorage = (storage: StorageSharedType) => { const selectStorage = (
storage: StorageSharedType,
parentSet?: StorageSetListItem
) => {
if (isSet(storage)) { if (isSet(storage)) {
selectedSet.value = storage as StorageSetListItem; selectedSet.value = storage as StorageSetListItem;
selectedStorage.value = undefined;
return; return;
} }
selectedSet.value = parentSet;
selectedStorage.value = storage as StorageListItem; selectedStorage.value = storage as StorageListItem;
jfetch(`${BACKEND_URL}/storage/storages/${storage.id}`, { jfetch(`${BACKEND_URL}/storage/storages/${storage.id}`, {
headers: authHeader.value, headers: authHeader.value,
@ -300,4 +305,18 @@ const getRoomStorages = async (room: RoomLayoutObject) => {
console.error(e); console.error(e);
} }
}; };
const storageAdded = async (newStorage: StorageSharedType) => {
if (!selectedRoom.value) return;
await getRoomStorages(selectedRoom.value);
if (selectedSet.value) {
selectedSet.value.storages = [
...selectedSet.value.storages,
{
...(newStorage as StorageListItem),
itemCount: 0,
},
];
}
};
</script> </script>

View File

@ -1,5 +1,10 @@
<template> <template>
<div :class="['grid', selectedSet ? 'grid-cols-4' : 'grid-cols-3']"> <div
:class="[
'flex flex-col md:grid',
selectedSet ? 'md:grid-cols-4' : 'md:grid-cols-3',
]"
>
<div class="flex flex-col"> <div class="flex flex-col">
<button <button
v-for="room of rooms" v-for="room of rooms"
@ -15,13 +20,18 @@
</button> </button>
</div> </div>
<div class="flex flex-col" v-if="selectedRoom?.id"> <template v-if="selectedRoom?.id">
<div class="my-2 flex justify-center md:hidden">
<ChevronDoubleDownIcon class="h-6 w-6" />
</div>
<div class="flex flex-col">
<button <button
v-for="storage of storages" v-for="storage of storages"
@click="emit('selectStorage', storage)" @click="emit('selectStorage', storage)"
:class="[ :class="[
(isSet(storage) && selectedSet?.id === storage.id) || (isSet(storage) && selectedSet?.id === storage.id) ||
selectedStorage?.id === storage.id (selectedStorage?.id === storage.id && !isSet(storage))
? 'bg-blue-100 hover:bg-blue-200' ? 'bg-blue-100 hover:bg-blue-200'
: 'hover:bg-blue-100', : 'hover:bg-blue-100',
'flex items-center justify-between border-b-2 border-gray-100 py-2 px-2', 'flex items-center justify-between border-b-2 border-gray-100 py-2 px-2',
@ -34,7 +44,9 @@
>({{ (storage as StorageListItem).itemCount }})</span >({{ (storage as StorageListItem).itemCount }})</span
> >
<span v-else> <span v-else>
(set) ({{ (storage as StorageSetListItem).storages.length }})</span (set) ({{
(storage as StorageSetListItem).storages.length
}})</span
> >
</span> </span>
<ChevronRightIcon class="h-4 w-4" /> <ChevronRightIcon class="h-4 w-4" />
@ -56,13 +68,21 @@
</button> </button>
</div> </div>
</div> </div>
</template>
<div class="flex flex-col" v-if="selectedSet"> <template v-if="selectedSet">
<div class="my-2 flex justify-center md:hidden">
<ChevronDoubleDownIcon class="h-6 w-6" />
</div>
<div class="flex flex-col">
<button <button
v-for="storage of selectedSet.storages" v-for="storage of selectedSet.storages"
@click="emit('selectStorage', storage)" @click="emit('selectStorage', storage, selectedSet)"
:class="[ :class="[
'hover:bg-blue-100', selectedStorage?.id === storage.id && !isSet(storage)
? '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', 'flex items-center justify-between border-b-2 border-gray-100 py-2 px-2',
]" ]"
> >
@ -79,8 +99,14 @@
</button> </button>
</div> </div>
</div> </div>
</template>
<div class="flex flex-col" v-if="selectedStorage?.items"> <template v-if="selectedStorage?.items">
<div class="my-2 flex justify-center md:hidden">
<ChevronDoubleDownIcon class="h-6 w-6" />
</div>
<div class="flex flex-col">
<button <button
v-for="item of selectedStorage.items" v-for="item of selectedStorage.items"
:class="[ :class="[
@ -101,11 +127,23 @@
</button> </button>
</div> </div>
</div> </div>
</template>
<div
class="my-2 mb-4 flex justify-center md:hidden"
v-if="selectedStorage?.items?.length"
>
<ChevronDoubleDownIcon class="h-6 w-6" />
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ChevronRightIcon, PlusIcon } from '@heroicons/vue/24/outline'; import {
ChevronDoubleDownIcon,
ChevronRightIcon,
PlusIcon,
} from '@heroicons/vue/24/outline';
import isSet from '../../../utils/is-storage-set'; import isSet from '../../../utils/is-storage-set';
import { import {
StorageListItem, StorageListItem,
@ -123,7 +161,11 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'selectStorage', storage: StorageSharedType): void; (
e: 'selectStorage',
storage: StorageSharedType,
parentSet?: StorageSetListItem
): void;
(e: 'selectRoom', room: RoomLayoutObject): void; (e: 'selectRoom', room: RoomLayoutObject): void;
(e: 'newStorage', set: boolean | StorageSetListItem): void; (e: 'newStorage', set: boolean | StorageSetListItem): void;
(e: 'addItem', storage: StorageListItem): void; (e: 'addItem', storage: StorageListItem): void;

View File

@ -15,7 +15,9 @@
<button <button
class="absolute left-1/2 bottom-0.5 -translate-x-1/2 rounded-full bg-gray-50 px-1 py-1 ring-1 ring-black ring-opacity-5" class="absolute left-1/2 bottom-0.5 -translate-x-1/2 rounded-full bg-gray-50 px-1 py-1 ring-1 ring-black ring-opacity-5"
@click.stop="emit('startMoving')" @click.stop="emit('startMoving')"
title="Move storage"
> >
<span class="sr-only">Move storage</span>
<ArrowsPointingOutIcon class="h-4 w-4" /> <ArrowsPointingOutIcon class="h-4 w-4" />
</button> </button>
</div> </div>