diff --git a/.changeset/28552-bulk-select-all-matching-cap.md b/.changeset/28552-bulk-select-all-matching-cap.md new file mode 100644 index 00000000000..b86e6a3ca89 --- /dev/null +++ b/.changeset/28552-bulk-select-all-matching-cap.md @@ -0,0 +1,16 @@ +--- +'@object-ui/plugin-grid': patch +'@object-ui/i18n': patch +--- + +fix(plugin-grid): over the 5000-record cap, the "select all matching" bulk bar says that only the first 5000 matching records will be processed + +Clause-②: yes (widening) +Metadata-protocol: none + +A grid's "select all matching" bulk action hands the action at most 5000 records, but the bulk bar counted the whole match set. With 7200 matches it read "All 7200 matching records are selected.", and a bulk delete then processed the first 5000 and said nothing about the rest. + +- **Over the cap, the bar names it.** When the match set is larger than 5000, the select-all button reads "Select the first 5000 of 7200 matching", the banner reads "Only the first 5000 of 7200 matching records will be processed.", and the selection count, in the bar and in the selection's context menu, reads "5000 selected (of 7200 matches)". +- **At or under the cap, nothing changes.** The bar keeps today's text. +- **One number.** The fan-out and every one of those strings read one constant in `@object-ui/plugin-grid`, so the screen and the action cannot disagree. +- **New strings.** `@object-ui/i18n` gains `grid.bulkSelectAllMatchingCapped`, `grid.bulkAllMatchingSelectedCapped` and `grid.bulkSelectedAllMatchesCapped` in all ten built-in packs. They interpolate `cap` and `total`. diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 9dbbd11c222..f9212ce89a1 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -506,6 +506,9 @@ const ar = { bulkAllOnPage_other: "تم تحديد جميع العناصر ({{count}}) في هذه الصفحة.", bulkSelectAllMatching: "تحديد كل النتائج المطابقة ({{count}})", bulkAllMatchingSelected: "تم تحديد جميع السجلات المطابقة ({{count}}).", + bulkSelectAllMatchingCapped: "تحديد أول {{cap}} من النتائج المطابقة ({{total}})", + bulkAllMatchingSelectedCapped: "ستتم معالجة أول {{cap}} سجل فقط من السجلات المطابقة ({{total}}).", + bulkSelectedAllMatchesCapped: "تم تحديد {{cap}} (إجمالي النتائج المطابقة: {{total}})", bulkActions: "إجراءات جماعية", bulk: { confirmDefault: "سيُطبَّق هذا على {{count}} من السجلات.", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index ef4e53f3830..4decce88333 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -468,6 +468,9 @@ const de = { bulkAllOnPage_other: "Alle {{count}} auf dieser Seite sind ausgewählt.", bulkSelectAllMatching: "Alle {{count}} Treffer auswählen", bulkAllMatchingSelected: "Alle {{count}} übereinstimmenden Datensätze sind ausgewählt.", + bulkSelectAllMatchingCapped: "Die ersten {{cap}} von {{total}} Treffern auswählen", + bulkAllMatchingSelectedCapped: "Nur die ersten {{cap}} von {{total}} übereinstimmenden Datensätzen werden verarbeitet.", + bulkSelectedAllMatchesCapped: "{{cap}} ausgewählt (von {{total}} Treffern)", bulkActions: "Massenaktionen", bulk: { confirmDefault: "Dies wird auf {{count}} Datensätze angewendet.", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 8846e82c33f..6254b0551e4 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -720,6 +720,9 @@ const en = { bulkAllOnPage_other: 'All {{count}} on this page are selected.', bulkSelectAllMatching: 'Select all {{count}} matching', bulkAllMatchingSelected: 'All {{count}} matching records are selected.', + bulkSelectAllMatchingCapped: 'Select the first {{cap}} of {{total}} matching', + bulkAllMatchingSelectedCapped: 'Only the first {{cap}} of {{total}} matching records will be processed.', + bulkSelectedAllMatchesCapped: '{{cap}} selected (of {{total}} matches)', bulkActions: 'Bulk actions', empty: 'Empty', yes: 'Yes', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 35aa33ebdb1..aa7fbedea3c 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -481,6 +481,9 @@ const es = { bulkAllOnPage_other: "Están seleccionados los {{count}} de esta página.", bulkSelectAllMatching: "Seleccionar las {{count}} coincidencias", bulkAllMatchingSelected: "Están seleccionados los {{count}} registros coincidentes.", + bulkSelectAllMatchingCapped: "Seleccionar las primeras {{cap}} de {{total}} coincidencias", + bulkAllMatchingSelectedCapped: "Solo se procesarán los primeros {{cap}} de {{total}} registros coincidentes.", + bulkSelectedAllMatchesCapped: "{{cap}} seleccionados (de {{total}} coincidencias)", bulkActions: "Acciones masivas", bulk: { confirmDefault: "Esto se aplicará a {{count}} registro(s).", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 68817218294..c64a87ef6fc 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -476,6 +476,9 @@ const fr = { bulkAllOnPage_other: "Les {{count}} éléments de cette page sont sélectionnés.", bulkSelectAllMatching: "Sélectionner les {{count}} correspondances", bulkAllMatchingSelected: "Les {{count}} enregistrements correspondants sont tous sélectionnés.", + bulkSelectAllMatchingCapped: "Sélectionner les {{cap}} premières des {{total}} correspondances", + bulkAllMatchingSelectedCapped: "Seuls les {{cap}} premiers des {{total}} enregistrements correspondants seront traités.", + bulkSelectedAllMatchesCapped: "{{cap}} sélectionnés (sur {{total}} résultats)", bulkActions: "Actions groupées", bulk: { confirmDefault: "Ceci s'appliquera à {{count}} enregistrement(s).", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 57bf0c5c2ae..8fb695a3524 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -468,6 +468,9 @@ const ja = { bulkAllOnPage_other: "このページの {{count}} 件がすべて選択されています。", bulkSelectAllMatching: "該当する {{count}} 件すべてを選択", bulkAllMatchingSelected: "該当する {{count}} 件のレコードがすべて選択されています。", + bulkSelectAllMatchingCapped: "該当する {{total}} 件のうち先頭の {{cap}} 件を選択", + bulkAllMatchingSelectedCapped: "該当する {{total}} 件のうち、処理されるのは先頭の {{cap}} 件のみです。", + bulkSelectedAllMatchesCapped: "{{cap}} 件を選択中(該当 {{total}} 件中)", bulkActions: "一括操作", bulk: { confirmDefault: "{{count}} 件のレコードに適用されます。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index abbb6a1c072..9708277f28c 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -468,6 +468,9 @@ const ko = { bulkAllOnPage_other: "이 페이지의 {{count}}건이 모두 선택되었습니다.", bulkSelectAllMatching: "일치하는 {{count}}건 모두 선택", bulkAllMatchingSelected: "일치하는 레코드 {{count}}건이 모두 선택되었습니다.", + bulkSelectAllMatchingCapped: "일치하는 {{total}}건 중 처음 {{cap}}건 선택", + bulkAllMatchingSelectedCapped: "일치하는 레코드 {{total}}건 중 처음 {{cap}}건만 처리됩니다.", + bulkSelectedAllMatchesCapped: "{{cap}}건 선택됨(일치 항목 {{total}}건 중)", bulkActions: "일괄 작업", bulk: { confirmDefault: "레코드 {{count}}건에 적용됩니다.", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index e56072aadbc..b4aa685c1f9 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -476,6 +476,9 @@ const pt = { bulkAllOnPage_other: "Os {{count}} desta página estão selecionados.", bulkSelectAllMatching: "Selecionar todas as {{count}} correspondências", bulkAllMatchingSelected: "Todos os {{count}} registros correspondentes estão selecionados.", + bulkSelectAllMatchingCapped: "Selecionar as primeiras {{cap}} de {{total}} correspondências", + bulkAllMatchingSelectedCapped: "Apenas os primeiros {{cap}} de {{total}} registros correspondentes serão processados.", + bulkSelectedAllMatchesCapped: "{{cap}} selecionados (de {{total}} correspondências)", bulkActions: "Ações em massa", bulk: { confirmDefault: "Isto será aplicado a {{count}} registro(s).", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index b3e5fb767ff..2fd28604007 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -487,6 +487,9 @@ const ru = { bulkAllOnPage_other: "Выбраны все записи на этой странице ({{count}}).", bulkSelectAllMatching: "Выбрать все совпадения ({{count}})", bulkAllMatchingSelected: "Выбраны все подходящие записи ({{count}}).", + bulkSelectAllMatchingCapped: "Выбрать первые {{cap}} из совпадений ({{total}})", + bulkAllMatchingSelectedCapped: "Будут обработаны только первые {{cap}} подходящих записей из {{total}}.", + bulkSelectedAllMatchesCapped: "Выбрано: {{cap}} (всего совпадений: {{total}})", bulkActions: "Массовые действия", bulk: { confirmDefault: "Будет применено к записям: {{count}}.", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 39b3c8d8b30..7104fdad5ad 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -489,6 +489,9 @@ const zh = { bulkAllOnPage_other: '本页 {{count}} 项已全选。', bulkSelectAllMatching: '选择全部 {{count}} 项匹配记录', bulkAllMatchingSelected: '已选择全部 {{count}} 项匹配记录。', + bulkSelectAllMatchingCapped: '选择匹配的 {{total}} 项中的前 {{cap}} 项', + bulkAllMatchingSelectedCapped: '将处理前 {{cap}} 条(共匹配 {{total}} 条记录)。', + bulkSelectedAllMatchesCapped: '已选择 {{cap}} 项(共匹配 {{total}} 项)', bulkActions: '批量操作', empty: '空', yes: '是', diff --git a/ui/packages/plugin-grid/src/ObjectGrid.tsx b/ui/packages/plugin-grid/src/ObjectGrid.tsx index f8e72346770..367179179b1 100644 --- a/ui/packages/plugin-grid/src/ObjectGrid.tsx +++ b/ui/packages/plugin-grid/src/ObjectGrid.tsx @@ -101,6 +101,7 @@ import { useObjectGridMobile } from './objectGridMobile'; import { useObjectGridGenerateColumns } from './objectGridGenerateColumns'; import { resolveObjectGridPersistedColumns } from './objectGridPersistedColumns'; import { resolveObjectGridActionsColumn } from './objectGridActionsColumn'; +import { BULK_SELECT_ALL_MATCHING_CAP } from './bulkSelectAllMatchingCap'; export type { RetiredListColumnKey, ObjectGridColumnHolds, ObjectGridColumnDraft, ObjectGridColumn } from './objectGridColumns'; export type { ObjectGridColumnState, ObjectGridFooterSummary, ObjectGridExternalPaginationProps, ObjectGridRowGroup } from './objectGridProps'; @@ -1940,7 +1941,8 @@ export const ObjectGrid: React.FC = ({ const base = { ...bulkFanoutParams } as Record; delete (base as any).$top; delete (base as any).$skip; - const HARD_CAP = 5000; + // The cap the bulk bar names to the user when the match set is larger. + const HARD_CAP = BULK_SELECT_ALL_MATCHING_CAP; const PAGE = 500; const collected: any[] = []; let skip = 0; diff --git a/ui/packages/plugin-grid/src/__tests__/BulkActionBar.selectAllMatchingCap-28552.test.tsx b/ui/packages/plugin-grid/src/__tests__/BulkActionBar.selectAllMatchingCap-28552.test.tsx new file mode 100644 index 00000000000..fa65434007f --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/BulkActionBar.selectAllMatchingCap-28552.test.tsx @@ -0,0 +1,122 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28552 — over the "select all matching" cap, the bulk bar names + * the cap. + * + * `resolveBulkRows` hands a bulk action at most BULK_SELECT_ALL_MATCHING_CAP + * records. The bar used to count the whole match set ("All 7200 matching + * records are selected."), so a bulk delete over 7200 matches reported every + * match and processed the first 5000. Over the cap, the banner, the + * select-all button, the count chip and the selection's context-menu label now + * name the cap and the total; at or under it they read as before. Strings are + * read from the packs, never from literals; the cap is read from the one + * constant `resolveBulkRows` reads too. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/react'; +import { DropdownMenu, DropdownMenuContent } from '@object-ui/components'; +import { en, zh } from '@object-ui/i18n/locales'; +import { BulkActionBar } from '../components/BulkActionBar'; +import { SelectionContextMenuItems } from '../components/RowContextMenu'; +import { BULK_SELECT_ALL_MATCHING_CAP } from '../bulkSelectAllMatchingCap'; + +afterEach(cleanup); + +const CAP = BULK_SELECT_ALL_MATCHING_CAP; +const PAGE = 50; +const page = Array.from({ length: PAGE }, (_, i) => ({ id: String(i + 1) })); + +const fill = (template: string, values: Record) => + template.replace(/\{\{(\w+)\}\}/g, (_, k: string) => String(values[k])); + +const renderBar = (language: string, totalMatching: number, allMatchingSelected: boolean) => + render( + + {}} + /> + , + ); + +describe('the bulk bar names the select-all-matching cap (objectstack#28552)', () => { + it('the cap is the number resolveBulkRows stops at', () => { + // bulkFanoutHostParams.test.tsx pins the fan-out stopping at this value. + expect(CAP).toBe(5000); + }); + + describe('over the cap', () => { + const total = CAP + 2200; + + it('the select-all button offers the first CAP of the total, not all of them', async () => { + renderBar('en', total, false); + const button = await screen.findByTestId('bulk-select-all-matching'); + expect(button).toHaveTextContent(fill(en.grid.bulkSelectAllMatchingCapped, { cap: CAP, total })); + expect(button).not.toHaveTextContent(fill(en.grid.bulkSelectAllMatching, { count: total })); + }); + + it('once selected, the banner says only the first CAP will be processed, and the chip counts CAP', async () => { + renderBar('en', total, true); + const banner = await screen.findByTestId('bulk-cross-page-banner'); + expect(banner).toHaveTextContent(fill(en.grid.bulkAllMatchingSelectedCapped, { cap: CAP, total })); + expect(banner).toHaveTextContent(String(CAP)); + expect(banner).not.toHaveTextContent(fill(en.grid.bulkAllMatchingSelected, { count: total })); + const bar = screen.getByTestId('bulk-actions-bar'); + expect(bar).toHaveTextContent(fill(en.grid.bulkSelectedAllMatchesCapped, { cap: CAP, total })); + expect(bar).not.toHaveTextContent(fill(en.grid.bulkSelectedAllMatches, { count: total })); + }); + + it('under zh, the banner reads the zh pack', async () => { + expect(zh.grid.bulkAllMatchingSelectedCapped).not.toBe(en.grid.bulkAllMatchingSelectedCapped); + renderBar('zh', total, true); + const banner = await screen.findByTestId('bulk-cross-page-banner'); + expect(banner).toHaveTextContent(fill(zh.grid.bulkAllMatchingSelectedCapped, { cap: CAP, total })); + }); + + it("the selection's context menu counts CAP of the total", async () => { + render( + + + + + + + , + ); + const label = await screen.findByTestId('row-context-selection-count'); + expect(label).toHaveTextContent(fill(en.grid.bulkSelectedAllMatchesCapped, { cap: CAP, total })); + }); + }); + + describe.each([ + ['exactly at the cap', CAP], + ['under the cap', CAP - 3800], + ])('%s', (_name, total) => { + it("the select-all button reads today's text", async () => { + renderBar('en', total, false); + const button = await screen.findByTestId('bulk-select-all-matching'); + expect(button).toHaveTextContent(fill(en.grid.bulkSelectAllMatching, { count: total })); + }); + + it("once selected, the banner and the chip read today's text", async () => { + renderBar('en', total, true); + const banner = await screen.findByTestId('bulk-cross-page-banner'); + expect(banner).toHaveTextContent(fill(en.grid.bulkAllMatchingSelected, { count: total })); + expect(within(screen.getByTestId('bulk-actions-bar')).getByText(fill(en.grid.bulkSelectedAllMatches, { count: total }))).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/packages/plugin-grid/src/__tests__/bulkFanoutHostParams.test.tsx b/ui/packages/plugin-grid/src/__tests__/bulkFanoutHostParams.test.tsx index 0f4dc8ef965..458f9962be4 100644 --- a/ui/packages/plugin-grid/src/__tests__/bulkFanoutHostParams.test.tsx +++ b/ui/packages/plugin-grid/src/__tests__/bulkFanoutHostParams.test.tsx @@ -58,6 +58,8 @@ vi.mock('@object-ui/permissions', async (importOriginal) => { }); import { ObjectGrid } from '../ObjectGrid'; +import { BULK_SELECT_ALL_MATCHING_CAP } from '../bulkSelectAllMatchingCap'; +import { en } from '@object-ui/i18n/locales'; import { registerAllFields } from '@object-ui/fields'; import { ActionProvider, I18nProvider } from '@object-ui/react'; @@ -367,6 +369,13 @@ describe('#4501 must-not-change — the internal-loader path', () => { await selectWholePage(); fireEvent.click(await screen.findByTestId('bulk-select-all-matching')); + // objectstack#28552 — the banner names the number the fan-out delivers, + // read from the one constant both sides read. + expect(screen.getByTestId('bulk-cross-page-banner')).toHaveTextContent( + en.grid.bulkAllMatchingSelectedCapped + .replace('{{cap}}', String(BULK_SELECT_ALL_MATCHING_CAP)) + .replace('{{total}}', String(BIG)), + ); const calls = await fanOutParams(ds, onBulkDelete); // 5000 / 500 = 10 pages, then the cap stops it — the 6000th record is never @@ -374,5 +383,6 @@ describe('#4501 must-not-change — the internal-loader path', () => { expect(calls).toHaveLength(10); expect(calls.map((p: any) => p.$skip)).toEqual([0, 500, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500]); expect(onBulkDelete.mock.calls[0][0]).toHaveLength(5000); + expect(onBulkDelete.mock.calls[0][0]).toHaveLength(BULK_SELECT_ALL_MATCHING_CAP); }); }); diff --git a/ui/packages/plugin-grid/src/bulkSelectAllMatchingCap.ts b/ui/packages/plugin-grid/src/bulkSelectAllMatchingCap.ts new file mode 100644 index 00000000000..717d04307b5 --- /dev/null +++ b/ui/packages/plugin-grid/src/bulkSelectAllMatchingCap.ts @@ -0,0 +1,22 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The most records a "select all matching" bulk action processes. + * + * `ObjectGrid`'s `resolveBulkRows` stops its paged fan-out here, and the bulk + * bar and the selection's context menu name this number whenever the match set + * is larger. All three read this one constant, so what the screen says is + * processed and what the action receives cannot drift apart. ⛔ Never re-spell + * the number at a consumption site. + */ +export const BULK_SELECT_ALL_MATCHING_CAP = 5000; + +/** True when a "select all matching" run over `totalMatching` records stops short at the cap. */ +export const exceedsBulkSelectAllMatchingCap = (totalMatching: number | undefined): totalMatching is number => + typeof totalMatching === 'number' && totalMatching > BULK_SELECT_ALL_MATCHING_CAP; diff --git a/ui/packages/plugin-grid/src/components/BulkActionBar.tsx b/ui/packages/plugin-grid/src/components/BulkActionBar.tsx index bfa4fd16786..15e02d3a62b 100644 --- a/ui/packages/plugin-grid/src/components/BulkActionBar.tsx +++ b/ui/packages/plugin-grid/src/components/BulkActionBar.tsx @@ -15,6 +15,7 @@ import { isActionSwitchedOff } from '@object-ui/core'; import type { BulkActionDef } from '@object-ui/types'; import { partitionBulkRows, hasVisibilityGate } from '../bulkEligibility'; import { formatActionLabel } from './RowActionMenu'; +import { BULK_SELECT_ALL_MATCHING_CAP, exceedsBulkSelectAllMatchingCap } from '../bulkSelectAllMatchingCap'; /** * Is this rich bulk def offered for this selection? `visible` is evaluated @@ -207,6 +208,9 @@ export const BulkActionBar: React.FC = ({ && pageSize > 0 && selectedRows.length >= pageSize && totalMatching > pageSize; + // Over the cap, every string that counts the match set says that only the + // first BULK_SELECT_ALL_MATCHING_CAP records are processed. + const overCap = exceedsBulkSelectAllMatchingCap(totalMatching); return (
= ({ <> - {t('grid.bulkAllMatchingSelected', { - count: totalMatching, - defaultValue: 'All {{count}} matching records are selected.', - })} + {overCap + ? t('grid.bulkAllMatchingSelectedCapped', { + cap: BULK_SELECT_ALL_MATCHING_CAP, + total: totalMatching, + defaultValue: 'Only the first {{cap}} of {{total}} matching records will be processed.', + }) + : t('grid.bulkAllMatchingSelected', { + count: totalMatching, + defaultValue: 'All {{count}} matching records are selected.', + })} ) : ( @@ -244,10 +254,16 @@ export const BulkActionBar: React.FC = ({ onClick={onSelectAllMatching} data-testid="bulk-select-all-matching" > - {t('grid.bulkSelectAllMatching', { - count: totalMatching, - defaultValue: 'Select all {{count}} matching', - })} + {overCap + ? t('grid.bulkSelectAllMatchingCapped', { + cap: BULK_SELECT_ALL_MATCHING_CAP, + total: totalMatching, + defaultValue: 'Select the first {{cap}} of {{total}} matching', + }) + : t('grid.bulkSelectAllMatching', { + count: totalMatching, + defaultValue: 'Select all {{count}} matching', + })} )} @@ -261,10 +277,16 @@ export const BulkActionBar: React.FC = ({ className="inline-block motion-safe:animate-in motion-safe:zoom-in-90 motion-safe:duration-150" > {allMatchingSelected - ? t('grid.bulkSelectedAllMatches', { - count: totalMatching, - defaultValue: '{{count}} selected (all matches)', - }) + ? overCap + ? t('grid.bulkSelectedAllMatchesCapped', { + cap: BULK_SELECT_ALL_MATCHING_CAP, + total: totalMatching, + defaultValue: '{{cap}} selected (of {{total}} matches)', + }) + : t('grid.bulkSelectedAllMatches', { + count: totalMatching, + defaultValue: '{{count}} selected (all matches)', + }) : t('grid.bulkSelected', { count: selectedRows.length, defaultValue: '{{count}} selected', diff --git a/ui/packages/plugin-grid/src/components/RowContextMenu.tsx b/ui/packages/plugin-grid/src/components/RowContextMenu.tsx index 4a097a40b3b..073501cda56 100644 --- a/ui/packages/plugin-grid/src/components/RowContextMenu.tsx +++ b/ui/packages/plugin-grid/src/components/RowContextMenu.tsx @@ -24,6 +24,7 @@ import { type RowActionMenuProps, } from './RowActionMenu'; import { isDestructiveBulkDef, useBulkActionItems } from './BulkActionBar'; +import { BULK_SELECT_ALL_MATCHING_CAP, exceedsBulkSelectAllMatchingCap } from '../bulkSelectAllMatchingCap'; /** * The items of a list row's context menu (objectstack#28434) — the second way @@ -40,7 +41,7 @@ const CONTEXT_MENU_FALLBACKS: Record = { function useContextMenuTranslation() { const { t } = useObjectTranslation(); - return (key: string, options?: { count?: number; defaultValue?: string }): string => { + return (key: string, options?: { count?: number; cap?: number; total?: number; defaultValue?: string }): string => { const v = String(options ? t(key, options) : t(key)); return v === key ? (CONTEXT_MENU_FALLBACKS[key] ?? key) : v; }; @@ -120,7 +121,13 @@ export const SelectionContextMenuItems: React.FC<{ <> {allMatchingSelected - ? t('grid.bulkSelectedAllMatches', { count: totalMatching, defaultValue: '{{count}} selected (all matches)' }) + ? exceedsBulkSelectAllMatchingCap(totalMatching) + ? t('grid.bulkSelectedAllMatchesCapped', { + cap: BULK_SELECT_ALL_MATCHING_CAP, + total: totalMatching, + defaultValue: '{{cap}} selected (of {{total}} matches)', + }) + : t('grid.bulkSelectedAllMatches', { count: totalMatching, defaultValue: '{{count}} selected (all matches)' }) : t('grid.bulkSelected', { count: selectedRows.length, defaultValue: '{{count}} selected' })} {hasActions && }