681 lines
26 KiB
JavaScript
681 lines
26 KiB
JavaScript
/**
|
||
* What a skill definition is allowed to say about the product.
|
||
*
|
||
* A skill can extend a KROW page: name a surface, declare a section, and the
|
||
* page renders it. That only stays safe — and only stays a *product* rather
|
||
* than a scripting host — because the vocabulary is closed. Everything a
|
||
* definition may name is in this file: the surfaces, the placements on each
|
||
* surface, the component types, and the data sources.
|
||
*
|
||
* The rule that makes it safe: **nothing here is code, and nothing here is
|
||
* looked up dynamically from the file.** A definition names a key; this module
|
||
* says whether that key exists; the renderer maps it to a component the app
|
||
* already ships. A definition that names something absent is rejected with a
|
||
* message, never rendered as an unknown thing and never executed.
|
||
*/
|
||
|
||
/**
|
||
* The surfaces a skill can extend.
|
||
*
|
||
* `aliases` keep the page keys the existing skills already use — `hired`,
|
||
* `university` — working under the names the product now shows, so the eight
|
||
* definitions on disk did not have to be rewritten to gain this feature.
|
||
*/
|
||
export const SKILL_SURFACES = [
|
||
{
|
||
id: 'control-center',
|
||
label: 'Control Center',
|
||
route: '/admin',
|
||
placements: ['after-header', 'before-footer'],
|
||
provides: {},
|
||
},
|
||
{
|
||
id: 'positions',
|
||
label: 'Positions',
|
||
route: '/admin/positions',
|
||
/* Mounted in three places, because Positions is three experiences: the page
|
||
that lists the roles, the card for each one, and the drawer "View
|
||
position" opens.
|
||
|
||
`after-position-list-summary` and `after-position-list` are the *page*:
|
||
they render once, above and below the grid, with no position in context.
|
||
`after-position-card` renders inside each card. The rest render inside
|
||
the drawer and on the full position page, where one position is being
|
||
read. Every one of these is mounted — a placement the vocabulary offers
|
||
and no page provides is a definition that validates and then silently
|
||
does nothing. */
|
||
placements: [
|
||
'after-position-list-summary',
|
||
'after-position-list',
|
||
'after-header',
|
||
'after-position-card',
|
||
'after-position-summary',
|
||
'before-candidates',
|
||
'after-candidates',
|
||
'before-footer',
|
||
],
|
||
/**
|
||
* What each of those placements actually hands a section, read off the
|
||
* `<SkillSurface>` call sites rather than assumed from the surface.
|
||
*
|
||
* The distinction is the whole point: the two list placements render above
|
||
* and below the grid with no position in context, while every other one
|
||
* renders inside a card, a drawer or the position page and passes the
|
||
* record. A section reading `position.*` is answerable at one and not at
|
||
* the other, and until this was written down both validated identically.
|
||
*/
|
||
provides: {
|
||
'after-position-list-summary': [],
|
||
'after-position-list': [],
|
||
'after-header': ['positionId'],
|
||
'after-position-card': ['positionId'],
|
||
'after-position-summary': ['positionId'],
|
||
'before-candidates': ['positionId'],
|
||
'after-candidates': ['positionId'],
|
||
'before-footer': ['positionId'],
|
||
},
|
||
},
|
||
{
|
||
/* The authoring form, which is a surface in its own right: what a skill has
|
||
to say there is about the position being specified, not about the ones
|
||
that already exist. Its placements follow the form's own three parts, so
|
||
a definition can sit beside the field group it is about. */
|
||
id: 'create-position',
|
||
label: 'Create Position',
|
||
route: '/admin/positions/new',
|
||
aliases: ['new-position'],
|
||
placements: [
|
||
'after-header',
|
||
'after-job-description',
|
||
'after-vetting-weights',
|
||
'before-footer',
|
||
],
|
||
/* The draft in the form is the position, so every placement here supplies
|
||
one — which is what lets `position.vetting` be read and written while the
|
||
role is still being specified. */
|
||
provides: {
|
||
'after-header': ['positionId'],
|
||
'after-job-description': ['positionId'],
|
||
'after-vetting-weights': ['positionId'],
|
||
'before-footer': ['positionId'],
|
||
},
|
||
},
|
||
{
|
||
id: 'candidates',
|
||
label: 'Candidates',
|
||
route: '/admin/candidates',
|
||
placements: ['after-header', 'after-candidate-summary', 'before-footer'],
|
||
/* Only the summary placement renders against one person — it is mounted on
|
||
the candidate profile, not on the list. */
|
||
provides: { 'after-candidate-summary': ['candidateId'] },
|
||
},
|
||
{
|
||
id: 'hired-history',
|
||
label: 'Hired History',
|
||
route: '/admin/hired',
|
||
aliases: ['hired'],
|
||
placements: ['after-header', 'before-footer'],
|
||
/* `before-footer` is inside the record drawer; `after-header` is the page. */
|
||
provides: { 'before-footer': ['candidateId'] },
|
||
},
|
||
{
|
||
id: 'talent-pool',
|
||
label: 'Talent Pool',
|
||
route: '/admin/talent-pool',
|
||
placements: ['after-header', 'before-footer'],
|
||
provides: {},
|
||
},
|
||
{
|
||
id: 'krow-forge',
|
||
label: 'KROW Forge',
|
||
route: '/admin/university',
|
||
aliases: ['university', 'forge'],
|
||
placements: ['after-header', 'before-footer'],
|
||
provides: {},
|
||
},
|
||
{
|
||
id: 'analytics',
|
||
label: 'Analytics',
|
||
route: '/admin/analytics',
|
||
placements: ['after-header', 'before-footer'],
|
||
provides: {},
|
||
},
|
||
{
|
||
id: 'activity',
|
||
label: 'Activity',
|
||
route: '/admin/activity',
|
||
placements: ['after-header', 'before-footer'],
|
||
provides: {},
|
||
},
|
||
/* Not in the eight product surfaces, but skills already attach to it and the
|
||
account page reads them. Kept so nothing that works today stops working. */
|
||
{
|
||
id: 'profile',
|
||
label: 'Profile',
|
||
route: '/admin/profile',
|
||
placements: ['after-header', 'before-footer'],
|
||
provides: {},
|
||
},
|
||
{
|
||
id: 'candidates-analysis',
|
||
label: 'Candidate Analysis',
|
||
route: '/admin/candidates-analysis',
|
||
placements: ['after-header', 'before-footer'],
|
||
provides: {},
|
||
},
|
||
];
|
||
|
||
const BY_KEY = new Map();
|
||
for (const surface of SKILL_SURFACES) {
|
||
BY_KEY.set(surface.id, surface);
|
||
for (const alias of surface.aliases || []) BY_KEY.set(alias, surface);
|
||
}
|
||
|
||
/** Every name a definition may use for a surface, for error messages. */
|
||
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
|
||
|
||
/**
|
||
* The surface a declared page name refers to, or null.
|
||
*
|
||
* Matched on the normalized name, not the literal one. A definition writing
|
||
* `Positions` or `Talent Pool` — which is how the page is spelled everywhere in
|
||
* the product — used to resolve to nothing, and a page name that resolves to
|
||
* nothing takes the whole `ui:` block with it. The keys stay canonical; only
|
||
* what an author may type to reach them widens.
|
||
*/
|
||
const normalizeKey = (page) => String(page ?? '')
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[\s_]+/g, '-');
|
||
|
||
export const surfaceFor = (page) => BY_KEY.get(normalizeKey(page)) || null;
|
||
|
||
/**
|
||
* Placement names a definition may use, beyond the canonical ones.
|
||
*
|
||
* A placement is a position in a page's layout, and the canonical names are
|
||
* written from the page's point of view — `after-position-card`. Authors write
|
||
* them from the definition's: `grid-card` is where it goes, `panel` is what it
|
||
* looks like. An alias only ever resolves to a placement the surface really
|
||
* offers, so the vocabulary stays exactly as closed as it was; what changes is
|
||
* how many ways there are to name a member of it.
|
||
*/
|
||
const PLACEMENT_ALIASES = {
|
||
'grid-card': 'after-position-card',
|
||
card: 'after-position-card',
|
||
panel: 'after-header',
|
||
top: 'after-header',
|
||
header: 'after-header',
|
||
footer: 'before-footer',
|
||
bottom: 'before-footer',
|
||
list: 'after-position-list',
|
||
summary: 'after-position-summary',
|
||
};
|
||
|
||
/**
|
||
* The canonical placement a declared name refers to on this surface, or null.
|
||
*
|
||
* An alias that points at a placement this surface does not offer resolves to
|
||
* null rather than to some other surface's placement — `panel` means
|
||
* `after-header` where there is one and nothing where there is not.
|
||
*/
|
||
export function placementFor(page, placement) {
|
||
const surface = surfaceFor(page);
|
||
if (!surface) return null;
|
||
|
||
const declared = normalizeKey(placement);
|
||
if (!declared) return null;
|
||
if (surface.placements.includes(declared)) return declared;
|
||
|
||
const aliased = PLACEMENT_ALIASES[declared];
|
||
return aliased && surface.placements.includes(aliased) ? aliased : null;
|
||
}
|
||
|
||
/** The canonical id for a declared page name — `hired` → `hired-history`. */
|
||
export const canonicalPage = (page) => surfaceFor(page)?.id || null;
|
||
|
||
/**
|
||
* What a placement hands a section, as the page actually mounts it.
|
||
*
|
||
* A source declares the record it needs — a position, a candidate, or nothing —
|
||
* and until this existed nothing compared that need against the pages a
|
||
* definition named. So a section reading `position.activity` could be attached
|
||
* to Analytics, validate cleanly, register, draw its title, and then report
|
||
* "This section needs a position to read" forever. That is the whole of the
|
||
* "the skill saved but does nothing" report, and it is an authoring mistake the
|
||
* product can catch rather than one the reader has to discover.
|
||
*
|
||
* Read per *placement*, never per surface: the Positions grid renders one
|
||
* section inside each card, with a position, and two more above and below the
|
||
* grid, with none. Both are `positions`.
|
||
*
|
||
* With no placement named, a definition is answered for the surface's default —
|
||
* `surface.placements[0]`, which is what `normalizeSection` fills in.
|
||
*/
|
||
export function placementProvides(page, placement = null) {
|
||
const surface = surfaceFor(page);
|
||
if (!surface) return [];
|
||
const at = String(placement || '').trim() || surface.placements[0];
|
||
return surface.provides?.[at] || [];
|
||
}
|
||
|
||
/**
|
||
* Everything the declared pages can supply at this placement, deduplicated.
|
||
*
|
||
* One page supplying a position is enough — a definition naming several pages
|
||
* is saying it belongs on all of them, and refusing it because one cannot
|
||
* answer would refuse the definition for the pages that can. The load-time
|
||
* diagnostic is where the partial case is reported.
|
||
*/
|
||
export function contextSuppliedBy(pages = [], placement = null) {
|
||
return [...new Set(
|
||
(Array.isArray(pages) ? pages : [pages])
|
||
.flatMap((page) => placementProvides(page, placement))
|
||
)];
|
||
}
|
||
|
||
/** "a position" / "a candidate", for a message an author can act on. */
|
||
export const contextLabel = (need) => (
|
||
need === 'positionId' ? 'a position' : need === 'candidateId' ? 'a candidate' : 'nothing'
|
||
);
|
||
|
||
/**
|
||
* The surface an Admin route belongs to — `/admin/positions/new` →
|
||
* `create-position`.
|
||
*
|
||
* The surfaces already carry their routes, so this reads the answer off the
|
||
* table rather than deriving a page key from the path a second time. That
|
||
* matters for the surfaces whose key is not their path tail: without it,
|
||
* `/admin/positions/new` would key as `positions/new`, which is a page nothing
|
||
* declares and no definition could attach to.
|
||
*/
|
||
export const surfaceForRoute = (route) =>
|
||
SKILL_SURFACES.find((s) => s.route === String(route || '').trim()) || null;
|
||
|
||
/**
|
||
* The section types a definition may ask for.
|
||
*
|
||
* Each entry names a component the application already ships. A type is a key
|
||
* in this table and nothing else: there is no path from a definition to a
|
||
* component that is not listed here, which is what stops `type:` from being an
|
||
* import statement in disguise.
|
||
*/
|
||
export const SECTION_TYPES = [
|
||
{ id: 'card', label: 'Card', summary: 'A titled panel of prose and figures.' },
|
||
{ id: 'stats', label: 'Stats', summary: 'A row of counted figures.' },
|
||
{ id: 'list', label: 'List', summary: 'A ranked or plain list of records.' },
|
||
{ id: 'timeline', label: 'Timeline', summary: 'Dated events, most recent first.' },
|
||
{ id: 'flow', label: 'Flow', summary: 'A sequence of stages or periods.' },
|
||
{ id: 'table', label: 'Table', summary: 'Rows and columns.' },
|
||
{ id: 'progress', label: 'Progress', summary: 'Bars against a total.' },
|
||
{ id: 'insight', label: 'Insight', summary: 'One finding, stated plainly.' },
|
||
/* Weighted criteria that share a budget. Distinct from `progress`, which is
|
||
bars against an independent maximum: these bars are shares of one total,
|
||
and the total is a fact about the set rather than about any one row. */
|
||
{ id: 'weights', label: 'Weights', summary: 'Weighted criteria as shares of one total.' },
|
||
];
|
||
|
||
export const SUPPORTED_SECTION_TYPES = SECTION_TYPES.map((t) => t.id);
|
||
|
||
/**
|
||
* What a definition may ask *Owliver* to do with the same reading.
|
||
*
|
||
* A skill declares `ui:` for the page and `owliver:` for the panel, and both
|
||
* name the same data source. A capability is the second half of that: the shape
|
||
* the answer takes when it is asked for in conversation rather than rendered on
|
||
* the page.
|
||
*
|
||
* `shape` is the section type the answer is drawn with, so `flow` in a chat
|
||
* reply is the *same* component the page renders — there is one flow renderer,
|
||
* not one per consumer. `summary` has no shape because a summary is prose: the
|
||
* figures are read back as sentences rather than drawn.
|
||
*
|
||
* `terms` are how a question is recognised as asking for this shape. They are
|
||
* deliberately about the *shape* and never about a subject: "as a flow" belongs
|
||
* here, "hiring activity" belongs in a definition's `triggers`. That split is
|
||
* what keeps this table closed while the skills stay open.
|
||
*/
|
||
export const OWLIVER_CAPABILITIES = [
|
||
{
|
||
id: 'summary',
|
||
label: 'Summary',
|
||
shape: null,
|
||
summary: 'Reads the figures back as sentences.',
|
||
terms: ['summary', 'summarise', 'summarize', 'summarised', 'summarized', 'summarising',
|
||
'summarizing', 'sum up', 'recap', 'overview', 'brief me', 'in short', 'tell me about',
|
||
'what is the', 'how is'],
|
||
},
|
||
{
|
||
id: 'flow',
|
||
label: 'Flow',
|
||
shape: 'flow',
|
||
summary: 'Draws the stages or periods as a sequence.',
|
||
terms: ['flow', 'as a flow', 'chart', 'graph', 'diagram', 'funnel', 'stages', 'visual',
|
||
'visualise', 'visualize', 'step by step'],
|
||
},
|
||
{
|
||
id: 'stats',
|
||
label: 'Stats',
|
||
shape: 'stats',
|
||
summary: 'A row of counted figures.',
|
||
terms: ['stats', 'statistics', 'figures', 'numbers', 'counts', 'how many'],
|
||
},
|
||
{
|
||
id: 'list',
|
||
label: 'List',
|
||
shape: 'list',
|
||
summary: 'A ranked or plain list of records.',
|
||
terms: ['list', 'who are', 'which ones', 'show me the records'],
|
||
},
|
||
{
|
||
id: 'table',
|
||
label: 'Table',
|
||
shape: 'table',
|
||
summary: 'Rows and columns.',
|
||
terms: ['table', 'as a table', 'rows', 'grid', 'spreadsheet'],
|
||
},
|
||
{
|
||
id: 'timeline',
|
||
label: 'Timeline',
|
||
shape: 'timeline',
|
||
summary: 'Dated events, most recent first.',
|
||
terms: ['timeline', 'history', 'chronology', 'over time', 'what happened'],
|
||
},
|
||
{
|
||
id: 'progress',
|
||
label: 'Progress',
|
||
shape: 'progress',
|
||
summary: 'Bars against a total.',
|
||
terms: ['progress', 'bars', 'completion', 'how far'],
|
||
},
|
||
{
|
||
id: 'weights',
|
||
label: 'Weights',
|
||
shape: 'weights',
|
||
summary: 'The weighted criteria, adjustable when the page accepts the write.',
|
||
terms: ['weight', 'weights', 'weighting', 'weightings', 'importance', 'balance',
|
||
'set the weights', 'adjust the weights', 'screening weight', 'vetting weight',
|
||
'criteria'],
|
||
},
|
||
{
|
||
id: 'insight',
|
||
label: 'Insight',
|
||
shape: 'insight',
|
||
summary: 'One finding, stated plainly.',
|
||
terms: ['insight', 'finding', 'takeaway', 'headline', 'what stands out'],
|
||
},
|
||
{
|
||
id: 'card',
|
||
label: 'Card',
|
||
shape: 'card',
|
||
summary: 'A titled panel of figures.',
|
||
terms: ['card', 'panel', 'at a glance'],
|
||
},
|
||
];
|
||
|
||
export const SUPPORTED_OWLIVER_CAPABILITIES = OWLIVER_CAPABILITIES.map((c) => c.id);
|
||
|
||
/** The capability a declared name refers to, or null. */
|
||
export const owliverCapabilityFor = (id) =>
|
||
OWLIVER_CAPABILITIES.find((c) => c.id === String(id || '').trim()) || null;
|
||
|
||
/** `flow` → `Flow`. */
|
||
export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label || id;
|
||
|
||
/**
|
||
* The data a section may ask for.
|
||
*
|
||
* Each source is a named reading of data the application already holds, and
|
||
* `context` says what a page must know for the reading to be possible — a
|
||
* position id, a candidate id, or nothing. A source is resolved by
|
||
* `dataResolver.js`; a definition cannot reach a store directly, cannot write,
|
||
* and cannot name a field that is not offered here.
|
||
*/
|
||
export const DATA_SOURCES = [
|
||
{
|
||
id: 'position.activity',
|
||
label: 'Position activity',
|
||
context: 'positionId',
|
||
summary: 'Applications to this position, counted over time.',
|
||
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
|
||
options: ['periods'],
|
||
},
|
||
{
|
||
id: 'position.pipeline',
|
||
label: 'Position pipeline',
|
||
context: 'positionId',
|
||
summary: 'Applied → screened → shortlisted → interviewed → hired.',
|
||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||
},
|
||
{
|
||
id: 'position.candidates',
|
||
label: 'Position candidates',
|
||
context: 'positionId',
|
||
summary: 'Candidates matched to this position, best first.',
|
||
shapes: ['list', 'table', 'stats', 'card'],
|
||
options: ['limit'],
|
||
},
|
||
{
|
||
/**
|
||
* Who could actually do this work, ranked.
|
||
*
|
||
* Distinct from `position.candidates`, which lists the people who *applied
|
||
* here*. This one reads the whole candidate pool against what the position
|
||
* states — so a role published a minute ago, with no applications at all,
|
||
* still has an answer. The reading is `poolFor` in `lib/workforce.js`, the
|
||
* same engine the panel's workforce conversation and the position page use;
|
||
* nothing about matching is decided in the resolver.
|
||
*/
|
||
id: 'position.matches',
|
||
label: 'Position candidate matches',
|
||
context: 'positionId',
|
||
summary: 'The candidate pool scored against this position, best first.',
|
||
shapes: ['list', 'table', 'stats', 'card', 'insight'],
|
||
options: ['limit'],
|
||
},
|
||
{
|
||
id: 'position.requirements',
|
||
label: 'Position requirements',
|
||
context: 'positionId',
|
||
summary: 'What this position states it needs.',
|
||
shapes: ['list', 'table', 'card'],
|
||
},
|
||
{
|
||
id: 'candidate.readiness',
|
||
label: 'Candidate readiness',
|
||
context: 'candidateId',
|
||
summary: 'Screening dimensions for one candidate.',
|
||
shapes: ['progress', 'stats', 'list', 'table', 'card'],
|
||
},
|
||
{
|
||
id: 'candidate.activity',
|
||
label: 'Candidate activity',
|
||
context: 'candidateId',
|
||
summary: 'What has happened on this candidate’s record.',
|
||
shapes: ['timeline', 'list', 'table', 'card'],
|
||
},
|
||
{
|
||
id: 'candidates.pipeline',
|
||
label: 'Candidate pipeline',
|
||
context: null,
|
||
summary: 'Every candidate, counted by stage.',
|
||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||
},
|
||
{
|
||
id: 'candidates.activity',
|
||
label: 'Candidate activity',
|
||
context: null,
|
||
summary: 'Applications across the workspace, counted over time.',
|
||
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
|
||
options: ['periods'],
|
||
},
|
||
{
|
||
id: 'positions.demand',
|
||
label: 'Position demand',
|
||
context: null,
|
||
summary: 'Open positions and what they still need.',
|
||
shapes: ['list', 'table', 'stats', 'card'],
|
||
options: ['limit'],
|
||
},
|
||
{
|
||
id: 'workforce.training',
|
||
label: 'Workforce training',
|
||
context: null,
|
||
summary: 'Training paths and progress against them.',
|
||
shapes: ['progress', 'list', 'stats', 'table', 'card'],
|
||
options: ['limit'],
|
||
},
|
||
{
|
||
/* The vetting weights a position is being specified with. `positionId`
|
||
context, but the "position" on Create Position is the draft in the form
|
||
rather than a saved record — which is the point: the resolver reads the
|
||
same field either way, so one source serves the form and the position it
|
||
becomes. */
|
||
id: 'position.vetting',
|
||
label: 'Position vetting weights',
|
||
context: 'positionId',
|
||
summary: 'How this position weights each screening criterion.',
|
||
shapes: ['weights', 'flow', 'progress', 'stats', 'table', 'card', 'insight'],
|
||
/**
|
||
* This reading can be written back.
|
||
*
|
||
* `writable` is what lets a definition declare `editable: true` and get
|
||
* controls instead of a read-out. It is a property of the *source*, not of
|
||
* the definition — a skill cannot make a reading writable by asking, and a
|
||
* source with no page willing to accept the write simply renders read-only.
|
||
* That keeps the closed vocabulary closed in both directions.
|
||
*/
|
||
writable: true,
|
||
writeSummary: 'Sets the screening weights on the position being specified.',
|
||
},
|
||
{
|
||
id: 'hires.recent',
|
||
label: 'Recent hires',
|
||
context: null,
|
||
summary: 'Who was hired, for which role, and when.',
|
||
shapes: ['list', 'table', 'timeline', 'stats', 'card'],
|
||
options: ['limit'],
|
||
},
|
||
{
|
||
id: 'hires.performance',
|
||
label: 'Hiring performance',
|
||
context: null,
|
||
summary: 'Hires, time-to-hire, quality and conversion, counted together.',
|
||
shapes: ['stats', 'card', 'table', 'flow', 'insight'],
|
||
},
|
||
{
|
||
id: 'activity.events',
|
||
label: 'Workspace activity',
|
||
context: null,
|
||
summary: 'What has happened across the workspace, most recent first.',
|
||
shapes: ['timeline', 'list', 'table', 'stats', 'card'],
|
||
options: ['limit'],
|
||
},
|
||
];
|
||
|
||
export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id);
|
||
|
||
export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null;
|
||
|
||
/* ── Source / shape compatibility ───────────────────────────────────────────
|
||
One resolver, three consumers: `normalizeSection` refuses on it, the Board
|
||
editor's source picker filters on it, and the Owliver editor's per-capability
|
||
picker filters on it. They used to be three readings of `source.shapes` —
|
||
inline in the normalizer, absent from one editor and approximated in the
|
||
other — which is how a form could compose `list` against a source that has no
|
||
list in it and only find out at save. */
|
||
|
||
/**
|
||
* The section type a capability is drawn with, or null when it is prose.
|
||
*
|
||
* `summary` is the one capability with no component, so it is compatible with
|
||
* every source: the figures are read back as sentences rather than drawn.
|
||
*/
|
||
export const shapeForCapability = (capability) => owliverCapabilityFor(capability)?.shape || null;
|
||
|
||
/** Can this source be drawn as this shape? A null shape is prose, and always can. */
|
||
export function sourceSupportsShape(sourceId, shape) {
|
||
const source = dataSourceFor(sourceId);
|
||
if (!source) return false;
|
||
if (!shape) return true;
|
||
return Boolean(source.shapes?.includes(shape));
|
||
}
|
||
|
||
/**
|
||
* The sources that can fill this shape, optionally narrowed to what a placement
|
||
* can supply context for.
|
||
*
|
||
* `context` is the list a placement provides — `contextSuppliedBy(pages,
|
||
* placement)`. Passing it is how the Board editor offers only sources that can
|
||
* actually resolve where the section is mounted; the Owliver editor passes
|
||
* nothing, because a response with an unmet need asks which record is meant
|
||
* rather than rendering dead.
|
||
*/
|
||
export function sourcesForShape(shape, { context = null } = {}) {
|
||
return DATA_SOURCES.filter((source) => {
|
||
if (!sourceSupportsShape(source.id, shape)) return false;
|
||
if (!context) return true;
|
||
return !source.context || context.includes(source.context);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Whether a source reads an option a form can offer — `periods` or `limit`.
|
||
*
|
||
* Declared per source rather than inferred from its shapes, because the two do
|
||
* not line up: `positions.demand` is a list that honours `limit` and has no
|
||
* periods at all, while `candidates.activity` is the reverse. The editors used
|
||
* to guess from the shape list and offered period checkboxes that changed
|
||
* nothing.
|
||
*/
|
||
export const sourceSupportsOption = (sourceId, option) =>
|
||
Boolean(dataSourceFor(sourceId)?.options?.includes(option));
|
||
|
||
/**
|
||
* Can this reading be written back?
|
||
*
|
||
* The one question `editable:` is checked against. A page opts in by publishing
|
||
* a handler for the source (see `usePublishPageActions`); a definition opts in
|
||
* by declaring `editable: true`. Both have to hold before a control is drawn,
|
||
* so neither the author nor the page can enable editing on its own.
|
||
*/
|
||
export const isSourceWritable = (id) => Boolean(dataSourceFor(id)?.writable);
|
||
|
||
/**
|
||
* The periods a time-based section may ask for.
|
||
*
|
||
* Only the ones the data layer can actually compute from record timestamps.
|
||
* A period is a window over `created_date`, resolved at read time against the
|
||
* current date — never a stored figure and never a hard-coded date.
|
||
*/
|
||
export const PERIODS = [
|
||
{ id: 'today', label: 'Today' },
|
||
{ id: 'yesterday', label: 'Yesterday' },
|
||
{ id: 'last-7-days', label: 'Last 7 days' },
|
||
{ id: 'last-week', label: 'Last week' },
|
||
{ id: 'this-month', label: 'This month' },
|
||
{ id: 'previous-month', label: 'Previous month' },
|
||
];
|
||
|
||
export const SUPPORTED_PERIODS = PERIODS.map((p) => p.id);
|
||
|
||
export const periodLabel = (id) => PERIODS.find((p) => p.id === id)?.label || id;
|
||
|
||
/* ── Human labels ───────────────────────────────────────────────────────────
|
||
The vocabulary is written in kebab-case because it is configuration; it is
|
||
read by people, so every id has a label. One place, so the preview, the
|
||
rendered section and any future surface all say the same words. */
|
||
|
||
/** `flow` → `Flow`. */
|
||
export const sectionTypeLabel = (id) =>
|
||
SECTION_TYPES.find((t) => t.id === id)?.label || id;
|
||
|
||
/** `position.activity` → `Position activity`. */
|
||
export const dataSourceLabel = (id) => dataSourceFor(id)?.label || id;
|
||
|
||
/** `after-position-summary` → `After position summary`. */
|
||
export const placementLabel = (id) => {
|
||
const words = String(id || '').replace(/-/g, ' ').trim();
|
||
return words ? words[0].toUpperCase() + words.slice(1) : id;
|
||
};
|