update the workspace and skills flow
This commit is contained in:
@@ -798,6 +798,591 @@ for (const skill of reg.SKILLS) {
|
||||
`${rehydrated.id} / ${rehydrated.name}`);
|
||||
}
|
||||
|
||||
/* ── 8. Markdown ↔ manual equivalence ─────────────────────────────────────
|
||||
The two authoring paths, asserted to produce one artefact.
|
||||
|
||||
A form that cannot express what the format can is not a shortcut to it: the
|
||||
manual Owliver editor carried a single `source` shared by every selected
|
||||
capability, so a capability chosen before a source was picked composed no
|
||||
`responses:` block at all — and a definition whose capabilities all drop
|
||||
registers, shows as active on its page, and contributes no chip. These check
|
||||
that both doors compose the same definition, and that the one that cannot be
|
||||
configured is refused rather than saved empty. */
|
||||
console.log('\n── Markdown ↔ manual equivalence ──');
|
||||
|
||||
const dataResolver = await server.ssrLoadModule('/src/lib/skills/dataResolver.js');
|
||||
const positionsContext = Object.entries(placement.PLACEMENT_ROUTES)
|
||||
.find(([route]) => reg.pageKeyForRoute(route) === 'positions')?.[1];
|
||||
|
||||
/* The canonical model is what `parseSkill` makes of a definition. Comparing the
|
||||
whole record would compare its Markdown too, which is the one thing the two
|
||||
paths are allowed to differ on — so the runtime configuration is compared,
|
||||
which is exactly what every consumer reads. */
|
||||
const canonical = (skill) => JSON.parse(JSON.stringify({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
status: skill.status,
|
||||
pages: skill.pages,
|
||||
kind: skill.kind,
|
||||
triggers: skill.triggers,
|
||||
ui: skill.ui,
|
||||
owliver: skill.owliver,
|
||||
}));
|
||||
|
||||
/* TEST A / B — the Board card, written both ways. */
|
||||
const BOARD_MD = `---
|
||||
id: board
|
||||
name: Board
|
||||
description: Helps Owliver understand, analyze, and act on the current task board.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
ui:
|
||||
type: card
|
||||
placement: after-position-list-summary
|
||||
title: Board
|
||||
source: candidates.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
---
|
||||
|
||||
# Board
|
||||
`;
|
||||
|
||||
const boardManual = templates.uiSkillTemplate({
|
||||
id: 'board',
|
||||
name: 'Board',
|
||||
description: 'Helps Owliver understand, analyze, and act on the current task board.',
|
||||
pages: ['positions'],
|
||||
type: 'card',
|
||||
placement: 'after-position-list-summary',
|
||||
title: 'Board',
|
||||
source: 'candidates.activity',
|
||||
periods: ['today', 'last-7-days', 'previous-month'],
|
||||
});
|
||||
|
||||
record('TEST A: Markdown Board saves', reg.validateSkillSource(BOARD_MD) === null,
|
||||
reg.validateSkillSource(BOARD_MD) || 'accepted');
|
||||
record('TEST B: manual Board saves', reg.validateSkillSource(boardManual) === null,
|
||||
reg.validateSkillSource(boardManual) || 'accepted');
|
||||
|
||||
const boardFromMd = reg.parseSkill(BOARD_MD, { custom: true });
|
||||
const boardFromForm = reg.parseSkill(boardManual, { custom: true });
|
||||
const boardSection = (skill) => Object.values(skill.ui || {}).flatMap((pg) => pg.sections || [])[0];
|
||||
|
||||
record('TEST A: one section, above the position list',
|
||||
Object.values(boardFromMd.ui).flatMap((pg) => pg.sections).length === 1
|
||||
&& boardSection(boardFromMd).placement === 'after-position-list-summary',
|
||||
`${boardSection(boardFromMd).type}@${boardSection(boardFromMd).placement}`);
|
||||
record('TEST A: the Board card contributes no Owliver capability',
|
||||
boardFromMd.owliver.enabled === false && boardFromMd.owliver.capabilities.length === 0);
|
||||
record('TEST A: no unresolvable section', reg.unresolvableSections(boardFromMd).length === 0,
|
||||
reg.unresolvableSections(boardFromMd)[0]?.message || 'none');
|
||||
record('TEST B: manual Board renders the identical section',
|
||||
JSON.stringify(boardSection(boardFromForm)) === JSON.stringify(boardSection(boardFromMd)),
|
||||
JSON.stringify(boardSection(boardFromForm)));
|
||||
record('TEST B: manual Board keeps its title', boardSection(boardFromForm).title === 'Board',
|
||||
String(boardSection(boardFromForm).title));
|
||||
|
||||
/* TEST C / D — the conversational skill, written both ways. */
|
||||
const OWLIVER_MD = `---
|
||||
id: owliver-conversation-test
|
||||
name: Hiring Activity Test
|
||||
description: Shows hiring activity from the Positions page.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
triggers:
|
||||
- hiring activity test
|
||||
owliver:
|
||||
enabled: true
|
||||
suggestions:
|
||||
- Show hiring activity
|
||||
- Summarize hiring activity
|
||||
capabilities:
|
||||
- summary
|
||||
responses:
|
||||
summary:
|
||||
source: candidates.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
---
|
||||
|
||||
# Hiring Activity Test
|
||||
`;
|
||||
|
||||
const owliverManual = templates.owliverSkillTemplate({
|
||||
id: 'owliver-conversation-test',
|
||||
name: 'Hiring Activity Test',
|
||||
description: 'Shows hiring activity from the Positions page.',
|
||||
pages: ['positions'],
|
||||
suggestions: ['Show hiring activity', 'Summarize hiring activity'],
|
||||
capabilities: ['summary'],
|
||||
responses: {
|
||||
summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'] },
|
||||
},
|
||||
});
|
||||
|
||||
record('TEST C: Markdown Owliver skill saves', reg.validateSkillSource(OWLIVER_MD) === null,
|
||||
reg.validateSkillSource(OWLIVER_MD) || 'accepted');
|
||||
record('TEST D: manual Owliver skill saves', reg.validateSkillSource(owliverManual) === null,
|
||||
reg.validateSkillSource(owliverManual) || 'accepted');
|
||||
|
||||
for (const [label, source] of [['TEST C', OWLIVER_MD], ['TEST D', owliverManual]]) {
|
||||
const custom = [{ path: 'custom/owliver-conversation-test.md', raw: source }];
|
||||
const skill = reg.parseSkill(source, { custom: true });
|
||||
|
||||
record(`${label}: the capability keeps its source`,
|
||||
skill.owliver.responses.summary?.source === 'candidates.activity',
|
||||
skill.owliver.responses.summary?.source || 'dropped');
|
||||
record(`${label}: the capability keeps its periods`,
|
||||
JSON.stringify(skill.owliver.responses.summary?.periods)
|
||||
=== JSON.stringify(['today', 'last-7-days', 'previous-month']),
|
||||
JSON.stringify(skill.owliver.responses.summary?.periods));
|
||||
record(`${label}: it draws no page card`,
|
||||
Object.keys(skill.ui).length === 0, `${Object.keys(skill.ui).length} pages`);
|
||||
|
||||
const chips = resolver.owliverSuggestions(positionsContext, [], custom, {});
|
||||
const mine = chips.filter((c) => c.skillId === 'owliver-conversation-test');
|
||||
record(`${label}: its suggestion reaches the panel`,
|
||||
mine.some((c) => c.label === 'Show hiring activity'),
|
||||
JSON.stringify(chips.map((c) => c.label)));
|
||||
record(`${label}: the suggestion answers without asking for a record`,
|
||||
mine.every((c) => !c.deferred));
|
||||
|
||||
const owlSkills = resolver.owliverSkillsForContext(positionsContext, [], custom);
|
||||
const matched = resolver.matchOwliverSkill('Show hiring activity', owlSkills);
|
||||
record(`${label}: clicking it routes to this skill's summary`,
|
||||
matched?.skill.id === 'owliver-conversation-test' && matched?.capability === 'summary' && matched?.exact,
|
||||
`${matched?.skill.id}:${matched?.capability} exact=${matched?.exact}`);
|
||||
|
||||
const answered = resolver.resolveOwliverResponse({
|
||||
skill: matched.skill,
|
||||
capability: matched.capability,
|
||||
question: 'Show hiring activity',
|
||||
context: {
|
||||
positions: [{ id: 'p1', title: 'Line Cook', status: 'active' }],
|
||||
applications: [{ id: 'a1', job_posting_id: 'p1', created_date: new Date().toISOString() }],
|
||||
},
|
||||
now: new Date(),
|
||||
});
|
||||
record(`${label}: it answers with the configured reading`,
|
||||
answered?.missing === null && resolver.summaryLines(answered.data).length === 3,
|
||||
JSON.stringify(resolver.summaryLines(answered?.data || {})));
|
||||
}
|
||||
|
||||
/* TEST H — the same skill through both doors, normalized, compared. */
|
||||
record('TEST H: Markdown and manual normalize identically (Board)',
|
||||
JSON.stringify(canonical(boardFromMd)) === JSON.stringify(canonical(boardFromForm)),
|
||||
JSON.stringify(canonical(boardFromForm)).slice(0, 120));
|
||||
record('TEST H: Markdown and manual normalize identically (Owliver)',
|
||||
JSON.stringify(canonical(reg.parseSkill(OWLIVER_MD, { custom: true })))
|
||||
=== JSON.stringify(canonical(reg.parseSkill(owliverManual, { custom: true }))),
|
||||
JSON.stringify(canonical(reg.parseSkill(owliverManual, { custom: true }))).slice(0, 120));
|
||||
|
||||
/* TEST E — a capability with no source is refused, never saved empty. */
|
||||
const noSourceFields = {
|
||||
id: 'no-source-test',
|
||||
name: 'No Source Test',
|
||||
description: 'A capability with nothing to read.',
|
||||
pages: ['positions'],
|
||||
suggestions: ['Show hiring activity'],
|
||||
capabilities: ['summary'],
|
||||
responses: {},
|
||||
};
|
||||
const noSource = templates.owliverSkillTemplate(noSourceFields);
|
||||
record('TEST E: an unconfigured capability is named by the form',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(noSourceFields)) === '["summary"]',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(noSourceFields)));
|
||||
record('TEST E: and the save is refused',
|
||||
/owliver\.responses\.summary/.test(reg.validateSkillSource(noSource) || ''),
|
||||
reg.validateSkillSource(noSource) || 'ACCEPTED — a skill with no capability would register');
|
||||
record('TEST E: the refused definition would have had zero capabilities',
|
||||
reg.parseSkill(noSource, { custom: true }).owliver.capabilities.length === 0,
|
||||
'which is why it is refused rather than stored');
|
||||
|
||||
/* TEST F — two capabilities, two different sources, neither inheriting. */
|
||||
const twoSources = templates.owliverSkillTemplate({
|
||||
id: 'two-source-test',
|
||||
name: 'Two Source Test',
|
||||
description: 'Two capabilities reading two sources.',
|
||||
pages: ['positions'],
|
||||
suggestions: ['Summarize workspace applications', 'List the open roles'],
|
||||
capabilities: ['summary', 'list'],
|
||||
responses: {
|
||||
summary: { source: 'candidates.activity', periods: ['today'] },
|
||||
list: { source: 'positions.demand', limit: 5 },
|
||||
},
|
||||
});
|
||||
const twoSkill = reg.parseSkill(twoSources, { custom: true });
|
||||
record('TEST F: manual save accepted', reg.validateSkillSource(twoSources) === null,
|
||||
reg.validateSkillSource(twoSources) || 'accepted');
|
||||
record('TEST F: each capability keeps its own source',
|
||||
twoSkill.owliver.responses.summary?.source === 'candidates.activity'
|
||||
&& twoSkill.owliver.responses.list?.source === 'positions.demand',
|
||||
`summary→${twoSkill.owliver.responses.summary?.source}, list→${twoSkill.owliver.responses.list?.source}`);
|
||||
record('TEST F: neither inherits the other\'s options',
|
||||
JSON.stringify(twoSkill.owliver.responses.summary.periods) === '["today"]'
|
||||
&& twoSkill.owliver.responses.list.limit === 5
|
||||
&& twoSkill.owliver.responses.list.periods.length === 0,
|
||||
`summary periods=${JSON.stringify(twoSkill.owliver.responses.summary.periods)}, list limit=${twoSkill.owliver.responses.list.limit}`);
|
||||
record('TEST F: both are offered as suggestions',
|
||||
resolver.owliverSuggestions(positionsContext, [], [{ path: 'custom/two.md', raw: twoSources }], {})
|
||||
.filter((c) => c.skillId === 'two-source-test').length === 2);
|
||||
|
||||
/* TEST G — editing one field leaves every other configuration alone. */
|
||||
const edited = fields.patchFrontmatter(
|
||||
twoSources,
|
||||
fields.owliverPatch(
|
||||
{ ...fields.owliverFieldsFromSource(twoSources), description: 'A new description.' },
|
||||
{ existing: twoSources }
|
||||
)
|
||||
);
|
||||
const editedSkill = reg.parseSkill(edited, { custom: true });
|
||||
record('TEST G: the edit lands', editedSkill.description === 'A new description.', editedSkill.description);
|
||||
record('TEST G: every capability source survives the edit',
|
||||
JSON.stringify(canonical(editedSkill).owliver.responses)
|
||||
=== JSON.stringify(canonical(twoSkill).owliver.responses),
|
||||
`summary→${editedSkill.owliver.responses.summary?.source}, list→${editedSkill.owliver.responses.list?.source}`);
|
||||
record('TEST G: suggestions survive the edit',
|
||||
JSON.stringify(editedSkill.owliver.suggestions) === JSON.stringify(twoSkill.owliver.suggestions));
|
||||
|
||||
const boardEdited = fields.patchFrontmatter(
|
||||
boardManual,
|
||||
fields.boardPatch(
|
||||
{ ...fields.boardFieldsFromSource(boardManual), description: 'A new description.' },
|
||||
{ existing: boardManual }
|
||||
)
|
||||
);
|
||||
record('TEST G: Board UI configuration survives the edit',
|
||||
JSON.stringify(boardSection(reg.parseSkill(boardEdited, { custom: true })))
|
||||
=== JSON.stringify(boardSection(boardFromForm)),
|
||||
JSON.stringify(boardSection(reg.parseSkill(boardEdited, { custom: true }))));
|
||||
|
||||
/* TEST 6 — one source/shape resolver, shared by the pickers and the validator. */
|
||||
record('shape compatibility: the resolver refuses what the normalizer refuses',
|
||||
surfaces.sourcesForShape('list').every((src) => surfaces.sourceSupportsShape(src.id, 'list'))
|
||||
&& !surfaces.sourcesForShape('list').some((src) => src.id === 'candidates.activity'),
|
||||
`list sources: ${surfaces.sourcesForShape('list').map((s) => s.id).join(', ')}`);
|
||||
record('shape compatibility: a list against candidates.activity is refused on save',
|
||||
/cannot be shown as/.test(reg.validateSkillSource(templates.owliverSkillTemplate({
|
||||
id: 'bad-shape-test',
|
||||
name: 'Bad Shape Test',
|
||||
description: 'A list of something with no list in it.',
|
||||
pages: ['positions'],
|
||||
capabilities: ['list'],
|
||||
responses: { list: { source: 'candidates.activity' } },
|
||||
})) || ''),
|
||||
'the picker cannot offer it, and the save refuses it');
|
||||
record('summary is prose, so every source suits it',
|
||||
surfaces.shapeForCapability('summary') === null
|
||||
&& surfaces.sourcesForShape(null).length === surfaces.DATA_SOURCES.length);
|
||||
|
||||
/* ── 9. The reported failure, reproduced end to end ───────────────────────── */
|
||||
console.log('\n── Regression: manual Owliver skill on Positions ──');
|
||||
|
||||
/* Exactly the configuration from the report: Pages = Positions, one suggestion,
|
||||
Summary, `candidates.activity`, three periods — composed the way the form
|
||||
composes it, then read the way the panel reads it. */
|
||||
const REGRESSION = templates.owliverSkillTemplate({
|
||||
id: 'owliver-conversation-test',
|
||||
name: 'Hiring Activity Test',
|
||||
description: 'Shows hiring activity from the Positions page.',
|
||||
pages: ['positions'],
|
||||
suggestions: ['Show hiring activity'],
|
||||
capabilities: ['summary'],
|
||||
responses: {
|
||||
summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'] },
|
||||
},
|
||||
});
|
||||
const stored = [{ path: 'custom/owliver-conversation-test.md', raw: REGRESSION }];
|
||||
|
||||
record('the manual definition saves', reg.validateSkillSource(REGRESSION) === null,
|
||||
reg.validateSkillSource(REGRESSION) || 'accepted');
|
||||
record('it registers', reg.allSkills(stored).some((s) => s.id === 'owliver-conversation-test'));
|
||||
record('it is attached to Positions',
|
||||
reg.skillsForContext(positionsContext, [], stored).some((s) => s.id === 'owliver-conversation-test'));
|
||||
record('it keeps a capability — the step that used to empty',
|
||||
reg.parseSkill(REGRESSION, { custom: true }).owliver.capabilities.length === 1,
|
||||
JSON.stringify(reg.parseSkill(REGRESSION, { custom: true }).owliver.capabilities));
|
||||
record('it reaches owliverSkillsForContext',
|
||||
resolver.owliverSkillsForContext(positionsContext, [], stored)
|
||||
.some((s) => s.id === 'owliver-conversation-test'));
|
||||
|
||||
const regressionChips = resolver.owliverSuggestions(positionsContext, [], stored, {});
|
||||
record('"Show hiring activity" is offered as a chip',
|
||||
regressionChips.some((c) => c.label === 'Show hiring activity'),
|
||||
JSON.stringify(regressionChips.map((c) => c.label)));
|
||||
|
||||
const clicked = resolver.matchOwliverSkill(
|
||||
'Show hiring activity',
|
||||
resolver.owliverSkillsForContext(positionsContext, [], stored)
|
||||
);
|
||||
record('clicking it invokes this skill\'s summary',
|
||||
clicked?.skill.id === 'owliver-conversation-test' && clicked?.capability === 'summary',
|
||||
`${clicked?.skill.id}:${clicked?.capability}`);
|
||||
|
||||
const now = new Date();
|
||||
const answer = resolver.resolveOwliverResponse({
|
||||
skill: clicked.skill,
|
||||
capability: clicked.capability,
|
||||
question: 'Show hiring activity',
|
||||
context: {
|
||||
positions: [{ id: 'p1', title: 'Line Cook', status: 'active' }],
|
||||
applications: [
|
||||
{ id: 'a1', job_posting_id: 'p1', created_date: now.toISOString() },
|
||||
{ id: 'a2', job_posting_id: 'p1', created_date: new Date(now - 3 * 86400000).toISOString() },
|
||||
],
|
||||
},
|
||||
now,
|
||||
});
|
||||
record('it answers with the configured source and periods',
|
||||
answer?.missing === null && answer.section.source === 'candidates.activity'
|
||||
&& answer.section.periods.length === 3,
|
||||
`${answer?.section.source} over ${JSON.stringify(answer?.section.periods)}`);
|
||||
record('the answer carries real figures',
|
||||
resolver.summaryLines(answer.data).length === 3,
|
||||
JSON.stringify(resolver.summaryLines(answer.data)));
|
||||
record('and no `position.activity needs a position` is possible',
|
||||
dataResolver.resolveSkillData(answer.section, { applications: [], positions: [] }, now).unavailable !== true,
|
||||
'the source declares no context requirement');
|
||||
|
||||
/* Reload: what is stored is the Markdown, so the round trip through storage is
|
||||
the round trip through the parser. */
|
||||
const reloaded = reg.parseSkill(
|
||||
fields.patchFrontmatter(REGRESSION, {}),
|
||||
{ custom: true }
|
||||
);
|
||||
record('the source survives a reload',
|
||||
reloaded.owliver.responses.summary?.source === 'candidates.activity',
|
||||
reloaded.owliver.responses.summary?.source || 'lost');
|
||||
record('the form reads it back unchanged',
|
||||
JSON.stringify(fields.owliverFieldsFromSource(REGRESSION).responses)
|
||||
=== JSON.stringify({ summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'], limit: null } }),
|
||||
JSON.stringify(fields.owliverFieldsFromSource(REGRESSION).responses));
|
||||
|
||||
/* ── 10. Storage round trip ───────────────────────────────────────────────
|
||||
The editors do not hand the registry a record; they write Markdown into
|
||||
`preferences.customSkills`, which `base44Client` persists to localStorage and
|
||||
reads back on the next load. "The source disappeared after reload" is a claim
|
||||
about *that* path, so it is exercised here rather than assumed: the same
|
||||
module the app runs, against a localStorage the same shape the browser's is. */
|
||||
console.log('\n── Storage round trip ──');
|
||||
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
};
|
||||
|
||||
const { base44 } = await server.ssrLoadModule('/src/api/base44Client.js');
|
||||
const custom = await server.ssrLoadModule('/src/lib/skills/customSkills.js');
|
||||
|
||||
/* Saving is what the editor's Save button does: compose, validate, upsert,
|
||||
write the preference. */
|
||||
const saved = custom.upsertCustomSkill(base44.auth.preferences().customSkills || [], REGRESSION);
|
||||
const written = await base44.auth.updatePreferences({ customSkills: saved.next });
|
||||
record('the definition is persisted', written.persisted, written.error?.message || 'written to storage');
|
||||
|
||||
/* Reload: a fresh read of the same key, exactly as a page load does. */
|
||||
const rehydratedSkills = base44.auth.preferences().customSkills || [];
|
||||
record('it survives the reload', rehydratedSkills.length === 1
|
||||
&& String(rehydratedSkills[0].raw).includes('candidates.activity'),
|
||||
`${rehydratedSkills.length} stored`);
|
||||
|
||||
const afterReload = reg.parseSkill(rehydratedSkills[0].raw, { custom: true });
|
||||
record('its capability survives the reload',
|
||||
afterReload.owliver.responses.summary?.source === 'candidates.activity',
|
||||
afterReload.owliver.responses.summary?.source || 'lost');
|
||||
record('and it still offers its chip after the reload',
|
||||
resolver.owliverSuggestions(positionsContext, [], rehydratedSkills, {})
|
||||
.some((c) => c.label === 'Show hiring activity'),
|
||||
JSON.stringify(resolver.owliverSuggestions(positionsContext, [], rehydratedSkills, {}).map((c) => c.label)));
|
||||
record('the editor reopens it with its source intact',
|
||||
fields.owliverFieldsFromSource(rehydratedSkills[0].raw).responses.summary?.source === 'candidates.activity',
|
||||
JSON.stringify(fields.owliverFieldsFromSource(rehydratedSkills[0].raw).responses));
|
||||
|
||||
/* The Board skill through the same path, stored beside it — the two subsystems
|
||||
are independent, so both must survive one write. */
|
||||
const bothStored = custom.upsertCustomSkill(rehydratedSkills, boardManual).next;
|
||||
await base44.auth.updatePreferences({ customSkills: bothStored });
|
||||
const finalStored = base44.auth.preferences().customSkills || [];
|
||||
const finalRegistry = reg.readSkillRegistry(finalStored);
|
||||
record('Board and Owliver skills coexist in storage', finalStored.length === 2,
|
||||
`${finalStored.length} stored`);
|
||||
record('neither produces a registry diagnostic',
|
||||
!finalRegistry.diagnostics.some((d) => d.level === 'error'),
|
||||
finalRegistry.diagnostics.filter((d) => d.level === 'error').map((d) => d.message).join(' | ') || 'clean');
|
||||
record('the Board card draws once on Positions, and offers no chip',
|
||||
reg.getSkillsForPage('positions', { customSources: finalStored })
|
||||
.filter((sk) => sk.id === 'board')
|
||||
.every((sk) => Object.values(sk.ui).flatMap((pg) => pg.sections).length === 1
|
||||
&& sk.owliver.capabilities.length === 0));
|
||||
record('the Owliver skill offers its chip, and draws no card',
|
||||
resolver.owliverSuggestions(positionsContext, [], finalStored, {})
|
||||
.some((c) => c.skillId === 'owliver-conversation-test')
|
||||
&& Object.keys(reg.parseSkill(REGRESSION, { custom: true }).ui).length === 0);
|
||||
|
||||
/* ── 11. A fresh Add Owliver Skill screen ─────────────────────────────────
|
||||
What the form starts as, asserted at the state that drives the checkbox
|
||||
rather than at the checkbox. `checked` is
|
||||
`draft.capabilities.includes(capability.id)` and the per-capability
|
||||
configuration is rendered under `{checked && …}`, so the array below is the
|
||||
whole of "what is ticked" and "what is shown". */
|
||||
console.log('\n── Fresh Add Owliver Skill ──');
|
||||
|
||||
const CAPS = surfaces.OWLIVER_CAPABILITIES.map((c) => c.id);
|
||||
|
||||
/* 1. NEW skill. `emptyDraft` is `EMPTY_OWLIVER_FIELDS`, which is what the
|
||||
editor initializes to when there is no `:id` and nothing handed over. */
|
||||
record('new skill: initial capabilities are []',
|
||||
Array.isArray(fields.EMPTY_OWLIVER_FIELDS.capabilities)
|
||||
&& fields.EMPTY_OWLIVER_FIELDS.capabilities.length === 0,
|
||||
JSON.stringify(fields.EMPTY_OWLIVER_FIELDS.capabilities));
|
||||
record('new skill: no response is pre-configured either',
|
||||
Object.keys(fields.EMPTY_OWLIVER_FIELDS.responses).length === 0,
|
||||
JSON.stringify(fields.EMPTY_OWLIVER_FIELDS.responses));
|
||||
record(`new skill: all ${CAPS.length} capability checkboxes start unchecked`,
|
||||
CAPS.every((id) => !fields.EMPTY_OWLIVER_FIELDS.capabilities.includes(id)),
|
||||
CAPS.filter((id) => fields.EMPTY_OWLIVER_FIELDS.capabilities.includes(id)).join(', ') || 'none ticked');
|
||||
|
||||
/* The template the fresh screen composes must not declare capabilities either:
|
||||
an author who types a name before ticking anything patches *that* file, and a
|
||||
`capabilities:` key in it would tick boxes nobody chose. */
|
||||
const freshTemplate = templates.owliverSkillTemplate({});
|
||||
record('new skill: the composed template declares no capabilities',
|
||||
!/^\s*capabilities:/m.test(freshTemplate),
|
||||
freshTemplate.split('\n').filter((l) => /capabilit/i.test(l)).join(' / ') || 'no capabilities key');
|
||||
record('new skill: reading that template back still yields []',
|
||||
fields.owliverFieldsFromSource(freshTemplate).capabilities.length === 0,
|
||||
JSON.stringify(fields.owliverFieldsFromSource(freshTemplate).capabilities));
|
||||
|
||||
/* 2. One capability, chosen. The editor's `update({ capabilities })` is a plain
|
||||
state merge, so the assertion is on what that array then means. */
|
||||
const pickedSummary = { ...fields.EMPTY_OWLIVER_FIELDS, capabilities: ['summary'] };
|
||||
record('selecting Summary ticks exactly Summary',
|
||||
JSON.stringify(pickedSummary.capabilities) === '["summary"]');
|
||||
record('and only Summary renders a configuration block',
|
||||
CAPS.filter((id) => pickedSummary.capabilities.includes(id)).length === 1,
|
||||
CAPS.filter((id) => pickedSummary.capabilities.includes(id)).join(', '));
|
||||
record('an unsourced Summary is invalid, and only Summary is reported',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(pickedSummary)) === '["summary"]',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(pickedSummary)));
|
||||
|
||||
/* 3. Choosing its source clears the error, and the definition saves. */
|
||||
const sourcedSummary = {
|
||||
...pickedSummary,
|
||||
id: 'fresh-summary-test',
|
||||
name: 'Fresh Summary Test',
|
||||
description: 'One capability, configured.',
|
||||
pages: ['positions'],
|
||||
suggestions: ['Show hiring activity'],
|
||||
responses: { summary: { source: 'candidates.activity', periods: ['today'] } },
|
||||
};
|
||||
record('choosing a source clears the validation error',
|
||||
fields.unconfiguredCapabilities(sourcedSummary).length === 0,
|
||||
JSON.stringify(fields.unconfiguredCapabilities(sourcedSummary)));
|
||||
const freshSaved = templates.owliverSkillTemplate(sourcedSummary);
|
||||
record('and the save succeeds', reg.validateSkillSource(freshSaved) === null,
|
||||
reg.validateSkillSource(freshSaved) || 'accepted');
|
||||
record('the saved definition declares only the chosen capability',
|
||||
JSON.stringify(reg.parseSkill(freshSaved, { custom: true }).owliver.capabilities) === '["summary"]',
|
||||
JSON.stringify(reg.parseSkill(freshSaved, { custom: true }).owliver.capabilities));
|
||||
record('and its suggestion reaches the Positions panel',
|
||||
resolver.owliverSuggestions(positionsContext, [], [{ path: 'custom/fresh.md', raw: freshSaved }], {})
|
||||
.some((c) => c.skillId === 'fresh-summary-test'));
|
||||
|
||||
/* 4. Several, chosen by hand — only those, each validated on its own. */
|
||||
const pickedTwo = {
|
||||
...fields.EMPTY_OWLIVER_FIELDS,
|
||||
capabilities: ['summary', 'list'],
|
||||
responses: { summary: { source: 'candidates.activity' } },
|
||||
};
|
||||
record('selecting two capabilities renders exactly those two',
|
||||
CAPS.filter((id) => pickedTwo.capabilities.includes(id)).join(',') === 'summary,list');
|
||||
record('and only the unconfigured one is reported',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(pickedTwo)) === '["list"]',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(pickedTwo)));
|
||||
|
||||
/* 5. An EXISTING skill hydrates from its own definition, and from nothing else.
|
||||
Including the capability that did *not* resolve: it is the one the author
|
||||
opened the editor to fix, and dropping it from the form would delete it
|
||||
from the file on the next save. */
|
||||
const DECLARED_ONE = `---
|
||||
id: declared-one
|
||||
name: Declared One
|
||||
description: Declares a single capability.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
owliver:
|
||||
enabled: true
|
||||
suggestions:
|
||||
- Show hiring activity
|
||||
capabilities:
|
||||
- summary
|
||||
responses:
|
||||
summary:
|
||||
source: candidates.activity
|
||||
---
|
||||
|
||||
# Declared One
|
||||
`;
|
||||
record('existing skill: hydrates exactly its declared capability',
|
||||
JSON.stringify(fields.owliverFieldsFromSource(DECLARED_ONE).capabilities) === '["summary"]',
|
||||
JSON.stringify(fields.owliverFieldsFromSource(DECLARED_ONE).capabilities));
|
||||
record('existing skill: no new-skill default is applied on top',
|
||||
CAPS.filter((id) => fields.owliverFieldsFromSource(DECLARED_ONE).capabilities.includes(id)).length === 1);
|
||||
|
||||
const HALF_BROKEN = DECLARED_ONE
|
||||
.replace(' - summary\n', ' - summary\n - list\n')
|
||||
.replace('id: declared-one', 'id: half-broken');
|
||||
const halfFields = fields.owliverFieldsFromSource(HALF_BROKEN);
|
||||
record('existing skill: a declared capability that lost its source still shows',
|
||||
JSON.stringify(halfFields.capabilities) === '["summary","list"]',
|
||||
JSON.stringify(halfFields.capabilities));
|
||||
record('...unsourced, so it stays invalid rather than silently dropped',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(halfFields)) === '["list"]',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(halfFields)));
|
||||
record('...and the configured one keeps its source through the round trip',
|
||||
halfFields.responses.summary.source === 'candidates.activity',
|
||||
halfFields.responses.summary.source);
|
||||
record('...while the runtime still registers only what can answer',
|
||||
JSON.stringify(reg.parseSkill(HALF_BROKEN, { custom: true }).owliver.capabilities) === '["summary"]',
|
||||
JSON.stringify(reg.parseSkill(HALF_BROKEN, { custom: true }).owliver.capabilities));
|
||||
|
||||
/* A definition inheriting its reading from a `ui:` section still hydrates the
|
||||
capability it actually offers — the union is declaration-first, not
|
||||
declaration-only. */
|
||||
const INHERITED = `---
|
||||
id: inherited-cap
|
||||
name: Inherited Cap
|
||||
description: A capability reading the page section.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
ui:
|
||||
type: card
|
||||
placement: after-position-list-summary
|
||||
source: candidates.activity
|
||||
owliver:
|
||||
enabled: true
|
||||
capabilities:
|
||||
- card
|
||||
---
|
||||
|
||||
# Inherited Cap
|
||||
`;
|
||||
record('a capability inheriting from `ui:` still hydrates with its source',
|
||||
fields.owliverFieldsFromSource(INHERITED).responses.card?.source === 'candidates.activity',
|
||||
fields.owliverFieldsFromSource(INHERITED).responses.card?.source || 'lost');
|
||||
|
||||
await server.close();
|
||||
|
||||
/* ── 8. Production bundle ─────────────────────────────────────────────────── */
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ResponseDocument } from './ResponseBlocks';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseSkill } from './registry';
|
||||
import { boardPatch, owliverPatch, patchFrontmatter } from './skillFields';
|
||||
|
||||
/**
|
||||
* Account-authored skills, as stored.
|
||||
@@ -28,6 +29,22 @@ const frontMatter = ({ id, name, description, pages, fallback }) => [
|
||||
'status: active',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* The identity block every definition opens with, and nothing else.
|
||||
*
|
||||
* The configuration that follows it — the `ui:` section, the `owliver:` block —
|
||||
* is written by `boardPatch` and `owliverPatch`, which is the same writer the
|
||||
* editors use on an existing file. A template that composed its own YAML was a
|
||||
* second writer with a second field set, and the fields one knew about were not
|
||||
* the fields the other did.
|
||||
*/
|
||||
const skeleton = (fields, fallback, body) => `---
|
||||
${frontMatter({ ...fields, fallback })}
|
||||
---
|
||||
|
||||
${body}
|
||||
`;
|
||||
|
||||
/** A definition that draws a section on the pages it names. */
|
||||
export const uiSkillTemplate = ({
|
||||
id = '', name = '', description = '', pages = [],
|
||||
@@ -40,26 +57,18 @@ export const uiSkillTemplate = ({
|
||||
* record the page never had. A template must produce something that works
|
||||
* before it is edited, so the default reads a source that needs nothing.
|
||||
*/
|
||||
type = 'flow', placement = '', source = 'candidates.activity', periods = [],
|
||||
} = {}) => `---
|
||||
${frontMatter({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
pages,
|
||||
fallback: {
|
||||
id: 'my-ui-skill',
|
||||
name: 'My UI Skill',
|
||||
description: 'What this skill adds to the page.',
|
||||
},
|
||||
})}
|
||||
ui:
|
||||
type: ${type}
|
||||
${placement ? ` placement: ${placement}\n` : ''} title: ${name || 'My UI Skill'}
|
||||
source: ${source}
|
||||
${periods.length ? ` periods:\n${periods.map((p) => ` - ${p}`).join('\n')}\n` : ''}---
|
||||
type = 'flow', placement = '', title = '', source = 'candidates.activity', periods = [],
|
||||
} = {}) => {
|
||||
const identity = { id, name, description, pages };
|
||||
const fallback = {
|
||||
id: 'my-ui-skill',
|
||||
name: 'My UI Skill',
|
||||
description: 'What this skill adds to the page.',
|
||||
};
|
||||
const heading = name || fallback.name;
|
||||
|
||||
# ${name || 'My UI Skill'}
|
||||
return patchFrontmatter(
|
||||
skeleton(identity, fallback, `# ${heading}
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -68,59 +77,36 @@ Describe what this section shows, and why it belongs on these pages.
|
||||
## Capabilities
|
||||
|
||||
- Describe one thing the section reports.
|
||||
- Add more as needed.
|
||||
`;
|
||||
- Add more as needed.`),
|
||||
boardPatch({
|
||||
...identity,
|
||||
type,
|
||||
placement,
|
||||
/* A card with no title of its own is headed by the skill's name, which is
|
||||
what the previous template wrote out. Kept, so a fresh draft reads the
|
||||
same as it always did. */
|
||||
title: title || heading,
|
||||
source,
|
||||
periods,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/** A definition that teaches Owliver what it can be asked for. */
|
||||
export const owliverSkillTemplate = ({
|
||||
id = '', name = '', description = '', pages = [],
|
||||
triggers = [], suggestions = [], capabilities = [], source = '', periods = [],
|
||||
triggers = [], suggestions = [], capabilities = [], responses = {},
|
||||
} = {}) => {
|
||||
const label = name || 'My Owliver Skill';
|
||||
const lines = [`---
|
||||
${frontMatter({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
pages,
|
||||
fallback: {
|
||||
id: 'my-owliver-skill',
|
||||
name: label,
|
||||
description: 'What this skill helps Owliver answer.',
|
||||
},
|
||||
})}`];
|
||||
const identity = { id, name, description, pages };
|
||||
const fallback = {
|
||||
id: 'my-owliver-skill',
|
||||
name: 'My Owliver Skill',
|
||||
description: 'What this skill helps Owliver answer.',
|
||||
};
|
||||
const label = name || fallback.name;
|
||||
|
||||
lines.push('triggers:');
|
||||
lines.push((triggers.length ? triggers : [label.toLowerCase()]).map((t) => ` - ${t}`).join('\n'));
|
||||
|
||||
lines.push('owliver:');
|
||||
lines.push(' enabled: true');
|
||||
|
||||
if (suggestions.length) {
|
||||
lines.push(' suggestions:');
|
||||
lines.push(suggestions.map((s) => ` - ${s}`).join('\n'));
|
||||
}
|
||||
|
||||
if (capabilities.length) {
|
||||
lines.push(' capabilities:');
|
||||
lines.push(capabilities.map((c) => ` - ${c}`).join('\n'));
|
||||
|
||||
if (source) {
|
||||
lines.push(' responses:');
|
||||
for (const capability of capabilities) {
|
||||
lines.push(` ${capability}:`);
|
||||
lines.push(` source: ${source}`);
|
||||
if (periods.length) {
|
||||
lines.push(' periods:');
|
||||
lines.push(periods.map((p) => ` - ${p}`).join('\n'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`---
|
||||
|
||||
# ${label}
|
||||
return patchFrontmatter(
|
||||
skeleton(identity, fallback, `# ${label}
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -129,10 +115,17 @@ Describe what Owliver should be able to answer on these pages.
|
||||
## Capabilities
|
||||
|
||||
- Describe one thing Owliver can be asked for.
|
||||
- Add more as needed.
|
||||
`);
|
||||
|
||||
return lines.join('\n');
|
||||
- Add more as needed.`),
|
||||
owliverPatch({
|
||||
...identity,
|
||||
/* A definition that claims no phrase of its own still answers to its
|
||||
name — written out so the author can see what it will match on. */
|
||||
triggers: triggers.length ? triggers : [label.toLowerCase()],
|
||||
suggestions,
|
||||
capabilities,
|
||||
responses,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { hasFrontmatter, normalizeDefinition, parseFrontmatter, parseSkill } from './registry';
|
||||
import { SUPPORTED_OWLIVER_CAPABILITIES, sourceSupportsOption } from './surfaces';
|
||||
|
||||
/**
|
||||
* A definition, read into editor fields — and edited fields, written back.
|
||||
@@ -62,13 +63,33 @@ export const isReadableDefinition = (source) => hasFrontmatter(source);
|
||||
/** The Board (UI) editor's fields, before a definition is loaded into them. */
|
||||
export const EMPTY_BOARD_FIELDS = {
|
||||
id: '', name: '', description: '', pages: [],
|
||||
type: 'flow', placement: '', source: 'candidates.activity', periods: [],
|
||||
type: 'flow', placement: '', title: '', source: 'candidates.activity', periods: [],
|
||||
};
|
||||
|
||||
/** The Owliver editor's fields, before a definition is loaded into them. */
|
||||
/**
|
||||
* The Owliver editor's fields, before a definition is loaded into them.
|
||||
*
|
||||
* `responses` is keyed by capability, because that is how the format is keyed
|
||||
* and how the runtime reads it. It used to be one `source` and one `periods`
|
||||
* shared by every selected capability, and that single field is the whole of
|
||||
* the bug this shape exists to remove:
|
||||
*
|
||||
* - A capability selected before a source was picked composed **no**
|
||||
* `responses:` block at all. `normalizeSkillOwliver` then dropped every
|
||||
* capability for want of a source, `owliver.capabilities` normalized to
|
||||
* `[]`, and `owliverSkillsForContext` excludes a skill with none — so the
|
||||
* definition registered, showed as active on its page, and contributed no
|
||||
* suggestion chip.
|
||||
* - Two capabilities could not read different sources, and one global source
|
||||
* that suited the first often could not be drawn as the second: `list`
|
||||
* against `candidates.activity` is refused by `normalizeSection`, so
|
||||
* picking it silently cost the author that capability.
|
||||
*
|
||||
* A form that cannot express what the format can is not a shortcut to it.
|
||||
*/
|
||||
export const EMPTY_OWLIVER_FIELDS = {
|
||||
id: '', name: '', description: '', pages: [],
|
||||
triggers: [], suggestions: [], capabilities: [], source: '', periods: [],
|
||||
triggers: [], suggestions: [], capabilities: [], responses: {},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -94,6 +115,10 @@ export function boardFieldsFromSource(source) {
|
||||
pages: skill.pages,
|
||||
type: section?.type || EMPTY_BOARD_FIELDS.type,
|
||||
placement: section?.placement || '',
|
||||
/* The heading the card draws. Composed from the skill's name when it is
|
||||
left out, but it is a field of its own in the format and a definition
|
||||
that set it must survive a round trip through the form. */
|
||||
title: section?.title || '',
|
||||
source: section?.source || EMPTY_BOARD_FIELDS.source,
|
||||
periods: section?.periods || [],
|
||||
};
|
||||
@@ -113,8 +138,61 @@ export function owliverFieldsFromSource(source) {
|
||||
try {
|
||||
const skill = readSkill(source);
|
||||
if (!skill) throw new Error('no frontmatter');
|
||||
const first = skill.owliver.capabilities[0];
|
||||
const response = first ? skill.owliver.responses[first] : null;
|
||||
|
||||
/**
|
||||
* Every capability the definition *declares*, not only the ones that
|
||||
* resolved.
|
||||
*
|
||||
* `skill.owliver.capabilities` is filtered to those that found a reading,
|
||||
* which is right for the runtime and wrong for a form: a stored definition
|
||||
* declaring `summary` and `list` where `list` lost its source would open
|
||||
* with `list` simply absent, and the next save would delete a capability
|
||||
* its author never removed. The one that cannot answer is exactly the one
|
||||
* they opened the editor to fix, so it is shown — checked, with no source,
|
||||
* and refused by the same validation as before.
|
||||
*
|
||||
* Read from the frontmatter the definition actually carries, and narrowed
|
||||
* to the closed vocabulary, so a typo is still not a capability.
|
||||
*/
|
||||
const declared = (() => {
|
||||
let data;
|
||||
try {
|
||||
({ data } = parseFrontmatter(String(source)));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const block = data?.owliver;
|
||||
if (!block || typeof block !== 'object' || Array.isArray(block)) return [];
|
||||
const listed = Array.isArray(block.capabilities) ? block.capabilities : [];
|
||||
const keyed = block.responses && typeof block.responses === 'object' && !Array.isArray(block.responses)
|
||||
? Object.keys(block.responses)
|
||||
: [];
|
||||
return [...listed, ...keyed]
|
||||
.map((capability) => String(capability).trim())
|
||||
.filter((capability) => SUPPORTED_OWLIVER_CAPABILITIES.includes(capability));
|
||||
})();
|
||||
|
||||
/* Declaration order first, then anything that resolved without being
|
||||
listed — a definition whose capability came from its `ui:` section. */
|
||||
const capabilities = [...new Set([...declared, ...skill.owliver.capabilities])];
|
||||
|
||||
/**
|
||||
* Every capability's own reading, not the first one's.
|
||||
*
|
||||
* Reading one and showing it against all of them is how a two-capability
|
||||
* definition lost the second's source the first time any field was edited:
|
||||
* the form wrote back what it had read, and it had read half the block.
|
||||
*/
|
||||
const responses = Object.fromEntries(
|
||||
capabilities.map((capability) => {
|
||||
const response = skill.owliver.responses[capability];
|
||||
return [capability, {
|
||||
source: response?.source || '',
|
||||
periods: response?.periods || [],
|
||||
limit: response?.limit ?? null,
|
||||
}];
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
id: skill.id,
|
||||
@@ -133,9 +211,8 @@ export function owliverFieldsFromSource(source) {
|
||||
*/
|
||||
triggers: skill.declaredTriggers ? skill.triggers : [],
|
||||
suggestions: skill.owliver.suggestions.map((s) => s.label),
|
||||
capabilities: skill.owliver.capabilities,
|
||||
source: response?.source || '',
|
||||
periods: response?.periods || [],
|
||||
capabilities,
|
||||
responses,
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_OWLIVER_FIELDS;
|
||||
@@ -404,3 +481,115 @@ export function patchFrontmatter(source, patch = {}) {
|
||||
|
||||
return `---\n${lines.join('\n')}\n---${raw.slice(match[0].length)}`;
|
||||
}
|
||||
|
||||
/* ── Writing fields back as one canonical patch ─────────────────────────── */
|
||||
|
||||
/**
|
||||
* The frontmatter a set of Board fields means.
|
||||
*
|
||||
* Both composing paths go through this: a fresh draft applies it to an empty
|
||||
* definition, an existing one applies it to the file already open. That is what
|
||||
* makes "typed into the form" and "pasted as Markdown" the same artefact — the
|
||||
* fields have one writer, so there is no second field set that only one path
|
||||
* knows how to express.
|
||||
*
|
||||
* `existing` is the definition being edited, and only two questions are asked of
|
||||
* it: whether its `pages:` are derived from its `ui:` entries, and whether its
|
||||
* `ui:` block is one this form can represent at all. Both are refusals to
|
||||
* overwrite what the fields cannot hold, never a second shape.
|
||||
*/
|
||||
export function boardPatch(fields, { existing = '' } = {}) {
|
||||
const derived = existing ? pagesAreDerived(existing) : false;
|
||||
const editable = existing ? uiIsEditableFromFields(existing) : true;
|
||||
|
||||
const section = editable ? {
|
||||
'ui.type': fields.type || undefined,
|
||||
'ui.placement': fields.placement || undefined,
|
||||
'ui.title': fields.title || undefined,
|
||||
'ui.source': fields.source || undefined,
|
||||
/* Periods only where the reading has any. A source that counts nothing over
|
||||
time keeps no period list, so switching to one cannot leave the previous
|
||||
source's windows behind as a block that validates and does nothing. */
|
||||
'ui.periods': fields.periods?.length && sourceSupportsOption(fields.source, 'periods')
|
||||
? fields.periods
|
||||
: REMOVE,
|
||||
} : {};
|
||||
|
||||
return {
|
||||
id: fields.id || undefined,
|
||||
name: fields.name || undefined,
|
||||
description: fields.description || undefined,
|
||||
pages: fields.pages?.length && !derived ? fields.pages : undefined,
|
||||
...section,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One capability's response, as the format writes it.
|
||||
*
|
||||
* Returns null when the capability has no source. That is deliberate and is the
|
||||
* point of the whole change: an unconfigured capability is left out of
|
||||
* `responses:` so `normalizeSkillOwliver` reports it by name and
|
||||
* `validateSkillSource` refuses the save. The alternative — inventing a source
|
||||
* to make the block well-formed — is how a definition gets saved reading data
|
||||
* its author never chose.
|
||||
*/
|
||||
function responseFor(fields, capability) {
|
||||
const response = fields.responses?.[capability];
|
||||
const source = response?.source || '';
|
||||
if (!source) return null;
|
||||
|
||||
const entry = { source };
|
||||
if (response.periods?.length && sourceSupportsOption(source, 'periods')) {
|
||||
entry.periods = response.periods;
|
||||
}
|
||||
if (response.limit && sourceSupportsOption(source, 'limit')) {
|
||||
entry.limit = Number(response.limit);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frontmatter a set of Owliver fields means.
|
||||
*
|
||||
* Every selected capability that has a source is written with its *own*
|
||||
* `source:`, and its own periods or limit where the reading takes them. Nothing
|
||||
* is inherited from a `ui:` block here: this editor owns definitions that have
|
||||
* no `ui:` block at all, and a response that relies on inheriting one silently
|
||||
* loses its reading the day the section is edited.
|
||||
*/
|
||||
export function owliverPatch(fields, { existing = '' } = {}) {
|
||||
const derived = existing ? pagesAreDerived(existing) : false;
|
||||
|
||||
const responses = {};
|
||||
for (const capability of fields.capabilities || []) {
|
||||
const response = responseFor(fields, capability);
|
||||
if (response) responses[capability] = response;
|
||||
}
|
||||
|
||||
return {
|
||||
id: fields.id || undefined,
|
||||
name: fields.name || undefined,
|
||||
description: fields.description || undefined,
|
||||
pages: fields.pages?.length && !derived ? fields.pages : undefined,
|
||||
triggers: fields.triggers?.length ? fields.triggers : REMOVE,
|
||||
'owliver.enabled': true,
|
||||
'owliver.suggestions': fields.suggestions?.length ? fields.suggestions : REMOVE,
|
||||
'owliver.capabilities': fields.capabilities?.length ? fields.capabilities : REMOVE,
|
||||
/* An empty mapping is not a mapping the parser will take — `owliver.responses`
|
||||
must be a mapping of capability names — so nothing configured removes the
|
||||
key rather than writing a header with nothing under it. */
|
||||
'owliver.responses': Object.keys(responses).length ? responses : REMOVE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which selected capabilities are not configured, by name.
|
||||
*
|
||||
* The editor shows these against the capability rather than only as a refusal
|
||||
* on save, so "Summary needs a source" is read where the source is chosen. The
|
||||
* *refusal* is still `validateSkillSource`'s, on the same definition the
|
||||
* registry reads — this only says the same thing earlier.
|
||||
*/
|
||||
export const unconfiguredCapabilities = (fields) =>
|
||||
(fields.capabilities || []).filter((capability) => !responseFor(fields, capability));
|
||||
|
||||
@@ -438,6 +438,7 @@ export const DATA_SOURCES = [
|
||||
context: 'positionId',
|
||||
summary: 'Applications to this position, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
|
||||
options: ['periods'],
|
||||
},
|
||||
{
|
||||
id: 'position.pipeline',
|
||||
@@ -452,6 +453,7 @@ export const DATA_SOURCES = [
|
||||
context: 'positionId',
|
||||
summary: 'Candidates matched to this position, best first.',
|
||||
shapes: ['list', 'table', 'stats', 'card'],
|
||||
options: ['limit'],
|
||||
},
|
||||
{
|
||||
/**
|
||||
@@ -469,6 +471,7 @@ export const DATA_SOURCES = [
|
||||
context: 'positionId',
|
||||
summary: 'The candidate pool scored against this position, best first.',
|
||||
shapes: ['list', 'table', 'stats', 'card', 'insight'],
|
||||
options: ['limit'],
|
||||
},
|
||||
{
|
||||
id: 'position.requirements',
|
||||
@@ -504,6 +507,7 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'Applications across the workspace, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
|
||||
options: ['periods'],
|
||||
},
|
||||
{
|
||||
id: 'positions.demand',
|
||||
@@ -511,6 +515,7 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'Open positions and what they still need.',
|
||||
shapes: ['list', 'table', 'stats', 'card'],
|
||||
options: ['limit'],
|
||||
},
|
||||
{
|
||||
id: 'workforce.training',
|
||||
@@ -518,6 +523,7 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'Training paths and progress against them.',
|
||||
shapes: ['progress', 'list', 'stats', 'table', 'card'],
|
||||
options: ['limit'],
|
||||
},
|
||||
{
|
||||
/* The vetting weights a position is being specified with. `positionId`
|
||||
@@ -548,6 +554,7 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'Who was hired, for which role, and when.',
|
||||
shapes: ['list', 'table', 'timeline', 'stats', 'card'],
|
||||
options: ['limit'],
|
||||
},
|
||||
{
|
||||
id: 'hires.performance',
|
||||
@@ -562,6 +569,7 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'What has happened across the workspace, most recent first.',
|
||||
shapes: ['timeline', 'list', 'table', 'stats', 'card'],
|
||||
options: ['limit'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -569,6 +577,60 @@ export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id);
|
||||
|
||||
export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null;
|
||||
|
||||
/* ── Source / shape compatibility ───────────────────────────────────────────
|
||||
One resolver, three consumers: `normalizeSection` refuses on it, the Board
|
||||
editor's source picker filters on it, and the Owliver editor's per-capability
|
||||
picker filters on it. They used to be three readings of `source.shapes` —
|
||||
inline in the normalizer, absent from one editor and approximated in the
|
||||
other — which is how a form could compose `list` against a source that has no
|
||||
list in it and only find out at save. */
|
||||
|
||||
/**
|
||||
* The section type a capability is drawn with, or null when it is prose.
|
||||
*
|
||||
* `summary` is the one capability with no component, so it is compatible with
|
||||
* every source: the figures are read back as sentences rather than drawn.
|
||||
*/
|
||||
export const shapeForCapability = (capability) => owliverCapabilityFor(capability)?.shape || null;
|
||||
|
||||
/** Can this source be drawn as this shape? A null shape is prose, and always can. */
|
||||
export function sourceSupportsShape(sourceId, shape) {
|
||||
const source = dataSourceFor(sourceId);
|
||||
if (!source) return false;
|
||||
if (!shape) return true;
|
||||
return Boolean(source.shapes?.includes(shape));
|
||||
}
|
||||
|
||||
/**
|
||||
* The sources that can fill this shape, optionally narrowed to what a placement
|
||||
* can supply context for.
|
||||
*
|
||||
* `context` is the list a placement provides — `contextSuppliedBy(pages,
|
||||
* placement)`. Passing it is how the Board editor offers only sources that can
|
||||
* actually resolve where the section is mounted; the Owliver editor passes
|
||||
* nothing, because a response with an unmet need asks which record is meant
|
||||
* rather than rendering dead.
|
||||
*/
|
||||
export function sourcesForShape(shape, { context = null } = {}) {
|
||||
return DATA_SOURCES.filter((source) => {
|
||||
if (!sourceSupportsShape(source.id, shape)) return false;
|
||||
if (!context) return true;
|
||||
return !source.context || context.includes(source.context);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a source reads an option a form can offer — `periods` or `limit`.
|
||||
*
|
||||
* Declared per source rather than inferred from its shapes, because the two do
|
||||
* not line up: `positions.demand` is a list that honours `limit` and has no
|
||||
* periods at all, while `candidates.activity` is the reverse. The editors used
|
||||
* to guess from the shape list and offered period checkboxes that changed
|
||||
* nothing.
|
||||
*/
|
||||
export const sourceSupportsOption = (sourceId, option) =>
|
||||
Boolean(dataSourceFor(sourceId)?.options?.includes(option));
|
||||
|
||||
/**
|
||||
* Can this reading be written back?
|
||||
*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
|
||||
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, placementFor,
|
||||
surfaceFor,
|
||||
sourceSupportsShape, surfaceFor,
|
||||
} from './surfaces';
|
||||
|
||||
/**
|
||||
@@ -151,7 +151,10 @@ export function normalizeSection(raw, {
|
||||
summary is prose, so there is no shape for a source to be incompatible
|
||||
with. Every drawn type maps to itself. */
|
||||
const shape = shapeFor(type);
|
||||
if (shape && definition.shapes && !definition.shapes.includes(shape)) {
|
||||
/* `sourceSupportsShape` rather than a reading of `definition.shapes` here:
|
||||
the editors' source pickers ask the same question, and the answer has to
|
||||
come from one place or a form can compose what the normalizer refuses. */
|
||||
if (!sourceSupportsShape(source, shape)) {
|
||||
errors.push(
|
||||
`${at || `${where}.${id}`}: \`${source}\` cannot be shown as \`${type}\`. It supports: ${definition.shapes.join(', ')}.`
|
||||
);
|
||||
|
||||
@@ -12,11 +12,12 @@ import { customSkillSource, owliverSkillTemplate, upsertCustomSkill } from '@/li
|
||||
import { reportSave } from '@/lib/skills/saveFeedback';
|
||||
import {
|
||||
EMPTY_OWLIVER_FIELDS, facetsFromSource, isReadableDefinition, normalizeUpload,
|
||||
owliverFieldsFromSource, pagesAreDerived, patchFrontmatter,
|
||||
owliverFieldsFromSource, owliverPatch, patchFrontmatter, unconfiguredCapabilities,
|
||||
} from '@/lib/skills/skillFields';
|
||||
import {
|
||||
DATA_SOURCES, OWLIVER_CAPABILITIES, PERIODS, dataSourceFor, dataSourceLabel,
|
||||
owliverCapabilityLabel, periodLabel, sectionTypeLabel, surfaceFor,
|
||||
OWLIVER_CAPABILITIES, PERIODS, contextLabel, dataSourceLabel, owliverCapabilityLabel,
|
||||
periodLabel, sectionTypeLabel, shapeForCapability, sourceSupportsOption, sourcesForShape,
|
||||
surfaceFor,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
|
||||
@@ -186,32 +187,12 @@ export default function OwliverSkillEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Responses are only rewritten when this form has enough to say what they
|
||||
are. A definition whose capabilities read from an inherited `ui:` section
|
||||
declares no `responses:` block, and inventing one here would pin a
|
||||
reading the author deliberately left to be inherited. */
|
||||
const responses = next.capabilities.length && next.source
|
||||
? Object.fromEntries(next.capabilities.map((capability) => [
|
||||
capability,
|
||||
next.periods.length
|
||||
? { source: next.source, periods: next.periods }
|
||||
: { source: next.source },
|
||||
]))
|
||||
: undefined;
|
||||
|
||||
setSource(patchFrontmatter(source, {
|
||||
id: next.id || undefined,
|
||||
name: next.name || undefined,
|
||||
description: next.description || undefined,
|
||||
/* A definition whose reach comes from its `ui:` entries shows those pages
|
||||
as a read-out; writing them back would pin a list that then governs the
|
||||
block. Same rule as the Board editor, for the same reason. */
|
||||
pages: next.pages?.length && !pagesAreDerived(source) ? next.pages : undefined,
|
||||
triggers: next.triggers?.length ? next.triggers : undefined,
|
||||
'owliver.suggestions': next.suggestions?.length ? next.suggestions : undefined,
|
||||
'owliver.capabilities': next.capabilities?.length ? next.capabilities : undefined,
|
||||
'owliver.responses': responses,
|
||||
}));
|
||||
/* `owliverPatch` is the same writer the template composes a fresh draft
|
||||
with, so a definition typed into this form and one pasted as Markdown are
|
||||
the same artefact rather than two dialects of it. Each selected capability
|
||||
carries its own source, periods and limit — nothing is inherited from a
|
||||
`ui:` block, which this editor's definitions frequently do not have. */
|
||||
setSource(patchFrontmatter(source, owliverPatch(next, { existing: source })));
|
||||
};
|
||||
|
||||
const setStatus = (nextActive) => {
|
||||
@@ -288,11 +269,23 @@ export default function OwliverSkillEditor() {
|
||||
navigate('/admin/workspace/skills?tab=owliver');
|
||||
};
|
||||
|
||||
/* Only the sources the declared pages can actually supply context for are
|
||||
worth offering — a source needing a candidate is not answerable from the
|
||||
Analytics panel, and offering it would validate and then never resolve. */
|
||||
const source_ = draft.source ? dataSourceFor(draft.source) : null;
|
||||
const timeBased = Boolean(source_?.shapes?.includes('flow') || source_?.shapes?.includes('timeline'));
|
||||
/**
|
||||
* One capability's response, edited.
|
||||
*
|
||||
* Keyed by capability because the format is: `owliver.responses.summary` and
|
||||
* `owliver.responses.list` are two readings, and a form with one source field
|
||||
* could only ever write one of them.
|
||||
*/
|
||||
const updateResponse = (capability, patch) => update({
|
||||
responses: {
|
||||
...draft.responses,
|
||||
[capability]: { ...(draft.responses[capability] || {}), ...patch },
|
||||
},
|
||||
});
|
||||
|
||||
/* Capabilities selected but not yet configured. Shown against the capability
|
||||
itself; `validateSkillSource` is still what refuses the save. */
|
||||
const unconfigured = unconfiguredCapabilities(draft);
|
||||
|
||||
/* The other half of the same definition, when it has one. Read-only: this
|
||||
editor does not own it, but hiding it would misrepresent the skill. */
|
||||
@@ -312,7 +305,16 @@ export default function OwliverSkillEditor() {
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" /> Skills
|
||||
</Button>
|
||||
<Button size="sm" shape="rounded" onClick={save} loading={updatePreferences.isPending}>
|
||||
{/* A capability with no source cannot be saved: `validateSkillSource`
|
||||
refuses it, and refusing at the button says so before the author
|
||||
has left the form. */}
|
||||
<Button
|
||||
size="sm"
|
||||
shape="rounded"
|
||||
onClick={save}
|
||||
disabled={unconfigured.length > 0}
|
||||
loading={updatePreferences.isPending}
|
||||
>
|
||||
{editingId ? 'Save skill' : 'Add skill'}
|
||||
</Button>
|
||||
</>
|
||||
@@ -441,21 +443,31 @@ export default function OwliverSkillEditor() {
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* ── What an answer looks like ──────────────────────────────── */}
|
||||
{/* ── What an answer looks like, and what each one reads ────── */}
|
||||
<section className="space-y-3">
|
||||
<SectionTitle title="Capabilities" meta="The shapes an answer can take" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
|
||||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||||
{OWLIVER_CAPABILITIES.map((capability) => {
|
||||
const checked = draft.capabilities.includes(capability.id);
|
||||
return (
|
||||
<label
|
||||
key={capability.id}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 transition-colors',
|
||||
checked ? 'border-krow-blue/30 bg-krow-blue-tint/50' : 'border-border bg-surface hover:bg-surface-subtle'
|
||||
)}
|
||||
>
|
||||
<SectionTitle title="Capabilities" meta="Each one reads its own source" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-2.5">
|
||||
{OWLIVER_CAPABILITIES.map((capability) => {
|
||||
const checked = draft.capabilities.includes(capability.id);
|
||||
const response = draft.responses[capability.id] || {};
|
||||
/* The shapes a source must be able to fill for this capability.
|
||||
`summary` is prose and has none, so every source suits it. */
|
||||
const shape = shapeForCapability(capability.id);
|
||||
/* One compatibility resolver, the same one `normalizeSection`
|
||||
refuses on — so this list cannot offer what the save will
|
||||
reject. */
|
||||
const options = sourcesForShape(shape);
|
||||
const missing = checked && !response.source;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={capability.id}
|
||||
className={cn(
|
||||
'rounded-lg border transition-colors',
|
||||
checked ? 'border-krow-blue/30 bg-krow-blue-tint/40' : 'border-border bg-surface'
|
||||
)}
|
||||
>
|
||||
<label className="flex cursor-pointer items-start gap-2.5 px-3 py-2.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() => update({
|
||||
@@ -471,58 +483,105 @@ export default function OwliverSkillEditor() {
|
||||
<span className="block text-caption leading-relaxed text-ink-3">{capability.summary}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* ── What it reads ──────────────────────────────────────────── */}
|
||||
<section className="space-y-3">
|
||||
<SectionTitle title="Data" meta="The same resolver the pages use" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
|
||||
<Field
|
||||
label="Source"
|
||||
hint="A named reading of data KROW already holds. A UI skill naming the same source reads exactly the same records."
|
||||
>
|
||||
<Select value={draft.source || undefined} onValueChange={(value) => update({ source: value })}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_SOURCES.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.label} — {s.summary}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{timeBased && (
|
||||
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PERIODS.map((period) => {
|
||||
const selected = draft.periods.includes(period.id);
|
||||
return (
|
||||
<button
|
||||
key={period.id}
|
||||
type="button"
|
||||
onClick={() => update({
|
||||
periods: selected
|
||||
? draft.periods.filter((p) => p !== period.id)
|
||||
: [...draft.periods, period.id],
|
||||
})}
|
||||
className={cn(
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
|
||||
selected
|
||||
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
|
||||
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
|
||||
)}
|
||||
{/* The response this capability answers with. Shown only
|
||||
when it is selected, because an unselected capability has
|
||||
no response to configure — and every selected one must
|
||||
have a source of its own before the definition will
|
||||
save. */}
|
||||
{checked && (
|
||||
<div className="space-y-3 border-t border-border/60 px-3 py-3">
|
||||
<Field
|
||||
label="Source"
|
||||
hint={`A named reading of data KROW already holds. Only sources that can be answered as ${capability.label.toLowerCase()} are offered.`}
|
||||
error={missing ? `${capability.label} needs a source before this skill can be saved.` : undefined}
|
||||
>
|
||||
<span>{period.label}</span>
|
||||
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<Select
|
||||
value={response.source || undefined}
|
||||
onValueChange={(value) => updateResponse(capability.id, { source: value })}
|
||||
>
|
||||
<SelectTrigger aria-label={`${capability.label} source`}>
|
||||
<SelectValue placeholder="Select a data source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.label} — {option.summary}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{/* A reading that needs a record says so. Not a refusal:
|
||||
an Owliver response with an unmet need asks which
|
||||
position is meant and answers once told — which is
|
||||
exactly what a page card cannot do. */}
|
||||
{response.source && (
|
||||
<p className="text-caption text-ink-3">
|
||||
{sourcesForShape(shape).find((o) => o.id === response.source)?.context
|
||||
? `Reads ${contextLabel(sourcesForShape(shape).find((o) => o.id === response.source).context)}. Owliver asks which one when the page has none open.`
|
||||
: 'Reads across the workspace, so it answers with nothing selected.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sourceSupportsOption(response.source, 'periods') && (
|
||||
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PERIODS.map((period) => {
|
||||
const selected = (response.periods || []).includes(period.id);
|
||||
return (
|
||||
<button
|
||||
key={period.id}
|
||||
type="button"
|
||||
onClick={() => updateResponse(capability.id, {
|
||||
periods: selected
|
||||
? (response.periods || []).filter((p) => p !== period.id)
|
||||
: [...(response.periods || []), period.id],
|
||||
})}
|
||||
className={cn(
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
|
||||
selected
|
||||
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
|
||||
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
<span>{period.label}</span>
|
||||
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{sourceSupportsOption(response.source, 'limit') && (
|
||||
<Field label="Limit" hint="How many records the answer lists. Left empty, the reading uses its own default.">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={response.limit ?? ''}
|
||||
aria-label={`${capability.label} limit`}
|
||||
onChange={(e) => updateResponse(capability.id, {
|
||||
limit: e.target.value ? Number(e.target.value) : null,
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
|
||||
{unconfigured.length > 0 && (
|
||||
<p className="text-caption text-destructive">
|
||||
{unconfigured.map(owliverCapabilityLabel).join(', ')}
|
||||
{unconfigured.length === 1 ? ' has' : ' have'} no source yet.
|
||||
A capability with nothing to read is dropped when the definition is registered,
|
||||
so the skill would save and then offer no suggestion at all.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
@@ -11,12 +11,13 @@ import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/ski
|
||||
import { customSkillSource, uiSkillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
|
||||
import { reportSave } from '@/lib/skills/saveFeedback';
|
||||
import {
|
||||
EMPTY_BOARD_FIELDS, boardFieldsFromSource, facetsFromSource, isReadableDefinition,
|
||||
EMPTY_BOARD_FIELDS, boardFieldsFromSource, boardPatch, facetsFromSource, isReadableDefinition,
|
||||
normalizeUpload, pagesAreDerived, patchFrontmatter, uiIsEditableFromFields,
|
||||
} from '@/lib/skills/skillFields';
|
||||
import {
|
||||
DATA_SOURCES, PERIODS, SECTION_TYPES, contextLabel, contextSuppliedBy, dataSourceLabel,
|
||||
owliverCapabilityLabel, periodLabel, placementLabel, sectionTypeLabel, surfaceFor,
|
||||
PERIODS, SECTION_TYPES, contextLabel, contextSuppliedBy, dataSourceLabel,
|
||||
owliverCapabilityLabel, periodLabel, placementLabel, sectionTypeLabel, sourceSupportsOption,
|
||||
sourcesForShape, surfaceFor,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
|
||||
@@ -188,29 +189,13 @@ export default function SkillEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Only the section keys this form can faithfully represent. A definition
|
||||
/* `boardPatch` is the same writer the template composes a fresh draft
|
||||
with, and it is what decides which keys this form may own: a definition
|
||||
written as a list, or addressing pages by name, may hold several sections
|
||||
across several pages; overwriting that from four single-valued fields
|
||||
would throw away everything but the first, so those fields are read-only
|
||||
against it. Identity and reach still patch normally — a multi-page
|
||||
across several pages, and single-valued fields would throw away all but
|
||||
the first. Identity and reach still patch normally — a multi-page
|
||||
definition must still be renameable. */
|
||||
const section = uiIsEditableFromFields(source) ? {
|
||||
'ui.type': next.type || undefined,
|
||||
'ui.placement': next.placement || undefined,
|
||||
'ui.source': next.source || undefined,
|
||||
'ui.periods': next.periods?.length ? next.periods : undefined,
|
||||
} : {};
|
||||
|
||||
setSource(patchFrontmatter(source, {
|
||||
id: next.id || undefined,
|
||||
name: next.name || undefined,
|
||||
description: next.description || undefined,
|
||||
/* Only when the definition owns a `pages:` key. A definition whose reach
|
||||
comes from its `ui:` entries is showing a read-out here, and writing it
|
||||
back would pin a list that governs the block from then on. */
|
||||
pages: pages.length && !pagesAreDerived(source) ? pages : undefined,
|
||||
...section,
|
||||
}));
|
||||
setSource(patchFrontmatter(source, boardPatch({ ...next, pages }, { existing: source })));
|
||||
};
|
||||
|
||||
const setStatus = (nextActive) => {
|
||||
@@ -450,6 +435,17 @@ export default function SkillEditor() {
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Card title"
|
||||
hint="The heading the section draws. Left empty, it is headed by the skill's name."
|
||||
>
|
||||
<Input
|
||||
value={meta.title}
|
||||
placeholder={meta.name || 'Board'}
|
||||
onChange={(e) => syncFromMeta({ ...meta, title: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Data source"
|
||||
hint="A named reading of data KROW already holds. An Owliver skill naming the same source reads exactly the same records."
|
||||
@@ -457,7 +453,15 @@ export default function SkillEditor() {
|
||||
<Select value={meta.source} onValueChange={(source) => syncFromMeta({ ...meta, source })}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_SOURCES.map((src) => {
|
||||
{/* Only the sources that can be drawn as the chosen
|
||||
component, from the one resolver `normalizeSection`
|
||||
refuses on — a picker that offers a shape the source
|
||||
cannot fill is a form composing what the save rejects.
|
||||
The ones that *could* be drawn but have no record here
|
||||
stay visible and disabled, with the reason: an author who
|
||||
cannot find a source they know exists assumes it was
|
||||
removed. */}
|
||||
{sourcesForShape(meta.type).map((src) => {
|
||||
const why = sourceUnavailable(src);
|
||||
return (
|
||||
<SelectItem key={src.id} value={src.id} disabled={Boolean(why)}>
|
||||
@@ -469,6 +473,7 @@ export default function SkillEditor() {
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{sourceSupportsOption(meta.source, 'periods') && (
|
||||
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PERIODS.map((period) => {
|
||||
@@ -497,6 +502,7 @@ export default function SkillEditor() {
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{!uiIsEditableFromFields(source) && (
|
||||
<p className="border-t border-border/50 pt-3 text-caption leading-relaxed text-ink-4">
|
||||
|
||||
Reference in New Issue
Block a user