1 · What Changed & Why
Previously, a Work Instruction step held a flat list of content blocks — one after another, top to bottom, all the same width. This worked for simple text + image steps but could not represent real manufacturing documentation layouts: safety banners that span full width, side-by-side reference images, tabbed procedures, or accordion-collapsed sub-steps.
This release introduces a Container Model sitting between the step and its blocks. A container defines the layout for the blocks inside it. Multiple containers can live in one step, stacked vertically. Each container can be a single column, two columns, a banner, an accordion, or a tab panel.
The same model is used in the CMS — content documents are now assembled from containers and blocks rather than a monolithic body, giving authors full control over page layout.
contents[] field still work — the renderer automatically wraps them into a single Single container. No data migration needed.2 · The Mental Model Shift
If you have worked with the old WI or CMS system, think of it this way:
// Step had one flat array of blocks
WiStep {
contents: [
{ contentType: RichText, ... },
{ contentType: Image, ... },
{ contentType: Checklist, ... }
]
}
// Step now has containers, each with blocks WiStep { containers: [ { containerType: Single, // full-width blocks: [{ contentType: RichText }] }, { containerType: TwoColumn, // side-by-side blocks: [ { contentType: Image, zoneName: 'left' }, { contentType: Image, zoneName: 'right' } ] } ] }
The key insight: containers control layout, blocks carry content. A block does not know what layout it sits in — it always renders the same way. The container's containerType is what tells the renderer how to arrange blocks in space.
3 · The Container Model in Detail
WiStepContainer interface
interface WiStepContainer { containerId: number; // unique ID from the database containerType: WiContainerType; // Single | TwoColumn | Banner | Accordion | Tab containerLabel?: string; // used as accordion title / tab label blocks: WiStepContent[]; // all blocks regardless of zone }
WiStepContent — added fields
Content blocks gained four new optional fields that the container renderer uses. They default gracefully for legacy blocks that don't have them.
interface WiStepContent { // ── existing fields (unchanged) ──────────────────────── wiStepContentId: number; contentType: ContentType; contentData: string; // JSON stringified block payload sequenceOrder: number; isActive: number; // ── NEW container-placement fields ───────────────────── containerId?: number; // which container this block belongs to containerType?: WiContainerType; // container's type (informational) containerLabel?: string; // container label (informational) zoneName?: string; // e.g. 'main', 'left', 'right', 'panel-1' blockInContainerPos?:number; // sort order within the zone }
'main' when absent. For Single containers all blocks share the 'main' zone. Only TwoColumn uses 'left'/'right' by convention.4 · Layout Types — Visual Reference
There are five container layout types defined in WiContainerType. Each maps to a specific CSS layout on the rendered output.
Single
Blocks stack vertically in a single column. CSS: flex-direction: column. Use for default step content, warnings, and checklists.
TwoColumn
Block 1
Block 2
Two equal columns via CSS Grid (1fr 1fr). Use zoneName: 'left' and zoneName: 'right' on blocks. Ideal for before/after images or diagram + instructions.
Banner
Full-width container, position: relative. Designed to hold a single WiContentBannerComponent block. The banner block applies its own background image and caption overlay.
Accordion
Each unique zoneName becomes one accordion panel. Collapses to a single click-to-expand section. In print mode, all zones expand automatically.
Tab
Each unique zoneName becomes one tab. Only the active tab's blocks are visible. In print mode, all tabs are shown as sequential sections.
'Safety Checklist', 'Assembly Steps', 'Torque Values'. Generic names like 'zone1' will appear as ugly tab/accordion headers.5 · Zones — How Blocks Are Placed
A zone is a named slot within a container. Every block has a zoneName field that declares which slot it belongs to. The renderer extracts distinct zone names from a container's blocks in order of first appearance, then renders each zone as a separate div.
Zone naming conventions
| Container type | Zone names | Notes |
|---|---|---|
| Single | 'main' | All blocks go in the same zone. zoneName can be omitted — defaults to 'main'. |
| TwoColumn | 'left', 'right' | Convention only — the renderer maps zones to grid columns in order of first appearance. Use 'left' and 'right' for clarity. |
| Banner | 'main' | Typically one banner block covering the full width. |
| Accordion | Any descriptive name | Each unique name = one accordion panel. Label is used as the panel header. |
| Tab | Any descriptive name | Each unique name = one tab. Label shown in the tab bar. |
Rendering logic (source: ContentContainerRendererComponent)
// Step 1 — extract unique zones in insertion order protected zones(): string[] { const blocks = this.container().blocks; const seen = new Set<string>(); return blocks .map(b => b.zoneName ?? 'main') .filter(z => { if (seen.has(z)) return false; seen.add(z); return true; }); } // Step 2 — get blocks for a specific zone, sorted by blockInContainerPos protected blocksInZone(zone: string) { return this.container().blocks .filter(b => (b.zoneName ?? 'main') === zone) .sort((a, b) => (a.blockInContainerPos ?? 1) - (b.blockInContainerPos ?? 1)); }
blockInContainerPos controls ordering within a zone. Blocks across different zones in the same container don't need to have globally unique positions — only per-zone ordering matters.6 · Complete Data Shape — Worked Examples
Example A — Two-column image comparison step
// A step with one TwoColumn container holding two images { wiStepId: 42, stepTitle: "Verify torque marks align", containers: [ { containerId: 101, containerType: WiContainerType.TwoColumn, // = 1 blocks: [ { wiStepContentId: 201, contentType: ContentType.Image, contentData: JSON.stringify({ imageUrl: '/img/before.png', caption: 'Before' }), zoneName: 'left', blockInContainerPos: 1, isActive: 1 }, { wiStepContentId: 202, contentType: ContentType.Image, contentData: JSON.stringify({ imageUrl: '/img/after.png', caption: 'After' }), zoneName: 'right', blockInContainerPos: 1, isActive: 1 } ] } ] }
Example B — Safety banner + tabbed procedures
// Two containers in one step { wiStepId: 55, stepTitle: "High-voltage connection", containers: [ // Container 1 — safety banner full-width { containerId: 110, containerType: WiContainerType.Banner, // = 2 blocks: [{ wiStepContentId: 210, contentType: ContentType.Banner, contentData: JSON.stringify({ message: "DANGER — Disconnect power before proceeding", imageUrl: '/img/danger-banner.jpg' }), zoneName: 'main', blockInContainerPos: 1, isActive: 1 }] }, // Container 2 — tabbed instructions for three tool variants { containerId: 111, containerType: WiContainerType.Tab, // = 4 blocks: [ { wiStepContentId: 211, contentType: ContentType.RichText, contentData: JSON.stringify({ html: '<p>Use torque wrench T-12...</p>' }), zoneName: 'Model A', blockInContainerPos: 1, isActive: 1 }, { wiStepContentId: 212, contentType: ContentType.RichText, contentData: JSON.stringify({ html: '<p>Use power driver PD-8...</p>' }), zoneName: 'Model B', blockInContainerPos: 1, isActive: 1 } ] } ] }
Example C — Accordion with grouped checklist and notes
{
containerId: 120,
containerType: WiContainerType.Accordion, // = 3
blocks: [
// Accordion panel 1: "Pre-checks"
{
contentType: ContentType.Checklist,
contentData: JSON.stringify({ title: 'Pre-checks', items: [...] }),
zoneName: 'Pre-checks', blockInContainerPos: 1, isActive: 1
},
// Accordion panel 2: "Torque Spec"
{
contentType: ContentType.DataTable,
contentData: JSON.stringify({ headers: ['Bolt', 'Nm'], rows: [['M8', '25']] }),
zoneName: 'Torque Spec', blockInContainerPos: 1, isActive: 1
}
]
}
7 · 3-Layer Renderer Architecture
The renderer is split into three single-responsibility Angular components, each handling one level of the hierarchy.
@Input containers[]Iterates containers. No layout logic.
@Input containerExtracts zones, applies CSS layout class, calls layer 3 per block.
@Input block@switch on ContentType, delegates to specific component.
Layer 1 — ContentDocumentRendererComponent
// Selector: <content-document-renderer [containers]="containers" /> readonly containers = input.required<WiStepContainer[]>(); // Template (simplified): <div class="wi-document"> <!-- flex-column, gap 1.5rem --> @for (container of containers(); track container.containerId) { <content-container-renderer [container]="container" /> } </div>
Layer 2 — ContentContainerRendererComponent
// Selector: <content-container-renderer [container]="c" /> readonly container = input.required<WiStepContainer>(); // CSS classes applied by containerType: 'single' → display: flex; flex-direction: column; gap: 1rem 'two-column' → display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem 'banner' → position: relative; width: 100% 'accordion' → display: flex; flex-direction: column 'tab' → display: flex; flex-direction: column // Each zone becomes a <div class="wi-zone" data-zone="zoneName">
Layer 3 — ContentBlockRendererComponent
// @switch covers all 10 content types: ContentType.RichText → <wi-content-richtext> ContentType.Image → <wi-content-image> ContentType.Video → <wi-content-video> ContentType.PDF → <wi-content-pdf> ContentType.Checklist → <wi-content-checklist> ContentType.Warning → <wi-content-warning> ContentType.DecisionBranch → <wi-content-decision> ContentType.File → <wi-content-file> NEW ContentType.DataTable → <wi-content-table> NEW ContentType.Banner → <wi-content-banner> NEW
8 · Block Types — Content Data Schemas
Every block stores its payload as a JSON string in contentData. Below are the schemas for all 10 types — especially the three new ones.
New block types
File — ContentType.File = 7
// contentData JSON shape { fileUrl: "https://storage.domain/files/torque-spec.pdf", fileName: "Torque Specification v3.pdf", fileSize: 204800 // bytes } // Renders as a clickable download link with a file icon. // If fileName is absent, the URL filename is used as the display label. // Always opens in a new tab (target="_blank").
DataTable — ContentType.DataTable = 9
// contentData JSON shape (used by WiContentTableComponent) // Note: the component reads contentRef, not contentData for the table payload. { headers: ["Fastener", "Torque (Nm)", "Tool"], rows: [ ["M6 hex bolt", "10", "Torque wrench T-10"], ["M8 hex bolt", "25", "Torque wrench T-20"], ["M10 hex bolt", "48", "Pneumatic driver"] ] } // Rendered as a standard HTML <table> with <thead>/<tbody>. // Component has safe JSON.parse with empty-state fallback.
Banner — ContentType.Banner = 8
// contentData JSON shape { title?: "CAUTION", message: "Always wear safety goggles in this zone", imageUrl?: "https://storage.domain/banners/safety-zone.jpg", ctaLabel?: "View Safety Policy", ctaUrl?: "https://intranet/safety" } // Rendered as a full-width div with background image (if imageUrl provided). // Caption is overlaid at the bottom of the image. // In print mode: background removed, border added for ink safety.
Existing block types — quick reference
| ContentType | Value | Key contentData fields |
|---|---|---|
| RichText | 0 | { html, plainText? } |
| Image | 1 | { imageUrl, caption?, altText? } |
| Video | 2 | { videoUrl, provider, thumbnail?, duration? } |
| 3 | { pdfUrl, pageNumber? } | |
| Checklist | 4 | { title?, items: [{ id, label, required }], allowPartial } |
| Warning | 5 | { message, warningLevel } |
| DecisionBranch | 6 | { question, branches: [{ branchId, label, targetStepId }] } |
9 · Backward Compatibility
Existing steps that use contents[] (the flat list) continue to work without any change. The backward-compat wrapper is implemented in two places:
In WiPlayerStepComponent
protected activeContainers(): WiStepContainer[] { const step = this.player.currentStep(); // New path — step has containers already if (step?.containers?.length) return step.containers; // Legacy path — wrap flat contents into a single Single container const contents = this.activeContents(); return contents.length ? [{ containerId: 0, containerType: 0, blocks: contents }] : []; }
In WiPrintComponent
stepContainers(step: WiStep) { if (step.containers?.length) return step.containers; return [{ containerId: 0, containerType: 0 as const, blocks: step.contents ?? [] }]; }
containerType: 0 (Single) and zoneName defaults to 'main' — they render exactly as they always did, just through the new renderer pipeline.containers[], not contents[]. The flat contents[] field is kept for legacy compatibility only. New WI steps created via the CMS editor will always return containers[].10 · Print View
The WiPrintComponent renders the entire work instruction document in a linear, print-optimised layout.
Usage
<!-- Embed in any parent that has the WI ID -->
<wi-print [wiId]="selectedWiId" />
What it does
- 1Fetches full detail
Calls
WiDbService.getWiFullDetail(wiId)on init. Stores result in a signal. - 2Renders header block
Shows WI title, code, version, and effective date at the top of the document.
- 3Iterates sections → steps
Uses
stepsForSection(sectionId)to group steps under their section heading. - 4Renders each step via ContentDocumentRenderer
Calls
stepContainers(step)(backward-compat wrapper) to get containers, passes to<content-document-renderer>. - 5Sign-off boxes
For steps with
requireSignOff === 1, a printed signature line and date field are added below the step content. - 6Print action
The Print button calls
window.print(). The SCSS hides the action bar and UI chrome for print media, forces page breaks between sections.
Print media behaviour
| Container type | Print behaviour |
|---|---|
| Single | Renders normally — flex column. |
| TwoColumn | Keeps two-column grid (if paper width allows). |
| Banner | Background image removed; border added for ink safety. Caption still shown. |
| Accordion | All panels forced to display: block — all content visible without interaction. |
| Tab | All panels shown sequentially — no tabs, all content printed. |
11 · CMS — Content Catalogue
The Content Catalogue is the main browse hub for all CMS content. Think of it as the content library — every piece of content the organisation has authored appears here.
Filtering and search
- 1Keyword search (debounced 300 ms)
Triggers
CmsDbService.searchContent(text, spaceId, typeId). Automatically switches togetContentList()with filters when the search box is cleared. - 2Status filter chips
Click any status chip (Draft / In Review / Approved / Published / Archived) to filter. Status maps to numeric codes: Draft=0, InReview=1, Approved=2, Published=3, Archived=4.
- 3Space and Content Type dropdowns
Pass
spaceId=-1andcontentTypeId=-1to show all. Select a specific space or type to narrow the list. - 4Grid / List view toggle
Grid view shows cards; list view shows a compact table. State is stored in the
viewModesignal — persists while the component is mounted.
Pagination
Default page size is 20. Use nextPage() / prevPage(). The pageNo signal drives re-fetch automatically via the search/filter pipeline.
12 · CMS — Content Editor
The Content Editor is the authoring environment where CMS documents are assembled from containers and blocks. It mirrors the same container model used by the WI renderer.
Layout tab — how to author a document
- 1Add a Container
Click "Add Container" and choose a container type from the dropdown. The new container appears in the canvas. Each container gets a
ContentContainerIdfrom the backend on save. - 2Add Blocks to the Container
Select a container in the canvas, then click "Add Block" and choose from the Block Library panel on the right. Pick a block type (RichText, Image, Table, etc.) and enter its
propsJson. - 3Set Zone (for multi-zone containers)
For TwoColumn, Accordion, and Tab containers, enter the
zoneNamewhen adding a block. For TwoColumn useleftorright. For Accordion/Tab use any descriptive name. - 4Edit block props — auto-saved
Click any block in the canvas to select it. The properties panel on the right shows the block's
propsJsonas an editable JSON field. Changes are debounced 2 seconds and automatically saved viaCmsEditorService. - 5Reorder containers
Containers can be reordered by dragging. The backend API
Cms.ContentContainer.Reorderis called to persist the new order.
CmsEditorService — state management
The editor's reactive state is centralised in CmsEditorService. All editor components read from and write to this service rather than holding local state.
// Key signals available to all editor components editorService.currentDocument() // full CmsDocument | null editorService.selectedContainerIdx() // index into containers[] editorService.selectedBlockId() // block ID or -1 editorService.isDirty() // unsaved changes exist editorService.isSaving() // auto-save in flight // Computed signals editorService.selectedContainer() // derived from idx editorService.selectedBlock() // derived from blockId // Actions editorService.loadDocument(contentId) editorService.addContainer(dto) editorService.addBlock(containerId, dto) editorService.updateSelectedBlockProps(propsJson) // triggers 2s auto-save editorService.reset()
updateSelectedBlockProps() uses signal.update(doc => ({ ...doc, containers: doc.containers.map(...) })) — it never mutates the existing object. This ensures Angular's OnPush change detection fires correctly.13 · CMS — Content Lifecycle
Every content item passes through a defined lifecycle. Status transitions are enforced by the backend and surfaced in the UI as action buttons.
SubmitForReviewApproveContentPublishContentVisible in Portal
ArchiveContentVersion history
Every time a content item is published, a version snapshot is created. Authors can:
- View all past versions via
Cms.ContentVersion.GetVersionHistory - Restore any version via
Cms.ContentVersion.RestoreVersion— this creates a new draft from the selected snapshot, preserving the version history
14 · CMS — Campaigns, Collections & Learning Paths
Campaigns
Campaigns deliver content to specific users or groups. Three campaign types are supported:
| Type | Code | Use case |
|---|---|---|
| Notification | 0 | Push a news or announcement to a group. Content opens in the Portal. |
| Assignment | 1 | Assign a content item to users who must read/complete it. Tracks per-user completion status. |
| Learning Path | 2 | Assign an ordered learning path. Tracks path-level and item-level progress. |
Campaign lifecycle: Draft → Scheduled → Sent → Closed. Once Sent, the campaign is immutable. Assignment statuses can still be updated per-user via Cms.Campaign.UpdateAssignmentStatus.
Collections
Collections are dynamic groups of content items resolved at query time. Instead of manually picking content IDs, a collection defines criteria (space, type, status, tags) and the ResolveCollection API returns the current matching items. Use in portals where the curated list should stay fresh automatically.
Learning Paths
A learning path is an ordered sequence of content items. Users complete items in order. Each item has a positionNo that can be updated via drag-reorder. Items can be added at any time — re-sending the path to users in progress preserves their current position.
15 · Developer — Consuming the Renderer
To render WI content anywhere in the application, use <content-document-renderer> directly. It is a standalone component — just add it to your component's imports array.
// 1. Import import { ContentDocumentRendererComponent } from 'projects/wi/src/lib/renderer/content-document-renderer.component'; // 2. Add to component imports @Component({ imports: [ContentDocumentRendererComponent], template: `<content-document-renderer [containers]="stepContainers()" />` }) // 3. Supply containers — use the backward-compat wrapper if step may be legacy stepContainers(step: WiStep): WiStepContainer[] { if (step.containers?.length) return step.containers; return [{ containerId: 0, containerType: 0, blocks: step.contents ?? [] }]; }
content-document-renderer. It handles the container/zone/block hierarchy, backward compat, and print-mode CSS. Bypassing it for individual blocks loses layout and print support.16 · Developer — Adding a New Block Type
Follow this checklist when a new content type is required (e.g., ContentType.QRCode = 8 is defined but not yet rendered):
-
1Define the enum value
Add to
ContentTypeinwi/src/lib/enums/wi.enums.ts. Pick the next available integer. -
2Define the content data interface
Add a
QRCodeContentinterface towi/src/lib/models/wi.models.tswith all fields the block needs. -
3Create the block component
Create
wi/src/lib/player/content/wi-content-qrcode.component.ts. Follow the pattern of any existing content component:import { Component, ChangeDetectionStrategy, input } from '@angular/core'; import { WiStepContent } from '../../models/wi.models'; @Component({ selector: 'wi-content-qrcode', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: `...` }) export class WiContentQrcodeComponent { readonly block = input.required<WiStepContent>(); }
-
4Register in ContentBlockRendererComponent
Open
wi/src/lib/renderer/content-block-renderer.component.ts. Add the import and a new@casein the@switch:// Add import import { WiContentQrcodeComponent } from '../player/content/wi-content-qrcode.component'; // Add to imports array imports: [ ..., WiContentQrcodeComponent ], // Add case in template @case (ContentType.QRCode) { <wi-content-qrcode [block]="block()" /> }
-
5Add to the Block Library (CMS side)
In
cms/master/blocklibrary/blocklibrary.component.ts, add an entry toCANONICAL_BLOCKS:{ code: 'QRCODE', name: 'QR Code', icon: '⬛', category: 'Media', description: 'Scannable QR code linking to a URL' }
17 · Developer — API Endpoint Reference
CMS endpoints (cms.ts)
Content lifecycle
- GET Cms.Content.GetContentList ?SpaceId&ContentTypeId&ContentStatus&AuthorId&PageNo&PageSize
- GET Cms.Content.SearchContent ?SearchText&SpaceId&ContentTypeId&PageNo&PageSize
- GET Cms.Content.GetContentFullDocument ?ContentId — returns containers + blocks tree
- POST Cms.Content.SubmitForReview body: { contentId }
- POST Cms.Content.ApproveContent body: { contentId }
- POST Cms.Content.PublishContent body: { contentId }
- POST Cms.Content.ArchiveContent body: { contentId }
Document structure
- GET Cms.ContentContainer.GetByContent ?ContentId
- POST Cms.ContentContainer.Save body: { contentId, containerTypeCode, containerLabel, positionNo }
- POST Cms.ContentContainer.Reorder body: { contentId, orderedIds: number[] }
- DELETE Cms.ContentContainer.Delete ?ContentContainerId
- POST Cms.ContentBlock.Save body: { contentContainerId, blockCode, zoneName, positionNo, propsJson }
- POST Cms.ContentBlock.UpdateProps body: { blockId, propsJson } — partial update, used by auto-save
- DELETE Cms.ContentBlock.Delete ?ContentBlockId
Versioning
- GET Cms.ContentVersion.GetVersionHistory ?ContentId
- POST Cms.ContentVersion.RestoreVersion body: { versionId }
18 · Admin — CMS Administration Tasks
Setting up a new content space
- 1Create the space
Go to CMS → Settings → Spaces. A space is a logical grouping (e.g. "Manufacturing SOPs", "HR Policies"). Assign a code, name, and optionally restrict write access by role.
- 2Set up content types
Go to CMS → Settings → Content Types. Define the types of documents your space will hold. Content types control which fields appear in the Fields tab of the editor.
- 3Set up taxonomy
Go to CMS → Taxonomy (OntologyService). Add taxonomy nodes that authors will tag content with. These are searchable from the Catalogue.
- 4Set up a Design System (optional)
If you use the Render Output feature, create a Design System (CSS/theme token file) that the renderer will apply to generated output.
Campaign administration
| Task | Where | Notes |
|---|---|---|
| Create campaign | CMS → Campaigns → New | Set type, audience group, and link content or learning path. |
| Send campaign | Campaign detail → Send button | Calls Cms.Campaign.SendCampaign. Irreversible — content is dispatched to all recipients. |
| Track assignments | Campaign detail → Assignments tab | Lists every user's assignment status. Admin can manually update status via UpdateAssignmentStatus. |
| Close campaign | Campaign detail → Close button | Moves to Closed status. Past assignment records are retained for audit. |
19 · Admin — Work Instruction Administration Tasks
Creating a WI step with a multi-column layout
- 1Open the WI editor
Navigate to the WI detail → Steps tab. Select or create the step.
- 2Add a TwoColumn container
In the step's content editor, click "Add Container" → select "Two Column". A new container row appears in the canvas.
- 3Add blocks to each zone
Click into the left zone → Add Block → Image. Set zone name to
left. Repeat for the right zone with zone nameright. - 4Set images for each block
Click each image block → edit props → set
imageUrl,caption,altText. Changes auto-save after 2 seconds. - 5Preview the step
Click Preview in the step editor to see the rendered output before publishing.
Adding a Safety Banner to a step
- 1Add a Banner container first
Banner blocks must sit inside a Banner container. Do not put a Banner block inside a Single container — it will render but without the full-width positioning.
- 2Add a Banner block
Inside the Banner container, add one Banner block. Set
imageUrlfor the background andmessagefor the caption overlay. - 3Position at top of step
The Banner container should be the first container in the step (lowest
positionNo). Drag it to the top of the container list.
Using the print view
The print view is triggered by embedding <wi-print [wiId]="id"> in any parent component, then clicking the Print button it renders. All containers and blocks are shown — accordion panels expand, tabs all show simultaneously. Sign-off boxes render as blank signature lines on the physical print.
Checklist for new WI steps using containers
- ☐ Decide container layout before adding blocks — harder to rearrange blocks across containers once set
- ☐ For TwoColumn: set
zoneName: 'left'andzoneName: 'right'explicitly on every block - ☐ For Accordion/Tab: use meaningful zone names — they appear as panel/tab labels
- ☐ Always put Banner containers first in the step (before instructional content)
- ☐ Keep DataTable
contentDataJSON valid — the component fails silently to empty table if JSON is malformed - ☐ File blocks: verify
fileUrlis accessible from the target user's network before publishing the WI - ☐ Test print view before publishing WI with Accordion/Tab containers