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>
<button
class="relative"
type="button"
@click="inputRef.click()"
: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',

View File

@ -8,6 +8,7 @@
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/shared';
import get from 'lodash.get';
import { provide, ref, watch } from 'vue';
import deepUnref from '../../utils/deep-unref';
import { FormData, FormErrors, FormSubmit } from './form.types';
@ -42,7 +43,11 @@ const validateField = async (field: string) => {
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);
const result = await fn(
field,
get(formData.value, field),
formData.value
);
if (!result.isValid) {
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
>
<input
<slot
name="input"
:for-id="forId"
:type="type"
:id="forId"
:name="name"
:fieldName="name"
:value="value"
:class="inputClass"
:placeholder="placeholder"
@input="onInput($event)"
@change="onChange($event)"
@focus="onFocus"
@blur="onBlur"
/>
:invalid="!!errors?.length"
:onInput="onInput"
:onChange="onChange"
:onFocus="onFocus"
:onBlur="onBlur"
:setValue="setValue"
>
<FormInput
:for-id="forId"
:type="type"
:id="forId"
:name="name"
:value="value"
:placeholder="placeholder"
:invalid="!!errors?.length"
@input="onInput"
@change="onChange"
@focus="onFocus"
@blur="onBlur"
@set-value="setValue"
/>
</slot>
<slot />
<span
class="text-sm text-red-500"
@ -31,9 +49,12 @@
</template>
<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 { FormData, FormErrors } from './form.types';
import FormInput from './FormInput.vue';
const props = withDefaults(
defineProps<{
@ -62,42 +83,43 @@ const emit = defineEmits<{
const formData = inject<Ref<FormData>>('formData');
const formErrors = inject<Ref<FormErrors>>('formErrors');
const formGroup = inject<Ref<string>>('formGroup', ref(''));
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 fieldName = computed(() =>
formGroup?.value ? `${formGroup.value}.${props.name}` : props.name
);
const forId = computed(() => `form-${fieldName}`);
const value = computed(() => get(formData?.value, fieldName.value));
const errors = computed(() => formErrors?.value[fieldName.value] || []);
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);
setValue(cleanValue);
};
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);
setValue(cleanValue);
}
};
const setValue = (value: unknown) => {
if (!formData) return;
set(formData.value, fieldName.value, value);
emit('change', value);
fieldChange?.(fieldName.value);
};
const onFocus = () => emit('focus');
const onBlur = () => {
emit('blur');
fieldChange?.(props.name);
fieldChange?.(fieldName.value);
};
</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 {
return (_: string, value: unknown) => {
let isValid = true;
if (value != null && typeof value === 'string' && value.length >= length)
if (value != null && typeof value === 'string' && value.length > length)
isValid = false;
return {
isValid,

View File

@ -5,9 +5,14 @@
</template>
<template #default="{ closeModal }">
<FormAlert :message="error" />
<Form @submit="onSubmit" v-model="data" :validators="validators">
<FormField name="displayName" label="Display Name" />
<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>
</Form>
</template>
@ -16,22 +21,37 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useAccessToken } from '../../composables/useAccessToken';
import { BACKEND_URL } from '../../constants';
import { BuildingListItem } from '../../interfaces/building.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 Form from '../form/Form.vue';
import FormAlert from '../form/FormAlert.vue';
import FormField from '../form/FormField.vue';
import { IsRequired, MinLength } from '../form/validators';
import Modal from '../Modal.vue';
const { authHeader } = useAccessToken();
const defaults = {
displayName: '',
type: '',
locationDescription: '',
color: '#000000',
};
const modalRef = ref<InstanceType<typeof Modal>>();
const room = ref<RoomListItem>();
const building = ref<BuildingListItem>();
const set = ref<StorageSetListItem | boolean>();
const data = ref({
displayName: '',
});
const data = ref({ ...defaults });
const error = ref('');
const validators = ref([
{
@ -44,6 +64,10 @@ const validators = ref([
},
]);
const emit = defineEmits<{
(e: 'added', storage: StorageSharedType): void;
}>();
defineExpose({
openModal: (
useBuilding: BuildingListItem,
@ -53,9 +77,49 @@ defineExpose({
room.value = useRoom;
building.value = useBuilding;
set.value = useSet;
data.value = { ...defaults };
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>

View File

@ -479,7 +479,7 @@ export class HousePlannerCanvas {
private calculateViewport(box: Vec2Box): [number, Vec2] {
let [min, max] = box;
const gap = this.headless ? 10 : 80;
const gap = this.headless ? 50 : 80;
min = vec2Sub(min, [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-for="storage of storages">
<StorageBubble
v-if="!movingBubble || storage.id === movingBubble?.id"
v-if="
!movingBubble ||
(storage.id === movingBubble?.id &&
isSet(storage) === isSet(movingBubble))
"
:storage="storage"
:class="{
'z-20':
@ -111,9 +115,9 @@
:selected-room="selectedRoom"
:selected-storage="selectedStorage"
:selected-set="selectedSet"
@select-room="(room) => selectRoomFromList(room)"
@select-storage="(storage) => selectStorage(storage)"
@new-storage="(set) => addNewStorage(set)"
@select-room="selectRoomFromList"
@select-storage="selectStorage"
@new-storage="addNewStorage"
/>
<StoredItemCard
@ -122,7 +126,7 @@
:stored-item="stored"
/>
<NewStorageModal ref="newStorage" />
<NewStorageModal ref="newStorage" @added="storageAdded" />
</template>
<script setup lang="ts">
@ -186,6 +190,7 @@ const clickOnRoom = (room?: RoomLayoutObject, x?: number, y?: number) => {
selectedStorage.value = undefined;
selectedSet.value = undefined;
if (room && (!selectedRoom.value || room.id !== selectedRoom.value?.id)) {
movingBubble.value = undefined;
getRoomStorages(room);
} else if (room?.id == selectedRoom.value?.id) {
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) => {
if (movingBubble.value) {
const storage = storages.value.find(
(item) => item.id === movingBubble.value?.id
);
if (!storage) return;
storage.location = `${x},${y}`;
}
if (!movingBubble.value) return;
movingBubble.value.location = `${x},${y}`;
};
const moveBubble = (bubble: StorageSharedType) => {
@ -245,12 +245,17 @@ const selectRoomFromList = (room: RoomLayoutObject) => {
selectedRoom.value = room;
};
const selectStorage = (storage: StorageSharedType) => {
const selectStorage = (
storage: StorageSharedType,
parentSet?: StorageSetListItem
) => {
if (isSet(storage)) {
selectedSet.value = storage as StorageSetListItem;
selectedStorage.value = undefined;
return;
}
selectedSet.value = parentSet;
selectedStorage.value = storage as StorageListItem;
jfetch(`${BACKEND_URL}/storage/storages/${storage.id}`, {
headers: authHeader.value,
@ -300,4 +305,18 @@ const getRoomStorages = async (room: RoomLayoutObject) => {
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>

View File

@ -1,5 +1,10 @@
<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">
<button
v-for="room of rooms"
@ -15,97 +20,130 @@
</button>
</div>
<div class="flex flex-col" v-if="selectedRoom?.id">
<button
v-for="storage of storages"
@click="emit('selectStorage', storage)"
:class="[
(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',
]"
>
<span
>{{ storage.displayName }}
<template v-if="selectedRoom?.id">
<div class="my-2 flex justify-center md:hidden">
<ChevronDoubleDownIcon class="h-6 w-6" />
</div>
<span v-if="!isSet(storage)"
>({{ (storage as StorageListItem).itemCount }})</span
<div class="flex flex-col">
<button
v-for="storage of storages"
@click="emit('selectStorage', storage)"
:class="[
(isSet(storage) && selectedSet?.id === storage.id) ||
(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',
]"
>
<span
>{{ storage.displayName }}
<span v-if="!isSet(storage)"
>({{ (storage as StorageListItem).itemCount }})</span
>
<span v-else>
(set) ({{
(storage as StorageSetListItem).storages.length
}})</span
>
</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"
>
<span v-else>
(set) ({{ (storage as StorageSetListItem).storages.length }})</span
<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"
>
</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>
<PlusIcon class="h-4 w-4" /> <span>Add Storage Set</span>
</button>
</div>
</div>
</div>
</template>
<div class="flex flex-col" v-if="selectedSet">
<button
v-for="storage of selectedSet.storages"
@click="emit('selectStorage', storage)"
:class="[
'hover:bg-blue-100',
'flex items-center justify-between border-b-2 border-gray-100 py-2 px-2',
]"
>
<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>
<template v-if="selectedSet">
<div class="my-2 flex justify-center md:hidden">
<ChevronDoubleDownIcon class="h-6 w-6" />
</div>
</div>
<div class="flex flex-col" v-if="selectedStorage?.items">
<button
v-for="item of selectedStorage.items"
:class="[
'hover:bg-blue-100',
'flex items-center justify-between border-b-2 border-gray-100 py-2 px-2',
]"
>
<span>{{ item.item.displayName }}</span>
<ChevronRightIcon class="h-4 w-4" />
</button>
<div class="mx-auto flex space-x-2 py-2">
<div class="flex flex-col">
<button
@click="emit('addItem', selectedStorage!)"
class="flex items-center space-x-1 text-blue-500 hover:text-blue-600 hover:underline"
v-for="storage of selectedSet.storages"
@click="emit('selectStorage', storage, selectedSet)"
:class="[
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',
]"
>
<PlusIcon class="h-4 w-4" /> <span>Add Items</span>
<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>
</template>
<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
v-for="item of selectedStorage.items"
:class="[
'hover:bg-blue-100',
'flex items-center justify-between border-b-2 border-gray-100 py-2 px-2',
]"
>
<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>
</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>
</template>
<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 {
StorageListItem,
@ -123,7 +161,11 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
(e: 'selectStorage', storage: StorageSharedType): void;
(
e: 'selectStorage',
storage: StorageSharedType,
parentSet?: StorageSetListItem
): void;
(e: 'selectRoom', room: RoomLayoutObject): void;
(e: 'newStorage', set: boolean | StorageSetListItem): void;
(e: 'addItem', storage: StorageListItem): void;

View File

@ -15,7 +15,9 @@
<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"
@click.stop="emit('startMoving')"
title="Move storage"
>
<span class="sr-only">Move storage</span>
<ArrowsPointingOutIcon class="h-4 w-4" />
</button>
</div>