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
8 changes: 6 additions & 2 deletions packages/core/core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,7 @@ export interface PretableGridUiCore<TRow extends object, TRowId extends Pretable
readonly rowId: TRowId;
readonly columnId: TEditColumnId;
readonly value: ColumnValueOf<TColumns, TEditColumnId>;
readonly status?: "checking" | "editing";
}) => void;
// (undocumented)
readonly cancelEdit: () => void;
Expand Down Expand Up @@ -757,7 +758,7 @@ export interface PretableGridUiCore<TRow extends object, TRowId extends Pretable
// (undocumented)
readonly setEditDraft: (value: unknown) => void;
// (undocumented)
readonly setEditStatus: (status: "editing" | "validating" | "saving" | "error", error?: string) => void;
readonly setEditStatus: (status: PretableOpenEditStatus, error?: string) => void;
// (undocumented)
readonly setFocus: (focus: PretableIndexedFocusState<TRowId, TColumnId>) => void;
readonly setRowSelection: (rows: PretableRowSelectionState<TRowId>) => void;
Expand Down Expand Up @@ -859,7 +860,7 @@ export type PretableIndexedEditingState<TRowId extends PretableRowId, TColumns>
readonly rowId: TRowId;
readonly columnId: TColumnId;
readonly value: ColumnValueOf<TColumns, TColumnId>;
readonly status: "editing" | "validating" | "saving" | "error";
readonly status: PretableEditStatus;
readonly error?: string;
};
}[ColumnIdOf<TColumns>];
Expand Down Expand Up @@ -993,6 +994,9 @@ export interface PretableMutationResult<TRowId extends PretableRowId> {
readonly updated: number;
}

// @public
export type PretableOpenEditStatus = "editing" | "validating" | "saving" | "error";

