Refactor AddPersons modal into a separate PersonChooseModal component for improved modularity and reusability.

This commit is contained in:
2025-09-11 18:07:13 +02:00
parent 38935edb93
commit 4c73c4d9d0
2 changed files with 450 additions and 462 deletions

View File

@@ -8,482 +8,62 @@
<span v-if="displayTextButton">{{ buttonTitle }}</span>
</a>
<teleport to="body">
<modal
v-if="showModal"
@close="closeModal"
:modal-dialog-class="modalDialogClass"
:show="showModal"
:hide-footer="false"
>
<template #header>
<h3 class="modal-title">
{{ modalTitle }}
</h3>
</template>
<template #body-head>
<div class="modal-body">
<div class="search">
<label class="col-form-label" style="float: right">
{{
trans(ADD_PERSONS_SUGGESTED_COUNTER, {
count: suggestedCounter,
})
}}
</label>
<input
id="search-persons"
name="query"
v-model="query"
:placeholder="trans(ADD_PERSONS_SEARCH_SOME_PERSONS)"
ref="searchRef"
/>
<i class="fa fa-search fa-lg" />
<i
class="fa fa-times"
v-if="queryLength >= 3"
@click="resetSuggestion"
/>
</div>
</div>
<div class="modal-body" v-if="checkUniq === 'checkbox'">
<div class="count">
<span>
<a v-if="suggestedCounter > 2" @click="selectAll">
{{ trans(ACTION_CHECK_ALL) }}
</a>
<a v-if="selectedCounter > 0" @click="resetSelection">
<i v-if="suggestedCounter > 2"> </i>
{{ trans(ACTION_RESET) }}
</a>
</span>
<span v-if="selectedCounter > 0">
{{
trans(ADD_PERSONS_SELECTED_COUNTER, {
count: selectedCounter,
})
}}
</span>
</div>
</div>
</template>
<template #body>
<div class="results">
<person-suggestion
v-for="item in selectedAndSuggested.slice().reverse()"
:key="itemKey(item)"
:item="item"
:search="search"
:type="checkUniq"
@save-form-on-the-fly="saveFormOnTheFly"
@new-prior-suggestion="newPriorSuggestion"
@update-selected="updateSelected"
/>
<div class="create-button">
<on-the-fly
v-if="
queryLength >= 3 &&
(options.type.includes('person') ||
options.type.includes('thirdparty'))
"
:button-text="trans(ONTHEFLY_CREATE_BUTTON, { q: query })"
:allowed-types="options.type"
:query="query"
action="create"
@save-form-on-the-fly="saveFormOnTheFly"
ref="onTheFly"
/>
</div>
</div>
</template>
<template #footer>
<button
class="btn btn-create"
@click.prevent="
() => {
$emit('addNewPersons', {
selected: selectedComputed,
});
query = '';
closeModal();
}
"
>
{{ trans(ACTION_ADD) }}
</button>
</template>
</modal>
</teleport>
<person-choose-modal
v-if="showModal"
:show="showModal"
:modal-title="modalTitle"
:options="options"
:suggested="suggested"
:selected="selected"
:modal-dialog-class="'modal-dialog-scrollable modal-xl'"
@close="closeModal"
@addNewPersons="payload => $emit('addNewPersons', payload)"
/>
</template>
<script setup lang="ts">
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { ref, reactive, computed, nextTick, watch, shallowRef } from "vue";
import PersonSuggestion from "./AddPersons/PersonSuggestion.vue";
import { searchEntities } from "ChillPersonAssets/vuejs/_api/AddPersons";
import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue";
import { ref, computed } from 'vue';
import PersonChooseModal from './AddPersons/PersonChooseModal.vue';
import type { Suggestion, SearchOptions } from 'ChillPersonAssets/types';
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import {
trans,
ADD_PERSONS_SUGGESTED_COUNTER,
ADD_PERSONS_SEARCH_SOME_PERSONS,
ADD_PERSONS_SELECTED_COUNTER,
ONTHEFLY_CREATE_BUTTON,
ACTION_CHECK_ALL,
ACTION_RESET,
ACTION_ADD,
} from "translator";
import {
Suggestion,
Search,
AddPersonResult as OriginalResult,
SearchOptions,
} from "ChillPersonAssets/types";
// Extend Result type to include optional addressId
type Result = OriginalResult & { addressId?: number };
const props = defineProps({
suggested: { type: Array as () => Suggestion[], default: () => [] },
selected: { type: Array as () => Suggestion[], default: () => [] },
buttonTitle: { type: String, required: true },
modalTitle: { type: String, required: true },
options: { type: Object as () => SearchOptions, required: true },
});
defineEmits(["addNewPersons"]);
const showModal = ref(false);
const modalDialogClass = ref("modal-dialog-scrollable modal-xl");
const modal = shallowRef({
showModal: false,
modalDialogClass: "modal-dialog-scrollable modal-xl",
});
const search = reactive({
query: "" as string,
previousQuery: "" as string,
currentSearchQueryController: null as AbortController | null,
suggested: props.suggested as Suggestion[],
selected: props.selected as Suggestion[],
priorSuggestion: {} as Partial<Suggestion>,
});
const searchRef = ref<HTMLInputElement | null>(null);
const onTheFly = ref<InstanceType<typeof OnTheFly> | null>(null);
const query = computed({
get: () => search.query,
set: (val) => setQuery(val),
});
const queryLength = computed(() => search.query.length);
const suggestedCounter = computed(() => search.suggested.length);
const selectedComputed = computed(() => search.selected);
const selectedCounter = computed(() => search.selected.length);
const getClassButton = computed(() => {
let size = props.options?.button?.size ?? "";
let type = props.options?.button?.type ?? "btn-create";
return size ? size + " " + type : type;
});
const displayTextButton = computed(() =>
props.options?.button?.display !== undefined
? props.options.button.display
: true,
);
const checkUniq = computed(() =>
props.options.uniq === true ? "radio" : "checkbox",
);
const priorSuggestion = computed(() => search.priorSuggestion);
const hasPriorSuggestion = computed(() => !!search.priorSuggestion.key);
const itemKey = (item: Suggestion) => item.result.type + item.result.id;
function addPriorSuggestion() {
if (hasPriorSuggestion.value) {
// Type assertion is safe here due to the checks above
search.suggested.unshift(priorSuggestion.value as Suggestion);
search.selected.unshift(priorSuggestion.value as Suggestion);
newPriorSuggestion(null);
}
interface AddPersonsConfig {
suggested?: Suggestion[];
selected?: Suggestion[];
buttonTitle: string;
modalTitle: string;
options: SearchOptions;
}
const selectedAndSuggested = computed(() => {
addPriorSuggestion();
const uniqBy = (a: Suggestion[], key: (item: Suggestion) => string) => [
...new Map(a.map((x) => [key(x), x])).values(),
];
let union = [
...new Set([
...search.suggested.slice().reverse(),
...search.selected.slice().reverse(),
]),
];
return uniqBy(union, (k: Suggestion) => k.key);
const props = withDefaults(defineProps<AddPersonsConfig>(), {
suggested: () => [],
selected: () => [],
});
const emit = defineEmits<{
(e: 'addNewPersons', payload: { selected: Suggestion[] }): void;
}>();
const showModal = ref(false);
const getClassButton = computed(() => {
const size = props.options?.button?.size ?? '';
const type = props.options?.button?.type ?? 'btn-create';
return size ? `${size} ${type}` : type;
});
const displayTextButton = computed(() =>
props.options?.button?.display !== undefined ? props.options.button.display : true,
);
function openModal() {
showModal.value = true;
nextTick(() => {
if (searchRef.value) searchRef.value.focus();
});
}
function closeModal() {
showModal.value = false;
}
function setQuery(q: string) {
search.query = q;
// Clear previous search if any
if (search.currentSearchQueryController) {
search.currentSearchQueryController.abort();
search.currentSearchQueryController = null;
}
if (q === "") {
loadSuggestions([]);
return;
}
// Debounce delay based on query length
const delay = q.length > 3 ? 300 : 700;
setTimeout(() => {
// Only search if query hasn't changed in the meantime
if (q !== search.query) return;
search.currentSearchQueryController = new AbortController();
searchEntities(
{ query: q, options: props.options },
search.currentSearchQueryController.signal,
)
.then((suggested: Search) => {
loadSuggestions(suggested.results);
})
.catch((error: DOMException) => {
if (error instanceof DOMException && error.name === "AbortError") {
// Request was aborted, ignore
return;
}
throw error;
});
}, delay);
}
function loadSuggestions(suggestedArr: Suggestion[]) {
search.suggested = suggestedArr;
search.suggested.forEach((item) => {
item.key = itemKey(item);
});
}
function updateSelected(value: Suggestion[]) {
search.selected = value;
}
function resetSuggestion() {
search.query = "";
search.suggested = [];
}
function resetSelection() {
search.selected = [];
}
function resetSearch() {
resetSelection();
resetSuggestion();
}
function selectAll() {
search.suggested.forEach((item) => {
search.selected.push(item);
});
}
function newPriorSuggestion(entity: Result | null) {
if (entity !== null) {
let suggestion = {
key: entity.type + entity.id,
relevance: 0.5,
result: entity,
};
search.priorSuggestion = suggestion;
} else {
search.priorSuggestion = {};
}
}
async function saveFormOnTheFly({
type,
data,
}: {
type: string;
data: Result;
}) {
try {
if (type === "person") {
const responsePerson: Result = await makeFetch(
"POST",
"/api/1.0/person/person.json",
data,
);
newPriorSuggestion(responsePerson);
if (onTheFly.value) onTheFly.value.closeModal();
if (data.addressId != null) {
const household = { type: "household" };
const address = { id: data.addressId };
try {
const responseHousehold: Result = await makeFetch(
"POST",
"/api/1.0/person/household.json",
household,
);
const member = {
concerned: [
{
person: {
type: "person",
id: responsePerson.id,
},
start_date: {
datetime: `${new Date().toISOString().split("T")[0]}T00:00:00+02:00`,
},
holder: false,
comment: null,
},
],
destination: {
type: "household",
id: responseHousehold.id,
},
composition: null,
};
await makeFetch(
"POST",
"/api/1.0/person/household/members/move.json",
member,
);
try {
const _response = await makeFetch(
"POST",
`/api/1.0/person/household/${responseHousehold.id}/address.json`,
address,
);
console.log(_response);
} catch (error) {
console.error(error);
}
} catch (error) {
console.error(error);
}
}
} else if (type === "thirdparty") {
const response: Result = await makeFetch(
"POST",
"/api/1.0/thirdparty/thirdparty.json",
data,
);
newPriorSuggestion(response);
if (onTheFly.value) onTheFly.value.closeModal();
}
} catch (error) {
console.error(error);
}
}
watch(
() => props.selected,
(newSelected) => {
search.selected = newSelected;
},
{ deep: true },
);
watch(
() => props.suggested,
(newSuggested) => {
search.suggested = newSuggested;
},
{ deep: true },
);
watch(
() => modal,
(val) => {
showModal.value = val.value.showModal;
modalDialogClass.value = val.value.modalDialogClass;
},
{ deep: true },
);
defineExpose({
resetSearch,
showModal,
});
</script>
<style lang="scss">
li.add-persons {
a {
cursor: pointer;
}
}
div.body-head {
overflow-y: unset;
div.modal-body:first-child {
margin: auto 4em;
div.search {
position: relative;
input {
width: 100%;
padding: 1.2em 1.5em 1.2em 2.5em;
//margin: 1em 0;
}
i {
position: absolute;
opacity: 0.5;
padding: 0.65em 0;
top: 50%;
}
i.fa-search {
left: 0.5em;
}
i.fa-times {
right: 1em;
padding: 0.75em 0;
cursor: pointer;
}
}
}
div.modal-body:last-child {
padding-bottom: 0;
}
div.count {
margin: -0.5em 0 0.7em;
display: flex;
justify-content: space-between;
a {
cursor: pointer;
}
}
}
.create-button > a {
margin-top: 0.5em;
margin-left: 2.6em;
}
<style lang="scss" scoped>
/* Button styles can remain here if needed */
</style>

View File

@@ -0,0 +1,408 @@
<template>
<teleport to="body">
<modal
v-if="show"
@close="() => emit('close')"
:modal-dialog-class="modalDialogClass"
:show="show"
:hide-footer="false"
>
<template #header>
<h3 class="modal-title">{{ modalTitle }}</h3>
</template>
<template #body-head>
<div class="modal-body">
<div class="search">
<label class="col-form-label" style="float: right">
{{
trans(ADD_PERSONS_SUGGESTED_COUNTER, { count: suggestedCounter })
}}
</label>
<input
id="search-persons"
name="query"
v-model="query"
:placeholder="trans(ADD_PERSONS_SEARCH_SOME_PERSONS)"
ref="searchRef"
/>
<i class="fa fa-search fa-lg" />
<i class="fa fa-times" v-if="queryLength >= 3" @click="resetSuggestion" />
</div>
</div>
<div class="modal-body" v-if="checkUniq === 'checkbox'">
<div class="count">
<span>
<a v-if="suggestedCounter > 2" @click="selectAll">
{{ trans(ACTION_CHECK_ALL) }}
</a>
<a v-if="selectedCounter > 0" @click="resetSelection">
<i v-if="suggestedCounter > 2"> </i>
{{ trans(ACTION_RESET) }}
</a>
</span>
<span v-if="selectedCounter > 0">
{{ trans(ADD_PERSONS_SELECTED_COUNTER, { count: selectedCounter }) }}
</span>
</div>
</div>
</template>
<template #body>
<div class="results">
<person-suggestion
v-for="item in selectedAndSuggested.slice().reverse()"
:key="itemKey(item)"
:item="item"
:search="search"
:type="checkUniq"
@save-form-on-the-fly="saveFormOnTheFly"
@new-prior-suggestion="newPriorSuggestion"
@update-selected="updateSelected"
/>
<div class="create-button">
<on-the-fly
v-if="
queryLength >= 3 &&
(options.type.includes('person') || options.type.includes('thirdparty'))
"
:button-text="trans(ONTHEFLY_CREATE_BUTTON, { q: query })"
:allowed-types="options.type"
:query="query"
action="create"
@save-form-on-the-fly="saveFormOnTheFly"
ref="onTheFly"
/>
</div>
</div>
</template>
<template #footer>
<button
type="button"
class="btn btn-create"
@click.prevent="pickEntities"
>
{{ trans(ACTION_ADD) }}
</button>
</template>
</modal>
</teleport>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick, watch } from 'vue';
import Modal from 'ChillMainAssets/vuejs/_components/Modal.vue';
import PersonSuggestion from './PersonSuggestion.vue';
import OnTheFly from 'ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue';
import { searchEntities } from 'ChillPersonAssets/vuejs/_api/AddPersons';
import { makeFetch } from 'ChillMainAssets/lib/api/apiMethods';
import {
trans,
ADD_PERSONS_SUGGESTED_COUNTER,
ADD_PERSONS_SEARCH_SOME_PERSONS,
ADD_PERSONS_SELECTED_COUNTER,
ONTHEFLY_CREATE_BUTTON,
ACTION_CHECK_ALL,
ACTION_RESET,
ACTION_ADD,
} from 'translator';
import type {
Suggestion,
Search,
AddPersonResult as OriginalResult,
SearchOptions, EntitiesOrMe,
} from 'ChillPersonAssets/types';
type Result = OriginalResult & { addressId?: number };
interface Props {
show: boolean;
modalTitle: string;
options: SearchOptions;
suggested?: Suggestion[];
selected?: Suggestion[];
modalDialogClass?: string;
}
const props = withDefaults(defineProps<Props>(), {
suggested: () => [],
selected: () => [],
modalDialogClass: 'modal-dialog-scrollable modal-xl',
});
const emit = defineEmits<{
(e: 'close'): void;
(e: 'addNewPersons', payload: { selected: Suggestion[] }): void;
(e: 'onPickEntities', payload: { selected: EntitiesOrMe[] }): void;
}>();
const searchRef = ref<HTMLInputElement | null>(null);
const onTheFly = ref<InstanceType<typeof OnTheFly> | null>(null);
watch(
() => props.show,
async (visible) => {
if (visible) {
await nextTick();
searchRef.value?.focus();
}
}
);
const search = reactive({
query: '' as string,
previousQuery: '' as string,
currentSearchQueryController: null as AbortController | null,
suggested: (props.suggested ?? []) as Suggestion[],
selected: (props.selected ?? []) as Suggestion[],
priorSuggestion: {} as Partial<Suggestion>,
});
watch(
() => props.selected,
(newSelected) => {
search.selected = newSelected ? [...newSelected] : [];
},
{ deep: true }
);
watch(
() => props.suggested,
(newSuggested) => {
search.suggested = newSuggested ? [...newSuggested] : [];
},
{ deep: true }
);
const query = computed({
get: () => search.query,
set: (val: string) => setQuery(val),
});
const queryLength = computed(() => search.query.length);
const suggestedCounter = computed(() => search.suggested.length);
const selectedComputed = computed<Suggestion[]>(() => search.selected);
const selectedCounter = computed(() => search.selected.length);
const checkUniq = computed(() => (props.options.uniq === true ? 'radio' : 'checkbox'));
const priorSuggestion = computed(() => search.priorSuggestion);
const hasPriorSuggestion = computed(() => !!search.priorSuggestion.key);
const itemKey = (item: Suggestion) => item.result.type + item.result.id;
function addPriorSuggestion() {
if (hasPriorSuggestion.value) {
search.suggested.unshift(priorSuggestion.value as Suggestion);
search.selected.unshift(priorSuggestion.value as Suggestion);
newPriorSuggestion(null);
}
}
const selectedAndSuggested = computed(() => {
addPriorSuggestion();
const uniqBy = (a: Suggestion[], key: (item: Suggestion) => string) => [
...new Map(a.map((x) => [key(x), x])).values(),
];
const union = [
...new Set([
...search.suggested.slice().reverse(),
...search.selected.slice().reverse(),
]),
];
return uniqBy(union, (k: Suggestion) => k.key);
});
function setQuery(q: string) {
search.query = q;
if (search.currentSearchQueryController) {
search.currentSearchQueryController.abort();
search.currentSearchQueryController = null;
}
if (q === '') {
loadSuggestions([]);
return;
}
const delay = q.length > 3 ? 300 : 700;
setTimeout(() => {
if (q !== search.query) return;
search.currentSearchQueryController = new AbortController();
searchEntities(
{ query: q, options: props.options },
search.currentSearchQueryController.signal,
)
.then((suggested: Search) => {
loadSuggestions(suggested.results);
})
.catch((error: DOMException) => {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
throw error;
});
}, delay);
}
function loadSuggestions(suggestedArr: Suggestion[]) {
search.suggested = suggestedArr;
search.suggested.forEach((item) => {
item.key = itemKey(item);
});
}
function updateSelected(value: Suggestion[]) {
search.selected = value;
}
function resetSuggestion() {
search.query = '';
search.suggested = [];
}
function resetSelection() {
search.selected = [];
}
function resetSearch() {
resetSelection();
resetSuggestion();
}
function selectAll() {
search.suggested.forEach((item) => {
search.selected.push(item);
});
}
function newPriorSuggestion(entity: Result | null) {
if (entity !== null) {
const suggestion: Suggestion = {
key: entity.type + entity.id,
relevance: 0.5,
result: entity,
} as Suggestion;
search.priorSuggestion = suggestion;
} else {
search.priorSuggestion = {};
}
}
/**
* Triggered when the user clicks on the "add" button.
*/
function pickEntities(): void {
emit('addNewPersons', { selected: search.selected });
emit('onPickEntities', {selected: search.selected.map((s: Suggestion) => s.result )})
search.query = '';
emit('close');
}
async function saveFormOnTheFly({ type, data }: { type: string; data: Result }) {
try {
if (type === 'person') {
const responsePerson: Result = await makeFetch('POST', '/api/1.0/person/person.json', data);
newPriorSuggestion(responsePerson);
onTheFly.value?.closeModal();
if (data.addressId != null) {
const household = { type: 'household' };
const address = { id: data.addressId };
try {
const responseHousehold: Result = await makeFetch('POST', '/api/1.0/person/household.json', household);
const member = {
concerned: [
{
person: { type: 'person', id: responsePerson.id },
start_date: { datetime: `${new Date().toISOString().split('T')[0]}T00:00:00+02:00` },
holder: false,
comment: null,
},
],
destination: { type: 'household', id: responseHousehold.id },
composition: null,
};
await makeFetch('POST', '/api/1.0/person/household/members/move.json', member);
try {
await makeFetch('POST', `/api/1.0/person/household/${responseHousehold.id}/address.json`, address);
} catch (error) {
console.error(error);
}
} catch (error) {
console.error(error);
}
}
} else if (type === 'thirdparty') {
const response: Result = await makeFetch('POST', '/api/1.0/thirdparty/thirdparty.json', data);
newPriorSuggestion(response);
onTheFly.value?.closeModal();
}
} catch (error) {
console.error(error);
}
}
defineExpose({ resetSearch });
</script>
<style lang="scss" scoped>
li.add-persons {
a {
cursor: pointer;
}
}
div.body-head {
overflow-y: unset;
div.modal-body:first-child {
margin: auto 4em;
div.search {
position: relative;
input {
width: 100%;
padding: 1.2em 1.5em 1.2em 2.5em;
}
i {
position: absolute;
opacity: 0.5;
padding: 0.65em 0;
top: 50%;
}
i.fa-search {
left: 0.5em;
}
i.fa-times {
right: 1em;
padding: 0.75em 0;
cursor: pointer;
}
}
}
div.modal-body:last-child {
padding-bottom: 0;
}
div.count {
margin: -0.5em 0 0.7em;
display: flex;
justify-content: space-between;
a {
cursor: pointer;
}
}
}
.create-button > a {
margin-top: 0.5em;
margin-left: 2.6em;
}
</style>