👨‍💻 Developer 🛠 Admin ✍️ Content Creator Commit 86ef2197

Dynamic Layout & Container Model

The complete guide to understanding and working with the new layout system in CMS and Work Instructions. This document is written for teams moving from hardcoded, single-column layouts to a dynamic, multi-zone container model where layout is decided at content time — not at code time.

Problem being solvedTeams needed 2-column, tabbed, and banner layouts per step — impossible without per-screen code.
What changedLayout is now a data decision. One renderer handles all layouts dynamically.
Who is affectedFrontend devs (renderer), admins (WI editor), content creators (CMS editor).

1 · Why the Layout Model Changed

Previously, every layout in this application was decided by a developer in code. If a Work Instruction step needed a two-column view (image on the left, instructions on the right), a developer had to write a custom component or add a conditional in an existing one. If an SOP needed a safety banner at the top, it required a code change and a deployment.

This creates a bottleneck: content structure is coupled to code. Every layout variation requires developer time. Adding a new layout type means new CSS classes, new template conditions, new code review, and a new deployment.

Real-world analogy

Imagine a newspaper where every article's column layout (1-column, 2-column, full-width headline, sidebar) had to be hardcoded by a software engineer. The journalist can't change from a 2-column story to a full-page feature without filing a code ticket. That is exactly how the old CMS/WI worked — and why it was replaced.

The new system separates concerns correctly: developers write the renderer once, and admins or content creators choose the layout when authoring content. A new column layout, accordion, or tab panel requires zero code changes — just a different container type selected in the UI.

The core principle
"Layout is a content decision, not a code decision."
Developers ship the renderer. Admins and creators pick the layout. No redeploy needed for layout changes.

2 · Old Model vs New Model — Side by Side

🔴 Old model — fixed, code-decided layout
// Developer wrote specific HTML per step type.
// Two-column? Add class="row" in code.
// Accordion? Write ngbAccordion in template.
// Each layout = a new if/else or component.

// Example: Angular template (hardcoded)
<div *ngIf="step.type === 'comparison'"
     class="row">
  <div class="col-6">
    <img [src]="step.imageLeft">
  </div>
  <div class="col-6">
    <p>{{ step.textRight }}</p>
  </div>
</div>

<div *ngIf="step.type === 'single'">
  <p>{{ step.text }}</p>
</div>

// Problem: adding a 3-column or tab layout
// requires code change + redeploy.
🟢 New model — dynamic, data-decided layout
// Developer writes the renderer ONCE.
// Layout comes from data — the container type.
// No if/else per layout. No new code for new layouts.

// WI step data from the API
{
  containers: [
    {
      containerType: TwoColumn, // ← layout choice
      blocks: [
        { contentType: Image,   zoneName: 'left'  },
        { contentType: RichText, zoneName: 'right' }
      ]
    }
  ]
}

// Template — one line, handles ALL layouts:
<content-document-renderer
  [containers]="stepContainers()" />

What each role's job is now

RoleOld responsibilityNew responsibility
DeveloperWrite a new template or component for each layout type neededWrite the renderer once (done). Add new block types when needed.
AdminFile a change request for each layout variationSelect container type when building a WI step or CMS document
Content CreatorWrite only text; developer arranged visualsChoose layout, add blocks, set zones — no developer needed

3 · Vocabulary — Terms You Must Know

The entire system is built on four concepts. Every developer, admin, and content creator must understand all four before working with CMS or WI layouts.

TermWhat it isAnalogyDecided by
Step / Document The top-level unit. A WI step or a CMS content page. Contains one or more containers stacked top-to-bottom. A newspaper page Developer (WI step) or Author (CMS content)
Container A layout region within a step/document. Defines HOW content inside it is arranged (1-column, 2-column, accordion, tab, banner). A newspaper section (sports, news) — each has its own column layout Admin / Content Creator at authoring time
Zone A named slot inside a container. For a 2-column container, zones are 'left' and 'right'. For a tab container, zones are the tab labels. Columns within that newspaper section Admin / Content Creator — set per block
Block A single piece of content (rich text, image, table, checklist, video, etc.) placed inside a zone. Blocks never know about layout — they just render their content. A single article or photo in that column Admin / Content Creator — content + zone assignment
Hierarchy — every level nests inside the level above it
Top level
Step / Doc
One WI step or CMS page. Stacks containers vertically.
↓
Layout layer
Container
Owns the layout type: Single, TwoColumn, Banner, Accordion, Tab
↓
Placement
Zone
Named slot: 'left', 'right', 'main', 'Safety Checks', 'Model A'…
↓
Content
Block
RichText / Image / Table / File / Checklist / Warning / Video / Banner…

4 · What Is a Container?

A container is the fundamental unit of layout control. It is just a record in the database with a containerType value. That single field tells the renderer which CSS grid or flex layout to apply to everything inside it.