// @public
export type PretableProcessingAuthority = "engine" | "external";

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export type {
PretableMutationResult,
PretableQueryFor,
PretableQueryTransition,
PretableOpenEditStatus,
PretableProcessingAuthority,
PretableProcessingOptions,
PretableResultMeta,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type {
PretableFocusState,
PretableMatchingTotal,
PretableMoveFocusOptions,
PretableOpenEditStatus,
PretableProcessingAuthority,
PretableProcessingOptions,
PretableResultMeta,
Expand Down
3 changes: 2 additions & 1 deletion packages/grid-core/src/create-grid-ui-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,12 +798,13 @@ export function createGridUiCore<
readonly rowId: TRowId;
readonly columnId: TEditColumnId;
readonly value: ColumnValueOf<TColumns, TEditColumnId>;
readonly status?: "checking" | "editing";
}) {
const editing = Object.freeze({
rowId: input.rowId,
columnId: input.columnId,
value: input.value,
status: "editing" as const,
status: input.status ?? "editing",
}) as PretableIndexedEditingState<TRowId, TColumns>;
command(() => {
const snapshot = observed?.snapshot;
Expand Down
1 change: 1 addition & 0 deletions packages/grid-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type {
PretableHeaderRowRef,
PretableMatchingTotal,
PretableMoveFocusOptions,
PretableOpenEditStatus,
PretableProcessingAuthority,
PretableProcessingOptions,
PretableResultMeta,
Expand Down
36 changes: 34 additions & 2 deletions packages/grid-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,33 @@ export type PretableRow = object;
*/
export type PretableSortDirection = "asc" | "desc" | null;

/**
* Phase of a cell edit that is already OPEN — i.e. every phase except
* `"checking"`, which is the pre-authorization phase only `beginEdit` can
* enter and which nothing can return to.
*
* This union had no name and was spelled out in four places. It is written as
* literals rather than `Exclude<PretableEditStatus, "checking">` for two
* reasons: an `Exclude` whose second argument stops naming a member silently
* widens to the whole union instead of failing, and the docs guard can only
* pin a table to a union whose members the API report states outright.
*
* The cost is that the two unions could drift, so they are held together by
* assertion instead of by construction — see the `toEqualTypeOf` pair in
* `packages/react/src/__tests__/narrowed-literal-unions.test.ts`, which fails
* if either list changes without the other.
*
* @public
*/
export type PretableOpenEditStatus =
"editing" | "validating" | "saving" | "error";

/**
* Phase of an in-progress cell edit.
*
* `"checking"` is the pre-authorization phase an async `editable` predicate
* runs under; the rest are {@link PretableOpenEditStatus}.
*
* @public
*/
export type PretableEditStatus =
Expand Down Expand Up @@ -768,7 +792,7 @@ export type PretableIndexedEditingState<
readonly rowId: TRowId;
readonly columnId: TColumnId;
readonly value: ColumnValueOf<TColumns, TColumnId>;
readonly status: "editing" | "validating" | "saving" | "error";
readonly status: PretableEditStatus;
readonly error?: string;
};
}[ColumnIdOf<TColumns>];
Expand Down Expand Up @@ -872,15 +896,23 @@ export interface PretableGridUiCore<
* carries a value, and only the schema says what type the value in a given
* column has. A drawn-but-unschema'd column (a checkbox gutter, a group
* label) has no value to edit, and this signature is what says so.
*
* `status` is the phase the session OPENS in and defaults to `"editing"`.
* `"checking"` is the only other legal entry phase: it is what an async
* `editable` predicate runs under, and it exists so the editor can render
* read-only and `aria-busy` while the answer is in flight. Nothing can
* return to it, which is why {@link PretableOpenEditStatus} — the type of
* every later transition — excludes it.
*/
readonly beginEdit: <TEditColumnId extends ColumnIdOf<TColumns>>(input: {
readonly rowId: TRowId;
readonly columnId: TEditColumnId;
readonly value: ColumnValueOf<TColumns, TEditColumnId>;
readonly status?: "checking" | "editing";
}) => void;
readonly setEditDraft: (value: unknown) => void;
readonly setEditStatus: (
status: "editing" | "validating" | "saving" | "error",
status: PretableOpenEditStatus,
error?: string,
) => void;
readonly cancelEdit: () => void;
Expand Down
11 changes: 8 additions & 3 deletions packages/react/react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1200,6 +1200,7 @@ export interface PretableGridUiCore<TRow extends object, TRowId extends Pretable
readonly rowId: TRowId;
readonly columnId: TEditColumnId;
readonly value: ColumnValueOf<TColumns, TEditColumnId>;
readonly status?: "checking" | "editing";
}) => void;
// (undocumented)
readonly cancelEdit: () => void;
Expand Down Expand Up @@ -1234,7 +1235,7 @@ export interface PretableGridUiCore<TRow extends object, TRowId extends Pretable
// (undocumented)
readonly setEditDraft: (value: unknown) => void;
// (undocumented)
readonly setEditStatus: (status: "editing" | "validating" | "saving" | "error", error?: string) => void;
readonly setEditStatus: (status: PretableOpenEditStatus, error?: string) => void;
// (undocumented)
readonly setFocus: (focus: PretableIndexedFocusState<TRowId, TColumnId>) => void;
readonly setRowSelection: (rows: PretableRowSelectionState<TRowId>) => void;
Expand Down Expand Up @@ -1353,7 +1354,7 @@ export type PretableIndexedEditingState<TRowId extends PretableRowId, TColumns>
readonly rowId: TRowId;
readonly columnId: TColumnId;
readonly value: ColumnValueOf<TColumns, TColumnId>;
readonly status: "editing" | "validating" | "saving" | "error";
readonly status: PretableEditStatus;
readonly error?: string;
};
}[ColumnIdOf<TColumns>];
Expand Down Expand Up @@ -1547,6 +1548,9 @@ export interface PretableMutationResult<TRowId extends PretableRowId> {
readonly updated: number;
}

// @public
export type PretableOpenEditStatus = "editing" | "validating" | "saving" | "error";

