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/11865-m-plugin-singles-shared-select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/plugin-view': patch
'@object-ui/plugin-list': patch
'@object-ui/plugin-kanban': patch
'@object-ui/plugin-grid': patch
---

Four plugin controls pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the plugins' single selects): `SharedViewLink`'s "Expires after", `ViewSettingsPopover`'s "Color by field", a `select` field of the kanban `InlineQuickAdd` form, and the grouped grid's "Rows per page".

The four were browser-native selects, so they looked and behaved differently from the console's other dropdowns. They now use the shared Radix `Select`: the same trigger, dropdown and keyboard behaviour. The grouped grid's size picker is now drawn as the flat grid's pager draws its own.

What they write is unchanged. Each option gives the same value as before: "Never" still generates a link with no expiry, "None" still clears the row-colour config, the quick-add placeholder still submits an empty string, and each page size still repaginates from page 1. Re-picking the current option writes nothing. The quick-add picker keeps the accessible name its label gave the native select and still takes the form's first focus. Its keys keep the form's contract: Enter on the closed picker still submits the form and Escape still cancels it; Space and the arrow keys open the list, and Enter or Escape inside the open list selects or closes it without submitting or cancelling the form.

One display change: a value none of a picker's options carries now shows as itself. The native select showed its first option instead ("None" for a row-colour field, the placeholder for a quick-add value), which is not what the view or the form holds.