Think of a container as a transparent box that organises its children. You can have multiple containers inside a single WI step or CMS document — they stack vertically, one after another. The first container might be a full-width safety banner; the second might be a two-column image comparison; the third might be an accordion with sub-steps.

What a container is NOT

  • It is not a content type — it has no visible content of its own
  • It is not a template or component that developers write per-use-case
  • It is not a hardcoded CSS class added at code time
  • It is not something that needs to be changed when the layout changes — you just change the containerType field

The container's job is simply to tell the renderer: "render the blocks inside me using THIS layout."

5 · The 5 Layout Types

The containerType field maps to one of five values in the WiContainerType enum. Each produces a specific CSS layout.

1 · Single
WiContainerType.Single = 0 · CSS: flex-column
Block 1 — full width
Block 2 — full width
Block 3 — full width
Use when: normal step content — instruction text, a single image, a checklist. This is the default. All blocks share the 'main' zone and stack top-to-bottom.
2 · Two Column
WiContainerType.TwoColumn = 1 · CSS: grid 1fr 1fr
Zone: left
Block A
Zone: right
Block B
Use when: before/after image comparison, diagram + instruction side-by-side, reference table + notes. Assign blocks to zoneName: 'left' or zoneName: 'right'.
3 · Banner
WiContainerType.Banner = 2 · CSS: relative, full-width
DANGER — Background image
+ caption overlay at bottom
Use when: safety warnings, step preambles, section headers with imagery. Always holds a single ContentType.Banner block. Background image + text overlay. Print-safe (removes image, adds border).
4 · Accordion
WiContainerType.Accordion = 3 · CSS: flex-column
▶ Pre-checks (collapsed)
▼ Torque Spec (open)
Table block visible here
▶ Completion Notes (collapsed)
Use when: grouped sub-steps that users expand on demand, optional reference data, multi-part checklists. Each unique zoneName = one collapsible panel. The zone name is used as the panel header label.
5 · Tab
WiContainerType.Tab = 4 · CSS: flex-column
Model A
Model B
Model C
Active tab: Model A content
Use when: variant-specific instructions (different models, tools, or user roles), alternative procedures for the same step. Each unique zoneName = one tab. Zone name shown as tab label.
⚠️
Accordion and Tab containers use the zone name as the visible label. Name zones descriptively — 'Safety Pre-checks', 'Torque Specification', 'Model 3A Instructions'. Generic names like 'zone1' or 'panel' will appear as ugly accordion headers or tab labels in the UI.

6 · Zones — How Blocks Are Placed Inside Containers

A zone is the named slot that tells the renderer which column, panel, or tab a block belongs to. Every block has an optional zoneName field. If absent, it defaults to 'main'.

Zones are not predefined in code — they are extracted dynamically from the blocks in a container. The renderer finds all unique zone names in the block list (in insertion order) and creates one div per unique name. This means:

  • You can add a new accordion panel just by adding a block with a new zone name — no code change
  • The order of zones is determined by the first appearance of each zone name in the block list
  • Zones within a container are sorted by blockInContainerPos (per zone, not globally)
❌ Misunderstanding — global position
// WRONG: thinking blockInContainerPos
// is a global order across all zones

blocks: [
  { zoneName: 'left',  blockInContainerPos: 1 },
  { zoneName: 'right', blockInContainerPos: 2 }, ← NOT "after" left
  { zoneName: 'left',  blockInContainerPos: 3 }  ← NOT "after" right
]
✅ Correct — position is per-zone
// blockInContainerPos orders blocks
// WITHIN a zone, independently per zone

blocks: [
  { zoneName: 'left',  blockInContainerPos: 1 }, ← 1st in left column
  { zoneName: 'left',  blockInContainerPos: 2 }, ← 2nd in left column
  { zoneName: 'right', blockInContainerPos: 1 }, ← 1st in right column
  { zoneName: 'right', blockInContainerPos: 2 }  ← 2nd in right column
]

Zone name conventions by container type

Container typeExpected zone namesDisplayed as
Single'main' (or omit — defaults to 'main')No visible zone UI — just stacks blocks
TwoColumn'left' and 'right' (convention, not enforced)Two CSS grid columns — blocks in left zone go left, right zone go right
Banner'main'Full-width — typically one banner block only
AccordionAny descriptive label, e.g. 'Pre-checks', 'Torque Spec'The zone name IS the accordion panel header
TabAny descriptive label, e.g. 'Model A', 'Model B'The zone name IS the tab label in the tab bar

7 · Block Types — 10 Content Types Available

A block is a single piece of content. Every block has a contentType that determines which component renders it, and a contentData JSON string that holds its payload.

