Ga naar hoofdinhoud

CnStagesWidget

The stages a record moves through, as a placeable widget. Clicking a stage moves the record there.

Registered in the widget catalog under the stages type, on the detail-page surface only, and configured by CnStagesWidgetForm. It draws the strip with CnTimelineStages, reads the current stage off the bound record, and moves the record through OpenRegister's lifecycle when a reachable stage is clicked. Nothing about the record type is hard-coded, so a case, a request and a deal all use the same widget with different config.

It is the same contract CnLifecycleActions speaks, rendered differently. That component draws the allowed moves as buttons, this one draws them as a timeline. Both call useLifecycleTransitions, so reading the allowed actions, performing a transition and turning a refusal into a sentence exist once rather than twice.

This replaces the hand-written transition strip an app used to ship as its own component. A hand-written strip cannot be moved, resized or removed by the person who owns the page, and it has to be rewritten in every app that wants one.

Content shape​

{
"currentField": "status",
"stagesEndpoint": {
"url": "/apps/myapp/api/case-types/@object.caseType/blueprint",
"path": "statusTypes",
"orderField": "order",
"finalField": "isFinal"
},
"transition": { "kind": "lifecycle" },
"unreachableReason": "Not possible from the current stage"
}

The stage list​

The lifecycle says what is reachable now. It does not say what the whole process looks like, and a timeline that only showed the next step would not be a timeline. So the list of stages is configured, and it is display only: it grants nothing.

Configure exactly one of two sources.

stagesEndpoint reads an app endpoint: { url, method?, path, params?, idField?, labelField?, descriptionField?, orderField?, finalField? }. The url and the params take the shared token grammar (@objectId, @object.<field>), so /apps/myapp/api/types/@object.type/stages names the record's own type. path points at the array in the response, and method is GET unless the endpoint wants otherwise.

stagesSource reads an OpenRegister query instead: { register, schema, filter?, orderBy?, idField?, labelField?, descriptionField?, finalField?, limit? }. The filter takes the same tokens.

idField names the property holding each stage's id, for a row that does not carry id, @self.id or uuid. finalField marks the stages that close the record, which the timeline draws differently. Neither grants anything.

The read goes through the shared useEndpointSource engine, so two widgets reading one endpoint issue one request, and both refetch on cn:page:refresh.

Which stages can be reached​

GET /apps/openregister/api/objects/{id}/available-actions, the same endpoint CnLifecycleActions reads. It answers { actions: [{ action, to, requires, description, inputs?, blocked? }] } already filtered to the record's current state, so a stage is reachable exactly when an action leads to it.

That is the whole guard, and it is why there is nothing here to configure. There is no allowed flag to read, no field mapping to get backwards, and no config that can remove the guard: a stage no action reaches is disabled because nothing said it was reachable. It fails closed by construction rather than by a check somebody has to remember to write.

description from a reachable action becomes the note beside the stage, so the person can see what the move does before making it. It is not a gate: OpenRegister has already filtered the list, and it re-validates the move.

requires is never shown. OpenRegister copies it verbatim out of the schema annotation, and what apps write there is the dependency-injection tag of the guard class, so putting it beside a stage printed OCA\Learniq\Lifecycle\AdmissionsDecisionGuard at somebody trying to close a case. A move that carries a guard and no description now says nothing at all, which is the honest answer and leaves no empty line under the stage.

A stage no action reaches is dimmed and cannot be clicked. Its reason is not printed beside it. It used to be, and on a live case that meant one generic sentence rendered next to every stage ahead of the record, plus a fourth copy under the strip. Three routes replace the printed copy:

  • hover: the reason is the stage's title.
  • click, tap or Enter: the reason appears once in a note card under the strip.
  • screen reader: a copy stays inside the stage, so the reason is part of what is announced for it, with no interaction at all.

unreachableReason replaces the default wording when an app has better words for its own process. A stage the record has already passed says nothing at all: a reason under a completed stage reads as something having gone wrong with something that already happened. A past stage an action does reach, a reopen, keeps its click and its note like any other move.

A move that is offered but refused​

An action may answer blocked: true, with description as the reason. It is the third case, and the three are three different claims:

The answerWhat it meansWhat the person sees
The action is in the listThe record can move there nowThe stage is clickable, with the move's description beside it
The action is in the list with blocked: trueThe move exists, a guard refuses it right nowThe stage takes the warning colour and an exclamation mark, at full contrast. The guard's own reason is on its title, and a click puts it in a warning note card below the strip
No action reaches the stageNothing moves the record there from hereThe stage is dimmed and stays grey, with unreachableReason or "Not reachable from the current stage" on its title

Without blocked, an app's guard had only two answers to choose between, so "the decision document is missing" arrived as the generic "not reachable from the current stage": a claim about the process, where the truth was about this one record. A blocked stage keeps its place in the strip and only loses its click, and no path through the widget POSTs a move a guard has already refused. A guard that blocks without saying why falls back to "This move is not possible right now", because a refused stage that explains nothing is what this whole branch exists to prevent.

Only this one case is coloured. A stage further down the process stays grey on purpose: if every stage the record cannot reach were orange, a normal timeline would read as a wall of refusals and nothing on it would stand out. Warning is also the level, not error. A move a guard declines is a guarded record, and an error colour there would look like a broken instance.

CnLifecycleActions reads the same endpoint and ignores the key: it maps action, to, description and inputs by name and passes nothing else through, so a blocked action still renders as a button there and the server refuses the POST with its own sentence.