**Clause-②: no.** No published face moves: the package entries export the same names, the four components take the same props, and no i18n key is added. What moves is the four controls' own markup, described above.
30 changes: 19 additions & 11 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
Badge, Button, NavigationOverlay, EmptyValue,
legacyRecordDrawerWidthKey, recordOverlayWidthStorageKey, useOverlayAnchor,
Popover, PopoverContent, PopoverTrigger,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
RefreshIndicator,
DataEmptyState, resolveIcon,
} from '@object-ui/components';
Expand Down Expand Up @@ -6443,18 +6444,25 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 px-3 sm:px-4 py-2 border-t">
<div className="flex items-center gap-2">
<span className="text-xs sm:text-sm text-muted-foreground">{t('table.rowsPerPage')}:</span>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
value={groupedPageSize}
onChange={(e) => { setGroupedPageSize(Number(e.target.value)); setGroupedPage(1); }}
{/* The shared `Select`, drawn as the DataTable pager draws its own
size picker (objectui#11865); it was a browser-native `<select>`.
A pick writes the same number and returns to page 1, as before. */}
<Select
value={String(groupedPageSize)}
onValueChange={(size) => { setGroupedPageSize(Number(size)); setGroupedPage(1); }}
>
{/* The active size is merged in, as the DataTable pager does, so the
selector shows the size in force even when it is not one of the
steps: the spec's display default is not (objectui#9853). */}
{Array.from(new Set([5, 10, 20, 50, 100, groupedPageSize])).sort((a, b) => a - b).map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
<SelectTrigger className="h-8 w-20 px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{/* The active size is merged in, as the DataTable pager does, so the
selector shows the size in force even when it is not one of the
steps: the spec's display default is not (objectui#9853). */}
{Array.from(new Set([5, 10, 20, 50, 100, groupedPageSize])).sort((a, b) => a - b).map((n) => (
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs sm:text-sm text-muted-foreground">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
* the fetch side off the WIRE (`$top` the data source received).
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { render, cleanup, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

Expand Down Expand Up @@ -305,9 +305,10 @@ describe('ObjectGrid — one display page size from the spec, and a distinct fet
// The display default is not one of the selector's fixed steps; without
// the active size merged in, the control would display a size that is not
// the one on screen.
const select = document.querySelector('select') as HTMLSelectElement | null;
expect(select).toBeTruthy();
expect(select!.value).toBe(String(SPEC_DISPLAY_DEFAULT));
// The selector is the shared `Select` (objectui#11865): its trigger shows
// the size in force.
const trigger = screen.getByRole('combobox');
expect(trigger.textContent).toBe(String(SPEC_DISPLAY_DEFAULT));
});

it('CONTROL — a declared page size is honoured on every surface, and on the bucketed fetch as before', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* 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 grouped grid's "Rows per page" picks with the shared `Select`
* (objectui#11865).
*
* The flat grid's pager (the DataTable's) already drew its size picker with
* the shared Radix `Select`; the grouped pager's was a browser-native select.
* The card asks for one control for one kind of choice, surface by surface.
*
* What is pinned:
* - it IS the primitive (a Radix combobox trigger), shows the size in force
* and lists the native control's sizes in its order, a declared size that
* is not one of the steps merged in as before;
* - every size does what the native control did, picked from page 2: the
* same page label and the same number of groups on screen, back on page
* 1; re-picking the size in force changes nothing;
* - the keyboard alone opens the picker and selects.
*
* The picker has no name: the native control had none either ("Rows per page"
* is a plain span beside it, and it had no `aria-label`), so there is no name
* to keep. The pager has no read-only state.
*
* DIRECTION, observed against the native control: every pin here reads the
* control as the primitive's trigger, so each is red there. What makes the
* size rows guards of "the conversion changed nothing the pager does" is the
* reading each compares against: a `change` event on the pre-conversion native
* control, from page 2, left that same page label and that many groups, read
* once on this component with this fixture. That probe's `change` event fired
* for the size in force too, which a browser's native select does not do, so
* the re-pick row pins the primitive.
*/

import '@testing-library/jest-dom/vitest';
import React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, within, waitFor, act } from '@testing-library/react';
import { PaginationConfigSchema } from '@objectstack/spec/ui';
import type { ObjectGridSchema } from '@object-ui/types';
import { ObjectGrid } from '../ObjectGrid';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';

registerAllFields();
afterEach(() => cleanup());

const DISPLAY_DEFAULT: number = PaginationConfigSchema.parse({}).pageSize;
/** Two full pages of the display default and ten groups more, one row each. */
const GROUP_COUNT = DISPLAY_DEFAULT * 2 + 10;
const ITEMS = Array.from({ length: GROUP_COUNT }, (_, i) => ({
id: String(i + 1),
name: `Row ${i + 1}`,
category: `Cat ${String(i + 1).padStart(3, '0')}`,
}));

const groupRows = () => document.querySelectorAll('[data-testid^="group-row-"]');
const pageLabel = () => screen.queryByText(/^Page \d+ of \d+$/)?.textContent ?? 'NO PAGER';

function renderGrouped(extra?: Partial<ObjectGridSchema>) {
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'test_object',
columns: [{ field: 'name', label: 'Name' }],
data: { provider: 'value', items: ITEMS },
grouping: { fields: [{ field: 'category' }] },
...extra,
};
render(
<ActionProvider>
<ObjectGrid schema={schema} />
</ActionProvider>,
);
}

async function openPicker(trigger: HTMLElement): Promise<HTMLElement[]> {
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
const listbox = await screen.findByRole('listbox');
return within(listbox).getAllByRole('option');
}

/** Render, step to page 2, and return the size picker. */
async function onPageTwo(): Promise<HTMLElement> {
renderGrouped();
await waitFor(() => expect(pageLabel()).toMatch(/^Page 1 of/));
const nav = screen.getByText(/^Page 1 of/).parentElement!.querySelectorAll('button');
fireEvent.click(nav[2]);
await waitFor(() => expect(pageLabel()).toMatch(/^Page 2 of/));
return screen.getByRole('combobox');
}

/**
* [size, the page label and group count the native control left, picked from
* page 2], read on the fixture above, where the display default is 50.
*/
const SIZES: ReadonlyArray<readonly [number, string, number]> = [
[5, 'Page 1 of 22', 5],
[10, 'Page 1 of 11', 10],
[20, 'Page 1 of 6', 20],
[100, 'Page 1 of 2', 100],
];

describe('ObjectGrid — the grouped pager\'s size picker is the shared Select (objectui#11865)', () => {
it('reads the fixture the size rows were measured on', () => {
// Guards the premise: the rows below were read with a display default of 50.
expect(DISPLAY_DEFAULT).toBe(50);
});

it('is the primitive, shows the size in force, and lists the native sizes in order', async () => {
const trigger = await onPageTwo();
expect(trigger.tagName).toBe('BUTTON');
expect(trigger.textContent).toBe(String(DISPLAY_DEFAULT));
const options = await openPicker(trigger);
expect(options.map((o) => o.textContent)).toEqual(['5', '10', '20', '50', '100']);
});

it('merges a declared size that is not one of the steps, as before', async () => {
renderGrouped({ pagination: { pageSize: 7 } });
await waitFor(() => expect(pageLabel()).toMatch(/^Page 1 of/));
const trigger = screen.getByRole('combobox');
expect(trigger.textContent).toBe('7');
const options = await openPicker(trigger);
expect(options.map((o) => o.textContent)).toEqual(['5', '7', '10', '20', '50', '100']);
});

it.each(SIZES)('picking %i from page 2 does what the native control did', async (size, label, rows) => {
const trigger = await onPageTwo();
const options = await openPicker(trigger);
fireEvent.click(options.find((o) => o.textContent === String(size))!);
await act(async () => {});
expect(pageLabel()).toBe(label);
expect(groupRows()).toHaveLength(rows);
expect(trigger.textContent).toBe(String(size));
});

it('re-picking the size in force changes nothing', async () => {
const trigger = await onPageTwo();
const options = await openPicker(trigger);
fireEvent.click(options.find((o) => o.textContent === String(DISPLAY_DEFAULT))!);
await act(async () => {});
expect(pageLabel()).toBe('Page 2 of 3');
expect(groupRows()).toHaveLength(DISPLAY_DEFAULT);
});

it('Enter opens the picker and Enter on a size selects it', async () => {
const trigger = await onPageTwo();
fireEvent.keyDown(trigger, { key: 'Enter' });
const listbox = await screen.findByRole('listbox');
fireEvent.keyDown(within(listbox).getByRole('option', { name: '20' }), { key: 'Enter' });
await act(async () => {});
expect(pageLabel()).toBe('Page 1 of 6');
expect(groupRows()).toHaveLength(20);
});
});
9 changes: 5 additions & 4 deletions packages/plugin-grid/src/__tests__/groupedPagination.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* container so columns stay aligned with a single x-axis scrollbar.
*/
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { PaginationConfigSchema } from '@objectstack/spec/ui';
Expand Down Expand Up @@ -120,9 +120,10 @@ describe('Grouped view pagination', () => {
);
expect(resizeTo, 'no fixed selector step splits the fixture into two pages').toBeDefined();

const select = document.querySelector('select') as HTMLSelectElement;
expect(select).toBeTruthy();
fireEvent.change(select, { target: { value: String(resizeTo) } });
// The selector is the shared `Select` (objectui#11865): open it and pick.
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' });
const listbox = await screen.findByRole('listbox');
fireEvent.click(within(listbox).getByRole('option', { name: String(resizeTo) }));

// Two pages, page reset to 1, the first page shows `resizeTo` groups.
await waitFor(() => expect(screen.getByText('Page 1 of 2')).toBeInTheDocument());
Expand Down
102 changes: 83 additions & 19 deletions packages/plugin-kanban/src/InlineQuickAdd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import * as React from "react"
import { Button, Input } from "@object-ui/components"
import { Button, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@object-ui/components"
import { Check, X } from "lucide-react"
import type { InlineFieldDefinition } from "./types"

Expand Down Expand Up @@ -46,7 +46,7 @@ export const InlineQuickAdd: React.FC<InlineQuickAddProps> = ({
return initial
})

const firstInputRef = React.useRef<HTMLInputElement | HTMLSelectElement>(null)
const firstInputRef = React.useRef<HTMLInputElement | HTMLButtonElement>(null)

React.useEffect(() => {
// Auto-focus first field on mount
Expand Down Expand Up @@ -124,6 +124,77 @@ export const InlineQuickAdd: React.FC<InlineQuickAddProps> = ({
)
}

/** The item a value none of the options carries is shown by. */
const OUTSIDE_OPTIONS = "outside"

/**
* objectui#11865 — a select field of the quick-add form, drawn with the shared
* `Select`, the control the rest of the console picks with. It used to be a
* browser-native `<select>`. A pick writes the option's own value, the string
* the native control's `change` carried; re-picking the current option writes
* nothing, as it did there.
*
* - Items carry their option's INDEX, not its value: the placeholder is the
* option whose value is `''`, which `SelectItem` refuses.
* - The value is matched as the native control matched it, by its string, so
* a pre-filled `2` still shows the option whose value is `"2"`.
* - A value none of the options carries gets an item of its own, labelled with
* the value, so the trigger shows what the card will be created with. The
* native control showed the placeholder there. Picking that item writes
* nothing.
* - `id` goes on the trigger, so the `<label htmlFor>` names it as it named the
* native control, and the ref reaches the trigger, so it takes the form's
* first focus.
* - Keys keep the form's contract: Enter on the closed picker still submits the
* form and Escape still cancels it, as on the native control. The other open
* keys (Space, the arrows) open the list, and a key pressed in the open list
* stays in it: Enter there selects, and Escape closes the list, not the form.
*/
const QuickAddPicker = React.forwardRef<
HTMLButtonElement,
{
id?: string
value: unknown
options: ReadonlyArray<{ value: string; label: string }>
onPick: (value: string) => void
className?: string
}
>(({ id, value, options, onPick, className }, ref) => {
const shown = String(value ?? "")
const at = options.findIndex(o => o.value === shown)
return (
<Select
value={at !== -1 ? String(at) : OUTSIDE_OPTIONS}
onValueChange={token => {
// `undefined` for the outside item: it is the form's own value, so there is nothing to write.
const picked = options[Number(token)]
if (picked) onPick(picked.value)
}}
>
<SelectTrigger
ref={ref}
id={id}
className={cn(className, "px-2 py-1")}
onKeyDown={e => {
// Enter is the form's submit key; it does not open the list.
if (e.key === "Enter") e.preventDefault()
}}
>
<SelectValue />
</SelectTrigger>
<SelectContent onKeyDown={e => e.stopPropagation()}>
{at === -1 && <SelectItem value={OUTSIDE_OPTIONS}>{shown}</SelectItem>}
{options.map((o, i) => (
<SelectItem key={`${i}:${o.value}`} value={String(i)}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
})
QuickAddPicker.displayName = "QuickAddPicker"

/** Renders a single field based on its type. */
function renderField(
field: InlineFieldDefinition,
Expand All @@ -137,24 +208,17 @@ function renderField(
switch (field.type) {
case "select":
return (
<select
ref={ref as React.Ref<HTMLSelectElement>}
<QuickAddPicker
ref={ref as React.Ref<HTMLButtonElement>}
id={id}
value={value ?? ""}
onChange={e => onChange(field.name, e.target.value)}
className={cn(
commonClasses,
"w-full rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
)}
>
<option value="">{field.placeholder ?? `Select ${field.label ?? field.name}...`}</option>
{(field.options ?? []).map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
value={value}
options={[
{ value: "", label: field.placeholder ?? `Select ${field.label ?? field.name}...` },
...(field.options ?? []),
]}
onPick={picked => onChange(field.name, picked)}
className={commonClasses}
/>
)

case "number":
Expand Down
Loading
Loading