ContentTypeValueRenders asKey data fields
RichText0Formatted HTML paragraph, headings, lists{ html, plainText? }
Image1Responsive image with optional caption{ imageUrl, caption?, altText? }
Video2Embedded video player (YouTube/Vimeo/local){ videoUrl, provider, thumbnail? }
PDF3Inline PDF viewer with page selector{ pdfUrl, pageNumber? }
Checklist4Interactive checkbox list{ title?, items[], allowPartial }
Warning5Coloured warning/caution box{ message, warningLevel }
DecisionBranch6Branching question with clickable answers{ question, branches[] }
File7Downloadable file link with icon{ fileUrl, fileName, fileSize }NEW
QRCode8Scannable QR code{ targetUrl, label? }NEW
DataTable9Structured data table (thead/tbody){ headers[], rows[][] }NEW
Banner—Full-width image with caption overlay{ message, imageUrl?, title? }NEW
ℹ️
Blocks do not know or care about their container layout. A RichText block renders the same whether it's in a Single container, the left zone of a TwoColumn container, or a tab panel. The container controls the space; the block fills it.

👨‍💻 Developer Section

8 · Developer — The Mental Model Shift

If you have been writing Angular components in this project before this commit, your instinct when asked for a "two-column step" was to open a component file, add a ngIf, write a Bootstrap grid row, and handle the responsive case with CSS. That instinct is now wrong — and understanding why will save you time.

The old instinct: layout in the template

// OLD WAY — template knows about layout
@Component({ template: `
  <!-- developer hardcoded this -->
  <div class="row">
    <div class="col-6">
      <wi-content-image [block]="leftImage" />
    </div>
    <div class="col-6">
      <wi-content-richtext [block]="instructions" />
    </div>
  </div>
` })
export class SpecificStepComponent {
  leftImage = ...; // hardcoded field
  instructions = ...; // hardcoded field
}

// Every new layout = new component or more if/else.
// Admin cannot change layout without code change.

The new way: data decides the layout

// NEW WAY — template knows nothing about layout
@Component({ template: `
  <content-document-renderer [containers]="containers()" />
` })
export class AnyStepComponent {
  readonly step = input.required<WiStep>();

  protected containers(): WiStepContainer[] {
    // Handles both new (containers[]) and legacy (contents[]) data
    if (this.step().containers?.length) return this.step().containers!;
    return [{ containerId: 0, containerType: 0, blocks: this.step().contents ?? [] }];
  }
}

// Admin changes from Single to TwoColumn in the WI editor.
// Zero code change. Zero redeploy.
// Template stays identical — data changes.
✅
Your job as a developer is now: (1) use <content-document-renderer> everywhere content is displayed, (2) supply it with a WiStepContainer[] array, (3) handle the backward-compat wrapper for legacy steps. Layout is not your concern.

The rule: never write layout logic in component templates

🔴 Never do this
// Layout as code — DO NOT write this
@if (step.isTwoColumn) {
  <div class="grid-2col">
    <wi-content-image .../>
    <wi-content-richtext .../>
  </div>
}
@if (!step.isTwoColumn) {
  <div class="single">
    <wi-content-richtext .../>
  </div>
}
🟢 Always do this
// Layout as data — always use renderer
<content-document-renderer
  [containers]="stepContainers(step)"
/>

// The renderer reads containerType from
// data and applies the right CSS.
// You never write column/grid HTML.
👨‍💻 Developer Section

9 · Developer — Data Shape & TypeScript Interfaces

WiStepContainer — the layout unit

interface WiStepContainer {
  containerId:     number;           // DB-assigned ID (0 = legacy wrapper)
  containerType:   WiContainerType;  // Single=0, TwoColumn=1, Banner=2, Accordion=3, Tab=4
  containerLabel?: string;           // used as accordion/tab container description
  blocks:          WiStepContent[];  // ALL blocks regardless of zone
}

WiStepContent — the content unit (updated fields)

interface WiStepContent {
  // ── existing fields ──────────────────────────────────────────────
  wiStepContentId:   number;
  contentType:       ContentType;   // 0–9 — drives which component renders it
  contentData:       string;        // JSON string — parse to get the block payload
  sequenceOrder:     number;        // global sort across flat list (legacy use only)
  isActive:          number;        // 1 = active, 0 = hidden

  // ── NEW container-placement fields ─────────────────────────────
  containerId?:        number;        // which container this block sits in
  containerType?:      WiContainerType; // container's type (informational mirror)
  zoneName?:           string;        // 'main' | 'left' | 'right' | any zone label
  blockInContainerPos?:number;        // sort order WITHIN this zone (per-zone, not global)
}

WiContainerType enum

enum WiContainerType {
  Single     = 0,   // flex-column — default, all blocks in 'main' zone
  TwoColumn  = 1,   // grid 1fr 1fr — expects 'left' and 'right' zones
  Banner     = 2,   // position:relative full-width — for Banner content blocks
  Accordion  = 3,   // collapsible panels — zone name = panel label
  Tab        = 4,   // tab panel — zone name = tab label
}

WiStep — updated with containers alongside legacy contents

interface WiStep {
  wiStepId:  number;
  // ... other step fields ...

  contents?:   WiStepContent[];     // LEGACY — flat list, backward compat only
  containers?: WiStepContainer[];  // NEW — hierarchical, use this for new steps
}

