This commit is contained in:
@@ -282,6 +282,40 @@ export function resolveAgentForTurn(agents = [], activeId, contextId) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Skill ownership ────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Whether this agent context permits a skill's UI.
|
||||
*
|
||||
* Ownership is **opt-in**, and the asymmetry is the whole rule:
|
||||
*
|
||||
* - a skill claimed by at least one agent belongs to those agents, and its
|
||||
* sections are drawn only where one of them is answering;
|
||||
* - a skill claimed by nobody is unowned, and unowned means unchanged — the
|
||||
* pages it declares and the account switch decide it, exactly as before.
|
||||
*
|
||||
* Enforcing ownership on every skill instead would have removed every skill
|
||||
* section in the product on the day it shipped: the definitions that draw page
|
||||
* UI are authored on an account and claimed by no agent, while every skill this
|
||||
* build ships is conversation-only. Attaching a skill to an agent is therefore
|
||||
* the act that brings it under that agent's control — a decision an author
|
||||
* makes in Agent Configure, not one taken on their behalf.
|
||||
*
|
||||
* With no agents loaded — outside a provider, or before the registry has
|
||||
* answered — nothing is owned and nothing is constrained, which is the safe
|
||||
* reading rather than a permissive one: it can only ever show what the page and
|
||||
* the account already allow.
|
||||
*
|
||||
* This answers one question only. What pages a skill declares and what the
|
||||
* account has switched off are separate rules, checked separately.
|
||||
*/
|
||||
export function agentPermitsSkill(skillId, { agents = [], agent = null } = {}) {
|
||||
if (!skillId) return false;
|
||||
const owned = (agents || []).some((a) => (a?.skills || []).includes(skillId));
|
||||
if (!owned) return true;
|
||||
return (agent?.skills || []).includes(skillId);
|
||||
}
|
||||
|
||||
/* ── Starters ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,6 +43,36 @@ export function desiredPayLabel(record = {}) {
|
||||
return min ? `From $${min}/hr` : `Up to $${max}/hr`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body for creating a NEW employee together with their first declared role.
|
||||
*
|
||||
* Two nested records, because they are two rows and the endpoint writes them in
|
||||
* one transaction: the worker on the outside, the role under `role`. Nesting
|
||||
* rather than flattening is what stops a key meant for one landing on the
|
||||
* other — `experience_years` means something different on a profile than on a
|
||||
* declared role, and a flat body would have to guess.
|
||||
*
|
||||
* The email is passed through as the caller typed it. Nothing here derives one,
|
||||
* defaults one, or falls back to another record's; an absent email reaches the
|
||||
* server absent, and the server refuses it.
|
||||
*/
|
||||
export function toNewWorkerWithRolePayload(draft = {}) {
|
||||
const role = toEmployeeRolePayload(draft);
|
||||
/* The identity fields belong to the worker. The server copies them onto the
|
||||
role from the row it just created, so sending them twice would let the two
|
||||
disagree. */
|
||||
const { worker_email: email, worker_name: name, worker_profile_id: _ignored, ...roleOnly } = role;
|
||||
|
||||
return {
|
||||
full_name: name,
|
||||
email,
|
||||
availability: role.availability,
|
||||
certifications: role.certifications,
|
||||
experience_years: role.experience_years,
|
||||
role: roleOnly,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The record the API is asked to create.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { mergeLayouts, normalizeLayouts } from '@/lib/ui/patch';
|
||||
import { API_BASE_URL, base44 } from '@/api/base44Client';
|
||||
import { request } from '@/api/httpClient';
|
||||
import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi';
|
||||
import { recalcProfilePatch } from './krowScore';
|
||||
import { logActivity } from './userTracking';
|
||||
@@ -62,6 +65,52 @@ export function useUpdatePreferences() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A person's own UI layout changes, per page.
|
||||
*
|
||||
* Stored beside `customSkills` in the account's preferences, which is a store
|
||||
* that already exists, is already per-user, and already reaches Postgres
|
||||
* through `PATCH /api/v1/me/preferences`. No new endpoint, no new table, and
|
||||
* nothing to deploy — which is the whole point: a layout change is a runtime
|
||||
* change, and a runtime change that needed a release would not be one.
|
||||
*
|
||||
* What is written is the **operation list** the UI engine validated, never a
|
||||
* copy of the rendered tree and never Markdown. A stored tree would be a
|
||||
* photograph of the page on the day it was saved; an operation still means what
|
||||
* it said after a release moves the built-ins around it.
|
||||
*
|
||||
* The whole `uiLayouts` object is sent on every write, because the endpoint
|
||||
* shallow-merges its top-level keys: sending one page would replace the map and
|
||||
* silently drop every other page's layout.
|
||||
*/
|
||||
export function useUiLayouts() {
|
||||
const preferences = usePreferences();
|
||||
const update = useUpdatePreferences();
|
||||
|
||||
const layouts = React.useMemo(
|
||||
() => normalizeLayouts(preferences.uiLayouts).layouts,
|
||||
[preferences.uiLayouts]
|
||||
);
|
||||
|
||||
/**
|
||||
* Store one page's patch.
|
||||
*
|
||||
* A patch with no operations is removed rather than stored empty — that is
|
||||
* the same state as never having customised the page, and keeping the key
|
||||
* would grow the blob with a record of every page somebody once opened.
|
||||
*/
|
||||
const save = React.useCallback(async (page, patch) => {
|
||||
const key = String(page || '').trim();
|
||||
if (!key) return null;
|
||||
/* Only `uiLayouts` is sent. Every other preference — `customSkills` above
|
||||
all — is left for the endpoint's shallow merge to preserve, so a layout
|
||||
change can never disturb an authored skill. */
|
||||
return update.mutateAsync({ uiLayouts: mergeLayouts(layouts, key, patch) });
|
||||
}, [layouts, update]);
|
||||
|
||||
return { layouts, save, saving: update.isPending };
|
||||
}
|
||||
|
||||
export function useJobPostings() {
|
||||
return useQuery({
|
||||
queryKey: ['jobPostings'],
|
||||
@@ -326,6 +375,29 @@ export function useCreateJobPosting() {
|
||||
* organization now has one more worker offering that role and what is worth
|
||||
* asking has changed with it.
|
||||
*/
|
||||
/**
|
||||
* Record a NEW employee and their first declared role, in one transaction.
|
||||
*
|
||||
* One request, not two. Creating the worker and then the role as separate calls
|
||||
* leaves a worker nobody meant to create when the second fails — indistinguishable
|
||||
* from a real one and with nothing to say why it is there. The endpoint writes
|
||||
* both inside a transaction and rolls the worker back if the role cannot be
|
||||
* written, so a refused create leaves the database exactly as it was.
|
||||
*/
|
||||
export function useCreateWorkerWithRole() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: /** @param {any} data */ (data) => request('POST', '/worker-profiles/with-role', { body: data }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['employeeRoles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
|
||||
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||
logActivity('create_employee_role');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Record another role for somebody already on file. A different request. */
|
||||
export function useCreateEmployeeRole() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -11,7 +11,65 @@
|
||||
* `useCreateJobPosting`, in both paths.
|
||||
*/
|
||||
|
||||
/** Certifications a position can require, in the order they are offered. */
|
||||
/**
|
||||
* Which certifications matter for a role, from what this organization has
|
||||
* actually asked for.
|
||||
*
|
||||
* THE SOURCE OF TRUTH, and it is worth being explicit about why it is this one.
|
||||
* The schema has no role-to-certification relationship at all: `role_categories`
|
||||
* and `certifications` are both bare `(id, org_id, name)` lists with nothing
|
||||
* joining them. So relevance cannot be looked up — but it can be OBSERVED, and
|
||||
* the observation is real organizational behaviour rather than a guess:
|
||||
* `job_postings` carries `role_category` and `certifications_required` on the
|
||||
* same row, so every posting this organization has written is a statement that
|
||||
* these certifications matter for that role.
|
||||
*
|
||||
* That makes the answer tenant-specific for free. A staffing company that puts
|
||||
* `Guard Card` on its Security postings gets Guard Card; one that does not,
|
||||
* does not. Nothing here carries a list of roles or a list of certifications,
|
||||
* and adding either to the product changes this function's output without
|
||||
* changing this function.
|
||||
*
|
||||
* `postings` is the caller's own already-loaded set, so this widens nobody's
|
||||
* view: the API scoped it before it reached the browser.
|
||||
*
|
||||
* Returns `null` when the postings have not loaded, and `[]` when they have and
|
||||
* the role genuinely has none. Those are different answers — "we do not know
|
||||
* yet" must not render as "there are none" — and the caller is expected to tell
|
||||
* them apart rather than treating both as empty.
|
||||
*/
|
||||
export function certificationsForRole(role, postings) {
|
||||
if (!Array.isArray(postings)) return null;
|
||||
|
||||
const want = String(role || '').trim().toLowerCase();
|
||||
if (!want) return [];
|
||||
|
||||
const found = new Set();
|
||||
for (const posting of postings) {
|
||||
if (String(posting?.role_category || '').trim().toLowerCase() !== want) continue;
|
||||
for (const cert of posting.certifications_required || []) {
|
||||
const name = String(cert || '').trim();
|
||||
if (name) found.add(name);
|
||||
}
|
||||
}
|
||||
return [...found];
|
||||
}
|
||||
|
||||
/**
|
||||
* Certifications the CREATE POSITION FORM offers, in the order they are offered.
|
||||
*
|
||||
* A fixed list, and it should not be one — the organization keeps its own in
|
||||
* the `certifications` table, which `useCertifications()` already reads and
|
||||
* which nothing in this product currently consults. Against the live tenant
|
||||
* this list is wrong twice over: it offers `ABC License`, which that
|
||||
* organization does not use, and spells `CPR/First Aid` where the record says
|
||||
* `CPR / First Aid`, so the two can never match.
|
||||
*
|
||||
* Left in place deliberately rather than quietly rewired: the form is a
|
||||
* multi-select over a fixed vocabulary and changing its source is a change to
|
||||
* how positions are authored, which is a product decision and not a bug fix.
|
||||
* The conversational flows no longer read it — see `certificationsForRole`.
|
||||
*/
|
||||
export const CERT_OPTIONS = [
|
||||
'Food Handler Card',
|
||||
'ServSafe',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toEmployeeRolePayload } from '@/lib/employeeRoleModel';
|
||||
import { toNewWorkerWithRolePayload } from '@/lib/employeeRoleModel';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS, toPositionPayload } from '@/lib/positionModel';
|
||||
import { routeForPageKey } from './registry';
|
||||
|
||||
@@ -488,10 +488,21 @@ const HANDLERS = {
|
||||
* situation rather than how finished the record is, so the conversation's one
|
||||
* verb commits it as `seeking` — there is no draft of a person's own role.
|
||||
*/
|
||||
create_employee_role: ({ draft, status }) => ({
|
||||
type: 'create_employee_role',
|
||||
data: { ...toEmployeeRolePayload(draft || {}), status: status || 'seeking' },
|
||||
}),
|
||||
/**
|
||||
* Record a NEW employee and their first declared role.
|
||||
*
|
||||
* The payload is two nested records because the endpoint writes two rows in
|
||||
* one transaction. Refused outright without a name and an email: those are
|
||||
* the person, and neither is ever derived from the other or from the caller.
|
||||
*/
|
||||
create_employee_role: ({ draft, status }) => {
|
||||
const record = { ...(draft || {}) };
|
||||
if (!String(record.worker_name || '').trim() || !String(record.worker_email || '').trim()) return null;
|
||||
return {
|
||||
type: 'create_employee_role',
|
||||
data: toNewWorkerWithRolePayload({ ...record, status: status || 'seeking' }),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the Add Skill Training flow, on the Forge page, with what the request
|
||||
|
||||
@@ -129,10 +129,16 @@ export const missingRequired = (registry, flow, steps) => steps
|
||||
* appearing as the literal "@workers", which is what it did before registries
|
||||
* existed and each resolver knew every token.
|
||||
*/
|
||||
function suggestionsFor(registry, step, ctx) {
|
||||
function suggestionsFor(registry, step, ctx, draft = {}) {
|
||||
const resolved = step.options.flatMap((option) => {
|
||||
if (!option.startsWith('@')) return [option];
|
||||
return registry.resolve?.(option, ctx) || [];
|
||||
/* The DRAFT is passed as well as the context, and it is what makes an
|
||||
option list able to depend on an earlier answer. Certifications are the
|
||||
case that forced it: which ones matter is a fact about the role chosen
|
||||
two questions ago, and a resolver that only saw the page's data could
|
||||
never know it. Recomputed on every ask, so changing the role from the
|
||||
change menu recomputes rather than reusing what the last role produced. */
|
||||
return registry.resolve?.(option, ctx, draft) || [];
|
||||
});
|
||||
|
||||
const capped = [...new Set(resolved.filter(Boolean))].slice(0, 6);
|
||||
@@ -158,7 +164,7 @@ function ask(registry, flow, steps, ctx, { preamble = null, retry = null } = {})
|
||||
text(step.question),
|
||||
retry ? note(retry) : null
|
||||
),
|
||||
followUp: suggestionsFor(registry, step, ctx),
|
||||
followUp: suggestionsFor(registry, step, ctx, flow.draft),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS } from '@/lib/positionModel';
|
||||
import { ENGLISH_LEVELS, certificationsForRole } from '@/lib/positionModel';
|
||||
import {
|
||||
AVAILABILITY_OPTIONS, defaultEmployeeRole, desiredPayLabel,
|
||||
AVAILABILITY_OPTIONS, desiredPayLabel,
|
||||
} from '@/lib/employeeRoleModel';
|
||||
import {
|
||||
extractCertifications, extractEnglish, extractExperience, extractPay, extractRole,
|
||||
@@ -31,37 +31,54 @@ const fields = {
|
||||
* and what survives a worker profile being removed. A name with no email
|
||||
* behind it is not an answer, so `parse` refuses one it cannot resolve.
|
||||
*/
|
||||
worker: {
|
||||
label: 'Worker',
|
||||
settled: (draft) => Boolean(String(draft.worker_email || '').trim()),
|
||||
retry: 'Name the worker, or type their email address.',
|
||||
parse: (answer, { workers = [] }) => {
|
||||
const said = String(answer).trim();
|
||||
if (!said) return null;
|
||||
|
||||
/* An email typed outright is the worker, whether or not a profile
|
||||
exists — a role can be recorded before the profile is created. */
|
||||
const email = /\b[^\s@]+@[^\s@]+\.[^\s@]+\b/.exec(said)?.[0];
|
||||
const lower = said.toLowerCase();
|
||||
|
||||
const match = workers.find((w) => (email
|
||||
? String(w.email || '').toLowerCase() === email.toLowerCase()
|
||||
: String(w.name || '').toLowerCase() === lower))
|
||||
/* A chip carries the full name; a typed answer may be part of one. */
|
||||
|| (!email && workers.find((w) => String(w.name || '').toLowerCase().includes(lower)));
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
worker_profile_id: match.id || null,
|
||||
worker_email: match.email,
|
||||
worker_name: match.name || '',
|
||||
};
|
||||
}
|
||||
return email ? { worker_profile_id: null, worker_email: email, worker_name: '' } : null;
|
||||
/**
|
||||
* The new employee's name.
|
||||
*
|
||||
* A name and nothing more. It is NOT looked up, because a name cannot select
|
||||
* anybody: an organization may employ any number of people who share one, and
|
||||
* the previous version of this field searched the existing workers for a name
|
||||
* match and attached the role to the first hit. With several people of the
|
||||
* same name that silently filed the role against the wrong person; with a
|
||||
* name nobody had, it understood nothing and asked the same question again,
|
||||
* which is the loop this flow was stuck in.
|
||||
*/
|
||||
worker_name: {
|
||||
label: 'Name',
|
||||
settled: (draft) => Boolean(String(draft.worker_name || '').trim()),
|
||||
retry: 'Type the new employee\u2019s full name.',
|
||||
parse: (answer) => {
|
||||
const name = String(answer).trim().replace(/\s+/g, ' ');
|
||||
return name.length >= 2 ? { worker_name: name } : null;
|
||||
},
|
||||
summary: (draft) => (draft.worker_name
|
||||
? `Worker: ${draft.worker_name} (${draft.worker_email})`
|
||||
: `Worker: ${draft.worker_email}`),
|
||||
summary: (draft) => `Name: ${draft.worker_name}`,
|
||||
},
|
||||
|
||||
/**
|
||||
* The new employee's email, which is their identity.
|
||||
*
|
||||
* Asked outright and never derived. There is no rule anywhere that turns a
|
||||
* name into an address, no fallback to the operator's own account, and no
|
||||
* reuse of anything an earlier conversation collected — an invented address
|
||||
* is a real person's record filed under something they do not own.
|
||||
*
|
||||
* `worker_profiles` carries UNIQUE (org_id, email) over a `citext` column, so
|
||||
* this value is what decides whether the person already exists. The check is
|
||||
* the database's, not this field's: two operators recording the same person
|
||||
* at the same moment cannot both win, whatever either browser believed.
|
||||
*/
|
||||
worker_email: {
|
||||
label: 'Email',
|
||||
settled: (draft) => Boolean(String(draft.worker_email || '').trim()),
|
||||
retry: 'Type the employee\u2019s email address — it is how the record is identified.',
|
||||
parse: (answer) => {
|
||||
const said = String(answer).trim();
|
||||
/* The whole answer must be the address. Pulling one out of a sentence
|
||||
would accept "I don't know, maybe bob@x.com" as a considered answer. */
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(said)
|
||||
? { worker_email: said }
|
||||
: null;
|
||||
},
|
||||
summary: (draft) => `Email: ${draft.worker_email}`,
|
||||
},
|
||||
|
||||
role_category: {
|
||||
@@ -174,12 +191,25 @@ const fields = {
|
||||
* `@workers` is the worker profiles the panel has already loaded for this
|
||||
* caller — org-scoped by the API, and nothing here widens that view.
|
||||
*/
|
||||
const resolve = (token, { roles = [], workers = [] }) => {
|
||||
const resolve = (token, { roles = [], postings = null }, draft = {}) => {
|
||||
switch (token) {
|
||||
case '@workers': return workers.map((w) => w.name).filter(Boolean);
|
||||
case '@roles': return roles;
|
||||
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
|
||||
case '@certifications': return CERT_OPTIONS;
|
||||
/**
|
||||
* Only the certifications that matter for the role just chosen.
|
||||
*
|
||||
* Never the global list. A worker declaring themselves a Chef is asked
|
||||
* about the certifications this organization puts on its Chef postings, and
|
||||
* about nothing else — offering `Guard Card` there is the assistant
|
||||
* inventing a requirement, and a chip is a suggestion the reader is
|
||||
* entitled to read as informed.
|
||||
*
|
||||
* `null` back from the helper means the postings have not arrived; `[]`
|
||||
* means this role genuinely has none. Both produce no chips here, and
|
||||
* neither falls back to a global list — the step is optional, so `Skip` is
|
||||
* offered by the engine either way and the reader can still type one.
|
||||
*/
|
||||
case '@certifications': return certificationsForRole(draft.role_category, postings) || [];
|
||||
case '@availability': return AVAILABILITY_OPTIONS;
|
||||
default: return [];
|
||||
}
|
||||
@@ -296,14 +326,28 @@ export const employeeRoleRegistry = {
|
||||
extract,
|
||||
|
||||
/**
|
||||
* No prefill from the opening request.
|
||||
* No prefill from the opening request, and an EMPTY draft rather than a
|
||||
* defaulted one.
|
||||
*
|
||||
* "Create an employee role" names nobody, and the posting flow's habit of
|
||||
* reading a role out of the request would settle `role_category` from the
|
||||
* word "role" in the phrase that started the conversation. The first question
|
||||
* is who this is about, and it is asked.
|
||||
*
|
||||
* Returning `defaultEmployeeRole()` here — the record's write-time defaults —
|
||||
* was worse than it looks. Every `settled` test asks whether a field HAS a
|
||||
* value, and the defaults give all of them one: `certifications: []` is an
|
||||
* array, `notes: ''` is a string, `experience_years: 0` is a number. So five
|
||||
* of the eight questions were answered before they were asked, and the
|
||||
* conversation went worker → role → pay and stopped. The certification step
|
||||
* could not be reached at all.
|
||||
*
|
||||
* The two are different things wearing the same shape: write-time defaults
|
||||
* are what a MISSING answer becomes, and a conversation must be able to tell
|
||||
* missing from answered. `toEmployeeRolePayload` still applies them at the
|
||||
* write, so nothing is lost by starting empty.
|
||||
*/
|
||||
prefill: () => defaultEmployeeRole(),
|
||||
prefill: () => ({}),
|
||||
|
||||
/**
|
||||
* One verb, because there is one outcome. A declared role has no draft state:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
|
||||
import { ENGLISH_LEVELS, certificationsForRole, payLabel } from '@/lib/positionModel';
|
||||
import {
|
||||
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
|
||||
extractLocation, extractPay, extractRole,
|
||||
@@ -143,12 +143,16 @@ const fields = {
|
||||
* provision a TENANT the operator cannot then see, because every read is
|
||||
* predicated on the session's own org_id.
|
||||
*/
|
||||
const resolve = (token, { roles = [], companies = [] }) => {
|
||||
const resolve = (token, { roles = [], companies = [], postings = null }, draft = {}) => {
|
||||
switch (token) {
|
||||
case '@roles': return roles;
|
||||
case '@companies': return companies;
|
||||
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
|
||||
case '@certifications': return CERT_OPTIONS;
|
||||
/* The same rule as the role flow, from the same helper: what this
|
||||
organization already asks for on postings of this category. A position
|
||||
being written for a role it has never posted before offers none, which is
|
||||
honest — there is nothing to go on yet. */
|
||||
case '@certifications': return certificationsForRole(draft.role_category || draft.title, postings) || [];
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -366,11 +366,36 @@ function blockEnd(lines, start, indent) {
|
||||
return end;
|
||||
}
|
||||
|
||||
/**
|
||||
* How wide a line's indentation is.
|
||||
*
|
||||
* The same measurement the parser makes — `yaml.js` reads a leading run of
|
||||
* whitespace and counts a tab as two — and it has to be, because a key the
|
||||
* parser can see and the
|
||||
* writer cannot is a key the writer will decide is missing and add a second
|
||||
* copy of.
|
||||
*
|
||||
* That is not hypothetical: a definition stored on this account indents with
|
||||
* U+00A0. JavaScript's `\s` matches it, so the parser read the file correctly
|
||||
* and every screen showed the right values; the writer compared against literal
|
||||
* spaces, found no `title:` inside `ui:`, and appended a whole second `ui:`
|
||||
* block on the first edit. The Go port agrees with the parser here too — see
|
||||
* `jsIsSpace` in `internal/definition/jsvalue.go`, which lists `0x00A0` — so the
|
||||
* writer was the only thing in the chain using a narrower idea of a space.
|
||||
*/
|
||||
const indentWidth = (line) => (line.match(/^\s*/)?.[0] || '').replace(/\t/g, ' ').length;
|
||||
|
||||
/** The index of `key` at `indent` within `[from, to)`, or -1. */
|
||||
function findKey(lines, key, indent, from, to) {
|
||||
const pattern = new RegExp(`^${' '.repeat(indent)}${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
|
||||
const pattern = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
|
||||
for (let i = from; i < to; i += 1) {
|
||||
if (pattern.test(lines[i])) return i;
|
||||
const line = lines[i];
|
||||
if (line === undefined || indentWidth(line) !== indent) continue;
|
||||
/* Measured, then matched on what is left — so the comparison is about how
|
||||
deep the key sits, never about which characters were used to put it
|
||||
there. Identical for ASCII input, which is every definition this
|
||||
repository ships. */
|
||||
if (pattern.test(line.replace(/^\s*/, ''))) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -187,3 +187,70 @@ export function toolsForContext(contextId, disabled = [], customSkills = []) {
|
||||
*/
|
||||
export const toolAllowed = (name, allowed = []) =>
|
||||
allowed.some((tool) => tool.name === name);
|
||||
|
||||
/* ── Typed action intent ────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* How much has to be typed before a partial word counts as an intent.
|
||||
*
|
||||
* Three, and the number is the whole point of the rule. One or two characters
|
||||
* cannot say what somebody meant — "wh" is the start of four unrelated
|
||||
* questions — and offering anything on them is how the composer ended up
|
||||
* interrupting every reader who had already decided what to ask. Three is the
|
||||
* shortest prefix of a real verb, and it still matches nothing unless it is
|
||||
* genuinely the beginning of an action this page can perform.
|
||||
*/
|
||||
const ACTION_INTENT_MIN = 3;
|
||||
|
||||
/**
|
||||
* A phrase reduced to what it means, so matching is about intent not typing.
|
||||
*
|
||||
* Case, surrounding space and an article are not differences: "Create",
|
||||
* "create" and "create a" are all the beginning of the same request. Kept local
|
||||
* and deliberately tiny — it exists to compare two short phrases, and anything
|
||||
* cleverer would start deciding what a reader meant.
|
||||
*/
|
||||
const canon = (value) => String(value || '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w && !/^(?:a|an|the)$/.test(w))
|
||||
.join(' ');
|
||||
|
||||
/**
|
||||
* The actions this page can perform that the typed text is starting to name.
|
||||
*
|
||||
* Derived, never listed. The phrases come from the skills themselves — a
|
||||
* definition's `prompt:` is the sentence its author wrote for exactly this
|
||||
* purpose — and the candidate set is whatever `skillsForContext` already
|
||||
* resolved, which has the page filter and the agent's scoping applied to it.
|
||||
* So a skill added tomorrow is offered here without this file changing, a skill
|
||||
* switched off in Settings is not offered at all, and there is no second list
|
||||
* of action names to keep in step with the first.
|
||||
*
|
||||
* Only skills that DECLARE an action are eligible. A reading skill has nothing
|
||||
* to autocomplete towards: "which positions are in draft" is a question, and
|
||||
* offering it while somebody types is the generic-catalogue behaviour this
|
||||
* replaced.
|
||||
*
|
||||
* Matched on `canon`, so "Create" reaches "Create a position" — the article and
|
||||
* the case are not differences — and "create position" reaches it too. A prefix
|
||||
* rather than a substring: typing the middle of a phrase is not evidence of
|
||||
* intent, and substring matching is what made every keystroke produce chips.
|
||||
*/
|
||||
export function actionSuggestions(typed, skills = []) {
|
||||
const query = canon(typed);
|
||||
if (query.length < ACTION_INTENT_MIN) return [];
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const skill of skills) {
|
||||
if (!skill?.prompt || !skill.actions?.length) continue;
|
||||
const phrase = canon(skill.prompt);
|
||||
if (!phrase.startsWith(query)) continue;
|
||||
if (seen.has(phrase)) continue;
|
||||
seen.add(phrase);
|
||||
out.push({ label: skill.prompt, prompt: skill.prompt });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -32,13 +32,51 @@ const has = (q, ...terms) => terms.some((t) => q.includes(t));
|
||||
* enquiry. "Assign them" after a preview must not be read as a fresh request
|
||||
* for recommendations.
|
||||
*/
|
||||
/**
|
||||
* The words that name a GROUP of people rather than one person.
|
||||
*
|
||||
* A detail request is about somebody: "show Maria Gonzalez" opens a record.
|
||||
* A question naming a group is a search, and answering it with one person's
|
||||
* record is the wrong answer to a different question.
|
||||
*/
|
||||
const GROUP_WORDS = [
|
||||
'candidate', 'candidates', 'applicant', 'applicants',
|
||||
'employee', 'employees', 'worker', 'workers', 'people', 'talent',
|
||||
];
|
||||
|
||||
/**
|
||||
* Words that place a question in the WORKFORCE rather than the hiring pipeline.
|
||||
*
|
||||
* These are two different sources and the product keeps them apart: a candidate
|
||||
* is somebody in `job_applications`, and the talent pool is `worker_profiles`.
|
||||
* Every intent below answers from the pipeline — `poolFor` is built from
|
||||
* applications and a profile only ever enriches a candidate it already found —
|
||||
* so a question about employees must not reach any of them.
|
||||
*
|
||||
* Returned as NO workforce intent, which hands the question to the Talent Pool
|
||||
* responder to answer from the workforce. That is the correct source, and it is
|
||||
* the direction this file used to get wrong: "show employees who match this
|
||||
* role" was caught by a bare `show ` test and answered as a candidate lookup.
|
||||
*/
|
||||
const WORKFORCE_WORDS = ['talent pool', 'talent directory', 'employee', 'employees', 'worker', 'workers'];
|
||||
const PIPELINE_WORDS = ['candidate', 'applicant', 'application'];
|
||||
|
||||
export function matchWorkforceIntent(question) {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
/* Source before intent. A question that names the workforce and not the
|
||||
pipeline is not answered from the pipeline, whatever else it says. */
|
||||
if (has(q, ...WORKFORCE_WORDS) && !has(q, ...PIPELINE_WORDS)) return null;
|
||||
|
||||
/* Inspection and record-opening are checked before assignment, so "show X"
|
||||
and "open the full profile for X" never read as a request to assign. */
|
||||
if (has(q, 'open the full profile', 'view full profile', 'full profile for')) return 'open_profile';
|
||||
if (has(q, 'show ', "'s details", 'candidate details', 'tell me about ')) return 'candidate_detail';
|
||||
if (has(q, "'s details", 'candidate details', 'tell me about ')) return 'candidate_detail';
|
||||
/* `show X` is a detail request only when X is a person. Bare `show ` was
|
||||
checked here unguarded and swallowed every collection query in the
|
||||
product — "show candidates matching bartender" resolved to one candidate's
|
||||
record rather than to a search. */
|
||||
if (has(q, 'show ') && !has(q, ...GROUP_WORDS)) return 'candidate_detail';
|
||||
if (has(q, 'can ', 'why not eligible', 'be assigned')) return 'eligibility';
|
||||
|
||||
if (has(q, 'confirm interview')) return 'confirm_interview';
|
||||
@@ -53,11 +91,17 @@ export function matchWorkforceIntent(question) {
|
||||
|
||||
if (has(q, 'confirm assignment', 'yes, assign', 'confirm the assignment')) return 'confirm_assign';
|
||||
if (has(q, 'assign ', 'fill this position', 'fill the position', 'assign the best', 'assign them')) return 'assign';
|
||||
if (has(q, 'ready for interview', 'who should i interview', 'interview ready')) return 'interview_ready';
|
||||
if (has(q, 'ready for interview', 'should i interview', 'interview ready')) return 'interview_ready';
|
||||
if (has(q, 'applied today', "today's applicant", 'new applicant', 'new application')) return 'applied_today';
|
||||
if (has(q, 'available', 'start earliest', 'can start', 'availability', 'free now')) return 'availability';
|
||||
/* Candidate matching, in the words people actually use for it. The list was
|
||||
narrow enough that "who is a match for this role", "find the best
|
||||
candidate" and "find strong candidates" all fell through to no intent at
|
||||
all. Every one of these is answered from the pipeline. */
|
||||
if (has(q, 'who matches', 'who can fill', 'find candidates', 'best match', 'strongest candidate',
|
||||
'recommend candidate', 'suitable candidate', 'who is suitable', 'candidates for')) return 'matches';
|
||||
'recommend candidate', 'suitable candidate', 'who is suitable', 'candidates for',
|
||||
'is a match', 'match for', 'best candidate', 'strong candidate', 'strongest match',
|
||||
'which candidate', 'candidates matching', 'candidate matching', 'matching ')) return 'matches';
|
||||
if (has(q, 'needs people first', 'biggest gap', 'largest gap', 'most understaffed',
|
||||
'which position should i fill', 'priority')) return 'priority';
|
||||
return null;
|
||||
|
||||
127
src/lib/ui/composition.js
Normal file
127
src/lib/ui/composition.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* What a page is made of, and how a user's changes are folded into it.
|
||||
*
|
||||
* A page registers its own composition — the nodes it ships with, in order —
|
||||
* and this module holds the table. The engine reads the table; it never learns
|
||||
* a page name, and there is no switch here that would have to grow a case per
|
||||
* surface. Registering is how a page opts in, exactly as `registerNodeType` is
|
||||
* how a component does.
|
||||
*
|
||||
* The merge is the other half:
|
||||
*
|
||||
* built-in composition what the application ships
|
||||
* ⊕ saved user patch the person's own changes, persisted
|
||||
* ⊕ preview patch what they are trying, not yet saved
|
||||
* = the tree that renders
|
||||
*
|
||||
* Both patches are **lists of operations**, replayed onto a freshly computed
|
||||
* base. That is what makes a saved layout survive a release: a stored tree
|
||||
* would be a photograph of the page on the day it was saved, and every
|
||||
* improvement afterwards would be invisible to whoever had customised it.
|
||||
*
|
||||
* Skill sections are deliberately **not** merged here. A skill's `ui:` block is
|
||||
* still rendered by `SkillSurface`, exactly as it is today, and a surface is
|
||||
* simply one of the nodes a page composes. That keeps the existing Board-skill
|
||||
* behaviour byte-for-byte unchanged while still putting it in the tree, where
|
||||
* it can be hidden and reordered like anything else.
|
||||
*/
|
||||
|
||||
import { makeNode } from './node';
|
||||
import { applyPatch } from './patch';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/** @type {Map<string, any[]>} */
|
||||
const compositions = new Map();
|
||||
|
||||
/**
|
||||
* A container node's placement, as its own registration declared it.
|
||||
*
|
||||
* Read off `props` rather than from a field the engine knows about, so the
|
||||
* composition layer needs no concept of what a placement is — only that a node
|
||||
* may name one and that skill sections are grouped by the same name.
|
||||
*/
|
||||
const placementOf = (node) => String(node?.props?.placement || '');
|
||||
|
||||
/**
|
||||
* The composition with each slot's skill sections hung underneath it.
|
||||
*
|
||||
* Done here, before any patch is replayed, so a person's saved operations act
|
||||
* on the same tree they were made against — including the skill sections. A
|
||||
* patch that hides a Board card keeps working; a patch naming a card whose
|
||||
* skill has since been switched off is skipped, like any other stale operation.
|
||||
*/
|
||||
function attachSkillNodes(base, byPlacement) {
|
||||
if (!byPlacement) return base;
|
||||
return base.map((node) => {
|
||||
const placement = placementOf(node);
|
||||
const children = placement ? byPlacement[placement] : null;
|
||||
if (!children?.length) return node;
|
||||
return { ...node, children };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the nodes a page ships with.
|
||||
*
|
||||
* Called once, at module scope, beside the page it describes — so a page and
|
||||
* its composition move together and neither can be deployed without the other.
|
||||
* Re-registering replaces, which is what a hot module reload needs; a duplicate
|
||||
* is not an error the way a duplicate *type* is, because the second call is the
|
||||
* same page saying the same thing again.
|
||||
*/
|
||||
export function registerPageComposition(page, nodes) {
|
||||
const key = String(page ?? '').trim();
|
||||
if (!key) throw new Error('registerPageComposition: a composition needs a page.');
|
||||
compositions.set(key, (nodes || []).map((node) => makeNode({ origin: 'builtin', ...node })));
|
||||
return compositions.get(key);
|
||||
}
|
||||
|
||||
/** The nodes a page ships with, or an empty list for a page that has not opted in. */
|
||||
export const compositionFor = (page) => compositions.get(String(page ?? '').trim()) || [];
|
||||
|
||||
/** Whether this page composes through the node system yet. */
|
||||
export const hasComposition = (page) => compositions.has(String(page ?? '').trim());
|
||||
|
||||
/** Every page that has registered. Used by tests and, later, by the editor. */
|
||||
export const composedPages = () => [...compositions.keys()];
|
||||
|
||||
/** Forget everything. Tests only. */
|
||||
export const resetCompositions = () => compositions.clear();
|
||||
|
||||
/**
|
||||
* The tree to render for a page.
|
||||
*
|
||||
* `skipped` carries the operations that no longer apply — a saved change naming
|
||||
* a node a release has since removed. They are reported rather than thrown:
|
||||
* that is not the user's mistake, and it must not cost them the rest of their
|
||||
* layout.
|
||||
*/
|
||||
export function composePage(page, {
|
||||
patch = null, preview = null, registry = nodeRegistry, role = null,
|
||||
/**
|
||||
* The sections this page's definitions contribute, grouped by placement.
|
||||
*
|
||||
* Passed in rather than read here, because resolving them needs the account's
|
||||
* custom skills and disabled list — React state, which this module must stay
|
||||
* free of to remain a pure function two callers can trust equally.
|
||||
*/
|
||||
skillNodes = null,
|
||||
} = {}) {
|
||||
const base = attachSkillNodes(compositionFor(page), skillNodes);
|
||||
const context = { registry, role };
|
||||
const skipped = [];
|
||||
|
||||
let tree = base;
|
||||
|
||||
/* Saved first, then preview. Order matters: a preview is composed against
|
||||
what the person has already saved, so what they see while deciding is what
|
||||
they will get if they keep it. */
|
||||
for (const layer of [patch, preview]) {
|
||||
if (!layer?.ops?.length) continue;
|
||||
const result = applyPatch(tree, layer, context);
|
||||
tree = result.tree;
|
||||
skipped.push(...result.skipped);
|
||||
}
|
||||
|
||||
return { tree, skipped };
|
||||
}
|
||||
259
src/lib/ui/inspect.js
Normal file
259
src/lib/ui/inspect.js
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* What the UI looks like, described rather than drawn.
|
||||
*
|
||||
* The agent must never guess what "this card" or "that section" refers to. This
|
||||
* module turns a tree into a flat, addressable inventory — every node with its
|
||||
* id, what it is, what it is showing, and what may be done to it — so a request
|
||||
* is resolved against what is actually on the page rather than against what the
|
||||
* model remembers about the product.
|
||||
*
|
||||
* It is a *read*. Nothing here mutates, and nothing here decides: resolving an
|
||||
* ambiguous phrase to a single node is refused in favour of returning the
|
||||
* candidates, because picking one and being wrong edits the thing the user was
|
||||
* looking at while they were looking at something else.
|
||||
*
|
||||
* Everything is derived from the registry and the data vocabulary. No node
|
||||
* name, page name or component name appears below.
|
||||
*/
|
||||
|
||||
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
import { locate, walk } from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/**
|
||||
* One node, as the agent and the editor see it.
|
||||
*
|
||||
* `editable` is the answer to "what could I change here" — derived from the
|
||||
* type's declared prop schema rather than from a list kept in parallel, so a
|
||||
* property added to a registration becomes offerable without an edit here.
|
||||
*/
|
||||
export function describeNode(node, { registry = nodeRegistry, parent = null, index = 0 } = {}) {
|
||||
const entry = registry.get(node.type);
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
label: entry?.label || node.type,
|
||||
/**
|
||||
* The words on screen, which is how a person refers to a node out loud.
|
||||
*
|
||||
* Falls back to what the type can say about *this* node. Two skill slots on
|
||||
* one page both rendered as "Skill sections" — indistinguishable in the
|
||||
* outline, and unresolvable by name, because a slot carries no title of its
|
||||
* own and its type label is the same for every one of them. The type
|
||||
* supplies a `describe` and says where this one is; nothing here knows what
|
||||
* a placement is.
|
||||
*/
|
||||
title: String(node.props?.title || '').trim()
|
||||
|| (typeof entry?.describe === 'function' ? String(entry.describe(node) || '').trim() || null : null),
|
||||
known: Boolean(entry),
|
||||
container: Boolean(entry?.container),
|
||||
origin: node.origin,
|
||||
hidden: node.hidden === true,
|
||||
locked: node.locked === true,
|
||||
parent: parent?.id ?? null,
|
||||
index,
|
||||
data: node.data
|
||||
? {
|
||||
source: node.data.source,
|
||||
label: dataSourceLabel(node.data.source),
|
||||
params: node.data.params || {},
|
||||
known: Boolean(dataSourceFor(node.data.source)),
|
||||
}
|
||||
: null,
|
||||
layout: { ...(node.layout || {}) },
|
||||
/* What this node looks like, and what it *could* look like. Both, because
|
||||
every consumer needs the pair: the editor draws pickers from the second
|
||||
and marks the first, and the conversation refuses a value that is not in
|
||||
the second by name. One reading, so the two cannot disagree. */
|
||||
presentation: { ...(node.presentation || {}) },
|
||||
variants: entry ? [...entry.variants] : [],
|
||||
densities: entry ? [...entry.densities] : [],
|
||||
capabilities: entry ? [...entry.capabilities] : [],
|
||||
editable: entry
|
||||
? Object.entries(entry.propSchema).map(([key, rule]) => ({
|
||||
key,
|
||||
label: rule.label || key,
|
||||
kind: Array.isArray(rule.enum) ? 'enum' : rule.type || 'string',
|
||||
options: Array.isArray(rule.enum) ? [...rule.enum] : null,
|
||||
required: rule.required === true,
|
||||
value: node.props?.[key] ?? null,
|
||||
}))
|
||||
: [],
|
||||
children: (node.children || []).map((child) => child.id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole tree, flattened.
|
||||
*
|
||||
* Flat rather than nested because every question the agent asks is "which node
|
||||
* is this" — and a nested shape makes that a traversal at every call site.
|
||||
* Parentage survives as `parent` and `index`, so the structure is still
|
||||
* recoverable.
|
||||
*/
|
||||
export function inspectTree(nodes, { registry = nodeRegistry } = {}) {
|
||||
const out = [];
|
||||
|
||||
const visit = (list, parent) => {
|
||||
(list || []).forEach((node, index) => {
|
||||
out.push(describeNode(node, { registry, parent, index }));
|
||||
if (node.children?.length) visit(node.children, node);
|
||||
});
|
||||
};
|
||||
|
||||
visit(nodes, null);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, readable rendering of the inventory.
|
||||
*
|
||||
* What a person is shown when they ask what is on the page, and what a
|
||||
* conversation quotes back when a phrase matched more than one node. Indented
|
||||
* by depth so the structure reads without drawing it.
|
||||
*/
|
||||
export function outlineTree(nodes, { registry = nodeRegistry } = {}) {
|
||||
const lines = [];
|
||||
|
||||
const visit = (list, depth) => {
|
||||
for (const node of list || []) {
|
||||
const entry = registry.get(node.type);
|
||||
const title = String(node.props?.title || '').trim();
|
||||
const bits = [
|
||||
`${' '.repeat(depth)}${title || entry?.label || node.type}`,
|
||||
`(${node.id})`,
|
||||
node.hidden ? '· hidden' : '',
|
||||
node.data?.source ? `· ${dataSourceLabel(node.data.source)}` : '',
|
||||
].filter(Boolean);
|
||||
lines.push(bits.join(' '));
|
||||
if (node.children?.length) visit(node.children, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
visit(nodes, 0);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nodes a phrase could mean, best first.
|
||||
*
|
||||
* Deliberately a *list*. The caller decides what to do with two candidates, and
|
||||
* the right answer in a conversation is to ask — so this never collapses a tie,
|
||||
* and never returns a node on no evidence at all.
|
||||
*
|
||||
* Scoring is over what the node itself says: its id, its title, its type label
|
||||
* and the label of the reading it shows. There is no dictionary of component
|
||||
* names here, so a type registered tomorrow is matchable by its own label
|
||||
* without this function changing.
|
||||
*/
|
||||
export function resolveTarget(nodes, phrase, {
|
||||
registry = nodeRegistry, type = null,
|
||||
/**
|
||||
* Narrow to nodes that are, or are not, currently hidden.
|
||||
*
|
||||
* A hidden node stays in the tree and stays addressable — the renderer skips
|
||||
* it, nothing else does. This filter exists so a caller can ask the question
|
||||
* that actually disambiguates "show the timeline": is there something by that
|
||||
* name which is currently not on screen? `null` means do not care.
|
||||
*/
|
||||
hidden = null,
|
||||
} = {}) {
|
||||
const want = canon(phrase);
|
||||
if (!want) return [];
|
||||
|
||||
/* Words that carry no evidence about which node is meant. Matching on them
|
||||
would make every phrase fit every node. */
|
||||
const tokens = want.split(' ').filter((word) => word.length > 2 && !STOP.has(word));
|
||||
|
||||
const candidates = walk(nodes)
|
||||
.filter((node) => (type ? node.type === type : true))
|
||||
.filter((node) => (hidden === null ? true : Boolean(node.hidden) === hidden))
|
||||
.map((node) => {
|
||||
const entry = registry.get(node.type);
|
||||
const title = canon(node.props?.title);
|
||||
const label = canon(entry?.label || node.type);
|
||||
const source = canon(node.data ? dataSourceLabel(node.data.source) : '');
|
||||
const id = canon(node.id);
|
||||
|
||||
let score = 0;
|
||||
/* An id said verbatim is not a guess — it is the address, and it wins. */
|
||||
if (id && id === want) score += 100;
|
||||
if (title && title === want) score += 60;
|
||||
if (title && want.includes(title)) score += 40;
|
||||
if (title && title.includes(want)) score += 24;
|
||||
if (label && want.includes(label)) score += 18;
|
||||
if (source && want.includes(source)) score += 14;
|
||||
if (id && want.includes(id)) score += 10;
|
||||
|
||||
/**
|
||||
* Part of a name is still a name.
|
||||
*
|
||||
* "the notice" has to reach "Privileged actions notice", and nobody says
|
||||
* a section's full label out loud. Scored per matching word and below
|
||||
* every whole-name rule above, so a partial match never outranks somebody
|
||||
* naming the thing properly — which is what keeps "the audit section"
|
||||
* pointing at the audit log rather than tying with "Skill sections".
|
||||
*/
|
||||
for (const token of tokens) {
|
||||
if (title && title.includes(token)) score += 8;
|
||||
else if (label && label.includes(token)) score += 6;
|
||||
else if (id && id.includes(token)) score += 4;
|
||||
}
|
||||
|
||||
return { node, score };
|
||||
})
|
||||
.filter((row) => row.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
return candidates.map((row) => ({
|
||||
...describeNode(row.node, {
|
||||
registry,
|
||||
parent: locate(nodes, row.node.id)?.parent || null,
|
||||
index: locate(nodes, row.node.id)?.index || 0,
|
||||
}),
|
||||
/* Carried out so a caller can tell a clear winner from a tie. Deciding that
|
||||
here would be deciding what to do about ambiguity, which belongs to
|
||||
whoever has somebody to ask. */
|
||||
score: row.score,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Words too common to distinguish one node from another. */
|
||||
const STOP = new Set([
|
||||
'the', 'this', 'that', 'these', 'those', 'and', 'for', 'with', 'from', 'into',
|
||||
'show', 'hide', 'move', 'make', 'add', 'put', 'change', 'turn', 'switch',
|
||||
'above', 'below', 'under', 'over', 'before', 'after', 'top', 'bottom',
|
||||
'please', 'section', 'sections', 'panel', 'panels', 'page', 'here',
|
||||
]);
|
||||
|
||||
/** Lower-case, punctuation-free, single-spaced. The one normaliser for matching. */
|
||||
const canon = (value) => String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* What could be added inside a container, and what each could show.
|
||||
*
|
||||
* The picker's source of truth, for the agent and the visual editor alike.
|
||||
* Reads the registry and the data vocabulary, so a newly registered type is
|
||||
* offered without this being touched.
|
||||
*/
|
||||
export function addableTypes(parentType, { registry = nodeRegistry, role = null, page = null } = {}) {
|
||||
return registry.all()
|
||||
/* `offersChild`, not `acceptsChild`: what may structurally sit here is only
|
||||
half the question. The other half — is this a type a person may add, and
|
||||
does it belong to this page — is what kept every page's private sections
|
||||
out of every other page's picker. */
|
||||
.filter((entry) => registry.offersChild(parentType, entry.type, { page }))
|
||||
.filter((entry) => !entry.roles || !role || entry.roles.includes(role))
|
||||
.map((entry) => ({
|
||||
type: entry.type,
|
||||
label: entry.label,
|
||||
summary: entry.summary,
|
||||
container: entry.container,
|
||||
dataShapes: [...entry.dataShapes],
|
||||
dataRequired: entry.dataRequired,
|
||||
}));
|
||||
}
|
||||
579
src/lib/ui/intent.js
Normal file
579
src/lib/ui/intent.js
Normal file
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* A request in words, turned into one validated operation.
|
||||
*
|
||||
* This is the whole of Owliver's UI-editing understanding, and it is
|
||||
* deliberately small. It reads the verbs from a table, the type names from the
|
||||
* node registry, the data sources from the closed vocabulary, and the targets
|
||||
* from the tree that is actually on screen. There is no page in it, no
|
||||
* component name, and no branch on what a node happens to be — a type
|
||||
* registered tomorrow is addressable tomorrow, by its own label, with this file
|
||||
* unchanged.
|
||||
*
|
||||
* What it can produce is an **operation**, never markup. The model — when there
|
||||
* is one — is not in this path at all: matching is deterministic, which is what
|
||||
* makes it impossible for a hallucinated component name or an invented data
|
||||
* source to reach the engine. The worst a request can do is fail to match.
|
||||
*
|
||||
* Every outcome is one of a small set, and two of them are questions rather
|
||||
* than actions:
|
||||
*
|
||||
* - `plan` an operation, ready to preview
|
||||
* - `inspect` a description of what is on the page
|
||||
* - `apply` / `discard` acting on a preview already shown
|
||||
* - `ambiguous` more than one node fits, so the caller must ask
|
||||
* - `unknown` a target that matches nothing on the page
|
||||
* - `refused` understood, and not allowed — with the reason
|
||||
*/
|
||||
|
||||
import { SUPPORTED_DATA_SOURCES, dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
import { addableTypes, describeNode, resolveTarget } from './inspect';
|
||||
import { freeNodeId, walk } from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/** Lower-case, punctuation-free. The one normaliser, shared with `inspect`. */
|
||||
const canon = (value) => String(value ?? '')
|
||||
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
|
||||
const has = (text, ...words) => words.some((w) => text.includes(canon(w)));
|
||||
|
||||
/**
|
||||
* The verbs, as data.
|
||||
*
|
||||
* Each names an operation and the words that ask for it. Ordered: the first
|
||||
* match wins, so the more specific readings are declared before the general
|
||||
* ones. Nothing here names a component or a page.
|
||||
*/
|
||||
const VERBS = [
|
||||
{ op: 'inspect', words: ['what is on this page', 'whats on this page', 'what sections', 'list the sections', 'show me the layout', 'what can i change'] },
|
||||
{ op: 'apply', words: ['apply', 'keep it', 'keep that', 'save it', 'save that', 'yes apply'] },
|
||||
{ op: 'discard', words: ['discard', 'cancel that', 'undo that', 'never mind', 'nevermind', 'revert that'] },
|
||||
/* Unambiguously about the interface: nobody says "unhide" about data. */
|
||||
{ op: 'unhide', words: ['unhide', 'show again', 'bring back', 'bring it back', 'restore', 'put back', 'reveal'] },
|
||||
|
||||
{ op: 'hide', words: ['hide', 'remove', 'delete', 'get rid of', 'take off', 'take away'] },
|
||||
{ op: 'move', words: ['move', 'put', 'place', 'reorder', 'bring'] },
|
||||
/**
|
||||
* How something looks, rather than what it is.
|
||||
*
|
||||
* Before `replace`, because "change this card to compact" names a
|
||||
* presentation value and `replace` would read the same sentence as a request
|
||||
* for a different type. The words themselves are the gate: none of them is
|
||||
* something a person says about data.
|
||||
*/
|
||||
{ op: 'present', words: ['compact', 'comfortable', 'spacious', 'emphasis', 'emphasise', 'emphasize', 'subtle', 'denser', 'tighter', 'roomier'] },
|
||||
|
||||
{ op: 'replace', words: ['change', 'turn', 'switch', 'convert', 'make it a', 'show as', 'show it as'] },
|
||||
{ op: 'add', words: ['add', 'insert', 'create a', 'put a new'] },
|
||||
{ op: 'layout', words: ['columns', 'column', 'side by side', 'two up', 'wider', 'narrower'] },
|
||||
/**
|
||||
* Plain "show" is the hard one, and it is matched last.
|
||||
*
|
||||
* "Show me the candidates" is a reading; "show the timeline" — when the
|
||||
* timeline is hidden — is a layout change. The word cannot tell them apart,
|
||||
* so the *tree* does: this becomes a UI edit only when the phrase names
|
||||
* something currently hidden, and names it convincingly. Last in the list
|
||||
* because the word appears inside other requests — "add a card showing
|
||||
* candidate activity" is an `add`, and would be stolen by an earlier `show`.
|
||||
*/
|
||||
{ op: 'show', words: ['show', 'display'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Words that say the request is about the interface rather than about the data.
|
||||
*
|
||||
* "chart" used to be here and is not any more: it names a registered type now,
|
||||
* so `namedType` recognises it and the gate already opens on that. Keeping it
|
||||
* would have been the language layer holding a component name of its own —
|
||||
* which is the thing the check script greps for, and rightly.
|
||||
*/
|
||||
const UI_WORDS = [
|
||||
'section', 'sections', 'panel', 'panels', 'block', 'blocks', 'widget', 'widgets',
|
||||
'layout', 'page', 'column', 'columns',
|
||||
'above', 'below', 'top', 'bottom', 'first', 'last', 'order',
|
||||
];
|
||||
|
||||
const SHOW_CONFIDENCE = 18;
|
||||
|
||||
const NUMBER_WORDS = { one: 1, two: 2, three: 3, four: 4, six: 6, twelve: 12 };
|
||||
|
||||
/**
|
||||
* Read a request.
|
||||
*
|
||||
* `tree` is what is on screen. Nothing is matched against a remembered page —
|
||||
* a target is only resolvable if it is really there, which is what stops a
|
||||
* confident answer about a section that does not exist.
|
||||
*/
|
||||
export function matchUiEdit(question, {
|
||||
tree = [], registry = nodeRegistry, role = null, previewing = false,
|
||||
/* The page being edited. Only ever compared, never interpreted — it is what
|
||||
keeps "add a recent hiring timeline" from being answerable on a page that
|
||||
publishes none of the records such a section reads. */
|
||||
page = null,
|
||||
} = {}) {
|
||||
const text = canon(question);
|
||||
if (!text) return null;
|
||||
|
||||
const verb = VERBS.find((v) => has(text, ...v.words));
|
||||
if (!verb) return null;
|
||||
|
||||
/**
|
||||
* Apply and discard.
|
||||
*
|
||||
* While something is being previewed these are unambiguous. With nothing
|
||||
* previewed they are not: "apply" is an ordinary word — applying for a role,
|
||||
* applying a filter — and this must not swallow it.
|
||||
*
|
||||
* But it must not hand back the panel's *own* words either. The chips this
|
||||
* panel offers say "Apply the layout change" and "Discard the layout change",
|
||||
* and a person who clicks one a second time, or after a re-render has dropped
|
||||
* the preview, was previously answered by the model: an agent scoped to open
|
||||
* roles explaining that layout changes are not in its scope. A request about
|
||||
* the interface must never be answered by something that does not know the
|
||||
* interface exists.
|
||||
*
|
||||
* So the phrase is claimed when it names the interface — the same word gate
|
||||
* the other verbs use — and answered with the plain fact that there is
|
||||
* nothing to act on. Everything else still falls through untouched.
|
||||
*/
|
||||
if (verb.op === 'apply' || verb.op === 'discard') {
|
||||
if (previewing) return { kind: verb.op };
|
||||
return has(text, ...UI_WORDS) ? { kind: 'nothing-previewed', op: verb.op } : null;
|
||||
}
|
||||
|
||||
if (verb.op === 'inspect') return { kind: 'inspect' };
|
||||
|
||||
/**
|
||||
* The gate that keeps ordinary questions ordinary.
|
||||
*
|
||||
* A verb alone is not enough — "show me the candidates" is a reading, not a
|
||||
* layout change. The request has to also name something on this page, a type
|
||||
* the registry knows, or a word about the interface itself.
|
||||
*/
|
||||
/**
|
||||
* Bringing something back is decided by the tree, before the word gate.
|
||||
*
|
||||
* `show` deliberately does not consult the interface-words gate: the evidence
|
||||
* that it means the interface is that a hidden node answers to the phrase,
|
||||
* and requiring "section" or "panel" as well would make the only way to undo
|
||||
* a hide harder to say than the hide was.
|
||||
*/
|
||||
if (verb.op === 'show') return planShow(text, tree, registry);
|
||||
if (verb.op === 'unhide') return planUnhide(text, tree, registry);
|
||||
|
||||
const named = namedType(text, registry, page);
|
||||
const mentionsUi = has(text, ...UI_WORDS) || Boolean(named);
|
||||
const anyTarget = walk(tree).some((node) => resolveTarget(tree, text, { registry }).length > 0);
|
||||
if (!mentionsUi && !anyTarget) return null;
|
||||
|
||||
switch (verb.op) {
|
||||
case 'hide': return planVisibility(text, tree, registry, true);
|
||||
case 'move': return planMove(text, tree, registry);
|
||||
case 'replace': return planReplace(text, tree, registry, page);
|
||||
case 'add': return planAdd(text, tree, registry, named, role, page);
|
||||
case 'layout': return planLayout(text, tree, registry);
|
||||
case 'present': return planPresent(text, tree, registry);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The node a phrase means, or the question to ask instead.
|
||||
*
|
||||
* Ambiguity is never resolved by picking the first: editing the wrong section
|
||||
* while somebody is looking at another one is the failure this exists to
|
||||
* prevent. Two candidates come back as a question with both named.
|
||||
*/
|
||||
function target(text, tree, registry, { exclude = [], preferHidden = false } = {}) {
|
||||
const all = resolveTarget(tree, text, { registry })
|
||||
.filter((node) => !exclude.includes(node.id));
|
||||
/* "Bring back the timeline" means the hidden one, when a hidden one fits.
|
||||
Only a preference: with nothing hidden, the phrase still resolves. */
|
||||
const hiddenOnly = all.filter((node) => node.hidden);
|
||||
const hits = preferHidden && hiddenOnly.length ? hiddenOnly : all;
|
||||
|
||||
if (!hits.length) return { kind: 'unknown', phrase: text };
|
||||
|
||||
/**
|
||||
* A tie is two nodes the phrase fits equally well.
|
||||
*
|
||||
* Judged on the score the resolver assigned rather than on a second reading
|
||||
* of the words here: one place decides how well a phrase fits a node, and
|
||||
* this only decides what to do when two fit the same. A clear winner is acted
|
||||
* on; anything else is a question back.
|
||||
*/
|
||||
if (hits.length > 1 && hits[0].score === hits[1].score) {
|
||||
return { kind: 'ambiguous', candidates: hits.filter((h) => h.score === hits[0].score).slice(0, 4) };
|
||||
}
|
||||
return { node: hits[0] };
|
||||
}
|
||||
|
||||
|
||||
/** Hide or show a node the phrase named. */
|
||||
function planVisibility(text, tree, registry, hidden) {
|
||||
const found = target(text, tree, registry);
|
||||
if (found.kind) return found;
|
||||
return visibilityPlan(found.node, hidden);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan, or the reason there is nothing to do.
|
||||
*
|
||||
* A node already in the state being asked for produces no operation. Saying so
|
||||
* is better than storing a second `hide` on something hidden: the patch stays
|
||||
* the record of decisions a person actually made, and undo steps back through
|
||||
* changes rather than through no-ops.
|
||||
*/
|
||||
function visibilityPlan(node, hidden) {
|
||||
if (!node.capabilities.includes('hide')) {
|
||||
return { kind: 'refused', message: `\`${node.label}\` cannot be ${hidden ? 'hidden' : 'shown'}.` };
|
||||
}
|
||||
if (Boolean(node.hidden) === hidden) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: `\`${node.title || node.label}\` is already ${hidden ? 'hidden' : 'showing'}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'hide', target: node.id, hidden },
|
||||
summary: `${hidden ? 'Hide' : 'Show'} ${node.title || node.label}`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain "show", resolved only against what is hidden.
|
||||
*
|
||||
* Returning null is the important branch: it is what leaves "show me the
|
||||
* candidates" to the rest of Owliver untouched. A hidden node answering to the
|
||||
* phrase is the whole of the evidence that the interface was meant.
|
||||
*/
|
||||
function planShow(text, tree, registry) {
|
||||
const hits = resolveTarget(tree, text, { registry, hidden: true })
|
||||
/**
|
||||
* Enough evidence to outweigh the ordinary meaning of the word.
|
||||
*
|
||||
* `SHOW_CONFIDENCE` is the score a phrase earns by naming a node properly —
|
||||
* containing its whole label or title. A single shared word does not reach
|
||||
* it, which is what keeps "show me the recent hires" a question about hires
|
||||
* rather than a request to reveal the Recent hiring timeline.
|
||||
*/
|
||||
.filter((node) => node.score >= SHOW_CONFIDENCE);
|
||||
if (!hits.length) return null;
|
||||
if (hits.length > 1 && hits[0].score === hits[1].score) {
|
||||
return { kind: 'ambiguous', candidates: hits.filter((h) => h.score === hits[0].score).slice(0, 4) };
|
||||
}
|
||||
return visibilityPlan(hits[0], false);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Unhide", "bring it back", "restore".
|
||||
*
|
||||
* Always a layout request, so this resolves against the whole tree and reports
|
||||
* when the thing named is already on screen — which is more useful than
|
||||
* silently matching nothing.
|
||||
*/
|
||||
function planUnhide(text, tree, registry) {
|
||||
const found = target(text, tree, registry, { preferHidden: true });
|
||||
if (found.kind) return found;
|
||||
return visibilityPlan(found.node, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one node relative to another, or to an end of the page.
|
||||
*
|
||||
* Expressed as a `reorder` of the root rather than a `move`, because that is
|
||||
* what "above the notice" means on a flat page: the node stays where it lives
|
||||
* and the order around it changes.
|
||||
*/
|
||||
function planMove(text, tree, registry) {
|
||||
const roots = tree.map((node) => describeNode(node, { registry }));
|
||||
|
||||
const before = has(text, 'above', 'before', 'over', 'on top of', 'to the top', 'first');
|
||||
const after = has(text, 'below', 'under', 'beneath', 'after', 'to the bottom', 'last', 'end');
|
||||
|
||||
/* Split on the positional word so the two halves name two different nodes:
|
||||
"move timeline above the notice" is a subject and a reference. */
|
||||
const pivot = ['above', 'before', 'below', 'under', 'beneath', 'after', 'over']
|
||||
.map((w) => ({ w, at: text.indexOf(` ${w} `) }))
|
||||
.filter((p) => p.at > 0)
|
||||
.sort((a, b) => a.at - b.at)[0];
|
||||
|
||||
const subjectText = pivot ? text.slice(0, pivot.at) : text;
|
||||
const subject = target(subjectText, tree, registry);
|
||||
if (subject.kind) return subject;
|
||||
|
||||
const order = roots.map((n) => n.id);
|
||||
const from = order.indexOf(subject.node.id);
|
||||
if (from < 0) return { kind: 'unknown', phrase: subjectText };
|
||||
|
||||
if (!subject.node.capabilities.includes('move')) {
|
||||
return { kind: 'refused', message: `\`${subject.node.label}\` cannot be moved.` };
|
||||
}
|
||||
|
||||
let to;
|
||||
if (pivot) {
|
||||
const referenceText = text.slice(pivot.at + pivot.w.length + 2);
|
||||
const reference = target(referenceText, tree, registry, { exclude: [subject.node.id] });
|
||||
if (reference.kind) return reference;
|
||||
const at = order.indexOf(reference.node.id);
|
||||
if (at < 0) return { kind: 'unknown', phrase: referenceText };
|
||||
to = ['above', 'before', 'over'].includes(pivot.w) ? at : at + 1;
|
||||
} else if (before) {
|
||||
to = 0;
|
||||
} else if (after) {
|
||||
to = order.length;
|
||||
} else {
|
||||
return { kind: 'unknown', phrase: text };
|
||||
}
|
||||
|
||||
const next = [...order];
|
||||
next.splice(from, 1);
|
||||
next.splice(to > from ? to - 1 : to, 0, subject.node.id);
|
||||
|
||||
if (next.join() === order.join()) {
|
||||
return { kind: 'refused', message: `\`${subject.node.title || subject.node.label}\` is already there.` };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'reorder', parent: null, order: next },
|
||||
summary: `Move ${subject.node.title || subject.node.label}`,
|
||||
node: subject.node,
|
||||
};
|
||||
}
|
||||
|
||||
/** The registered type a phrase names, by type id or by its label. */
|
||||
function namedType(text, registry, page = null) {
|
||||
const hit = registry.all()
|
||||
/* Only what this page could actually hold. A type named in a sentence that
|
||||
does not belong here is not a type this reader can mean. */
|
||||
.filter((entry) => registry.offersChild(null, entry.type, { page }))
|
||||
.map((entry) => ({ entry, word: canon(entry.label) }))
|
||||
.filter(({ entry, word }) => has(text, entry.type) || (word && has(text, word)))
|
||||
/* Longest label first, so "audit log" is not read as "log". */
|
||||
.sort((a, b) => b.word.length - a.word.length)[0];
|
||||
return hit?.entry || null;
|
||||
}
|
||||
|
||||
/** Turn one node into another type. */
|
||||
function planReplace(text, tree, registry, page = null) {
|
||||
/**
|
||||
* The type asked for is the one after the connector.
|
||||
*
|
||||
* "change the audit log to a table" names two things the registry knows — the
|
||||
* section being changed and the shape it should become — and reading the whole
|
||||
* sentence for a type finds whichever label happens to be longer. The
|
||||
* connector is what tells them apart, and it is how people say it.
|
||||
*/
|
||||
const split = /\b(?:in)?to\s+(?:an?\s+)?|\bas\s+(?:an?\s+)?/.exec(text);
|
||||
const wanted = split ? text.slice(split.index + split[0].length) : text;
|
||||
/* Page-scoped, like every other reading of a type name. Without it a request
|
||||
could name a section belonging to another page, and the refusal listed the
|
||||
whole registry back — every page's private sections, to a person who can
|
||||
use none of them. */
|
||||
const named = namedType(wanted, registry, page);
|
||||
|
||||
if (!named) {
|
||||
return {
|
||||
kind: 'unknown-type',
|
||||
phrase: text,
|
||||
offered: addableTypes(null, { registry, page }).map((t) => t.type),
|
||||
};
|
||||
}
|
||||
|
||||
/* The subject is whatever came before the connector; with no connector, the
|
||||
sentence minus the type name. */
|
||||
const subject = split ? text.slice(0, split.index) : text.replace(canon(named.label), ' ');
|
||||
const found = target(subject, tree, registry);
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
if (!node.capabilities.includes('replace')) {
|
||||
return { kind: 'refused', message: `\`${node.label}\` cannot be changed into something else.` };
|
||||
}
|
||||
if (node.type === named.type) {
|
||||
return { kind: 'refused', message: `\`${node.title || node.label}\` is already a ${named.label}.` };
|
||||
}
|
||||
|
||||
/* The binding decides what it can become. Asking the registry rather than
|
||||
deciding here is what keeps this free of type knowledge. */
|
||||
const shapes = node.data ? dataSourceFor(node.data.source)?.shapes || [] : null;
|
||||
const allowed = registry.replacements(node.type, { shapes, page });
|
||||
if (!allowed.includes(named.type)) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: node.data
|
||||
? `\`${dataSourceLabel(node.data.source)}\` cannot be shown as a ${named.label}.`
|
||||
: `\`${node.label}\` cannot become a ${named.label}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'replace', target: node.id, type: named.type },
|
||||
summary: `Change ${node.title || node.label} to a ${named.label}`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reading.
|
||||
*
|
||||
* A new node needs a type and a source, and both must be named — a source is
|
||||
* never guessed. Asked for without one, this returns the choices rather than
|
||||
* inventing a binding, which is the difference between a product that offers
|
||||
* what it has and one that makes something up.
|
||||
*/
|
||||
function planAdd(text, tree, registry, named, role, page = null) {
|
||||
if (!named) {
|
||||
return {
|
||||
kind: 'unknown-type',
|
||||
phrase: text,
|
||||
offered: addableTypes(null, { registry, role, page }).map((t) => t.type),
|
||||
};
|
||||
}
|
||||
if (!registry.offersChild(null, named.type, { page })) {
|
||||
return { kind: 'refused', message: `A ${named.label} cannot be added to this page.` };
|
||||
}
|
||||
if (named.roles && role && !named.roles.includes(role)) {
|
||||
return { kind: 'refused', message: `You do not have access to ${named.label}.` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which readings could fill this shape *here*.
|
||||
*
|
||||
* Filtered by what the page can answer, not just by shape. A reading that
|
||||
* needs a record — "Position activity" — placed on a page that is inside no
|
||||
* record renders "This section needs a position to read." forever, and
|
||||
* offering it is the same mistake the visual picker was making.
|
||||
*/
|
||||
const fits = (id) => {
|
||||
const entry = dataSourceFor(id);
|
||||
if (!entry || !(entry.shapes || []).includes(named.type)) return false;
|
||||
return !entry.context;
|
||||
};
|
||||
|
||||
const source = namedSource(text, named, fits);
|
||||
if (!source) {
|
||||
const options = SUPPORTED_DATA_SOURCES.filter(fits);
|
||||
return { kind: 'needs-source', type: named, options: options.slice(0, 6) };
|
||||
}
|
||||
if (!fits(source)) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: `${dataSourceLabel(source)} needs a record this page is not showing.`,
|
||||
};
|
||||
}
|
||||
|
||||
const id = freeNodeId(tree, named.type);
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: {
|
||||
op: 'add',
|
||||
parent: null,
|
||||
node: { id, type: named.type, data: { source }, props: { title: dataSourceLabel(source) } },
|
||||
},
|
||||
summary: `Add a ${named.label} showing ${dataSourceLabel(source)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The data source a phrase names, restricted to what this type can draw. */
|
||||
function namedSource(text, named, answerable = null) {
|
||||
const hits = SUPPORTED_DATA_SOURCES
|
||||
.filter((id) => (dataSourceFor(id)?.shapes || []).includes(named.type))
|
||||
.map((id) => ({ id, words: canon(dataSourceLabel(id)) }))
|
||||
.filter(({ id, words }) => has(text, words) || has(text, canon(id)))
|
||||
.sort((a, b) => b.words.length - a.words.length);
|
||||
|
||||
if (!hits.length) return null;
|
||||
|
||||
/**
|
||||
* Two readings can answer to the same words.
|
||||
*
|
||||
* `candidate.activity` and `candidates.activity` are both labelled "Candidate
|
||||
* activity" — one is what happened on one candidate's record, the other is
|
||||
* applications across the workspace. Nothing in the phrase separates them, so
|
||||
* a sort decided, and on a page showing no candidate the sort could pick the
|
||||
* one that can never resolve: a panel reading "This section needs a candidate
|
||||
* to read." for as long as it is kept.
|
||||
*
|
||||
* So where the words do not decide, what the page can answer does. A reading
|
||||
* that fits is preferred over one that cannot; with nothing to choose
|
||||
* between, the longest match still wins and the caller refuses it by name.
|
||||
*/
|
||||
const fits = answerable || (() => true);
|
||||
return (hits.find((hit) => fits(hit.id)) || hits[0]).id;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a node presents itself.
|
||||
*
|
||||
* The words map to values from the closed vocabulary and nothing else — there
|
||||
* is no path from a sentence to a class name. "Reset" is included because
|
||||
* putting something back is the request people actually make after trying
|
||||
* something, and it has to be sayable.
|
||||
*/
|
||||
function planPresent(text, tree, registry) {
|
||||
const wants = {};
|
||||
if (has(text, 'compact', 'denser', 'tighter')) wants.density = 'compact';
|
||||
if (has(text, 'comfortable', 'spacious', 'roomier')) wants.density = 'comfortable';
|
||||
if (has(text, 'emphasis', 'emphasise', 'emphasize')) wants.variant = 'emphasis';
|
||||
if (has(text, 'subtle')) wants.variant = 'subtle';
|
||||
if (has(text, 'default', 'reset', 'normal')) wants.variant = 'default';
|
||||
if (!Object.keys(wants).length) return { kind: 'unknown', phrase: text };
|
||||
|
||||
const found = target(text, tree, registry);
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
if (!node.capabilities.includes('update')) {
|
||||
return { kind: 'refused', message: `\`${node.label}\` cannot be changed.` };
|
||||
}
|
||||
|
||||
/* Refused by name where the type cannot draw it, rather than stored as a
|
||||
setting the component ignores — the same rule the validator applies, said
|
||||
earlier so the person hears it instead of seeing nothing happen. */
|
||||
for (const [key, value] of Object.entries(wants)) {
|
||||
const supported = key === 'variant' ? node.variants : node.densities;
|
||||
if (!supported.includes(value)) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: supported.length
|
||||
? `\`${node.title || node.label}\` supports ${key}: ${supported.join(', ')}.`
|
||||
: `\`${node.title || node.label}\` has no ${key} to set.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const said = Object.entries(wants).map(([k, v]) => `${k} ${v}`).join(' and ');
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'update', target: node.id, presentation: wants },
|
||||
summary: `Set ${node.title || node.label} to ${said}`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
/** Column counts. */
|
||||
function planLayout(text, tree, registry) {
|
||||
const digit = /(\d+)\s*(?:column|col)/.exec(text)?.[1];
|
||||
const word = Object.keys(NUMBER_WORDS).find((w) => has(text, `${w} column`));
|
||||
const columns = Number(digit) || NUMBER_WORDS[word] || (has(text, 'side by side', 'two up') ? 2 : null);
|
||||
if (!columns) return { kind: 'unknown', phrase: text };
|
||||
|
||||
const found = target(text, tree, registry);
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
if (!node.capabilities.includes('update')) {
|
||||
return { kind: 'refused', message: `\`${node.label}\` cannot be changed.` };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'update', target: node.id, layout: { columns } },
|
||||
summary: `Make ${node.title || node.label} ${columns} columns`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
315
src/lib/ui/node.js
Normal file
315
src/lib/ui/node.js
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* A UI node — the unit the agent and the editor address.
|
||||
*
|
||||
* This is the whole shape of a piece of interface as far as the mutation engine
|
||||
* is concerned. It is deliberately dumb: a node names a *type* and carries
|
||||
* *configuration*, and it never carries a component, a class name, markup, or
|
||||
* anything that could be evaluated. Turning a node into pixels is the
|
||||
* renderer's job, and the renderer only ever looks the type up in a registry of
|
||||
* components the application already ships.
|
||||
*
|
||||
* That split is the security boundary. `type` is a key, not an import; `props`
|
||||
* are checked against a schema the type declares; `data` names a reading from
|
||||
* the closed vocabulary in `lib/skills/surfaces.js`. There is no field here
|
||||
* through which a definition can introduce code, and no field the engine passes
|
||||
* through without checking. It is the same guarantee `SkillSections.jsx` and
|
||||
* `uiConfig.js` already make for skill sections, widened from one slot to a
|
||||
* whole tree.
|
||||
*
|
||||
* Nothing in this module knows a page name, a component name, or a product
|
||||
* feature. Everything specific lives in the registry (`registry.js`) or in the
|
||||
* composition a page publishes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Where a node came from, which is what keeps the three persistence tiers
|
||||
* separate.
|
||||
*
|
||||
* `builtin` is the application's own composition; `skill` is adapted from a
|
||||
* definition's `ui:` block; `user` is something a person added at runtime. The
|
||||
* distinction matters at save time — a user patch is stored as operations
|
||||
* against the other two, never as a copy of them — and at delete time, because
|
||||
* removing a built-in is hiding it, while removing a node a user added is
|
||||
* really removing it.
|
||||
*/
|
||||
export const NODE_ORIGINS = ['builtin', 'skill', 'user'];
|
||||
|
||||
/**
|
||||
* What may be done to a node, declared per type rather than assumed.
|
||||
*
|
||||
* A type opts in. The engine never infers a capability from a type's name or
|
||||
* shape, so a component that must not be moved says so once, in its
|
||||
* registration, and every operation honours it without knowing what it is.
|
||||
*
|
||||
* `add` and `reorder` are about a node's *children* and only mean anything on a
|
||||
* container. The rest are about the node itself.
|
||||
*/
|
||||
export const NODE_CAPABILITIES = [
|
||||
'add', 'update', 'remove', 'move', 'replace', 'hide', 'reorder',
|
||||
];
|
||||
|
||||
/** Ids are addresses. Same rule as a section id, so the two can never disagree. */
|
||||
export const NODE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/**
|
||||
* How wide a node may be, as a count of grid columns.
|
||||
*
|
||||
* Bounded here rather than per type so that "make this two columns" has one
|
||||
* answer everywhere; a type narrows it further through `constraints`.
|
||||
* Twelve because that is the largest arrangement the grid utilities express,
|
||||
* and an unbounded number is a layout that breaks below a phone width.
|
||||
*/
|
||||
export const MIN_COLUMNS = 1;
|
||||
export const MAX_COLUMNS = 12;
|
||||
|
||||
/** The layout keys a node may carry. Anything else an author writes is dropped. */
|
||||
export const LAYOUT_KEYS = ['columns', 'span', 'gap', 'align', 'spacingBefore', 'spacingAfter'];
|
||||
|
||||
export const GAP_VALUES = ['none', 'sm', 'md', 'lg'];
|
||||
export const ALIGN_VALUES = ['start', 'center', 'end', 'stretch'];
|
||||
|
||||
/**
|
||||
* Space above or below a node, as a step on a scale rather than a measurement.
|
||||
*
|
||||
* A closed set of four, each mapping to one class the design system already
|
||||
* ships. This exists because a page sometimes needs a node to sit further from
|
||||
* what precedes it than the page's own rhythm gives — and the alternative,
|
||||
* letting configuration carry a class name, would put arbitrary CSS into a
|
||||
* schema a person can edit. A step cannot say anything the product has not
|
||||
* already decided it can say.
|
||||
*/
|
||||
export const SPACING_VALUES = ['none', 'sm', 'md', 'lg'];
|
||||
|
||||
/**
|
||||
* How a node presents itself, as distinct from where it sits.
|
||||
*
|
||||
* `layout` answers *arrangement* — columns, span, the space around a node.
|
||||
* This answers *treatment*: how tight the node is and how much weight it
|
||||
* carries. They are kept apart because they are edited for different reasons
|
||||
* and because calling this "layout" would make the word mean everything.
|
||||
*
|
||||
* Two closed scales, and closed is the point. A person can say "compact" and
|
||||
* the product decides what compact means; there is no value here that carries a
|
||||
* class name, a measurement or a colour, so nothing a person or an agent writes
|
||||
* can reach the stylesheet. A type that has not declared it supports a value
|
||||
* refuses it — see `variants` and `densities` on a registration.
|
||||
*/
|
||||
export const PRESENTATION_KEYS = ['variant', 'density'];
|
||||
|
||||
/** The weight a node carries. `default` is the panel every section already draws. */
|
||||
export const VARIANT_VALUES = ['default', 'subtle', 'emphasis'];
|
||||
|
||||
/** How tightly a node is packed. `comfortable` is today's spacing, unchanged. */
|
||||
export const DENSITY_VALUES = ['comfortable', 'compact'];
|
||||
|
||||
/**
|
||||
* A node, with every field settled.
|
||||
*
|
||||
* Callers hand in whatever they have; this decides what the rest of the system
|
||||
* sees. It does **not** validate — `validate.js` does that, against the
|
||||
* registry, and keeping the two apart is what lets an invalid node exist long
|
||||
* enough to be reported with a message instead of vanishing.
|
||||
*
|
||||
* Unknown keys are dropped rather than carried. A node that survived with an
|
||||
* extra field would eventually have that field read by something, and then the
|
||||
* closed vocabulary would be closed only by convention.
|
||||
*/
|
||||
/** @param {any} raw @returns {any} */
|
||||
export function makeNode(raw = {}) {
|
||||
/** @type {any} */
|
||||
const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
||||
|
||||
return {
|
||||
id: String(source.id ?? '').trim(),
|
||||
type: String(source.type ?? '').trim(),
|
||||
props: plainObject(source.props),
|
||||
data: normalizeBinding(source.data),
|
||||
layout: normalizeLayout(source.layout),
|
||||
presentation: normalizePresentation(source.presentation),
|
||||
children: Array.isArray(source.children) ? source.children.map(makeNode) : [],
|
||||
hidden: source.hidden === true,
|
||||
origin: NODE_ORIGINS.includes(source.origin) ? source.origin : 'builtin',
|
||||
/* Locked is the type's word, not the node's, but it is stored on the node so
|
||||
a composition can pin one instance — a page may legitimately want its
|
||||
header fixed while the same type is movable elsewhere. */
|
||||
locked: source.locked === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** A shallow copy of a plain object, or an empty one. Never an array, never null. */
|
||||
function plainObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The data binding, or null.
|
||||
*
|
||||
* `source` is an id in the closed data-source vocabulary and is checked there,
|
||||
* not here. `params` is the small bag of options a source declares it reads —
|
||||
* periods, limit — and is likewise checked at validation. What this does is
|
||||
* make the shape predictable: a binding is either absent or an object with a
|
||||
* string source, so nothing downstream has to test both `data.source` and a
|
||||
* bare `source`.
|
||||
*/
|
||||
function normalizeBinding(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string') {
|
||||
const source = value.trim();
|
||||
return source ? { source, params: {} } : null;
|
||||
}
|
||||
if (typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const source = String(value.source ?? '').trim();
|
||||
if (!source) return null;
|
||||
return { source, params: plainObject(value.params) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout, reduced to the four keys that mean something.
|
||||
*
|
||||
* Coerced rather than refused: `columns: "2"` is what a form control produces
|
||||
* and a conversation says, and treating that as an authoring error would make
|
||||
* the format precious about typing. Out-of-range values are clamped at
|
||||
* validation, where the type's own constraints are known — not here, where they
|
||||
* are not.
|
||||
*/
|
||||
function normalizeLayout(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const layout = {};
|
||||
for (const key of LAYOUT_KEYS) {
|
||||
if (value[key] == null || value[key] === '') continue;
|
||||
if (key === 'columns' || key === 'span') {
|
||||
const n = Number(value[key]);
|
||||
if (Number.isFinite(n)) layout[key] = Math.round(n);
|
||||
continue;
|
||||
}
|
||||
layout[key] = String(value[key]).trim();
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* The presentation keys a node may carry, and nothing else.
|
||||
*
|
||||
* Values are not checked here — `validate.js` does that against the type's own
|
||||
* declared support, so an unsupported value survives long enough to be reported
|
||||
* by name rather than disappearing silently.
|
||||
*/
|
||||
function normalizePresentation(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const presentation = {};
|
||||
for (const key of PRESENTATION_KEYS) {
|
||||
if (value[key] == null || value[key] === '') continue;
|
||||
presentation[key] = String(value[key]).trim();
|
||||
}
|
||||
return presentation;
|
||||
}
|
||||
|
||||
/* ── Reading a tree ─────────────────────────────────────────────────────────
|
||||
A tree is an array of root nodes, not a single node with a synthetic root.
|
||||
A page is a list of things, and inventing a root would give the engine one
|
||||
node that every rule then has to except. */
|
||||
|
||||
/** Every node, parents before children. Order is the reading order of the page. */
|
||||
export function walk(nodes) {
|
||||
const out = [];
|
||||
const visit = (list) => {
|
||||
for (const node of list || []) {
|
||||
out.push(node);
|
||||
if (node.children?.length) visit(node.children);
|
||||
}
|
||||
};
|
||||
visit(nodes);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The node with this id, or null. */
|
||||
export function findNode(nodes, id) {
|
||||
const want = String(id ?? '').trim();
|
||||
if (!want) return null;
|
||||
return walk(nodes).find((node) => node.id === want) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a node sits: its parent (null at the root) and its index among siblings.
|
||||
*
|
||||
* Every structural operation needs this, and every one of them computing it
|
||||
* separately is how `move` and `reorder` end up disagreeing about what an index
|
||||
* means.
|
||||
*/
|
||||
export function locate(nodes, id) {
|
||||
const want = String(id ?? '').trim();
|
||||
if (!want) return null;
|
||||
|
||||
const search = (list, parent) => {
|
||||
for (let index = 0; index < list.length; index += 1) {
|
||||
if (list[index].id === want) return { parent, siblings: list, index, node: list[index] };
|
||||
const deeper = list[index].children?.length ? search(list[index].children, list[index]) : null;
|
||||
if (deeper) return deeper;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return search(nodes || [], null);
|
||||
}
|
||||
|
||||
/** Every id in the tree, including duplicates — the caller decides what that means. */
|
||||
export const idsOf = (nodes) => walk(nodes).map((node) => node.id);
|
||||
|
||||
/**
|
||||
* A tree with one node replaced by the result of `fn`, and everything else
|
||||
* shared.
|
||||
*
|
||||
* Structural sharing rather than a deep clone: React re-renders what changed,
|
||||
* and a wholesale copy would repaint a page for a hidden flag. Returning `null`
|
||||
* from `fn` removes the node, which is what makes `remove` a special case of
|
||||
* this rather than a second traversal.
|
||||
*/
|
||||
export function mapNode(nodes, id, fn) {
|
||||
const want = String(id ?? '').trim();
|
||||
let touched = false;
|
||||
|
||||
const visit = (list) => list.reduce((acc, node) => {
|
||||
if (node.id === want) {
|
||||
touched = true;
|
||||
const next = fn(node);
|
||||
if (next) acc.push(next);
|
||||
return acc;
|
||||
}
|
||||
if (node.children?.length) {
|
||||
const children = visit(node.children);
|
||||
acc.push(children === node.children ? node : { ...node, children });
|
||||
return acc;
|
||||
}
|
||||
acc.push(node);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const next = visit(nodes || []);
|
||||
return touched ? next : nodes;
|
||||
}
|
||||
|
||||
/** A tree with `fn` applied to one node's children list. */
|
||||
export function mapChildren(nodes, parentId, fn) {
|
||||
const want = String(parentId ?? '').trim();
|
||||
/* The root is addressed by a null parent, so a caller does not need a
|
||||
different function to reorder top-level nodes than nested ones. */
|
||||
if (!want) return fn(nodes || []);
|
||||
return mapNode(nodes, want, (node) => ({ ...node, children: fn(node.children || []) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* An id nothing in the tree is using, derived from the type.
|
||||
*
|
||||
* Shared, because Owliver and the editor both add nodes and two id schemes
|
||||
* would mean the same action produced different addresses depending on which
|
||||
* surface asked for it.
|
||||
*/
|
||||
export function freeNodeId(nodes, type) {
|
||||
const taken = new Set(walk(nodes).map((node) => node.id));
|
||||
let n = 1;
|
||||
while (taken.has(`${type}-${n}`)) n += 1;
|
||||
return `${type}-${n}`;
|
||||
}
|
||||
|
||||
/** A node and everything under it, as a fresh tree. Used by move and by undo. */
|
||||
export const cloneNode = (node) => makeNode(node);
|
||||
475
src/lib/ui/operations.js
Normal file
475
src/lib/ui/operations.js
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* The mutation engine.
|
||||
*
|
||||
* Eight operations over a UI tree, and **not one of them branches on a node
|
||||
* type**. Every decision an operation makes — may this move, may that hold a
|
||||
* child, is this column count legal, may this person do it at all — is read
|
||||
* from the type's registration or from the closed data vocabulary. That is the
|
||||
* property that makes a new UI type one `registerNodeType` call instead of an
|
||||
* edit here, and it is worth checking on any change to this file: a `card`,
|
||||
* `chart`, `table`, `positions` or `control-center` appearing below is a bug in
|
||||
* the design, not a special case.
|
||||
*
|
||||
* Every operation is pure — `(tree, op) → { ok, tree, problems, diff }` — and
|
||||
* returns a **new** tree, sharing everything it did not touch. Purity is what
|
||||
* lets preview and apply run the identical code path: a preview is the result
|
||||
* held in memory, an apply is the same result persisted. There is no second
|
||||
* implementation for either, so they cannot disagree.
|
||||
*
|
||||
* An operation that fails returns the tree it was given, unchanged, with the
|
||||
* problems that stopped it. Nothing is half-applied.
|
||||
*/
|
||||
|
||||
import { dataSourceFor, sourceSupportsShape } from '@/lib/skills/surfaces';
|
||||
import {
|
||||
cloneNode, findNode, locate, makeNode, mapChildren, mapNode,
|
||||
} from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
import { takenIds, validateTree } from './validate';
|
||||
|
||||
/** The operation names the engine understands. Anything else is refused by name. */
|
||||
export const OPERATIONS = [
|
||||
'add', 'update', 'remove', 'move', 'replace', 'hide', 'reorder',
|
||||
];
|
||||
|
||||
const fail = (tree, ...problems) => ({
|
||||
ok: false,
|
||||
tree,
|
||||
problems: problems.flat().map((p) => (typeof p === 'string' ? { at: null, message: p } : p)),
|
||||
diff: null,
|
||||
});
|
||||
|
||||
const done = (tree, diff) => ({ ok: true, tree, problems: [], diff });
|
||||
|
||||
/**
|
||||
* Apply one operation.
|
||||
*
|
||||
* The candidate tree is built first and validated whole, rather than each
|
||||
* operation checking its own effects. A structural change can invalidate a node
|
||||
* it did not touch — moving a chart into a container that does not accept it,
|
||||
* or leaving a container below its minimum — and only a whole-tree check sees
|
||||
* that.
|
||||
*/
|
||||
export function applyOperation(tree, op, { registry = nodeRegistry, role = null } = {}) {
|
||||
const nodes = Array.isArray(tree) ? tree : [];
|
||||
const name = String(op?.op ?? '').trim();
|
||||
|
||||
if (!OPERATIONS.includes(name)) {
|
||||
return fail(nodes, `Unknown operation: ${name || '(none)'}. Known: ${OPERATIONS.join(', ')}.`);
|
||||
}
|
||||
|
||||
const context = { registry, role };
|
||||
const built = BUILDERS[name](nodes, op, context);
|
||||
if (!built.ok) return built;
|
||||
|
||||
/* One gate, after the change is composed and before it is anybody's. */
|
||||
const { ok, problems } = validateTree(built.tree, context);
|
||||
if (!ok) return fail(nodes, problems);
|
||||
|
||||
return done(built.tree, built.diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a list, stopping at the first failure.
|
||||
*
|
||||
* All-or-nothing: a patch that applied its first three operations and refused
|
||||
* the fourth would leave a page in a state the user never asked for and cannot
|
||||
* name. The caller gets the original tree back and the problems from the one
|
||||
* that stopped it.
|
||||
*/
|
||||
export function applyOperations(tree, ops, context = {}) {
|
||||
const nodes = Array.isArray(tree) ? tree : [];
|
||||
let current = nodes;
|
||||
const diffs = [];
|
||||
|
||||
for (const [index, op] of (ops || []).entries()) {
|
||||
const result = applyOperation(current, op, context);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
tree: nodes,
|
||||
problems: result.problems.map((p) => ({ ...p, opIndex: index })),
|
||||
diff: null,
|
||||
};
|
||||
}
|
||||
current = result.tree;
|
||||
diffs.push(result.diff);
|
||||
}
|
||||
|
||||
return { ok: true, tree: current, problems: [], diff: diffs };
|
||||
}
|
||||
|
||||
/* ── Shared checks ──────────────────────────────────────────────────────────
|
||||
Written once because an operation that resolved its own target would be the
|
||||
place a rule silently differs. */
|
||||
|
||||
/** The node an operation names, or a refusal that says which id was not found. */
|
||||
function target(nodes, id) {
|
||||
const found = findNode(nodes, id);
|
||||
if (!found) return { problem: `No UI node with the id \`${id ?? '(none)'}\`.` };
|
||||
return { node: found };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the type behind this node permits this capability.
|
||||
*
|
||||
* Two refusals, deliberately different: a node the registry does not know is an
|
||||
* unknown component, while a node whose type declines the capability is a
|
||||
* component that exists and will not do this. Collapsing them would tell a user
|
||||
* their chart does not exist.
|
||||
*/
|
||||
function permits(registry, node, capability) {
|
||||
const entry = registry.get(node.type);
|
||||
if (!entry) return `Unsupported UI type: ${node.type}.`;
|
||||
if (node.locked) return `\`${entry.label}\` is fixed here and cannot be changed.`;
|
||||
if (!entry.capabilities.includes(capability)) {
|
||||
return `\`${entry.label}\` cannot be ${PARTICIPLE[capability]}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const PARTICIPLE = {
|
||||
add: 'added to', update: 'changed', remove: 'removed', move: 'moved',
|
||||
replace: 'replaced', hide: 'hidden', reorder: 'reordered',
|
||||
};
|
||||
|
||||
/** The container an operation places into: the root, or a node that accepts children. */
|
||||
function container(nodes, parentId, registry) {
|
||||
if (parentId == null || parentId === '') return { parent: null, children: nodes };
|
||||
const found = findNode(nodes, parentId);
|
||||
if (!found) return { problem: `No UI node with the id \`${parentId}\`.` };
|
||||
const entry = registry.get(found.type);
|
||||
if (!entry?.container) return { problem: `\`${entry?.label || found.type}\` cannot hold other nodes.` };
|
||||
return { parent: found, children: found.children || [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a node's own origin says it may be deleted.
|
||||
*
|
||||
* Removing something the application ships is not removal, it is hiding: the
|
||||
* built-in comes back with the next release, and a patch that claimed to have
|
||||
* deleted it would silently stop matching. A node a person added is genuinely
|
||||
* theirs to remove.
|
||||
*
|
||||
* Origin, not type — which is what keeps this rule generic.
|
||||
*/
|
||||
const isRemovable = (node) => node.origin === 'user';
|
||||
|
||||
/** A record reduced to the keys a type declared. Anything else is left behind. */
|
||||
const keep = (source, allowed) => Object.fromEntries(
|
||||
Object.entries(source || {}).filter(([key]) => allowed.includes(key))
|
||||
);
|
||||
|
||||
/* ── The operations ─────────────────────────────────────────────────────── */
|
||||
|
||||
const BUILDERS = {
|
||||
/** Put a new node into a container, at an index or at the end. */
|
||||
add(nodes, op, { registry }) {
|
||||
const spot = container(nodes, op.parent, registry);
|
||||
if (spot.problem) return fail(nodes, spot.problem);
|
||||
|
||||
if (spot.parent) {
|
||||
const refusal = permits(registry, spot.parent, 'add');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
}
|
||||
|
||||
const node = makeNode({ ...op.node, origin: op.node?.origin || 'user' });
|
||||
|
||||
if (!node.id) return fail(nodes, 'A new node needs an `id`.');
|
||||
if (takenIds(nodes).has(node.id)) {
|
||||
return fail(nodes, `A node with the id \`${node.id}\` already exists.`);
|
||||
}
|
||||
if (!registry.has(node.type)) {
|
||||
return fail(
|
||||
nodes,
|
||||
`Unsupported UI type: ${node.type || '(none)'}. Supported types: ${registry.list().join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
const at = index(op.index, spot.children.length);
|
||||
const next = mapChildren(nodes, spot.parent?.id ?? null, (list) => [
|
||||
...list.slice(0, at), node, ...list.slice(at),
|
||||
]);
|
||||
|
||||
return done(next, {
|
||||
op: 'add', target: node.id, parent: spot.parent?.id ?? null, index: at,
|
||||
summary: `Add ${registry.get(node.type).label}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Change a node's configuration in place.
|
||||
*
|
||||
* Props and layout merge rather than replace, so "make it two columns" does
|
||||
* not clear a title nobody mentioned. A key set to `null` is removed, which is
|
||||
* how a value gets *unset* — otherwise there would be no way to say "no
|
||||
* limit" that was distinguishable from not mentioning it.
|
||||
*
|
||||
* `data` is different: it replaces, because a binding is one decision and a
|
||||
* half-merged one would name a source with another source's options.
|
||||
*/
|
||||
update(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'update');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
const before = found.node;
|
||||
const next = mapNode(nodes, before.id, (node) => makeNode({
|
||||
...node,
|
||||
props: 'props' in op ? merge(node.props, op.props) : node.props,
|
||||
layout: 'layout' in op ? merge(node.layout, op.layout) : node.layout,
|
||||
/* Merged, not replaced, for the same reason layout is: setting a density
|
||||
must not clear a variant nobody mentioned. `null` still unsets. */
|
||||
presentation: 'presentation' in op ? merge(node.presentation, op.presentation) : node.presentation,
|
||||
data: 'data' in op ? op.data : node.data,
|
||||
}));
|
||||
|
||||
return done(next, {
|
||||
op: 'update', target: before.id,
|
||||
changed: [
|
||||
...('props' in op ? Object.keys(op.props || {}) : []),
|
||||
...('layout' in op ? Object.keys(op.layout || {}) : []),
|
||||
...('presentation' in op ? Object.keys(op.presentation || {}) : []),
|
||||
...('data' in op ? ['data'] : []),
|
||||
],
|
||||
summary: `Change ${registry.get(before.type)?.label || before.type}`,
|
||||
});
|
||||
},
|
||||
|
||||
/** Take a node out of the tree. Only where its origin allows it. */
|
||||
remove(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'remove');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
if (!isRemovable(found.node)) {
|
||||
return fail(
|
||||
nodes,
|
||||
`\`${registry.get(found.node.type)?.label || found.node.type}\` is part of the page and `
|
||||
+ 'cannot be deleted. Hide it instead.'
|
||||
);
|
||||
}
|
||||
|
||||
return done(mapNode(nodes, found.node.id, () => null), {
|
||||
op: 'remove', target: found.node.id,
|
||||
summary: `Remove ${registry.get(found.node.type)?.label || found.node.type}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Move a node to another container, or to another position in its own.
|
||||
*
|
||||
* Detach then insert, computing the index against the list *after* removal so
|
||||
* that moving a node down within its own parent lands where a reader expects.
|
||||
* Getting this wrong is the classic off-by-one that makes "move it to the
|
||||
* end" stop one short.
|
||||
*/
|
||||
move(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'move');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
const parentId = op.parent === undefined ? locate(nodes, found.node.id)?.parent?.id ?? null : op.parent;
|
||||
|
||||
/* A container cannot be moved inside itself; the tree would stop being one. */
|
||||
if (parentId && findNode([found.node], parentId)) {
|
||||
return fail(nodes, 'A node cannot be moved inside itself.');
|
||||
}
|
||||
|
||||
const spot = container(nodes, parentId, registry);
|
||||
if (spot.problem) return fail(nodes, spot.problem);
|
||||
if (spot.parent) {
|
||||
const parentRefusal = permits(registry, spot.parent, 'add');
|
||||
if (parentRefusal) return fail(nodes, parentRefusal);
|
||||
}
|
||||
|
||||
const moved = cloneNode(found.node);
|
||||
const detached = mapNode(nodes, found.node.id, () => null);
|
||||
|
||||
const siblings = parentId == null
|
||||
? detached
|
||||
: findNode(detached, parentId)?.children || [];
|
||||
const at = index(op.index, siblings.length);
|
||||
|
||||
const next = mapChildren(detached, parentId, (list) => [
|
||||
...list.slice(0, at), moved, ...list.slice(at),
|
||||
]);
|
||||
|
||||
return done(next, {
|
||||
op: 'move', target: moved.id, parent: parentId, index: at,
|
||||
summary: `Move ${registry.get(moved.type)?.label || moved.type}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Turn a node into another type, in place.
|
||||
*
|
||||
* The id, position and binding are kept — which is what makes "show this as a
|
||||
* table" mean *this* reading as a table, rather than a new empty table where
|
||||
* a chart used to be. Props are dropped unless the operation supplies new
|
||||
* ones, because props belong to a type and carrying a chart's variant onto a
|
||||
* table is how an invalid prop arrives without anyone writing one.
|
||||
*/
|
||||
replace(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'replace');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
const type = String(op.type ?? '').trim();
|
||||
const entry = registry.get(type);
|
||||
if (!entry) {
|
||||
return fail(
|
||||
nodes,
|
||||
`Unsupported UI type: ${type || '(none)'}. Supported types: ${registry.list().join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
const before = found.node;
|
||||
if (before.children?.length && !entry.container) {
|
||||
return fail(nodes, `\`${entry.label}\` cannot hold the nodes already inside \`${before.id}\`.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can the new type draw what this node is already reading?
|
||||
*
|
||||
* Asked here, and refused by name, rather than left to the tree validator.
|
||||
* Both would stop it, but only one can say *why*: "a Table cannot draw
|
||||
* Candidate activity" is a sentence a person can act on, and "this node's
|
||||
* binding is incoherent" is not. The binding itself is never touched — a
|
||||
* replacement that cannot read what the node reads is refused, never
|
||||
* repointed at a source that happens to fit.
|
||||
*/
|
||||
const binding = 'data' in op ? op.data : before.data;
|
||||
if (binding?.source && entry.dataShapes.length) {
|
||||
const drawable = entry.dataShapes.some((shape) => sourceSupportsShape(binding.source, shape));
|
||||
if (!drawable) {
|
||||
const label = dataSourceFor(binding.source)?.label || binding.source;
|
||||
return fail(nodes, `\`${entry.label}\` cannot show ${label}.`);
|
||||
}
|
||||
}
|
||||
if (!binding?.source && entry.dataRequired) {
|
||||
return fail(nodes, `\`${entry.label}\` needs a reading, and \`${before.id}\` has none.`);
|
||||
}
|
||||
|
||||
const next = mapNode(nodes, before.id, (node) => makeNode({
|
||||
...node,
|
||||
type,
|
||||
/**
|
||||
* What survives is what the new type has said it understands.
|
||||
*
|
||||
* Props were dropped wholesale, which is safe and also loses a title the
|
||||
* person typed even when the new type has the very same field. Carried
|
||||
* per key against the new type's own `propSchema` instead: a property both
|
||||
* types declare is theirs to keep, one that belonged to the old type
|
||||
* alone goes. Presentation is filtered the same way, so replacing into a
|
||||
* type that cannot draw `emphasis` quietly drops it rather than failing
|
||||
* the whole operation over a value nobody asked to keep.
|
||||
*
|
||||
* Id, layout, visibility, origin and children pass through untouched —
|
||||
* the node is the same node, drawn differently.
|
||||
*/
|
||||
props: keep('props' in op ? merge({}, op.props) : node.props, Object.keys(entry.propSchema)),
|
||||
presentation: {
|
||||
...(entry.variants.includes(node.presentation?.variant) ? { variant: node.presentation.variant } : {}),
|
||||
...(entry.densities.includes(node.presentation?.density) ? { density: node.presentation.density } : {}),
|
||||
},
|
||||
data: binding,
|
||||
}));
|
||||
|
||||
return done(next, {
|
||||
op: 'replace', target: before.id, from: before.type, to: type,
|
||||
summary: `Change ${registry.get(before.type)?.label || before.type} to ${entry.label}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide or show a node.
|
||||
*
|
||||
* Its own operation rather than an `update` of a flag, because it is its own
|
||||
* capability: a component may reasonably be hideable and not otherwise
|
||||
* editable, and a page's header is the opposite. Idempotent — hiding what is
|
||||
* already hidden is not an error, it is the state the user asked for.
|
||||
*/
|
||||
hide(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'hide');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
const hidden = op.hidden !== false;
|
||||
const next = mapNode(nodes, found.node.id, (node) => ({ ...node, hidden }));
|
||||
|
||||
return done(next, {
|
||||
op: 'hide', target: found.node.id, hidden,
|
||||
summary: `${hidden ? 'Hide' : 'Show'} ${registry.get(found.node.type)?.label || found.node.type}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Rearrange one container's children.
|
||||
*
|
||||
* Takes the ids in their new order. A partial list is honoured — the named
|
||||
* nodes take the order given, and anything unnamed keeps its relative
|
||||
* position after them — so "put the funnel first" does not require restating
|
||||
* the whole page.
|
||||
*/
|
||||
reorder(nodes, op, { registry }) {
|
||||
const spot = container(nodes, op.parent, registry);
|
||||
if (spot.problem) return fail(nodes, spot.problem);
|
||||
|
||||
if (spot.parent) {
|
||||
const refusal = permits(registry, spot.parent, 'reorder');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
}
|
||||
|
||||
const order = Array.isArray(op.order) ? op.order.map((id) => String(id).trim()) : null;
|
||||
if (!order?.length) return fail(nodes, '`reorder` needs an `order` of node ids.');
|
||||
|
||||
const present = new Set(spot.children.map((node) => node.id));
|
||||
const stranger = order.find((id) => !present.has(id));
|
||||
if (stranger) {
|
||||
return fail(nodes, `\`${stranger}\` is not inside \`${op.parent ?? 'the page'}\`.`);
|
||||
}
|
||||
if (new Set(order).size !== order.length) {
|
||||
return fail(nodes, '`order` names the same node twice.');
|
||||
}
|
||||
|
||||
const next = mapChildren(nodes, spot.parent?.id ?? null, (list) => {
|
||||
const named = order.map((id) => list.find((node) => node.id === id));
|
||||
const rest = list.filter((node) => !order.includes(node.id));
|
||||
return [...named, ...rest];
|
||||
});
|
||||
|
||||
return done(next, {
|
||||
op: 'reorder', parent: spot.parent?.id ?? null, order,
|
||||
summary: `Reorder ${spot.parent ? registry.get(spot.parent.type)?.label || spot.parent.type : 'the page'}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** An insertion point, clamped. Absent means the end, which is what "add" means. */
|
||||
function index(value, length) {
|
||||
if (value == null) return length;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return length;
|
||||
return Math.max(0, Math.min(length, Math.round(n)));
|
||||
}
|
||||
|
||||
/** Shallow merge where an explicit `null` deletes the key. */
|
||||
function merge(base, patch) {
|
||||
const next = { ...(base || {}) };
|
||||
for (const [key, value] of Object.entries(patch || {})) {
|
||||
if (value === null) delete next[key];
|
||||
else next[key] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
203
src/lib/ui/patch.js
Normal file
203
src/lib/ui/patch.js
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* A user's UI changes, as an ordered list of operations.
|
||||
*
|
||||
* **Operations are stored; trees are not.** That is the decision this module
|
||||
* exists to hold, and it is what makes a saved layout survive the application
|
||||
* changing underneath it. A stored tree is a photograph: the day a release adds
|
||||
* a section to a page, every saved photograph is missing it, and the user's
|
||||
* page silently stops receiving product improvements. A stored operation is an
|
||||
* instruction — "hide `cc-activity`", "put `funnel` first" — which still means
|
||||
* what it said after the built-ins around it move.
|
||||
*
|
||||
* It also gives undo and rollback for free. Applying is `push`, undoing is
|
||||
* `pop`, and resetting is the empty list. There is no inverse-operation
|
||||
* machinery to get wrong, because the base tree is recomputed rather than
|
||||
* mutated.
|
||||
*
|
||||
* The stored shape is **normalized structured configuration** — never JSX,
|
||||
* never a component, never Markdown. What is written here is what
|
||||
* `operations.js` already validated.
|
||||
*/
|
||||
|
||||
import { applyOperations, OPERATIONS } from './operations';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/**
|
||||
* The stored format's version.
|
||||
*
|
||||
* Read on load and written on save, so a future change to the operation
|
||||
* vocabulary can migrate rather than misread. A patch whose version this build
|
||||
* does not know is dropped with a reason rather than half-applied — a partly
|
||||
* understood layout is worse than the default one.
|
||||
*/
|
||||
export const PATCH_SCHEMA = 1;
|
||||
|
||||
/** An empty patch for a page. The shape a page gets when nobody has changed it. */
|
||||
export const emptyPatch = (page) => ({
|
||||
schema: PATCH_SCHEMA,
|
||||
page: String(page ?? '').trim(),
|
||||
ops: [],
|
||||
updatedAt: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Only the fields an operation is allowed to carry, per operation.
|
||||
*
|
||||
* A whitelist rather than a pass-through, because this is the boundary where
|
||||
* stored data becomes engine input. Anything else a client wrote — or anything
|
||||
* that arrived in `user_preferences.extra` from an older build or another tab —
|
||||
* is dropped before it reaches the engine.
|
||||
*/
|
||||
const OP_FIELDS = {
|
||||
add: ['parent', 'index', 'node'],
|
||||
update: ['target', 'props', 'layout', 'presentation', 'data'],
|
||||
remove: ['target'],
|
||||
move: ['target', 'parent', 'index'],
|
||||
replace: ['target', 'type', 'props', 'data'],
|
||||
hide: ['target', 'hidden'],
|
||||
reorder: ['parent', 'order'],
|
||||
};
|
||||
|
||||
/**
|
||||
* One stored operation, reduced to what the engine reads.
|
||||
*
|
||||
* Returns null for anything unrecognised. The caller reports how many were
|
||||
* dropped rather than failing the whole patch: one unreadable operation from a
|
||||
* newer build should not cost a user the other nine they made.
|
||||
*/
|
||||
export function normalizeOp(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const op = String(raw.op ?? '').trim();
|
||||
if (!OPERATIONS.includes(op)) return null;
|
||||
|
||||
const next = { op };
|
||||
for (const field of OP_FIELDS[op]) {
|
||||
if (raw[field] === undefined) continue;
|
||||
next[field] = raw[field];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** A stored patch, checked and reduced. `dropped` says what was not understood. */
|
||||
export function normalizePatch(raw, page) {
|
||||
const base = emptyPatch(page);
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { patch: base, dropped: 0 };
|
||||
|
||||
if (Number(raw.schema) !== PATCH_SCHEMA) {
|
||||
/* Unknown version: keep nothing rather than guess. The page renders as the
|
||||
application ships it, which is a defensible state; a half-read layout is
|
||||
not. */
|
||||
return { patch: base, dropped: Array.isArray(raw.ops) ? raw.ops.length : 0 };
|
||||
}
|
||||
|
||||
const ops = [];
|
||||
let dropped = 0;
|
||||
for (const candidate of Array.isArray(raw.ops) ? raw.ops : []) {
|
||||
const op = normalizeOp(candidate);
|
||||
if (op) ops.push(op);
|
||||
else dropped += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
patch: {
|
||||
schema: PATCH_SCHEMA,
|
||||
page: base.page,
|
||||
ops,
|
||||
updatedAt: raw.updatedAt || null,
|
||||
},
|
||||
dropped,
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole store: `{ [page]: patch }`, as it sits in preferences. */
|
||||
export function normalizeLayouts(raw) {
|
||||
const layouts = {};
|
||||
let dropped = 0;
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { layouts, dropped };
|
||||
|
||||
for (const [page, value] of Object.entries(raw)) {
|
||||
const key = String(page ?? '').trim();
|
||||
if (!key) continue;
|
||||
const result = normalizePatch(value, key);
|
||||
dropped += result.dropped;
|
||||
/* An empty patch is not stored. A key mapping to no operations is the same
|
||||
state as no key, and keeping it would grow the preferences blob with a
|
||||
record of pages a user once looked at. */
|
||||
if (result.patch.ops.length) layouts[key] = result.patch;
|
||||
}
|
||||
|
||||
return { layouts, dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an operation to a patch.
|
||||
*
|
||||
* Appends rather than folding into what is there. Two updates to the same node
|
||||
* stay two operations: replaying them costs nothing, and collapsing them would
|
||||
* mean undo skipped a step the user remembers making.
|
||||
*/
|
||||
export const pushOp = (patch, op) => ({
|
||||
...patch,
|
||||
ops: [...patch.ops, op],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
/** Undo: drop the last operation. The tree is recomputed, never reversed. */
|
||||
export const popOp = (patch) => ({
|
||||
...patch,
|
||||
ops: patch.ops.slice(0, -1),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
/** Reset: back to what the application ships. */
|
||||
export const clearOps = (patch) => ({ ...patch, ops: [], updatedAt: new Date().toISOString() });
|
||||
|
||||
/**
|
||||
* The whole layout store with one page's patch set, cleared, or replaced.
|
||||
*
|
||||
* Pure, and separate from the hook that calls it, because this is where a
|
||||
* mistake would be expensive and invisible: the preferences endpoint
|
||||
* shallow-merges its top-level keys, so writing `uiLayouts` replaces the entire
|
||||
* map. Sending one page's entry would silently delete every other page the
|
||||
* person had customised, and they would only find out by visiting one.
|
||||
*
|
||||
* A patch with no operations removes its key rather than storing an empty one:
|
||||
* that is the same state as never having customised the page.
|
||||
*/
|
||||
export function mergeLayouts(layouts, page, patch) {
|
||||
const key = String(page ?? '').trim();
|
||||
const next = { ...(layouts || {}) };
|
||||
if (!key) return next;
|
||||
if (patch?.ops?.length) next[key] = { ...patch, schema: PATCH_SCHEMA, page: key };
|
||||
else delete next[key];
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree a patch produces from a base.
|
||||
*
|
||||
* **Operations that no longer apply are skipped, not fatal.** A release that
|
||||
* removes a section leaves any patch that mentioned it naming a node that is
|
||||
* not there — which is not the user's mistake and must not cost them the rest
|
||||
* of their layout. `skipped` reports them so a surface can say so quietly.
|
||||
*
|
||||
* This is the one place tolerance is right. Everywhere else — composing a new
|
||||
* change, saving one — a refusal is correct, because there is a person present
|
||||
* to be told.
|
||||
*/
|
||||
export function applyPatch(base, patch, { registry = nodeRegistry, role = null } = {}) {
|
||||
const ops = patch?.ops || [];
|
||||
let tree = base;
|
||||
const skipped = [];
|
||||
|
||||
for (const [index, op] of ops.entries()) {
|
||||
const result = applyOperations(tree, [op], { registry, role });
|
||||
if (result.ok) {
|
||||
tree = result.tree;
|
||||
continue;
|
||||
}
|
||||
skipped.push({ index, op, problems: result.problems });
|
||||
}
|
||||
|
||||
return { tree, skipped };
|
||||
}
|
||||
377
src/lib/ui/registry.js
Normal file
377
src/lib/ui/registry.js
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* The node type registry — the only extension point in the UI system.
|
||||
*
|
||||
* A type is a name, a component the application already ships, and the metadata
|
||||
* that says what may be done to it. Adding a UI type is one `register` call:
|
||||
* the mutation engine, the validator, the renderer and the agent's conversation
|
||||
* are untouched, because none of them contains a branch on a type. They ask
|
||||
* this table instead.
|
||||
*
|
||||
* That is the whole reason this file exists. The alternative — an engine that
|
||||
* knows `card` from `chart` — puts every future component into the engine, and
|
||||
* the engine then has to be edited to add a UI type, which is the thing the
|
||||
* design is meant to prevent.
|
||||
*
|
||||
* **The registry never receives a component name as a string.** A registration
|
||||
* hands over a component *reference*, resolved by the module graph at build
|
||||
* time. There is no dynamic import here and no lookup from configuration to
|
||||
* code — configuration only ever names a key that is already in this table.
|
||||
*/
|
||||
|
||||
import {
|
||||
DENSITY_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_CAPABILITIES, NODE_ID_PATTERN, VARIANT_VALUES,
|
||||
} from './node';
|
||||
|
||||
/**
|
||||
* A registration, with every field settled.
|
||||
*
|
||||
* Defaults are deliberately conservative: a type that says nothing about its
|
||||
* capabilities gets the read-only set, because a component whose author has not
|
||||
* thought about being moved is one that should not be moved yet. Opting in is
|
||||
* one word; opting out after a bug is a migration.
|
||||
*/
|
||||
const DEFAULT_CAPABILITIES = ['update', 'remove', 'move', 'replace', 'hide'];
|
||||
|
||||
/** Containers can also be added to and reordered — that is what makes them containers. */
|
||||
const CONTAINER_CAPABILITIES = [...DEFAULT_CAPABILITIES, 'add', 'reorder'];
|
||||
|
||||
export class NodeTypeRegistry {
|
||||
constructor() {
|
||||
/** @type {Map<string, any>} */
|
||||
this.types = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare a node type.
|
||||
*
|
||||
* Refuses at registration rather than at render. A registry that accepted a
|
||||
* malformed type would fail later, inside a component, with a stack that
|
||||
* names React rather than the registration that caused it — and by then the
|
||||
* validator has already told a user their change was fine.
|
||||
*/
|
||||
register(definition) {
|
||||
const type = String(definition?.type ?? '').trim();
|
||||
if (!type) throw new Error('registerNodeType: a type needs a `type`.');
|
||||
if (!NODE_ID_PATTERN.test(type)) {
|
||||
throw new Error(`registerNodeType: \`${type}\` must be lower-case letters, numbers and dashes.`);
|
||||
}
|
||||
if (this.types.has(type)) {
|
||||
throw new Error(`registerNodeType: \`${type}\` is already registered.`);
|
||||
}
|
||||
if (typeof definition.component !== 'function' && typeof definition.component !== 'object') {
|
||||
throw new Error(`registerNodeType: \`${type}\` needs a \`component\`.`);
|
||||
}
|
||||
|
||||
const container = definition.container === true;
|
||||
const declared = Array.isArray(definition.capabilities) ? definition.capabilities : null;
|
||||
const capabilities = declared || (container ? CONTAINER_CAPABILITIES : DEFAULT_CAPABILITIES);
|
||||
|
||||
const unknown = capabilities.find((c) => !NODE_CAPABILITIES.includes(c));
|
||||
if (unknown) {
|
||||
throw new Error(
|
||||
`registerNodeType: \`${type}\` declares unknown capability \`${unknown}\`. `
|
||||
+ `Known: ${NODE_CAPABILITIES.join(', ')}.`
|
||||
);
|
||||
}
|
||||
/* A registration may choose from the vocabulary; it may not invent one.
|
||||
Caught at boot, where the author can see it, rather than at validation
|
||||
where it would look like the *user's* value was wrong. */
|
||||
const refuseUnknown = (word, declared, allowed) => {
|
||||
const bad = (declared || []).find((value) => !allowed.includes(value));
|
||||
if (bad) {
|
||||
throw new Error(
|
||||
`registerNodeType: \`${type}\` declares unknown ${word} \`${bad}\`. `
|
||||
+ `Known: ${allowed.join(', ')}.`
|
||||
);
|
||||
}
|
||||
};
|
||||
refuseUnknown('variant', definition.variants, VARIANT_VALUES);
|
||||
refuseUnknown('density', definition.densities, DENSITY_VALUES);
|
||||
|
||||
/* A type offered by a picker and unable to be removed is a dead end: a
|
||||
person creates a node and has no way to take it back. Said here rather
|
||||
than discovered in the editor. */
|
||||
if (definition.addable === true && !capabilities.includes('remove')) {
|
||||
throw new Error(
|
||||
`registerNodeType: \`${type}\` is declared addable but cannot be removed. `
|
||||
+ 'Anything a person can add, they must be able to remove.'
|
||||
);
|
||||
}
|
||||
|
||||
/* A non-container claiming `add` or `reorder` is a registration that will
|
||||
never do what its author expects: there is nowhere to put a child. */
|
||||
if (!container) {
|
||||
const childOnly = capabilities.find((c) => c === 'add' || c === 'reorder');
|
||||
if (childOnly) {
|
||||
throw new Error(
|
||||
`registerNodeType: \`${type}\` declares \`${childOnly}\` but is not a container.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const entry = Object.freeze({
|
||||
type,
|
||||
label: String(definition.label ?? type).trim(),
|
||||
summary: String(definition.summary ?? '').trim(),
|
||||
component: definition.component,
|
||||
container,
|
||||
capabilities: Object.freeze([...capabilities]),
|
||||
/* `['*']` accepts anything, which is what a page-level container wants.
|
||||
An empty list on a container accepts nothing, which is a leaf that
|
||||
merely renders its own children — legitimate, and worth being able to
|
||||
say. */
|
||||
accepts: Object.freeze([...(definition.accepts || (container ? ['*'] : []))]),
|
||||
propSchema: Object.freeze({ ...(definition.propSchema || {}) }),
|
||||
/* Which readings this type can draw. Empty means the type takes no data
|
||||
at all — a layout container, a divider — and validation then refuses a
|
||||
binding rather than resolving one nothing will read. */
|
||||
dataShapes: Object.freeze([...(definition.dataShapes || [])]),
|
||||
dataRequired: definition.dataRequired === true,
|
||||
constraints: Object.freeze({
|
||||
minColumns: MIN_COLUMNS,
|
||||
maxColumns: MAX_COLUMNS,
|
||||
...(definition.constraints || {}),
|
||||
}),
|
||||
/**
|
||||
* Whether the renderer supplies identity through a wrapper element.
|
||||
*
|
||||
* Most components render a root they control and can spread the node's
|
||||
* attributes onto it. Some — the shared design-system primitives, which
|
||||
* destructure their props explicitly — cannot, and for those the renderer
|
||||
* puts a bare `div` around the component so the node is still addressable
|
||||
* in the DOM.
|
||||
*
|
||||
* Declared per type rather than detected, because there is no way to ask
|
||||
* a React component whether it forwards unknown props, and guessing wrong
|
||||
* either loses the identity or adds an element nobody asked for.
|
||||
*/
|
||||
wrap: definition.wrap === true,
|
||||
/* `null` means every role. A list narrows it, and is checked against the
|
||||
caller's role at validation — the same three roles the API enforces. */
|
||||
roles: definition.roles ? Object.freeze([...definition.roles]) : null,
|
||||
|
||||
/**
|
||||
* The presentation values this type can actually draw.
|
||||
*
|
||||
* Empty means the type supports none, and that is the default on purpose:
|
||||
* a presentation value is a promise that the component renders something
|
||||
* different, and a type that has not made that promise must refuse it
|
||||
* rather than store a setting nobody honours. Opting in is one list; the
|
||||
* cost of the other default would be a person setting "compact" on a
|
||||
* section that stays exactly as it was.
|
||||
*
|
||||
* Each value is also checked against the closed vocabulary in `node.js`,
|
||||
* so a registration cannot widen what the product can say — only choose
|
||||
* from it.
|
||||
*/
|
||||
variants: Object.freeze([...(definition.variants || [])]),
|
||||
densities: Object.freeze([...(definition.densities || [])]),
|
||||
|
||||
/**
|
||||
* The page this type belongs to, or `null` for one that belongs anywhere.
|
||||
*
|
||||
* The registry is global — one table, so a page can be composed before
|
||||
* anything about it is known here — but most types are not. A page's own
|
||||
* sections read that page's published render context: Hired History's
|
||||
* chronology destructures `hires` and `filtered`, and on any other page
|
||||
* those are simply absent. Offered there and added, it threw on
|
||||
* `undefined.length` and took the whole application down with it.
|
||||
*
|
||||
* So a registration says where it belongs, and the engine only ever
|
||||
* compares this string to the page being composed. It still knows no page
|
||||
* names — `null` here is "anywhere", which is what the nine reading types
|
||||
* and any future generic component declare by saying nothing.
|
||||
*/
|
||||
page: definition.page ? String(definition.page).trim() : null,
|
||||
|
||||
/**
|
||||
* What one *instance* of this type should be called.
|
||||
*
|
||||
* Optional. Most types are named well enough by their label — there is
|
||||
* one Hiring funnel on Analytics. Types a page mounts more than once are
|
||||
* not: every skill slot is a "Skill sections", and two of them in an
|
||||
* outline are two identical rows nobody can tell apart or name out loud.
|
||||
* The type answers for its own instances; the engine only calls it.
|
||||
*/
|
||||
describe: typeof definition.describe === 'function' ? definition.describe : null,
|
||||
|
||||
/**
|
||||
* Whether a person may add one of these.
|
||||
*
|
||||
* Two things have to be true, and the second is derived rather than
|
||||
* declared: **anything a person can add, they must be able to remove.**
|
||||
*
|
||||
* The editor offered a page's own sections, which declare `move` and
|
||||
* `hide` and not `remove` — so one could be added and then never deleted.
|
||||
* The placeholder for a failed node said it "can be hidden or removed"
|
||||
* and the Remove button was not there, because the inspector asks the
|
||||
* type while the engine asks the node's origin. Deriving it here settles
|
||||
* the disagreement in the one place both of them read.
|
||||
*
|
||||
* What is left addable is what a person actually adds: the readings. A
|
||||
* page's own sections are composed by the page and a skill surface is an
|
||||
* anchor the page owns — both are moved, hidden and reordered, never
|
||||
* conjured up by a picker.
|
||||
*/
|
||||
addable: definition.addable !== false && capabilities.includes('remove'),
|
||||
});
|
||||
|
||||
this.types.set(type, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The registration, or null. Every consumer goes through this. */
|
||||
get(type) {
|
||||
return this.types.get(String(type ?? '').trim()) || null;
|
||||
}
|
||||
|
||||
has(type) {
|
||||
return this.types.has(String(type ?? '').trim());
|
||||
}
|
||||
|
||||
/** Every registered type name, in registration order. */
|
||||
list() {
|
||||
return [...this.types.keys()];
|
||||
}
|
||||
|
||||
/** Every registration, for a picker that has to describe what it is offering. */
|
||||
all() {
|
||||
return [...this.types.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this node type do this?
|
||||
*
|
||||
* The single question every operation asks. A type that is absent can do
|
||||
* nothing — an unregistered type is not a permissive default, it is an
|
||||
* unknown component, and the engine refuses it.
|
||||
*/
|
||||
allows(type, capability) {
|
||||
const entry = this.get(type);
|
||||
return Boolean(entry && entry.capabilities.includes(capability));
|
||||
}
|
||||
|
||||
/**
|
||||
* May a node of `childType` sit inside `parentType`?
|
||||
*
|
||||
* A null parent is the page root, which accepts anything registered — the
|
||||
* page's own composition decides what is actually there, and refusing at the
|
||||
* root would mean the root needed a registration of its own.
|
||||
*/
|
||||
acceptsChild(parentType, childType) {
|
||||
if (!this.has(childType)) return false;
|
||||
if (parentType == null) return true;
|
||||
const parent = this.get(parentType);
|
||||
if (!parent || !parent.container) return false;
|
||||
return parent.accepts.includes('*') || parent.accepts.includes(childType);
|
||||
}
|
||||
|
||||
/**
|
||||
* May a person put one of these on this page, here?
|
||||
*
|
||||
* `acceptsChild` answers the structural half — does this container hold that
|
||||
* kind of thing. This answers the rest, and the rest is what was missing:
|
||||
* whether the type is one a person may add at all, and whether it belongs to
|
||||
* the page they are standing on.
|
||||
*
|
||||
* Both halves have to hold. Without the first, the picker offered the slot it
|
||||
* renders into; without the second, Candidates Analysis offered every private
|
||||
* section of every other page in the application — forty-three types on a
|
||||
* page that has seven — and adding one crashed the app.
|
||||
*/
|
||||
offersChild(parentType, childType, { page = null } = {}) {
|
||||
if (!this.acceptsChild(parentType, childType)) return false;
|
||||
const child = this.get(childType);
|
||||
if (!child.addable) return false;
|
||||
/* A type that names no page belongs anywhere. A page that is not named
|
||||
cannot vouch for anything page-bound, so it is offered only the generic
|
||||
types — which is the safe reading, not a permissive one. */
|
||||
return child.page === null || child.page === page;
|
||||
}
|
||||
|
||||
/**
|
||||
* The types a node could be turned into, given what it is bound to.
|
||||
*
|
||||
* Derived rather than declared, so "change this chart to a table" is answered
|
||||
* by the same compatibility rule that refuses an impossible section — a type
|
||||
* is a candidate when it can draw at least one shape the current binding
|
||||
* supports. A node with no binding may become any type that needs no data.
|
||||
*
|
||||
* `shapes` is passed in rather than looked up because the shape vocabulary
|
||||
* belongs to `lib/skills/surfaces.js`, and this module deliberately does not
|
||||
* import the product's data vocabulary: the registry is about components, and
|
||||
* coupling it to data sources would make a UI type impossible to register
|
||||
* without one.
|
||||
*/
|
||||
replacements(type, { shapes = null, page = null } = {}) {
|
||||
const current = this.get(type);
|
||||
if (!current) return [];
|
||||
|
||||
/* What the candidate has to be able to draw: the binding's shapes when a
|
||||
binding was named, otherwise whatever this type itself draws. */
|
||||
const wanted = shapes || current.dataShapes;
|
||||
|
||||
return this.all()
|
||||
.filter((entry) => entry.type !== type)
|
||||
/**
|
||||
* The same scope that governs adding.
|
||||
*
|
||||
* Offering a replacement is offering to put that type on this page, so it
|
||||
* answers to the same two rules: a type bound to another page belongs to
|
||||
* that page, and a type nobody may add is not a thing to turn something
|
||||
* into. This was safe only by accident — every page-bound section happens
|
||||
* to declare no shapes, so the shape filter below excluded them — and an
|
||||
* accident is not a rule. A section that declared one would have appeared
|
||||
* in every page's "Show as" list.
|
||||
*/
|
||||
.filter((entry) => this.offersChild(null, entry.type, { page }))
|
||||
.filter((entry) => (
|
||||
/* A type that reads no data can only be swapped for another that reads
|
||||
none — a divider is not an alternative rendering of a chart. */
|
||||
wanted.length
|
||||
? entry.dataShapes.some((shape) => wanted.includes(shape))
|
||||
: entry.dataShapes.length === 0
|
||||
))
|
||||
.map((entry) => entry.type);
|
||||
}
|
||||
|
||||
/** Forget everything. Tests only — a fresh registry per case beats shared state. */
|
||||
reset() {
|
||||
this.types.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry the application uses.
|
||||
*
|
||||
* A module singleton because the type table is the application's, not a
|
||||
* component's, and threading it through every call site would be ceremony. Every
|
||||
* function that reads it takes an override, so a test can register a throwaway
|
||||
* type without touching this one.
|
||||
*/
|
||||
export const nodeRegistry = new NodeTypeRegistry();
|
||||
|
||||
/**
|
||||
* Declare a node type on the application registry.
|
||||
*
|
||||
* Re-declaring **replaces**, which `register` itself refuses to do. The
|
||||
* difference is deliberate and the two are for different callers:
|
||||
*
|
||||
* - `nodeRegistry.register` is strict, because a registry that quietly
|
||||
* accepted two definitions of one type would render whichever module
|
||||
* happened to load second.
|
||||
* - This is what a module-scope registration wants. Types are declared as a
|
||||
* side effect of importing the module that owns them, and the dev server
|
||||
* re-runs that module every time the file is saved. Strictness there would
|
||||
* throw on every edit, and keeping the first registration instead would
|
||||
* leave the page drawing the component as it was before the edit — which is
|
||||
* worse, because it looks like the change did not work.
|
||||
*
|
||||
* The cost is that two modules claiming one type name silently agree on the
|
||||
* last one loaded. That is a real risk and the reason type names are prefixed
|
||||
* with the surface that owns them.
|
||||
*/
|
||||
export const registerNodeType = (definition) => {
|
||||
nodeRegistry.types.delete(String(definition?.type ?? '').trim());
|
||||
return nodeRegistry.register(definition);
|
||||
};
|
||||
83
src/lib/ui/skillNodes.js
Normal file
83
src/lib/ui/skillNodes.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* A skill's declared section, as a node in the page tree.
|
||||
*
|
||||
* This is the join between the two halves of the UI system. A Board skill's
|
||||
* `ui:` block is still parsed by `uiConfig.normalizeSection`, still validated
|
||||
* against the closed vocabulary, and still drawn by the same nine components —
|
||||
* nothing about definitions changes. What this adds is *identity*: the section
|
||||
* becomes an addressable node, so it can be hidden, moved and reordered by the
|
||||
* same operations that move a built-in.
|
||||
*
|
||||
* **Nothing here writes back to Markdown.** A node carries `origin: 'skill'`,
|
||||
* and a change to it is stored as an operation in the person's own layout
|
||||
* patch. The definition on disk, and the definition in the account's custom
|
||||
* skills, are read-only from here — which is what keeps a layout preference
|
||||
* from silently editing something another user also sees.
|
||||
*/
|
||||
|
||||
import { dataSourceFor, sourceSupportsOption } from '@/lib/skills/surfaces';
|
||||
import { makeNode } from './node';
|
||||
|
||||
/** Ids are `skill-<skill>-<section>`, so provenance is legible in the DOM. */
|
||||
export const skillNodeId = (skillId, sectionId) => [
|
||||
'skill', slug(skillId), slug(sectionId),
|
||||
].filter(Boolean).join('-');
|
||||
|
||||
const slug = (value) => String(value || '')
|
||||
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
|
||||
/**
|
||||
* One normalized section, adapted.
|
||||
*
|
||||
* Only options the source actually declares are carried across. The skill
|
||||
* format checks that a period is a real period; this checks that the reading
|
||||
* takes periods at all — a stricter rule, and one a definition written against
|
||||
* the looser one could fail. Dropping the option is right where refusing the
|
||||
* node would not be: a Board card must never disappear because of a parameter
|
||||
* that was doing nothing anyway.
|
||||
*/
|
||||
export function skillSectionNode(skill, section) {
|
||||
const params = {};
|
||||
if (section.periods?.length && sourceSupportsOption(section.source, 'periods')) {
|
||||
params.periods = [...section.periods];
|
||||
}
|
||||
if (section.limit && sourceSupportsOption(section.source, 'limit')) {
|
||||
params.limit = section.limit;
|
||||
}
|
||||
|
||||
return makeNode({
|
||||
id: skillNodeId(skill.id, section.id),
|
||||
type: section.type,
|
||||
origin: 'skill',
|
||||
data: { source: section.source, params },
|
||||
props: {
|
||||
/* The same fallback the surface uses, so a section with no title of its
|
||||
own is still named by the skill that contributed it. */
|
||||
title: section.title || skill.name,
|
||||
...(section.description ? { description: section.description } : {}),
|
||||
/* Attribution is what lets a reader tell an extension from a built-in
|
||||
panel, and which skill to switch off. */
|
||||
attribution: skill.name,
|
||||
...(section.editable ? { editable: true } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every section a page's definitions contribute, grouped by placement.
|
||||
*
|
||||
* Grouped because that is how the composition consumes them: each
|
||||
* `skill-surface` node names one placement and takes the sections that declared
|
||||
* it. A placement no slot offers simply has nowhere to render, which is the
|
||||
* same outcome as today.
|
||||
*/
|
||||
export function skillNodesByPlacement(sections = []) {
|
||||
const out = {};
|
||||
for (const { skill, section } of sections) {
|
||||
if (!dataSourceFor(section.source)) continue;
|
||||
const key = section.placement || '';
|
||||
if (!out[key]) out[key] = [];
|
||||
out[key].push(skillSectionNode(skill, section));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
393
src/lib/ui/validate.js
Normal file
393
src/lib/ui/validate.js
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* What a UI tree has to satisfy before anything is applied or saved.
|
||||
*
|
||||
* This is the gate the plan runs through, and it is deliberately the *only*
|
||||
* place that decides whether a change is allowed. Operations build a candidate
|
||||
* tree; this says whether the product can honour it. Keeping the two apart is
|
||||
* what makes a preview trustworthy: the tree a user is shown is the tree that
|
||||
* passed, not a tree that will be checked again differently on the way to
|
||||
* storage.
|
||||
*
|
||||
* Every refusal names what it refused and why. A validator that returns a
|
||||
* boolean pushes the explaining into whichever caller happens to be nearest,
|
||||
* and the caller does not know which rule fired.
|
||||
*
|
||||
* The rules split in two:
|
||||
*
|
||||
* - **Structure**, which this module owns: ids, types, props, layout,
|
||||
* containment, capability, role.
|
||||
* - **Data**, which it borrows from `lib/skills/surfaces.js` — the same
|
||||
* closed source vocabulary and the same source/shape compatibility rule
|
||||
* that `uiConfig.normalizeSection` already refuses on. Borrowed rather than
|
||||
* restated: two readings of what a source can draw is how a form composes
|
||||
* what the normalizer rejects.
|
||||
*/
|
||||
|
||||
import {
|
||||
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, dataSourceFor, sourceSupportsOption,
|
||||
sourceSupportsShape,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import {
|
||||
ALIGN_VALUES, DENSITY_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_ID_PATTERN,
|
||||
PRESENTATION_KEYS, SPACING_VALUES, VARIANT_VALUES, walk,
|
||||
} from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/** One refusal. `at` is the node it concerns, so an editor can point at it. */
|
||||
const problem = (at, message) => ({ at: at || null, message });
|
||||
|
||||
/**
|
||||
* Check one node in isolation, given where it sits.
|
||||
*
|
||||
* `parentType` is null at the root. Returns a list of problems, empty when the
|
||||
* node is fine — never throws, because a tree with three bad nodes should
|
||||
* report three, not the first.
|
||||
*/
|
||||
export function validateNode(node, {
|
||||
parentType = null, registry = nodeRegistry, role = null,
|
||||
} = {}) {
|
||||
const problems = [];
|
||||
const at = node?.id || null;
|
||||
|
||||
if (!node || typeof node !== 'object') {
|
||||
return [problem(null, 'A node must be a mapping of options.')];
|
||||
}
|
||||
|
||||
/* ── Identity ─────────────────────────────────────────────────────────── */
|
||||
if (!node.id) {
|
||||
problems.push(problem(null, 'A node needs an `id`.'));
|
||||
} else if (!NODE_ID_PATTERN.test(node.id)) {
|
||||
problems.push(problem(at, `\`${node.id}\`: an id must be lower-case letters, numbers and dashes.`));
|
||||
}
|
||||
|
||||
/* ── Type ─────────────────────────────────────────────────────────────── */
|
||||
if (!node.type) {
|
||||
problems.push(problem(at, 'A node needs a `type`.'));
|
||||
return problems;
|
||||
}
|
||||
|
||||
const entry = registry.get(node.type);
|
||||
if (!entry) {
|
||||
/* The hallucinated-component gate. A type the application does not ship
|
||||
cannot be rendered, and naming the supported set is what turns a refusal
|
||||
into something the asker can act on. */
|
||||
problems.push(problem(
|
||||
at,
|
||||
`Unsupported UI type: ${node.type}. Supported types: ${registry.list().join(', ') || 'none registered'}.`
|
||||
));
|
||||
return problems;
|
||||
}
|
||||
|
||||
/* ── Containment ──────────────────────────────────────────────────────── */
|
||||
if (!registry.acceptsChild(parentType, node.type)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${node.type}\` cannot sit inside \`${parentType}\`.`
|
||||
));
|
||||
}
|
||||
if (node.children?.length && !entry.container) {
|
||||
problems.push(problem(at, `\`${node.type}\` cannot hold other nodes.`));
|
||||
}
|
||||
|
||||
const { minChildren, maxChildren } = entry.constraints;
|
||||
const count = node.children?.length || 0;
|
||||
if (Number.isFinite(minChildren) && count < minChildren) {
|
||||
problems.push(problem(at, `\`${node.type}\` needs at least ${minChildren} node(s); it has ${count}.`));
|
||||
}
|
||||
if (Number.isFinite(maxChildren) && count > maxChildren) {
|
||||
problems.push(problem(at, `\`${node.type}\` holds at most ${maxChildren} node(s); it has ${count}.`));
|
||||
}
|
||||
|
||||
/* ── Permission ───────────────────────────────────────────────────────── */
|
||||
if (entry.roles && role && !entry.roles.includes(role)) {
|
||||
/* Named without describing the component, because the refusal is about the
|
||||
caller and not about what they are missing. */
|
||||
problems.push(problem(at, `You do not have access to \`${entry.label}\`.`));
|
||||
}
|
||||
|
||||
problems.push(...validateProps(node, entry));
|
||||
problems.push(...validateLayout(node, entry));
|
||||
problems.push(...validatePresentation(node, entry));
|
||||
problems.push(...validateBinding(node, entry));
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props, against the schema the type declared.
|
||||
*
|
||||
* Unknown keys are refused rather than dropped. Dropping is right when reading a
|
||||
* definition an author wrote by hand — `uiConfig` does exactly that — but this
|
||||
* path is a *mutation*, and a silently discarded prop is a change the user
|
||||
* asked for, was told had been applied, and then did not happen.
|
||||
*/
|
||||
function validateProps(node, entry) {
|
||||
const problems = [];
|
||||
const schema = entry.propSchema || {};
|
||||
const at = node.id;
|
||||
|
||||
for (const [key, value] of Object.entries(node.props || {})) {
|
||||
const rule = schema[key];
|
||||
if (!rule) {
|
||||
const known = Object.keys(schema);
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${entry.type}\` has no property \`${key}\`.`
|
||||
+ (known.length ? ` It accepts: ${known.join(', ')}.` : '')
|
||||
));
|
||||
continue;
|
||||
}
|
||||
problems.push(...checkValue(at, entry.type, key, value, rule));
|
||||
}
|
||||
|
||||
for (const [key, rule] of Object.entries(schema)) {
|
||||
if (rule?.required && node.props?.[key] == null) {
|
||||
problems.push(problem(at, `\`${entry.type}\` needs \`${key}\`.`));
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** One prop against one rule. Kept separate so the rule vocabulary has one reader. */
|
||||
function checkValue(at, type, key, value, rule) {
|
||||
const problems = [];
|
||||
|
||||
if (Array.isArray(rule.enum)) {
|
||||
if (!rule.enum.includes(value)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${type}.${key}\` must be one of: ${rule.enum.join(', ')}. Got \`${value}\`.`
|
||||
));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
const kind = rule.type || 'string';
|
||||
const actual = Array.isArray(value) ? 'array' : typeof value;
|
||||
|
||||
if (kind === 'number') {
|
||||
if (!Number.isFinite(Number(value))) {
|
||||
problems.push(problem(at, `\`${type}.${key}\` must be a number. Got \`${value}\`.`));
|
||||
return problems;
|
||||
}
|
||||
const n = Number(value);
|
||||
if (Number.isFinite(rule.min) && n < rule.min) {
|
||||
problems.push(problem(at, `\`${type}.${key}\` must be at least ${rule.min}.`));
|
||||
}
|
||||
if (Number.isFinite(rule.max) && n > rule.max) {
|
||||
problems.push(problem(at, `\`${type}.${key}\` must be at most ${rule.max}.`));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
if (kind !== actual) {
|
||||
problems.push(problem(at, `\`${type}.${key}\` must be a ${kind}. Got ${actual}.`));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout, bounded globally and then by the type.
|
||||
*
|
||||
* The responsive gate. A column count is the one layout value that can make a
|
||||
* page unusable on a phone rather than merely ugly, so it is bounded twice —
|
||||
* once by the grid the application can express at all, and once by what this
|
||||
* particular component stays readable in.
|
||||
*/
|
||||
function validateLayout(node, entry) {
|
||||
const problems = [];
|
||||
const at = node.id;
|
||||
const layout = node.layout || {};
|
||||
const { minColumns, maxColumns } = entry.constraints;
|
||||
|
||||
for (const key of ['columns', 'span']) {
|
||||
if (layout[key] == null) continue;
|
||||
const n = Number(layout[key]);
|
||||
if (!Number.isInteger(n)) {
|
||||
problems.push(problem(at, `\`${key}\` must be a whole number. Got \`${layout[key]}\`.`));
|
||||
continue;
|
||||
}
|
||||
if (n < MIN_COLUMNS || n > MAX_COLUMNS) {
|
||||
problems.push(problem(at, `\`${key}\` must be between ${MIN_COLUMNS} and ${MAX_COLUMNS}.`));
|
||||
continue;
|
||||
}
|
||||
if (key === 'columns' && (n < minColumns || n > maxColumns)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${entry.type}\` supports ${minColumns}–${maxColumns} columns. Got ${n}.`
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (layout.gap != null && !GAP_VALUES.includes(layout.gap)) {
|
||||
problems.push(problem(at, `\`gap\` must be one of: ${GAP_VALUES.join(', ')}.`));
|
||||
}
|
||||
if (layout.align != null && !ALIGN_VALUES.includes(layout.align)) {
|
||||
problems.push(problem(at, `\`align\` must be one of: ${ALIGN_VALUES.join(', ')}.`));
|
||||
}
|
||||
|
||||
for (const key of ['spacingBefore', 'spacingAfter']) {
|
||||
if (layout[key] != null && !SPACING_VALUES.includes(layout[key])) {
|
||||
problems.push(problem(at, `\`${key}\` must be one of: ${SPACING_VALUES.join(', ')}.`));
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentation, checked twice.
|
||||
*
|
||||
* Once against the product's vocabulary — a value that is not a value cannot be
|
||||
* stored — and once against what *this type* declared it can draw. The second
|
||||
* check is the one that matters: a setting a component ignores is a change a
|
||||
* person made and cannot see, which is worse than being told no.
|
||||
*
|
||||
* The refusal names what is available, because the person is choosing from a
|
||||
* closed set and the set is short enough to say out loud.
|
||||
*/
|
||||
function validatePresentation(node, entry) {
|
||||
const problems = [];
|
||||
const at = node.id;
|
||||
const presentation = node.presentation || {};
|
||||
|
||||
for (const key of Object.keys(presentation)) {
|
||||
if (!PRESENTATION_KEYS.includes(key)) {
|
||||
problems.push(problem(at, `\`${key}\` is not a presentation setting.`));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, allowed, supported] of [
|
||||
['variant', VARIANT_VALUES, entry.variants],
|
||||
['density', DENSITY_VALUES, entry.densities],
|
||||
]) {
|
||||
const value = presentation[key];
|
||||
if (value == null) continue;
|
||||
|
||||
if (!allowed.includes(value)) {
|
||||
problems.push(problem(at, `\`${key}\` must be one of: ${allowed.join(', ')}.`));
|
||||
continue;
|
||||
}
|
||||
if (!supported.includes(value)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
supported.length
|
||||
? `\`${entry.type}\` supports ${key}: ${supported.join(', ')}. Got \`${value}\`.`
|
||||
: `\`${entry.type}\` has no ${key} to set.`
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* The data binding — the gate that stops invented figures.
|
||||
*
|
||||
* A node cannot carry data; it can only name a reading the application already
|
||||
* offers. So a fabricated metric has nowhere to live: the resolver either
|
||||
* returns real rows for a real source or the section draws its empty note. The
|
||||
* three checks are that the source exists, that this component can draw it, and
|
||||
* that any options named are ones the source actually reads.
|
||||
*/
|
||||
function validateBinding(node, entry) {
|
||||
const problems = [];
|
||||
const at = node.id;
|
||||
const binding = node.data;
|
||||
|
||||
if (!binding) {
|
||||
if (entry.dataRequired) {
|
||||
problems.push(problem(at, `\`${entry.type}\` needs a data source.`));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
if (!entry.dataShapes.length) {
|
||||
problems.push(problem(at, `\`${entry.type}\` does not read data, so it cannot take a source.`));
|
||||
return problems;
|
||||
}
|
||||
|
||||
if (!SUPPORTED_DATA_SOURCES.includes(binding.source)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`Unsupported data source: ${binding.source}. `
|
||||
+ `Supported sources: ${SUPPORTED_DATA_SOURCES.join(', ')}.`
|
||||
));
|
||||
return problems;
|
||||
}
|
||||
|
||||
/* A component draws one or more shapes; a source can fill some of them. The
|
||||
node is only coherent where the two overlap. */
|
||||
const drawable = entry.dataShapes.filter((shape) => sourceSupportsShape(binding.source, shape));
|
||||
if (!drawable.length) {
|
||||
const source = dataSourceFor(binding.source);
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${binding.source}\` cannot be shown as \`${entry.type}\`. `
|
||||
+ `It supports: ${(source?.shapes || []).join(', ')}.`
|
||||
));
|
||||
}
|
||||
|
||||
const params = binding.params || {};
|
||||
|
||||
if (params.periods != null) {
|
||||
if (!Array.isArray(params.periods)) {
|
||||
problems.push(problem(at, '`periods` must be a list.'));
|
||||
} else {
|
||||
const unknown = params.periods.find((p) => !SUPPORTED_PERIODS.includes(p));
|
||||
if (unknown) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`Unsupported period: ${unknown}. Supported periods: ${SUPPORTED_PERIODS.join(', ')}.`
|
||||
));
|
||||
} else if (params.periods.length && !sourceSupportsOption(binding.source, 'periods')) {
|
||||
problems.push(problem(at, `\`${binding.source}\` does not read periods.`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (params.limit != null) {
|
||||
const limit = Number(params.limit);
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
|
||||
problems.push(problem(at, '`limit` must be a whole number between 1 and 50.'));
|
||||
} else if (!sourceSupportsOption(binding.source, 'limit')) {
|
||||
problems.push(problem(at, `\`${binding.source}\` does not read a limit.`));
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole tree.
|
||||
*
|
||||
* Two things can only be seen from up here: that no id is used twice, and that
|
||||
* containment holds all the way down. Both are checked once, over one walk, so
|
||||
* a large page is not traversed per rule.
|
||||
*/
|
||||
export function validateTree(nodes, { registry = nodeRegistry, role = null } = {}) {
|
||||
const problems = [];
|
||||
const seen = new Set();
|
||||
|
||||
const visit = (list, parentType) => {
|
||||
for (const node of list || []) {
|
||||
if (node?.id) {
|
||||
/* The duplicate-node gate. Two nodes with one id means every operation
|
||||
after this point addresses whichever the traversal reached first. */
|
||||
if (seen.has(node.id)) {
|
||||
problems.push(problem(node.id, `Two nodes share the id \`${node.id}\`.`));
|
||||
}
|
||||
seen.add(node.id);
|
||||
}
|
||||
problems.push(...validateNode(node, { parentType, registry, role }));
|
||||
if (node?.children?.length) visit(node.children, node.type);
|
||||
}
|
||||
};
|
||||
|
||||
visit(nodes, null);
|
||||
return { ok: problems.length === 0, problems };
|
||||
}
|
||||
|
||||
/** Every id already in use, so a new node can be given one that is not. */
|
||||
export const takenIds = (nodes) => new Set(walk(nodes).map((node) => node.id));
|
||||
Reference in New Issue
Block a user