};
}
type ColumnPluginOption = P extends BasePlugin
@@ -21,7 +33,16 @@ export type CellOptions = {
defaultValue?: string;
} & Record;
-export interface ColumnConfig {
+/**
+ * `CellArgs` are the args a `Cell` takes besides `@row` and `@column`,
+ * passed where the cell is rendered.
+ */
+export interface ColumnConfig<
+ T = unknown,
+ ColumnMeta = unknown,
+ Meta = unknown,
+ CellArgs = unknown,
+> {
/**
* the `key` is required for preferences storage, as well as
* managing uniqueness of the columns in an easy-to-understand way.
@@ -36,25 +57,41 @@ export interface ColumnConfig {
/**
* Optionally provide a function to determine the value of a row at this column
+ *
+ * `column.meta` is `unknown` here, and in `options`.
+ * Typed with the column meta, it would be `any`:
+ * TypeScript takes the type from the plain column list in `HeadlessTableConfig`,
+ * where the column meta is `any`.
*/
- value?: (context: CellContext) => ContentValue;
+ value?: (context: CellContext>) => ContentValue;
/**
* Recommended property to use for custom components for each cell per column.
* Out-of-the-box, this property isn't used, but the provided type may be
* a convenience for consumers of the headless table
*/
- Cell?: ComponentLike>;
+ Cell?: ComponentLike<
+ CellContext, NoInfer> & CellArgs
+ >;
/**
* The name or title of the column, shown in the column heading / th
*/
name?: string;
+ /**
+ * Information about the column that is not tied to a row,
+ * for example the alignment of its cells.
+ *
+ * Read it back as `column.meta`.
+ * Its type is inferred from what the columns config provides.
+ */
+ meta?: ColumnMeta;
+
/**
* Bag of extra properties to pass to Cell via `@options`, if desired
*/
- options?: (context: CellContext) => CellOptions;
+ options?: (context: CellContext>) => CellOptions;
/**
* Each plugin may provide column options, and provides similar syntax to how
@@ -68,6 +105,14 @@ export interface ColumnConfig {
* ```
*/
pluginOptions?: ColumnPluginOption[];
+
+ /**
+ * Type-only, never set.
+ *
+ * Without a direct mention of `T`, a list typed `ColumnConfig[]`
+ * gives `headlessTable` a row type that depends on the order TypeScript checks the program in.
+ */
+ readonly [rowType]?: T;
}
export type ColumnKey = NonNullable['key']>;
diff --git a/table/src/-private/interfaces/table.ts b/table/src/-private/interfaces/table.ts
index b22aa327..70f6b0da 100644
--- a/table/src/-private/interfaces/table.ts
+++ b/table/src/-private/interfaces/table.ts
@@ -1,5 +1,6 @@
import type { Plugins } from '../../plugins/-private/utils';
import type { ColumnConfig } from './column';
+import type { CellArgsOf } from '../meta.ts';
import type { Pagination } from './pagination';
import type { PreferencesAdapter } from './preferences';
import type { Selection } from './selection';
@@ -9,13 +10,16 @@ export interface TableMeta {
totalRowsSelectedCount?: number;
}
-export interface TableConfig {
+/**
+ * `Meta` is the type of this config's own `meta`.
+ */
+export interface TableConfig {
/**
* Configuration describing how the table will crawl through `data`
* and render it. Within this `columns` config, there will also be opportunities
* to set the behavior of columns when rendered
*/
- columns: () => ColumnConfig[];
+ columns: () => ColumnConfig[];
/**
* The data to render, as described via the `columns` option.
*
@@ -87,7 +91,11 @@ export interface TableConfig {
onRowSelectionChange?: (selection: DataType | undefined) => void;
// Uncategorized
- meta?: TableMeta;
+ /**
+ * Information about the table, for plugins, columns, and cells.
+ * Its type is inferred, and read back as `table.config.meta`.
+ */
+ meta?: TableMeta & Meta;
pagination?: Pagination;
/**
@@ -137,3 +145,43 @@ export interface TableConfig {
}
| (() => { key: string; adapter?: PreferencesAdapter });
}
+
+/**
+ * The config that `headlessTable` takes.
+ *
+ * `ColumnMetas` holds the `meta` of each column, in order,
+ * so that each column's `meta` is inferred.
+ *
+ * `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.
+ *
+ * `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.
+ */
+export type HeadlessTableConfig<
+ DataType,
+ ColumnMetas extends unknown[] = unknown[],
+ Meta = unknown,
+ Columns extends readonly unknown[] = readonly unknown[],
+> = Omit, 'columns'> & {
+ /**
+ * Configuration describing how the table will crawl through `data`
+ * and render it. Within this `columns` config, there will also be opportunities
+ * to set the behavior of columns when rendered
+ */
+ columns: () => {
+ [K in keyof ColumnMetas]: ColumnConfig<
+ DataType,
+ ColumnMetas[K],
+ Meta,
+ NoInfer>
+ >;
+ } & Columns &
+ readonly ColumnConfig[];
+};
diff --git a/table/src/-private/js-helper.ts b/table/src/-private/js-helper.ts
index de3fffc6..ac66473d 100644
--- a/table/src/-private/js-helper.ts
+++ b/table/src/-private/js-helper.ts
@@ -2,7 +2,8 @@ import { assert } from '@ember/debug';
import { Table } from './table.ts';
-import type { TableConfig } from './interfaces';
+import type { HeadlessTableConfig, TableConfig } from './interfaces';
+import type { CellArgsOf, ColumnMetaOf } from './meta.ts';
/**
* Represents a UI-less version of a table
@@ -24,15 +25,21 @@ import type { TableConfig } from './interfaces';
* ```
*
*/
-export function headlessTable(
+export function headlessTable<
+ T = unknown,
+ const ColumnMetas extends unknown[] = unknown[],
+ Meta = unknown,
+ Columns extends readonly unknown[] = readonly unknown[],
+>(
parent: object,
- options: TableConfig,
-): Table {
+ options: HeadlessTableConfig,
+): Table, Meta, CellArgsOf> {
assert(
`headlessTable requires a parent object as the first argument, usually \`this\`. ` +
`The single-argument form was removed, because the table is no longer a Resource.`,
options,
);
- return new Table(parent, options);
+ // The meta types only shape what the table returns, so they come from the return type.
+ return new Table(parent, options as TableConfig);
}
diff --git a/table/src/-private/meta.ts b/table/src/-private/meta.ts
new file mode 100644
index 00000000..0c43e160
--- /dev/null
+++ b/table/src/-private/meta.ts
@@ -0,0 +1,111 @@
+import type { TemplateOnlyComponent } from '@ember/component/template-only';
+import type {
+ Invoke,
+ UnwrapNamedArgs,
+} from '@glint/template/-private/integration';
+import type { ComponentSignatureArgs } from '@glint/template/-private/signature';
+
+/**
+ * The metas of columns that set one.
+ * A column without `meta` infers `unknown`, and would swallow the union.
+ */
+type ProvidedMetas = {
+ [K in keyof Metas]: unknown extends Metas[K] ? never : Metas[K];
+}[number];
+
+type KeysOf = U extends unknown ? keyof U : never;
+
+type ValueAt = U extends unknown
+ ? K extends keyof U
+ ? U[K]
+ : never
+ : never;
+
+/**
+ * The type of `column.meta`, from the `meta` of each column in a config.
+ *
+ * A list written in place is a tuple, and its metas merge into one object:
+ *
+ * [{ meta: { align: 'left' } }, { meta: { align: 'right', width: 2 } }]
+ * → { align?: 'left' | 'right'; width?: 2 }
+ *
+ * A list with a declared type keeps that type:
+ *
+ * ColumnConfig[] → ReportMeta
+ */
+export type ColumnMetaOf =
+ number extends Metas['length']
+ ? Metas[number]
+ : [ProvidedMetas] extends [never]
+ ? unknown
+ : {
+ -readonly [K in KeysOf>]?: ValueAt<
+ ProvidedMetas,
+ K
+ >;
+ };
+
+/**
+ * The Cells of columns that set one.
+ */
+type ProvidedCells =
+ Columns[number] extends infer Column
+ ? Column extends { Cell?: infer Cell }
+ ? NonNullable
+ : never
+ : never;
+
+/**
+ * The named args of a component:
+ *
+ * - a template-only component: from its signature
+ * - a class component: from the `[Invoke]` Glint gives it, else from `args`
+ * - a `ComponentLike`: from `[Invoke]`
+ */
+type NamedArgsOf =
+ Cell extends TemplateOnlyComponent
+ ? ComponentSignatureArgs['Named']
+ : Cell extends abstract new (...args: any) => infer Instance
+ ? Instance extends { [Invoke]: (...args: infer Params) => any }
+ ? Params extends [...unknown[], infer Named]
+ ? UnwrapNamedArgs
+ : Params extends [(infer Named)?]
+ ? UnwrapNamedArgs>
+ : never
+ : Instance extends { args: infer Args }
+ ? Args
+ : never
+ : never;
+
+type ExtraArgsOf = Cell extends unknown
+ ? Omit, 'row' | 'column'>
+ : never;
+
+type UnionToIntersection = (
+ U extends unknown ? (union: U) => void : never
+) extends (intersection: infer I) => void
+ ? I
+ : never;
+
+/**
+ * The args of `column.Cell` besides `@row` and `@column`:
+ * every arg that any Cell of the config takes.
+ *
+ * [{ Cell: GroupByCell }, { Cell: UpdateCell }]
+ * → { groupBy: ... } & { onUpdate: ... }
+ */
+export type CellArgsOf = [
+ ProvidedCells,
+] extends [never]
+ ? unknown
+ : UnionToIntersection>>;
+
+/**
+ * The `@options` the Cells of a table ask for, from their args.
+ * Without such a Cell, nothing is added to the default value.
+ */
+export type CellOptionsOf = CellArgs extends {
+ options?: infer Options;
+}
+ ? Options
+ : unknown;
diff --git a/table/src/-private/table.ts b/table/src/-private/table.ts
index 8ae789e9..58e90eef 100644
--- a/table/src/-private/table.ts
+++ b/table/src/-private/table.ts
@@ -51,7 +51,12 @@ const attachContainer = (element: Element, table: Table) => {
* Symbol-keyed fields live on this interface,
* because `isolatedDeclarations` cannot emit computed class members.
*/
-export interface Table {
+export interface Table<
+ DataType = unknown,
+ ColumnMeta = unknown,
+ Meta = unknown,
+ CellArgs = any,
+> {
/**
* @private
*/
@@ -70,8 +75,20 @@ export interface Table {
[ROW_META_KEY]: WeakMap, any>>;
}
+/**
+ * `ColumnMeta` is the type of `column.meta`,
+ * and `Meta` the type of `table.config.meta`, apart from the keys of `TableMeta`.
+ * `CellArgs` are the args of `column.Cell` besides `@row` and `@column`.
+ *
+ * `headlessTable` infers all three from the config.
+ */
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
-export class Table {
+export class Table<
+ DataType = unknown,
+ ColumnMeta = unknown,
+ Meta = unknown,
+ CellArgs = any,
+> {
/**
* @private
*
@@ -95,9 +112,9 @@ export class Table {
scrollContainerElement?: HTMLElement;
#parent: object;
- #config: TableConfig;
+ #config: TableConfig;
- constructor(parent: object, config: TableConfig) {
+ constructor(parent: object, config: TableConfig) {
this.#parent = parent;
this.#config = config;
this[TABLE_KEY] = guidFor(this);
@@ -142,7 +159,7 @@ export class Table {
*
* used by other private APIs
*/
- get config(): TableConfig {
+ get config(): TableConfig {
return this.#config;
}
@@ -277,7 +294,10 @@ export class Table {
return result as unknown as Instance | undefined;
}
- rows: MappedArray> = map(this, {
+ rows: MappedArray<
+ DataType[],
+ Row & { table: Table }
+ > = map(this, {
data: () => {
const dataFn = this.#config.data;
@@ -285,10 +305,17 @@ export class Table {
return dataFn() ?? [];
},
- map: (datum) => new Row(this, datum),
+ // A row's table is this table, so it has this table's types.
+ map: (datum) =>
+ new Row(this, datum) as Row & {
+ table: Table;
+ },
});
- columns: MappedArray[], Column> = map(this, {
+ columns: MappedArray<
+ ColumnConfig[],
+ Column
+ > = map(this, {
data: () => {
const configFn = this.#config.columns;
@@ -320,7 +347,7 @@ export class Table {
return result;
},
map: (config) => {
- return new Column(this, {
+ return new Column(this, {
...DEFAULT_COLUMN_CONFIG,
...config,
});
diff --git a/table/src/index.ts b/table/src/index.ts
index e4182017..af8b79bf 100644
--- a/table/src/index.ts
+++ b/table/src/index.ts
@@ -12,8 +12,10 @@ export { deserializeSorts, serializeSorts } from './utils.ts';
*******************************/
export type { Column } from './-private/column.ts';
export type {
+ CellContext,
ColumnConfig,
ColumnKey,
+ HeadlessTableConfig,
Pagination,
PreferencesAdapter,
TablePreferencesData as PreferencesData,
diff --git a/table/src/plugins/-private/base.ts b/table/src/plugins/-private/base.ts
index d0c66948..e0dec7ad 100644
--- a/table/src/plugins/-private/base.ts
+++ b/table/src/plugins/-private/base.ts
@@ -309,7 +309,25 @@ export const preferences = {
* This works recursively up the plugin tree up until a plugin has no requirements, and then
* all columns from the table are returned.
*/
-function columnsFor(
+function columnsFor<
+ DataType = any,
+ ColumnMeta = unknown,
+ Meta = unknown,
+ CellArgs = unknown,
+>(
+ table: Table,
+ requester?: Plugin,
+): Column[] {
+ // Plugins hold columns of this same table, so they have its meta.
+ return resolveColumns(table, requester) as Column<
+ DataType,
+ ColumnMeta,
+ Meta,
+ CellArgs
+ >[];
+}
+
+function resolveColumns(
table: Table,
requester?: Plugin,
): Column[] {
@@ -432,10 +450,15 @@ export const columns = {
* If a plugin class is provided, the hierarchy of column list modifications
* will be respected.
*/
- next: (
- current: Column,
+ next: <
+ Data = unknown,
+ ColumnMeta = unknown,
+ Meta = unknown,
+ CellArgs = unknown,
+ >(
+ current: Column,
requester?: Plugin,
- ): Column | undefined => {
+ ): Column | undefined => {
const columns = requester
? columnsFor(current.table, requester)
: columnsFor(current.table);
@@ -464,10 +487,15 @@ export const columns = {
* If a plugin class is provided, the hierarchy of column list modifications
* will be respected.
*/
- previous: (
- current: Column,
+ previous: <
+ Data = unknown,
+ ColumnMeta = unknown,
+ Meta = unknown,
+ CellArgs = unknown,
+ >(
+ current: Column,
requester?: Plugin,
- ): Column | undefined => {
+ ): Column | undefined => {
const columns = requester
? columnsFor(current.table, requester)
: columnsFor(current.table);
@@ -494,10 +522,15 @@ export const columns = {
* if a plugin class is provided, the hierarchy of column list modifications
* will be respected.
*/
- before: (
- current: Column,
+ before: <
+ Data = unknown,
+ ColumnMeta = unknown,
+ Meta = unknown,
+ CellArgs = unknown,
+ >(
+ current: Column,
requester?: Plugin,
- ): Column[] => {
+ ): Column[] => {
const columns = requester
? columnsFor(current.table, requester)
: columnsFor(current.table);
@@ -513,10 +546,15 @@ export const columns = {
* if a plugin class is provided, the hierarchy of column list modifications
* will be respected.
*/
- after: (
- current: Column,
+ after: <
+ Data = unknown,
+ ColumnMeta = unknown,
+ Meta = unknown,
+ CellArgs = unknown,
+ >(
+ current: Column,
requester?: Plugin,
- ): Column[] => {
+ ): Column[] => {
const columns = requester
? columnsFor(current.table, requester)
: columnsFor(current.table);
diff --git a/table/src/plugins/column-reordering/helpers.ts b/table/src/plugins/column-reordering/helpers.ts
index cfc5d1b9..da9d675c 100644
--- a/table/src/plugins/column-reordering/helpers.ts
+++ b/table/src/plugins/column-reordering/helpers.ts
@@ -88,9 +88,14 @@ export const canMoveRight = (
* // Use the ordered columns for rendering or other operations
* ```
*/
-export const orderedColumnsFor = (
- table: Table,
-): Column[] => {
+export const orderedColumnsFor = <
+ DataType = unknown,
+ ColumnMeta = unknown,
+ Meta = unknown,
+ CellArgs = unknown,
+>(
+ table: Table,
+): Column[] => {
// Note: The meta.forTable API doesn't preserve the DataType generic from the table parameter.
// This is a limitation of the current plugin meta system architecture.
// We use a type assertion here because we know the columns come from the same table.
@@ -98,5 +103,10 @@ export const orderedColumnsFor = (
table,
ColumnReordering,
) as TableMeta;
- return tableMeta.columnOrder.orderedColumns;
+ return tableMeta.columnOrder.orderedColumns as Column<
+ DataType,
+ ColumnMeta,
+ Meta,
+ CellArgs
+ >[];
};
diff --git a/test-app/tests/integration/cells-test.gts b/test-app/tests/integration/cells-test.gts
new file mode 100644
index 00000000..332683ad
--- /dev/null
+++ b/test-app/tests/integration/cells-test.gts
@@ -0,0 +1,135 @@
+import { setOwner } from "@ember/owner";
+import { render } from "@ember/test-helpers";
+import { module, test } from "qunit";
+import { setupRenderingTest } from "ember-qunit";
+
+import { headlessTable } from "@universal-ember/table";
+
+import type { TOC } from "@ember/component/template-only";
+import type { CellContext } from "@universal-ember/table";
+
+interface Person {
+ name: string;
+ age: number;
+}
+
+const people: Person[] = [
+ { name: "Ada", age: 36 },
+ { name: "Grace", age: 45 },
+];
+
+const GroupedCell: TOC<{
+ Args: CellContext & { groupBy: "day" | "week" };
+}> =
+ {{@row.data.name}} by {{@groupBy}}
+;
+
+const AgeCell: TOC<{ Args: CellContext }> =
+ {{@row.data.age}}
+;
+
+const UnitCell: TOC<{
+ Args: CellContext & { options: { unit: string } };
+}> =
+ {{@row.data.age}} {{@options.unit}}
+;
+
+class OptionsContext {
+ table = headlessTable(this, {
+ columns: () => [
+ { key: "age", Cell: UnitCell, options: () => ({ unit: "years" }) },
+ ],
+ data: () => people,
+ });
+}
+
+class Context {
+ table = headlessTable(this, {
+ columns: () => [
+ { key: "name", Cell: GroupedCell },
+ { key: "age", Cell: AgeCell },
+ {
+ key: "inline",
+ Cell:
+ inline
+ ,
+ },
+ ],
+ data: () => people,
+ });
+}
+
+module("Cells", function (hooks) {
+ setupRenderingTest(hooks);
+
+ test("each Cell is rendered with the args of the table", async function (assert) {
+ const ctx = new Context();
+
+ setOwner(ctx, this.owner);
+
+ const table = ctx.table;
+
+ await render(
+
+ {{#each table.rows as |row|}}
+ {{#each table.columns as |column|}}
+ {{#if column.Cell}}
+
+ {{/if}}
+ {{/each}}
+ {{/each}}
+ ,
+ );
+
+ assert.dom(".grouped").exists({ count: 2 });
+ assert.dom(".grouped").hasText("Ada by week");
+ assert.dom(".age").exists({ count: 2 });
+ assert.dom(".inline").exists({ count: 2 });
+ });
+
+ test("a Cell gets the options of its column", async function (assert) {
+ const ctx = new OptionsContext();
+
+ setOwner(ctx, this.owner);
+
+ const table = ctx.table;
+
+ await render(
+
+ {{#each table.rows as |row|}}
+ {{#each table.columns as |column|}}
+ {{#if column.Cell}}
+
+ {{/if}}
+ {{/each}}
+ {{/each}}
+ ,
+ );
+
+ assert.dom(".unit").exists({ count: 2 });
+ assert.dom(".unit").hasText("36 years");
+ });
+});
+
+/**
+ * Never rendered: these templates only exist for the type checks.
+ */
+const TypeChecks: TOC<{ Args: { table: Context["table"] } }> =
+ {{#each @table.rows as |row|}}
+ {{#each @table.columns as |column|}}
+ {{#if column.Cell}}
+ {{! @glint-expect-error the table's Cells need @groupBy }}
+
+
+ {{! @glint-expect-error not one of the groupings }}
+
+ {{/if}}
+ {{/each}}
+ {{/each}}
+;
+
+void TypeChecks;
| | |