// Rule: if containers[] is present and non-empty, use it.
// Otherwise wrap contents[] into a single Single container.
// NEVER use contents[] directly in new code.

Worked example — what the API returns

// API response for a step with Banner + TwoColumn + Single containers
{
  wiStepId: 42, stepTitle: "Install connector bracket",
  containers: [

    // Container 1 — full-width safety banner
    {
      containerId: 101, containerType: 2, // Banner
      blocks: [{ contentType: 9, zoneName: 'main', blockInContainerPos: 1,
                 contentData: '{"message":"HIGH VOLTAGE AREA","imageUrl":"/banners/hv.jpg"}' }]
    },

    // Container 2 — image on left, steps on right
    {
      containerId: 102, containerType: 1, // TwoColumn
      blocks: [
        { contentType: 1, zoneName: 'left',  blockInContainerPos: 1,
          contentData: '{"imageUrl":"/img/bracket.png","caption":"Bracket position"}' },
        { contentType: 0, zoneName: 'right', blockInContainerPos: 1,
          contentData: '{"html":"<ol><li>Align bracket to marks</li>...</ol>"}' }
      ]
    },

    // Container 3 — checklist at bottom, single column
    {
      containerId: 103, containerType: 0, // Single
      blocks: [{ contentType: 4, zoneName: 'main', blockInContainerPos: 1,
                 contentData: '{"items":[{"id":1,"label":"Bracket aligned","required":true}]}' }]
    }

  ]
}
👨‍💻 Developer Section

10 · Developer — The 3-Layer Renderer

Three components handle rendering, each responsible for exactly one level of the hierarchy. You should never need to modify these components — they are written once and handle all layout combinations.

Component call chain for one step
Layer 1
content-document-renderer
Input: containers: WiStepContainer[]
Iterates containers. No layout logic here.
→
Layer 2 · ALL layout lives here
content-container-renderer
Input: container: WiStepContainer
Extracts zones, applies data-container-type CSS, iterates zones → blocks.
→
Layer 3
content-block-renderer
Input: block: WiStepContent
@switch on contentType → delegates to the right content component.

Layer 2 in full — this is where CSS layout is applied

// content-container-renderer.component.ts (simplified)
@Component({
  template: `
    <div class="wi-container"
         [attr.data-container-type]="containerTypeClass()">
      @for (zone of zones(); track zone) {
        <div class="wi-zone" [attr.data-zone]="zone">
          @for (block of blocksInZone(zone); track block.wiStepContentId) {
            <content-block-renderer [block]="block" />
          }
        </div>
      }
    </div>
  `,
  styles: [`
    .wi-container[data-container-type="single"]
      { display: flex; flex-direction: column; gap: 1rem; }
    .wi-container[data-container-type="two-column"]
      { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
    .wi-container[data-container-type="banner"]
      { position: relative; width: 100%; }
    .wi-container[data-container-type="accordion"]
      { display: flex; flex-direction: column; }
    .wi-container[data-container-type="tab"]
      { display: flex; flex-direction: column; }
  `]
})

// Zones extracted in insertion order — enables dynamic panel/tab creation
zones(): string[] {
  const seen = new Set<string>();
  return this.container().blocks
    .map(b => b.zoneName ?? 'main')
    .filter(z => { if (seen.has(z)) return false; seen.add(z); return true; });
}

// Blocks for a specific zone, sorted by position
blocksInZone(zone: string) {
  return this.container().blocks
    .filter(b => (b.zoneName ?? 'main') === zone)
    .sort((a, b) => (a.blockInContainerPos ?? 1) - (b.blockInContainerPos ?? 1));
}
🔑
Key insight: The CSS grid/flex layout is applied by data-container-type attribute on a single div. Each wi-zone div becomes one column/panel/tab naturally — because they are direct children of the grid/flex container. No extra wrapper divs or conditional classes needed.
👨‍💻 Developer Section

11 · Developer — Using the Renderer in Your Component

Step 1 — import the component

import { ContentDocumentRendererComponent }
  from 'projects/wi/src/lib/renderer/content-document-renderer.component';

@Component({
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ContentDocumentRendererComponent],
  template: `<content-document-renderer [containers]="containers()" />`
})

Step 2 — supply containers with the backward-compat wrapper

// ALWAYS use this pattern — handles both new and legacy steps
protected containers(): WiStepContainer[] {
  const step = this.step();

  // New path — step already has containers from the API
  if (step.containers?.length) return step.containers;

  // Legacy path — flat contents[] wrapped into a single Single container
  const active = (step.contents ?? []).filter(c => c.isActive === 1);
  return active.length
    ? [{ containerId: 0, containerType: WiContainerType.Single, blocks: active }]
    : [];
}

Step 3 — print view (bonus)

// If you need a printable view of a whole WI document:
import { WiPrintComponent } from 'projects/wi/src/lib/player/wi-print.component';

