fix(web): search and multi-select data scope filters

Long group and source lists overflowed the team settings panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-25 10:48:15 +03:30
co-authored by Cursor
parent 546a7a7e46
commit 91637cb3a6
+171 -188
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed } from 'vue';
import { Select as ASelect } from 'ant-design-vue';
import type {
DataAccessOwnerKind,
DataAccessRule,
@@ -15,8 +16,6 @@ import {
PERSON_MOODS,
sectionHasDataAccessLimits,
} from '@mento/shared';
import { UiButton } from '@/components/ui';
export type IdOption = { id: string; name: string };
const props = withDefaults(
@@ -212,11 +211,37 @@ type ListKey =
| 'boardIds'
| 'typeIds';
function toggleInList(key: ListKey, value: string) {
const current = new Set((filter.value[key] as string[] | undefined) ?? []);
if (current.has(value)) current.delete(value);
else current.add(value);
const list = [...current];
type Choice = { value: string; label: string };
type PickerMode = 'multiple' | 'tags';
type Picker = {
key: ListKey;
legend: string;
mode: PickerMode;
placeholder: string;
notFound: string;
hint?: string;
options: Choice[];
};
function namedOptions(items: IdOption[]): Choice[] {
return items.map((item) => ({ value: item.id, label: item.name }));
}
function withSelected(options: Choice[], selected: readonly string[] | undefined): Choice[] {
const known = new Set(options.map((option) => option.value));
const extra = (selected ?? [])
.filter((id) => id && !known.has(id))
.map((id) => ({ value: id, label: id }));
return extra.length ? [...options, ...extra] : options;
}
function listValue(key: ListKey): string[] {
return ((filter.value[key] as string[] | undefined) ?? []).map(String);
}
function setList(key: ListKey, value: unknown) {
const raw = Array.isArray(value) ? value : value == null || value === '' ? [] : [value];
const list = [...new Set(raw.map((item) => String(item).trim()).filter(Boolean))];
patchFilter({ [key]: list.length ? list : undefined });
}
@@ -227,23 +252,121 @@ const sourceOptions = computed(() =>
})),
);
const moodOptions: Choice[] = PERSON_MOODS.map((mood) => ({
value: mood,
label: PERSON_MOOD_LABELS[mood],
}));
const pickers = computed((): Picker[] => {
const rule = filter.value;
const items: Picker[] = [];
if (showGroups.value) {
items.push({
key: 'groupIds',
legend: 'گروه‌ها',
mode: 'multiple',
placeholder: props.groups.length ? 'جستجو و انتخاب گروه…' : 'گروهی تعریف نشده',
notFound: props.groups.length ? 'موردی پیدا نشد' : 'گروهی تعریف نشده',
options: withSelected(namedOptions(props.groups), rule.groupIds),
});
}
if (showImportSources.value) {
items.push({
key: 'sources',
legend: 'منبع',
mode: 'tags',
placeholder: 'جستجو، انتخاب یا نوشتن منبع جدید…',
notFound: 'برای منبع جدید Enter بزنید',
hint: 'چند منبع را با هم انتخاب کنید. نام تازه را بنویسید و Enter بزنید.',
options: withSelected(sourceOptions.value, rule.sources),
});
}
if (showLeadSources.value) {
items.push({
key: 'sources',
legend: 'منبع لید',
mode: 'tags',
placeholder: 'مثلاً اینستاگرام، سایت…',
notFound: 'نام را بنویسید و Enter بزنید',
hint: 'هر منبع را بنویسید و با Enter اضافه کنید.',
options: withSelected([], rule.sources),
});
}
if (showNoteTypes.value) {
items.push({
key: 'sources',
legend: 'نوع یادداشت',
mode: 'multiple',
placeholder: 'جستجو و انتخاب نوع…',
notFound: 'موردی پیدا نشد',
options: withSelected(noteTypeOptions, rule.sources),
});
}
if (showTicketStatuses.value) {
items.push({
key: 'sources',
legend: 'وضعیت تیکت',
mode: 'multiple',
placeholder: 'جستجو و انتخاب وضعیت…',
notFound: 'موردی پیدا نشد',
options: withSelected(ticketStatusOptions, rule.sources),
});
}
if (showMoods.value) {
items.push({
key: 'moods',
legend: 'حال مشتری',
mode: 'multiple',
placeholder: 'جستجو و انتخاب حال مشتری…',
notFound: 'موردی پیدا نشد',
options: withSelected(moodOptions, rule.moods),
});
}
if (showPipelines.value) {
items.push({
key: 'pipelineIds',
legend: 'پایپ‌لاین',
mode: 'multiple',
placeholder: props.pipelines.length ? 'جستجو و انتخاب پایپ‌لاین…' : 'پایپ‌لاینی نیست',
notFound: props.pipelines.length ? 'موردی پیدا نشد' : 'پایپ‌لاینی نیست',
options: withSelected(namedOptions(props.pipelines), rule.pipelineIds),
});
}
if (showStages.value) {
items.push({
key: 'stageIds',
legend: 'مرحله',
mode: 'multiple',
placeholder: props.stages.length ? 'جستجو و انتخاب مرحله…' : 'مرحله‌ای نیست',
notFound: props.stages.length ? 'موردی پیدا نشد' : 'مرحله‌ای نیست',
options: withSelected(namedOptions(props.stages), rule.stageIds),
});
}
if (showBoards.value) {
items.push({
key: 'boardIds',
legend: boardsLegend.value,
mode: 'multiple',
placeholder: props.boards.length ? 'جستجو و انتخاب…' : 'بوردی نیست',
notFound: props.boards.length ? 'موردی پیدا نشد' : 'بوردی نیست',
options: withSelected(namedOptions(props.boards), rule.boardIds),
});
}
if (showTypes.value) {
items.push({
key: 'typeIds',
legend: typesLegend.value,
mode: 'multiple',
placeholder: props.activityTypes.length ? 'جستجو و انتخاب…' : 'موردی تعریف نشده',
notFound: props.activityTypes.length ? 'موردی پیدا نشد' : 'موردی تعریف نشده',
options: withSelected(namedOptions(props.activityTypes), rule.typeIds),
});
}
return items;
});
const hasLimits = computed(() => sectionHasDataAccessLimits(props.modelValue));
const customSource = ref('');
watch(
() => props.modelValue,
() => {
customSource.value = '';
},
);
function addCustomSource() {
const v = customSource.value.trim();
if (!v) return;
toggleInList('sources', v);
customSource.value = '';
}
const filterSummary = computed(() => {
const rule = filter.value;
const parts: string[] = [];
@@ -277,7 +400,7 @@ const filterSummary = computed(() => {
<p class="hint">
فقط رکوردهایی دیده می‌شوند که با
<strong>همهٔ فیلترهای پرشده</strong>
جور باشند. در هر ردیف، چند تیک یعنی هر کدام کافی است.
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
</p>
<p v-if="hadMultipleRules" class="hint warn">
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود.
@@ -305,160 +428,23 @@ const filterSummary = computed(() => {
</label>
</fieldset>
<fieldset v-if="showGroups" :disabled="disabled">
<legend>گروه‌ها</legend>
<p v-if="!groups.length" class="muted">گروهی تعریف نشده.</p>
<label v-for="g in groups" :key="g.id" class="chip">
<input
type="checkbox"
:checked="filter.groupIds?.includes(g.id)"
@change="toggleInList('groupIds', g.id)"
/>
{{ g.name }}
</label>
</fieldset>
<fieldset v-if="showImportSources" :disabled="disabled">
<legend>منبع</legend>
<label v-for="s in sourceOptions" :key="s.value" class="chip">
<input
type="checkbox"
:checked="filter.sources?.includes(s.value)"
@change="toggleInList('sources', s.value)"
/>
{{ s.label }}
</label>
<div class="custom-source">
<input
v-model="customSource"
type="text"
placeholder="منبع سفارشی…"
:disabled="disabled"
@keydown.enter.prevent="addCustomSource"
/>
<UiButton
v-if="!disabled"
type="button"
variant="secondary"
@click.prevent="addCustomSource"
>
افزودن
</UiButton>
</div>
</fieldset>
<fieldset v-if="showLeadSources" :disabled="disabled">
<legend>منبع لید</legend>
<div class="custom-source">
<input
v-model="customSource"
type="text"
placeholder="مثلاً اینستاگرام، سایت…"
:disabled="disabled"
@keydown.enter.prevent="addCustomSource"
/>
<UiButton
v-if="!disabled"
type="button"
variant="secondary"
@click.prevent="addCustomSource"
>
افزودن
</UiButton>
</div>
<label v-for="s in filter.sources ?? []" :key="s" class="chip">
<input type="checkbox" checked @change="toggleInList('sources', s)" />
{{ s }}
</label>
</fieldset>
<fieldset v-if="showNoteTypes" :disabled="disabled">
<legend>نوع یادداشت</legend>
<label v-for="t in noteTypeOptions" :key="t.value" class="chip">
<input
type="checkbox"
:checked="filter.sources?.includes(t.value)"
@change="toggleInList('sources', t.value)"
/>
{{ t.label }}
</label>
</fieldset>
<fieldset v-if="showTicketStatuses" :disabled="disabled">
<legend>وضعیت تیکت</legend>
<label v-for="t in ticketStatusOptions" :key="t.value" class="chip">
<input
type="checkbox"
:checked="filter.sources?.includes(t.value)"
@change="toggleInList('sources', t.value)"
/>
{{ t.label }}
</label>
</fieldset>
<fieldset v-if="showMoods" :disabled="disabled">
<legend>حال مشتری</legend>
<label v-for="m in PERSON_MOODS" :key="m" class="chip">
<input
type="checkbox"
:checked="filter.moods?.includes(m)"
@change="toggleInList('moods', m)"
/>
{{ PERSON_MOOD_LABELS[m] }}
</label>
</fieldset>
<fieldset v-if="showPipelines" :disabled="disabled">
<legend>پایپ‌لاین</legend>
<p v-if="!pipelines.length" class="muted">پایپ‌لاینی نیست.</p>
<label v-for="p in pipelines" :key="p.id" class="chip">
<input
type="checkbox"
:checked="filter.pipelineIds?.includes(p.id)"
@change="toggleInList('pipelineIds', p.id)"
/>
{{ p.name }}
</label>
</fieldset>
<fieldset v-if="showStages" :disabled="disabled">
<legend>مرحله</legend>
<p v-if="!stages.length" class="muted">مرحله‌ای نیست.</p>
<label v-for="s in stages" :key="s.id" class="chip">
<input
type="checkbox"
:checked="filter.stageIds?.includes(s.id)"
@change="toggleInList('stageIds', s.id)"
/>
{{ s.name }}
</label>
</fieldset>
<fieldset v-if="showBoards" :disabled="disabled">
<legend>{{ boardsLegend }}</legend>
<p v-if="!boards.length" class="muted">بوردی نیست.</p>
<label v-for="b in boards" :key="b.id" class="chip">
<input
type="checkbox"
:checked="filter.boardIds?.includes(b.id)"
@change="toggleInList('boardIds', b.id)"
/>
{{ b.name }}
</label>
</fieldset>
<fieldset v-if="showTypes" :disabled="disabled">
<legend>{{ typesLegend }}</legend>
<p v-if="!activityTypes.length" class="muted">موردی تعریف نشده.</p>
<label v-for="t in activityTypes" :key="t.id" class="chip">
<input
type="checkbox"
:checked="filter.typeIds?.includes(t.id)"
@change="toggleInList('typeIds', t.id)"
/>
{{ t.name }}
</label>
</fieldset>
<label v-for="picker in pickers" :key="picker.legend" class="pick">
<span>{{ picker.legend }}</span>
<a-select
:value="listValue(picker.key)"
:mode="picker.mode"
allow-clear
show-search
option-filter-prop="label"
:options="picker.options"
:disabled="disabled"
:placeholder="picker.placeholder"
:not-found-content="picker.notFound"
style="width: 100%"
@update:value="setList(picker.key, $event)"
/>
<p v-if="picker.hint" class="muted">{{ picker.hint }}</p>
</label>
</template>
<p v-else-if="!hasLimits" class="hint">
@@ -511,17 +497,14 @@ legend {
color: #475569;
font-weight: 600;
}
.custom-source {
display: flex;
gap: 0.4rem;
margin-top: 0.35rem;
.pick {
display: grid;
gap: 0.35rem;
}
.custom-source input {
flex: 1;
font: inherit;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.35rem 0.5rem;
.pick > span {
font-size: 0.8rem;
font-weight: 600;
color: #475569;
}
.compact fieldset {
padding: 0.45rem 0.55rem;