# Record Layouts

The CMS record detail page groups fields into collapsible sections. By default, fields are auto-grouped by naming heuristics (Identity, Contact Info, Financial, etc.). With **record layouts**, you control the exact grouping.

There are two layers:

1. **Code-defined layouts** — developers configure named layouts in the table config
2. **User-created views** — end-users create and save custom views via the CMS UI

## Code-Defined Layouts

Add a `layouts` property to your table config:

```typescript
// quickback/features/contacts/contacts.ts
import { feature, q } from "@quickback/compiler";

export default feature("contacts", {
  columns: {
    id:             q.id(),
    name:           q.text().required(),
    status:         q.text().required(),
    email:          q.text().optional(),
    phone:          q.text().optional(),
    mobile:         q.text().optional(),
    address1:       q.text().optional(),
    address2:       q.text().optional(),
    city:           q.text().optional(),
    state:          q.text().optional(),
    zip:            q.text().optional(),
    notes:          q.text().optional(),
    internalNotes:  q.text().optional(),
    organizationId: q.scope("organization"),
    ...q.audit(),
    ...q.softDelete(),
  },
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  // Contact details are detected as sensitive; on a CRM contact record they
  // are the point of the record, so this one is reviewed and left visible.
  masking: { email: false, phone: false, mobile: false },
  create: { /* ... */ },
  update: { /* ... */ },
  delete: { /* ... */ },
  layouts: {
    default: {
      sections: [
        { label: "Contact Info", columns: 2, fields: ["name", "email", "phone", "mobile"] },
        { label: "Address", columns: 2, fields: ["address1", "address2", "city", "state", "zip"] },
        { label: "Internal Notes", collapsed: true, fields: ["notes", "internalNotes"] }
      ]
    },
    compact: {
      sections: [
        { label: "Summary", fields: ["name", "status", "email"] }
      ]
    }
  }
});
```

Each layout has an ordered list of sections. Each section specifies:

| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `label` | string | required | Section header text |
| `fields` | string[] | required | Column names to display |
| `columns` | `1 \| 2` | `1` | Number of columns for field layout |
| `collapsed` | boolean | `false` | Whether the section starts collapsed |

Fields not assigned to any section are collected into an "Other Fields" section at the bottom.

### Layout Switcher

When a table has multiple named layouts, a dropdown appears in the record detail header. Selections persist per table using `localStorage`.

If only one layout is defined, it's used automatically without showing a dropdown.

## User-Created Custom Views

End-users can create their own record layouts via the CMS UI. These are stored in the database and can be shared with other organization members.

### Creating a View

1. Open any record detail page
2. Click the **+ View** button in the header
3. In the view builder dialog:
   - Name your view
   - Add sections and assign fields from a dropdown
   - Set columns (1 or 2) and collapsed state per section
   - Optionally share with your organization
4. Click **Create View**

The new view appears in the layout dropdown alongside code-defined layouts.

### Editing and Deleting Views

- Click the **gear icon** next to the dropdown to edit the current custom view
- Click the **trash icon** to delete it (with confirmation)
- Code-defined layouts cannot be edited or deleted from the CMS

### Access Control

| Operation | Who Can Do It |
|-----------|---------------|
| Create a view | Any member, admin, or owner |
| Edit/delete own views | The creator |
| Edit/delete any view | Admins and owners |
| View shared views | All organization members |

### Setting Up the Custom View Feature

To enable user-created views, add a `customView` table to your Quickback project:

```typescript
// quickback/features/cms/custom-view.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { defineTable } from "@quickback/compiler";

export const customView = sqliteTable("custom_view", {
  id: text("id").primaryKey(),
  organizationId: text("organization_id").notNull(),
  tableName: text("table_name").notNull(),
  name: text("name").notNull(),
  description: text("description"),
  layoutConfig: text("layout_config").notNull(),
  isShared: integer("is_shared", { mode: "boolean" }).default(false),
  // ── quickback:audit (compiler-managed — edits are validated, not merged) ──
  // No deletedAt/deletedBy: this resource hard-deletes (see `delete.mode` below),
  // and declaring the soft-delete pair on a hard-delete table is a compile error.
  createdAt: text("created_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()),
  modifiedAt: text("modified_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()).$onUpdate(() => new Date().toISOString()),
  createdBy: text("created_by"),
  modifiedBy: text("modified_by"),
});

export default defineTable(customView, {
  displayColumn: "name",
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  read: {
    access: { roles: ["owner", "admin", "member"] },
  },
  create: { access: { roles: ["owner", "admin", "member"] } },
  update: {
    access: {
      or: [
        { roles: ["owner", "admin"] },
        { roles: ["member"], record: { createdBy: { equals: "$userId" } } }
      ]
    }
  },
  delete: {
    access: {
      or: [
        { roles: ["owner", "admin"] },
        { roles: ["member"], record: { createdBy: { equals: "$userId" } } }
      ]
    },
    mode: "hard"
  },
  guards: {
    createable: ["tableName", "name", "description", "layoutConfig", "isShared"],
    updatable: ["name", "description", "layoutConfig", "isShared"]
  }
});
```

Compile your project to generate the API endpoints. The CMS will automatically detect the `customView` table and enable the view builder UI.

## Fallback Behavior

| Scenario | Result |
|----------|--------|
| No `layouts` config, no custom views | Auto-grouping by naming heuristics |
| `layouts` config defined | Uses "default" layout or first available |
| Multiple layouts | Dropdown for switching, persisted per table |
| Custom views created | Appear in dropdown below code-defined layouts |

## Next Steps

- **[Table Views](/ui/admin/table-views)** — Column projections for list views
- **[Schema Format](/ui/admin/schema-format)** — Full TypeScript type reference
- **[Database Schema](/define/schema)** — defineTable() configuration reference