// Template:
<wi-print [wiId]="42" />
// Fetches all sections/steps, renders all containers with print-safe CSS.
// Accordion and Tab panels expand fully for print.
🚫
Do not render blocks individually outside the renderer. Even if you think you only have one block, always go through content-document-renderer. It handles backward compat, zone ordering, print CSS, and future layout changes automatically. Direct block rendering bypasses all of that.
👨‍💻 Developer Section

12 · Developer — Adding a New Block Type

Adding a new block type requires touching exactly 4 files. No layout code changes.

  1. 1
    Add enum value in wi.enums.ts

    Append the next integer to ContentType. Example: QRCode = 8 is already defined but not yet rendered.

  2. 2
    Add interface in wi.models.ts

    Define a typed interface for the block's contentData JSON, e.g. QRCodeContent { targetUrl: string; label?: string }.

  3. 3
    Create the block component

    File: wi/src/lib/player/content/wi-content-qrcode.component.ts. Must have: ChangeDetectionStrategy.OnPush, standalone: true, readonly block = input.required<WiStepContent>(). Parse contentData in a computed signal for type safety.

  4. 4
    Register in content-block-renderer.component.ts

    Import the component, add it to the imports array, add one @case line in the @switch template. That's it — the renderer automatically routes blocks of the new type to your new component.

// content-block-renderer.component.ts — add these two lines:

// 1. Import
import { WiContentQrcodeComponent } from '../player/content/wi-content-qrcode.component';

// 2. Add to imports[]
imports: [ ..., WiContentQrcodeComponent ],

// 3. Add @case in template
@case (ContentType.QRCode) { <wi-content-qrcode [block]="block()" /> }
👨‍💻 Developer Section

13 · Developer — Code Patterns Reference

All new components follow these rules

  • ChangeDetectionStrategy.OnPush on every component — no exceptions
  • readonly block = input.required<WiStepContent>() — signal input, not decorator @Input()
  • Parse contentData in a computed signal, never in ngOnInit
  • No subscriptions in content block components — they are pure render components
  • Print-safe SCSS: @media print must remove backgrounds, add borders, show all collapsed content

Correct content block component pattern

