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.

✅
Fully backward-compatible. Existing WI steps that use the old flat 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:

OLD — Flat list
// Step had one flat array of blocks
WiStep {
  contents: [
    { contentType: RichText,  ... },
    { contentType: Image,     ... },
    { contentType: Checklist, ... }
  ]
}
NEW — Containers with blocks
// 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.

Hierarchy in a single step
Step
WiStep
One step in a WI section
→
Container
WiStepContainer
Defines the layout. Multiple per step.
→
Zone
zoneName string
Named column/panel within the container
→
Block
WiStepContent
One content item (text, image, etc.)

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
}
ℹ️
zoneName defaults to '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
WiContainerType.Single = 0
Block 1
Block 2
Block 3

Blocks stack vertically in a single column. CSS: flex-direction: column. Use for default step content, warnings, and checklists.

TwoColumn
WiContainerType.TwoColumn = 1
Left zone
Block 1
Right zone
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
WiContainerType.Banner = 2
Full-width Banner Block

Full-width container, position: relative. Designed to hold a single WiContentBannerComponent block. The banner block applies its own background image and caption overlay.

Accordion
WiContainerType.Accordion = 3
▶ Zone A — collapsed
▼ Zone B — open, blocks here
▶ Zone C — collapsed

Each unique zoneName becomes one accordion panel. Collapses to a single click-to-expand section. In print mode, all zones expand automatically.

Tab
WiContainerType.Tab = 4
Panel 1
Panel 2
Panel 3
Active tab content

Each unique zoneName becomes one tab. Only the active tab's blocks are visible. In print mode, all tabs are shown as sequential sections.

⚠️
Accordion and Tab containers use zoneName as the panel label. Name zones descriptively: '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 typeZone namesNotes
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.
AccordionAny descriptive nameEach unique name = one accordion panel. Label is used as the panel header.
TabAny descriptive nameEach 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.

Renderer component tree
Layer 1
ContentDocumentRenderer
@Input containers[]
Iterates containers. No layout logic.
→
Layer 2 — layout lives here
ContentContainerRenderer
@Input container
Extracts zones, applies CSS layout class, calls layer 3 per block.
→
Layer 3
ContentBlockRenderer
@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

ContentTypeValueKey contentData fields
RichText0{ html, plainText? }
Image1{ imageUrl, caption?, altText? }
Video2{ videoUrl, provider, thumbnail?, duration? }
PDF3{ pdfUrl, pageNumber? }
Checklist4{ title?, items: [{ id, label, required }], allowPartial }
Warning5{ message, warningLevel }
DecisionBranch6{ 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 ?? [] }];
}
✅
No migration needed. Legacy blocks get containerType: 0 (Single) and zoneName defaults to 'main' — they render exactly as they always did, just through the new renderer pipeline.
⚠️
New backend responses should use 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

  • 1
    Fetches full detail

    Calls WiDbService.getWiFullDetail(wiId) on init. Stores result in a signal.

  • 2
    Renders header block

    Shows WI title, code, version, and effective date at the top of the document.

  • 3
    Iterates sections → steps

    Uses stepsForSection(sectionId) to group steps under their section heading.

  • 4
    Renders each step via ContentDocumentRenderer

    Calls stepContainers(step) (backward-compat wrapper) to get containers, passes to <content-document-renderer>.

  • 5
    Sign-off boxes

    For steps with requireSignOff === 1, a printed signature line and date field are added below the step content.

  • 6
    Print 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 typePrint behaviour
SingleRenders normally — flex column.
TwoColumnKeeps two-column grid (if paper width allows).
BannerBackground image removed; border added for ink safety. Caption still shown.
AccordionAll panels forced to display: block — all content visible without interaction.
TabAll 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

  • 1
    Keyword search (debounced 300 ms)

    Triggers CmsDbService.searchContent(text, spaceId, typeId). Automatically switches to getContentList() with filters when the search box is cleared.

  • 2
    Status 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.

  • 3
    Space and Content Type dropdowns

    Pass spaceId=-1 and contentTypeId=-1 to show all. Select a specific space or type to narrow the list.

  • 4
    Grid / List view toggle

    Grid view shows cards; list view shows a compact table. State is stored in the viewMode signal — 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.

Editor tabs
Tab 1
Fields
Title, space, type, metadata
Tab 2
Layout
Add containers + blocks, drag order
Tab 3
Preview
Live rendered output
Tab 4
Metadata
Tags, SEO, taxonomy

