Skip to content

Tables: what users can do

Every list screen in a Kumiko app (entityList, projectionList and relatedList) can give its users a set of controls: which columns show, how dense the rows are, which column stays put while the table scrolls, groups with sums computed on the server, a selection with actions on all selected rows, and an export to Excel. Each part is opt-in. A list without the table block renders and behaves as before.

The first part of this guide shows what users see. The second part shows what an app developer writes to switch it on. The screenshots come from the task board in the framework styleguide, a list of twelve tasks with status, priority, due date, assignee and an estimate in hours.

The view menu opens from the column icon at the right end of the toolbar. It lists every column of the screen with a checkbox and arrows to move it up or down; the handle on the left drags a column to a new place.

Task board with the open view menu listing the columns with checkboxes and move arrows and the density switch

Unchecking a column removes it from the table right away, and the arrows change the order. A column the app starts as hidden is unchecked here and can be added back the same way.

Task board after hiding and reordering columns in the view menu

Under “Density” the menu offers “Comfortable” and “Compact”. Compact shrinks the row height so that more rows fit on one screen. The app sets the default, the user’s choice sticks for the browser session.

Task board in compact density with twelve rows and the Export button in the toolbar

On a phone the same list turns into cards. Each card shows the title, the badges and a chevron into the record; the view menu and the export stay in the toolbar.

The same task list on a phone as a stack of cards with status and priority badges

Pin columns, resize them, move with the keyboard

Section titled “Pin columns, resize them, move with the keyboard”

With pinning on, the view menu gets a “Pinned columns” section with a left and a right button per column. A pinned column stays visible while the rest of the table scrolls sideways.

View menu with the Pinned columns section, Title pinned left Task board scrolled sideways with the pinned Title column still in place

With resizing on, the border between two column headers can be dragged. The app gives each column a start width and a minimum; the user’s widths are kept for the session.

With keyboard navigation on, the table is one tab stop. The arrow keys move the focus from row to row, Home and End jump to the first and last row, Enter opens the focused row and Space toggles its checkbox when the list has bulk actions.

With rememberState on, the search text, sort, page size, facets and date range of a list survive leaving and re-entering it within the browser session. A user who opens a record from a filtered list and comes back finds the same filter. The memory is per list, per user and per tenant, and it is cleared on logout. A link that carries its own parameters still wins over the remembered state, so a shared link shows what the sender saw.

With resetAll on, a “Reset” button appears in the toolbar as soon as anything differs from the defaults: search, facets, sort, page size, columns, density or pinned columns. One click puts everything back.

Task board filtered by the search text invoice with the Reset button next to the facets

With grouping on, the list is split into groups by one field, in this screenshot by status. Each group header shows the number of rows and the sums the app has configured, here the estimate in hours. These numbers come from one aggregate query over the whole filtered list on the server, so they are right even when a group shows only its first rows, and the “Total” row at the bottom covers the whole result set.

Task board grouped by status with counts and estimate sums per group header, Done collapsed, and a Total row

A group starts collapsed when the app says so (here “Done”) and loads nothing until it is opened. Each open group loads its first page; “Load more” fetches the next rows of that group alone. When the app allows it, the view menu gets a “Group by” section where the user picks another field or “None”. A list with more than 200 groups shows the first 200 and says so.

A list can show a total row over the filtered list without grouping, for example the sum of all estimates. Changing a filter changes the total; paging does not, because the sum is computed on the server over all matching rows.

Estimates list showing three of twelve rows per page with a Total row of 106 over all matching rows

For a number or money field the app can offer a range facet: a from and a to input next to the other facets. The range is part of the URL like any other facet, so it can be shared, and it is remembered with the rest of the list state.

List filtered to estimates between 4 and 8 hours with a Total row of 30 under three rows

Select rows and run an action on all of them

Section titled “Select rows and run an action on all of them”

With bulk actions on, every row gets a checkbox and the header gets one for the loaded rows of the page. A bar above the table shows how many rows are selected, one button per action and “Clear selection”. The selection covers at most 100 rows of the current page; there is no “select everything that matches”.

A destructive action, or one with a confirmation, asks once for the whole selection. The framework then runs the single-row action once per selected row, four at a time, so each row goes through the same access checks, hooks and audit trail as a click on that row alone. “Cancel” stops new rows, running ones finish. Afterwards a banner shows how many succeeded, failed, were skipped (rows the action does not apply to, such as a done task for “Delete”) or were not run. Failed rows stay selected for a retry, and the list reloads.

