implement adding items to storage

This commit is contained in:
Evert Prants 2023-01-27 20:11:20 +02:00
parent 266e7194c0
commit 90b6f666d1
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
13 changed files with 436 additions and 7 deletions

View File

@ -28,6 +28,7 @@ const props = withDefaults(
); );
const isValid = ref(true); const isValid = ref(true);
const knownFields = ref<string[]>([]);
const invalidFields = ref<string[]>([]); const invalidFields = ref<string[]>([]);
const formData = ref<FormData>(props.modelValue); const formData = ref<FormData>(props.modelValue);
const formErrors = ref<FormErrors>(props.errors); const formErrors = ref<FormErrors>(props.errors);
@ -70,6 +71,7 @@ const validateField = async (field: string) => {
const validateAll = async () => { const validateAll = async () => {
const fields = props.validators const fields = props.validators
.map((validator) => validator.field) .map((validator) => validator.field)
.filter((field) => knownFields.value.includes(field))
.filter((value, index, array) => array.indexOf(value) === index); .filter((value, index, array) => array.indexOf(value) === index);
if (!fields.length) return; if (!fields.length) return;
return Promise.allSettled(fields.map((field) => validateField(field))); return Promise.allSettled(fields.map((field) => validateField(field)));
@ -80,6 +82,24 @@ const fieldChange = useDebounceFn(validateField, 300);
provide('formData', formData); provide('formData', formData);
provide('formErrors', formErrors); provide('formErrors', formErrors);
provide('fieldChange', fieldChange); provide('fieldChange', fieldChange);
provide('registerField', (field: string) => {
if (knownFields.value?.includes(field)) return;
knownFields.value.push(field);
});
provide('unregisterField', (field: string) => {
if (!knownFields.value?.includes(field)) return;
knownFields.value = knownFields.value.filter((entry) => field !== entry);
invalidFields.value = invalidFields.value.filter((entry) => field !== entry);
if (formErrors.value[field]) {
delete formErrors.value[field];
}
if (formData.value[field]) {
delete formData.value[field];
}
if (!isValid.value) {
isValid.value = !invalidFields.value.length;
}
});
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'submit', data: FormSubmit): void; (e: 'submit', data: FormSubmit): void;
@ -101,7 +121,15 @@ const onSubmit = async () => {
}); });
}; };
const reset = () => {
formData.value = {};
formErrors.value = {};
invalidFields.value = [];
};
defineExpose({ defineExpose({
isValid, isValid,
validateAll,
reset,
}); });
</script> </script>

View File