Layout tab — how to author a document

  1. 1
    Add a Container

    Click "Add Container" and choose a container type from the dropdown. The new container appears in the canvas. Each container gets a ContentContainerId from the backend on save.

  2. 2
    Add 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.

  3. 3
    Set Zone (for multi-zone containers)

    For TwoColumn, Accordion, and Tab containers, enter the zoneName when adding a block. For TwoColumn use left or right. For Accordion/Tab use any descriptive name.

  4. 4
    Edit block props — auto-saved

    Click any block in the canvas to select it. The properties panel on the right shows the block's propsJson as an editable JSON field. Changes are debounced 2 seconds and automatically saved via CmsEditorService.

  5. 5
    Reorder containers

    Containers can be reordered by dragging. The backend API Cms.ContentContainer.Reorder is 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()
ℹ️
Immutable update pattern. 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.

Status flow
Start
Draft
Editable, not visible in Portal
→
Action
In Review
SubmitForReview
→
Action
Approved
ApproveContent
→
Live
Published
PublishContent
Visible in Portal
→
End
Archived
ArchiveContent

Version 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:

TypeCodeUse case
Notification0Push a news or announcement to a group. Content opens in the Portal.
Assignment1Assign a content item to users who must read/complete it. Tracks per-user completion status.
Learning Path2Assign 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 ?? [] }];
}
⚠️
Do not render blocks directly. Always go through 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):

  1. 1
    Define the enum value

    Add to ContentType in wi/src/lib/enums/wi.enums.ts. Pick the next available integer.

  2. 2
    Define the content data interface

    Add a QRCodeContent interface to wi/src/lib/models/wi.models.ts with all fields the block needs.

  3. 3
    Create 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>();
    }
  4. 4
    Register in ContentBlockRendererComponent

    Open wi/src/lib/renderer/content-block-renderer.component.ts. Add the import and a new @case in 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()" /> }
  5. 5
    Add to the Block Library (CMS side)

    In cms/master/blocklibrary/blocklibrary.component.ts, add an entry to CANONICAL_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

  1. 1
    Create 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.

  2. 2
    Set 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.

  3. 3
    Set up taxonomy

    Go to CMS → Taxonomy (OntologyService). Add taxonomy nodes that authors will tag content with. These are searchable from the Catalogue.

  4. 4
    Set 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

TaskWhereNotes
Create campaignCMS → Campaigns → NewSet type, audience group, and link content or learning path.
Send campaignCampaign detail → Send buttonCalls Cms.Campaign.SendCampaign. Irreversible — content is dispatched to all recipients.
Track assignmentsCampaign detail → Assignments tabLists every user's assignment status. Admin can manually update status via UpdateAssignmentStatus.
Close campaignCampaign detail → Close buttonMoves to Closed status. Past assignment records are retained for audit.
🚫
Sending a campaign is irreversible. Verify the content, audience list, and campaign type carefully before clicking Send. There is no unsend or recall.

19 · Admin — Work Instruction Administration Tasks

Creating a WI step with a multi-column layout

  1. 1
    Open the WI editor

    Navigate to the WI detail → Steps tab. Select or create the step.

  2. 2
    Add a TwoColumn container

    In the step's content editor, click "Add Container" → select "Two Column". A new container row appears in the canvas.

  3. 3
    Add blocks to each zone

    Click into the left zone → Add Block → Image. Set zone name to left. Repeat for the right zone with zone name right.

  4. 4
    Set images for each block

    Click each image block → edit props → set imageUrl, caption, altText. Changes auto-save after 2 seconds.

  5. 5
    Preview the step

    Click Preview in the step editor to see the rendered output before publishing.

Adding a Safety Banner to a step

  1. 1
    Add 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.

  2. 2
    Add a Banner block

    Inside the Banner container, add one Banner block. Set imageUrl for the background and message for the caption overlay.

  3. 3
    Position 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.

ℹ️
Print behaviour for banners: In print mode, the background image is removed and replaced with a border outline, with the caption text still visible. This ensures the safety message is legible in black-and-white print.

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' and zoneName: '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 contentData JSON valid — the component fails silently to empty table if JSON is malformed
  • ☐ File blocks: verify fileUrl is accessible from the target user's network before publishing the WI
  • ☐ Test print view before publishing WI with Accordion/Tab containers