"use client" /** * The properties pane: whatever one node is selected on the canvas. * * Exactly one thing is editable at a time, and it is the thing you clicked. That * is the fix for the previous builder, where every flow, step, and option could be * expanded at once and the page became a wall of fields with no indication of * where you were. * * Nothing here is a surprise: the canvas already told you what this node does, so * the pane is only the controls behind that sentence. */ import * as React from "react" import { Button } from "@/components/ui/button" import { Checkbox, CheckboxLabel } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { RadioGroup, RadioGroupItem, RadioGroupLabel } from "@/components/ui/radio-group" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Separator } from "@/components/ui/separator" import { ConditionRows } from "@/components/builder/condition-rows" import type { CanvasSelection } from "@/components/builder/flow-canvas" import { AUTH_METHOD_LABEL, COMMON_LANDING_PATHS, GRANTABLE_PRODUCTS, WORKSPACE_ROLES, createChoiceOption, flowSession, reachesEnd, type AuthMethod, type ChoiceOption, type ChoiceStep, type FlowSession, type FlowStep, type LoginFlowDefinition, type WorkspaceRole, } from "@/lib/login-flow" import { isStudentHomePath } from "@/lib/student-shell" import type { Product } from "@exxatdesignux/product-framework" /** `Select` values are strings, so "no product" needs a stand-in. */ const NO_PRODUCT = "none" /** Likewise "this branch names nobody, so the flow's own role stands". */ const FLOW_ROLE = "flow" /** * What each role costs, in the terms the author is about to check. * * Member and student read differently on purpose. Both deny the console, but a * member is staff who simply has no console, while a student is why the choice is * a choice: there is no way to tick administrator back on for them. */ const ROLE_HINT: Record = { administrator: "Gets the Administrator console: the home tile, the switcher row, and /admin.", member: "No Administrator console. The tile and the switcher row go, and /admin sends them home.", student: "Never administers. The console is closed to students whatever else this flow grants.", } /** * What the window is titled. The title lives in the window chrome rather than the * body, so the node you picked is named once, in the place that stays put while * you scroll the fields. */ export function selectionLabel( flow: LoginFlowDefinition, selection: CanvasSelection, ): { title: string; meta: string } | null { if (selection.kind === "flow") return null if (selection.kind === "session") { return { title: "Signed in", meta: "What this flow hands the session" } } const index = flow.steps.findIndex(step => step.id === selection.stepId) const step = flow.steps[index] if (!step) return null if (selection.kind === "option" && step.kind === "choice") { const option = step.options.find(current => current.id === selection.optionId) if (option) { return { title: option.label || "Untitled branch", meta: `Branch of "${step.heading}"`, } } } return { title: step.kind === "auth" ? AUTH_METHOD_LABEL[step.method] : "Choice", meta: `Step ${index + 2}`, } } /** * The delete action for whatever is selected, as a value rather than a callback. * * Pure so the window chrome can render the button, its label, and its reason for * being disabled without knowing which kind of node it is looking at, and without * the pane and the chrome each holding half of the same edit. */ export function deleteTarget( flow: LoginFlowDefinition, selection: CanvasSelection, ): { label: string disabledReason: string | null next: LoginFlowDefinition nextSelection: CanvasSelection } | null { // Neither the flow nor the end of it is a thing you can remove: every flow ends // somewhere, so the session node has settings but no delete. if (selection.kind === "flow" || selection.kind === "session") return null const index = flow.steps.findIndex(step => step.id === selection.stepId) const step = flow.steps[index] if (!step) return null if (selection.kind === "option" && step.kind === "choice") { const remaining = step.options.filter(option => option.id !== selection.optionId) return { label: "Delete branch", // Two is what makes it a choice. One branch is a question with one answer. disabledReason: remaining.length >= 2 ? null : "A choice needs at least two branches", next: { ...flow, steps: flow.steps.map((current, i) => i === index ? { ...step, options: remaining } : current, ), }, nextSelection: { kind: "step", stepId: step.id }, } } return { label: "Delete step", disabledReason: null, next: { ...flow, steps: flow.steps.filter((_, i) => i !== index) }, nextSelection: { kind: "flow" }, } } /** * What the flow hands the session: which apps, where it lands, and which of the * home sections it asks for. * * Checkboxes rather than switches for all of it. A switch in this design system * labels itself "On" or "Off", so three of them in a column would announce as * three controls with the same name; a checkbox takes its name from the label * beside it, which is what a properties form wants anyway. * * "Apps" is a set rather than a single select because two apps is a real answer: * a student with Clinical Education and Exxat One is not the same person as a * student with one of them, and the old single-grant field could not say it. */ function SessionPane({ flow, onChangeFlow, }: { flow: LoginFlowDefinition onChangeFlow: (next: LoginFlowDefinition) => void }) { const session = flowSession(flow) const branchesDecide = !reachesEnd(flow) function update(next: Partial) { onChangeFlow({ ...flow, session: { ...session, ...next } }) } function toggleProduct(product: Product, on: boolean) { const current = session.products ?? [] const next = on ? [...current, product] : current.filter(entry => entry !== product) update({ products: next.length > 0 ? next : null }) } return (
Apps {GRANTABLE_PRODUCTS.map(entry => { const id = `session-${entry.value}` return (
toggleProduct(entry.value, checked === true)} /> {entry.label}
) })}