// @public
export type PretablePresentationColumns<TColumns, TRowId extends string | number> = TColumns extends readonly (infer TColumn)[] ? readonly (TColumn extends {
readonly id: infer TId extends string;
Expand Down Expand Up @@ -1688,9 +1692,10 @@ export type PretableReactGrid<TRow extends object, TRowId extends PretableRowId,
readonly rowId: TRowId;
readonly columnId: TEditColumnId;
readonly value: ColumnValueOf<TColumns, TEditColumnId>;
readonly status?: "checking" | "editing";
}) => void;
readonly setEditDraft: (value: unknown) => void;
readonly setEditStatus: (status: "editing" | "validating" | "saving" | "error", error?: string) => void;
readonly setEditStatus: (status: PretableOpenEditStatus, error?: string) => void;
readonly cancelEdit: () => void;
readonly setColumnWidth: (columnId: TColumnId, width: number) => void;
readonly setColumnPinned: (columnId: TColumnId, pinned: "left" | "right" | null) => void;
Expand Down
48 changes: 47 additions & 1 deletion packages/react/src/__tests__/csv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,13 +472,18 @@ describe("serializeCsv vouches on the value, not the declaration", () => {
});

describe("serializeCsv reports rows hidden by collapsed groups", () => {
async function grouped(collapse: boolean) {
async function groupedModel() {
const model = createLocalRowModel({ rows, columns: modelColumns });
await model.setQuery({
filters: [],
sort: [],
rowGroups: [{ columnId: "a" }],
}).finished;
return model;
}

async function grouped(collapse: boolean) {
const model = await groupedModel();
if (collapse) model.collapseAll();
return serializeCsv({
rowModelSnapshot: model.getState().snapshot,
Expand All @@ -503,6 +508,47 @@ describe("serializeCsv reports rows hidden by collapsed groups", () => {
expect(file?.complete).toBe(false);
expect(file!.rowCount).toBeLessThan((await grouped(false))!.rowCount);
});

// The two clauses of `hidesCollapsedRows` are checked separately, because a
// suite that only ever exercises expand-all vs collapse-all cannot tell
// `default.kind !== "expanded"` from `overrideCount > 0` — either clause
// alone would carry both cases above.
async function withExpansion(
mutate: (model: Awaited<ReturnType<typeof groupedModel>>) => void,
) {
const model = await groupedModel();
mutate(model);
return serializeCsv({
rowModelSnapshot: model.getState().snapshot,
columns: [
{ id: GROUP_COLUMN_ID, header: "Group" },
{ id: "n", header: "N", type: "number" },
],
scope: "all",
options: { bom: false },
});
}

it("is INCOMPLETE for a non-'expanded' default even with no overrides", async () => {
const file = await withExpansion((model) => {
model.setExpansionDefault({ kind: "through-depth", depth: 0 });
});
expect(file?.complete).toBe(false);
expect(file?.omissions.map((o) => o.kind)).toEqual(["collapsed-groups"]);
});

it("is INCOMPLETE for an expanded default carrying an override", async () => {
const file = await withExpansion((model) => {
const snapshot = model.getState().snapshot;
const group = snapshot.rowAt(0);
if (group?.kind !== "group") throw new Error("expected a group row");
model.setGroupExpanded(group.groupId, false);
});
expect(file?.complete).toBe(false);
expect(file?.omissions).toEqual([
{ kind: "collapsed-groups", expansionOverrideCount: 1 },
]);
});
});

describe("serializeCsv pins each formula trigger individually", () => {
Expand Down
112 changes: 112 additions & 0 deletions packages/react/src/__tests__/narrowed-literal-unions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, expectTypeOf, it } from "vitest";

import type {
PretableEditStatus,
PretableExpansionState,
PretableIndexedEditingState,
PretableOpenEditStatus,
} from "@pretable/core";

import { hidesCollapsedRows } from "../csv";

/**
* Two copies of a named union had been widened to `string` and then compared
* against string literals. A `string` makes every such comparison unchecked:
* renaming the phase, or typo-ing the literal, compiles and silently changes
* behavior. These assertions are the compiler-enforced half of the fix — the
* behavioral half lives in `pretable-surface-editing.test.tsx` (the edit
* lifecycle) and `csv.test.ts` (export completeness).
*
* Every `@ts-expect-error` below is load-bearing in BOTH directions: it fails
* as an unused directive the moment the field widens back to `string`.
*/

type Columns = readonly [
{
readonly id: "name";
readonly accessor: (row: { id: string; name: string }) => string;
},
];
type EditingStatus = NonNullable<
PretableIndexedEditingState<string, Columns>
>["status"];

describe("edit status is a checked union", () => {
it("names the post-authorization phases", () => {
// The union that appeared unnamed in four places, now named once.
expectTypeOf<PretableOpenEditStatus>().toEqualTypeOf<
"editing" | "validating" | "saving" | "error"
>();
// THE ANTI-DRIFT CLAUSE. Both unions spell their members out, because the
// docs guard pins a table to the members the API report states and an
// `Exclude<>` hides them. That leaves two lists that could disagree, so
// their relationship is asserted here instead: `PretableEditStatus` is
// exactly `"checking"` plus the open phases, no more and no less.
expectTypeOf<PretableEditStatus>().toEqualTypeOf<
"checking" | PretableOpenEditStatus
>();
expectTypeOf<
Exclude<PretableEditStatus, "checking">
>().toEqualTypeOf<PretableOpenEditStatus>();
});

it("keeps 'checking' reachable in the observable editing state", () => {
// THE BUG. The store's editing state excluded `"checking"` while
// `useCellEditController` asked `beginEdit` to open in it and three
// consumers (`useEditorField`'s pending set, `BooleanCellControl`, the
// controller's own gate) compared against it — comparisons no value could
// ever satisfy. The surface facade's `status: string` is what kept the
// compiler quiet about all of them.
expectTypeOf<"checking">().toMatchTypeOf<EditingStatus>();
expectTypeOf<EditingStatus>().toEqualTypeOf<PretableEditStatus>();
});

it("rejects a status the union cannot produce", () => {
const status = "editing" as EditingStatus;
// @ts-expect-error -- a typo'd phase has no overlap with the union. Unused
// (and therefore itself an error) if `status` ever widens to `string`.
const typo: boolean = status === "editting";
void typo;
});

it("keeps 'checking' out of the post-authorization transitions", () => {
// Nothing can return to `"checking"`; only `beginEdit` can enter it. That
// is the entire reason `PretableOpenEditStatus` exists as a second name.
// @ts-expect-error -- `"checking"` is not an open-edit transition.
const notATransition: PretableOpenEditStatus = "checking";
void notATransition;
});
});

describe("expansion kind is a checked union", () => {
it("accepts the real expansion state", () => {
const expanded: PretableExpansionState = {
default: { kind: "expanded" },
overrideCount: 0,
};
expectTypeOf(expanded).toMatchTypeOf<
Parameters<typeof hidesCollapsedRows>[0]
>();
expect(hidesCollapsedRows(expanded)).toBe(false);
});

it("rejects a typo'd expansion kind", () => {
// The comparison inside `hidesCollapsedRows` is what decides whether a
// CSV export reports `complete: true` or a `collapsed-groups` omission.
// Before the narrowing the parameter was `{ default: { kind: string } }`,
// so this call compiled and this directive went unused.
hidesCollapsedRows({
// @ts-expect-error -- "expandedd" is not a PretableExpansionDefault kind.
default: { kind: "expandedd" },
overrideCount: 0,
});
});

it("rejects an expansion shape the row model never publishes", () => {
hidesCollapsedRows({
// @ts-expect-error -- the through-depth variant also carries `depth`.
default: { kind: "through-depth" },
overrideCount: 0,
});
});
});
Loading
Loading