Bläddra i källkod

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 <[email protected]>
Nikan Zeyaei 23 timmar sedan
förälder
incheckning
07e5e8498e

+ 51 - 0
frontend/src/components/form/SelectAllClearButtons.tsx

@@ -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 - 0
frontend/src/components/form/index.ts

@@ -1,3 +1,4 @@
 export { default as DateTimePicker } from './DateTimePicker';
 export { default as JsonEditor } from './JsonEditor';
 export { default as HeaderMapEditor } from './HeaderMapEditor';
+export { default as SelectAllClearButtons } from './SelectAllClearButtons';

+ 18 - 10
frontend/src/pages/clients/BulkAttachInboundsModal.tsx

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Modal, Select, Typography, message } from 'antd';
 
+import { SelectAllClearButtons } from '@/components/form';
 import type { InboundOption } from '@/hooks/useClients';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import type { BulkAttachResult } from '@/schemas/client';
@@ -82,16 +83,23 @@ export default function BulkAttachInboundsModal({
         {targetOptions.length === 0 ? (
           <Alert type="info" showIcon message={t('pages.clients.attachToInboundsNoTargets')} />
         ) : (
-          <Select
-            mode="multiple"
-            style={{ width: '100%' }}
-            value={targetIds}
-            onChange={setTargetIds}
-            options={targetOptions}
-            placeholder={t('pages.clients.attachToInboundsTargets')}
-            optionFilterProp="label"
-            autoFocus
-          />
+          <>
+            <SelectAllClearButtons
+              options={targetOptions}
+              value={targetIds}
+              onChange={setTargetIds}
+            />
+            <Select
+              mode="multiple"
+              style={{ width: '100%' }}
+              value={targetIds}
+              onChange={setTargetIds}
+              options={targetOptions}
+              placeholder={t('pages.clients.attachToInboundsTargets')}
+              optionFilterProp="label"
+              autoFocus
+            />
+          </>
         )}
       </Modal>
     </>

+ 18 - 10
frontend/src/pages/clients/BulkDetachInboundsModal.tsx

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Modal, Select, Typography, message } from 'antd';
 
+import { SelectAllClearButtons } from '@/components/form';
 import type { InboundOption } from '@/hooks/useClients';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import type { BulkDetachResult } from '@/schemas/client';
@@ -82,16 +83,23 @@ export default function BulkDetachInboundsModal({
         {targetOptions.length === 0 ? (
           <Alert type="info" showIcon message={t('pages.clients.detachFromInboundsNoTargets')} />
         ) : (
-          <Select
-            mode="multiple"
-            style={{ width: '100%' }}
-            value={targetIds}
-            onChange={setTargetIds}
-            options={targetOptions}
-            placeholder={t('pages.clients.detachFromInboundsTargets')}
-            optionFilterProp="label"
-            autoFocus
-          />
+          <>
+            <SelectAllClearButtons
+              options={targetOptions}
+              value={targetIds}
+              onChange={setTargetIds}
+            />
+            <Select
+              mode="multiple"
+              style={{ width: '100%' }}
+              value={targetIds}
+              onChange={setTargetIds}
+              options={targetOptions}
+              placeholder={t('pages.clients.detachFromInboundsTargets')}
+              optionFilterProp="label"
+              autoFocus
+            />
+          </>
         )}
       </Modal>
     </>

+ 6 - 1
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -8,7 +8,7 @@ import type { Dayjs } from 'dayjs';
 import { RandomUtil, SizeFormatter } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 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 { 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.Item label={t('pages.clients.attachedInbounds')} required>
+            <SelectAllClearButtons
+              options={inboundOptions}
+              value={form.inboundIds}
+              onChange={(v) => update('inboundIds', v)}
+            />
             <Select
               mode="multiple"
               value={form.inboundIds}

+ 6 - 2
frontend/src/pages/clients/ClientFormModal.tsx

@@ -18,10 +18,9 @@ import {
 import { EyeOutlined, ReloadOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
-
 import { HttpUtil, RandomUtil } from '@/utils';
 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 type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
@@ -601,6 +600,11 @@ export default function ClientFormModal({
           </Row>
 
           <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
+            <SelectAllClearButtons
+              options={inboundOptions}
+              value={form.inboundIds}
+              onChange={(v) => update('inboundIds', v)}
+            />
             <Select
               mode="multiple"
               value={form.inboundIds}

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -703,6 +703,8 @@
       "duration": "المدة",
       "attachedInbounds": "الاتصالات الواردة المرتبطة",
       "selectInbound": "حدد اتصالاً واردًا واحدًا أو أكثر",
+      "selectAllInbounds": "تحديد الكل",
+      "clearAllInbounds": "مسح الكل",
       "noSubId": "هذا العميل ليس لديه subId، لا يوجد رابط قابل للمشاركة.",
       "noLinks": "لا توجد روابط للمشاركة — قم بإرفاق هذا العميل بأحد الاتصالات الواردة الداعمة للبروتوكول أولاً.",
       "link": "الرابط",

+ 2 - 0
internal/web/translation/en-US.json

@@ -704,6 +704,8 @@
       "duration": "Duration",
       "attachedInbounds": "Attached inbounds",
       "selectInbound": "Select one or more inbounds",
+      "selectAllInbounds": "Select all",
+      "clearAllInbounds": "Clear all",
       "noSubId": "This client has no subId, no shareable link.",
       "noLinks": "No shareable links — attach this client to a protocol-capable inbound first.",
       "link": "Link",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -703,6 +703,8 @@
       "duration": "Duración",
       "attachedInbounds": "Inbounds asociados",
       "selectInbound": "Selecciona uno o más inbounds",
+      "selectAllInbounds": "Seleccionar todo",
+      "clearAllInbounds": "Limpiar todo",
       "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.",
       "link": "Enlace",

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -703,6 +703,8 @@
       "duration": "مدت",
       "attachedInbounds": "اینباندهای متصل",
       "selectInbound": "یک یا چند اینباند انتخاب کنید",
+      "selectAllInbounds": "انتخاب همه",
+      "clearAllInbounds": "پاک کردن همه",
       "noSubId": "این کلاینت subId ندارد، لینک اشتراک‌گذاری وجود ندارد.",
       "noLinks": "لینکی برای اشتراک‌گذاری نیست — ابتدا این کلاینت را به یک اینباند با پروتکل سازگار متصل کنید.",
       "link": "لینک",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -703,6 +703,8 @@
       "duration": "Durasi",
       "attachedInbounds": "Inbound terlampir",
       "selectInbound": "Pilih satu atau lebih inbound",
+      "selectAllInbounds": "Pilih semua",
+      "clearAllInbounds": "Hapus semua",
       "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.",
       "link": "Tautan",

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -703,6 +703,8 @@
       "duration": "期間",
       "attachedInbounds": "関連付けされたインバウンド",
       "selectInbound": "1 つ以上のインバウンドを選択",
+      "selectAllInbounds": "すべて選択",
+      "clearAllInbounds": "すべてクリア",
       "noSubId": "このクライアントには subId がなく、共有可能なリンクはありません。",
       "noLinks": "共有可能なリンクがありません — まずこのクライアントを対応するプロトコルのインバウンドに関連付けてください。",
       "link": "リンク",

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -703,6 +703,8 @@
       "duration": "Duração",
       "attachedInbounds": "Inbounds associados",
       "selectInbound": "Selecione um ou mais inbounds",
+      "selectAllInbounds": "Selecionar tudo",
+      "clearAllInbounds": "Limpar tudo",
       "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.",
       "link": "Link",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -703,6 +703,8 @@
       "duration": "Длительность",
       "attachedInbounds": "Привязанные входящие",
       "selectInbound": "Выберите один или несколько входящих",
+      "selectAllInbounds": "Выбрать всё",
+      "clearAllInbounds": "Очистить всё",
       "noSubId": "У этого клиента нет subId, ссылка для общего доступа недоступна.",
       "noLinks": "Нет ссылок для общего доступа — сначала привяжите клиента к входящему с поддерживаемым протоколом.",
       "link": "Ссылка",

+ 2 - 1
internal/web/translation/tr-TR.json

@@ -704,6 +704,8 @@
       "duration": "Süre",
       "attachedInbounds": "Bağlı Gelen Bağlantılar",
       "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.",
       "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ı",
@@ -1705,4 +1707,3 @@
     }
   }
 }
-

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -703,6 +703,8 @@
       "duration": "Тривалість",
       "attachedInbounds": "Прив'язані вхідні",
       "selectInbound": "Виберіть один або кілька вхідних",
+      "selectAllInbounds": "Вибрати все",
+      "clearAllInbounds": "Очистити все",
       "noSubId": "У цього клієнта немає subId, посилання для спільного доступу відсутнє.",
       "noLinks": "Немає посилань для спільного доступу — спочатку прив'яжіть цього клієнта до вхідного з підтримкою протоколу.",
       "link": "Посилання",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -703,6 +703,8 @@
       "duration": "Thời hạn",
       "attachedInbounds": "Inbound đã gắn",
       "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ẻ.",
       "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",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -703,6 +703,8 @@
       "duration": "时长",
       "attachedInbounds": "关联入站",
       "selectInbound": "选择一个或多个入站",
+      "selectAllInbounds": "全选",
+      "clearAllInbounds": "全部清除",
       "noSubId": "该客户端没有 subId,无法生成共享链接。",
       "noLinks": "没有可共享的链接 — 请先将此客户端关联到支持协议的入站。",
       "link": "链接",

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -703,6 +703,8 @@
       "duration": "時長",
       "attachedInbounds": "關聯入站",
       "selectInbound": "選擇一個或多個入站",
+      "selectAllInbounds": "全選",
+      "clearAllInbounds": "全部清除",
       "noSubId": "此客戶端沒有 subId,無法產生共享連結。",
       "noLinks": "沒有可共享的連結 — 請先將此客戶端關聯至支援協定的入站。",
       "link": "連結",