Task board with two rows selected and the bar above the table showing 2 selected, a Delete button and Clear selection

With export on, an “Export” button sits next to the view menu. It downloads the whole filtered list as an .xlsx file, not only the visible page, with the columns of the screen in their configured order and reference columns resolved to their labels. Dates and times are written in the browser’s time zone as plain values. Fields that are encrypted, marked sensitive or write-only, and fields the user may not read, are left out. The default limit is 10000 rows; the app can set another limit of up to 100000 rows. A list above the limit is refused with a hint to narrow the filter rather than cut off.

Task list with status facets and the Export button next to the view menu

Everything above hangs on the table block of a list screen and on bulkActions. Each key is independent. The boot validator rejects a block that does not fit the screen, for example a hidden column without the column picker, a pinned column without pinning, a width without resizing or a bulk action that points at a navigate row action.

KeyWhat the user gets
rememberStateSearch, sort, page size, facets and date range survive leaving the list within the browser session.
resetAllOne “Reset” button for everything that differs from the defaults.
columnPickerHide, show and reorder columns in the view menu; hidden: true on a column starts it hidden.
densityThe default row density; userChoice: true adds the switch.
pinningPin columns left or right; pinned: "left" on a column starts it pinned.
resizingDrag column borders; width and minWidth on a column are start values in px.
keyboardNavigationArrow keys between rows, Enter opens, Space selects.
exportThe Export button; maxRows replaces the default limit of 10000 rows, up to 100000.
groupingGroups by field; collapsedWhen starts one value collapsed; userChoice and fields open the “Group by” menu.
totalsA footer row with sum, avg, count, min or max per field.

The recipe below sets all of them on an entity list, adds a range facet, a bulk delete, and a projection list over a hand-written query that keeps grouping and totals by accepting aggregate and answering it with answerListAggregate:

import {
answerListAggregate,
buildEntityTable,
createEventStoreExecutor,
} from "@cosmicdrift/kumiko-framework/db";
import {
createDateField,
createEntity,
createNumberField,
createSelectField,
createTextField,
defineFeature,
entityListSchema,
type FeatureDefinition,
} from "@cosmicdrift/kumiko-framework/engine";
import type { ListTableOptions } from "@cosmicdrift/kumiko-framework/ui-types";
import { z } from "zod";
export const taskEntity = createEntity({
table: "read_list_table_tasks",
fields: {
title: createTextField({
personal: false,
reason: "is_business_data",
required: true,
searchable: true,
sortable: true,
}),
status: createSelectField({
options: ["open", "inProgress", "blocked", "done"] as const,
default: "open",
sortable: true,
filterable: true,
}),
priority: createSelectField({
options: ["low", "medium", "high"] as const,
default: "medium",
sortable: true,
filterable: true,
}),
dueDate: createDateField({ sortable: true }),
assignee: createTextField({ personal: false, reason: "is_business_data", sortable: true }),
estimateHours: createNumberField({ sortable: true, filterable: true }),
},
});
export const openAccess = {
access: {
openToAll: { reason: "recipe: any signed-in user manages the tasks; no per-user ownership" },
},
} as const;
// Overridable so a test can feed the boot validator a block that must be rejected.
export function createTaskBoardFeature(
taskTable: ListTableOptions = {
rememberState: true,
resetAll: true,
columnPicker: true,
density: { default: "comfortable", userChoice: true },
pinning: true,
resizing: true,
keyboardNavigation: true,
export: { xlsx: true, maxRows: 5000 },
grouping: { field: "status", collapsedWhen: "done", userChoice: true, fields: ["priority"] },
totals: [{ field: "estimateHours", kind: "sum" }],
},
): FeatureDefinition {
return defineFeature("task-board", (r) => {
r.crud("task", taskEntity, { write: openAccess, read: openAccess });
r.screen({
id: "task-list",
type: "entityList",
entity: "task",
columns: [
// Starts pinned left; width and minWidth are start values for resizing.
{ field: "title", pinned: "left", width: 280, minWidth: 160 },
"status",
"priority",
{ field: "dueDate", hideOnNarrow: true },
{ field: "assignee", hideOnNarrow: true },
// Starts hidden; the user can add it back in the view menu.
{ field: "estimateHours", hidden: true, hideOnNarrow: true },
],
facets: { status: { display: "chips" }, priority: {}, estimateHours: { display: "range" } },
defaultSort: { field: "dueDate", dir: "asc" },
searchable: true,
table: taskTable,
rowActions: [
{ kind: "navigate", id: "open", label: "Open", screen: "task-edit", rowClick: true },
{
kind: "writeHandler",
id: "delete",
label: "Delete",
handler: "task-board:write:task:delete",
style: "danger",
// Done tasks are skipped by the bulk run and counted as skipped.
visible: { field: "status", ne: "done" },
},
],
// Row checkboxes and a selection bar; each selected row gets its own delete write.
bulkActions: [{ id: "delete", rowAction: "delete" }],
access: openAccess.access,
});
r.screen({
id: "task-edit",
type: "entityEdit",
entity: "task",
dormant: true,
listScreenId: "task-list",
layout: {
sections: [
{
columns: 2,
fields: ["title", "assignee", "status", "priority", "dueDate", "estimateHours"],
},
],
},
});
// The same block works on a list over any query.
r.queryHandler({
name: "task:overdue",
schema: z.object({}),
handler: async () => ({
rows: [{ id: "t1", title: "Quarterly VAT filing", assignee: "Ines", daysLate: 3 }],
nextCursor: null,
}),
access: openAccess.access,
outputSchema: z.object({
rows: z.array(
z.object({
id: z.string(),
title: z.string(),
assignee: z.string(),
daysLate: z.number(),
}),
),
nextCursor: z.string().nullable(),
}),
});
r.screen({
id: "task-overdue",
type: "projectionList",
query: "task-board:query:task:overdue",
columns: ["title", "assignee", "daysLate"],
table: { rememberState: true, density: { userChoice: true } },
});
// A hand-written list query keeps grouping and totals when it accepts
// `aggregate` (entityListSchema carries it) and answers it on the server.
const taskExecutor = createEventStoreExecutor(
buildEntityTable("task", taskEntity),
taskEntity,
{
entityName: "task",
},
);
r.queryHandler({
name: "task:workload",
schema: entityListSchema,
handler: async (query, ctx) => {
const parentVisibility = { entities: ctx.registry.getAllEntities() };
const listPayload = query.payload;
if (listPayload.aggregate !== undefined) {
return answerListAggregate(
taskExecutor,
{ ...listPayload, aggregate: listPayload.aggregate },
query.user,
ctx.db,
{ parentVisibility },
{
entity: taskEntity,
resolveEntity: (name) => ctx.registry.getEntity(name),
viewer: { user: query.user, parentVisibility },
},
);
}
return taskExecutor.list(listPayload, query.user, ctx.db, { parentVisibility });
},
access: openAccess.access,
});
r.screen({
id: "task-workload",
type: "projectionList",
query: "task-board:query:task:workload",
columns: ["title", "assignee", "status", "estimateHours"],
defaultSort: { field: "title", dir: "asc" },
table: {
grouping: { field: "assignee" },
totals: [{ field: "estimateHours", kind: "sum" }],
},
});
r.nav({ id: "tasks", label: "task-board:nav.tasks", icon: "list", order: 10 });
r.nav({
id: "task-list",
label: "task-board:nav.list",
parent: "task-board:nav:tasks",
screen: "task-board:screen:task-list",
order: 10,
});
r.nav({
id: "task-overdue",
label: "task-board:nav.overdue",
parent: "task-board:nav:tasks",
screen: "task-board:screen:task-overdue",
order: 20,
});
r.nav({
id: "task-workload",
label: "task-board:nav.workload",
parent: "task-board:nav:tasks",
screen: "task-board:screen:task-workload",
order: 30,
});
});
}

The bundled features (users, tenants, jobs, sessions, audit and the others) ship their lists without a table block. bundledLists in kumiko.config.ts sets one block for all of them, so the user list gets the column picker and the export without the app redefining the screen. The same value goes into the run options of the bin files, so kumiko check and the running app see the same screens. grouping and totals are not allowed there, because they depend on the fields of one list.

export default defineAppConfig({
features: [...APP_FEATURES],
bundledLists: {
table: {
rememberState: true,
resetAll: true,
columnPicker: true,
density: { userChoice: true },
pinning: true,
export: { xlsx: true },
},
pinFirstColumn: true,
},
});

A feature package opts its lists in with r.adoptsBundledLists(); a feature of the app itself never does, so its own screens stay as written. A table block on a bundled screen wins per key over the shared one.