@ -6,7 +6,8 @@
'block text-sm font-medium transition-colors duration-200', 'block text-sm font-medium transition-colors duration-200',
errors.length ? 'text-red-500' : 'text-gray-700', errors.length ? 'text-red-500' : 'text-gray-700',
]" ]"
>{{ label }}</label >{{ label }}
<span v-if="required" class="font-bold text-red-600">*</span></label
> >
<slot <slot
name="input" name="input"
@ -14,6 +15,7 @@
:type="type" :type="type"
:id="forId" :id="forId"
:fieldName="name" :fieldName="name"
:fieldFQN="fieldName"
:value="value" :value="value"
:placeholder="placeholder" :placeholder="placeholder"
:invalid="!!errors?.length" :invalid="!!errors?.length"
@ -32,6 +34,9 @@
:placeholder="placeholder" :placeholder="placeholder"
:disabled="disabled" :disabled="disabled"
:readonly="readonly" :readonly="readonly"
:step="step"
:min="min"
:max="max"
@input="onInput" @input="onInput"
@change="onChange" @change="onChange"
@focus="onFocus" @focus="onFocus"
@ -51,7 +56,7 @@
<script setup lang="ts"> <script setup lang="ts">
import set from 'lodash.set'; import set from 'lodash.set';
import get from 'lodash.get'; import get from 'lodash.get';
import { computed, ref, Ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, Ref } from 'vue';
import { inject } from 'vue'; import { inject } from 'vue';
import { FormData, FormErrors, InputType } from './form.types'; import { FormData, FormErrors, InputType } from './form.types';
@ -61,12 +66,17 @@ const props = withDefaults(
forIdPrefix?: string; forIdPrefix?: string;
label: string; label: string;
name: string; name: string;
required?: boolean;
disabled?: boolean; disabled?: boolean;
readonly?: boolean; readonly?: boolean;
placeholder?: string; placeholder?: string;
step?: string;
min?: string;
max?: string;
}>(), }>(),
{ {
type: 'text', type: 'text',
required: false,
disabled: false, disabled: false,
readonly: false, readonly: false,
} }
@ -82,6 +92,8 @@ 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 formGroup = inject<Ref<string>>('formGroup', ref(''));
const fieldChange = inject<(field: string) => void>('fieldChange'); const fieldChange = inject<(field: string) => void>('fieldChange');
const registerField = inject<(field: string) => void>('registerField');
const unregisterField = inject<(field: string) => void>('unregisterField');
const fieldName = computed(() => const fieldName = computed(() =>
formGroup?.value ? `${formGroup.value}.${props.name}` : props.name formGroup?.value ? `${formGroup.value}.${props.name}` : props.name
@ -127,7 +139,12 @@ const inputClass = computed(() => {
errors.value.length errors.value.length
? 'border-red-300 focus:border-red-500 focus:ring-red-500' ? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-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`, props.type === 'checkbox' || props.type === 'radio' ? '' : 'w-full',
props.disabled ? 'bg-gray-100 hover:border-gray-400' : '',
`mt-1 block rounded-md shadow-sm sm:text-sm transition-colors duration-200`,
]; ];
}); });
onMounted(() => registerField?.(fieldName.value));
onBeforeUnmount(() => unregisterField?.(fieldName.value));
</script> </script>

View File

@ -8,6 +8,7 @@
:id="forId" :id="forId"
:class="inputClass" :class="inputClass"
:displayValue="(option: any) => getLabel(option)" :displayValue="(option: any) => getLabel(option)"
autocomplete="off"
@change="query = $event.target.value" @change="query = $event.target.value"
/> />
<ComboboxButton <ComboboxButton
@ -130,6 +131,7 @@ const inputClass = computed(() => {
props.invalid props.invalid
? 'border-red-300 focus:border-red-500 focus:ring-red-500' ? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500', : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
props.disabled ? 'bg-gray-100 hover:border-gray-400' : '',
`block w-full rounded-md shadow-sm sm:text-sm transition-colors duration-200`, `block w-full rounded-md shadow-sm sm:text-sm transition-colors duration-200`,
'py-2 pl-3 pr-10 text-left cursor-default h-[38px] border-[1px] focus:ring-1', 'py-2 pl-3 pr-10 text-left cursor-default h-[38px] border-[1px] focus:ring-1',
]; ];

View File

@ -107,6 +107,7 @@ const inputClass = computed(() => {
props.invalid props.invalid
? 'border-red-300 focus:border-red-500 focus:ring-red-500' ? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500', : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
props.disabled ? 'bg-gray-100 hover:border-gray-400' : '',
`block w-full rounded-md shadow-sm sm:text-sm transition-colors duration-200`, `block w-full rounded-md shadow-sm sm:text-sm transition-colors duration-200`,
'py-2 pl-3 pr-10 text-left cursor-default h-[38px] border-[1px] focus:ring-1', 'py-2 pl-3 pr-10 text-left cursor-default h-[38px] border-[1px] focus:ring-1',
]; ];

View File

@ -4,10 +4,11 @@
:label="label" :label="label"
:placeholder="placeholder" :placeholder="placeholder"
:disabled="disabled" :disabled="disabled"
:required="required"
> >
<template #input="{ invalid, forId, value, setValue }"> <template #input="{ invalid, id, value, setValue }">
<Autocomplete <Autocomplete
:for-id="forId" :for-id="id"
:invalid="invalid" :invalid="invalid"
:disabled="disabled" :disabled="disabled"
:initialOptions="initialOptions" :initialOptions="initialOptions"
@ -44,6 +45,7 @@ const props = withDefaults(
bindValue?: string; bindValue?: string;
bindLabel?: string | ((obj: any) => string); bindLabel?: string | ((obj: any) => string);
disabled?: boolean; disabled?: boolean;
required?: boolean;
placeholder?: string; placeholder?: string;
}>(), }>(),
{ {

View File

@ -2,6 +2,7 @@
<FormField <FormField
:name="name" :name="name"
:label="label" :label="label"
:required="required"
:placeholder="placeholder" :placeholder="placeholder"
:disabled="disabled" :disabled="disabled"
> >
@ -25,6 +26,7 @@ const props = withDefaults(
forId?: string; forId?: string;
label: string; label: string;
name: string; name: string;
required?: boolean;
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
}>(), }>(),

View File

@ -3,14 +3,15 @@
:name="name" :name="name"
:label="label" :label="label"
:disabled="disabled" :disabled="disabled"
:required="required"
for-id-prefix="dp-input" for-id-prefix="dp-input"
> >
<template #input="{ invalid, fieldName, value, setValue }"> <template #input="{ invalid, fieldFQN, value, setValue }">
<Datepicker <Datepicker
text-input text-input
arrow-navigation arrow-navigation
:class="[invalid ? 'dp__invalid' : '']" :class="[invalid ? 'dp__invalid' : '']"
:uid="fieldName" :uid="fieldFQN"
:placeholder="placeholder" :placeholder="placeholder"
:disabled="disabled" :disabled="disabled"
:clearable="clearable" :clearable="clearable"
@ -32,6 +33,7 @@ interface DatePickerProps extends ExtractComponentProps<typeof Datepicker> {
label: string; label: string;
name: string; name: string;
disabled?: boolean; disabled?: boolean;
required?: boolean;
clearable?: boolean; clearable?: boolean;
placeholder?: string; placeholder?: string;
} }

View File

@ -2,6 +2,7 @@
<FormField <FormField
:name="name" :name="name"
:label="label" :label="label"
:required="required"
:placeholder="placeholder" :placeholder="placeholder"
:disabled="disabled" :disabled="disabled"
> >
@ -34,6 +35,7 @@ const props = withDefaults(
label: string; label: string;
name: string; name: string;
options: SelectOption[]; options: SelectOption[];
required?: boolean;
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
}>(), }>(),

View File

@ -0,0 +1,57 @@
<template>
<FormField
:name="name"
:label="label"
:disabled="disabled"
:required="required"
:readonly="readonly"
>
<template #input="{ invalid, id, value, onInput, onFocus, onBlur }">
<textarea
:id="id"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:clearable="clearable"
:model-value="(value as string)"
:class="inputClass(invalid)"
:rows="rows"
:cols="cols"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
/>
</template>
<template #default><slot /></template>
</FormField>
</template>
<script setup lang="ts">
import FormField from '../FormField.vue';
interface TextareaProps {
label: string;
name: string;
rows?: string;
cols?: string;
disabled?: boolean;
required?: boolean;
clearable?: boolean;
readonly?: boolean;
placeholder?: string;
}
const props = withDefaults(defineProps<TextareaProps>(), {
disabled: false,
});
const inputClass = (invalid: boolean) => {
return [
invalid
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
props.disabled ? 'bg-gray-100 hover:border-gray-400' : '',
`mt-1 block rounded-md shadow-sm sm:text-sm transition-colors duration-200`,
];
};
</script>

View File

@ -0,0 +1,296 @@
<template>
<Modal ref="modalRef" size="xl">
<template #title> Add a new item </template>
<template #default="{ closeModal }">
<FormAlert :message="error" />
<Form @submit="onSubmit" v-model="data" :validators="validators">
<FormAutocompleteField
v-if="typeof item !== 'string'"
name="item"
label="Item"
required
:search-fn="searchForItems"
>
<template #notfound="{ query }">
<button @click="startAddingNewItem(query)">Add a new item</button>
</template>
</FormAutocompleteField>
<template v-if="item && typeof item === 'string'">
<FormField name="displayName" label="Display Name" required />
<FormSelectField
:options="itemTypesOptions"
required
name="type"
label="Type"
/>
<FormField name="barcode" label="Barcode" />
<FormField
name="weight"
type="number"
step="0.001"
label="Weight (g)"
/>
<FormField name="url" label="URL" />
<FormField
name="consumable"
type="checkbox"
label="Consumable / Food item"
/>
<FormField name="public" type="checkbox" label="Public item" />
<FormTextareaField name="notes" label="Notes" />
</template>
<FormGroup name="transactionInfo">
<FormSelectField
:options="itemTransactionTypesOptions"
name="type"
required
label="Transaction type"
:disabled="!item"
/>
<FormDateField
name="actionAt"
label="Action committed at"
:disabled="!item"
required
/>
<div class="grid grid-cols-4 space-x-2">
<FormField
name="price"
label="Cost"
type="number"
step=".01"
class="col-span-3"
:disabled="!item"
/>
<FormSelectField
:options="currencies"
:disabled="!item"
name="currency"
label="Currency"
/>
</div>
<FormTextareaField
:disabled="!item"
name="notes"
label="Additional notes about the transaction"
/>
</FormGroup>
<FormGroup name="additionalInfo">
<div class="grid grid-cols-3 space-x-2">
<FormDateField
name="expiresAt"
label="Expires at"
:disabled="!item"
/>
<FormDateField
name="acquiredAt"
label="Acquired at"
:disabled="!item"
/>
<FormDateField
name="consumedAt"
label="Consumed at"
:disabled="!item"
/>
</div>
<FormTextareaField
name="notes"
label="Additional notes about the individual stored item"
:disabled="!item"
/>
</FormGroup>
<button type="submit">Submit</button>
</Form>
</template>
</Modal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useAccessToken } from '../../composables/useAccessToken';
import { BACKEND_URL } from '../../constants';
import { BuildingListItem } from '../../interfaces/building.interfaces';
import {
StorageItem,
StorageListItem,
StoredItem,
} from '../../interfaces/storage.interfaces';
import jfetch from '../../utils/jfetch';
import takeError from '../../utils/take-error';
import { FormSubmit, SelectOption } from '../form/form.types';
import Form from '../form/Form.vue';
import FormAlert from '../form/FormAlert.vue';
import FormField from '../form/FormField.vue';
import FormSelectField from '../form/fields/FormSelectField.vue';
import { IsRequired, MinLength } from '../form/validators';
import Modal from '../Modal.vue';
import { ItemType, ItemTypeName } from '../../enums/item-type.enum';
import {
TransactionType,
TransactionTypeDescription,
TransactionTypeName,
} from '../../enums/transaction-type.enum';
import FormGroup from '../form/FormGroup.vue';
import FormDateField from '../form/fields/FormDateField.vue';
import FormAutocompleteField from '../form/fields/FormAutocompleteField.vue';
import { FormValidator } from '../form/validator.types';
import deepUnref from '../../utils/deep-unref';
import FormTextareaField from '../form/fields/FormTextareaField.vue';
const defaults: any = {
item: null,
transactionInfo: {
actionAt: new Date(),
},
additionalInfo: {
acquiredAt: new Date(),
},
};
const { authHeader } = useAccessToken();
const modalRef = ref<InstanceType<typeof Modal>>();
const building = ref<BuildingListItem>();
const storage = ref<StorageListItem>();
const item = ref<StorageItem | string | null>(null);
const data: any = ref({
...deepUnref(defaults),
});
const error = ref('');
const emit = defineEmits<{
(e: 'added', storage: StoredItem): void;
}>();
const itemTypesOptions = computed(() => {
return Object.keys(ItemTypeName).reduce<SelectOption[]>(
(list, key) => [
...list,
{
value: key.toString(),
name: ItemTypeName[key as ItemType],
},
],
[]
);
});
const itemTransactionTypesOptions = computed(() => {
return Object.keys(TransactionTypeName).reduce<SelectOption[]>(
(list, key) => [
...list,
{
value: key.toString(),
name: TransactionTypeName[key as TransactionType],
description: TransactionTypeDescription[key as TransactionType],
},
],
[]
);
});
const validators = ref<FormValidator[]>([
{
field: 'displayName',
validators: [MinLength(3), IsRequired()],
},
{
field: 'type',
validators: [IsRequired()],
},
{
field: 'transactionInfo.type',
validators: [IsRequired()],
},
{
field: 'transactionInfo.actionAt',
validators: [IsRequired()],
},
]);
const currencies = [
{
value: 'EUR',
name: 'EUR',
},
{
value: 'USD',
name: 'USD',
},
];
const searchForItems = async (search: string) => {
if (!search || search.length < 3) return [];
const { data: list } = await jfetch(
`${BACKEND_URL}/storage/item?searchTerm=${search}`,
{
headers: authHeader.value,
}
);
return list;
};
defineExpose({
openModal: (useBuilding: BuildingListItem, useStorage: StorageListItem) => {
building.value = useBuilding;
storage.value = useStorage;
item.value = null;
data.value = {
...deepUnref(defaults),
};
modalRef.value?.openModal();
},
});
watch(
() => data.value.item,
(selected) => {
if (selected && selected.id) {
item.value = selected;
return;
}
item.value = null;
}
);
const startAddingNewItem = (insert: string) => {
item.value = insert;
data.value['displayName'] = insert;
};
const onSubmit = async (form: FormSubmit) => {
error.value = '';
if (!form.isValid || !storage.value) return;
const body = {
...form.formData,
};
delete body.item;
const requestUrl = `${BACKEND_URL}/storage/item/${storage.value.id}${
item.value && typeof item.value !== 'string' ? `/${item.value.id}` : ''
}`;
let createdStoredItem: StoredItem;
try {
const { data: response } = await jfetch(requestUrl, {
method: 'POST',
headers: authHeader.value,
body,
});
createdStoredItem = response;
} catch (e) {
error.value = takeError(e);
return;
}
emit('added', createdStoredItem);
modalRef.value?.closeModal();
};
</script>

View File

@ -6,6 +6,10 @@
.dp__input { .dp__input {
@apply block w-full rounded-md border-gray-300 shadow-sm transition-colors duration-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 sm:text-sm; @apply block w-full rounded-md border-gray-300 shadow-sm transition-colors duration-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 sm:text-sm;
height: 38px; height: 38px;
&.dp__disabled {
@apply bg-gray-100 hover:border-gray-400;
}
} }
.dp__invalid .dp__input { .dp__invalid .dp__input {

View File

@ -17,6 +17,7 @@
name="autocomplete" name="autocomplete"
label="autocomplete" label="autocomplete"
/> />
<FormTextareaField name="textarea" label="textarea" rows="3" />
<button type="submit">test</button> <button type="submit">test</button>
</Form> </Form>
</StandardLayout> </StandardLayout>
@ -31,6 +32,7 @@ import Form from '../components/form/Form.vue';
import FormField from '../components/form/FormField.vue'; import FormField from '../components/form/FormField.vue';
import { IsRequired } from '../components/form/validators'; import { IsRequired } from '../components/form/validators';
import FormAutocompleteField from '../components/form/fields/FormAutocompleteField.vue'; import FormAutocompleteField from '../components/form/fields/FormAutocompleteField.vue';
import FormTextareaField from '../components/form/fields/FormTextareaField.vue';
const userStore = useUserStore(); const userStore = useUserStore();
const user = ref(userStore.user); const user = ref(userStore.user);

View File

@ -118,6 +118,7 @@
@select-room="selectRoomFromList" @select-room="selectRoomFromList"
@select-storage="selectStorage" @select-storage="selectStorage"
@new-storage="addNewStorage" @new-storage="addNewStorage"
@add-item="newItem?.openModal(building!, selectedStorage!)"
/> />
<StoredItemCard <StoredItemCard
@ -127,6 +128,7 @@
/> />
<NewStorageModal ref="newStorage" @added="storageAdded" /> <NewStorageModal ref="newStorage" @added="storageAdded" />
<NewItemModal ref="newItem" @added="newStoredItemAdded" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -150,6 +152,7 @@ import {
StorageListItem, StorageListItem,
StorageSetListItem, StorageSetListItem,
StorageSharedType, StorageSharedType,
StoredItem,
} from '../../../interfaces/storage.interfaces'; } from '../../../interfaces/storage.interfaces';
import StorageBubble from './StorageBubble.vue'; import StorageBubble from './StorageBubble.vue';
import RoomPolygon from './RoomPolygon.vue'; import RoomPolygon from './RoomPolygon.vue';
@ -158,6 +161,7 @@ import isSet from '../../../utils/is-storage-set';
import ItemSelector from './ItemSelector.vue'; import ItemSelector from './ItemSelector.vue';
import StoredItemCard from '../../../components/item/StoredItemCard.vue'; import StoredItemCard from '../../../components/item/StoredItemCard.vue';
import NewStorageModal from '../../../components/item/NewStorageModal.vue'; import NewStorageModal from '../../../components/item/NewStorageModal.vue';
import NewItemModal from '../../../components/item/NewItemModal.vue';
const route = useRoute(); const route = useRoute();
const buildingStore = useBuildingStore(); const buildingStore = useBuildingStore();
@ -174,6 +178,7 @@ const selectedSet = ref<StorageSetListItem>();
const selectedStorage = ref<StorageListItem>(); const selectedStorage = ref<StorageListItem>();
const newStorage = ref<InstanceType<typeof NewStorageModal>>(); const newStorage = ref<InstanceType<typeof NewStorageModal>>();
const newItem = ref<InstanceType<typeof NewItemModal>>();
const floor = computed(() => const floor = computed(() =>
building.value?.floors.find( building.value?.floors.find(
@ -319,4 +324,13 @@ const storageAdded = async (newStorage: StorageSharedType) => {
]; ];
} }
}; };
const newStoredItemAdded = async (newItem: StoredItem) => {
if (!selectedStorage.value) return;
selectedStorage.value.items = [
newItem,
...(selectedStorage.value.items || []),
];
selectedStorage.value.itemCount = selectedStorage.value.items.length;
};
</script> </script>