refactor: api select value (#69)

* feat: add file

* fix: wrong type

* feat: select customer component

* fixup! feat: select customer component

* fix: char case

* refactor: fn alias

* chore: add space

* feat: accept fetch parameter

* refactor: naming

* feat: add emit event create

* fix: add suffix to add text

* feat: add before options slot for select input comp

* fix: error when label not found

* fix: value type

* feat: add required param

* fix: wording

* refactor: fix customer

* feat: use new select component

* chore: add note

* feat: add decoration for select with creatable

* feat: emit event

* feat: close popup on click

* feat: adjust alignment

* feat: add readonly params

* feat: add select branch option

* feat: use new select component

* feat: add disabled params

* feat: adjust internal search and select

* refactor: props type

* feat: use new select component

* feat: add lib for select component

* refactor: use factory function instead

* refactor: merge two lines of code

* refactor: move watch inside

* refactor: fix value not in list check

* chore: cleanup

* fix: remove test page size

* chore: remove unused

* feat: use new select component

* fix: typo

* fix: error

* refactor: extract type

* refactor: change ref var to normal var

* refactor: force overwrite params to prevent error on render

* feat: add clearable parameter

* feat: make clearable
This commit is contained in:
Methapon Metanipat 2024-11-12 11:56:14 +07:00 committed by GitHub
parent a227744131
commit d414685fe7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 500 additions and 319 deletions

View file

@ -1,4 +1,4 @@
<script lang="ts" setup>
<script lang="ts" setup generic="T extends Record<string, unknown>">
import { onMounted, ref, watch } from 'vue';
import { selectFilterOptionRefMod } from 'src/stores/utils';
import { QSelect } from 'quasar';
@ -15,9 +15,9 @@ const props = withDefaults(
defineProps<{
id?: string;
label?: string;
option: Record<string, unknown>[];
optionLabel?: string;
optionValue?: string;
option: T[];
optionLabel?: keyof T;
optionValue?: keyof T;
placeholder?: string;
hideSelected?: boolean;
@ -40,14 +40,18 @@ const props = withDefaults(
);
defineEmits<{
(e: 'filter', val: string, update: void): void;
(
e: 'filter',
val: string,
update: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
): void;
}>();
onMounted(() => {
defaultFilter = selectFilterOptionRefMod(
ref(props.option),
options,
props.optionLabel,
typeof props.optionLabel === 'string' ? props.optionLabel : 'label',
);
});
@ -57,7 +61,7 @@ watch(
defaultFilter = selectFilterOptionRefMod(
ref(props.option),
options,
props.optionLabel,
typeof props.optionLabel === 'string' ? props.optionLabel : 'label',
);
},
);
@ -76,8 +80,12 @@ watch(
:fill-input="fillInput && !!model"
:hide-dropdown-icon="readonly"
input-debounce="500"
:option-value="optionValue"
:option-label="optionLabel"
:option-value="
typeof props.optionValue === 'string' ? props.optionValue : 'value'
"
:option-label="
typeof props.optionLabel === 'string' ? props.optionLabel : 'label'
"
v-model="model"
dense
autocomplete="off"
@ -95,9 +103,13 @@ watch(
<template v-if="$slots.prepend" v-slot:prepend>
<slot name="prepend"></slot>
</template>
<template v-if="$slots.append" v-slot:append>
<slot name="append"></slot>
</template>
<template v-slot:no-option>
<slot name="noOption"></slot>
<slot name="no-option"></slot>
<q-item v-if="!$slots.noOption">
<q-item-section class="text-grey">
@ -106,12 +118,20 @@ watch(
</q-item>
</template>
<template v-if="$slots.selectedItem" v-slot:selected-item="scope">
<slot name="selectedItem" :scope="scope"></slot>
<template
v-if="$slots.selectedItem || $slots['selected-item']"
v-slot:selected-item="scope"
>
<slot name="selectedItem" :scope="scope" :opt="scope.opt as T"></slot>
<slot name="selected-item" :scope="scope" :opt="scope.opt as T"></slot>
</template>
<template v-if="$slots.option" v-slot:option="scope">
<slot name="option" :scope="scope"></slot>
<slot name="option" :scope="scope" :opt="scope.opt as T"></slot>
</template>
<template v-if="$slots['before-options']" #before-options>
<slot name="before-options"></slot>
</template>
</q-select>
</template>

View file

@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { createSelect, SelectProps } from './select';
import SelectInput from '../SelectInput.vue';
import { Branch } from 'src/stores/branch/types';
import useStore from 'src/stores/branch';
type SelectOption = Branch;
const value = defineModel<string | null | undefined>('value', {
required: true,
});
const valueOption = defineModel<SelectOption>('valueOption', {
required: false,
});
const selectOptions = ref<SelectOption[]>([]);
const { fetchList: getList, fetchById: getById } = useStore();
defineEmits<{
(e: 'create'): void;
}>();
type ExclusiveProps = {
selectFirstValue?: boolean;
};
const props = defineProps<SelectProps<typeof getList> & ExclusiveProps>();
const { getOptions, setFirstValue, getSelectedOption, filter } =
createSelect<SelectOption>(
{
value,
valueOption,
selectOptions,
getList: async (query) => {
const ret = await getList({
query,
includeCustomer: true,
...props.params,
});
if (ret) return ret.result;
},
getByValue: async (id) => {
const ret = await getById(id);
if (ret) return ret;
},
},
{ valueField: 'id' },
);
onMounted(async () => {
await getOptions();
if (props.autoSelectOnSingle && selectOptions.value.length === 1) {
setFirstValue();
}
if (props.selectFirstValue) {
setDefaultValue();
} else await getSelectedOption();
});
function setDefaultValue() {
setFirstValue();
}
</script>
<template>
<SelectInput
v-model="value"
incremental
:label
:placeholder
:readonly
:disable="disabled"
:option="
selectOptions.map((v) => {
const ret = {
label: (
{
['eng']: [v.nameEN, `(${v.code})`].join(' '),
['tha']: [v.name, `(${v.code})`].join(' '),
} as const
)[$i18n.locale],
value: v.id,
};
return ret;
})
"
:hide-selected="false"
:fill-input="false"
:rules="[(v: string) => !!v || $t('form.error.required')]"
@filter="filter"
>
<template #append v-if="clearable">
<q-icon
v-if="!readonly && value"
name="mdi-close-circle"
@click.stop="value = ''"
class="cursor-pointer clear-btn"
/>
</template>
</SelectInput>
</template>

View file

@ -0,0 +1,128 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { createSelect, SelectProps } from './select';
import SelectInput from '../SelectInput.vue';
import SelectCustomerItem from './SelectCustomerItem.vue';
import { Customer, CustomerBranch } from 'src/stores/customer/types';
import useStore from 'src/stores/customer';
type SelectOption = CustomerBranch & { customer: Customer };
const value = defineModel<string | null | undefined>('value', {
required: true,
});
const valueOption = defineModel<SelectOption>('valueOption', {
required: false,
});
const selectOptions = ref<SelectOption[]>([]);
const {
fetchListCustomerBranch: getList,
fetchListCustomerBranchById: getById,
} = useStore();
defineEmits<{
(e: 'create'): void;
}>();
type ExclusiveProps = {
simple?: boolean;
};
const props = defineProps<SelectProps<typeof getList> & ExclusiveProps>();
const { getOptions, setFirstValue, getSelectedOption, filter } =
createSelect<SelectOption>(
{
value,
valueOption,
selectOptions,
getList: async (query) => {
const ret = await getList({
query,
...props.params,
includeCustomer: true,
});
if (ret) return ret.result;
},
getByValue: async (id) => {
const ret = await getById(id);
if (ret) return ret;
},
},
{ valueField: 'id' },
);
onMounted(async () => {
await getOptions();
if (props.autoSelectOnSingle && selectOptions.value.length === 1) {
setFirstValue();
}
await getSelectedOption();
});
</script>
<template>
<SelectInput
v-model="value"
option-value="id"
incremental
:label
:placeholder
:readonly
:disable="disabled"
:option="selectOptions"
:hide-selected="false"
:fill-input="false"
:rules="[(v: string) => !!v || $t('form.error.required')]"
@filter="filter"
>
<template #selected-item="{ opt }">
<SelectCustomerItem
v-if="typeof opt === 'object'"
:data="opt"
:simple
single-line
/>
</template>
<template #before-options v-if="creatable">
<q-item clickable v-close-popup @click.stop="$emit('create')">
<q-item-section>
<b class="row items-center">
<q-icon
name="mdi-plus-circle-outline"
class="q-mr-sm"
style="color: hsl(var(--positive-bg))"
/>
{{ $t('general.add', { text: $t('quotation.newCustomer') }) }}
</b>
</q-item-section>
</q-item>
<q-separator class="q-mx-sm" />
</template>
<template #option="{ opt, scope }">
<q-item v-bind="scope.itemProps">
<SelectCustomerItem :data="opt" :simple />
</q-item>
<q-separator class="q-mx-sm" />
</template>
<template #append v-if="clearable">
<q-icon
v-if="!readonly && value"
name="mdi-close-circle"
@click.stop="value = ''"
class="cursor-pointer clear-btn"
/>
</template>
</SelectInput>
</template>

View file

@ -0,0 +1,100 @@
<script lang="ts" setup>
import { Customer, CustomerBranch } from 'src/stores/customer/types';
import useOptionStore from 'src/stores/options';
import { formatAddress } from 'src/utils/address';
defineProps<{
data?: CustomerBranch & { customer: Customer };
simple?: boolean;
singleLine?: boolean;
}>();
const optionStore = useOptionStore();
</script>
<template>
<template v-if="data">
<div v-if="simple" class="row items-center">
{{
{
['CORP']: {
['eng']: data.registerNameEN,
['tha']: data.registerName,
}[$i18n.locale],
['PERS']:
{
['eng']: `${optionStore.mapOption(data.namePrefix)} ${data.firstNameEN} ${data.lastNameEN}`,
['tha']: `${optionStore.mapOption(data.namePrefix)} ${data.firstName} ${data.lastName}`,
}[$i18n.locale] || '-',
}[data.customer.customerType]
}}
({{ data.code }})
</div>
<div
v-else
:class="{
['row']: singleLine,
['items-center']: singleLine,
}"
>
<div class="q-mr-sm">
<span style="font-weight: 600">
{{
data.customer.customerType === 'CORP'
? $t('customer.form.registerName')
: $t('customer.form.ownerName')
}}:
</span>
{{
{
['CORP']: {
['eng']: data.registerNameEN,
['tha']: data.registerName,
}[$i18n.locale],
['PERS']:
{
['eng']: `${optionStore.mapOption(data.namePrefix)} ${data.firstNameEN} ${data.lastNameEN}`,
['tha']: `${optionStore.mapOption(data.namePrefix)} ${data.firstName} ${data.lastName}`,
}[$i18n.locale] || '-',
}[data.customer.customerType]
}}
({{ data.code }})
</div>
<div
class="text-caption app-text-muted-2"
v-if="data.customer && data.province"
>
{{
$t(
`branch.form.title.${data.code.endsWith('-00') ? 'branchHQLabel' : 'branchLabel'}`,
)
}}
{{ !data.code.endsWith('-00') ? +data.code.split('-')[1] : '' }}
</div>
<div
class="text-caption app-text-muted-2"
v-if="data.customer && data.province"
>
{{ $t('general.address') }}
{{
{
['eng']: formatAddress({ ...data, en: true }),
['tha']: formatAddress({ ...data, en: false }),
}[$i18n.locale]
}}
<q-tooltip v-if="data.customer && data.province">
{{ $t('customerBranch.form.title') }}:
{{ $t('general.address') }}
{{
{
['eng']: formatAddress({ ...data, en: true }),
['tha']: formatAddress({ ...data, en: false }),
}[$i18n.locale]
}}
</q-tooltip>
</div>
</div>
</template>
</template>

View file

@ -0,0 +1,94 @@
import { QSelect } from 'quasar';
import { Ref, watch } from 'vue';
export type SelectProps<T extends (...args: any[]) => any> = {
params?: Parameters<T>[0];
creatable?: boolean;
label?: string;
placeholder?: string;
readonly?: boolean;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
autoSelectOnSingle?: boolean;
};
export const createSelect = <T extends Record<string, any>>(
state: {
value: Ref<string | null | undefined>;
valueOption: Ref<T | undefined>;
selectOptions: Ref<T[]>;
getByValue: (id: string) => Promise<T | void> | T | void;
getList: (query?: string) => Promise<T[] | void> | T[] | void;
},
opts?: {
valueField?: keyof T;
},
) => {
const { value, valueOption, selectOptions, getList, getByValue } = state;
const valueField = opts?.valueField || 'value';
let cache: T[];
let previousSearch = '';
watch(value, (v) => {
if (!v || (cache && cache.find((opt) => opt[valueField] === v))) return;
getSelectedOption();
});
async function getOptions(query?: string) {
if (cache && selectOptions.value.length > 0 && previousSearch === query) {
selectOptions.value = JSON.parse(JSON.stringify(cache));
return;
}
const ret = await getList(query);
if (ret) {
cache = ret;
selectOptions.value = JSON.parse(JSON.stringify(cache));
previousSearch = query || previousSearch;
}
}
async function setFirstValue() {
if (value.value) return;
const first = selectOptions.value.at(0);
if (first) value.value = first[valueField];
}
async function getSelectedOption() {
const currentValue = value.value;
if (!currentValue) return;
if (selectOptions.value.find((v) => v[valueField] === currentValue)) return;
if (valueOption.value && valueOption.value[valueField] === currentValue) {
return selectOptions.value.unshift(valueOption.value);
}
const ret = await getByValue(currentValue);
if (ret) {
selectOptions.value.unshift(ret);
valueOption.value = ret;
}
}
type QuasarSelectUpdate = (
callback: () => void,
afterFn?: ((ref: QSelect) => void) | undefined,
) => void;
function filter(value: string, update: QuasarSelectUpdate) {
update(
() => getOptions(value),
(ref) => {
if (!!value && ref.options && ref.options.length > 0) {
ref.setOptionIndex(-1);
ref.moveOptionSelection(1, true);
}
},
);
}
return { getOptions, setFirstValue, getSelectedOption, filter };
};