Skip to content

Commit

Permalink
Merge pull request #260 from Perdolique/253-brand-selection
Browse files Browse the repository at this point in the history
feat(equipment): add brand selection and search functionality in equipment forms
  • Loading branch information
Perdolique authored Dec 4, 2024
2 parents 011a122 + f1e7898 commit 70203aa
Show file tree
Hide file tree
Showing 12 changed files with 580 additions and 207 deletions.
214 changes: 214 additions & 0 deletions app/components/ComboBox/ComboBox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<template>
<div
ref="rootRef"
:class="$style.component"
>
<PerdInput
:required="required"
:model-value="inputValue"
:label="label"
:placeholder="placeholder"
autocomplete="off"
@update:model-value="onUpdateInput"
/>

<div :class="[$style.options, { opened: isOpen }]">
<div
v-if="isLoading"
:class="[$style.option, 'loading']"
>
Loading...

<FidgetSpinner />
</div>

<div v-else-if="isEmpty" :class="[$style.option, 'empty']">
No results found
</div>

<template v-else>
<div
:class="$style.option"
v-for="option in filteredOptions"
:key="option.value"
@click="onOptionClick(option)"
>
{{ option.label }}
</div>
</template>
</div>
</div>
</template>

<script lang="ts" setup>
import { onClickOutside } from '@vueuse/core';
import PerdInput from '~/components/PerdInput.vue';
import FidgetSpinner from '../FidgetSpinner.vue';
interface Option {
readonly label: string;
readonly value: string;
}
interface Props {
readonly label: string;
readonly options: Option[];
readonly ignoreFilter?: boolean;
readonly loading?: boolean;
readonly maxItems?: number;
readonly placeholder?: string;
readonly required?: boolean;
}
interface Emits {
search: [query: string];
}
const {
ignoreFilter,
label,
loading,
maxItems = 4,
options,
placeholder
} = defineProps<Props>();
const emit = defineEmits<Emits>();
const selected = defineModel<Option | null>('selected', {
required: true
});
const rootRef = useTemplateRef('rootRef');
const inputValue = ref(selected.value?.label ?? '');
const isOpen = ref(false);
const isLoading = computed(() => ignoreFilter ? loading : false);
const filteredOptions = computed(() => {
if (ignoreFilter) {
return options
};
return options.filter(option =>
option.label.toLowerCase().includes(inputValue.value.toLowerCase())
);
});
const selectedLabel = computed(() => {
return selected.value?.label ?? '';
});
const isEmpty = computed(
() => filteredOptions.value.length === 0
);
const visibleOptionsCount = computed(() => {
if (isLoading.value || isEmpty.value) {
return 1;
}
return Math.max(Math.min(filteredOptions.value.length, maxItems), 1)
})
function onOptionClick(option: Option) {
selected.value = option;
inputValue.value = option.label;
isOpen.value = false;
}
function onUpdateInput(value: string) {
inputValue.value = value;
isOpen.value = true;
if (ignoreFilter) {
emit('search', value);
}
}
onClickOutside(rootRef, () => {
inputValue.value = selectedLabel.value;
isOpen.value = false
})
watch(selected, () => {
if (selected.value === null) {
inputValue.value = '';
}
})
</script>

<style module>
.component {
position: relative;
}
@starting-style {
.options {
opacity: 0;
}
}
.options {
--spacing-top: 4px;
display: none;
opacity: 0;
translate: 0 calc(var(--spacing-top) * -1);
position: absolute;
top: calc(100% + var(--spacing-top));
left: 0;
right: 0;
z-index: 1;
height: calc(v-bind(visibleOptionsCount) * var(--dropdown-option-height));
box-sizing: content-box;
overflow: hidden auto;
background-color: var(--input-color-background);
border: 1px solid var(--input-color-focus);
border-radius: var(--input-border-radius);
transition:
height var(--transition-time-medium),
translate var(--transition-time-medium),
opacity var(--transition-time-medium),
display var(--transition-time-medium) allow-discrete;
&:global(.opened) {
translate: 0;
display: block;
opacity: 1;
@starting-style {
translate: 0 calc(var(--spacing-top) * -1);
opacity: 0;
}
}
}
.option {
align-content: center;
height: var(--dropdown-option-height);
padding: var(--dropdown-option-padding);
color: var(--text-700);
cursor: pointer;
transition: background-color 0.15s ease-out;
&:hover {
background-color: var(--select-color-hover);
}
&:global(.loading),
&:global(.empty) {
color: var(--dropdown-option-color-placeholder);
cursor: default;
&:hover {
background-color: transparent;
}
}
&:global(.loading) {
display: grid;
grid-auto-flow: column;
justify-content: space-between;
}
}
</style>
16 changes: 15 additions & 1 deletion app/components/PerdInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
:required="required"
:type="type"
:disabled="disabled"
@focus="onFocus"
@blur="onBlur"
>

<label
Expand Down Expand Up @@ -48,7 +50,11 @@
readonly disabled?: InputHTMLAttributes['disabled'];
}
type Emits = (event: 'clear') => void;
interface Emits {
(event: 'clear') : void;
(event: 'focus') : void;
(event: 'blur') : void;
}
defineProps<Props>();
Expand All @@ -65,6 +71,14 @@
emit('clear');
}
function onFocus() {
emit('focus');
}
function onBlur() {
emit('blur');
}
</script>

<style module>
Expand Down
15 changes: 10 additions & 5 deletions app/components/equipment/AddEquipmentForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
v-model:weight="weight"
v-model:type-id="typeId"
v-model:group-id="groupId"
v-model:brand="brand"
:groups="groupOptions"
:types="typeOptions"
:submitting="isSubmitting"
Expand All @@ -23,13 +24,14 @@

<script lang="ts" setup>
import EmptyState from '~/components/EmptyState.vue';
import EditEquipmentForm from '~/components/equipment/EditEquipmentForm.vue';
import EditEquipmentForm, { type Brand } from '~/components/equipment/EditEquipmentForm.vue';
const name = ref('')
const description = ref('')
const weight = ref('')
const typeId = ref('')
const groupId = ref('')
const brand = ref<Brand | null>(null)
const isSubmitting = ref(false)
const { addToast } = useToaster()
const { showErrorToast } = useApiErrorToast()
Expand Down Expand Up @@ -59,7 +61,8 @@
description.value = ''
weight.value = ''
typeId.value = ''
groupId.value = ''
groupId.value = '',
brand.value = null
}
async function onSubmit() {
Expand All @@ -71,16 +74,18 @@
isSubmitting.value = true
const descriptionValue = description.value === '' ? undefined : description.value
const brandId = brand.value === null ? undefined : parseInt(brand.value.value)
await $fetch('/api/equipment/items', {
method: 'POST',
body: {
name: name.value,
brandId,
description: descriptionValue,
weight: parseInt(weight.value),
groupId: parseInt(groupId.value),
name: name.value,
typeId: parseInt(typeId.value),
groupId: parseInt(groupId.value)
weight: parseInt(weight.value)
}
})
Expand Down
Loading

0 comments on commit 70203aa

Please sign in to comment.