feat(ui): add select all / clear all shortcuts for inbound multi-select (#5175)

* feat(ui): add select all / clear all shortcuts for inbound multi-select

Adds 'Select all' and 'Clear all' buttons above the inbound multi-select in:
- ClientFormModal (add/edit client)
- BulkAttachInboundsModal (bulk attach clients to inbounds)
- BulkDetachInboundsModal (bulk detach clients from inbounds)
- ClientBulkAddModal (add bulk clients)

Extracts the repeated button logic into a reusable SelectAllClearButtons component.

Includes i18n keys for all 13 supported languages with proper translations.

Closes #5144

* refactor(form): decouple SelectAllClearButtons labels and harden select-all

Accept optional selectAllLabel/clearLabel props so the generic form component is not tied to the client-inbound i18n keys (defaults unchanged). Compute the all-selected state by checking every option is present and union the current value on select-all, so it stays correct if value holds ids outside options.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Nikan Zeyaei
2026-06-11 13:09:58 +02:00
committed by GitHub
co-authored by Sanaei
parent ffde2f7ebf
commit 07e5e8498e
19 changed files with 126 additions and 24 deletions
@@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next';
import { Button } from 'antd';
interface Option {
value: number;
}
interface SelectAllClearButtonsProps {
options: Option[];
value: number[];
onChange: (value: number[]) => void;
/** Override the default "Select all" label (defaults to the inbound copy). */
selectAllLabel?: string;
/** Override the default "Clear all" label (defaults to the inbound copy). */
clearLabel?: string;
}
export default function SelectAllClearButtons({
options,
value,
onChange,
selectAllLabel,
clearLabel,
}: SelectAllClearButtonsProps) {
const { t } = useTranslation();
const optionValues = options.map((o) => o.value);
// Treat as "all selected" when every option is chosen, rather than comparing
// lengths — this stays correct even if `value` holds ids outside `options`.
const allSelected = options.length > 0 && optionValues.every((v) => value.includes(v));
return (
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Button
size="small"
disabled={allSelected}
// Union with the current value so selections outside `options` are kept.
onClick={() => onChange(Array.from(new Set([...value, ...optionValues])))}
>
{selectAllLabel ?? t('pages.clients.selectAllInbounds')}
</Button>
<Button
size="small"
disabled={value.length === 0}
onClick={() => onChange([])}
>
{clearLabel ?? t('pages.clients.clearAllInbounds')}
</Button>
</div>
);
}
+1
View File
@@ -1,3 +1,4 @@
export { default as DateTimePicker } from './DateTimePicker'; export { default as DateTimePicker } from './DateTimePicker';
export { default as JsonEditor } from './JsonEditor'; export { default as JsonEditor } from './JsonEditor';
export { default as HeaderMapEditor } from './HeaderMapEditor'; export { default as HeaderMapEditor } from './HeaderMapEditor';
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Modal, Select, Typography, message } from 'antd'; import { Alert, Modal, Select, Typography, message } from 'antd';
import { SelectAllClearButtons } from '@/components/form';
import type { InboundOption } from '@/hooks/useClients'; import type { InboundOption } from '@/hooks/useClients';
import { formatInboundLabel } from '@/lib/inbounds/label'; import { formatInboundLabel } from '@/lib/inbounds/label';
import type { BulkAttachResult } from '@/schemas/client'; import type { BulkAttachResult } from '@/schemas/client';
@@ -82,16 +83,23 @@ export default function BulkAttachInboundsModal({
{targetOptions.length === 0 ? ( {targetOptions.length === 0 ? (
<Alert type="info" showIcon message={t('pages.clients.attachToInboundsNoTargets')} /> <Alert type="info" showIcon message={t('pages.clients.attachToInboundsNoTargets')} />
) : ( ) : (
<Select <>
mode="multiple" <SelectAllClearButtons
style={{ width: '100%' }} options={targetOptions}
value={targetIds} value={targetIds}
onChange={setTargetIds} onChange={setTargetIds}
options={targetOptions} />
placeholder={t('pages.clients.attachToInboundsTargets')} <Select
optionFilterProp="label" mode="multiple"
autoFocus style={{ width: '100%' }}
/> value={targetIds}
onChange={setTargetIds}
options={targetOptions}
placeholder={t('pages.clients.attachToInboundsTargets')}
optionFilterProp="label"
autoFocus
/>
</>
)} )}
</Modal> </Modal>
</> </>
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Modal, Select, Typography, message } from 'antd'; import { Alert, Modal, Select, Typography, message } from 'antd';
import { SelectAllClearButtons } from '@/components/form';
import type { InboundOption } from '@/hooks/useClients'; import type { InboundOption } from '@/hooks/useClients';
import { formatInboundLabel } from '@/lib/inbounds/label'; import { formatInboundLabel } from '@/lib/inbounds/label';
import type { BulkDetachResult } from '@/schemas/client'; import type { BulkDetachResult } from '@/schemas/client';
@@ -82,16 +83,23 @@ export default function BulkDetachInboundsModal({
{targetOptions.length === 0 ? ( {targetOptions.length === 0 ? (
<Alert type="info" showIcon message={t('pages.clients.detachFromInboundsNoTargets')} /> <Alert type="info" showIcon message={t('pages.clients.detachFromInboundsNoTargets')} />
) : ( ) : (
<Select <>
mode="multiple" <SelectAllClearButtons
style={{ width: '100%' }} options={targetOptions}
value={targetIds} value={targetIds}
onChange={setTargetIds} onChange={setTargetIds}
options={targetOptions} />
placeholder={t('pages.clients.detachFromInboundsTargets')} <Select
optionFilterProp="label" mode="multiple"
autoFocus style={{ width: '100%' }}
/> value={targetIds}
onChange={setTargetIds}
options={targetOptions}
placeholder={t('pages.clients.detachFromInboundsTargets')}
optionFilterProp="label"
autoFocus
/>
</>
)} )}
</Modal> </Modal>
</> </>
@@ -8,7 +8,7 @@ import type { Dayjs } from 'dayjs';
import { RandomUtil, SizeFormatter } from '@/utils'; import { RandomUtil, SizeFormatter } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label'; import { formatInboundLabel } from '@/lib/inbounds/label';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives'; import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
import { DateTimePicker } from '@/components/form'; import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { useClients, type InboundOption } from '@/hooks/useClients'; import { useClients, type InboundOption } from '@/hooks/useClients';
import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client'; import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
@@ -213,6 +213,11 @@ export default function ClientBulkAddModal({
> >
<Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}> <Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
<Form.Item label={t('pages.clients.attachedInbounds')} required> <Form.Item label={t('pages.clients.attachedInbounds')} required>
<SelectAllClearButtons
options={inboundOptions}
value={form.inboundIds}
onChange={(v) => update('inboundIds', v)}
/>
<Select <Select
mode="multiple" mode="multiple"
value={form.inboundIds} value={form.inboundIds}
@@ -18,10 +18,9 @@ import {
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons'; import { EyeOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { HttpUtil, RandomUtil } from '@/utils'; import { HttpUtil, RandomUtil } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label'; import { formatInboundLabel } from '@/lib/inbounds/label';
import { DateTimePicker } from '@/components/form'; import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives'; import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client'; import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
@@ -601,6 +600,11 @@ export default function ClientFormModal({
</Row> </Row>
<Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}> <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
<SelectAllClearButtons
options={inboundOptions}
value={form.inboundIds}
onChange={(v) => update('inboundIds', v)}
/>
<Select <Select
mode="multiple" mode="multiple"
value={form.inboundIds} value={form.inboundIds}
+2
View File
@@ -703,6 +703,8 @@
"duration": "المدة", "duration": "المدة",
"attachedInbounds": "الاتصالات الواردة المرتبطة", "attachedInbounds": "الاتصالات الواردة المرتبطة",
"selectInbound": "حدد اتصالاً واردًا واحدًا أو أكثر", "selectInbound": "حدد اتصالاً واردًا واحدًا أو أكثر",
"selectAllInbounds": "تحديد الكل",
"clearAllInbounds": "مسح الكل",
"noSubId": "هذا العميل ليس لديه subId، لا يوجد رابط قابل للمشاركة.", "noSubId": "هذا العميل ليس لديه subId، لا يوجد رابط قابل للمشاركة.",
"noLinks": "لا توجد روابط للمشاركة — قم بإرفاق هذا العميل بأحد الاتصالات الواردة الداعمة للبروتوكول أولاً.", "noLinks": "لا توجد روابط للمشاركة — قم بإرفاق هذا العميل بأحد الاتصالات الواردة الداعمة للبروتوكول أولاً.",
"link": "الرابط", "link": "الرابط",
+2
View File
@@ -704,6 +704,8 @@
"duration": "Duration", "duration": "Duration",
"attachedInbounds": "Attached inbounds", "attachedInbounds": "Attached inbounds",
"selectInbound": "Select one or more inbounds", "selectInbound": "Select one or more inbounds",
"selectAllInbounds": "Select all",
"clearAllInbounds": "Clear all",
"noSubId": "This client has no subId, no shareable link.", "noSubId": "This client has no subId, no shareable link.",
"noLinks": "No shareable links — attach this client to a protocol-capable inbound first.", "noLinks": "No shareable links — attach this client to a protocol-capable inbound first.",
"link": "Link", "link": "Link",
+2
View File
@@ -703,6 +703,8 @@
"duration": "Duración", "duration": "Duración",
"attachedInbounds": "Inbounds asociados", "attachedInbounds": "Inbounds asociados",
"selectInbound": "Selecciona uno o más inbounds", "selectInbound": "Selecciona uno o más inbounds",
"selectAllInbounds": "Seleccionar todo",
"clearAllInbounds": "Limpiar todo",
"noSubId": "Este cliente no tiene subId, no hay enlace compartible.", "noSubId": "Este cliente no tiene subId, no hay enlace compartible.",
"noLinks": "No hay enlaces compartibles — asocia primero este cliente a un inbound con protocolo válido.", "noLinks": "No hay enlaces compartibles — asocia primero este cliente a un inbound con protocolo válido.",
"link": "Enlace", "link": "Enlace",
+2
View File
@@ -703,6 +703,8 @@
"duration": "مدت", "duration": "مدت",
"attachedInbounds": "اینباندهای متصل", "attachedInbounds": "اینباندهای متصل",
"selectInbound": "یک یا چند اینباند انتخاب کنید", "selectInbound": "یک یا چند اینباند انتخاب کنید",
"selectAllInbounds": "انتخاب همه",
"clearAllInbounds": "پاک کردن همه",
"noSubId": "این کلاینت subId ندارد، لینک اشتراک‌گذاری وجود ندارد.", "noSubId": "این کلاینت subId ندارد، لینک اشتراک‌گذاری وجود ندارد.",
"noLinks": "لینکی برای اشتراک‌گذاری نیست — ابتدا این کلاینت را به یک اینباند با پروتکل سازگار متصل کنید.", "noLinks": "لینکی برای اشتراک‌گذاری نیست — ابتدا این کلاینت را به یک اینباند با پروتکل سازگار متصل کنید.",
"link": "لینک", "link": "لینک",
+2
View File
@@ -703,6 +703,8 @@
"duration": "Durasi", "duration": "Durasi",
"attachedInbounds": "Inbound terlampir", "attachedInbounds": "Inbound terlampir",
"selectInbound": "Pilih satu atau lebih inbound", "selectInbound": "Pilih satu atau lebih inbound",
"selectAllInbounds": "Pilih semua",
"clearAllInbounds": "Hapus semua",
"noSubId": "Klien ini tidak punya subId, tidak ada tautan yang bisa dibagikan.", "noSubId": "Klien ini tidak punya subId, tidak ada tautan yang bisa dibagikan.",
"noLinks": "Tidak ada tautan yang bisa dibagikan — lampirkan klien ini ke inbound yang mendukung protokol terlebih dahulu.", "noLinks": "Tidak ada tautan yang bisa dibagikan — lampirkan klien ini ke inbound yang mendukung protokol terlebih dahulu.",
"link": "Tautan", "link": "Tautan",
+2
View File
@@ -703,6 +703,8 @@
"duration": "期間", "duration": "期間",
"attachedInbounds": "関連付けされたインバウンド", "attachedInbounds": "関連付けされたインバウンド",
"selectInbound": "1 つ以上のインバウンドを選択", "selectInbound": "1 つ以上のインバウンドを選択",
"selectAllInbounds": "すべて選択",
"clearAllInbounds": "すべてクリア",
"noSubId": "このクライアントには subId がなく、共有可能なリンクはありません。", "noSubId": "このクライアントには subId がなく、共有可能なリンクはありません。",
"noLinks": "共有可能なリンクがありません — まずこのクライアントを対応するプロトコルのインバウンドに関連付けてください。", "noLinks": "共有可能なリンクがありません — まずこのクライアントを対応するプロトコルのインバウンドに関連付けてください。",
"link": "リンク", "link": "リンク",
+2
View File
@@ -703,6 +703,8 @@
"duration": "Duração", "duration": "Duração",
"attachedInbounds": "Inbounds associados", "attachedInbounds": "Inbounds associados",
"selectInbound": "Selecione um ou mais inbounds", "selectInbound": "Selecione um ou mais inbounds",
"selectAllInbounds": "Selecionar tudo",
"clearAllInbounds": "Limpar tudo",
"noSubId": "Este cliente não tem subId, sem link compartilhável.", "noSubId": "Este cliente não tem subId, sem link compartilhável.",
"noLinks": "Sem links compartilháveis — associe primeiro este cliente a um inbound compatível com o protocolo.", "noLinks": "Sem links compartilháveis — associe primeiro este cliente a um inbound compatível com o protocolo.",
"link": "Link", "link": "Link",
+2
View File
@@ -703,6 +703,8 @@
"duration": "Длительность", "duration": "Длительность",
"attachedInbounds": "Привязанные входящие", "attachedInbounds": "Привязанные входящие",
"selectInbound": "Выберите один или несколько входящих", "selectInbound": "Выберите один или несколько входящих",
"selectAllInbounds": "Выбрать всё",
"clearAllInbounds": "Очистить всё",
"noSubId": "У этого клиента нет subId, ссылка для общего доступа недоступна.", "noSubId": "У этого клиента нет subId, ссылка для общего доступа недоступна.",
"noLinks": "Нет ссылок для общего доступа — сначала привяжите клиента к входящему с поддерживаемым протоколом.", "noLinks": "Нет ссылок для общего доступа — сначала привяжите клиента к входящему с поддерживаемым протоколом.",
"link": "Ссылка", "link": "Ссылка",
+2 -1
View File
@@ -704,6 +704,8 @@
"duration": "Süre", "duration": "Süre",
"attachedInbounds": "Bağlı Gelen Bağlantılar", "attachedInbounds": "Bağlı Gelen Bağlantılar",
"selectInbound": "Bir veya Daha Fazla Gelen Bağlantı Seçin", "selectInbound": "Bir veya Daha Fazla Gelen Bağlantı Seçin",
"selectAllInbounds": "Tümünü Seç",
"clearAllInbounds": "Tümünü Temizle",
"noSubId": "Bu kullanıcının subId'si yok, dolayısıyla paylaşılabilir bir bağlantısı bulunmuyor.", "noSubId": "Bu kullanıcının subId'si yok, dolayısıyla paylaşılabilir bir bağlantısı bulunmuyor.",
"noLinks": "Paylaşılabilir bağlantı yok — önce bu kullanıcıyı bir protokole sahip olan gelen bağlantıya bağlayın.", "noLinks": "Paylaşılabilir bağlantı yok — önce bu kullanıcıyı bir protokole sahip olan gelen bağlantıya bağlayın.",
"link": "Bağlantı", "link": "Bağlantı",
@@ -1705,4 +1707,3 @@
} }
} }
} }
+2
View File
@@ -703,6 +703,8 @@
"duration": "Тривалість", "duration": "Тривалість",
"attachedInbounds": "Прив'язані вхідні", "attachedInbounds": "Прив'язані вхідні",
"selectInbound": "Виберіть один або кілька вхідних", "selectInbound": "Виберіть один або кілька вхідних",
"selectAllInbounds": "Вибрати все",
"clearAllInbounds": "Очистити все",
"noSubId": "У цього клієнта немає subId, посилання для спільного доступу відсутнє.", "noSubId": "У цього клієнта немає subId, посилання для спільного доступу відсутнє.",
"noLinks": "Немає посилань для спільного доступу — спочатку прив'яжіть цього клієнта до вхідного з підтримкою протоколу.", "noLinks": "Немає посилань для спільного доступу — спочатку прив'яжіть цього клієнта до вхідного з підтримкою протоколу.",
"link": "Посилання", "link": "Посилання",
+2
View File
@@ -703,6 +703,8 @@
"duration": "Thời hạn", "duration": "Thời hạn",
"attachedInbounds": "Inbound đã gắn", "attachedInbounds": "Inbound đã gắn",
"selectInbound": "Chọn một hoặc nhiều inbound", "selectInbound": "Chọn một hoặc nhiều inbound",
"selectAllInbounds": "Chọn tất cả",
"clearAllInbounds": "Xóa tất cả",
"noSubId": "Khách hàng này không có subId, không có liên kết chia sẻ.", "noSubId": "Khách hàng này không có subId, không có liên kết chia sẻ.",
"noLinks": "Không có liên kết chia sẻ — hãy gắn khách hàng này vào một inbound có giao thức tương thích trước.", "noLinks": "Không có liên kết chia sẻ — hãy gắn khách hàng này vào một inbound có giao thức tương thích trước.",
"link": "Liên kết", "link": "Liên kết",
+2
View File
@@ -703,6 +703,8 @@
"duration": "时长", "duration": "时长",
"attachedInbounds": "关联入站", "attachedInbounds": "关联入站",
"selectInbound": "选择一个或多个入站", "selectInbound": "选择一个或多个入站",
"selectAllInbounds": "全选",
"clearAllInbounds": "全部清除",
"noSubId": "该客户端没有 subId,无法生成共享链接。", "noSubId": "该客户端没有 subId,无法生成共享链接。",
"noLinks": "没有可共享的链接 — 请先将此客户端关联到支持协议的入站。", "noLinks": "没有可共享的链接 — 请先将此客户端关联到支持协议的入站。",
"link": "链接", "link": "链接",
+2
View File
@@ -703,6 +703,8 @@
"duration": "時長", "duration": "時長",
"attachedInbounds": "關聯入站", "attachedInbounds": "關聯入站",
"selectInbound": "選擇一個或多個入站", "selectInbound": "選擇一個或多個入站",
"selectAllInbounds": "全選",
"clearAllInbounds": "全部清除",
"noSubId": "此客戶端沒有 subId,無法產生共享連結。", "noSubId": "此客戶端沒有 subId,無法產生共享連結。",
"noLinks": "沒有可共享的連結 — 請先將此客戶端關聯至支援協定的入站。", "noLinks": "沒有可共享的連結 — 請先將此客戶端關聯至支援協定的入站。",
"link": "連結", "link": "連結",