@Component({
  selector: 'wi-content-table',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (tableData(); as data) {
      <table class="wi-data-table">
        <thead><tr>
          @for (h of data.headers; track h) { <th>{{ h }}</th> }
        </tr></thead>
        <tbody>
          @for (row of data.rows; track $index) {
            <tr>@for (cell of row; track $index) { <td>{{ cell }}</td> }</tr>
          }
        </tbody>
      </table>
    }
  `
})
export class WiContentTableComponent {
  readonly block = input.required<WiStepContent>();

  // Parse contentData safely in a computed — reactive to block() changes
  protected readonly tableData = computed(() => {
    try { return JSON.parse(this.block().contentData); }
    catch { return { headers: [], rows: [] }; }
  });
}

🛠 Admin Section

14 · Admin — Building Work Instruction Steps with Containers

As an admin, you now decide the visual layout of each WI step by choosing container types — without involving a developer. Here is how to think about it and how to do it.

Step 1 — Plan your step layout on paper first

Before opening the editor, sketch the step on paper. Ask yourself:

  • Does this step need a full-width safety alert at the top? → Start with a Banner container
  • Does this step show two things side by side (before/after, diagram + instructions)? → TwoColumn container
  • Does this step have sub-steps users should expand when needed? → Accordion container
  • Does this step vary by machine model or tool variant? → Tab container
  • Everything else (plain instructions, checklist, single image) → Single container

Step 2 — Add containers in order (top to bottom)

In the WI step editor, use "Add Container" for each layout region of the step. The first container you add becomes the topmost region on screen. A typical safety-focused step might have three containers:

Example step structure — 3 containers

Container 1 — Banner — Full-width danger warning image
Container 2 — TwoColumn
Left: diagram image
Container 2 — TwoColumn
Right: assembly steps text
Container 3 — Single — Completion checklist

Step 3 — Add blocks to each container

For each container, add the blocks that carry the actual content. For a TwoColumn container:

  • 1
    Click into the left zone area → Add Block → Image

    Set zoneName to left. Upload or link the diagram image. Set alt text and caption.

  • 2
    Click into the right zone area → Add Block → RichText

    Set zoneName to right. Write the assembly instructions in the rich text editor.

  • 3
    Preview the step before saving

    The Preview tab shows the rendered output in real time. Check that zones are correctly placed and the layout looks right on screen.

How to build an Accordion step

An accordion container shows each zone as a collapsible panel. The zone name is the panel header. To build a step with three accordion panels:

// You add 3 blocks, each with a different zoneName:
// The zoneName becomes the accordion header text

Block 1: contentType = Checklist, zoneName = "Pre-Assembly Checks"
Block 2: contentType = DataTable,  zoneName = "Torque Specifications"
Block 3: contentType = RichText,   zoneName = "Completion Notes"

// Result: 3 accordion panels labelled with those zone names.
// Users click to expand each panel independently.

How to build a Tab step (model variants)

A tab container is used when the same step has different instructions depending on a variable (machine model, operator role, tool type). Each zone becomes one tab:

// Blocks for a Tab container:
// Each zoneName becomes a visible tab label

Block 1: contentType = RichText, zoneName = "Model A (2022)"
Block 2: contentType = Image,    zoneName = "Model A (2022)"   // 2nd block in same tab
Block 3: contentType = RichText, zoneName = "Model B (2023)"
Block 4: contentType = RichText, zoneName = "Legacy (pre-2021)"

// Result: 3 tabs — "Model A (2022)", "Model B (2023)", "Legacy (pre-2021)"
// Model A has two blocks (text + image); the other tabs each have one block.
⚠️
Important for Accordion and Tab admins: The zone name you type is what users see as the panel or tab label. Use clear, descriptive names. If you misspell a zone name (e.g. 'Torque Spec' vs 'Torque Specs'), the renderer will treat them as two separate panels instead of one — check zone names carefully.
🛠 Admin Section

15 · Admin — CMS Content Authoring

The CMS editor works exactly like the WI step editor — you build documents by adding containers and blocks. The difference is that CMS content appears in the Content Portal and can be published to collections, campaigns, and learning paths.

Content lifecycle every admin must know

Status flow — cannot skip steps
Start
Draft
Editable. Not visible in Portal.
→
Submit
In Review
Locked for editing. Reviewer checks content.
→
Approve
Approved
Ready to publish.
→
Publish
Published
Live in Portal. Snapshot saved as version.
→
Retire
Archived
Removed from Portal.

Version history

Every time you publish, a version snapshot is saved. If a published document needs to be rolled back (e.g. incorrect data was published), go to Content → Version History and click Restore. This creates a new Draft from the old version without deleting the current published version.

Auto-save in the editor

The Layout tab has a 2-second debounced auto-save for block prop edits. The save indicator shows three states:

  • Saved — all changes are written to the database
  • Unsaved changes — you've made edits, auto-save timer is running
  • Saving… — the HTTP request is in flight
⚠️
Do not close the browser tab while the indicator shows "Saving…" or "Unsaved changes". Wait for "Saved" before navigating away.
🛠 Admin Section

16 · Admin — Print Layout Behaviour

The print view renders all sections and steps of a WI document in a single scrollable layout optimised for A4 paper (210mm width, 11pt font). It is triggered by clicking the Print button in the print view component.

Container typeScreen layoutPrint layout
SingleFlex columnSame — no change needed
TwoColumnTwo equal columnsKeeps grid if paper width allows; falls back to single column on narrow media
BannerBackground image + caption overlayBackground image removed, 2px solid border added, caption text shown in black — ink-safe
AccordionCollapsible panels — click to openAll panels forced to display: block — all content printed, nothing hidden
TabTab bar — click to switch tabAll tabs shown sequentially — tab bar removed, all content visible

Sign-off boxes

For steps with requireSignOff = 1, the print view renders a physical signature box below the step content:

Signature: ________________________    Date: ____________

These are invisible on screen and appear only in print output.

🛠 Admin Section

17 · Admin — Campaigns, Collections & Learning Paths

Campaigns

TypeWhen to useTrack completion?
NotificationPush a news/announcement. No completion tracking needed.No
AssignmentAssign content for mandatory reading. Track who completed it.Yes — per user
Learning PathAssign an ordered sequence of content items. Track sequential progress.Yes — per item in path
🚫
Sending a campaign is irreversible. Draft → Sent cannot be undone. Verify content, audience, and campaign type before clicking Send. There is no recall or unsend.

Collections

A collection dynamically resolves to a set of content items matching criteria you set (space, type, status, tags). Use collections in the Portal when you want a curated list that stays automatically up to date — for example, "All published SOPs in the Manufacturing space." The Portal shows whichever items currently match the collection rules.

Learning paths

Build a sequence of content items for a training programme. Items have a position number. Users must complete items in order. You can add new items to an active path — users in progress keep their place; new items appear at the end.


✍️ Content Creator Section

18 · Content Creator — Think in Containers, Not Pages

If you have written content in a traditional word processor or a basic CMS before, you are used to thinking of a page as a flowing document — text, then image, then more text, top to bottom. That model still works here with the Single container. But now you have more choices.

The key question to ask before you start writing is: "What is the most useful way for my reader to see this content?"

How to think about it

Imagine you are laying out a printed instruction sheet. You decide: "I'll put a big warning banner at the top so nobody misses it. Then I'll show the before and after photos side by side so the operator can compare them. Then I'll have a step-by-step list at the bottom." That decision is exactly what choosing container types does — it's your page layout decision, made before you write any content.

Questions to guide your layout decisions

If you are…Use this layoutBecause…
Writing a standard procedure with text, maybe one imageSingleSimple top-to-bottom reading order is best for most content
Showing a before/after, diagram + explanation, or two alternatives visuallyTwoColumnSide-by-side comparison is faster to read than up-down comparison
Starting a step with a safety warning that must not be missedBanner (first container)A full-width image banner with overlaid warning text is impossible to ignore
Writing content that applies differently depending on the reader's machine, role, or regionTabReaders click their own tab and only see what is relevant to them
Including supplementary reference data (specs, checklists) that some readers don't needAccordionCollapsed panels let readers choose what to expand — keeps the main content clean
✍️ Content Creator Section

19 · Content Creator — Real-World Layout Examples

Example 1: A simple procedure step

Scenario: "Tighten all 6 bolts in sequence. Use a torque wrench."

Layout plan

1 container → Single type

  • Block 1 (RichText): Instruction text explaining the sequence
  • Block 2 (Image): Numbered diagram showing bolt positions
  • Block 3 (Checklist): 6 checkboxes, one per bolt

All in the default Single container. No special layout needed.

Example 2: A step with a danger warning

Scenario: "Before proceeding, disconnecting the power is mandatory. Then align the bracket."

Layout plan — 2 containers

Container 1 → Banner type

  • Block 1 (Banner): Background: electrical hazard image. Message: "DANGER — Disconnect power before proceeding"

Container 2 → TwoColumn type

  • Block 1 (Image, zone: left): Photo of the power switch location
  • Block 2 (RichText, zone: right): Step-by-step bracket alignment instructions

Example 3: Model-variant instructions

Scenario: "The calibration procedure differs between Model X, Model Y, and older Legacy units."

Layout plan — 1 Tab container

Container 1 → Tab type

  • Block 1 (RichText, zone: "Model X (2023+)"): Calibration steps for Model X
  • Block 2 (Image, zone: "Model X (2023+)"): Calibration point diagram for Model X
  • Block 3 (RichText, zone: "Model Y (2021–2022)"): Calibration steps for Model Y
  • Block 4 (RichText, zone: "Legacy (pre-2021)"): Legacy calibration steps
  • Block 5 (Warning, zone: "Legacy (pre-2021)"): "Contact engineering for legacy units older than 2018."

Result: 3 tabs. The operator clicks their model's tab and only sees relevant instructions. The Legacy tab has both the instructions and a warning block.

Example 4: Reference-heavy step with expandable data

Scenario: "Apply adhesive to the joint. Specs table and curing checklist are available for reference."

Layout plan — 2 containers

Container 1 → Single type

  • Block 1 (RichText): Main adhesive application instructions
  • Block 2 (Image): Application technique photo

Container 2 → Accordion type

  • Block 1 (DataTable, zone: "Adhesive Specifications"): Table of adhesive types, quantities, temps
  • Block 2 (Checklist, zone: "Curing Checklist"): 5-item checklist for curing conditions
  • Block 3 (File, zone: "Safety Data Sheet"): Download link to the adhesive SDS PDF

Result: Main instructions are always visible. The three accordion panels are collapsed by default — operators who need the specs can expand them; operators who know them by heart are not distracted.

✍️ Content Creator Section

20 · Content Creator — Do's and Don'ts

✅ Do🚫 Don't
Use Banner container for safety-critical alerts at the top of a step — they are impossible to missPut a Warning block in a Single container and expect it to have the same visual impact as a Banner — Warning blocks are smaller, inline boxes
Name zones descriptively in Accordion and Tab containers — the zone name IS what the user reads as the panel/tab labelName zones 'zone1', 'panel', 'tab1' — these make useless labels for the reader
Put multiple blocks in the same zone to build up content in that panel — a tab panel can have text + image + checklist all togetherCreate a new container for every single block — one container can and should hold many blocks across multiple zones
Put the Banner container first, before all other containers in the stepPut a Banner container in the middle of a step — it reads as an afterthought and breaks the reading flow
Use TwoColumn to show things that should be compared side by sideUse TwoColumn for unrelated content — side-by-side implies a relationship
Check the print view if your step has Accordion or Tab containers — all panels print expandedAssume the print view will honour collapsed/tabbed state — it expands everything for paper
Test zone name spelling carefully in Accordion/Tab — 'Assembly Steps' and 'Assembly steps' (lowercase s) are different zonesCopy-paste zone names between blocks without verifying the case matches exactly

21 · Reference — Container Type Quick Table

EnumValueCSS appliedZonesBest for
Single0flex-direction: column'main'Default step content, instructions, checklists
TwoColumn1grid-template-columns: 1fr 1fr'left', 'right'Before/after, diagram + text, parallel reference
Banner2position: relative; width: 100%'main'Safety alerts, step headers with hero image
Accordion3flex-direction: column (zone = panel)Any descriptive nameReference data, grouped checklists, sub-steps
Tab4flex-direction: column (zone = tab)Any descriptive nameModel/role/variant-specific instructions

22 · Reference — Block Data Schemas

File block — ContentType.File = 7

// contentData JSON
{ fileUrl: "https://server/files/doc.pdf", fileName: "Safety Data Sheet.pdf", fileSize: 204800 }
// Renders: clickable link with 📎 icon. Opens in new tab.
// Display label: fileName (falls back to URL if absent)

DataTable block — ContentType.DataTable = 9

// contentData JSON — simple headers + rows format
{
  headers: ["Fastener", "Torque (Nm)", "Tool"],
  rows: [
    ["M6 hex",  "10", "T-10 torque wrench"],
    ["M8 hex",  "25", "T-20 torque wrench"],
    ["M10 hex", "48", "Pneumatic driver"]
  ]
}
// Renders as a styled thead/tbody HTML table.
// Invalid JSON renders as an empty table — validate your JSON before saving.

Banner block — for Banner containers

// contentData JSON
{
  title?:    "CAUTION",
  message:  "Wear eye protection in this zone",
  imageUrl?: "https://storage/banners/eye-protection.jpg",
  ctaLabel?: "View Safety Policy",
  ctaUrl?:   "https://intranet/safety"
}
// Renders: full-width div, background image, caption overlay at bottom.
// Print: image removed, solid border, black caption text.

23 · Reference — API Endpoints

Content lifecycle

KeyMethodParams
Cms.Content.GetContentFullDocumentGET?ContentId
Cms.Content.GetContentListGET?SpaceId&ContentTypeId&ContentStatus&AuthorId&PageNo&PageSize
Cms.Content.SearchContentGET?SearchText&SpaceId&ContentTypeId&PageNo&PageSize
Cms.Content.SubmitForReviewPOST{ contentId }
Cms.Content.PublishContentPOST{ contentId }
Cms.Content.ArchiveContentPOST{ contentId }
Cms.ContentVersion.RestoreVersionPOST{ versionId }

Document structure

KeyMethodParams
Cms.ContentContainer.SavePOST{ contentId, containerTypeCode, positionNo }
Cms.ContentContainer.ReorderPOST{ contentId, orderedIds: number[] }
Cms.ContentContainer.DeleteDELETE?ContentContainerId
Cms.ContentBlock.SavePOST{ contentContainerId, blockCode, zoneName, positionNo, propsJson }
Cms.ContentBlock.UpdatePropsPOST{ blockId, propsJson } — auto-save endpoint
Cms.ContentBlock.DeleteDELETE?ContentBlockId

24 · FAQ

Q: I'm used to writing CSS classes in templates for layout. Where do I put those now?

Nowhere. The renderer applies layout CSS automatically based on containerType. You do not add grid or flex classes to templates. If you find yourself writing class="row" or class="col-6" in a WI or CMS component, stop — that's the old model.

Q: What if I need a 3-column layout?

That is not yet in the enum. To add it, a developer adds ThreeColumn = 5 to WiContainerType and adds a CSS rule in ContentContainerRendererComponent: .wi-container[data-container-type="three-column"] { grid-template-columns: 1fr 1fr 1fr; }. Zero changes to any consuming component. Admin then selects "Three Column" in the container type dropdown once it's deployed.

Q: My existing WI steps use the old flat contents[] field. Do they break?

No. The backward-compat wrapper in WiPlayerStepComponent and WiPrintComponent automatically wraps flat contents[] into a single Single container. Everything renders as before.

Q: Can I have the same block appear in two zones?

No. A block belongs to exactly one zone. If you need the same content in two zones, add two blocks with the same content. There is no reference/alias mechanism.

Q: What happens to Accordion and Tab in print?

Both expand fully. All accordion panels are shown open; all tab panels are shown sequentially. The print SCSS uses @media print { .wi-zone { display: block !important; } } to force this regardless of the interactive state on screen.

Q: The zone name shows in the accordion/tab header — can I style it separately from zone content?

The zone name is rendered as the text of a panel header or tab button. Styling is controlled in the ContentContainerRendererComponent SCSS under .wi-zone[data-zone]::before or the tab/accordion header selectors. Developers can customise this once per container type without touching any consuming code.

Q: Can I reorder containers after creating them?

Yes. In the CMS editor's Layout tab, drag containers to reorder. This calls Cms.ContentContainer.Reorder with the new ordered ID array. For WI steps, the same reorder API exists.

Q: What does contentData look like for a RichText block?

It is a JSON string: {"html":"<p>Your formatted text</p>","plainText":"Your formatted text"}. The html field is rendered directly; the plainText field is used for search indexing and accessibility. Never put user-supplied HTML in html without server-side sanitisation.

Q: As an admin, if I change a container type from Single to TwoColumn, do existing blocks break?

The blocks themselves do not break — they continue to render their content. However, without zone assignments, all blocks will default to the 'main' zone and both columns will be in the left column. To properly populate a TwoColumn container, you need to explicitly set zoneName: 'left' or zoneName: 'right' on each block.