SKILL.md
atscript-ui-tables
Install
npx skills add moostjs/atscript-ui # installs all atscript-ui skills (this one + general + forms + wf + styles)
npx skills add moostjs/atscript # sibling — .as language
npx skills add moostjs/atscript-db # sibling — moost-db backs most tables
pnpm add @atscript/core @atscript/typescript @atscript/ui @atscript/ui-table @atscript/vue-table vue
pnpm add @atscript/vue-form # required peer (cell dispatch, action forms)
pnpm add @atscript/ui-fns # opt-in: dynamic @ui.table.fn.*
pnpm add @atscript/db-client # moost-db browser client
pnpm add @atscript/moost-ui-presets # server-side preset persistence (optional)
Quick start
// src/product.as
@db.table 'products'
@db.depth.limit 0
export interface Product {
@meta.id @db.default.increment
id: number
@meta.label 'SKU'
@ui.table.width '8em'
sku: string
@meta.label 'Name'
@db.index.fulltext 'name_fts'
name: string
@meta.label 'Price'
@db.column.precision 2
@db.amount.currency 'USD'
price: number
@meta.label 'In stock'
inStock: boolean
}
<script setup lang="ts">
import { createDefaultCellTypes } from "@atscript/vue-table";
const types = createDefaultCellTypes();
</script>
<template>
<AsTableRoot url="/api/db/tables/products" :types="types" :limit="20">
<AsTableActions />
<AsFilters />
<AsTable :column-menu="{ sort: true, filters: true, hide: true, resetWidth: true }" />
</AsTableRoot>
</template>
Replace url= with :query-fn="..." for a custom backend. See [query.md](references/query.md).
Invariants
| # | Rule |
|---|---|
| 1 | Programmatic state changes are picked up by the root watcher automatically. Each mutator on state (e.g. setFieldFilter, setColumnWidth, setSearchTerm) touches exactly one entity; the root watcher on [filters, sorters, pagination, columnNames] schedules the next query. Calling state.query() to "apply" a programmatic change will double-fetch and skip debouncing. |
| 2 | state.query() is reserved for user-initiated refresh. Wire it to a refresh button, pull-to-refresh, or devtools — not to apply programmatic state changes — except query({ silent: true }) for timer-driven live refresh (invariant 14). |
| 3 | filterFields (display) and filters (applied) are independent. Hiding a filter input via filterFields does NOT clear filters[path]. Clearing filters[path] does NOT hide the input. When building your own dialogs, write the new arrays directly — the root watcher reconciles. Cleanup loops that delete filters entries because filterFields shrank will fight the model and cause double-fetches. |
| 4 | @ui.table.type is for built-in renderer ids only. Built-ins: text, number, boolean, date, datetime, relative, array, object, union, enum, ref. Custom cells use @ui.table.component + the :components map. |
| 5 | Per-field filter conditions: inclusions OR-merge, exclusions AND-merge. Inclusion ops (eq, contains, starts, ends, gt, gte, lt, lte, bw, regex, null) → OR within field. Exclusion ops (ne, notNull) → AND within field. Across fields → AND. See filtersToUniqueryFilter in ui-table. |
| 6 | Force filters / sorters AND-merge; user can't remove them. Pass via useTable({ forceFilters, forceSorters }); they always prepend and dedupe by field — user mutations to filters / sorters never override them. |
| 7 | Window mode fetches in fixed-size blocks. <AsWindowTable> issues block-aligned fetches (default 100 rows) as the viewport scrolls; tune via <AsTableRoot :block-size> and :drag-release-debounce-ms (higher = fewer fetches during fast scroll). Custom virtual renderers read rows via state.dataAt(absIndex) / state.loadingAt(absIndex) / state.errorAt(absIndex) — see [query.md](references/query.md). |
| 8 | Presets opt-in per-aspect. A PresetSnapshot carries any subset of columns, filters, filterOps, sorters, itemsPerPage. Absent keys leave that slice untouched on apply. Use toWireSnapshot / fromWireSnapshot when crossing the network — never send the raw runtime dict. |
| 9 | Reserved preset id prefixes: sys: (system, client-only, never persisted), uc: (user config, deterministic id uc:<user>:<app>:<tableKey>), ac: (app config, deterministic ac:<user>:<app>). Client writes to sys:* are rejected by the server controller. |
| 10 | Server preset read gate: user = current OR (type='preset' AND public=true). Once-public-always-public — revoking publish permission doesn't unpublish existing rows. |
| 11 | /meta response carries preferredId on every row-returning read. moost-db widens $select automatically; cells/actions/refs can rely on identity. Aggregate ($groupBy) and $count responses are NOT widened — see atscript-db skill, invariant 10. |
| 12 | :controls takes only your overrides. Every dispatch site falls back internally (controls[key] ?? default), so passing createDefaultControls() wholesale is redundant AND opts the lazy dialogs into eager bundling+mounting. rowActions is not seeded — the actions column resolves controls.rowActions ?? types.actions ?? AsRowActions. |
| 13 | $sort overrides search relevance — opt into ignoreSortersWhenSearched to preserve it. A preset/v-model/header sort emitted alongside $search replaces relevance ranking on a scored backend (Atlas $search), so search returns the right row set in browse order. <AsTableRoot :ignore-sorters-when-searched> (default false) suppresses user sorters at query time while searching; forceSorters still emit. The flag is a model — sorting mid-search flips it off for the session; a new search resets it. Only for relevance-ranked backends. See [sorting-pagination.md](references/sorting-pagination.md#search-relevance-suppression). |
| 14 | query({ silent: true }) is the sanctioned timer-driven live-refresh path. Re-runs the current query (live filters/sorters/search/pagination/$actions) with no querying flip — no spinner, no skeletons, no query overlay. Loud-wins coalescing: a real (loud) query landing the same tick still shows the spinner. On failure, rows/totalCount/error state are left untouched — never blanks or toasts the grid. Preserves scroll position. Rides on keep-rows-until-settle — a stable contract where every query, silent or not, keeps prior rows visible until the response settles, then swaps results + totalCount atomically. See [query.md](references/query.md#silent-live-refresh). |
Key imports
// Tier 1 — primary (auto-imported by AsResolver)
import {
AsTableRoot,
AsTable,
AsWindowTable,
AsTableActions,
AsFilters,
AsPresetPicker,
} from "@atscript/vue-table";
// Tier 2 — defaults (swap targets)
import {
// cells
AsCellArray,
AsCellDate,
AsCellJson,
AsCellNumber,
AsCellUnion,
AsTableCellValue,
// dialogs
AsConfigDialog,
AsFilterDialog,
AsPresetDialog,
AsConfirmDialog,
// filter ui + headers + rows
AsFilterField,
AsFilterInput,
AsTableHeaderCell,
AsRowActions,
AsColumnMenu,
} from "@atscript/vue-table";
// AsActionFormDialog is on a dedicated subpath — it pulls in @atscript/vue-form,
// so the table root lazy-mounts it. Import this only to override / eager-load.
import AsActionFormDialog from "@atscript/vue-table/as-action-form-dialog";
// Composables
import {
useTable,
useTableContext,
useTableContextOptional,
createTableState,
createStaticTableState,
useTableSelection,
useTableNavBridge,
useTableFilter,
useTableSearch,
useTableActions,
useTableUrlQuery,
useAppPrefs,
usePresets,
useLocalDraft,
useCellLocale,
provideCellLocale,
useTableComponent,
} from "@atscript/vue-table";
// Factories
import { createDefaultControls, createDefaultCellTypes } from "@atscript/vue-table";
// Types (re-exported)
import type {
TAsTableControls,
TAsCellTypeComponents,
ReactiveTableState,
ColumnMenuConfig,
ConfigTab,
TableActionsState,
ActionResult,
} from "@atscript/vue-table";
// Framework-agnostic table model (filter / preset / query primitives)
import {
FilterCondition,
FieldFilters,
filtersToUniqueryFilter,
PresetSnapshot,
toWireSnapshot,
fromWireSnapshot,
buildTableQuery,
mergeFilters,
mergeSorters,
stateToUrlQueryString,
urlQueryStringToState,
} from "@atscript/ui-table";
// Server-side preset controller (Moost)
import { AsPresetsController, AsPresetEntry } from "@atscript/moost-ui-presets";
References — load only what's needed
| Domain | File | When |
|---|---|---|
| First contact | [getting-started.md](references/getting-started.md) | Install matrix, <AsTableRoot> props, the default :types + :controls maps, slot binding contract |
| Query / data wiring | [query.md](references/query.md) | url= (moost-db) vs queryFn (custom), buildTableQuery Uniquery assembly, force filters/sorters, meta endpoint, mutators-are-pure principle in detail |
| Filtering | [filtering.md](references/filtering.md) | Filter model (FieldFilters / FilterCondition / 13 condition types), OR/AND semantics, filtersToUniqueryFilter translation, <AsFilters> / <AsFilterField> / <AsFilterDialog>, value-help inside filter dialogs |
| Sorting + pagination | [sorting-pagination.md](references/sorting-pagination.md) | Sort model + multi-sort, header click semantics, <AsConfigDialog> sorters tab, paginated <AsTable> vs virtualized <AsWindowTable>, block-aligned fetching, dragReleaseDebounceMs tuning |
| Cells | [cells.md](references/cells.md) | Built-in cell components + default type map, provideCellLocale (language + timezone), custom cells via @ui.table.component + :components, slot API (#header-<path>, #cell-<path>, #empty, #query-loading, #error), per-cell styling via @ui.table.{classes,styles,attr} |
| State persistence | [state-persistence.md](references/state-persistence.md) | <AsConfigDialog> tabs (columns/sorters/filters), useTableUrlQuery (router two-way bind), client presets (PresetSnapshot, useLocalDraft, usePresets, useAppPrefs, <AsPresetPicker>, system/user/public, dateShortcuts), server presets via AsPresetsController |
| Actions + selection | [actions-selection.md](references/actions-selection.md) | Row / table actions on the .as type, <AsActionFormDialog> (action input form via vue-form), state.selectedRows (Set<PK>), togglePk / trimSelection / rowsToPks, state.actions.invoke(action, pk?, opts?), the __actions synthetic column; navigate-action links (navigateHrefFor, resolveHref, anchor vs button, native new-tab silent to @action) |
| Customization | [customization.md](references/customization.md) | When swapping table chrome: per-column / state slots (#cell-, #header-, #empty, #error, #last-row), :types / :components cell swap, replacing dialogs via :controls (and which control keys actually dispatch), custom row-actions cell, rendering without a header row (:headless) |
| Edit form + OCC | [edit-form-occ.md](references/edit-form-occ.md) | When building a row-edit <AsForm> against an OCC table: @db.column.version, meta.versionColumn → createFormDef, catching VersionMismatchError / currentVersion on submit |
| Annotation catalog | [annotations.md](../atscript-ui/references/annotations.md) | When writing or looking up any @ui.table. / @ui.table.fn. / @ui.dict.* annotation — args, constants, defaults (SSOT lives in the root atscript-ui skill) |
| Bundle optimization | [bundle-optimization.md](../atscript-ui/references/bundle-optimization.md) | When asked about table bundle size: lazy dialog latches, controls.X flipping a dialog eager, AsActionFormDialog pulling in vue-form, shedding unused chrome CSS via excludeComponents (SSOT lives in the root atscript-ui skill) |
Customization
Full swap-surface reference (slots, maps, dispatched control keys): [customization.md](references/customization.md).
Tables expose three swap surfaces, layered on the tier model:
- Tier 1 —
<AsTableRoot>,<AsTable>,<AsWindowTable>,<AsFilters>,<AsTableActions>,<AsPresetPicker>are the integration surface. Build withuseTable/createTableStatedirectly if you need a custom shell. - Tier 2 — default cells (
AsCellArray,AsCellDate,AsCellJson,AsCellNumber,AsCellUnion,AsTableCellValue) and default dialogs (AsConfigDialog,AsFilterDialog,AsPresetDialog,AsConfirmDialog,AsRowActions,AsColumnMenu,AsTableHeaderCell). These are what you swap. - Tier 3 — internal composition (header cells, virtualizer pieces, fields selector, sorter config). Not tagged directly; styles ride with the defaults that use them.
Swap a built-in cell renderer (:types)
:types maps built-in cell ids (per invariant 4: text, number, boolean, date, datetime, relative, array, object, union, enum, ref) to a component:
<script setup lang="ts">
import { createDefaultCellTypes } from "@atscript/vue-table";
import MyDateCell from "./MyDateCell.vue";
const types = { ...createDefaultCellTypes(), date: MyDateCell, datetime: MyDateCell };
</script>
<template>
<AsTableRoot url="/api/db/tables/products" :types="types" />
</template>
Swap a specific cell (:components + @ui.table.component)
For column-specific cells, opt in on the .as type and supply the named component:
@ui.table.component 'price-tag'
price: number
<script setup lang="ts">
import PriceTag from "./PriceTag.vue";
const components = { "price-tag": PriceTag };
</script>
<template>
<AsTableRoot url="..." :components="components" />
</template>
Wrap a cell in useCellLocale / provideCellLocale if it needs locale + timezone from useAppPrefs.
Swap a dialog (controls.*)
The toolbar, config, filter, and preset dialogs are swappable through controls. Pass only the entries you replace — spreading createDefaultControls() defeats dialog lazy-loading (see invariant 12):
<script setup lang="ts">
import MyFilterDialog from "./MyFilterDialog.vue";
const controls = { filterDialog: MyFilterDialog };
</script>
<template>
<AsTableRoot url="..." :controls="controls" />
</template>
Custom dialogs just write the new arrays back to state.filterFields / state.filters / state.sorters / state.columnNames — the root watcher reconciles and re-queries (see invariants 1–3 and [query.md](references/query.md)). Don't call state.query() or run cleanup loops mirroring display state into applied state; both fight the watcher.
Style consequence
Replacing AsFilterDialog with a custom dialog that doesn't tag the as-filter-* shortcuts drops those classes out of your bundle automatically. Keep the default whenever its chrome fits — you'll spend less time on style maintenance. For granular opt-out, atscript-ui-styles ships per-domain shortcut groups (tableShortcuts, formShortcuts, …) you can compose narrower than the default allShortcuts.
See also
Reference docs: https://ui.atscript.dev/tables/. Source: https://github.com/moostjs/atscript-ui.