update the workspace and skills flow
This commit is contained in:
@@ -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