When the guard cannot be read​

A 404 means the schema declares no lifecycle. That is an answer: there are no moves, and each stage says it is not reachable.

A 500, a timeout or a dropped connection is not an answer. The widget says the guard could not be checked, once, in an error note card under the strip, and every stage stays disabled without claiming anything about itself. Saying "not reachable from the current stage" there would state confidently something nobody has checked.

This is the state that gets the error level, and it is the reason a refused stage does not. A failed request is broken; a guarded record is working exactly as its app intends. They must not wear the same colour, or an admin cannot tell the two apart from the card.

What the widget says, and how​

MessageLevelComponent
A stage that cannot be chosen was clickedwarningNcNoteCard type="warning", with role="status" so it is announced
OpenRegister refused the moveerrorNcNoteCard type="error"
The allowed actions could not be readerrorNcNoteCard type="error"
The stages themselves could not be loadederrorNcNoteCard type="error"
No stages to shownoneA plain paragraph. An empty list is not a problem

Every message is a note card, the way the rest of this library reports one. They were paragraphs of coloured text before, which on a card under a timeline read as part of the record rather than as a message about it.

Moving the record​

POST /apps/openregister/api/objects/{id}/transition with { action }, or { action, data } when the action declares inputs: [{ field, required }], mirroring x-openregister-lifecycle.transitions.<action>.inputs.

An action with inputs opens the shared CnTransitionInputDialog first, and cancelling it sends nothing. The confirm button waits until every required field is filled. Pass the record's schema to render each declared field with its own title and type instead of a bare text box.

OpenRegister re-validates the move, and a 403 or 422 is shown where the click happened, in its own words.

After a successful move the widget shows the new stage at once, fires cn:page:refresh so the page re-reads the record, and re-reads the allowed actions for the stage the record is now on. It also emits moved with { stage, action }.

The three modes​

transition selects what a click does.

Absent. Read only. The stages render, nothing is clickable, and no actions are read.

{ kind: 'lifecycle' }, the registry default. The contract above.

{ kind: 'field' }, an explicit opt-in for a record whose schema declares no lifecycle. It writes currentField on the record through the object store, sending the stage change and the record's own properties. Nothing validates this path, so every stage is offered and whatever the timeline shows is what happens. That is why it is not the default.

A kind the widget does not know reads as read only, so a typo cannot silently select a mode nobody asked for.

Props​

PropTypeDefaultDescription
contentobject{}The widget config: currentField, stagesEndpoint or stagesSource, transition, unreachableReason, orientation, size, ariaLabel and variant. variant: "bars" draws a thin bar per step coloured by state (done: primary, current: accent, to do: border), the label under it (ellipsized, with a title), an optional date line from the source's dateField, and no description; the current step carries a visually hidden "(current step)". Theme hooks: --cn-stages-bar-height, --cn-stages-bar-radius, --cn-stages-bar-done, --cn-stages-bar-current, --cn-stages-bar-todo. ariaLabel falls back to label, then to "Stages", so a titled placement names its own strip without repeating the title.
objectDataobject|nullnullThe bound record, when the surface passes it. Falls back to the detail page's injected object context.
objectIdstring|number''The bound record's id, when the surface passes it. Falls back to the injected object context.
objectTypestring''The object-store type slug of the bound record, used by the field transition. Falls back to the context, then to the register and schema of the page.
storeobject|nullnullThe object store to save through on the field opt-in. Falls back to the context's store, then to the shared useObjectStore().
schemaobject|nullnullThe record's JSON Schema, forwarded to the transition input dialog so declared inputs render with their own title and type.
translatefunctionnullTranslate function for the manifest-authored strings. Falls back to the injected cnTranslate, an identity function by default.

Events​

EventPayloadDescription
moved{ stage, action }The record moved to another stage.

Notes​

  • Nothing is clickable before the allowed actions have been read, and a stage says nothing about itself while the answer is unknown. "Not read yet" and "no move allowed" are different states, and collapsing them would both leave the strip clickable for the length of one request and let it make a claim nobody has checked.
  • The list stops being authoritative the moment a re-read starts, not when the replacement lands. That covers a move made somewhere else, by another widget or another person, which reaches the widget with no move of its own in flight.
  • The current stage carries aria-current="step" and nothing else. It is not a move, and it is not blocked either, so it is not announced as blocked and never takes the warning colour. Clicking it does nothing, which stops a stray click re-firing the move that just landed.
  • A vertical strip is top aligned, and the connector runs through the centre of every circle. Both matter once a stage carries a longer sentence than its neighbours: centring slid that one label out of line, and the line that reached upwards by a percentage of the following stage's height slid off the circles.
  • A move in flight keeps every stage's focus stop and marks the stages disabled. Taking the stops away would drop a keyboard user's focus to the page body with nothing to restore it to.
  • A re-read record is authoritative whatever it says, so a call the server accepted without moving anything does not leave the strip claiming a stage.
  • On the field opt-in the save carries the record's own properties, minus the @self envelope and minus anything holding null or {}, which OpenRegister refuses on an object property. An empty list is kept: emptying it was a decision, and dropping it would only be safe if the write replaced rather than merged. A record with no id at all is refused rather than saved, because the save would create a duplicate instead of updating it.
  • The strip carries role="list" with ariaLabel as its name, so a screen reader announces what the stages belong to.

Next: configure a placement with CnStagesWidgetForm, or put the same moves in a button row with CnLifecycleActions.