Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/28552-bulk-select-all-matching-cap.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}} من السجلات.",
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}} 件のレコードに適用されます。",
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}건에 적용됩니다.",
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}.",
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '是',
Expand Down
4 changes: 3 additions & 1 deletion ui/packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -1940,7 +1941,8 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
const base = { ...bulkFanoutParams } as Record<string, unknown>;
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, number>) =>
template.replace(/\{\{(\w+)\}\}/g, (_, k: string) => String(values[k]));

const renderBar = (language: string, totalMatching: number, allMatchingSelected: boolean) =>
render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
<BulkActionBar
selectedRows={page}
actions={['delete']}
pageSize={PAGE}
totalMatching={totalMatching}
allMatchingSelected={allMatchingSelected}
onSelectAllMatching={() => {}}
/>
</I18nProvider>,
);

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(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
<DropdownMenu open>
<DropdownMenuContent>
<SelectionContextMenuItems selectedRows={page} actions={[]} allMatchingSelected totalMatching={total} />
</DropdownMenuContent>
</DropdownMenu>
</I18nProvider>,
);
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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -367,12 +369,20 @@ 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
// requested and never delivered.
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);
});
});
22 changes: 22 additions & 0 deletions ui/packages/plugin-grid/src/bulkSelectAllMatchingCap.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading