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
35 changes: 33 additions & 2 deletions docs-app/src/templates/1-get-started/typing-meta-and-cells.gjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,20 @@ columns: () => [

`column.getOptionsForRow` has the type of the `@options` that the Cells ask for, and the `defaultValue`.

TypeScript checks each column that you write in place:
a column whose Cell asks for `@options` must have an `options` that returns them.

```ts
columns: () => [
// error: `unit` is not a number
{ key: "age", Cell: UnitCell, options: () => ({ unit: 3 }) },
// error: the Cell asks for `@options`, and this column has no `options`
{ key: "height", Cell: UnitCell },
],
```

To name the types of options in your own code, import `CellOptions` (what `options` returns) and `CellOptionsOf` (the `@options` of a Cell's args).

## Code that takes any table

A function that accepts `Column<Person>` or `Table<Person>` accepts columns and tables with any meta and any cell args.
Expand All @@ -237,12 +251,29 @@ function exportWidthOf(column: Column<Person, { exportWidth?: number }>) {
}
```

## Shared table components

A component that takes a column list from its caller and builds the table itself
can pass its type arguments through.
The table keeps the cell args, and the column metas, that the list declares:

```ts
import type { ColumnConfig } from "@universal-ember/table";

function makeTable<Row, CellArgs>(
parent: object,
columns: ColumnConfig<Row, unknown, unknown, CellArgs>[],
data: Row[],
) {
return headlessTable(parent, { columns: () => columns, data: () => data });
}
```

## Limits

- In `value` and `options`, `column.meta` is `unknown`, because TypeScript cannot give it the inferred type there.
Use the value from the column config itself.
- An inline `<template>` Cell has no types for its args.
To use `@row` or other args in it, move it into a constant with a `TOC` type, as in the examples above.
- A function inside a `meta` needs types on its parameters.
- TypeScript does not check that a column's `options` returns the `@options` its Cell asks for.
The return type of `options` is only known after the Cell types are fixed.
- In a list with a declared type, `options` is not checked against the `@options` of the Cells.
23 changes: 23 additions & 0 deletions table/src/-private/-type-tests/cell-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,26 @@ expectTypeOf(options.defaultValue).toEqualTypeOf<string>();
expectTypeOf(plain.columns[0]!.getOptionsForRow(optionsRow)).toEqualTypeOf<{
defaultValue: string;
}>();

/////////////////////////////////////////////
// A table meta and a callback with parameters do not stop the inference
const withMetaAndCallback = headlessTable(
{},
{
columns: () => [
{ key: 'name', meta: { align: 'left' }, Cell: GroupedCell },
{ key: 'age', value: ({ row }) => row.data.age },
],
data: () => people,
meta: { currency: 'EUR' },
},
);

type MetaAndCallbackArgs = CellArgsOf<
(typeof withMetaAndCallback.columns)[0]['Cell']
>;

expectTypeOf<MetaAndCallbackArgs['groupBy']>().toEqualTypeOf<'day' | 'week'>();
expectTypeOf(withMetaAndCallback.columns[0]!.meta?.align).toEqualTypeOf<
'left' | undefined
>();
68 changes: 68 additions & 0 deletions table/src/-private/-type-tests/declared-cell-args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { expectTypeOf } from 'expect-type';

import { headlessTable } from '../../index.ts';

import type { CellContext, ColumnConfig } from '../../index.ts';
import type { ComponentLike } from '@glint/template';

interface Person {
name: string;
}
declare const people: Person[];

interface GroupArgs {
groupBy: 'day' | 'week';
}

type CellArgsOf<Cell> =
NonNullable<Cell> extends ComponentLike<infer Args> ? Args : never;

/////////////////////////////////////////////
// A shared component takes a column list from its caller.
// The cell args are a type parameter there, with no Cell to read them from.
function makeTable<T, CellArgs>(
columns: ColumnConfig<T, unknown, unknown, CellArgs>[],
data: T[],
) {
return headlessTable({}, { columns: () => columns, data: () => data });
}

declare const groupColumns: ColumnConfig<Person, unknown, unknown, GroupArgs>[];

const shared = makeTable(groupColumns, people);

expectTypeOf<
CellArgsOf<(typeof shared.columns)[0]['Cell']>['groupBy']
>().toEqualTypeOf<'day' | 'week'>();

/////////////////////////////////////////////
// A declared list keeps its args, even when no Cell in it asks for them
declare const PlainCell: ComponentLike<CellContext<Person>>;

const declared: ColumnConfig<Person, unknown, unknown, GroupArgs>[] = [
{ key: 'name', Cell: PlainCell },
];
const fromDeclared = headlessTable(
{},
{ columns: () => declared, data: () => people },
);

expectTypeOf<
CellArgsOf<(typeof fromDeclared.columns)[0]['Cell']>['groupBy']
>().toEqualTypeOf<'day' | 'week'>();

/////////////////////////////////////////////
// A list written in place still reads its Cells
declare const GroupedCell: ComponentLike<CellContext<Person> & GroupArgs>;

const inPlace = headlessTable(
{},
{
columns: () => [{ key: 'name', Cell: GroupedCell }, { key: 'plain' }],
data: () => people,
},
);

expectTypeOf<
CellArgsOf<(typeof inPlace.columns)[0]['Cell']>['groupBy']
>().toEqualTypeOf<'day' | 'week'>();
109 changes: 109 additions & 0 deletions table/src/-private/-type-tests/options-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { expectTypeOf } from 'expect-type';

import { headlessTable } from '../../index.ts';

import type {
CellContext,
CellOptions,
CellOptionsOf,
ColumnConfig,
} from '../../index.ts';
import type { ComponentLike } from '@glint/template';

interface Person {
name: string;
age: number;
}
declare const people: Person[];

interface Unit {
unit: string;
}

declare const UnitCell: ComponentLike<CellContext<Person> & { options: Unit }>;
declare const PlainCell: ComponentLike<CellContext<Person>>;

/////////////////////////////////////////////
// A column whose Cell asks for `@options` gives them through its `options`
headlessTable(
{},
{
columns: () => [
{ key: 'age', Cell: UnitCell, options: () => ({ unit: 'years' }) },
{ key: 'name', Cell: PlainCell },
],
data: () => people,
},
);

headlessTable(
{},
{
columns: () => [
{
key: 'age',
Cell: UnitCell,
// @ts-expect-error `unit` is not a number
options: () => ({ unit: 3 }),
},
],
data: () => people,
},
);

headlessTable(
{},
{
columns: () => [
// @ts-expect-error the Cell asks for `@options`, and the column has no `options`
{ key: 'age', Cell: UnitCell },
],
data: () => people,
},
);

headlessTable(
{},
{
columns: () => [
// @ts-expect-error the `options` of another column do not count
{ key: 'age', Cell: UnitCell },
{ key: 'name', options: () => ({ unit: 'years' }) },
],
data: () => people,
},
);

// The callbacks still see the row, and the column metas are still inferred
const withMeta = headlessTable(
{},
{
columns: () => [
{
key: 'age',
meta: { align: 'right' },
Cell: UnitCell,
options: ({ row }) => ({ unit: row.data.age > 1 ? 'years' : 'year' }),
},
],
data: () => people,
meta: { currency: 'EUR' },
},
);

expectTypeOf(withMeta.columns[0]!.meta?.align).toEqualTypeOf<
'right' | undefined
>();

/////////////////////////////////////////////
// The option types can be named
const unitOptions = (context: CellContext<Person>): CellOptions => ({
unit: context.row.data.age > 1 ? 'years' : 'year',
});

const declared: ColumnConfig<Person>[] = [{ key: 'age', options: unitOptions }];

expectTypeOf<CellOptionsOf<{ options: Unit }>>().toEqualTypeOf<Unit>();
expectTypeOf<CellOptionsOf<{ groupBy: 'day' }>>().toEqualTypeOf<unknown>();

void declared;
12 changes: 12 additions & 0 deletions table/src/-private/interfaces/column.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import type { Constructor } from '../private-types';
import type { ComponentLike, ContentValue } from '@glint/template';

declare const rowType: unique symbol;
declare const cellArgsType: unique symbol;

export type { cellArgsType };

/**
* What `value`, `options`, and a `Cell` receive.
Expand Down Expand Up @@ -113,6 +116,15 @@ export interface ColumnConfig<
* gives `headlessTable` a row type that depends on the order TypeScript checks the program in.
*/
readonly [rowType]?: T;

/**
* Type-only, never set.
*
* A column list with a declared type has no Cells to read the args from,
* for example a list that a shared component takes as an argument.
* The args it declares are read from here.
*/
readonly [cellArgsType]?: CellArgs;
}

export type ColumnKey<T> = NonNullable<ColumnConfig<T>['key']>;
26 changes: 12 additions & 14 deletions table/src/-private/interfaces/table.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Plugins } from '../../plugins/-private/utils';
import type { ColumnConfig } from './column';
import type { CellArgsOf } from '../meta.ts';
import type { ColumnCheck } from '../meta.ts';
import type { Pagination } from './pagination';
import type { PreferencesAdapter } from './preferences';
import type { Selection } from './selection';
Expand Down Expand Up @@ -154,15 +154,13 @@ export interface TableConfig<DataType, Meta = unknown> {
*
* `Columns` is the column list as written.
* The extra args of its Cells are read from it,
* and every Cell is checked against all of them.
* and each column of it is checked on its own, see `ColumnCheck`.
*
* `TableConfig` stays a plain interface,
* for code that annotates a config or reads `table.config`.
*
* The plain list next to the mapped one lets TypeScript infer `DataType`
* from the columns too, which the mapped list alone does not.
* Its column meta is `any`, so that Cells that read a meta fit it.
* The mapped list checks each column's meta.
* The plain list lets TypeScript infer `DataType` from the columns too,
* which the mapped list alone does not.
*/
export type HeadlessTableConfig<
DataType,
Expand All @@ -176,12 +174,12 @@ export type HeadlessTableConfig<
* to set the behavior of columns when rendered
*/
columns: () => {
[K in keyof ColumnMetas]: ColumnConfig<
DataType,
ColumnMetas[K],
Meta,
NoInfer<CellArgsOf<Columns>>
>;
} & Columns &
readonly ColumnConfig<DataType, any, Meta, any>[];
[K in keyof ColumnMetas]: ColumnConfig<DataType, ColumnMetas[K], Meta, any>;
} & Columns & {
[K in Extract<keyof Columns, `${number}`>]: ColumnCheck<
DataType,
Meta,
Columns[K]
>;
} & readonly ColumnConfig<DataType, any, Meta, any>[];
};
2 changes: 1 addition & 1 deletion table/src/-private/js-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function headlessTable<
T = unknown,
const ColumnMetas extends unknown[] = unknown[],
Meta = unknown,
Columns extends readonly unknown[] = readonly unknown[],
const Columns extends readonly unknown[] = readonly unknown[],
>(
parent: object,
options: HeadlessTableConfig<T, ColumnMetas, Meta, Columns>,
Expand Down
Loading
Loading