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.
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.
2 · Old Model vs New Model — Side by Side
// 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.
// 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
| Role | Old responsibility | New responsibility |
|---|---|---|
| Developer | Write a new template or component for each layout type needed | Write the renderer once (done). Add new block types when needed. |
| Admin | File a change request for each layout variation | Select container type when building a WI step or CMS document |
| Content Creator | Write only text; developer arranged visuals | Choose 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.
| Term | What it is | Analogy | Decided 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 |
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
containerTypefield
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
'main' zone and stack top-to-bottom.2 · Two Column
zoneName: 'left' or zoneName: 'right'.3 · Banner
+ caption overlay at bottom
ContentType.Banner block. Background image + text overlay. Print-safe (removes image, adds border).4 · Accordion
Table block visible here
zoneName = one collapsible panel. The zone name is used as the panel header label.5 · Tab
zoneName = one tab. Zone name shown as tab label.'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)
// 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 ]
// 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 type | Expected zone names | Displayed 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 |
| Accordion | Any descriptive label, e.g. 'Pre-checks', 'Torque Spec' | The zone name IS the accordion panel header |
| Tab | Any 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.
| ContentType | Value | Renders as | Key data fields | |
|---|---|---|---|---|
| RichText | 0 | Formatted HTML paragraph, headings, lists | { html, plainText? } | |
| Image | 1 | Responsive image with optional caption | { imageUrl, caption?, altText? } | |
| Video | 2 | Embedded video player (YouTube/Vimeo/local) | { videoUrl, provider, thumbnail? } | |
| 3 | Inline PDF viewer with page selector | { pdfUrl, pageNumber? } | ||
| Checklist | 4 | Interactive checkbox list | { title?, items[], allowPartial } | |
| Warning | 5 | Coloured warning/caution box | { message, warningLevel } | |
| DecisionBranch | 6 | Branching question with clickable answers | { question, branches[] } | |
| File | 7 | Downloadable file link with icon | { fileUrl, fileName, fileSize } | NEW |
| QRCode | 8 | Scannable QR code | { targetUrl, label? } | NEW |
| DataTable | 9 | Structured data table (thead/tbody) | { headers[], rows[][] } | NEW |
| Banner | — | Full-width image with caption overlay | { message, imageUrl?, title? } | NEW |
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.
<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
// 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> }
// 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.
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}]}' }] } ] }
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.
containers: WiStepContainer[]Iterates containers. No layout logic here.
container: WiStepContainerExtracts zones, applies
data-container-type CSS, iterates zones → blocks.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)); }
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.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.
content-document-renderer. It handles backward compat, zone ordering, print CSS, and future layout changes automatically. Direct block rendering bypasses all of that.12 · Developer — Adding a New Block Type
Adding a new block type requires touching exactly 4 files. No layout code changes.
-
1Add enum value in
wi.enums.tsAppend the next integer to
ContentType. Example:QRCode = 8is already defined but not yet rendered. -
2Add interface in
wi.models.tsDefine a typed interface for the block's
contentDataJSON, e.g.QRCodeContent { targetUrl: string; label?: string }. -
3Create 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>(). ParsecontentDatain a computed signal for type safety. -
4Register in
content-block-renderer.component.tsImport the component, add it to the
importsarray, add one@caseline in the@switchtemplate. 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()" /> }
13 · Developer — Code Patterns Reference
All new components follow these rules
ChangeDetectionStrategy.OnPushon every component — no exceptionsreadonly block = input.required<WiStepContent>()— signal input, not decorator@Input()- Parse
contentDatain a computed signal, never in ngOnInit - No subscriptions in content block components — they are pure render components
- Print-safe SCSS:
@media printmust 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: [] }; } }); }
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
Left: diagram image
Right: assembly steps text
Step 3 — Add blocks to each container
For each container, add the blocks that carry the actual content. For a TwoColumn container:
-
1Click into the left zone area → Add Block → Image
Set
zoneNametoleft. Upload or link the diagram image. Set alt text and caption. -
2Click into the right zone area → Add Block → RichText
Set
zoneNametoright. Write the assembly instructions in the rich text editor. -
3Preview 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.
'Torque Spec' vs 'Torque Specs'), the renderer will treat them as two separate panels instead of one — check zone names carefully.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
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
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 type | Screen layout | Print layout |
|---|---|---|
| Single | Flex column | Same — no change needed |
| TwoColumn | Two equal columns | Keeps grid if paper width allows; falls back to single column on narrow media |
| Banner | Background image + caption overlay | Background image removed, 2px solid border added, caption text shown in black — ink-safe |
| Accordion | Collapsible panels — click to open | All panels forced to display: block — all content printed, nothing hidden |
| Tab | Tab bar — click to switch tab | All 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.
17 · Admin — Campaigns, Collections & Learning Paths
Campaigns
| Type | When to use | Track completion? |
|---|---|---|
| Notification | Push a news/announcement. No completion tracking needed. | No |
| Assignment | Assign content for mandatory reading. Track who completed it. | Yes — per user |
| Learning Path | Assign an ordered sequence of content items. Track sequential progress. | Yes — per item in path |
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.
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?"
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 layout | Because… |
|---|---|---|
| Writing a standard procedure with text, maybe one image | Single | Simple top-to-bottom reading order is best for most content |
| Showing a before/after, diagram + explanation, or two alternatives visually | TwoColumn | Side-by-side comparison is faster to read than up-down comparison |
| Starting a step with a safety warning that must not be missed | Banner (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 region | Tab | Readers click their own tab and only see what is relevant to them |
| Including supplementary reference data (specs, checklists) that some readers don't need | Accordion | Collapsed panels let readers choose what to expand — keeps the main content clean |
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.
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 miss | Put 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 label | Name 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 together | Create 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 step | Put 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 side | Use 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 expanded | Assume 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 zones | Copy-paste zone names between blocks without verifying the case matches exactly |
21 · Reference — Container Type Quick Table
| Enum | Value | CSS applied | Zones | Best for |
|---|---|---|---|---|
| Single | 0 | flex-direction: column | 'main' | Default step content, instructions, checklists |
| TwoColumn | 1 | grid-template-columns: 1fr 1fr | 'left', 'right' | Before/after, diagram + text, parallel reference |
| Banner | 2 | position: relative; width: 100% | 'main' | Safety alerts, step headers with hero image |
| Accordion | 3 | flex-direction: column (zone = panel) | Any descriptive name | Reference data, grouped checklists, sub-steps |
| Tab | 4 | flex-direction: column (zone = tab) | Any descriptive name | Model/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
| Key | Method | Params |
|---|---|---|
| Cms.Content.GetContentFullDocument | GET | ?ContentId |
| Cms.Content.GetContentList | GET | ?SpaceId&ContentTypeId&ContentStatus&AuthorId&PageNo&PageSize |
| Cms.Content.SearchContent | GET | ?SearchText&SpaceId&ContentTypeId&PageNo&PageSize |
| Cms.Content.SubmitForReview | POST | { contentId } |
| Cms.Content.PublishContent | POST | { contentId } |
| Cms.Content.ArchiveContent | POST | { contentId } |
| Cms.ContentVersion.RestoreVersion | POST | { versionId } |
Document structure
| Key | Method | Params |
|---|---|---|
| Cms.ContentContainer.Save | POST | { contentId, containerTypeCode, positionNo } |
| Cms.ContentContainer.Reorder | POST | { contentId, orderedIds: number[] } |
| Cms.ContentContainer.Delete | DELETE | ?ContentContainerId |
| Cms.ContentBlock.Save | POST | { contentContainerId, blockCode, zoneName, positionNo, propsJson } |
| Cms.ContentBlock.UpdateProps | POST | { blockId, propsJson } — auto-save endpoint |
| Cms.ContentBlock.Delete | DELETE | ?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.