/** * Organization vocabulary, keyed by organization type and node kind. * * Every hierarchy surface (providers table, row actions, deletion dialogs, * onboarding copy) reads its wording from here instead of branching on a * per-provider boolean. The table is typed `satisfies Record`, so adding an organization type is a compile error until its vocabulary * exists — a new type can never silently inherit AWS wording. */ import { NODE_KIND, NodeKind, ORGANIZATION_TYPE, OrganizationType, } from "@/types/organizations"; interface CandidateNoun { singular: string; plural: string; } interface OrgTypeTerminology { /** Hierarchy container label, used when a node carries no `kind`. */ containerLabel: string; /** Where the provider-side organization/node name comes from. */ nameSourceLabel: string; /** What a discovered candidate is called in the onboarding flow. */ candidateNoun: CandidateNoun; } const ORGANIZATION_TERMINOLOGY = { [ORGANIZATION_TYPE.AWS]: { containerLabel: "Organizational Unit", nameSourceLabel: "AWS", candidateNoun: { singular: "account", plural: "accounts" }, }, [ORGANIZATION_TYPE.AZURE]: { containerLabel: "Management Group", nameSourceLabel: "Azure", candidateNoun: { singular: "subscription", plural: "subscriptions" }, }, [ORGANIZATION_TYPE.GCP]: { containerLabel: "Folder", nameSourceLabel: "Google Cloud", candidateNoun: { singular: "project", plural: "projects" }, }, } as const satisfies Record; const NODE_KIND_LABEL = { [NODE_KIND.ORGANIZATIONAL_UNIT]: "Organizational Unit", [NODE_KIND.FOLDER]: "Folder", } as const satisfies Record; const NODE_KINDS: readonly string[] = Object.values(NODE_KIND); /** * The organization-type enum mirrors a server-side one, so a type this build * doesn't know about can still arrive on the wire. Rendering neutral wording * beats crashing a table cell — or claiming AWS. */ const NEUTRAL_TERMINOLOGY: OrgTypeTerminology = { containerLabel: "Group", nameSourceLabel: "the cloud provider", candidateNoun: { singular: "account", plural: "accounts" }, }; const ORGANIZATION_TYPES: readonly string[] = Object.values(ORGANIZATION_TYPE); // Membership check, not a `??` on the lookup: the tables are object literals, so // an inherited key ("toString") would resolve to a truthy non-string. function terminologyFor(orgType: OrganizationType): OrgTypeTerminology { return ORGANIZATION_TYPES.includes(orgType) ? ORGANIZATION_TERMINOLOGY[orgType] : NEUTRAL_TERMINOLOGY; } /** * Container label for a hierarchy node. `kind` decides when present (canonical * contract); the organization type is the fallback. Never derived from ID * prefixes. */ export function getNodeLabel( orgType: OrganizationType, kind?: NodeKind, ): string { // `kind` is typed but unvalidated: node rows pass the wire attribute through. const knownKind = toNodeKind(kind); return knownKind ? NODE_KIND_LABEL[knownKind] : terminologyFor(orgType).containerLabel; } /** Provider-side source of the organization name (edit-name helper copy). */ export function getNameSourceLabel(orgType: OrganizationType): string { return terminologyFor(orgType).nameSourceLabel; } /** * What discovered candidates are called for this organization type. */ export function getCandidateNoun(orgType: OrganizationType): CandidateNoun { return terminologyFor(orgType).candidateNoun; } /** * Narrows a tree item's opaque `kind` string (the generic `TreeDataItem` carries * no organization types) to a canonical node kind. */ export function toNodeKind(kind?: string): NodeKind | undefined { return kind && NODE_KINDS.includes(kind) ? (kind as NodeKind) : undefined; }