{session.products ? "Everything else moves to More from Exxat." : "None checked, so this flow leaves the workspace apps alone."}

update({ landing: event.target.value })} list="session-landing-paths" placeholder="Products home" /> {COMMON_LANDING_PATHS.map(entry => ( ))}

{branchesDecide ? "Every branch in this flow lands somewhere of its own, so this is only a fallback." : "Leave it blank for the products home, or onboarding on a first run."}

Home shows
update({ showYourApp: checked === true })} /> Your App
update({ showMoreFromExxat: checked === true })} /> More from Exxat
{/* Its own group, not a third row under "Home shows". This is not about what a page displays: it decides whether the console exists for this session. One choice rather than an "administers the workspace" checkbox, because a checkbox beside a student landing let both be true, and a student who administers the workspace is the one thing this flow must not be able to say. */}
Signs in as update({ role: value as WorkspaceRole })} className="flex flex-col gap-2" aria-label="Signs in as" > {WORKSPACE_ROLES.map(entry => (
{entry.label}
))}

{ROLE_HINT[session.role]}

{/* Inside the role group, because it is an amendment to the answer above rather than a fourth thing the flow hands out: this person is a student *and* may walk in as the school instead. Not offered to an administrator, and not for tidiness: the pair is the two identities that deny the console, so pairing one with the console is the combination the role field exists to make unwritable. */} {session.role === "administrator" ? null : (
1} onCheckedChange={checked => update({ opensAs: checked === true ? ["student", "member"] : [] }) } /> Also opens as {session.role === "student" ? "school" : "a student"}

{session.opensAs.length > 1 ? "The product card offers both doors, so they choose each time they open the app. Neither door gets the console." : "One identity. The product card keeps a single Open."}

)}
) } function OptionPane({ flow, step, stepIndex, option, onChangeStep, }: { flow: LoginFlowDefinition step: ChoiceStep stepIndex: number option: ChoiceOption onChangeStep: (next: ChoiceStep) => void }) { const base = `option-${option.id}` const landing = option.outcome.kind === "land" ? option.outcome.path : "" function update(next: ChoiceOption) { onChangeStep({ ...step, options: step.options.map(current => (current.id === option.id ? next : current)), }) } return (
update({ ...option, label: event.target.value })} />
update({ ...option, description: event.target.value })} placeholder="Optional. Shown under the label." />
update({ ...option, icon: event.target.value })} placeholder="fa-light fa-graduation-cap" />

Granting one app moves the others to More from Exxat.

{/* The second thing a branch can hand the session, next to the first. Most branches leave it alone: they ask which app or which door, not which person. It exists for the flow where one human has two identities in the program and only they know which one today. */}

{isStudentHomePath(landing) ? "The student home signs in as a student whatever this says." : "Two branches that land in the same place can still open as different people."}

{option.outcome.kind === "land" ? (
update({ ...option, outcome: { kind: "land", path: event.target.value } }) } list={`${base}-paths`} placeholder="/home" /> {/* A datalist rather than a Select: the common targets are worth offering, but any internal path is valid and the point of a builder is not having to ask an engineer to add one. */} {COMMON_LANDING_PATHS.map(entry => ( ))}
) : null} update({ ...option, showWhen })} />
) } function StepPane({ flow, step, stepIndex, onChange, }: { flow: LoginFlowDefinition step: FlowStep stepIndex: number onChange: (next: FlowStep) => void }) { return (
{step.kind === "auth" ? (

Both are mock. Single sign-on shows the provider handoff, not a form.

) : ( <>
onChange({ ...step, heading: event.target.value })} />