--- name: admin-ui-config description: Configure the admin UI for collections and project-level Nova behavior — meta labels, preview/viewHint, sidebar layout, collectionGroups, i18n, field widgets, foreign references, and image handling. Use when setting up or customizing admin views. --- # admin-ui-config ## When to use this skill Use this skill when: - Configuring how a collection appears in the tibi-admin UI - Configuring collection preview and default list presentation - Configuring field widgets (dropdowns, media pickers, richtext, etc.) - Organizing fields into sidebar groups or sections - Setting up foreign key references between collections - Customizing the admin module (`frontend/src/admin.ts`) ## Reference source The canonical type definitions are in `tibi-admin-nova/types/admin.d.ts`. Always consult this file for the full API. This skill provides a practical summary. Treat this skill as Nova-first. Use current Nova concepts such as `preview`, `singleton: { enabled }`, `drillDown`, `dependsOn`, `containerProps.layout`, `pagebuilder`, `viewHint`, `subNavigation`, and AI media assist. --- ## Project-level admin contracts Not every important Nova contract lives in a collection YAML file. Some of the most important admin behaviors are configured at project level in `api/config.yml` under `meta:`. Current starter example: ```yaml meta: imageUrl: eval: "$projectBase + '_/assets/img/admin-pic.svg'" i18n: defaultLanguage: de languages: - code: de label: Deutsch - code: en label: English collectionGroups: - name: content label: { de: "Inhalte", en: "Content" } icon: article - name: media label: { de: "Medien", en: "Media" } icon: image_multiple ``` Treat these as part of the admin design, not as optional polish: - `meta.imageUrl` — project card/preview imagery in the admin - `meta.i18n` — project-wide language model for field-level and entry-level translation workflows - `meta.collectionGroups` — ordered collection groups for the sidebar Important rule: - collection-level `meta.group` must reference one of the project-level `meta.collectionGroups[].name` values if the collection should appear inside an explicit group If project-level `meta.i18n` is missing or inconsistent, even well-modeled collections can become confusing in Nova. --- ## Collection meta configuration The `meta` key in a collection YAML controls how the collection appears in the admin sidebar and collection/list UI. ```yaml name: mycollection meta: label: { de: "Produkte", en: "Products" } # Sidebar label (i18n) muiIcon: shopping_cart # Material UI icon name group: shop # Group in admin sidebar singleton: enabled: false hide: false # Set to true to hide the collection for non-admin users preview: label: name secondary: price ``` ### Preview Use `meta.preview` as the universal entry representation for Nova lists, breadcrumbs, foreign-key widgets, and search result previews: ```yaml preview: name preview: label: name secondary: slug badge: status preview: eval: "`${$this.firstName} ${$this.lastName}`" ``` ## List presentation For current Nova, use `meta.viewHint` plus `meta.preview` for collection/list presentation. ```yaml meta: viewHint: table preview: label: name secondary: slug badge: status table: - name - source: status label: Status - source: author.name label: Author select: - author.name ``` - `meta.viewHint` controls the preferred collection presentation (`table`, `cards`, `media`, or `navigation` object where supported). - `preview.table` defines explicit list columns for Nova. - `preview.select` can reduce lookup work for preview table columns. - `meta.subNavigation` defines filtered entry tabs in the sidebar. ### Sub-navigation tabs Use `meta.subNavigation` when one collection needs multiple curated views in the admin without splitting into multiple collections. ```yaml meta: subNavigation: - name: pages label: { de: "Seiten", en: "Pages" } muiIcon: article filter: type: page defaultSort: field: insertTime order: DESC setDefault: field: type value: page - name: news label: { de: "News", en: "News" } muiIcon: feed filter: type: news setDefault: field: type value: news ``` Use sub-navigation when: - one collection has several stable editorial slices - the underlying schema is still shared enough to stay one collection - authors benefit from filtered entry views and sensible defaults Do not use sub-navigation to hide a bad collection model. If the workflows truly diverge, split the collection instead. --- ## Field configuration Each field in the `fields` array can have a `meta` section controlling its admin UI behavior. ### Basic field with meta ```yaml fields: - name: name type: string meta: label: { de: "Name", en: "Name" } helperText: { de: "Anzeigename", en: "Display name" } position: main # "main" (default) or "sidebar" ``` ### Field types | YAML `type` | Admin widget (default) | Notes | | ----------- | ---------------------- | --------------------------------------------- | | `string` | Text input | Use `inputProps.multiline: true` for textarea | | `number` | Number input | | | `number[]` | Number chip array | Multiple numeric values | | `boolean` | Toggle/checkbox | | | `date` | Date picker | | | `object` | Nested field group | Requires `subFields` | | `object[]` | Repeatable group | Requires `subFields`, drag-to-reorder | | `string[]` | Tag input | | | `file` | File upload | | | `file[]` | Multi-file upload | | | `any` | JSON editor | For mixed/arbitrary data | ### inputProps — widget customization `inputProps` passes props directly to the field widget: ```yaml # Multiline text (textarea) - name: description type: string meta: label: { de: "Beschreibung", en: "Description" } inputProps: multiline: true rows: 5 # Number with min/max - name: price type: number meta: inputProps: min: 0 max: 99999 step: 0.01 # Placeholder text - name: email type: string meta: inputProps: placeholder: "name@example.com" ``` ### Widget override Override the default widget with `meta.widget`: ```yaml - name: content type: string meta: widget: richtext # Rich text editor (HTML) - name: heroImage type: file meta: widget: image # Image-focused file widget - name: relatedPages type: string[] meta: widget: foreignKeyChipArray ``` Common widget types: `text`, `checkbox`, `select`, `chipArray`, `checkboxArray`, `date`, `datetime`, `file`, `image`, `richtext`, `json`, `foreignKey`, `foreignKeyChipArray`, `pagebuilder`, `containerLessObject`, `containerLessObjectArray`. Important current widgets/features to consider when designing a real website backoffice: - `pagebuilder` for CMS-driven block/page authoring - `foreignKeyChipArray` for many-reference editing - `image` plus `imageEditor` / `downscale` for image-heavy workflows - `drillDown` editing for complex nested arrays ### Choices — dropdowns/selects Static choices: ```yaml - name: type type: string meta: label: { de: "Typ", en: "Type" } choices: - id: page name: { de: "Seite", en: "Page" } - id: blog name: { de: "Blog", en: "Blog" } - id: product name: { de: "Produkt", en: "Product" } ``` Dynamic choices from API: ```yaml - name: category type: string meta: choices: endpoint: categories # Collection name mapping: id: id name: name ``` ### Foreign references Link to entries in another collection: ```yaml - name: author type: string meta: label: { de: "Autor", en: "Author" } foreign: collection: users id: id sort: name projection: name,email render: label: name secondary: email createDefaults: role: author ``` Use `foreign.id: id` for the outward FK field identity. Only Mongo-style filters/query conditions use `_id`. Use `foreign.render` or target-collection `meta.preview` so references stay readable. Bare IDs are not acceptable authoring UX for a serious website project. ### Image fields ```yaml - name: image type: file meta: widget: image downscale: # Auto-resize on upload maxWidth: 1920 maxHeight: 1080 quality: 0.85 imageEditor: true # Enable crop/rotate editor ``` This field config controls the editor widget, not the filesystem target. Configure file storage once at collection level via top-level `uploadPath` (for this starter typically `../media/`), not on the individual file field. --- ## Layout: position, sections, sidebar ### Sidebar placement ```yaml - name: active type: boolean meta: position: sidebar # Moves field to sidebar - name: publishDate type: date meta: position: "sidebar:publishing" # Sidebar with group key ``` ### Sidebar groups (ordered) Define sidebar group order in collection meta: ```yaml meta: sidebar: - group: publishing label: { de: "Veröffentlichung", en: "Publishing" } - group: seo label: { de: "SEO", en: "SEO" } - group: settings label: { de: "Einstellungen", en: "Settings" } ``` ### Sections in main area ```yaml - name: seoTitle type: string meta: section: SEO # Groups fields under a section header - name: seoDescription type: string meta: section: SEO ``` ### Grid layout (columns) Use `containerProps` for multi-column layout: ```yaml - name: firstName type: string meta: containerProps: layout: size: col-6 # Half width (12-column grid) - name: lastName type: string meta: containerProps: layout: size: col-6 ``` `containerProps.layout` is one of the most important Nova ergonomics features. Use it aggressively to avoid long, single-column forms. Recommended pattern for real projects: - sidebar for publication, SEO, flags, relations, admin-only metadata - main area for editorial content - 2-column or 3-column layout for short related fields - section headings for repeated conceptual groups --- ## Nested objects and arrays ### Object (nested group) ```yaml - name: address type: object meta: label: { de: "Adresse", en: "Address" } subFields: - name: street type: string - name: city type: string - name: zip type: string ``` ### Object array (repeatable blocks) ```yaml - name: blocks type: object[] meta: label: { de: "Inhaltsblöcke", en: "Content Blocks" } widget: pagebuilder preview: { eval: "`${$this.type}: ${$this.headline || ''}`" } drillDown: true subFields: - name: type type: string meta: choices: - id: hero name: Hero - id: richtext name: Richtext - name: headline type: string - name: hide type: boolean ``` The `preview` eval determines what's shown in the collapsed state of each array item. ### Drill-down arrays For complex `object[]` data, prefer `drillDown: true` over dense inline editing. This is especially important for: - nested content blocks - FAQs / accordions - team members with nested metadata - pricing tables / feature matrices ### Pagebuilder fields Nova supports pagebuilder configuration at both collection and field level. Typical pattern: ```yaml meta: pagebuilder: blockTypeField: type defaultViewport: desktop blockRegistry: file: /_/assets/dist/admin.mjs fields: - name: blocks type: object[] meta: widget: pagebuilder pagebuilder: blockTypeField: type ``` Use pagebuilder when editors work with heterogeneous content blocks. Use plain `object[]` only when the structure is uniform and simple. ### dependsOn Use `dependsOn` to show only fields relevant to the selected block or mode: ```yaml - name: image type: file meta: dependsOn: eval: $parent.type == 'hero' ``` This is critical for keeping pagebuilder schemas usable. ### AI-aware media and admin features Current Nova types support AI-related admin capabilities, especially around media workflows. When appropriate for a project: - use AI-assisted alt/caption generation for image-heavy collections - prefer explicit target fields for generated metadata - keep AI assist opt-in and editorially reviewable Use AI only where it improves authoring quality; do not force it into every collection. ## Field-level permissions and authoring safety Current tibi-server supports `readonlyFields`, `hiddenFields`, and eval-based field visibility/readonly rules. Reflect these server rules in admin design: - do not put critical computed fields front-and-center if editors may not be allowed to modify them - use `dependsOn`, `hidden`, and readonly semantics deliberately - remember that server-side permissions are authoritative even if the UI looks editable ### Drill-down For complex nested objects, use `drillDown` to render them as a sub-page: ```yaml - name: variants type: object[] meta: drillDown: true # Opens as sub-page instead of inline ``` --- ## Admin module (frontend/src/admin.ts) The `admin.ts` file exports the **pagebuilder block registry** and optional custom Svelte components for the tibi-admin UI. This is how the admin preview renders your Svelte blocks. ### Pagebuilder block registry The current starter uses `createContentBlockDefinition()` to register each block type. This mounts real Svelte block components into Shadow DOM for admin previews: ```typescript import { mount, unmount, type Component, type SvelteComponent } from "svelte" import BlockRenderer from "./blocks/BlockRenderer.svelte" // Creates a block definition that renders the same Svelte component // used in the public frontend. The block is mounted inside Shadow DOM // for style isolation. function createContentBlockDefinition(presentation: { label: string; icon: string; color: string }) { return { css: [previewCssUrl], // CSS files to inject into Shadow DOM label: presentation.label, icon: presentation.icon, color: presentation.color, previewStyles: { "background-color": "white", }, render(container, row, context) { // Mount the Svelte component inside the admin preview const target = document.createElement("div") container.appendChild(target) let mountedComponent = mount(BlockRenderer as Component, { target, props: { blocks: [row], isAdminPreview: true }, }) return { update(nextRow) { unmount(mountedComponent) target.innerHTML = "" mountedComponent = mount(BlockRenderer as Component, { target, props: { blocks: [nextRow], isAdminPreview: true }, }) }, destroy() { unmount(mountedComponent) target.remove() }, } }, } } const blockRegistry = { hero: createContentBlockDefinition({ label: "Hero", icon: "image", color: "#1d4ed8" }), richtext: createContentBlockDefinition({ label: "Richtext", icon: "article", color: "#7c3aed" }), // ... add new blocks here } export { blockRegistry } ``` **Key points:** - Each registry entry wraps the Svelte `BlockRenderer` to render the block in the admin preview. - The `row` object is the block data (same shape as `ContentBlockEntry`). - Preview data may contain hydrated `_lookup.` foreign key data and absolute file URLs — do not prepend `apiBase` or attempt re-fetching. - The `previewCssUrl` loads the project's `index.css` into Shadow DOM so block styles apply. - After adding blocks to the registry, run `yarn build` so `frontend/dist/admin.mjs` is regenerated. ### Custom Svelte components (advanced) For custom dashboard widgets, preview components, or field widgets that require Svelte rendering inside the admin UI, use `getRenderedElement()`: ```typescript import type { SvelteComponent } from "svelte" function getRenderedElement( component: typeof SvelteComponent, options?: { props: { [key: string]: any }; addCss?: string[] }, nestedElements?: { tagName: string; className?: string }[] ) { // Creates a Shadow DOM container, mounts the Svelte component inside } export { getRenderedElement } ``` ### Build Run `yarn build`. The admin module (`frontend/src/admin.ts`) is compiled into `frontend/dist/admin.mjs` as part of the esbuild build pipeline (the same build produces both `index.mjs` for the SPA and `admin.mjs` for the admin module). tibi-admin-nova loads this module from the project's asset path (`/_/assets/dist/admin.mjs`). The `ADMIN_ASSET_VERSION` from `config.yml.env` is appended as a query parameter for cache busting: `admin.mjs?v=${ADMIN_ASSET_VERSION}`. --- ## Complete collection example ```yaml name: products meta: label: { de: "Produkte", en: "Products" } muiIcon: inventory_2 group: shop viewHint: table defaultSort: field: insertTime order: DESC preview: label: name secondary: sku badge: active table: - name - sku - source: price label: { de: "Preis", en: "Price" } - source: category label: { de: "Kategorie", en: "Category" } sidebar: - group: publishing label: { de: "Veröffentlichung", en: "Publishing" } - group: seo label: { de: "SEO", en: "SEO" } permissions: public: methods: get: true user: methods: get: true post: true put: true delete: false # usually false for real editorial workflows fields: - name: active type: boolean meta: label: { de: "Aktiv", en: "Active" } position: "sidebar:publishing" - name: name type: string meta: label: { de: "Name", en: "Name" } - name: sku type: string meta: label: { de: "Artikelnummer", en: "SKU" } containerProps: layout: size: col-6 - name: price type: number meta: label: { de: "Preis", en: "Price" } inputProps: min: 0 step: 0.01 containerProps: layout: size: col-6 - name: category type: string meta: label: { de: "Kategorie", en: "Category" } choices: - id: electronics name: { de: "Elektronik", en: "Electronics" } - id: clothing name: { de: "Kleidung", en: "Clothing" } - name: description type: string meta: label: { de: "Beschreibung", en: "Description" } inputProps: multiline: true rows: 4 - name: image type: file meta: label: { de: "Produktbild", en: "Product Image" } widget: image downscale: maxWidth: 1200 quality: 0.85 - name: seoTitle type: string meta: label: { de: "SEO Titel", en: "SEO Title" } position: "sidebar:seo" - name: seoDescription type: string meta: label: { de: "SEO Beschreibung", en: "SEO Description" } position: "sidebar:seo" inputProps: multiline: true rows: 3 ``` --- ## Indexes and search For production collections with many entries, consider adding indexes in the YAML: ```yaml name: products indexes: - name: price_sort key: [price] - name: category_active key: [category, -active] # -prefix for descending - name: slug_unique key: [slug] unique: true ``` Search configurations can be added for advanced text/vector search: ```yaml search: - name: default mode: text fields: [name, description] ``` See `tibi-server/docs/04-collections.md` (sections on indexes and search config) for full reference. ## Checklist-facing verification For a real project, do not stop after writing the YAML. Validate the authoring contract explicitly. Minimum review points: 1. project-level `meta.i18n` and `meta.collectionGroups` are coherent 2. each collection has a readable `meta.preview` 3. list views show meaningful columns instead of raw IDs or empty rows 4. foreign references render with readable previews 5. sidebars and `containerProps.layout` produce usable forms 6. pagebuilder collections expose both a working chooser and working preview path Committed admin Playwright coverage is preferred for stable contracts that should not regress. ## Common pitfalls - **`meta.label` supports both strings and i18n objects** — Use i18n objects only when the collection or field label must be localized. - **Project-level admin config is easy to forget** — `collectionGroups` and project-level `meta.i18n` live in `api/config.yml`, not in individual collection files. - **`meta.group` without a matching project group** — The collection still exists, but the sidebar grouping model becomes inconsistent. - **`choices.id` must match stored value** — The `id` in choices is what gets saved to the database. - **`inputProps` depends on widget** — Not all props work with all widgets. Check tibi-admin-nova source if unsure. - **`position: sidebar` without group** — Fields go to an ungrouped area. Use `position: "sidebar:GroupName"` for grouping. - **`type: object[]` needs `subFields`** — Forgetting `subFields` renders an empty repeater.