Compare commits
3 Commits
fix/owlive
...
f522b6508e
| Author | SHA1 | Date | |
|---|---|---|---|
| f522b6508e | |||
| 6249e00a3a | |||
| e02a0c23d4 |
393
docs/instanced-ui-nodes.md
Normal file
393
docs/instanced-ui-nodes.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# Repeated and instanced UI nodes — design
|
||||
|
||||
> **Status: design only. Nothing here is implemented.**
|
||||
> The audit below is traced from the code as it stands; the design that follows
|
||||
> is a proposal to be reviewed and tested before any of it is built.
|
||||
|
||||
Every page migrated so far is *flat*: each node in the composition renders
|
||||
exactly once, so a node id and a rendering are the same thing. Positions is the
|
||||
first surface where that is not true. Six of its eight extension points render
|
||||
**inside a record** — once per position — and the card those records are drawn
|
||||
in is hand-written JSX that the node system cannot see at all.
|
||||
|
||||
This document says what is there now, proposes a model for it, and is honest
|
||||
about what the model costs.
|
||||
|
||||
---
|
||||
|
||||
## 1. What is there now
|
||||
|
||||
### 1.1 The eight Positions placements, and which are instanced
|
||||
|
||||
`surfaces.js:47-76` declares eight placements under the `positions` surface and
|
||||
`provides` already records the distinction that matters — which of them hand a
|
||||
section a record:
|
||||
|
||||
| Placement | Rendered | Host | `provides` |
|
||||
|---|---|---|---|
|
||||
| `after-position-list-summary` | once per page | `Positions.jsx` (grid) | `[]` |
|
||||
| `after-position-list` | once per page | `Positions.jsx` (grid) | `[]` |
|
||||
| `after-position-card` | **once per record** | `PositionCard` | `positionId` |
|
||||
| `after-header` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
|
||||
| `after-position-summary` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
|
||||
| `before-candidates` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
|
||||
| `after-candidates` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
|
||||
| `before-footer` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
|
||||
|
||||
Only the first two are in the node tree today (`pages/admin/positions/nodes.js`),
|
||||
drawn through `UiNodeSlot`. The other six are still literal `<SkillSurface>`
|
||||
elements.
|
||||
|
||||
### 1.2 The render loops
|
||||
|
||||
**The grid** — `Positions.jsx:1158`:
|
||||
|
||||
```jsx
|
||||
{filtered.map((p) => (
|
||||
<PositionCard key={p.id} position={p} onOpen={openPosition} isRecent={recentIds.has(p.id)} />
|
||||
))}
|
||||
```
|
||||
|
||||
`filtered` is page state: the search box, the role and location selects and the
|
||||
sort, applied in `useMemo`. It is not a data source in the `surfaces.js` sense
|
||||
and has no entry in `DATA_SOURCES` — it is the page's own working set.
|
||||
|
||||
**Inside each card** — `Positions.jsx:626-641`. The card's last element, wrapped
|
||||
in a click-stopping `div` so a section inside a card does not open the drawer
|
||||
behind it:
|
||||
|
||||
```jsx
|
||||
<SkillSurface page="positions" placement="after-position-card"
|
||||
context={{ position: p }} className="mt-3" />
|
||||
```
|
||||
|
||||
**The drawer** — `PositionDrawer` (`Positions.jsx:719, 729, 793, 870, 897`) draws
|
||||
five surfaces, each with `context={{ position: p }}`, for the one position
|
||||
`openPosition` selected. `PositionDetail.jsx:288, 421, 472, 512, 513` draws the
|
||||
same five placements for the position named in the route.
|
||||
|
||||
### 1.3 How a record reaches a section
|
||||
|
||||
Unchanged all the way down, and already correct for this design:
|
||||
|
||||
```
|
||||
<SkillSurface context={{ position: p }} />
|
||||
→ useSkillDataContext(context) SkillSurface.jsx:76 { ...published, ...context, ...collections }
|
||||
→ resolveSkillData(section, ctx) dataResolver.js:1192
|
||||
if (section.context === 'positionId' && !context.position) → unavailable
|
||||
RESOLVERS['position.pipeline']({ position, applications }) → filters by position.id
|
||||
```
|
||||
|
||||
An explicit `context` prop beats what the page published, which is exactly the
|
||||
rule an instanced node needs: *a card knows which position it is.*
|
||||
|
||||
### 1.4 Section resolution
|
||||
|
||||
`useSkillSections(page, placement)` (`SkillSurface.jsx:46`) is independent of the
|
||||
record. It reads the account's active skills and returns
|
||||
`{ skill, section }[]` for the placement. **The same list is used for every
|
||||
card** — repetition happens below it, in data resolution, not in which sections
|
||||
exist. That is what makes one template node correct.
|
||||
|
||||
### 1.5 DOM identity today
|
||||
|
||||
None inside a card. `<SkillSurface>` renders `div.space-y-4 > section[aria-label]`
|
||||
with no `data-` attributes, and `SkillSection`'s React key
|
||||
(`` `${skill.id}:${section.id}` ``) is not emitted. `PositionCard` renders an
|
||||
`<article>` with no id. There is nothing in the document that says *which*
|
||||
position a rendering belongs to.
|
||||
|
||||
### 1.6 The card body
|
||||
|
||||
`PositionCard` (`Positions.jsx:519-643`) is roughly 120 lines of tuned JSX:
|
||||
role glyph and client/title/category block, status pill, a terms `<dl>`, one
|
||||
line of candidate criteria, a hairline rule, `Progression`, `WorkforceRow`, a
|
||||
health chip with the insight line, and a footer pinned with `mt-auto` so a row
|
||||
of cards keeps its footers aligned. Plus a two-minute "just saved" treatment.
|
||||
|
||||
**None of it is addressable.** "Make all position cards compact" has nothing to
|
||||
act on today, in either the node tree or the component: no density prop exists.
|
||||
|
||||
---
|
||||
|
||||
## 2. The model
|
||||
|
||||
### 2.1 One node, many renderings
|
||||
|
||||
The rule the whole design rests on:
|
||||
|
||||
> A repeated surface is **one node in the tree**. The tree holds the template
|
||||
> once; the DOM holds N renderings of it.
|
||||
|
||||
An operation targets the node, so one operation changes every card — which is
|
||||
the "do not duplicate operations once per record" requirement met in the stored
|
||||
bytes, not by a de-duplication pass.
|
||||
|
||||
### 2.2 Repetition is declared by the type, never by a patch
|
||||
|
||||
```js
|
||||
registerNodeType({
|
||||
type: 'position-card',
|
||||
label: 'Position card',
|
||||
component: PositionCardNode,
|
||||
container: true,
|
||||
accepts: ['skill-surface', ...],
|
||||
repeats: {
|
||||
from: 'positions', // a key in the bag the PAGE publishes to UiRenderContext
|
||||
as: 'position', // the context key each rendering is given
|
||||
key: 'id', // the record field that identifies a rendering
|
||||
},
|
||||
propSchema: { density: { enum: ['comfortable', 'compact'] } },
|
||||
capabilities: ['update', 'move', 'hide', 'reorder'],
|
||||
});
|
||||
```
|
||||
|
||||
`repeats` lives on the **registration**, which is code, and never on the node —
|
||||
so it is absent from `OP_FIELDS`, cannot be written by a stored patch, and gives
|
||||
the validator nothing new to police on user data. A patch can change what a card
|
||||
looks like; it can never change what a card iterates over.
|
||||
|
||||
`from` names a key in the page's own `UiRenderContext` bag
|
||||
(`UiTreeRenderer.jsx:39`) rather than a `DATA_SOURCES` entry, because `filtered`
|
||||
*is* page state — search, filters and sort applied. This adds no new data
|
||||
pathway: the renderer reads `useUiContext()[entry.repeats.from]` and still never
|
||||
looks inside the bag on its own account.
|
||||
|
||||
### 2.3 Node schema — unchanged
|
||||
|
||||
No new field on `UiNode`. A repeater is an ordinary container node whose *type*
|
||||
happens to repeat:
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'position-card',
|
||||
type: 'position-card',
|
||||
props: { density: 'comfortable' },
|
||||
layout: { ... },
|
||||
children: [ /* the template subtree */ ],
|
||||
hidden: false, origin: 'builtin', locked: false,
|
||||
}
|
||||
```
|
||||
|
||||
The template's children are ordinary nodes with ordinary ids
|
||||
(`position-card-terms`, `position-card-extensions`, …). Each is one node and N
|
||||
renderings, by the same rule.
|
||||
|
||||
### 2.4 DOM identity
|
||||
|
||||
```html
|
||||
<article data-ui-node="position-card" data-ui-instance="pos_123"> … </article>
|
||||
<article data-ui-node="position-card" data-ui-instance="pos_456"> … </article>
|
||||
```
|
||||
|
||||
`data-ui-node` keeps meaning **the node** — one value, N elements. `data-ui-instance`
|
||||
carries the record key from `repeats.key`. Descendants of a rendering inherit the
|
||||
instance from their ancestor rather than repeating it, so the pair
|
||||
(`data-ui-node`, nearest ancestor `data-ui-instance`) addresses exactly one
|
||||
rendering.
|
||||
|
||||
This **breaks the current invariant that `data-ui-node` is unique in a
|
||||
document**, and everything that assumes it must be found and changed. See §5.1.
|
||||
|
||||
---
|
||||
|
||||
## 3. Targeting semantics
|
||||
|
||||
Three scopes. Only the first is proposed for implementation now.
|
||||
|
||||
### 3.1 Template scope — the default
|
||||
|
||||
```json
|
||||
{ "op": "update", "target": "position-card", "props": { "density": "compact" } }
|
||||
```
|
||||
|
||||
No new field. Applies to the node, therefore to every rendering. This is
|
||||
"make all position cards compact", and it is one operation regardless of how
|
||||
many positions exist.
|
||||
|
||||
Everything already true stays true: the op is validated against the type's
|
||||
`propSchema` and `capabilities`, refused if `density` is not a declared enum
|
||||
value, and stored in `uiLayouts` like any other.
|
||||
|
||||
### 3.2 Instance scope — designed, not built
|
||||
|
||||
An optional `scope` on the **operation**, not on the node:
|
||||
|
||||
```json
|
||||
{ "op": "update", "target": "position-card",
|
||||
"scope": { "key": "pos_123" },
|
||||
"props": { "density": "comfortable" } }
|
||||
```
|
||||
|
||||
The tree stays one template. `applyPatch` partitions:
|
||||
|
||||
- **unscoped ops** are applied to the tree as they are today;
|
||||
- **scoped ops** are attached to their target node, grouped by key, and applied
|
||||
at render time to that one rendering — by `applyOperations`, the same engine,
|
||||
with the same validator.
|
||||
|
||||
There is no second mutation engine and no per-record tree in storage.
|
||||
|
||||
**Legality is declared per operation, not per page.** Each entry in `OPERATIONS`
|
||||
gains `instanceable: true|false`:
|
||||
|
||||
| Operation | Instanceable | Why |
|
||||
|---|---|---|
|
||||
| `update` | yes | changes one rendering |
|
||||
| `hide` | yes | changes one rendering |
|
||||
| `move`, `reorder` | no (first cut) | one card structurally unlike its neighbours |
|
||||
| `add`, `remove`, `replace` | no (first cut) | "remove this record's card" is a filter, not a layout change |
|
||||
|
||||
A `scope` on a node whose type does not declare `repeats` is refused — a registry
|
||||
read, not a page branch.
|
||||
|
||||
### 3.3 Predicate scope — reserved, not designed
|
||||
|
||||
`scope: { where: [...] }` — "make all *draft* position cards compact". Named here
|
||||
only so `scope` is an object from the first day rather than a bare key string
|
||||
that would have to be widened later.
|
||||
|
||||
### 3.4 Natural language
|
||||
|
||||
Template scope needs nothing new: `resolveTarget` already scores "the position
|
||||
card" against node titles, labels and ids, and there is exactly one such node.
|
||||
|
||||
Instance scope needs a record resolver — "this position's card" is only
|
||||
answerable when a position is selected, which `PageContext` publishes for the
|
||||
drawer but not for the grid. **Deferred.** Until then Owliver refuses instance
|
||||
phrasing explicitly rather than silently widening it to every card, which is the
|
||||
failure worth guarding hardest: a person who says "only this one" must never get
|
||||
"all of them".
|
||||
|
||||
---
|
||||
|
||||
## 4. Persistence
|
||||
|
||||
### 4.1 Shape
|
||||
|
||||
No new store, no new key, no new tier. `preferences.uiLayouts[page].ops` gains an
|
||||
optional `scope` per op:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 2,
|
||||
"page": "positions",
|
||||
"updatedAt": "…",
|
||||
"ops": [
|
||||
{ "op": "update", "target": "position-card", "props": { "density": "compact" } },
|
||||
{ "op": "hide", "target": "position-card-pay", "hidden": true },
|
||||
{ "op": "update", "target": "position-card",
|
||||
"scope": { "key": "pos_123" }, "props": { "density": "comfortable" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Size is **O(operations), not O(records)**. One op makes every card compact.
|
||||
|
||||
### 4.2 The schema bump is not optional
|
||||
|
||||
`OP_FIELDS` (`patch.js:51`) whitelists the fields each op may carry and
|
||||
`normalizeOp` **silently drops anything else**. A build that predates `scope`
|
||||
would therefore read the third op above as an unscoped one and make *every* card
|
||||
comfortable — the exact "only this one became all of them" failure.
|
||||
|
||||
So:
|
||||
|
||||
- a patch containing **no** scoped op keeps `schema: 1` and every existing build
|
||||
reads it exactly as it does today;
|
||||
- the first scoped op written raises that page's patch to `schema: 2`;
|
||||
- a reader that does not understand a schema **skips the whole patch** and
|
||||
reports it through the existing `skipped` channel, rather than applying part
|
||||
of it.
|
||||
|
||||
### 4.3 Stale keys
|
||||
|
||||
A scoped op naming a record that no longer exists is reported as `skipped`, like
|
||||
any stale target, and **retained** rather than dropped. Retained deliberately:
|
||||
the record may simply be filtered out of `filtered` by the page's own search or
|
||||
role filter at that moment, and treating the page's filter state as deletion
|
||||
would quietly destroy a person's saved overrides every time they typed in a
|
||||
search box. Clearing them is an explicit action in the editor.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risks
|
||||
|
||||
**5.1 `data-ui-node` stops being unique.** Any `querySelector` on it silently
|
||||
takes the first rendering — in tests, in the editor, and in anything built later.
|
||||
*Mitigation:* replace the uniqueness assertion with the real invariant — ids are
|
||||
unique unless the node's type declares `repeats`, and (`data-ui-node`, ancestor
|
||||
`data-ui-instance`) is unique always. This is a test to write **before** the
|
||||
first repeater exists.
|
||||
|
||||
**5.2 Render cost.** Instance-scoped ops run the operation engine per rendering.
|
||||
Negligible for a handful of overrides over tens of cards; not negligible for
|
||||
hundreds of records. *Mitigation:* only records that actually carry a scoped op
|
||||
do any work, memoized on (template, key, ops hash).
|
||||
|
||||
**5.3 Migrating `PositionCard` is a real rewrite, and the riskiest one so far.**
|
||||
`mt-auto` footer alignment, truncation, the hover and focus rings, and the
|
||||
"just saved" animation are all tuned. Phase 5A already produced one margin
|
||||
regression on a far simpler surface. *Mitigation:* the same baseline capture
|
||||
used for every other migration, run over the grid with a fixed record set, plus
|
||||
the class-signature and word-signature checks.
|
||||
|
||||
**5.4 "Compact" does not exist yet.** No component honours a density prop. The
|
||||
engine cannot invent one: the schema is what makes a change *expressible*, not
|
||||
what makes it *possible*. Someone must implement two densities in `PositionCard`
|
||||
before "make all position cards compact" can do anything, and until then the
|
||||
honest answer to that request is that the card offers no such setting.
|
||||
|
||||
**5.5 The drawer and the detail page draw the same five placements.** They are
|
||||
never on screen together — the drawer overlays `/admin/positions`, the detail
|
||||
page is `/admin/positions/:id` — but they are different layouts around identical
|
||||
extension points. If both render one composition, a change made in the drawer
|
||||
also changes the detail page, which may surprise. *Proposal:* one composition
|
||||
(`position-detail`) drawn by both hosts, editable from the detail route only in
|
||||
the first cut, because an editing session currently holds a single composition
|
||||
keyed to the route. Making a session span two compositions is the extension, and
|
||||
`uiLayouts` already stores per page so it needs no storage change.
|
||||
|
||||
**5.6 Per-record overrides invite chaos.** Twenty individually tweaked cards is
|
||||
unreviewable. *Mitigation:* keep instance scope to `update` and `hide`, show the
|
||||
count of overrides on the node in the editor, and offer to clear them all.
|
||||
|
||||
**5.7 `repeats.from` is a page-state key, so a rename fails at run time.** The
|
||||
page renames its context key, the repeater finds nothing, the grid renders empty
|
||||
— with no build error. *Mitigation:* a check-script assertion that every
|
||||
registered `repeats.from` is a key the owning page publishes, in the same shape
|
||||
as the surface-route guard.
|
||||
|
||||
**5.8 Skill sections inside a repeater lose id uniqueness too.** A section under
|
||||
`after-position-card` is one node and N renderings, each resolving against a
|
||||
different `position`. Consistent with the model, and `SkillSurface`'s contract is
|
||||
unchanged — `as: 'position'` feeds exactly the `context={{ position }}` prop it
|
||||
already takes — but it is the same uniqueness caveat as §5.1 reaching the skill
|
||||
layer.
|
||||
|
||||
**5.9 The MD schema is untouched.** No frontmatter key, no `normalizeSection`
|
||||
change, therefore no Go parser change and no oracle regeneration. Confirmed by
|
||||
construction: nothing in this design is authored in Markdown.
|
||||
|
||||
---
|
||||
|
||||
## 6. What must be tested before any of it is built
|
||||
|
||||
Against the *existing* Positions architecture, so the semantics are proven
|
||||
before the card is touched:
|
||||
|
||||
1. One `update` on a repeater node changes every rendering — asserted on
|
||||
rendering count, not on one element.
|
||||
2. That change is **one** operation in `uiLayouts`, with a record count > 1.
|
||||
3. A scoped `update` changes exactly one rendering and leaves the others.
|
||||
4. A scoped op on a non-repeating node is refused.
|
||||
5. A `move`/`remove`/`add`/`replace` carrying a `scope` is refused.
|
||||
6. A schema-2 patch read by a schema-1 reader is skipped whole, never widened.
|
||||
7. A scoped op naming an absent record is reported skipped and **retained**.
|
||||
8. Filtering the grid does not drop overrides for the filtered-out records.
|
||||
9. `data-ui-node` × ancestor `data-ui-instance` is unique; ids repeat only for
|
||||
types declaring `repeats`.
|
||||
10. Owliver refuses instance phrasing rather than widening it to the template.
|
||||
11. A skill section under `after-position-card` resolves against its own card's
|
||||
position, in every rendering.
|
||||
12. The Positions grid renders identically to its captured baseline.
|
||||
952
package-lock.json
generated
952
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,11 @@
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node scripts/skill-check.mjs",
|
||||
"seed:fixture": "node scripts/seed-fixture.mjs --write",
|
||||
"seed:check": "node scripts/seed-fixture.mjs",
|
||||
"seed:fixture": "node scripts/seed-fixture.mjs --write",
|
||||
"seed:check": "node scripts/seed-fixture.mjs",
|
||||
"typecheck": "tsc -p ./jsconfig.json",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:browser": "node scripts/browser-check.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astryxdesign/core": "^0.3.0",
|
||||
@@ -61,6 +62,7 @@
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "^8.5.3",
|
||||
"puppeteer-core": "^23.11.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.1.0"
|
||||
|
||||
1
scripts/__baseline__/activity-page.pre-migration.html
Normal file
1
scripts/__baseline__/activity-page.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/analytics.pre-migration.html
Normal file
1
scripts/__baseline__/analytics.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/candidates.pre-migration.html
Normal file
1
scripts/__baseline__/candidates.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
5
scripts/__baseline__/control-center.pre-migration.html
Normal file
5
scripts/__baseline__/control-center.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/hired-history.pre-migration.html
Normal file
1
scripts/__baseline__/hired-history.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/positions.pre-migration.html
Normal file
1
scripts/__baseline__/positions.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/talent-pool.pre-migration.html
Normal file
1
scripts/__baseline__/talent-pool.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
166
scripts/browser-check.mjs
Normal file
166
scripts/browser-check.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* The live acceptance suite: the Owliver UI-editing journey, in a real browser.
|
||||
*
|
||||
* npm run test:browser
|
||||
*
|
||||
* Unit and SSR tests render these same modules and were not enough — every
|
||||
* defect this guards was found in a browser and missed by a green test run. So
|
||||
* this drives the real application: real routing, real React, real network,
|
||||
* real `preferences.uiLayouts`.
|
||||
*
|
||||
* **It never logs in.** The session is an HttpOnly cookie and this script has
|
||||
* no business handling anybody's credentials, so it attaches to a browser that
|
||||
* is *already* signed in and says so plainly when it cannot:
|
||||
*
|
||||
* KROW_E2E_BROWSER_URL=http://127.0.0.1:9222 attach to a running Chrome
|
||||
* (start one with: --remote-debugging-port=9222)
|
||||
*
|
||||
* KROW_E2E_USER_DATA_DIR=/path/to/profile launch Chrome on a profile
|
||||
* that has been signed in once by hand
|
||||
*
|
||||
* With neither set, or with the browser not signed in, it exits **BLOCKED** —
|
||||
* never a silent pass. An acceptance suite that reports green because it never
|
||||
* ran is worse than no suite.
|
||||
*
|
||||
* The journey spans reloads, so the phases live in `browser-flows.js` and the
|
||||
* reloads live here.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import puppeteer from 'puppeteer-core';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const BASE = process.env.KROW_E2E_BASE_URL || 'http://localhost:5173';
|
||||
const FLOWS = readFileSync(join(ROOT, 'scripts/browser-flows.js'), 'utf8');
|
||||
const CHROME = process.env.KROW_E2E_CHROME
|
||||
|| '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
|
||||
/**
|
||||
* The pages under test, as data.
|
||||
*
|
||||
* `target` is a node the page really composes and `phrase` is how a person
|
||||
* names it — the pair this suite drives every page with. Adding a page is a row.
|
||||
*/
|
||||
const PAGES = [
|
||||
{ page: 'activity', route: '/admin/activity', target: 'audit', phrase: 'the audit log' },
|
||||
{ page: 'hired-history', route: '/admin/hired', target: 'chronology', phrase: 'the recent hiring timeline' },
|
||||
{ page: 'analytics', route: '/admin/analytics', target: 'funnel', phrase: 'the hiring funnel' },
|
||||
{
|
||||
page: 'positions', route: '/admin/positions', target: 'skill-board-board', phrase: 'the Board card',
|
||||
/* The Board skill's own card: a *skill* node, so the same journey proves
|
||||
built-in and definition-contributed UI move through one engine. Needs the
|
||||
Board definition switched on, which `prepare` below does not do — the
|
||||
page is skipped rather than failed when its node is not there. */
|
||||
optional: true,
|
||||
data: ['What is on the board?', 'Show me the board activity'],
|
||||
ordinaryQuestion: 'How do I apply for this position?',
|
||||
},
|
||||
];
|
||||
|
||||
const results = [];
|
||||
const record = (name, pass, detail) => results.push({ name, pass, detail: detail || '' });
|
||||
|
||||
async function connect() {
|
||||
if (process.env.KROW_E2E_BROWSER_URL) {
|
||||
return puppeteer.connect({ browserURL: process.env.KROW_E2E_BROWSER_URL, defaultViewport: null });
|
||||
}
|
||||
if (process.env.KROW_E2E_USER_DATA_DIR) {
|
||||
return puppeteer.launch({
|
||||
executablePath: CHROME,
|
||||
userDataDir: process.env.KROW_E2E_USER_DATA_DIR,
|
||||
headless: false,
|
||||
defaultViewport: null,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Load a route, install the flows, and wait for the page to have composed. */
|
||||
async function open(page, route) {
|
||||
await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle2', timeout: 45_000 });
|
||||
await page.evaluate(FLOWS);
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('textarea') && document.querySelectorAll('[data-ui-node]').length >= 0,
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
}
|
||||
|
||||
const run = (page, fn, args) => page.evaluate(
|
||||
async (name, a) => window.__uiFlows[name](a), fn, args
|
||||
);
|
||||
|
||||
async function main() {
|
||||
const browser = await connect();
|
||||
if (!browser) {
|
||||
console.error(
|
||||
'BLOCKED — no browser to attach to.\n'
|
||||
+ ' This suite does not log in: the session is an HttpOnly cookie and this\n'
|
||||
+ ' script does not handle credentials. Point it at a signed-in browser:\n\n'
|
||||
+ ' KROW_E2E_BROWSER_URL=http://127.0.0.1:9222 npm run test:browser\n'
|
||||
+ ' (start Chrome with --remote-debugging-port=9222)\n\n'
|
||||
+ ' KROW_E2E_USER_DATA_DIR=/path/to/profile npm run test:browser\n'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await open(page, PAGES[0].route);
|
||||
|
||||
/* Signed in, or nothing below means anything. */
|
||||
const authed = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v1/me/preferences', { credentials: 'include' });
|
||||
return { status: r.status, composer: Boolean(document.querySelector('textarea')) };
|
||||
});
|
||||
if (authed.status !== 200 || !authed.composer) {
|
||||
console.error(
|
||||
`BLOCKED — that browser is not signed in (GET /me/preferences → ${authed.status}).\n`
|
||||
+ ' Sign in once in that profile, then run this again.'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
for (const spec of PAGES) {
|
||||
await open(page, spec.route);
|
||||
|
||||
const present = await page.evaluate((t) => window.__uiFlows.nodes().includes(t), spec.target);
|
||||
if (!present) {
|
||||
if (spec.optional) {
|
||||
record(`${spec.page}: SKIPPED — ${spec.target} is not on the page`, true,
|
||||
'the definition contributing it is switched off');
|
||||
continue;
|
||||
}
|
||||
record(`${spec.page}: ${spec.target} is on the page`, false, 'not composed');
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(...await run(page, 'nothingPreviewed', {
|
||||
page: spec.page, ordinaryQuestion: spec.ordinaryQuestion || null,
|
||||
}));
|
||||
if (spec.data) results.push(...await run(page, 'dataStaysData', { page: spec.page, questions: spec.data }));
|
||||
|
||||
results.push(...await run(page, 'phase1', spec));
|
||||
await open(page, spec.route);
|
||||
results.push(...await run(page, 'phase2', spec));
|
||||
await open(page, spec.route);
|
||||
results.push(...await run(page, 'phase3', spec));
|
||||
}
|
||||
} finally {
|
||||
if (process.env.KROW_E2E_BROWSER_URL) browser.disconnect();
|
||||
else await browser.close();
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => !r.pass);
|
||||
for (const r of results) {
|
||||
console.log(`[ ${r.pass ? ' ok ' : 'FAIL'} ] ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
|
||||
}
|
||||
console.log(`\n${results.length - failed.length}/${results.length} browser checks passed`);
|
||||
if (failed.length) {
|
||||
console.log('\nFailed:');
|
||||
for (const r of failed) console.log(` - ${r.name} (${r.detail})`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error('BROWSER SUITE ERROR:', error); process.exit(1); });
|
||||
256
scripts/browser-flows.js
Normal file
256
scripts/browser-flows.js
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* The Owliver UI-editing journey, as a script that runs *inside a real page*.
|
||||
*
|
||||
* Unit and SSR tests render the same modules this file drives, and they were
|
||||
* not enough: every defect this suite now guards was found in a browser, not in
|
||||
* a test run. So the acceptance test is the browser, and this is what it runs.
|
||||
*
|
||||
* It is deliberately dependency-free and framework-free — one function, no
|
||||
* imports, no build step — because it has to be evaluatable in any authenticated
|
||||
* session: through the puppeteer driver beside it, or pasted into a console.
|
||||
*
|
||||
* A full journey spans reloads, and nothing in a page survives one. So the
|
||||
* journey is split into **phases** and the *driver* owns the reloads:
|
||||
*
|
||||
* phase 1 inspect → hide → preview → discard → hide → Apply
|
||||
* ── reload ──
|
||||
* phase 2 verify hidden → hidden node still listed → unhide → Apply
|
||||
* ── reload ──
|
||||
* phase 3 verify restored
|
||||
*
|
||||
* Every assertion is about what the browser actually shows — the composed
|
||||
* nodes in the document and the bytes in `preferences.uiLayouts` — never about
|
||||
* a module's return value.
|
||||
*/
|
||||
|
||||
/* global window, document, fetch, HTMLTextAreaElement, Event */
|
||||
|
||||
(function attach() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
/** The nodes the page is actually drawing, in document order. */
|
||||
const nodes = () => [...document.querySelectorAll('[data-ui-node]')]
|
||||
.map((el) => el.getAttribute('data-ui-node'));
|
||||
|
||||
/** What the panel is showing. The transcript, not a component's state. */
|
||||
const panel = () => document.querySelector('[class*="assistant"], aside')?.innerText || '';
|
||||
|
||||
/** The saved layouts, read back over the wire like any other client would. */
|
||||
const saved = async () => {
|
||||
const response = await fetch('/api/v1/me/preferences', { credentials: 'include' });
|
||||
const body = await response.json();
|
||||
const prefs = (body.data || body).preferences || (body.data || body);
|
||||
return { uiLayouts: prefs.uiLayouts || {}, disabledSkills: prefs.disabledSkills || [] };
|
||||
};
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => { window.setTimeout(resolve, ms); });
|
||||
|
||||
/**
|
||||
* Wait until the panel is genuinely idle.
|
||||
*
|
||||
* Not cosmetic: a message submitted while the panel is streaming is dropped,
|
||||
* and a suite that does not wait reports a phantom failure for a request that
|
||||
* was never sent. That is exactly what happened during the manual audit.
|
||||
*/
|
||||
const idle = async () => {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const box = document.querySelector('textarea');
|
||||
if (box && !box.disabled && !/Thinking/.test(document.body.innerText)) return true;
|
||||
await sleep(500);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask Owliver, the way a person does.
|
||||
*
|
||||
* Through the composer and the form's own submit — not by calling a handler —
|
||||
* so the routing, the gate and the panel are all really exercised.
|
||||
*/
|
||||
const ask = async (question) => {
|
||||
await idle();
|
||||
const box = document.querySelector('textarea');
|
||||
const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
|
||||
setValue.call(box, question);
|
||||
box.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(150);
|
||||
box.closest('form').requestSubmit();
|
||||
await sleep(1500);
|
||||
await idle();
|
||||
await sleep(600);
|
||||
return panel();
|
||||
};
|
||||
|
||||
/**
|
||||
* Every request that left the page, so "did this reach the model?" is
|
||||
* answered by the network rather than by reading the reply and guessing.
|
||||
*/
|
||||
const spy = () => {
|
||||
if (window.__uiFlowSpy) { window.__uiFlowCalls = []; return; }
|
||||
window.__uiFlowCalls = [];
|
||||
const original = window.fetch;
|
||||
window.fetch = function spied(...args) {
|
||||
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
|
||||
const method = (args[1]?.method || 'GET').toUpperCase();
|
||||
if (/\/api\/v1\/(agents|runs)/.test(url) || method !== 'GET') {
|
||||
window.__uiFlowCalls.push(`${method} ${url}`);
|
||||
}
|
||||
return original.apply(this, args);
|
||||
};
|
||||
window.__uiFlowSpy = true;
|
||||
};
|
||||
|
||||
const modelCalls = () => (window.__uiFlowCalls || []).filter((c) => /\/agents\//.test(c));
|
||||
const writeCalls = () => (window.__uiFlowCalls || []).filter((c) => /^PATCH/.test(c));
|
||||
|
||||
/** One assertion. `detail` is what a reader needs to debug a failure. */
|
||||
const check = (results, name, pass, detail) => {
|
||||
results.push({ name, pass: Boolean(pass), detail: String(detail ?? '') });
|
||||
return Boolean(pass);
|
||||
};
|
||||
|
||||
/**
|
||||
* Phase 1 — the whole preview contract, before anything is kept.
|
||||
*
|
||||
* `target` is a node id; `phrase` is how a person would name it. Both are
|
||||
* given by the caller so this file names no page and no section.
|
||||
*/
|
||||
async function phase1({ page, target, phrase }) {
|
||||
const results = [];
|
||||
spy();
|
||||
|
||||
const inventory = await ask('What is on this page?');
|
||||
check(results, `${page}: inspect answers locally`, modelCalls().length === 0,
|
||||
modelCalls().join(', ') || 'no agent run');
|
||||
check(results, `${page}: inspect lists ${target}`, inventory.includes(`(${target})`),
|
||||
inventory.slice(-300));
|
||||
|
||||
const before = nodes();
|
||||
check(results, `${page}: ${target} is on the page`, before.includes(target), before.join(', '));
|
||||
|
||||
/* What was stored before anything was previewed. Compared against rather
|
||||
than assumed empty: a page that has been customised before still has a
|
||||
patch, and the claim under test is that a *preview* does not change it. */
|
||||
const storedBefore = JSON.stringify((await saved()).uiLayouts[page] ?? null);
|
||||
|
||||
window.__uiFlowCalls = [];
|
||||
await ask(`Hide ${phrase}`);
|
||||
const hidden = nodes();
|
||||
check(results, `${page}: hide removes it from the page`, !hidden.includes(target), hidden.join(', '));
|
||||
check(results, `${page}: hide does not reach the model`, modelCalls().length === 0,
|
||||
modelCalls().join(', ') || 'no agent run');
|
||||
|
||||
const storedDuring = JSON.stringify((await saved()).uiLayouts[page] ?? null);
|
||||
check(results, `${page}: preview writes nothing`,
|
||||
storedDuring === storedBefore && writeCalls().length === 0,
|
||||
`before ${storedBefore} · during ${storedDuring}`);
|
||||
|
||||
await ask('Discard the layout change');
|
||||
check(results, `${page}: discard restores it`, nodes().includes(target), nodes().join(', '));
|
||||
|
||||
await ask(`Hide ${phrase}`);
|
||||
check(results, `${page}: hide again`, !nodes().includes(target), nodes().join(', '));
|
||||
|
||||
window.__uiFlowCalls = [];
|
||||
await ask('Apply the layout change');
|
||||
await sleep(1200);
|
||||
const persisted = await saved();
|
||||
const patch = persisted.uiLayouts[page];
|
||||
check(results, `${page}: apply writes preferences`, writeCalls().length > 0, writeCalls().join(', '));
|
||||
check(results, `${page}: apply stores an operation, not a tree`,
|
||||
Boolean(patch) && patch.ops.some((op) => op.op === 'hide' && op.target === target && op.hidden === true),
|
||||
JSON.stringify(patch ?? null));
|
||||
check(results, `${page}: apply does not reach the model`, modelCalls().length === 0,
|
||||
modelCalls().join(', ') || 'no agent run');
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Phase 2 — after a cold reload: still hidden, still addressable, put back. */
|
||||
async function phase2({ page, target, phrase }) {
|
||||
const results = [];
|
||||
spy();
|
||||
|
||||
check(results, `${page}: reload reconstructs the hidden state`, !nodes().includes(target), nodes().join(', '));
|
||||
|
||||
const inventory = await ask('What is on this page?');
|
||||
check(results, `${page}: a hidden node stays in the inventory`, inventory.includes(`(${target})`),
|
||||
inventory.slice(-300));
|
||||
|
||||
window.__uiFlowCalls = [];
|
||||
await ask(`Show ${phrase}`);
|
||||
check(results, `${page}: a hidden node is addressable after a reload`, nodes().includes(target),
|
||||
nodes().join(', '));
|
||||
check(results, `${page}: unhide does not reach the model`, modelCalls().length === 0,
|
||||
modelCalls().join(', ') || 'no agent run');
|
||||
|
||||
await ask('Apply the layout change');
|
||||
await sleep(1200);
|
||||
const patch = (await saved()).uiLayouts[page];
|
||||
check(results, `${page}: the unhide is persisted`,
|
||||
Boolean(patch) && patch.ops.some((op) => op.op === 'hide' && op.target === target && op.hidden === false),
|
||||
JSON.stringify(patch ?? null));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Phase 3 — after a second cold reload, the page is itself again. */
|
||||
async function phase3({ page, target }) {
|
||||
const results = [];
|
||||
check(results, `${page}: reload reconstructs the restored state`, nodes().includes(target), nodes().join(', '));
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this suite exists for.
|
||||
*
|
||||
* Applying or discarding with nothing previewed used to fall through to the
|
||||
* model, which answered — reasonably, for an agent scoped to open roles —
|
||||
* that layout changes were not in its scope. A request about the interface
|
||||
* must never be answered by something that does not know the interface
|
||||
* exists. The second half is as important: ordinary "apply" must still be an
|
||||
* ordinary word.
|
||||
*/
|
||||
async function nothingPreviewed({ page, ordinaryQuestion }) {
|
||||
const results = [];
|
||||
spy();
|
||||
|
||||
for (const verb of ['Discard', 'Apply']) {
|
||||
window.__uiFlowCalls = [];
|
||||
const reply = await ask(`${verb} the layout change`);
|
||||
check(results, `${page}: "${verb} the layout change" with nothing previewed stays local`,
|
||||
modelCalls().length === 0, modelCalls().join(', ') || 'no agent run');
|
||||
check(results, `${page}: it says there is nothing to ${verb.toLowerCase()}`,
|
||||
new RegExp(`nothing to ${verb.toLowerCase()}`, 'i').test(reply), reply.slice(-240));
|
||||
}
|
||||
|
||||
if (ordinaryQuestion) {
|
||||
window.__uiFlowCalls = [];
|
||||
const reply = await ask(ordinaryQuestion);
|
||||
check(results, `${page}: an ordinary "apply" is not swallowed`,
|
||||
modelCalls().length > 0 && !/nothing to apply/i.test(reply.slice(-260)),
|
||||
modelCalls().join(', ') || 'no agent run');
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** A data question must stay a data question, however it is worded. */
|
||||
async function dataStaysData({ page, questions }) {
|
||||
const results = [];
|
||||
spy();
|
||||
for (const question of questions) {
|
||||
window.__uiFlowCalls = [];
|
||||
const before = nodes();
|
||||
await ask(question);
|
||||
check(results, `${page}: "${question}" is answered as data`,
|
||||
modelCalls().length > 0
|
||||
&& !/Previewing/.test(document.body.innerText)
|
||||
&& JSON.stringify(before) === JSON.stringify(nodes()),
|
||||
`${modelCalls().join(', ') || 'no agent run'} · nodes ${nodes().join(', ')}`);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
window.__uiFlows = { phase1, phase2, phase3, nothingPreviewed, dataStaysData, nodes, saved, ask, panel };
|
||||
}());
|
||||
115
scripts/render-page.mjs
Normal file
115
scripts/render-page.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Render an Admin page to static markup, outside a browser.
|
||||
*
|
||||
* The migration to the UI node tree has to be provable rather than asserted:
|
||||
* a page is rendered before it is touched, rendered again afterwards, and the
|
||||
* two are compared. This is the thing that renders it, used both to capture a
|
||||
* baseline and, from the check script, to compare against one.
|
||||
*
|
||||
* node scripts/render-page.mjs src/pages/admin/HiredHistory.jsx out.html
|
||||
*
|
||||
* The page is loaded through a real Vite server, so `@/` aliases, Markdown
|
||||
* imports and `import.meta.glob` behave exactly as they do in the app. Queries
|
||||
* are disabled rather than mocked: every page then renders its empty state,
|
||||
* deterministically, which is all a structural comparison needs.
|
||||
*/
|
||||
import { createServer } from 'vite';
|
||||
import { join } from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
/**
|
||||
* The design system reads `window` when its modules evaluate, which is a
|
||||
* pre-existing SSR limitation and not what any of this is testing. The shim is
|
||||
* the same one the citation tests use.
|
||||
*/
|
||||
export function shimWindow() {
|
||||
const had = 'window' in globalThis;
|
||||
const hadSvg = 'SVGElement' in globalThis;
|
||||
if (!had) {
|
||||
globalThis.window = {
|
||||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
};
|
||||
}
|
||||
/* Recharts tests `instanceof SVGElement` while measuring, which is a browser
|
||||
global with no Node equivalent. A bare class is enough: nothing is ever an
|
||||
instance of it, which is the correct answer outside a browser. */
|
||||
if (!hadSvg) globalThis.SVGElement = class SVGElement {};
|
||||
return () => {
|
||||
if (!had) delete globalThis.window;
|
||||
if (!hadSvg) delete globalThis.SVGElement;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one page.
|
||||
*
|
||||
* `providers` are supplied by the caller rather than assumed here, because the
|
||||
* editing session belongs to the layout and a baseline captured before a
|
||||
* migration must be rendered without it.
|
||||
*/
|
||||
export async function renderPage(server, modulePath, { route = '/', wrap = null } = {}) {
|
||||
/* Imported through Node rather than the Vite graph so the instance matches
|
||||
the one the page itself resolves — loading them as SSR modules creates a
|
||||
second copy, and a second QueryClientProvider provides nothing. */
|
||||
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
|
||||
const { MemoryRouter } = await import('react-router-dom');
|
||||
|
||||
const Page = (await server.ssrLoadModule(modulePath)).default;
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
|
||||
|
||||
const inner = wrap ? wrap(React.createElement(Page)) : React.createElement(Page);
|
||||
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(MemoryRouter, { initialEntries: [route] },
|
||||
React.createElement(QueryClientProvider, { client }, inner))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two renderings are the same page when they paint the same styled boxes, in
|
||||
* the same order, around the same words.
|
||||
*
|
||||
* Compared this way rather than byte-for-byte because a migration legitimately
|
||||
* adds `data-ui-*` identity attributes, and for components that cannot forward
|
||||
* unknown props a wrapper element carrying nothing else. Both are invisible.
|
||||
* A changed utility class, a reordered section or altered text moves one of
|
||||
* these and fails.
|
||||
*/
|
||||
export const classSignature = (html) => (html.match(/class="[^"]*"/g) || []).join('\n');
|
||||
export const wordSignature = (html) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
/** Tag counts, so an added element is visible and can be characterised. */
|
||||
export const tagCounts = (html) => (html.match(/<\/?[a-z][a-z0-9-]*/gi) || [])
|
||||
.map((t) => t.toLowerCase())
|
||||
.reduce((acc, t) => ({ ...acc, [t]: (acc[t] || 0) + 1 }), {});
|
||||
|
||||
/* Run directly: capture a baseline. */
|
||||
if (process.argv[1] && process.argv[1].endsWith('render-page.mjs')) {
|
||||
const [modulePath, out, route] = process.argv.slice(2);
|
||||
const restore = shimWindow();
|
||||
const server = await createServer({
|
||||
root: process.cwd(),
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom',
|
||||
logLevel: 'error',
|
||||
resolve: { alias: { 'react-hot-toast': join(process.cwd(), 'scripts/stubs/react-hot-toast.js') } },
|
||||
});
|
||||
try {
|
||||
const html = await renderPage(server, `/${modulePath.replace(/^\//, '')}`, { route: route || '/' });
|
||||
writeFileSync(out, html);
|
||||
console.log(`rendered ${html.length} chars -> ${out}`);
|
||||
} catch (error) {
|
||||
console.error('RENDER FAILED:', error.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await server.close();
|
||||
restore();
|
||||
/* `server.close()` leaves a handle open often enough that the process hangs
|
||||
without this, and a capture script that never exits is a capture script
|
||||
nobody runs. */
|
||||
process.exit(process.exitCode || 0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
19
scripts/stubs/react-hot-toast.js
vendored
Normal file
19
scripts/stubs/react-hot-toast.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* A no-op stand-in for `react-hot-toast`, used only by scripts/skill-check.mjs.
|
||||
*
|
||||
* The real package evaluates `goober`, which calls `document.createElement` the
|
||||
* moment it is imported. That makes the whole design-system barrel — and so the
|
||||
* Owliver block renderers — impossible to load in Node, which would leave the
|
||||
* citation tests asserting on block objects rather than on rendered markup.
|
||||
*
|
||||
* Nothing under test ever raises a toast, so a stub costs nothing and buys the
|
||||
* stronger assertion: the real renderers, producing real HTML.
|
||||
*/
|
||||
const noop = () => '';
|
||||
export const toast = Object.assign(noop, {
|
||||
success: noop, error: noop, loading: noop, custom: noop, dismiss: noop, remove: noop, promise: noop,
|
||||
});
|
||||
export const Toaster = () => null;
|
||||
export const useToaster = () => ({ toasts: [], handlers: {} });
|
||||
export const useToasterStore = () => ({ toasts: [] });
|
||||
export default toast;
|
||||
13
src/App.jsx
13
src/App.jsx
@@ -25,6 +25,7 @@ import WorkerProfile from '@/pages/WorkerProfile';
|
||||
import KrowIdentity from '@/pages/KrowIdentity';
|
||||
import Owliver from '@/pages/Owliver';
|
||||
import EmployeeDashboard from '@/pages/EmployeeDashboard';
|
||||
import OpportunityDetail from '@/pages/OpportunityDetail';
|
||||
import DesignSystem from '@/pages/DesignSystem';
|
||||
|
||||
/* Admin product — its own layout and route tree, so the Admin redesign can
|
||||
@@ -98,6 +99,12 @@ const AuthenticatedApp = () => {
|
||||
screening dimensions, which a 480px column cannot show without
|
||||
hiding most of it. */}
|
||||
<Route path="candidates/:id" element={<AdminCandidateProfile />} />
|
||||
{/* Candidate Analysis. A sibling segment, not `candidates/:id`:
|
||||
the analysis reads the whole pool, so it is not addressed by a
|
||||
candidate id and must not be matched as one. The route is the
|
||||
one the `candidates-analysis` surface declares, so the skill
|
||||
table, the assistant placement table and this agree. */}
|
||||
<Route path="candidates-analysis" element={<AdminCandidatesAnalysis />} />
|
||||
<Route path="hired" element={<AdminHiredHistory />} />
|
||||
<Route path="talent-pool" element={<AdminTalentPool />} />
|
||||
<Route path="university" element={<University />} />
|
||||
@@ -131,6 +138,12 @@ const AuthenticatedApp = () => {
|
||||
</Route>
|
||||
|
||||
{/* Redirect top-level legacy aliases to the unified global shell */}
|
||||
{/* Employee (talent) opportunity flow — full-page routes, no drawer.
|
||||
Standalone (outside AdminLayout/AdminRoute, which are the employer
|
||||
shell and gate); authenticated via ProtectedRoute. */}
|
||||
<Route path="/employee" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-5xl mx-auto px-4 py-8"><EmployeeDashboard /></div></div>} />
|
||||
<Route path="/opportunities/:id" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-3xl mx-auto px-4 py-8"><OpportunityDetail /></div></div>} />
|
||||
<Route path="/apply" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-3xl mx-auto px-4 py-8"><Apply /></div></div>} />
|
||||
<Route path="/overview" element={<Navigate to="/admin" replace />} />
|
||||
<Route path="/positions" element={<Navigate to="/admin/positions" replace />} />
|
||||
{/* `/positions` only matches the exact path, so the sub-routes need
|
||||
|
||||
@@ -4,7 +4,7 @@ name: Positions Agent
|
||||
description: Open roles — what they need, who has applied, and which are at risk of going unfilled.
|
||||
icon: briefcase
|
||||
status: published
|
||||
version: 1
|
||||
version: 2
|
||||
reasoning: balanced
|
||||
trigger: Use on Positions, for open roles, applicant flow, and specifying a new role.
|
||||
pages:
|
||||
@@ -12,6 +12,7 @@ pages:
|
||||
- create-position
|
||||
skills:
|
||||
- create-position
|
||||
- create-employee-role
|
||||
- hiring-activity-assistant
|
||||
- staffing-risk
|
||||
starters:
|
||||
|
||||
@@ -4,13 +4,14 @@ name: Talent Pool Agent
|
||||
description: Available talent — who is in the pool, who is verified, and who is ready to place.
|
||||
icon: layers
|
||||
status: published
|
||||
version: 1
|
||||
version: 2
|
||||
reasoning: balanced
|
||||
trigger: Use on Talent Pool, for supply, availability and readiness of known workers.
|
||||
pages:
|
||||
- talent-pool
|
||||
skills:
|
||||
- talent-pool-analysis
|
||||
- create-employee-role
|
||||
starters:
|
||||
- label: Who is available?
|
||||
prompt: Who is available in the talent pool?
|
||||
|
||||
@@ -29,6 +29,11 @@ const ENTITY_NAMES = [
|
||||
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
||||
'Course', 'Badge', 'LearningPath', 'Certification', 'RoleCategory',
|
||||
'UserActivity', 'Evidence', 'User',
|
||||
/* What a worker declares they DO — role, experience, desired pay,
|
||||
availability. The supply side of JobPosting, which is what the organization
|
||||
needs filled. The two meet through JobApplication, not through a reference
|
||||
between them. */
|
||||
'EmployeeRole',
|
||||
/* Who is on which position, and for how long. The record that turns "hired"
|
||||
into workforce allocation: without it a position knows its demand and its
|
||||
applicants but not who is actually covering it. */
|
||||
|
||||
@@ -99,6 +99,7 @@ const RESOURCE_PATHS = {
|
||||
AIInterview: 'ai-interviews',
|
||||
Staff: 'staff',
|
||||
WorkerProfile: 'worker-profiles',
|
||||
EmployeeRole: 'employee-roles',
|
||||
Course: 'courses',
|
||||
Badge: 'badges',
|
||||
LearningPath: 'learning-paths',
|
||||
|
||||
@@ -394,6 +394,10 @@ export function AgentSkillWorkspace({
|
||||
busy={busy}
|
||||
query={boardQuery}
|
||||
onQueryChange={setBoardQuery}
|
||||
/* Ownership, through the one handler that writes `agent.skills` —
|
||||
the same one the Owliver catalog attaches with. */
|
||||
attachedIds={attachedIds}
|
||||
onAttach={onToggleSkill}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { LayoutTemplate, Pencil, Plus, SearchX } from 'lucide-react';
|
||||
import { LayoutTemplate, Minus, Pencil, Plus, SearchX } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
||||
@@ -9,24 +9,30 @@ import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
||||
*
|
||||
* The second half of one Skills section — the same heading, a tab away from the
|
||||
* Owliver catalog — so nobody has to decide between two top-level destinations
|
||||
* ever again. What it is *not* is a second copy of that catalog with the word
|
||||
* Board on it, and the reason is worth stating because it is the one thing here
|
||||
* that could quietly become a lie:
|
||||
* ever again.
|
||||
*
|
||||
* **A Board skill is not carried by an agent.** `SkillSurface` renders a
|
||||
* section from the page it names and the account's `disabledSkills`; it does not
|
||||
* read `agent.skills` and has no agent in scope. Writing a Board skill id into
|
||||
* `agent.skills` would therefore record an assignment nothing honours — a card
|
||||
* that says "Added" and a product that behaves identically either way.
|
||||
* **Two controls, because there are two different questions.** This card used to
|
||||
* offer only the workspace switch, and said so at length: a Board skill was not
|
||||
* carried by an agent, because `SkillSurface` read the page and the account's
|
||||
* `disabledSkills` and never looked at `agent.skills`. That was true, and it is
|
||||
* not any more — `useSkillSections` now asks `agentPermitsSkill`, so an id
|
||||
* written into `agent.skills` is honoured on the page.
|
||||
*
|
||||
* So the relationship shown is the real one: these are the sections the pages
|
||||
* *this agent answers on* will draw, and the control offered is the one that
|
||||
* actually governs them — the workspace switch every surface already reads. It
|
||||
* is labelled as workspace-wide, because it is.
|
||||
* So both questions are asked here, and neither is dressed up as the other:
|
||||
*
|
||||
* - **Active** is the workspace switch. Off means off for everyone, on every
|
||||
* page, for every agent. It is the account's `disabledSkills`.
|
||||
* - **Add to agent** is ownership. It writes this skill's id into
|
||||
* `agent.skills`, through the same handler the Owliver catalog uses.
|
||||
*
|
||||
* Ownership is opt-in and that is what makes the two safe together: a skill no
|
||||
* agent claims still draws wherever its `pages:` say, exactly as before. The
|
||||
* first agent to claim it is what narrows it — which is why attaching is worth
|
||||
* saying out loud on the card rather than leaving as a silent side effect.
|
||||
*/
|
||||
|
||||
/** One Board skill: what it draws, where, and whether it is switched on. */
|
||||
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
|
||||
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = false, onAttach = null }) {
|
||||
const switchId = `board-${entry.id}`;
|
||||
|
||||
return (
|
||||
@@ -110,6 +116,31 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ownership, separate from the workspace switch above it. The button
|
||||
writes `agent.skills` through the same handler the Owliver catalog
|
||||
uses — there is no second assignment path and nothing new stored. */}
|
||||
{onAttach && (
|
||||
<div className="relative z-10 mt-3 flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle px-2.5 py-2">
|
||||
<span className="min-w-0 text-caption text-ink-3">
|
||||
{attached
|
||||
? 'Owned by this agent'
|
||||
: 'Not owned by any agent on this page'}
|
||||
</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={attached ? 'outline' : 'default'}
|
||||
shape="rounded"
|
||||
disabled={busy}
|
||||
onClick={() => onAttach(entry.id)}
|
||||
aria-label={`${attached ? 'Remove' : 'Add'} ${entry.name} ${attached ? 'from' : 'to'} this agent`}
|
||||
>
|
||||
{attached
|
||||
? <><Minus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove</>
|
||||
: <><Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Add to agent</>}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 mt-4 flex items-center justify-between gap-2 border-t border-border/60 pt-3 font-medium">
|
||||
<span className="text-caption text-ink-4">
|
||||
{entry.surfaces.some((s) => onSurfaces.has(s.id))
|
||||
@@ -131,6 +162,7 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
|
||||
/** @param {any} props */
|
||||
export function BoardSkillList({
|
||||
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
|
||||
attachedIds = null, onAttach = null,
|
||||
}) {
|
||||
const searchId = React.useId();
|
||||
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
|
||||
@@ -147,9 +179,11 @@ export function BoardSkillList({
|
||||
|
||||
return (
|
||||
<section aria-label="Board skills" className="flex min-w-0 flex-col gap-4">
|
||||
<Alert tone="info" title="Board skills belong to a page, not to an agent">
|
||||
They draw sections on KROW pages and are switched on for the whole workspace —
|
||||
so a change here affects every agent answering on that page, not just this one.
|
||||
<Alert tone="info" title="Two switches, two different questions">
|
||||
<strong>Active</strong> is workspace-wide: off means off on every page, for every
|
||||
agent. <strong>Add to agent</strong> is ownership — once any agent owns a board
|
||||
skill, it draws only where an owning agent is answering. A skill no agent owns
|
||||
keeps drawing wherever its pages say, as before.
|
||||
</Alert>
|
||||
|
||||
{total > 0 && (
|
||||
@@ -192,6 +226,8 @@ export function BoardSkillList({
|
||||
onToggle={onToggle}
|
||||
onSurfaces={onSurfaces}
|
||||
busy={busy}
|
||||
attached={Boolean(attachedIds?.has(entry.id))}
|
||||
onAttach={onAttach}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { allAgents } from '@/lib/agents/registry';
|
||||
import { AGENTS, readAgentRegistry } from '@/lib/agents/registry';
|
||||
import { sourcesFrom, useAgentDefinitions } from '@/lib/agents/agentStore';
|
||||
import {
|
||||
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
|
||||
} from '@/lib/agents/runtime';
|
||||
@@ -73,12 +74,30 @@ function writeSelection(selection) {
|
||||
export function AgentProvider({ contextId = null, children }) {
|
||||
const preferences = usePreferences();
|
||||
|
||||
/* Shipped definitions plus anything this account has authored, read through
|
||||
the one registry so the switcher and the management page cannot disagree
|
||||
about what exists. */
|
||||
/**
|
||||
* Shipped definitions plus anything this account has authored.
|
||||
*
|
||||
* Read from `agent-definitions` — the store the Agent Registry and Agent
|
||||
* Configure write to — rather than from `preferences.customAgents`, which is
|
||||
* where authored agents used to live. That move happened for the management
|
||||
* screens and this was left behind, so the panel's idea of an agent was the
|
||||
* shipped file and nothing else: an agent edited in Configure looked saved,
|
||||
* and the agent answering beside it was still the version off disk.
|
||||
*
|
||||
* It is only visible once something actually depends on an authored field.
|
||||
* Attaching a skill is that: ownership is read off `agent.skills`, and an
|
||||
* attachment made in Configure has to be the one the page sees, or the two
|
||||
* halves of the product disagree about what this agent owns.
|
||||
*/
|
||||
const definitions = useAgentDefinitions();
|
||||
const shippedIds = React.useMemo(() => new Set(AGENTS.map((a) => a.id)), []);
|
||||
const stored = React.useMemo(
|
||||
() => sourcesFrom(definitions.data || [], shippedIds),
|
||||
[definitions.data, shippedIds]
|
||||
);
|
||||
const agents = React.useMemo(
|
||||
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
|
||||
[preferences.customAgents, preferences.customSkills]
|
||||
() => readAgentRegistry(stored, { customSkills: preferences.customSkills || [] }).agents,
|
||||
[stored, preferences.customSkills]
|
||||
);
|
||||
|
||||
const [selection, setSelection] = React.useState(() => readSelection());
|
||||
|
||||
@@ -2,21 +2,24 @@ import * as React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
|
||||
History, Home, Maximize2, Minimize2, PanelRightClose, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import {
|
||||
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
|
||||
useAssignments, useAssignWorkers, useCreateWorkerWithRole, useCreateJobPosting,
|
||||
useGenerateJobDescription,
|
||||
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
|
||||
useUpdateJobPosting,
|
||||
usePreferences, useRoleCategories,
|
||||
} from '@/lib/krowHooks';
|
||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
import { runAction } from '@/lib/skills/actions';
|
||||
import { allSkills, pageKeyForContext } from '@/lib/skills/registry';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { allSkills, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
|
||||
import { actionSuggestions } from '@/lib/skills/tools';
|
||||
import { suggestionChips } from '@/lib/skills/serverSuggestions';
|
||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { profileForEmail } from '@/lib/skillGraph';
|
||||
@@ -38,7 +41,7 @@ import { PromptChips } from './PromptChips';
|
||||
*
|
||||
* Identity, not emptiness: `prompts` is recomputed on every keystroke, and a
|
||||
* fresh `[]` each time would rerender the row — and anything memoized against
|
||||
* it — throughout the whole of a query that is still too short to match.
|
||||
* it — on every turn that offers nothing.
|
||||
*/
|
||||
const EMPTY_PROMPTS = [];
|
||||
|
||||
@@ -52,41 +55,6 @@ const EMPTY_PROMPTS = [];
|
||||
*/
|
||||
const EMPTY_SUGGESTIONS = [];
|
||||
|
||||
/**
|
||||
* How long a pause counts as having finished typing.
|
||||
*
|
||||
* Short enough that the chips feel like they are keeping up, long enough that a
|
||||
* word typed at speed is one request rather than eight. The endpoint is cached
|
||||
* per query, so a reader deleting back to something already asked pays nothing
|
||||
* either way.
|
||||
*/
|
||||
const SUGGEST_DEBOUNCE_MS = 180;
|
||||
|
||||
/**
|
||||
* A value, held still until it stops changing.
|
||||
*
|
||||
* Deliberately generic and local: it debounces the composer's contents and
|
||||
* nothing else, and the alternative — debouncing inside the query hook — would
|
||||
* make every other caller of that hook pay for a delay it did not ask for.
|
||||
*/
|
||||
function useDebounced(value, delay) {
|
||||
const [settled, setSettled] = React.useState(value);
|
||||
|
||||
React.useEffect(() => {
|
||||
/* An emptied composer settles immediately. Waiting would leave the previous
|
||||
query's chips under a blank input for a fifth of a second, which reads as
|
||||
the panel not having noticed. */
|
||||
if (!value) {
|
||||
setSettled(value);
|
||||
return undefined;
|
||||
}
|
||||
const timer = setTimeout(() => setSettled(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return settled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owliver History — the conversations that came before.
|
||||
*
|
||||
@@ -168,77 +136,98 @@ function HistoryView({ groups, currentId, onOpen, onForget }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Back to Home row on scroll direction, not scroll position.
|
||||
* Whether the composer is offering anything, as one rule in one place.
|
||||
*
|
||||
* Hidden while reading downwards, back the instant the user scrolls up — the
|
||||
* way out of a long answer should not be something you have to scroll all the
|
||||
* way to the top to reach. Direction is read off the panel's own scrolling
|
||||
* element (the body region below), never the window: the page behind the panel
|
||||
* does not move when the thread does.
|
||||
* Extracted because it is the whole of the interaction and every clause is a
|
||||
* decision somebody could reasonably make differently:
|
||||
*
|
||||
* The listener is passive and rAF-throttled, and `setVisible` is only ever
|
||||
* called with a value that can change — React bails out on an identical one, so
|
||||
* a fast scroll costs at most one render per direction change.
|
||||
*
|
||||
* `pinToBottom` is the same follow-the-stream scroll the panel already did,
|
||||
* routed through here so a programmatic jump is not mistaken for the user
|
||||
* scrolling down and does not hide the control under them.
|
||||
* focused — an offer belongs to the thing you are about to type into. An
|
||||
* unfocused composer showing suggestions is the panel talking
|
||||
* first.
|
||||
* !busy — nothing is offered while an answer is still arriving; the next
|
||||
* question is not knowable until this one lands.
|
||||
* chat — History is a different view with a different body.
|
||||
* count — nothing to say, nothing shown, and this clause now carries what
|
||||
* a `!typed` test used to. Typing does not hide the panel by
|
||||
* rule; it changes what the panel HAS. An empty composer offers
|
||||
* follow-ups, a typed action intent offers the matching actions,
|
||||
* and arbitrary partial text matches no action and so offers
|
||||
* nothing — which is the same outcome by a more honest route,
|
||||
* and the reason "Create" can be answered while "he" cannot.
|
||||
*/
|
||||
function useDirectionalNav(scrollRef, { active, resetKey }) {
|
||||
const [visible, setVisible] = React.useState(true);
|
||||
const lastY = React.useRef(0);
|
||||
export const shouldShowSuggestions = ({ focused, busy, view, count }) => Boolean(
|
||||
focused && !busy && view === 'chat' && count > 0
|
||||
);
|
||||
|
||||
/* Ignore sub-pixel and trackpad jitter, but nothing a deliberate scroll would
|
||||
produce: a real direction change clears this within one frame. */
|
||||
const NOISE = 4;
|
||||
/**
|
||||
* The questions on offer, above the composer they belong to.
|
||||
*
|
||||
* A labelled panel rather than a bare row of chips: the label is what makes
|
||||
* three sentences read as an offer rather than as something the assistant just
|
||||
* said. It sits inside the composer's own region, above the input and below the
|
||||
* conversation, so it reads as part of the thing you are about to type into.
|
||||
*
|
||||
* Always mounted, height animated. Mounting on open would move the input the
|
||||
* instant the panel appeared and again when it left, so the caret would jump
|
||||
* under the reader's hands every time they focused the box. Animating a
|
||||
* collapsed height keeps the geometry continuous, and `pointer-events-none`
|
||||
* plus `inert`-style tab removal means the closed panel cannot be clicked or
|
||||
* tabbed into.
|
||||
*
|
||||
* `max-h` is generous enough for four wrapped questions and scrolls past that,
|
||||
* so a narrow panel on a small screen cannot push the composer off the bottom.
|
||||
*/
|
||||
function SuggestedQuestions({ prompts, open, onSelect }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-200 ease-out motion-reduce:transition-none',
|
||||
open
|
||||
? 'max-h-56 translate-y-0 opacity-100'
|
||||
: 'pointer-events-none max-h-0 translate-y-1 opacity-0'
|
||||
)}
|
||||
aria-hidden={open ? undefined : 'true'}
|
||||
>
|
||||
<p className="px-1 pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
Suggested questions
|
||||
</p>
|
||||
{/* The existing chip component, and the existing submit path behind it —
|
||||
`runPrompt` is the same handler a typed question goes through. */}
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={onSelect}
|
||||
align="start"
|
||||
focusable={open}
|
||||
className="pb-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pinToBottom = React.useCallback(() => {
|
||||
/**
|
||||
* Keep the thread pinned to its newest content.
|
||||
*
|
||||
* All that survives of a floating "Back to Home" row that used to sit between
|
||||
* the header and the conversation. That row was `absolute`, so it did not take
|
||||
* part in the layout — it OVERLAID the top of the scrolling region, and the
|
||||
* first line or two of a long answer arrived underneath it. It hid on
|
||||
* down-scroll to compensate, which meant the fix for a control covering the
|
||||
* answer was to make the control disappear while you read.
|
||||
*
|
||||
* The navigation it carried now lives in the header, where the panel's other
|
||||
* controls already are and where nothing can cover the response. What is left
|
||||
* here is the scroll behaviour, which was always a separate concern that had
|
||||
* been folded in because the two happened to share a listener.
|
||||
*
|
||||
* Direct `scrollTop` rather than smooth scrolling: at streaming frequency a
|
||||
* smooth scroll never catches up and the thread visibly lags the text.
|
||||
*/
|
||||
function usePinToBottom(scrollRef) {
|
||||
return React.useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
/* Adopt the new position before the scroll event lands, so the next read
|
||||
sees no delta and the row keeps whatever state the user left it in. */
|
||||
lastY.current = el.scrollTop;
|
||||
}, [scrollRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
/* Nothing to hide when the row is not rendered — and a fresh thread or a
|
||||
newly opened panel always starts with it showing. */
|
||||
setVisible(true);
|
||||
if (!el || !active) return undefined;
|
||||
|
||||
lastY.current = el.scrollTop;
|
||||
let frame = 0;
|
||||
|
||||
const read = () => {
|
||||
frame = 0;
|
||||
const y = el.scrollTop;
|
||||
if (y <= 0) {
|
||||
lastY.current = y;
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
const delta = y - lastY.current;
|
||||
/* Leave `lastY` alone below the threshold so slow scrolls accumulate
|
||||
rather than being swallowed frame by frame. */
|
||||
if (Math.abs(delta) < NOISE) return;
|
||||
lastY.current = y;
|
||||
setVisible(delta < 0);
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (!frame) frame = requestAnimationFrame(read);
|
||||
};
|
||||
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [scrollRef, active, resetKey]);
|
||||
|
||||
return { visible, pinToBottom };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -441,6 +430,66 @@ export default function KrowAssistant({
|
||||
return createJob.mutateAsync(result.data);
|
||||
}, [createJob]);
|
||||
|
||||
/**
|
||||
* Every conversation's write, keyed by the flow's id.
|
||||
*
|
||||
* `useAssistant` looks the writer up by the flow the answering skill declares,
|
||||
* so adding a conversation is adding an entry here rather than another prop
|
||||
* threaded through the panel.
|
||||
*/
|
||||
const createRole = useCreateWorkerWithRole();
|
||||
const createEmployeeRole = React.useCallback(async (draft, skill, status) => {
|
||||
const result = runAction('create_employee_role', { draft, skill, status });
|
||||
if (result?.type !== 'create_employee_role') return null;
|
||||
return createRole.mutateAsync(result.data);
|
||||
}, [createRole]);
|
||||
|
||||
/* The page's layout session, mounted by the layout above both this panel and
|
||||
the page. Null on a surface that composes no tree, which is every page that
|
||||
has not migrated — and every branch that reads it checks first. */
|
||||
const uiEditing = useUiEditing();
|
||||
|
||||
const flowWriters = React.useMemo(() => ({
|
||||
position: createPosition,
|
||||
'employee-role': createEmployeeRole,
|
||||
}), [createPosition, createEmployeeRole]);
|
||||
|
||||
/**
|
||||
* The clients this organization already staffs for.
|
||||
*
|
||||
* Distinct company names off the postings the panel has already loaded for
|
||||
* this caller — org-scoped by the API, and nothing here widens that. They are
|
||||
* offered as chips on the conversation's company question so an existing
|
||||
* client is a tap, while typing a name that is not on the list is still how a
|
||||
* new one is named. There is no company record to create: see the `@companies`
|
||||
* note in `lib/skills/flows/position.js`.
|
||||
*
|
||||
* Deliberately NOT sorted here. The order is the one the postings arrived in
|
||||
* — the API's `-created_date` — so the clients staffed for most recently are
|
||||
* the ones offered first, and the panel does no ranking of its own. That last
|
||||
* part is a rule `npm test` enforces structurally, and it is the right rule:
|
||||
* a second opinion formed in the panel outranking the server's is exactly the
|
||||
* failure that decays quietly.
|
||||
*/
|
||||
/**
|
||||
* The workers a role can be recorded against.
|
||||
*
|
||||
* The profiles the panel already holds for this caller — org-scoped by the
|
||||
* API. The conversation offers the names as chips and resolves a pick back to
|
||||
* the profile id and email, so the row names a real person rather than
|
||||
* whatever was typed. The operator is never the subject: the question is
|
||||
* required and there is no fallback to the session.
|
||||
*/
|
||||
const workers = React.useMemo(() => (facts.profiles || []).map((w) => ({
|
||||
id: w.id,
|
||||
name: w.full_name || w.name || '',
|
||||
email: w.email || '',
|
||||
})).filter((w) => w.email), [facts.profiles]);
|
||||
|
||||
const companies = React.useMemo(() => [...new Set(
|
||||
(facts.postings || []).map((p) => String(p.company || '').trim()).filter(Boolean)
|
||||
)], [facts.postings]);
|
||||
|
||||
/**
|
||||
* What to ask next, from the server, after something has been written.
|
||||
*
|
||||
@@ -460,13 +509,16 @@ export default function KrowAssistant({
|
||||
* used here — it was computed before the row existed.
|
||||
*/
|
||||
const queryClient = useQueryClient();
|
||||
const refreshSuggestions = React.useCallback(async () => {
|
||||
const refreshSuggestions = React.useCallback(async ({ query = '' } = {}) => {
|
||||
if (!owliverPage) return [];
|
||||
const fresh = await queryClient.fetchQuery({
|
||||
/* The empty query string is the untyped request — the same key
|
||||
`useOwliverSuggestions` uses when the composer is empty. */
|
||||
queryKey: ['owliverSuggestions', owliverPage, ''],
|
||||
queryFn: () => fetchOwliverSuggestions({ page: owliverPage }),
|
||||
/* An empty query is the untyped request — the same key
|
||||
`useOwliverSuggestions` uses when the composer is empty, and what a
|
||||
write wants: the page changed, so ask what matters on it now.
|
||||
A question passed in ranks the same catalogue AGAINST that question,
|
||||
which is what makes a follow-up follow from something. */
|
||||
queryKey: ['owliverSuggestions', owliverPage, query],
|
||||
queryFn: () => fetchOwliverSuggestions({ page: owliverPage, query }),
|
||||
staleTime: 0,
|
||||
});
|
||||
return suggestionChips(fresh || [], context.id);
|
||||
@@ -562,7 +614,13 @@ export default function KrowAssistant({
|
||||
pageLabel: context.page,
|
||||
onNavigate: goToPage,
|
||||
onAction: performAction,
|
||||
onCreatePosition: createPosition,
|
||||
flowWriters,
|
||||
uiEditing,
|
||||
companies,
|
||||
/* The postings this caller can already see — the evidence behind
|
||||
role-aware certification suggestions. */
|
||||
postings: facts.postings || [],
|
||||
workers,
|
||||
onRefreshSuggestions: refreshSuggestions,
|
||||
onUpdatePosition,
|
||||
onGenerateDescription,
|
||||
@@ -612,12 +670,12 @@ export default function KrowAssistant({
|
||||
const scrollRef = React.useRef(null);
|
||||
const isEmpty = messages.length === 0 && !pending;
|
||||
|
||||
/* The Back to Home row only exists in the states that are not already home. */
|
||||
const showBackRow = view === 'history' || messages.length > 0 || Boolean(pending);
|
||||
const { visible: backVisible, pinToBottom } = useDirectionalNav(scrollRef, {
|
||||
active: showBackRow,
|
||||
resetKey: `${view}:${conversationId || ''}`,
|
||||
});
|
||||
const pinToBottom = usePinToBottom(scrollRef);
|
||||
|
||||
/* Whether there is anything to leave. The header's own control is shown on
|
||||
exactly the states that are not already home — the same test the removed
|
||||
row used, now deciding a button rather than an overlay. */
|
||||
const canGoHome = view === 'history' || messages.length > 0 || Boolean(pending);
|
||||
|
||||
/* Greeting and suggestions come from live data, so they recompute only when
|
||||
the data or the page actually changes. */
|
||||
@@ -627,60 +685,98 @@ export default function KrowAssistant({
|
||||
);
|
||||
|
||||
/**
|
||||
* What the chip row actually shows, which is one of three separate things.
|
||||
* What the composer offers, and when.
|
||||
*
|
||||
* They are separate states, not one merged list, because they answer to
|
||||
* different owners. Follow-ups belong to the answer that raised them; the
|
||||
* suggestions belong to the server. Only one of them can be true at a time,
|
||||
* and the order below is that precedence.
|
||||
* Three rules, and they are about DIFFERENT questions — what to show, and
|
||||
* whether to show anything at all.
|
||||
*
|
||||
* 1. Follow-ups. When an answer ends by asking something, its chips *are*
|
||||
* the answers to it — the role list after "create a position". They are
|
||||
* never capped and never filtered, and they stand until the next turn or
|
||||
* until the reader starts typing something else.
|
||||
* WHAT. Before the first question, the page's own suggested questions: the
|
||||
* reader has asked nothing, so there is nothing to follow up and the useful
|
||||
* offer is the range of what this page can answer. After an answer, the
|
||||
* follow-ups that answer carried — questions this conversation has not
|
||||
* already covered, worked out in `nextSteps`. Never both: a thread that has
|
||||
* run out of new ground shows nothing rather than falling back to the
|
||||
* catalogue it has already been through.
|
||||
*
|
||||
* 2. The server's suggestions. From the moment there is something in the
|
||||
* composer, `GET /api/v1/owliver/suggestions` is asked what this page
|
||||
* can usefully answer for this query, and its reply is rendered in the
|
||||
* order it arrived. The panel does not rank, score, filter or reorder
|
||||
* it: which readings exist depends on the caller's role and on what is
|
||||
* actually in the database, and neither of those is knowable here.
|
||||
* WHEN. Only while the composer has focus and is empty. Suggestions used to
|
||||
* appear from the second character typed, which is the wrong moment twice
|
||||
* over: a reader who is typing has already decided what to ask, and two
|
||||
* characters is not enough to know what they mean. So typing hides them and
|
||||
* the reader's own text is never touched.
|
||||
*
|
||||
* 3. Nothing. An empty composer offers no chips at all. The panel used to
|
||||
* open on a dozen of them, which taught the range of what could be asked
|
||||
* by saying all of it at once and pushed the composer — the thing the
|
||||
* reader came for — under a wall of suggestions. The greeting still
|
||||
* carries the page's context; `buildIntro` reads the same fact sheet it
|
||||
* always did.
|
||||
* The typed-query branch is gone with it. The endpoint still takes a query
|
||||
* and `nextSteps` still uses it — that is what makes a follow-up follow from
|
||||
* something — but nothing asks it on a keystroke any more.
|
||||
*/
|
||||
const followUp = messages[messages.length - 1]?.followUp;
|
||||
const typed = input.trim();
|
||||
const [composerFocused, setComposerFocused] = React.useState(false);
|
||||
|
||||
/**
|
||||
* The request behind (2), debounced.
|
||||
*
|
||||
* The endpoint is cheap and cached per query, but a keystroke is not a
|
||||
* decision — a reader typing "positions" would otherwise fire nine requests
|
||||
* to see the answer to the ninth. A short delay means one request per pause,
|
||||
* and `placeholderData` in the hook keeps the previous answer on screen
|
||||
* meanwhile so the row does not empty and refill.
|
||||
*
|
||||
* Only asked while there is something in the composer. An empty one offers no
|
||||
* chips, so there would be nothing to render the answer into — and a reader
|
||||
* who starts typing has left the follow-up behind, which is why typing
|
||||
* supersedes it rather than being ranked against it.
|
||||
*/
|
||||
const debouncedQuery = useDebounced(typed, SUGGEST_DEBOUNCE_MS);
|
||||
const { data: suggested = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
|
||||
/* The page's own questions, for a thread that has not started. One untyped
|
||||
request, cached by the hook, asked only while it could be shown. */
|
||||
const { data: pageSuggestions = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
|
||||
page: owliverPage,
|
||||
query: debouncedQuery,
|
||||
enabled: Boolean(debouncedQuery),
|
||||
enabled: Boolean(owliverPage) && messages.length === 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* What is on offer, which now depends on whether anything has been typed.
|
||||
*
|
||||
* TYPED — the actions this page can perform that the text is starting to
|
||||
* name, and nothing else. "Create" reaches "Create a position" here because
|
||||
* that is a skill on this page that declares an action; "he" reaches nothing,
|
||||
* and neither does "abc". This is the narrow case the composer was missing:
|
||||
* a reader typing an action intent had to finish the sentence unaided, while
|
||||
* a reader typing anything at all used to get the whole page catalogue.
|
||||
*
|
||||
* EMPTY — the follow-ups the last answer left, or, before a thread starts,
|
||||
* what this page can be asked. Unchanged.
|
||||
*/
|
||||
const reachableSkills = React.useMemo(
|
||||
() => skillsForContext(context.id, disabledSkills, preferences.customSkills || []),
|
||||
[context.id, disabledSkills, preferences.customSkills]
|
||||
);
|
||||
|
||||
const prompts = React.useMemo(() => {
|
||||
if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
||||
return suggestionChips(suggested, context.id);
|
||||
}, [typed, followUp, suggested, context.id]);
|
||||
if (typed) return actionSuggestions(typed, reachableSkills);
|
||||
if (messages.length) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
||||
return suggestionChips(pageSuggestions, context.id);
|
||||
}, [typed, reachableSkills, messages.length, followUp, pageSuggestions, context.id]);
|
||||
|
||||
const showSuggestions = shouldShowSuggestions({
|
||||
focused: composerFocused, busy, view, count: prompts.length,
|
||||
});
|
||||
|
||||
/**
|
||||
* Focus, read at the composer rather than at the input.
|
||||
*
|
||||
* A chip lives inside the same region, so moving to one keeps the region
|
||||
* focused and the panel open long enough for the click to land — which a
|
||||
* `blur` handler on the textarea alone would not do. `relatedTarget` is what
|
||||
* makes "clicked outside" mean it: focus leaving for anywhere else in the
|
||||
* document closes the panel.
|
||||
*/
|
||||
const onComposerBlur = React.useCallback((event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) setComposerFocused(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Asking closes the panel, and only focus reopens it.
|
||||
*
|
||||
* A blur handler alone was not enough, which a live run showed: after a
|
||||
* question was sent, focus ended up on `document.body` while `composerFocused`
|
||||
* was still true, so the panel came back on its own under the finished answer
|
||||
* with nobody's cursor in the box. The subtree re-renders while the answer
|
||||
* streams, and a focus lost that way does not always arrive as a blur this
|
||||
* handler sees.
|
||||
*
|
||||
* So submitting is treated as what it is — the reader has finished with the
|
||||
* composer for now — rather than relying on a blur that may never come. The
|
||||
* state table is unchanged: focus opens it, everything else leaves it shut.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (busy) setComposerFocused(false);
|
||||
}, [busy]);
|
||||
|
||||
/* The newest assistant turn, which is the one that carries the rating. */
|
||||
const lastAnswerIndex = React.useMemo(
|
||||
@@ -833,6 +929,10 @@ export default function KrowAssistant({
|
||||
different places with different affordances. The panel does the
|
||||
first job only; the registry behind it is unchanged. */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* The way back to a clean panel, positioned in front of History */}
|
||||
{canGoHome && (
|
||||
<IconButton icon={Home} label="Back to home" variant="ghost" size="sm" onClick={goHome} />
|
||||
)}
|
||||
{/* History lives with the other window controls rather than in the
|
||||
body, so the layout of the panel is unchanged whether or not
|
||||
there is anything to show. It toggles: pressing it again returns
|
||||
@@ -845,9 +945,6 @@ export default function KrowAssistant({
|
||||
aria-pressed={view === 'history'}
|
||||
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
|
||||
/>
|
||||
{messages.length > 0 && view === 'chat' && (
|
||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
||||
)}
|
||||
{expanded
|
||||
? onRestore && (
|
||||
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
|
||||
@@ -861,37 +958,6 @@ export default function KrowAssistant({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating directional Back to Home row — reveals on UP-scroll, hides on DOWN-scroll */}
|
||||
{showBackRow && (
|
||||
<div
|
||||
className={cn(
|
||||
`absolute top-[3.25rem] left-0 right-0 z-20 flex items-center justify-between gap-2
|
||||
border-b border-border bg-white/95 dark:bg-slate-900/95 px-4 py-2 shadow-sm backdrop-blur-md
|
||||
transition-all duration-200 ease-out motion-reduce:transition-none`,
|
||||
backVisible
|
||||
? 'translate-y-0 opacity-100 pointer-events-auto'
|
||||
: '-translate-y-full opacity-0 pointer-events-none'
|
||||
)}
|
||||
aria-hidden={backVisible ? undefined : 'true'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goHome}
|
||||
tabIndex={backVisible ? undefined : -1}
|
||||
className="inline-flex items-center gap-1.5 rounded text-caption font-semibold text-ink-1 dark:text-white transition-colors
|
||||
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5 text-krow-blue" aria-hidden="true" />
|
||||
<span>Back to Home</span>
|
||||
</button>
|
||||
<span className="truncate text-[11px] font-medium text-ink-3">
|
||||
{view === 'history'
|
||||
? `${history.length} conversation${history.length === 1 ? '' : 's'}`
|
||||
: context.page}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/**
|
||||
* A capability test running through this panel.
|
||||
*
|
||||
@@ -1001,23 +1067,18 @@ export default function KrowAssistant({
|
||||
</div>
|
||||
|
||||
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
|
||||
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
|
||||
{/* All suggestions on the landing screen, where they teach what can be
|
||||
asked. Capped once a thread exists, because from then on the vertical
|
||||
space belongs to the conversation. Expanded fits more per line, so it
|
||||
can afford one more.
|
||||
|
||||
Follow-ups are never capped: when Owliver has asked a question, its
|
||||
chips *are* the answers, and hiding three of the six roles would make
|
||||
the flow look broken. */}
|
||||
{!busy && view === 'chat' && (
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={runPrompt}
|
||||
max={isEmpty || followUp?.length ? undefined : expanded ? 4 : 3}
|
||||
align="start"
|
||||
/>
|
||||
)}
|
||||
{/* Focus is tracked on the whole region rather than on the textarea, so
|
||||
reaching for a suggestion does not close the panel out from under the
|
||||
click. In normal flow, never floating: an overlay here would sit on
|
||||
top of the answer, which is the mistake the removed Back to Home row
|
||||
made. The body above is `flex-1`, so it yields the height and the
|
||||
response stays whole and scrollable. */}
|
||||
<div
|
||||
className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5"
|
||||
onFocusCapture={() => setComposerFocused(true)}
|
||||
onBlurCapture={onComposerBlur}
|
||||
>
|
||||
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
|
||||
{composer}
|
||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
||||
Owliver reads this page's data. Check anything you act on.
|
||||
|
||||
@@ -24,7 +24,13 @@ import { cn } from '@/lib/utils';
|
||||
* Arrow keys move between chips, so the whole set is one tab stop.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center', className = '' }) {
|
||||
export function PromptChips({
|
||||
prompts = [], onSelect, max = 0, align = 'center', className = '',
|
||||
/* Taken out of the tab order while the row is collapsed but still mounted:
|
||||
a chip inside a zero-height container is invisible, and a Tab that lands on
|
||||
something invisible is a keyboard user losing their place. */
|
||||
focusable = true,
|
||||
}) {
|
||||
const chipRefs = React.useRef([]);
|
||||
const visible = max ? prompts.slice(0, max) : prompts;
|
||||
|
||||
@@ -59,6 +65,7 @@ export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center',
|
||||
type="button"
|
||||
onClick={() => onSelect(prompt)}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
tabIndex={focusable ? undefined : -1}
|
||||
title={prompt.prompt}
|
||||
className={cn(
|
||||
/* 14px is the radius `rounded-full` already produces on a one-line
|
||||
|
||||
@@ -22,10 +22,42 @@ import { usePageAction } from './PageContext';
|
||||
* snapshot, and settled blocks must not re-render with it.
|
||||
*/
|
||||
|
||||
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_)/g;
|
||||
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_|\[[^\]\n]+\]\([^)\s]*\))/g;
|
||||
const LINK = /^\[([^\]\n]+)\]\(([^)\s]*)\)$/;
|
||||
|
||||
/** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is
|
||||
* carried by blocks, not by markup inside a paragraph. */
|
||||
/**
|
||||
* Where a link in an answer is allowed to point.
|
||||
*
|
||||
* An allow-list, and it is a security boundary rather than a tidiness rule: the
|
||||
* text being parsed here was written by a model, and a model reading a document
|
||||
* that says "link to javascript:…" is exactly the injection §I7 of CLAUDE.md
|
||||
* calls untrusted input. Anything not on this list renders as the plain text it
|
||||
* came from — visible, inert, and obvious.
|
||||
*
|
||||
* `#` alone is deliberately absent. A bare empty anchor is the shape the
|
||||
* citation sanitiser removes; one that reaches here is a link to nowhere, and
|
||||
* showing its label as text is better than an anchor that does nothing.
|
||||
*/
|
||||
const isSafeHref = (href) => (
|
||||
/^\/(?!\/)/.test(href) // in-app route
|
||||
|| /^#[^\s]+$/.test(href) // an anchor on this page, but not a bare '#'
|
||||
|| /^https?:\/\//i.test(href) // the open web
|
||||
|| /^mailto:[^\s]+$/i.test(href)
|
||||
);
|
||||
|
||||
/**
|
||||
* Inline `**bold**`, `_italic_` and `[label](href)`.
|
||||
*
|
||||
* Links were the gap: `markdownToBlocks` never touched them, and this renderer
|
||||
* had no case for them, so `[staffing policy](#staffing)` reached the reader as
|
||||
* its own source. Structure is still carried by blocks rather than by markup —
|
||||
* this stays three constructs, not a markdown library.
|
||||
*
|
||||
* A citation-shaped link never arrives here at all. `[337042b3](#)` is removed
|
||||
* upstream in `provider.js`, by the wrapper's shape, before a block is built —
|
||||
* so the two concerns stay apart: the sanitiser decides what is an internal
|
||||
* reference, and this decides how a real link looks.
|
||||
*/
|
||||
function Inline({ value }) {
|
||||
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
|
||||
|
||||
@@ -36,6 +68,33 @@ function Inline({ value }) {
|
||||
if (part.startsWith('_') && part.endsWith('_')) {
|
||||
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
|
||||
}
|
||||
|
||||
const link = LINK.exec(part);
|
||||
if (link) {
|
||||
const [, label, href] = link;
|
||||
if (!isSafeHref(href)) return part;
|
||||
/* An in-app route goes through the router, like every other internal link
|
||||
in this file — a full page load would throw away the conversation the
|
||||
reader is being pointed away from. Everything else is an anchor, and
|
||||
anything leaving the app opens away from it. */
|
||||
const external = /^https?:\/\//i.test(href);
|
||||
const className = 'font-medium text-krow-blue underline decoration-krow-blue/30 underline-offset-2'
|
||||
+ ' transition-colors hover:decoration-krow-blue focus-visible:outline-none'
|
||||
+ ' focus-visible:ring-2 focus-visible:ring-krow-blue/50 rounded-sm';
|
||||
|
||||
if (href.startsWith('/')) return <Link key={i} to={href} className={className}>{label}</Link>;
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={href}
|
||||
className={className}
|
||||
{...(external ? { target: '_blank', rel: 'noreferrer noopener' } : null)}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +53,22 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
||||
return {
|
||||
id: 'agent',
|
||||
|
||||
async *stream({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
|
||||
/**
|
||||
* Every snapshot leaves through here, and every snapshot is sanitised.
|
||||
*
|
||||
* The wrapper is the point. Below it there are four ways a response gets
|
||||
* built — streamed deltas, a completed run, a bounded run's trailing
|
||||
* message, and the two failure notes — and only one of them passes through
|
||||
* the markdown parser that removes citation ids. Sanitising at the yield
|
||||
* rather than at each construction means a fifth way, added later, cannot
|
||||
* reintroduce the leak by forgetting a call.
|
||||
*/
|
||||
async *stream(request) {
|
||||
for await (const snapshot of this.run(request)) yield sanitizeBlocks(snapshot);
|
||||
},
|
||||
|
||||
/** The run itself. Public only so `stream` can wrap it; call `stream`. */
|
||||
async *run({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
|
||||
/* No agent, no run. The panel resolves which agent covers the page before
|
||||
calling; reaching here without one means the routing layer changed and
|
||||
this should say so rather than guess at an agent id. */
|
||||
@@ -169,7 +184,9 @@ async function* readRunStream(response, signal) {
|
||||
|
||||
if (typeof event.delta === 'string') {
|
||||
text += event.delta;
|
||||
yield markdownToBlocks(text);
|
||||
/* Still arriving: the frontier rules apply, so a citation split
|
||||
across two frames is never rendered half-written. */
|
||||
yield markdownToBlocks(text, { partial: true });
|
||||
continue;
|
||||
}
|
||||
if (event.run) final = event.run;
|
||||
@@ -226,6 +243,290 @@ function toBlocks(run) {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* ── Citations ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Why any of this exists.
|
||||
*
|
||||
* The backend asks the model to cite: `knowledge/context.go` tells it "Each
|
||||
* <source> carries an id: cite it when you use what it says", and the
|
||||
* `knowledge_search` tool repeats it. What neither does is say HOW — so the
|
||||
* model picks a format, and picks a different one on a different day. The ids
|
||||
* themselves are `knowledge_chunks.id`, which are UUIDs, and the model quotes
|
||||
* them whole or truncated to their first block.
|
||||
*
|
||||
* The panel has no citation surface to render any of that into, so whatever
|
||||
* shape the model chose arrives on screen as raw markup. The formats seen so
|
||||
* far are a `<cite>` tag and a markdown link to an empty anchor; the rules
|
||||
* below are written against the SHAPE of an identifier rather than against
|
||||
* either format's syntax, so a third spelling of the same idea is far more
|
||||
* likely to be caught than to be a new bug.
|
||||
*
|
||||
* A citation id is hex and dashes — a UUID or a leading run of one. That is
|
||||
* what makes `10 applications`, `97% coverage` and `113 shifts` safe: decimal
|
||||
* counts in prose are not addresses, are never inside citation syntax, and no
|
||||
* rule here looks at a bare number.
|
||||
*/
|
||||
const CITATION_ID = String.raw`[0-9a-f]{4,}(?:-[0-9a-f]{4,})*`;
|
||||
|
||||
/**
|
||||
* The tag spelling — `<cite id="…">…</cite>`, whatever attributes it carries.
|
||||
*
|
||||
* Matched by TAG NAME, never by id: the ids are minted per run, so a rule
|
||||
* written against the ones in today's output would let tomorrow's through.
|
||||
*/
|
||||
const CITATION_TAG = /<\/?cit(?:e|ation)\b[^>]*>/gi;
|
||||
|
||||
/**
|
||||
* The link spelling, and the brackets the model wraps a run of them in —
|
||||
* `([337042b3](#), [2b94bc43](#))`.
|
||||
*
|
||||
* Identified by two conditions TOGETHER, never either alone: the target must be
|
||||
* a bare `#` anchor, AND the label must look like an identifier rather than
|
||||
* words. A real link has a real href, a real anchor link has a destination
|
||||
* after the `#`, and a link a person would click has a label they could read.
|
||||
* Requiring both is what keeps `[staffing policy](#staffing)`, `[Read more](/docs)`
|
||||
* and even `[Read more](#)` on screen.
|
||||
*
|
||||
* The group is removed whole rather than link by link, because removing them
|
||||
* one at a time leaves `(, )` behind — which reads worse than the ids did.
|
||||
*/
|
||||
const CITATION_GROUP = new RegExp(
|
||||
String.raw`\s*\(\s*\[${CITATION_ID}\]\(#\)(?:\s*,\s*\[${CITATION_ID}\]\(#\))*\s*\)`,
|
||||
'gi'
|
||||
);
|
||||
const CITATION_LINK = new RegExp(String.raw`\s*\[${CITATION_ID}\]\(#\)`, 'gi');
|
||||
|
||||
/**
|
||||
* The prose spelling: the model narrating the attribute rather than marking it
|
||||
* up — `(id `f34e8ef0-…`)`, `(reference `…`)`, `(source: `…`)`.
|
||||
*
|
||||
* Why this exists is the same reason the other two do. `context.go` hands the
|
||||
* model `<source id="…">` and tells it to cite the id without saying how, so
|
||||
* the model reaches for whatever syntax feels natural that day. This one is not
|
||||
* markup at all — it is the id written out in a parenthesis, which is why no
|
||||
* tag rule and no link rule saw it.
|
||||
*
|
||||
* THE WRAPPER IS WHAT IDENTIFIES IT, NEVER THE ID. That distinction is the
|
||||
* whole rule and it has to survive future edits: a worker's record id is the
|
||||
* same shape as a chunk id, so a rule that recognised ids rather than wrappers
|
||||
* would delete real data off a profile. `Worker ID: f34e8ef0-…` has no
|
||||
* parenthesis, so nothing here reaches it; `(id `f34e8ef0-…`)` is a
|
||||
* parenthesis containing a reference word and nothing but ids, which is not a
|
||||
* shape prose takes for any other reason.
|
||||
*
|
||||
* Backticks are optional on each side independently, because a model that opens
|
||||
* a code span and forgets to close it before the bracket must not defeat this.
|
||||
*/
|
||||
const REF_WORD = String.raw`(?:ids?|refs?|references?|sources?|citations?)`;
|
||||
const TICKED_ID = String.raw`\x60?${CITATION_ID}\x60?`;
|
||||
const CITATION_LABELLED = new RegExp(
|
||||
String.raw`\s*\(\s*${REF_WORD}\s*:?\s*${TICKED_ID}(?:\s*,\s*${TICKED_ID})*\s*\)`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
/**
|
||||
* Ids in square brackets with nothing else in them — `[uuid]`, `[uuid, uuid]`.
|
||||
*
|
||||
* A markdown link is `[label](target)`; a bracket holding only identifiers is
|
||||
* not a link and is not something prose does. The lookahead leaves anything
|
||||
* followed by `(` to the link rules, so a genuine link whose label happens to
|
||||
* be a reference number keeps its destination and stays on screen.
|
||||
*/
|
||||
/* Stricter than CITATION_ID, and only here. A bare bracket has no reference
|
||||
word to disambiguate it, so the id itself has to carry the evidence: at least
|
||||
eight hex characters, or a dashed group. Without that `[2026]` is four hex
|
||||
digits and a year in brackets would disappear from an answer. */
|
||||
const BRACKETABLE_ID = String.raw`(?:[0-9a-f]{8,}|[0-9a-f]{4,}(?:-[0-9a-f]{4,})+)`;
|
||||
const CITATION_BRACKETED = new RegExp(
|
||||
String.raw`\s*\[\s*\x60?${BRACKETABLE_ID}\x60?(?:\s*,\s*\x60?${BRACKETABLE_ID}\x60?)*\s*\](?!\()`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
/**
|
||||
* Whatever is still arriving at the end of the text.
|
||||
*
|
||||
* The streaming half of the problem, and it is a real one rather than a
|
||||
* theoretical one: `readRunStream` re-parses the WHOLE accumulated answer on
|
||||
* every delta, so a citation split across two frames is a state the reader can
|
||||
* see. `…before a first shift ([3370` renders for as long as the next delta
|
||||
* takes to arrive.
|
||||
*
|
||||
* Both rules are anchored to the end of the text, so they can only ever
|
||||
* describe the frontier of the stream and never something the answer has
|
||||
* already moved past. The fragment is held back until it completes, at which
|
||||
* point the rules above remove it properly — which is buffering, expressed as
|
||||
* a parse rather than as a second copy of the text.
|
||||
*/
|
||||
const CITATION_TAG_PARTIAL = /<\/?[a-z]*$|<\/?cit(?:e|ation)\b[^>]*$/i;
|
||||
const CITATION_LINK_PARTIAL = new RegExp([
|
||||
/* An open bracket holding at least one COMPLETE citation and not yet closed:
|
||||
`…shift ([337042b3](#)` and `…shift ([337042b3](#), `. Without this the
|
||||
complete link is removed by the rule above and the `(` is stranded. */
|
||||
String.raw`\s*\((?:\s*\[${CITATION_ID}\]\(#\)\s*,?)+\s*(?:\[[0-9a-f-]*(?:\](?:\(#?)?)?)?$`,
|
||||
/* One part-written, with or without its bracket: `([3370`, `[337042b3`,
|
||||
`[337042b3](`, `[337042b3](#`. */
|
||||
String.raw`\s*\(?\s*\[[0-9a-f-]*(?:\](?:\(#?)?)?$`,
|
||||
].join('|'), 'i');
|
||||
|
||||
/**
|
||||
* The same two, part-written.
|
||||
*
|
||||
* `Policy (id `f34e8ef0-9a01-5921` is a state the reader can see, because the
|
||||
* stream re-parses everything on every delta. Both are anchored to the end, so
|
||||
* they describe only the frontier.
|
||||
*
|
||||
* The labelled rule accepts any short leading word rather than only a reference
|
||||
* word, because `(i` and `(id` are prefixes of one and `(` is a prefix of
|
||||
* everything. The cost is that an ordinary parenthetical is held back for the
|
||||
* frames between its bracket and its first non-hex character —
|
||||
* `Omar Haddad (hired 2026-01-0` waits, `…(hired 2026-01-04) starts` does not.
|
||||
* A parenthesis arriving a frame late is not something a reader can notice; a
|
||||
* half-written reference id is exactly what they reported.
|
||||
*/
|
||||
const CITATION_LABELLED_PARTIAL = new RegExp(
|
||||
String.raw`\s*\((?:[a-z]{0,12}[\s:]*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?)?$`,
|
||||
'i'
|
||||
);
|
||||
const CITATION_BRACKETED_PARTIAL = new RegExp(
|
||||
String.raw`\s*\[\s*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?$`,
|
||||
'i'
|
||||
);
|
||||
|
||||
/**
|
||||
* What a lifted citation leaves behind.
|
||||
*
|
||||
* `check ()`, `check (, )`, `check .` — the wrapper is gone and its punctuation
|
||||
* is not, and a sentence ending in an empty bracket reads as broken markup
|
||||
* rather than as a clean sentence. Applied after the removals, never before.
|
||||
*/
|
||||
const EMPTY_PARENS = /\s*\(\s*[,;]*\s*\)/g;
|
||||
const SPACE_BEFORE_PUNCTUATION = /\s+([.,;:!?])/g;
|
||||
const DOUBLED_SPACES = / {2,}/g;
|
||||
|
||||
/**
|
||||
* Removes citation markup, keeping the sentence inside it.
|
||||
*
|
||||
* The wrapper is addressing, not content — it tells a client which retrieved
|
||||
* chunk a claim came from — and with nowhere to render it the honest move is to
|
||||
* show the claim and drop the envelope. The backend's citation metadata is
|
||||
* untouched: it is still on the run, still in the trajectory, and this only
|
||||
* decides what reaches a reader.
|
||||
*
|
||||
* Content is never altered, only the wrapper around it, so markdown inside a
|
||||
* citation — bold, a bullet, a table row — parses exactly as it would have
|
||||
* unwrapped.
|
||||
*
|
||||
* If the panel ever grows a real citation affordance, this is the seam: parse
|
||||
* the ids out here into a block the renderer can draw, rather than discarding
|
||||
* them. Nothing else has to move.
|
||||
*/
|
||||
export function stripCitations(markdown, { partial = false } = {}) {
|
||||
let out = String(markdown ?? '')
|
||||
.replace(CITATION_TAG, '')
|
||||
.replace(CITATION_GROUP, '')
|
||||
.replace(CITATION_LABELLED, '')
|
||||
.replace(CITATION_BRACKETED, '');
|
||||
|
||||
/**
|
||||
* The frontier rules, and ONLY while there is a frontier.
|
||||
*
|
||||
* They describe something that is still being written, so they are wrong to
|
||||
* apply to a finished answer — and quietly so. `…and note [12]` ends in a
|
||||
* bracket holding two hex characters, which is indistinguishable from the
|
||||
* first two characters of an id still arriving. Mid-stream, holding it back
|
||||
* for a frame is right. At the end of a completed answer there is nothing
|
||||
* more coming, the bracket is all there will ever be, and removing it deletes
|
||||
* a footnote marker from the reader's answer.
|
||||
*
|
||||
* The caller knows which it is: `readRunStream` passes `partial` on a delta
|
||||
* and not on the final snapshot. That is the only place the distinction
|
||||
* exists, so it is the only place it can be made.
|
||||
*
|
||||
* Order is load-bearing. `…shift ([337042b3](#),` is a COMPLETE link inside
|
||||
* an unclosed bracket — strip the link first and `(,` is left on screen,
|
||||
* which is the broken bracket this exists to prevent. Matching the
|
||||
* unterminated group first takes the whole fragment.
|
||||
*/
|
||||
if (partial) {
|
||||
out = out
|
||||
.replace(CITATION_TAG_PARTIAL, '')
|
||||
.replace(CITATION_LINK_PARTIAL, '')
|
||||
.replace(CITATION_LABELLED_PARTIAL, '')
|
||||
.replace(CITATION_BRACKETED_PARTIAL, '');
|
||||
}
|
||||
|
||||
return out
|
||||
.replace(CITATION_LINK, '')
|
||||
.replace(EMPTY_PARENS, '')
|
||||
.replace(SPACE_BEFORE_PUNCTUATION, '$1')
|
||||
.replace(DOUBLED_SPACES, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys that carry text a person reads, on any block.
|
||||
*
|
||||
* An allow-list rather than a deny-list, because the two mistakes do not cost
|
||||
* the same: missing a display field leaks an id, while sanitising an address
|
||||
* field would corrupt a confirmation token, a route or a record id and break
|
||||
* what it points at. A new block type gets its display keys covered for free; a
|
||||
* new addressing key is safe by default.
|
||||
*/
|
||||
const DISPLAY_KEYS = new Set([
|
||||
'text', 'sub', 'label', 'title', 'summary', 'caption',
|
||||
'description', 'detail', 'note', 'heading', 'hint',
|
||||
]);
|
||||
|
||||
/** Keys whose value is a list of sentences rather than one. */
|
||||
const DISPLAY_LISTS = new Set(['items', 'warnings', 'details', 'lines', 'rows']);
|
||||
|
||||
/**
|
||||
* Citation-proofs a whole response, whatever shape it arrived in.
|
||||
*
|
||||
* `markdownToBlocks` strips the model's markdown, and for a completed answer
|
||||
* that is the whole story. It is NOT the whole story for the response: the same
|
||||
* provider also emits `note(run.message)` when a run did not complete,
|
||||
* `note(event.error.message)` when the stream fails, and `confirmation(payload)`
|
||||
* carrying server wording composed around model-supplied arguments. None of
|
||||
* those go through the markdown parser, so each was a way for an id to reach
|
||||
* the DOM without passing the one place that removes them.
|
||||
*
|
||||
* Rather than a `stripCitations` call at each — three sites today, and a fourth
|
||||
* the next time the provider learns to say something — every block the agent
|
||||
* provider yields goes through here.
|
||||
*
|
||||
* Walks recursively so nested shapes are reached (a table's rows, a
|
||||
* confirmation's warnings, an insight's items) and touches only the keys above:
|
||||
* `token`, `id`, `route`, `to` and everything else addressing-like is left
|
||||
* exactly as the server sent it.
|
||||
*/
|
||||
export function sanitizeBlocks(blocks) {
|
||||
return (blocks || []).map((block) => sanitizeValue(block, null));
|
||||
}
|
||||
|
||||
function sanitizeValue(value, key) {
|
||||
if (typeof value === 'string') {
|
||||
return DISPLAY_KEYS.has(key) || DISPLAY_LISTS.has(key) ? stripCitations(value) : value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
/* The key travels into the elements, strings and objects alike. A string in
|
||||
`items` is display text; an object in `rows` is a row, and only the key
|
||||
says so — its own cell keys are positional (`c0`, `c1`) and carry no
|
||||
meaning at all. An object in `columns` still defers to its own keys. */
|
||||
return value.map((entry) => sanitizeValue(entry, key));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
/* A table row is `{ c0: 'Applied', c1: '4' }` — the cell keys are
|
||||
positional and carry no meaning, so the row itself marks them. */
|
||||
out[k] = key === 'rows' && typeof v === 'string' ? stripCitations(v) : sanitizeValue(v, k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a model's markdown into the block vocabulary the panel already renders.
|
||||
*
|
||||
@@ -247,8 +548,8 @@ function toBlocks(run) {
|
||||
* parser would be a large dependency in exchange for handling footnotes nobody
|
||||
* writes.
|
||||
*/
|
||||
export function markdownToBlocks(markdown) {
|
||||
const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
|
||||
export function markdownToBlocks(markdown, { partial = false } = {}) {
|
||||
const lines = stripCitations(markdown, { partial }).replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks = [];
|
||||
let paragraph = [];
|
||||
let listItems = null;
|
||||
@@ -353,7 +654,15 @@ function parseTable(lines, start) {
|
||||
* left alone — those go through `Inline`, which renders bold properly.
|
||||
*/
|
||||
function stripInline(value) {
|
||||
return String(value).replace(/\*\*(.+?)\*\*/g, '$1').replace(/`(.+?)`/g, '$1').trim();
|
||||
return String(value)
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/`(.+?)`/g, '$1')
|
||||
/* A link keeps its label and loses its target. Headings and cells are drawn
|
||||
as plain strings by their components, so an anchor cannot survive here —
|
||||
and the label alone reads correctly, where the raw `[label](href)` does
|
||||
not. `Inline` renders the real thing everywhere a link CAN be one. */
|
||||
.replace(/\[([^\]\n]+)\]\([^)\s]*\)/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,7 +747,7 @@ export function createAssistantProvider() {
|
||||
export function createUnconfiguredProvider() {
|
||||
return {
|
||||
id: 'unconfigured',
|
||||
// eslint-disable-next-line require-yield
|
||||
|
||||
async *stream() {
|
||||
yield [note(
|
||||
'Owliver is not configured on this deployment. Set VITE_AGENT_API and give the '
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { doc, heading, insights, list, note, skillSection, text } from './blocks';
|
||||
import { resolveUiEdit } from './uiEdit';
|
||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||
import { poolFor } from '@/lib/workforce';
|
||||
import { matchSkill } from '@/lib/skills/registry';
|
||||
@@ -23,7 +24,8 @@ import {
|
||||
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
|
||||
extractSkillName, findCourseByName,
|
||||
} from '@/lib/skills/actions';
|
||||
import { beginPositionFlow } from '@/lib/skills/positionFlow';
|
||||
import { beginFlow } from '@/lib/skills/conversationFlow';
|
||||
import { flowFor } from '@/lib/skills/flows';
|
||||
|
||||
/**
|
||||
* Intent routing — deciding whether a question belongs to the page you are on.
|
||||
@@ -633,7 +635,7 @@ function declaredAnswer({ skill, capability, question, skillContext }) {
|
||||
*/
|
||||
function resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
skillContext = null,
|
||||
companies = [], postings = null, skillContext = null,
|
||||
}) {
|
||||
/**
|
||||
* One question, one skill, then one way of answering it.
|
||||
@@ -728,15 +730,21 @@ function resolveSkill({
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Position is collected in the conversation, not in a form.
|
||||
* A conversational skill is collected in the chat, not in a form.
|
||||
*
|
||||
* Nothing opens and nothing is navigated to: the skill's questions come back
|
||||
* as a reply and its answers as chips, and the position is written at the end
|
||||
* as a reply and its answers as chips, and the record is written at the end
|
||||
* from what the conversation gathered. `flow` is the state that turn carries
|
||||
* forward — the panel keeps it and feeds the next answer back in.
|
||||
*
|
||||
* Which conversation is the SKILL'S OWN `flow:` declaration, resolved through
|
||||
* `FLOWS`. This used to be `if (skill.id === 'create-position')`, which made a
|
||||
* second conversational skill a change to the router rather than a file on
|
||||
* disk — precisely the `if agent_key == ...` shape §2's I6 rules out.
|
||||
*/
|
||||
if (skill.id === 'create-position') {
|
||||
return { kind: 'skill', skill, ...beginPositionFlow({ question, skill, roles }) };
|
||||
const registry = flowFor(skill);
|
||||
if (registry) {
|
||||
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies, postings } }) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -855,6 +863,11 @@ function resolveDraftAction(question, workforce, positionId = null) {
|
||||
export function resolveIntent({
|
||||
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
||||
courses = [], workforce = null, skillContext = null,
|
||||
/* The clients this organization already staffs for, offered as chips on the
|
||||
company question. Read off the postings the caller can already see, so it
|
||||
expands nobody's view — see the `@companies` note in flows/position.js. */
|
||||
companies = [],
|
||||
postings = null,
|
||||
/**
|
||||
* The active agent, and where the reader is.
|
||||
*
|
||||
@@ -869,6 +882,14 @@ export function resolveIntent({
|
||||
was one. Only the draft flow reads it; a typed question carries none and
|
||||
resolves exactly as it always did. */
|
||||
positionId = null,
|
||||
/**
|
||||
* The layout session for this page, when there is one.
|
||||
*
|
||||
* Carries the tree on screen and whether something is already being
|
||||
* previewed. Absent — or on a page that composes no tree — every branch below
|
||||
* resolves exactly as it did before this existed.
|
||||
*/
|
||||
ui = null,
|
||||
}) {
|
||||
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
|
||||
|
||||
@@ -900,9 +921,21 @@ export function resolveIntent({
|
||||
const draftIntent = resolveDraftAction(question, workforce || { positions: [] }, positionId);
|
||||
if (draftIntent) return draftIntent;
|
||||
|
||||
/**
|
||||
* 1b. Changing the page itself.
|
||||
*
|
||||
* Ahead of the skills because a request to hide a section is about the
|
||||
* interface, and a skill trigger reading the same words would answer about
|
||||
* the data behind it. Tightly gated: `resolveUiEdit` returns null unless the
|
||||
* page composes a tree AND the words name something on it, a registered
|
||||
* panel type, or the layout — so an ordinary question is never taken.
|
||||
*/
|
||||
const uiIntent = resolveUiEdit({ question, ui });
|
||||
if (uiIntent) return uiIntent;
|
||||
|
||||
/* 2. Current page skills — specific triggers, ahead of the general reader. */
|
||||
const skill = resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies, postings,
|
||||
/* The envelope travels beside the collections rather than replacing them:
|
||||
a resolver reads records, and the envelope says where the reader is. A
|
||||
source that needs a position still finds it exactly where it always was. */
|
||||
|
||||
224
src/components/ai-assistant/uiEdit.js
Normal file
224
src/components/ai-assistant/uiEdit.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import { doc, list, note, text } from './blocks';
|
||||
import { matchUiEdit } from '@/lib/ui/intent';
|
||||
import { outlineTree } from '@/lib/ui/inspect';
|
||||
import { dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
|
||||
/**
|
||||
* Owliver's half of a layout change.
|
||||
*
|
||||
* Turns a request into an intent the panel can act on, and into the words that
|
||||
* go back. The understanding itself is in `lib/ui/intent.js`; this decides what
|
||||
* to say about it.
|
||||
*
|
||||
* Every outcome is one of four kinds, and the split matters:
|
||||
*
|
||||
* - `ui-preview` an operation to show, not to keep
|
||||
* - `ui-apply` / `ui-discard` acting on what is already shown
|
||||
* - `ui-answer` a question back, or a refusal — nothing changes
|
||||
*
|
||||
* A preview is never applied in the same turn. The person asked for a change;
|
||||
* they have not yet seen it, and agreeing to something unseen is not agreement.
|
||||
*/
|
||||
|
||||
/** The chips offered while something is being previewed. */
|
||||
const PREVIEW_CHIPS = [
|
||||
{ label: 'Apply', prompt: 'Apply the layout change' },
|
||||
{ label: 'Discard', prompt: 'Discard the layout change' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Read a layout request.
|
||||
*
|
||||
* Returns null for anything that is not one, which is most of what is typed —
|
||||
* and returning null is what leaves every existing Owliver answer exactly as it
|
||||
* was. The gate is in `matchUiEdit`: a verb alone is never enough.
|
||||
*/
|
||||
export function resolveUiEdit({ question, ui }) {
|
||||
if (!ui?.available) return null;
|
||||
|
||||
const match = matchUiEdit(question, {
|
||||
tree: ui.tree,
|
||||
registry: ui.registry,
|
||||
role: ui.role,
|
||||
previewing: ui.previewing,
|
||||
/* Which page this is. Owliver may only offer, and only accept, what this
|
||||
page can actually hold — the same scope the visual editor's picker uses,
|
||||
so the two can never disagree about what is addable here. */
|
||||
page: ui.page,
|
||||
/* The node this conversation last changed. Nothing is remembered inside the
|
||||
matcher: continuity is a fact the caller holds and passes in. */
|
||||
focus: ui.focus || null,
|
||||
});
|
||||
if (!match) return null;
|
||||
|
||||
switch (match.kind) {
|
||||
case 'inspect':
|
||||
return { kind: 'ui-answer', doc: describe(ui.tree, ui.registry) };
|
||||
|
||||
case 'apply':
|
||||
return {
|
||||
kind: 'ui-apply',
|
||||
doc: doc(text('Saved. This page will look like this the next time you open it.')),
|
||||
};
|
||||
|
||||
case 'discard':
|
||||
return {
|
||||
kind: 'ui-discard',
|
||||
doc: doc(text('Put back the way it was. Nothing was saved.')),
|
||||
};
|
||||
|
||||
case 'plan':
|
||||
return {
|
||||
kind: 'ui-preview',
|
||||
op: match.op,
|
||||
doc: doc(
|
||||
text(`${match.summary}. This is a preview — nothing is saved yet.`),
|
||||
note('Choose Apply to keep it, or Discard to put it back.')
|
||||
),
|
||||
followUp: PREVIEW_CHIPS,
|
||||
};
|
||||
|
||||
/**
|
||||
* More than one thing fits.
|
||||
*
|
||||
* Named back rather than guessed at. Editing the wrong section while
|
||||
* somebody is looking at another one is the failure the whole target
|
||||
* resolver exists to avoid, and a coin toss here would reintroduce it.
|
||||
*/
|
||||
case 'ambiguous':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text('More than one part of this page fits that. Which did you mean?'),
|
||||
list(match.candidates.map((node) => `${node.title || node.label} (${node.id})`))
|
||||
),
|
||||
followUp: match.candidates.slice(0, 3).map((node) => ({
|
||||
label: node.title || node.label,
|
||||
prompt: `${node.id}`,
|
||||
})),
|
||||
};
|
||||
|
||||
case 'unknown':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text('I could not find that on this page.'),
|
||||
note('Ask what is on this page to see what can be changed.')
|
||||
),
|
||||
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
|
||||
};
|
||||
|
||||
/** A type nobody has registered. Offered the real ones rather than invented. */
|
||||
case 'unknown-type':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text('I do not have that kind of panel.'),
|
||||
text(`I can use: ${match.offered.join(', ')}.`)
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* A shape with no reading named.
|
||||
*
|
||||
* The one place a data source could be invented, and the place it is most
|
||||
* firmly refused: the choices come from the closed vocabulary, and the
|
||||
* person picks.
|
||||
*/
|
||||
case 'needs-source':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text(`What should the ${match.type.label} show?`),
|
||||
list(match.options.map(dataSourceLabel))
|
||||
),
|
||||
followUp: match.options.slice(0, 3).map((id) => ({
|
||||
label: dataSourceLabel(id),
|
||||
prompt: `Add a ${match.type.label} showing ${dataSourceLabel(id)}`,
|
||||
})),
|
||||
};
|
||||
|
||||
/**
|
||||
* Asked to apply or discard with nothing being previewed.
|
||||
*
|
||||
* Answered here rather than left to fall through, because falling through
|
||||
* sent the panel's own chip text to the model, which replied — correctly
|
||||
* for what it is — that layout changes are not in its scope. The honest
|
||||
* answer is that there is nothing to act on.
|
||||
*/
|
||||
case 'nothing-previewed':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text(match.op === 'apply'
|
||||
? 'There is nothing to apply — no layout change is being previewed.'
|
||||
: 'There is nothing to discard — no layout change is being previewed.'),
|
||||
note('Ask what is on this page to see what can be changed.')
|
||||
),
|
||||
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
|
||||
};
|
||||
|
||||
/**
|
||||
* Understood, and not possible — with the reason and the way forward.
|
||||
*
|
||||
* A refusal that only says no leaves a person guessing at a vocabulary they
|
||||
* cannot see. When the engine knows what this reading *could* be drawn as,
|
||||
* it says so and offers the choices as chips, so "no, but here" costs one
|
||||
* click rather than another round of guessing.
|
||||
*/
|
||||
case 'refused': {
|
||||
const offered = match.alternatives || [];
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: offered.length
|
||||
? doc(
|
||||
text(match.message),
|
||||
text(`It can be shown as: ${offered.map((o) => o.label).join(', ')}.`)
|
||||
)
|
||||
: doc(text(match.message)),
|
||||
followUp: offered.slice(0, 3).map((option) => ({
|
||||
label: option.label,
|
||||
prompt: `Show ${match.node?.title || match.node?.label || 'it'} as a ${option.label}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way this reading could honestly be drawn.
|
||||
*
|
||||
* The options are the registry's answer, not a suggestion: each is a
|
||||
* component the application ships, each will draw this node's own figures,
|
||||
* and choosing one produces exactly the operation `planReplace` would have
|
||||
* produced from the same words. Nothing here is generated.
|
||||
*/
|
||||
case 'options':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text(`${match.subject} can be drawn these ways. Each one uses the same figures.`),
|
||||
list(match.options.map((option) => `${option.label} — ${option.summary}`)),
|
||||
note('Pick one to preview it. Nothing is saved until you apply.')
|
||||
),
|
||||
followUp: match.options.map((option) => ({
|
||||
label: option.label,
|
||||
prompt: `Show ${match.node.title || match.node.label} as a ${option.label}`,
|
||||
})),
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What is on the page, as a reading rather than a change. */
|
||||
function describe(tree, registry) {
|
||||
const lines = outlineTree(tree, { registry });
|
||||
if (!lines.length) {
|
||||
return doc(text('This page is not one I can rearrange yet.'));
|
||||
}
|
||||
return doc(
|
||||
text('This page is made of these parts. You can hide, show or reorder any of them.'),
|
||||
list(lines),
|
||||
note('Say for example "hide the audit log" or "move the timeline to the top".')
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,8 @@ import {
|
||||
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { skillsForContext } from '@/lib/skills/registry';
|
||||
import {
|
||||
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
||||
} from '@/lib/skills/positionFlow';
|
||||
import { advanceFlow } from '@/lib/skills/conversationFlow';
|
||||
import { flowFor } from '@/lib/skills/flows';
|
||||
import {
|
||||
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
|
||||
publishedReply, weightsSetReply, weightsUnchangedReply,
|
||||
@@ -186,6 +185,60 @@ function normalizeDraftChip(chip) {
|
||||
return chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* One question, reduced to what it asks.
|
||||
*
|
||||
* Case, surrounding space and a trailing question mark are not differences, so
|
||||
* "What should I do next?" and "what should i do next" are one question and are
|
||||
* not offered twice.
|
||||
*/
|
||||
const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/[?.!]+$/, '');
|
||||
|
||||
/**
|
||||
* The chips to offer after an answer: what this conversation has not covered.
|
||||
*
|
||||
* Two rules, and the second is the one that matters. The suggestions are ranked
|
||||
* by the SERVER against the question just asked — the panel does not decide what
|
||||
* is worth asking, it only decides what has already been said — and then
|
||||
* anything this thread has asked or already offered is removed.
|
||||
*
|
||||
* Without that second rule the row repeats. A page carries a handful of intents
|
||||
* and the top of that list barely moves between turns, so the same three chips
|
||||
* come back after every answer, including the one the reader has just pressed.
|
||||
* Removing what has been used leaves genuinely new ground each time and runs out
|
||||
* honestly rather than looping.
|
||||
*
|
||||
* There is NO fallback to the page's own ranking, and that is the correction a
|
||||
* live run forced. Asking "Summarize hiring activity" matches nothing in the
|
||||
* catalogue, so nothing was excluded, so the fallback returned the page's top
|
||||
* three — and the reader got "How healthy is the platform right now?" under an
|
||||
* answer about hiring activity, which is the generic-catalogue behaviour this
|
||||
* function exists to end. A page ranking is what to ask on a PAGE; it is not a
|
||||
* follow-up to anything. When the conversation has no next question, the honest
|
||||
* answer is none.
|
||||
*/
|
||||
export async function nextSteps({ question, history, refresh }) {
|
||||
if (!refresh) return undefined;
|
||||
|
||||
const used = new Set([asQuestion(question)]);
|
||||
for (const message of history) {
|
||||
if (message.role === 'user') used.add(asQuestion(message.text));
|
||||
for (const chip of message.followUp || []) used.add(asQuestion(chip.prompt || chip.label));
|
||||
}
|
||||
|
||||
const unused = (chips) => (chips || []).filter((chip) => {
|
||||
const key = asQuestion(chip.prompt || chip.label);
|
||||
if (!key || used.has(key)) return false;
|
||||
/* A list that repeats itself within one turn is the same defect at a
|
||||
smaller scale. */
|
||||
used.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
const onTopic = unused(await refresh({ query: question }));
|
||||
return onTopic.length ? onTopic : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored thread, with any completed-then-reopen action stripped out.
|
||||
*
|
||||
@@ -237,7 +290,24 @@ function withoutAuthoringActions(messages = []) {
|
||||
* routing applies to both without either knowing it exists.
|
||||
*/
|
||||
export function useConversation({
|
||||
contextId, facts, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
|
||||
contextId, facts, onNavigate, onAction, onRefreshSuggestions,
|
||||
/**
|
||||
* How each conversation's record gets written, keyed by the flow's id.
|
||||
*
|
||||
* A single `onCreatePosition` prop was the last place the panel named one
|
||||
* kind of record. A second conversation needed a second prop, a second branch
|
||||
* at the write, and a second set of outcome renderers — three edits to answer
|
||||
* "and now employee roles too". This is one entry in a map.
|
||||
*/
|
||||
flowWriters = {},
|
||||
/**
|
||||
* The page's layout session, when the surface has one.
|
||||
*
|
||||
* Read for the tree Owliver inspects and called to preview or keep a change.
|
||||
* Absent on every page that composes no tree, and every branch that touches
|
||||
* it checks first — so the panel behaves exactly as it did before on those.
|
||||
*/
|
||||
uiEditing = null,
|
||||
onAssignWorkers, onScheduleInterview,
|
||||
/* Finishing a draft: the same two mutations the Create Position form calls.
|
||||
Passed in rather than reached for, so this layer still writes nothing
|
||||
@@ -245,6 +315,15 @@ export function useConversation({
|
||||
onUpdatePosition, onGenerateDescription,
|
||||
workforce = null, disabledSkills = [], customSkills = [],
|
||||
roles = [], skillCategories = [], courses = [], skillContext = null,
|
||||
/* The clients this organization already staffs for, offered as chips on the
|
||||
company question. Derived from postings the caller can already read. */
|
||||
companies = [],
|
||||
/* The caller's own postings, which is where role-to-certification relevance
|
||||
is observed from. See `certificationsForRole`. */
|
||||
postings = null,
|
||||
/* The worker profiles a declared role can be recorded against, as
|
||||
`{ id, name, email }`. Same rule: already-loaded, already-permitted rows. */
|
||||
workers = [],
|
||||
/**
|
||||
* The active agent and where the reader is.
|
||||
*
|
||||
@@ -475,7 +554,10 @@ export function useConversation({
|
||||
intent = {
|
||||
kind: 'flow',
|
||||
skill,
|
||||
...advancePositionFlow({ flow: flowRef.current, answer: text, skill, roles }),
|
||||
...advanceFlow({
|
||||
registry: flowFor(skill), flow: flowRef.current, answer: text, skill,
|
||||
ctx: { roles, companies, workers, postings },
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -490,11 +572,28 @@ export function useConversation({
|
||||
question: text,
|
||||
contextId: turnContext,
|
||||
disabledSkills: turnDisabled,
|
||||
customSkills, roles, skillCategories,
|
||||
customSkills, roles, skillCategories, companies, postings,
|
||||
courses, workforce, skillContext, positionId,
|
||||
agent: turnAgent,
|
||||
agentCoversPage: turnCovers,
|
||||
agentSuggestion, owliverContext,
|
||||
ui: uiEditing
|
||||
? {
|
||||
available: Boolean(uiEditing.tree?.length),
|
||||
tree: uiEditing.tree,
|
||||
previewing: uiEditing.previewing,
|
||||
role: uiEditing.role || null,
|
||||
registry: uiEditing.registry || undefined,
|
||||
/* Where the reader is standing. What can be added here is a
|
||||
property of the page, not of the registry, and this is how
|
||||
the conversation learns it. */
|
||||
page: uiEditing.page || null,
|
||||
/* What this session last changed, so "change it back" has an
|
||||
"it". A node id and nothing else — see `focus` on the
|
||||
editing provider. */
|
||||
focus: uiEditing.focus || null,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
/* Only while a real agent is behind the panel. With the local
|
||||
simulator there is nothing better to defer TO, and deferring
|
||||
@@ -503,19 +602,56 @@ export function useConversation({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A layout change, acted on before the reply says what happened.
|
||||
*
|
||||
* Three kinds, and the split is the safety property: a preview is shown and
|
||||
* nothing is stored; an apply keeps what was already shown; an answer — a
|
||||
* question back, or a refusal — changes nothing at all. Owliver never
|
||||
* applies in the same turn it proposes.
|
||||
*
|
||||
* `propose` validates against the tree on screen and refuses rather than
|
||||
* previewing something that could not be kept, so a refusal here is
|
||||
* reported in the words the engine gave rather than a generic apology.
|
||||
*/
|
||||
if (intent.kind === 'ui-preview' && uiEditing) {
|
||||
const result = uiEditing.propose(intent.op);
|
||||
if (!result.ok) {
|
||||
intent = {
|
||||
...intent,
|
||||
kind: 'ui-answer',
|
||||
doc: doc(textBlock(result.problems[0]?.message || 'That change is not possible here.')),
|
||||
followUp: undefined,
|
||||
};
|
||||
}
|
||||
} else if (intent.kind === 'ui-apply' && uiEditing) {
|
||||
const result = await uiEditing.apply();
|
||||
if (!result.ok) {
|
||||
intent = { ...intent, doc: doc(textBlock('That change could not be saved.')) };
|
||||
}
|
||||
} else if (intent.kind === 'ui-discard' && uiEditing) {
|
||||
uiEditing.discard();
|
||||
}
|
||||
|
||||
/**
|
||||
* The one step that writes. It happens before the reply rather than after,
|
||||
* because the reply is the outcome — "Position created successfully" has to
|
||||
* be true when it is said.
|
||||
*/
|
||||
if (intent.kind === 'flow' && intent.create) {
|
||||
/* The skill says which conversation this is, so the write and the wording
|
||||
of its outcome both come from that registry rather than from a name
|
||||
hardcoded here. */
|
||||
const registry = flowFor(intent.skill);
|
||||
let created = null;
|
||||
/* Kept, not swallowed. The reply states the outcome, and "it did not
|
||||
work" is a worse outcome to state than the reason it did not: a
|
||||
required field, a refused role, or an API that is not running. */
|
||||
let failure = null;
|
||||
try {
|
||||
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
|
||||
created = await flowWriters[registry.id]?.(
|
||||
intent.create.draft, intent.skill, intent.create.status
|
||||
);
|
||||
} catch (error) {
|
||||
created = null;
|
||||
failure = error;
|
||||
@@ -551,19 +687,16 @@ export function useConversation({
|
||||
intent = {
|
||||
...intent,
|
||||
flow: null,
|
||||
doc: positionCreatedReply(created),
|
||||
followUp: [...createdFollowUp(created), ...refreshed],
|
||||
doc: registry.outcome.created(created),
|
||||
followUp: [...registry.outcome.followUp(created), ...refreshed],
|
||||
};
|
||||
} else {
|
||||
/* Keep the answers: the summary is still there to try again from. */
|
||||
intent = {
|
||||
...intent,
|
||||
flow: { ...intent.flow, stage: 'review' },
|
||||
doc: positionFailedReply(failure?.message),
|
||||
followUp: [
|
||||
{ label: 'Create position', prompt: 'Create position' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
doc: registry.outcome.failed(failure?.message),
|
||||
followUp: registry.outcome.retryChips,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -765,9 +898,53 @@ export function useConversation({
|
||||
// half-written answer loses what they were already reading. Stopping
|
||||
// before the first block, though, should leave no empty turn behind.
|
||||
if (latest.length) {
|
||||
/**
|
||||
* What to ask NEXT — which is not the same as what is worth asking.
|
||||
*
|
||||
* Every other path in this file ends its turn with `followUp`; the
|
||||
* agent path was the one that did not, so an agent answer was the only
|
||||
* kind that left the chip row empty.
|
||||
*
|
||||
* The first attempt at fixing that asked for the page's untyped
|
||||
* suggestions, and they are ranked by signal rather than by the
|
||||
* conversation — so a page with six intents offered its top three, and
|
||||
* offered the same three after every answer, including the one that had
|
||||
* just been asked. Three standing highlights repeated verbatim are not
|
||||
* follow-ups; they are the landing screen redrawn under a reply.
|
||||
*
|
||||
* So the question is passed to the server as the query, which ranks the
|
||||
* same catalogue against what was actually asked, and anything this
|
||||
* thread has already asked or already offered is removed. What is left
|
||||
* is what this conversation has not covered yet — which is what a
|
||||
* follow-up is. When nothing is left, nothing is shown: a panel with
|
||||
* nothing new to suggest should say so by being quiet, not by repeating
|
||||
* itself.
|
||||
*
|
||||
* A stopped run is offered nothing. The reader interrupted the answer,
|
||||
* so the next step it implies has not been established.
|
||||
*/
|
||||
let followUp;
|
||||
if (!controller.signal.aborted) {
|
||||
try {
|
||||
followUp = await nextSteps({
|
||||
question: text,
|
||||
history: messagesRef.current,
|
||||
refresh: onRefreshSuggestions,
|
||||
});
|
||||
} catch {
|
||||
/* The answer arrived; failing to fetch what to ask next is not a
|
||||
reason to withhold it. */
|
||||
}
|
||||
}
|
||||
|
||||
const next = [
|
||||
...messagesRef.current,
|
||||
{ role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
|
||||
{
|
||||
role: 'assistant',
|
||||
blocks: latest,
|
||||
stopped: controller.signal.aborted || undefined,
|
||||
...(followUp ? { followUp } : null),
|
||||
},
|
||||
];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
@@ -785,13 +962,18 @@ export function useConversation({
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
|
||||
}, [contextId, facts, persist, onNavigate, onAction, flowWriters, onRefreshSuggestions,
|
||||
onUpdatePosition,
|
||||
onGenerateDescription, onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses, skillContext,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext]);
|
||||
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext,
|
||||
/* The layout session changes as a page's composition and the account's
|
||||
skills resolve, and a stale one means the tree Owliver inspects is the
|
||||
empty one from the first render — so a layout request falls through to
|
||||
the model and comes back as "I don't cover that". */
|
||||
uiEditing]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
|
||||
107
src/components/krow/talent/Opportunities.jsx
Normal file
107
src/components/krow/talent/Opportunities.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Briefcase, MapPin, Wallet, Sparkles, Compass, ArrowRight } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Opportunities — the Employee opportunity-discovery grid.
|
||||
*
|
||||
* Replaces the old demand-feed cards on the Employee jobs surface. Each card is
|
||||
* an opportunity, identified by its JOB TITLE — never a company/client name.
|
||||
* "View Position" navigates (route-based, no drawer) to the full-page detail at
|
||||
* /opportunities/:id.
|
||||
*
|
||||
* It takes the same `{ job, match }` records the dashboard already computes with
|
||||
* recommendJobs(profile, postings, 6); nothing about the matching changes here.
|
||||
*
|
||||
* COMPANY PRIVACY: this component reads only the fields the Employee experience
|
||||
* needs — id, title, role_category, location, pay_range_min/max, status, match —
|
||||
* and never `job.company`.
|
||||
*/
|
||||
function OpportunityCard({ rec }) {
|
||||
const navigate = useNavigate();
|
||||
const { job, match } = rec;
|
||||
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : null;
|
||||
const isHiring = job.status === 'active';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/opportunities/${job.id}`)}
|
||||
className="group text-left w-full rounded-2xl bg-white border border-[#E5E7EB] p-5 transition-all
|
||||
hover:border-[#0838E0]/40 hover:shadow-lg hover:shadow-[#0838E0]/5 hover:-translate-y-0.5
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="w-11 h-11 rounded-xl bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center shrink-0">
|
||||
<Briefcase className="w-5 h-5" />
|
||||
</div>
|
||||
{isHiring && (
|
||||
<span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full">
|
||||
Hiring
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The job title is the opportunity's identity — no company name. */}
|
||||
<h3 className="mt-3 text-[16px] font-bold text-[#0F172A] leading-snug line-clamp-2">{job.title}</h3>
|
||||
{job.role_category && (
|
||||
<p className="mt-0.5 text-[13px] text-[#6B7280] capitalize">{job.role_category}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<p className="text-[13px] text-[#6B7280] flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-[#9CA3AF]" /> {job.location || 'Remote'}
|
||||
</p>
|
||||
{pay && (
|
||||
<p className="text-[13px] font-medium text-[#111827] flex items-center gap-1.5">
|
||||
<Wallet className="w-3.5 h-3.5 text-[#9CA3AF]" /> {pay}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between pt-4 border-t border-[#F3F4F6]">
|
||||
<span className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0838E0] bg-[#EEF3FE] px-2.5 py-1 rounded-full">
|
||||
<Sparkles className="w-3 h-3" /> {match}% match
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[13px] font-semibold text-[#0838E0] group-hover:gap-1.5 transition-all">
|
||||
View Position <ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Opportunities({ jobRecs = [] }) {
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h2 className="text-[18px] font-bold text-[#0F172A] flex items-center gap-2">
|
||||
<Compass className="w-4 h-4 text-[#0838E0]" /> Opportunities for you
|
||||
</h2>
|
||||
<p className="text-[13px] text-[#6B7280] mt-0.5">Matched to your profile. Open one to see why you fit.</p>
|
||||
</div>
|
||||
{jobRecs.length > 0 && (
|
||||
<span className="inline-flex items-center text-[12px] font-semibold text-[#0838E0] bg-[#EEF3FE] px-3 py-1.5 rounded-full">
|
||||
{jobRecs.length} match{jobRecs.length === 1 ? '' : 'es'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{jobRecs.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3">
|
||||
<Compass className="w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-[14px] font-medium text-[#0F172A]">No matched opportunities yet — keep leveling up.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{jobRecs.map((rec) => (
|
||||
<OpportunityCard key={rec.job.id} rec={rec} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
|
||||
from the module rather than the package index so a page section never pulls
|
||||
the assistant panel in behind it. */
|
||||
import { usePageAction, usePageContext } from '@/components/ai-assistant/PageContext';
|
||||
/* Which agent is answering on this page. Imported from the module rather than
|
||||
the package index for the same reason `PageContext` is: a page section must
|
||||
not pull the assistant panel in behind it. */
|
||||
import { useActiveAgent } from '@/components/ai-assistant/AgentContext';
|
||||
import { agentPermitsSkill } from '@/lib/agents/runtime';
|
||||
|
||||
/**
|
||||
* The extension point: one controlled slot a page offers to skills.
|
||||
@@ -35,23 +40,71 @@ import { usePageAction, usePageContext } from '@/components/ai-assistant/PageCon
|
||||
*/
|
||||
|
||||
/** The skills contributing sections to this page right now. */
|
||||
function useSkillSections(page, placement) {
|
||||
/**
|
||||
* The sections definitions contribute to a page, optionally narrowed to one
|
||||
* placement.
|
||||
*
|
||||
* Exported because the node tree needs the identical reading: a skill's section
|
||||
* drawn as a child node and the same section drawn by this surface must come
|
||||
* from one resolution, or the two would disagree about what a page carries.
|
||||
* Called with no placement it returns every section on the page.
|
||||
*/
|
||||
export function useSkillSections(page, placement) {
|
||||
const preferences = usePreferences();
|
||||
/**
|
||||
* Who is answering here.
|
||||
*
|
||||
* Read through the same context the panel reads, which is mounted around the
|
||||
* page as well as around the panel — see `AssistantPanelContext`. Outside a
|
||||
* provider this returns an inert value, so a surface drawn anywhere else sees
|
||||
* no agents and no ownership, which is exactly today's behaviour.
|
||||
*/
|
||||
const { agent, agents } = useActiveAgent();
|
||||
|
||||
const customKey = JSON.stringify(preferences.customSkills || []);
|
||||
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
|
||||
/* Stable keys, because these are fresh arrays on every render and a memo
|
||||
keyed on their identity would recompute forever. */
|
||||
const ownedKey = JSON.stringify((agents || []).map((a) => a.skills || []));
|
||||
const mineKey = JSON.stringify(agent?.skills || []);
|
||||
|
||||
return useMemo(() => {
|
||||
const custom = JSON.parse(customKey);
|
||||
const disabled = JSON.parse(disabledKey);
|
||||
|
||||
/**
|
||||
* Agent ownership, and why it is opt-in.
|
||||
*
|
||||
* A skill that an agent claims belongs to that agent: its UI is drawn where
|
||||
* that agent is answering and nowhere else. A skill that **no** agent claims
|
||||
* is unowned, and unowned means unchanged — page scope and the account
|
||||
* switch decide it, exactly as they did before this existed.
|
||||
*
|
||||
* That asymmetry is the whole design. Every skill this product ships is
|
||||
* conversation-only and claimed for conversation; the definitions that draw
|
||||
* page UI are authored on the account and claimed by nobody. Enforcing
|
||||
* ownership on all of them would have removed every skill section in the
|
||||
* product on the day it shipped. Attaching a skill to an agent is therefore
|
||||
* the act that brings it under an agent's control — a decision an author
|
||||
* makes in Agent Configure, not one taken on their behalf here.
|
||||
*
|
||||
* Three separate ideas meet here and none of them is the others: what the
|
||||
* *agent* owns, what pages the *skill* declares, and what the *account* has
|
||||
* switched off. All three must pass.
|
||||
*/
|
||||
/* The rule itself lives with the other agent rules, so it can be reasoned
|
||||
about and tested without rendering a page. */
|
||||
const scope = { agents: JSON.parse(ownedKey).map((skills) => ({ skills })), agent: { skills: JSON.parse(mineKey) } };
|
||||
|
||||
return allSkills(custom)
|
||||
/* Inactive means registered but not offered — the same rule the assistant
|
||||
follows, so switching a skill off removes its UI too. */
|
||||
.filter((skill) => skill.status === 'active' && !disabled.includes(skill.id))
|
||||
.filter((skill) => agentPermitsSkill(skill.id, scope))
|
||||
.flatMap((skill) => sectionsForPage(skill, page)
|
||||
.filter((section) => !placement || section.placement === placement)
|
||||
.map((section) => ({ skill, section })));
|
||||
}, [page, placement, customKey, disabledKey]);
|
||||
}, [page, placement, customKey, disabledKey, ownedKey, mineKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
251
src/components/ui-editor/NodeInspector.jsx
Normal file
251
src/components/ui-editor/NodeInspector.jsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import React from 'react';
|
||||
import { ArrowDown, ArrowUp, Eye, EyeOff, Replace, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ds';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ALIGN_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, SPACING_VALUES,
|
||||
} from '@/lib/ui/node';
|
||||
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
import { kindOfBinding } from '@/lib/ui/series';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
import { hideOp, layoutOp, nudgeOp, presentationOp, propOp, removeOp, replaceOp } from './ops';
|
||||
|
||||
/**
|
||||
* Everything a person may change about the selected node.
|
||||
*
|
||||
* Every control below is *derived*: the properties come from the type's own
|
||||
* `propSchema`, the buttons from its declared `capabilities`, the replacement
|
||||
* options from `registry.replacements`, and the layout values from the closed
|
||||
* vocabulary in `lib/ui/node.js`. Nothing is listed by hand, which is what makes
|
||||
* a type registered tomorrow editable tomorrow — and what makes it impossible
|
||||
* for this panel to offer something the validator would refuse.
|
||||
*
|
||||
* There is no free-text style field anywhere, by construction. A person can set
|
||||
* a title, pick from an enum, choose a column count — and there is no control
|
||||
* that accepts a class name, a style, or markup, because no `propSchema`
|
||||
* declares one and the layout vocabulary is four fixed steps.
|
||||
*/
|
||||
|
||||
const field = 'w-full rounded-lg border border-border bg-surface px-2 py-1.5 text-body-sm text-ink-1 outline-none focus:border-krow-blue';
|
||||
const labelled = 'block text-[10px] font-semibold uppercase tracking-wide text-ink-4';
|
||||
|
||||
export function NodeInspector({ node, siblings, page = null, onOperate, registry = nodeRegistry }) {
|
||||
const entry = registry.get(node.type);
|
||||
if (!entry) {
|
||||
return <p className="text-caption text-ink-3">This node’s type is no longer available.</p>;
|
||||
}
|
||||
|
||||
const can = (capability) => entry.capabilities.includes(capability) && !node.locked;
|
||||
const index = siblings.findIndex((s) => s.id === node.id);
|
||||
|
||||
/** Reorder this node's own container by moving it one step. */
|
||||
const nudge = (delta) => {
|
||||
const op = nudgeOp(node, siblings, delta);
|
||||
if (op) onOperate(op);
|
||||
};
|
||||
|
||||
/**
|
||||
* What this node could be shown as.
|
||||
*
|
||||
* The same five facts the conversation passes, in the same order, to the same
|
||||
* registry call — the editor holds no compatibility rule of its own, so a
|
||||
* type in this list is exactly a type "show this as a …" would accept. Two
|
||||
* readings of the rule is how a picker comes to offer something the operation
|
||||
* then refuses.
|
||||
*/
|
||||
const shapes = node.data?.source ? dataSourceFor(node.data.source)?.shapes || [] : null;
|
||||
const replacements = can('replace')
|
||||
? registry.replacements(node.type, {
|
||||
shapes,
|
||||
page,
|
||||
seriesKind: kindOfBinding(node.data),
|
||||
seriesBound: Boolean(node.data?.series),
|
||||
bound: Boolean(node.data),
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">{node.title || node.label}</p>
|
||||
<p className="font-mono text-[10px] text-ink-4">{node.id} · {node.type} · {node.origin}</p>
|
||||
</header>
|
||||
|
||||
{/* ── What may be done at all ─────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{can('hide') && (
|
||||
<Button
|
||||
size="xs" variant="outline" shape="rounded"
|
||||
onClick={() => onOperate(hideOp(node))}
|
||||
>
|
||||
{node.hidden
|
||||
? <><Eye className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Show</>
|
||||
: <><EyeOff className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Hide</>}
|
||||
</Button>
|
||||
)}
|
||||
{can('move') && (
|
||||
<>
|
||||
<Button size="xs" variant="outline" shape="rounded" disabled={index <= 0}
|
||||
onClick={() => nudge(-1)} aria-label="Move up">
|
||||
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button size="xs" variant="outline" shape="rounded" disabled={index < 0 || index >= siblings.length - 1}
|
||||
onClick={() => nudge(1)} aria-label="Move down">
|
||||
<ArrowDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{can('remove') && (
|
||||
<Button size="xs" variant="outline" shape="rounded"
|
||||
onClick={() => onOperate(removeOp(node))}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Turn it into something else ─────────────────────────────────── */}
|
||||
{replacements.length > 0 && (
|
||||
<label className="block space-y-1">
|
||||
<span className={labelled}>
|
||||
<Replace className="mr-1 inline h-3 w-3" aria-hidden="true" />Show as
|
||||
</span>
|
||||
<select
|
||||
className={field}
|
||||
value=""
|
||||
onChange={(e) => e.target.value && onOperate(replaceOp(node, e.target.value))}
|
||||
>
|
||||
<option value="">Keep {entry.label}</option>
|
||||
{replacements.map((type) => (
|
||||
<option key={type} value={type}>{registry.get(type)?.label || type}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* ── Properties, exactly as the type declared them ───────────────── */}
|
||||
{can('update') && node.editable.length > 0 && (
|
||||
<div className="space-y-2.5">
|
||||
{node.editable.map((prop) => (
|
||||
<label key={prop.key} className="block space-y-1">
|
||||
<span className={labelled}>{prop.label}{prop.required ? ' *' : ''}</span>
|
||||
|
||||
{prop.kind === 'enum' && (
|
||||
<select
|
||||
className={field}
|
||||
value={prop.value ?? ''}
|
||||
onChange={(e) => onOperate(propOp(node, prop.key, e.target.value))}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{prop.options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{prop.kind === 'boolean' && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(prop.value)}
|
||||
onChange={(e) => onOperate(propOp(node, prop.key, e.target.checked || null))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{prop.kind === 'number' && (
|
||||
<input
|
||||
type="number" className={field} defaultValue={prop.value ?? ''}
|
||||
onBlur={(e) => onOperate(propOp(node, prop.key, e.target.value === '' ? null : Number(e.target.value)))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{prop.kind === 'string' && (
|
||||
<input
|
||||
type="text" className={field} defaultValue={prop.value ?? ''}
|
||||
onBlur={(e) => onOperate(propOp(node, prop.key, e.target.value))}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── What it is reading ──────────────────────────────────────────── */}
|
||||
{node.data && (
|
||||
<div className="space-y-1">
|
||||
<span className={labelled}>Reading</span>
|
||||
<p className="rounded-lg border border-border bg-surface-subtle px-2 py-1.5 text-body-sm text-ink-2">
|
||||
{dataSourceLabel(node.data.source)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── How it looks ────────────────────────────────────────────────
|
||||
Offered only where the type says it can draw them, so a picker never
|
||||
shows a setting the component would ignore. */}
|
||||
{can('update') && (node.variants.length > 0 || node.densities.length > 0) && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{node.variants.length > 0 && (
|
||||
<Choice
|
||||
label="Style" value={node.presentation.variant} options={node.variants}
|
||||
onPick={(v) => onOperate(presentationOp(node, 'variant', v))}
|
||||
/>
|
||||
)}
|
||||
{node.densities.length > 0 && (
|
||||
<Choice
|
||||
label="Density" value={node.presentation.density} options={node.densities}
|
||||
onPick={(v) => onOperate(presentationOp(node, 'density', v))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Layout: four closed vocabularies, nothing typed ─────────────── */}
|
||||
{can('update') && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Choice
|
||||
label="Columns" value={node.layout.columns}
|
||||
options={range(entry.constraints.minColumns ?? MIN_COLUMNS, entry.constraints.maxColumns ?? MAX_COLUMNS)}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'columns', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Span" value={node.layout.span} options={range(MIN_COLUMNS, MAX_COLUMNS)}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'span', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Gap" value={node.layout.gap} options={GAP_VALUES}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'gap', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Align" value={node.layout.align} options={ALIGN_VALUES}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'align', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Space above" value={node.layout.spacingBefore} options={SPACING_VALUES}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'spacingBefore', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Space below" value={node.layout.spacingAfter} options={SPACING_VALUES}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'spacingAfter', v))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One closed-vocabulary picker. Empty means "leave it to the page". */
|
||||
function Choice({ label, value, options, onPick }) {
|
||||
return (
|
||||
<label className="block space-y-1">
|
||||
<span className={labelled}>{label}</span>
|
||||
<select
|
||||
className={cn(field, 'text-caption')}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onPick(e.target.value === '' ? null : coerce(e.target.value))}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const range = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
||||
const coerce = (value) => (/^\d+$/.test(value) ? Number(value) : value);
|
||||
90
src/components/ui-editor/NodePicker.jsx
Normal file
90
src/components/ui-editor/NodePicker.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ds';
|
||||
import { addableTypes } from '@/lib/ui/inspect';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
import { dataSourceLabel, placementProvides, sourcesForShape } from '@/lib/skills/surfaces';
|
||||
import { addOp } from './ops';
|
||||
|
||||
/**
|
||||
* What can be added here, and what it would show.
|
||||
*
|
||||
* Both lists are answers to questions the product already knows how to answer:
|
||||
* `addableTypes` asks the registry what this container accepts and what this
|
||||
* role may use, and `sourcesForShape` asks the closed data vocabulary which
|
||||
* readings can fill that shape. Nothing is offered that validation would then
|
||||
* refuse, and nothing can be typed — a source is chosen from what exists, which
|
||||
* is what makes an invented metric impossible to compose here.
|
||||
*/
|
||||
/** A property's value on a described node, read the way the inspector reads it. */
|
||||
const propOf = (node, key) => node?.editable?.find((prop) => prop.key === key)?.value || null;
|
||||
|
||||
export function NodePicker({ tree, parent, page = null, role = null, onAdd, registry = nodeRegistry }) {
|
||||
const [type, setType] = React.useState('');
|
||||
const [source, setSource] = React.useState('');
|
||||
|
||||
const options = addableTypes(parent?.type ?? null, { registry, role, page });
|
||||
const chosen = options.find((option) => option.type === type) || null;
|
||||
|
||||
/**
|
||||
* What this location can actually answer.
|
||||
*
|
||||
* Every reading in the vocabulary was offered here, including the ones that
|
||||
* need a record — so a Card bound to "Position activity" could be added to a
|
||||
* page that has no position, and rendered "This section needs a position to
|
||||
* read." forever. Offered, accepted, saved, and dead.
|
||||
*
|
||||
* `provides` on the surface already records which context each placement
|
||||
* supplies, and `sourcesForShape` already filters on it. Only the question
|
||||
* was missing. A node at the page root is inside no record, so it is offered
|
||||
* the readings that need none.
|
||||
*/
|
||||
const slot = propOf(parent, 'placement');
|
||||
const provided = slot ? placementProvides(propOf(parent, 'page') || page, slot) : [];
|
||||
const sources = chosen?.dataShapes.length
|
||||
? sourcesForShape(chosen.dataShapes[0], { context: provided }).map((s) => s.id)
|
||||
: [];
|
||||
|
||||
/* A type that reads data cannot be added until a reading is picked. The
|
||||
button says so by staying disabled rather than by failing on submit. */
|
||||
const ready = Boolean(chosen) && (!chosen.dataRequired || Boolean(source));
|
||||
|
||||
const add = () => {
|
||||
if (!ready) return;
|
||||
onAdd(addOp(tree, parent, chosen.type, source || null));
|
||||
setType('');
|
||||
setSource('');
|
||||
};
|
||||
|
||||
if (!options.length) {
|
||||
return <p className="text-caption text-ink-4">Nothing can be added here.</p>;
|
||||
}
|
||||
|
||||
const field = 'w-full rounded-lg border border-border bg-surface px-2 py-1.5 text-body-sm text-ink-1 outline-none focus:border-krow-blue';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
Add {parent ? `inside ${parent.title || parent.label}` : 'to the page'}
|
||||
</p>
|
||||
|
||||
<select className={field} value={type} onChange={(e) => { setType(e.target.value); setSource(''); }}>
|
||||
<option value="">Choose a panel…</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.type} value={option.type}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{chosen?.dataShapes.length > 0 && (
|
||||
<select className={field} value={source} onChange={(e) => setSource(e.target.value)}>
|
||||
<option value="">What should it show?</option>
|
||||
{sources.map((id) => <option key={id} value={id}>{dataSourceLabel(id)}</option>)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<Button size="xs" shape="rounded" disabled={!ready} onClick={add}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Add
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/components/ui-editor/TreePanel.jsx
Normal file
87
src/components/ui-editor/TreePanel.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { Eye, EyeOff, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { inspectTree } from '@/lib/ui/inspect';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
|
||||
/**
|
||||
* The page, as an outline.
|
||||
*
|
||||
* Everything shown here comes from `inspectTree` — the same inventory Owliver
|
||||
* reads before it resolves "the recent hiring timeline". There is no second
|
||||
* description of a page anywhere: if the editor can see a node, so can the
|
||||
* conversation, and vice versa.
|
||||
*
|
||||
* **Hidden nodes are listed.** A hidden node is still in the tree and still
|
||||
* addressable; the renderer skips it and nothing else does. Leaving it out here
|
||||
* would make the editor the one place a person could not undo a hide.
|
||||
*/
|
||||
export function TreePanel({ tree, selectedId, onSelect, registry = nodeRegistry }) {
|
||||
const nodes = inspectTree(tree, { registry });
|
||||
|
||||
if (!nodes.length) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-caption text-ink-3">
|
||||
This page has no addressable sections yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* Depth from parentage rather than from a nested walk, so the flat inventory
|
||||
stays the one source and this only decides indentation. */
|
||||
const depthOf = (node) => {
|
||||
let depth = 0;
|
||||
let cursor = node;
|
||||
while (cursor?.parent) {
|
||||
cursor = nodes.find((n) => n.id === cursor.parent);
|
||||
depth += 1;
|
||||
}
|
||||
return depth;
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||||
{nodes.map((node) => {
|
||||
const selected = node.id === selectedId;
|
||||
return (
|
||||
<li key={node.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node.id)}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-left transition-colors',
|
||||
selected ? 'bg-krow-blue-tint' : 'hover:bg-surface-subtle'
|
||||
)}
|
||||
>
|
||||
<span style={{ paddingLeft: `${depthOf(node) * 14}px` }} className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className={cn('truncate text-body-sm', node.hidden ? 'text-ink-4' : 'text-ink-1')}>
|
||||
{node.title || node.label}
|
||||
</span>
|
||||
|
||||
{/* Provenance. A person needs to know which panels came from a
|
||||
definition they could switch off, and which are the page. */}
|
||||
{node.origin === 'skill' && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-krow-blue-tint px-1.5 py-0.5 text-[10px] font-semibold text-krow-blue">
|
||||
<Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
Skill
|
||||
</span>
|
||||
)}
|
||||
{node.origin === 'user' && (
|
||||
<span className="shrink-0 rounded-full bg-surface-sunken px-1.5 py-0.5 text-[10px] font-semibold text-ink-3">
|
||||
Added
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 font-mono text-[10px] text-ink-4">{node.type}</span>
|
||||
{node.hidden
|
||||
? <EyeOff className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-label="Hidden" />
|
||||
: <Eye className="h-3.5 w-3.5 shrink-0 text-ink-4/40" aria-hidden="true" />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
146
src/components/ui-editor/UiEditor.jsx
Normal file
146
src/components/ui-editor/UiEditor.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { RotateCcw, SlidersHorizontal, Undo2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ds';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { inspectTree } from '@/lib/ui/inspect';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
import { TreePanel } from './TreePanel';
|
||||
import { NodeInspector } from './NodeInspector';
|
||||
import { NodePicker } from './NodePicker';
|
||||
|
||||
/**
|
||||
* The visual editor.
|
||||
*
|
||||
* It is a *client* of the UI system, not a second implementation of it. Every
|
||||
* control it draws ends in one call — `propose(op)` on the editing session —
|
||||
* with an operation object of exactly the shape Owliver produces for the same
|
||||
* change. From there the two are indistinguishable: same validation, same
|
||||
* preview merge, same Apply, same `preferences.uiLayouts`.
|
||||
*
|
||||
* That is the whole architecture:
|
||||
*
|
||||
* editor / Owliver → operation → propose → validate → preview
|
||||
* ↓ Apply
|
||||
* preferences.uiLayouts
|
||||
*
|
||||
* There is no page in this file, no component name, and no branch on what a
|
||||
* node is. What can be done to the selected node comes from its registration;
|
||||
* what can be added comes from the registry and the closed data vocabulary;
|
||||
* what it is showing comes from the tree. A page that migrates tomorrow is
|
||||
* editable tomorrow with nothing here changed.
|
||||
*/
|
||||
export function UiEditor({ registry = nodeRegistry }) {
|
||||
const editing = useUiEditing();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [selectedId, setSelectedId] = React.useState(null);
|
||||
|
||||
/* Rendered only where a page has opted into composition. A page that has not
|
||||
is not broken; it simply has nothing to arrange. */
|
||||
if (!editing) return null;
|
||||
|
||||
const {
|
||||
tree, propose, discard, apply, undo, reset,
|
||||
previewing, customised, saving, problems, skipped,
|
||||
/* The page this session belongs to. Handed to the picker so what can be
|
||||
added here is decided by the registry rather than by the picker being
|
||||
shown everything that exists. */
|
||||
page,
|
||||
} = editing;
|
||||
|
||||
const nodes = inspectTree(tree, { registry });
|
||||
const selected = nodes.find((node) => node.id === selectedId) || null;
|
||||
/* A node's own container, for the reorder buttons and for the picker. */
|
||||
const siblings = selected ? nodes.filter((node) => node.parent === selected.parent) : [];
|
||||
const parent = selected?.container ? selected : nodes.find((n) => n.id === selected?.parent) || null;
|
||||
|
||||
/**
|
||||
* The one door out of this component.
|
||||
*
|
||||
* Everything the panels do arrives here as an operation and goes straight to
|
||||
* the session. Nothing is applied, nothing is stored, and nothing is
|
||||
* validated locally — `propose` refuses what cannot be kept and the refusal
|
||||
* is shown below.
|
||||
*/
|
||||
const operate = (op) => {
|
||||
const result = propose(op);
|
||||
/* A removed node cannot stay selected; a replaced one keeps its id. */
|
||||
if (result.ok && op.op === 'remove') setSelectedId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-ui-controls="editor" className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={open ? 'default' : 'outline'}
|
||||
shape="rounded"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
|
||||
Customise layout
|
||||
</Button>
|
||||
|
||||
{/* The preview bar. Unsaved and saved have to be told apart at a
|
||||
glance, because the whole promise is that nothing is kept until
|
||||
somebody says so. */}
|
||||
{(previewing || customised) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{previewing && (
|
||||
<span className="rounded-full bg-krow-blue-tint px-2 py-0.5 text-caption font-semibold text-krow-blue">
|
||||
Previewing — not saved yet
|
||||
</span>
|
||||
)}
|
||||
{!previewing && customised && (
|
||||
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold text-ink-3">
|
||||
Saved layout
|
||||
</span>
|
||||
)}
|
||||
{previewing && (
|
||||
<>
|
||||
<Button size="xs" shape="rounded" loading={saving} onClick={apply}>Apply</Button>
|
||||
<Button size="xs" variant="outline" shape="rounded" onClick={discard}>Discard</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="xs" variant="ghost" shape="rounded" onClick={undo}>
|
||||
<Undo2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Undo
|
||||
</Button>
|
||||
{customised && (
|
||||
<Button size="xs" variant="ghost" shape="rounded" onClick={reset}>
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{problems.length > 0 && (
|
||||
<p className="text-caption text-destructive">{problems[0].message}</p>
|
||||
)}
|
||||
|
||||
{skipped.length > 0 && (
|
||||
<p className="text-caption text-ink-4">
|
||||
{skipped.length} saved change{skipped.length === 1 ? '' : 's'} no longer apply to this page.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="grid gap-3 rounded-xl border border-border bg-surface-subtle p-3 lg:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<TreePanel tree={tree} selectedId={selectedId} onSelect={setSelectedId} registry={registry} />
|
||||
<NodePicker tree={tree} parent={parent} page={page} onAdd={operate} registry={registry} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-3">
|
||||
{selected
|
||||
? <NodeInspector node={selected} siblings={siblings} page={page} onOperate={operate} registry={registry} />
|
||||
: (
|
||||
<p className="text-caption text-ink-3">
|
||||
Choose a section on the left to see what can be changed about it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/ui-editor/ops.js
Normal file
99
src/components/ui-editor/ops.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { freeNodeId } from '@/lib/ui/node';
|
||||
import { dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
|
||||
/**
|
||||
* The operations the editor's controls stand for.
|
||||
*
|
||||
* Pure, and separate from the components, for one reason: it is the claim that
|
||||
* the editor and Owliver do the same thing, and a claim buried inside a click
|
||||
* handler cannot be checked. Here it can — `hideOp(node)` and the operation
|
||||
* `matchUiEdit('hide the recent hiring timeline')` produces are the same object,
|
||||
* and a test says so.
|
||||
*
|
||||
* Nothing here validates or applies. Every one of these is handed to
|
||||
* `propose()` on the editing session, which is the single gate both surfaces
|
||||
* pass through.
|
||||
*/
|
||||
|
||||
/** Hide a visible node, or show a hidden one. */
|
||||
export const hideOp = (node) => ({ op: 'hide', target: node.id, hidden: !node.hidden });
|
||||
|
||||
/** Set a node's hidden state explicitly, which is what a conversation says. */
|
||||
export const visibilityOp = (node, hidden) => ({ op: 'hide', target: node.id, hidden });
|
||||
|
||||
/**
|
||||
* Move a node one place within its own container.
|
||||
*
|
||||
* A reorder of the whole sibling list rather than a `move`, because that is
|
||||
* what the button means: nothing changes parent, the order around it changes.
|
||||
* The same shape Owliver produces for "move the timeline above the notice".
|
||||
*/
|
||||
export function nudgeOp(node, siblings, delta) {
|
||||
const order = siblings.map((s) => s.id);
|
||||
const from = order.indexOf(node.id);
|
||||
const to = from + delta;
|
||||
if (from < 0 || to < 0 || to >= order.length) return null;
|
||||
order.splice(to, 0, ...order.splice(from, 1));
|
||||
return { op: 'reorder', parent: node.parent ?? null, order };
|
||||
}
|
||||
|
||||
/** Put a node at an explicit index among its siblings. */
|
||||
export function reorderOp(node, siblings, index) {
|
||||
const order = siblings.map((s) => s.id);
|
||||
const from = order.indexOf(node.id);
|
||||
if (from < 0 || index < 0 || index >= order.length) return null;
|
||||
order.splice(index, 0, ...order.splice(from, 1));
|
||||
return { op: 'reorder', parent: node.parent ?? null, order };
|
||||
}
|
||||
|
||||
/** Turn a node into another registered type. */
|
||||
export const replaceOp = (node, type) => ({ op: 'replace', target: node.id, type });
|
||||
|
||||
/** Remove a node the person added. */
|
||||
export const removeOp = (node) => ({ op: 'remove', target: node.id });
|
||||
|
||||
/**
|
||||
* Change one declared property.
|
||||
*
|
||||
* An empty value becomes `null`, which the engine reads as "unset" — the only
|
||||
* way to clear a property, and the reason a blank field is not the same as a
|
||||
* field nobody touched.
|
||||
*/
|
||||
export const propOp = (node, key, value) => ({
|
||||
op: 'update', target: node.id, props: { [key]: value === '' || value === undefined ? null : value },
|
||||
});
|
||||
|
||||
/** Change one layout value, from the closed vocabulary. */
|
||||
export const layoutOp = (node, key, value) => ({
|
||||
op: 'update', target: node.id, layout: { [key]: value === '' || value === undefined ? null : value },
|
||||
});
|
||||
|
||||
/**
|
||||
* Change how a node presents itself, from the closed vocabulary.
|
||||
*
|
||||
* The same `update` operation everything else here produces — the editor has no
|
||||
* mutation of its own, and this is the shape Owliver emits for the same words.
|
||||
*/
|
||||
export const presentationOp = (node, key, value) => ({
|
||||
op: 'update',
|
||||
target: node.id,
|
||||
presentation: { [key]: value === '' || value === undefined ? null : value },
|
||||
});
|
||||
|
||||
/**
|
||||
* Add a node of a registered type, optionally bound to a reading.
|
||||
*
|
||||
* The id is generated by the shared helper, so a node added here and a node
|
||||
* added by Owliver get their addresses from one rule.
|
||||
*/
|
||||
export function addOp(tree, parent, type, source = null) {
|
||||
return {
|
||||
op: 'add',
|
||||
parent: parent?.id ?? null,
|
||||
node: {
|
||||
id: freeNodeId(tree, type),
|
||||
type,
|
||||
...(source ? { data: { source }, props: { title: dataSourceLabel(source) } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
208
src/components/ui-tree/UiEditingProvider.jsx
Normal file
208
src/components/ui-tree/UiEditingProvider.jsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
import { useUiLayouts } from '@/lib/krowHooks';
|
||||
import { useSkillSections } from '@/components/skills/SkillSurface';
|
||||
import { skillNodesByPlacement } from '@/lib/ui/skillNodes';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { applyOperation } from '@/lib/ui/operations';
|
||||
import { clearOps, emptyPatch, popOp, pushOp } from '@/lib/ui/patch';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
|
||||
/**
|
||||
* Preview, Apply, and the line between them.
|
||||
*
|
||||
* Three states of a UI change live here, and keeping them apart is the whole
|
||||
* job of this module:
|
||||
*
|
||||
* - **Preview** is in React state. It is what the person is trying. It never
|
||||
* reaches the network, and a reload discards it.
|
||||
* - **Saved** is the person's own stored operation list, read from and
|
||||
* written to their account preferences. It survives a reload and a new
|
||||
* session.
|
||||
* - **Source** — the page's registered composition and the Markdown skill
|
||||
* files — is never written by anything here. Not on preview, not on apply.
|
||||
*
|
||||
* A proposed operation is validated *before* it becomes a preview, so a change
|
||||
* that could not be saved is never shown as though it could. Preview and apply
|
||||
* then run the identical merge through `composePage` and the identical
|
||||
* renderer, which is what makes a preview honest: there is no second code path
|
||||
* for the applied state that could disagree with it.
|
||||
*/
|
||||
|
||||
const UiEditingContext = React.createContext(null);
|
||||
|
||||
/** The editing session for the page around it. Null outside a provider. */
|
||||
export const useUiEditing = () => React.useContext(UiEditingContext);
|
||||
|
||||
/**
|
||||
* The node an operation is about, or null.
|
||||
*
|
||||
* Read from the operation's own shape rather than from a table of operation
|
||||
* names with special cases: every operation that concerns one node names it in
|
||||
* `target`, and `add` names the node it is creating. `reorder` concerns a whole
|
||||
* list and has no single subject, so it leaves the previous one standing —
|
||||
* which is right: reordering the page does not change what "it" means.
|
||||
*/
|
||||
function subjectOf(op) {
|
||||
const named = String(op?.target ?? op?.node?.id ?? '').trim();
|
||||
return named || null;
|
||||
}
|
||||
|
||||
export function UiEditingProvider({ page, role = null, registry = nodeRegistry, children }) {
|
||||
const { layouts, save, saving } = useUiLayouts();
|
||||
|
||||
/**
|
||||
* What the person is trying, not yet theirs.
|
||||
*
|
||||
* Held per page so that navigating away and back does not carry an
|
||||
* unfinished experiment onto a different surface.
|
||||
*/
|
||||
const [preview, setPreview] = React.useState(() => emptyPatch(page));
|
||||
const [problems, setProblems] = React.useState([]);
|
||||
/**
|
||||
* The subject of the conversation, as a node id.
|
||||
*
|
||||
* What makes "change it back to a line chart" answerable. Recorded from the
|
||||
* operation that stood, so it is always a node that really exists and really
|
||||
* changed — never a guess, and never something the model supplied.
|
||||
*/
|
||||
const [focus, setFocus] = React.useState(null);
|
||||
|
||||
/* A page change is a new editing session. Anything unsaved was about the page
|
||||
that is no longer on screen. */
|
||||
React.useEffect(() => {
|
||||
setPreview(emptyPatch(page));
|
||||
setProblems([]);
|
||||
}, [page]);
|
||||
|
||||
const saved = layouts[page] || emptyPatch(page);
|
||||
|
||||
/**
|
||||
* What this page's definitions contribute, as nodes.
|
||||
*
|
||||
* Resolved here because it needs the account's custom skills and disabled
|
||||
* list, and handed to `composePage` so that module stays a pure function.
|
||||
* The result is that a Board skill's card is a node in the same tree as the
|
||||
* page's own sections — addressable by the same operations, and hidden or
|
||||
* moved by a patch rather than by editing the definition.
|
||||
*/
|
||||
const sections = useSkillSections(page);
|
||||
const skillNodes = React.useMemo(() => skillNodesByPlacement(sections), [sections]);
|
||||
|
||||
/**
|
||||
* The tree on screen: what the application ships, with what the person saved,
|
||||
* with what they are trying, in that order.
|
||||
*/
|
||||
const composed = React.useMemo(
|
||||
() => composePage(page, { patch: saved, preview, registry, role, skillNodes }),
|
||||
[page, saved, preview, registry, role, skillNodes]
|
||||
);
|
||||
|
||||
/**
|
||||
* Try an operation.
|
||||
*
|
||||
* Validated against the tree as it currently stands — saved changes included
|
||||
* — so an operation is judged against what the person is actually looking at.
|
||||
* A refusal returns the reasons and changes nothing; there is no partially
|
||||
* applied preview.
|
||||
*/
|
||||
const propose = React.useCallback((op) => {
|
||||
const result = applyOperation(composed.tree, op, { registry, role });
|
||||
if (!result.ok) {
|
||||
setProblems(result.problems);
|
||||
return result;
|
||||
}
|
||||
setProblems([]);
|
||||
setPreview((current) => pushOp(current, op));
|
||||
/* Only once the operation stood. A refused change never becomes the thing
|
||||
"it" refers to. */
|
||||
const named = subjectOf(op);
|
||||
if (named) setFocus(named);
|
||||
return result;
|
||||
}, [composed.tree, registry, role]);
|
||||
|
||||
/** Throw the experiment away. Nothing was stored, so nothing is undone. */
|
||||
const discard = React.useCallback(() => {
|
||||
setPreview(emptyPatch(page));
|
||||
setProblems([]);
|
||||
setFocus(null);
|
||||
}, [page]);
|
||||
|
||||
/**
|
||||
* Keep it.
|
||||
*
|
||||
* The preview's operations are appended to what was already saved and written
|
||||
* as one list. The preview is only cleared once the write resolves, so a
|
||||
* failed save leaves the person looking at the change they asked for rather
|
||||
* than watching it disappear with an error beside it.
|
||||
*/
|
||||
const apply = React.useCallback(async () => {
|
||||
if (!preview.ops.length) return { ok: true, saved };
|
||||
const next = { ...saved, page, ops: [...saved.ops, ...preview.ops], updatedAt: new Date().toISOString() };
|
||||
const result = await save(page, next);
|
||||
if (result?.persisted === false) {
|
||||
setProblems([{ at: null, message: result.error || 'That change could not be saved.' }]);
|
||||
return { ok: false, saved };
|
||||
}
|
||||
setPreview(emptyPatch(page));
|
||||
return { ok: true, saved: next };
|
||||
}, [preview, saved, page, save]);
|
||||
|
||||
/**
|
||||
* Undo one step.
|
||||
*
|
||||
* The most recent thing first: an unsaved operation if there is one, and only
|
||||
* then a saved one. Undoing a saved change is a write, because the saved list
|
||||
* is the record of what the person chose.
|
||||
*/
|
||||
const undo = React.useCallback(async () => {
|
||||
if (preview.ops.length) {
|
||||
setPreview((current) => popOp(current));
|
||||
return { ok: true };
|
||||
}
|
||||
if (!saved.ops.length) return { ok: true };
|
||||
await save(page, popOp(saved));
|
||||
return { ok: true };
|
||||
}, [preview, saved, page, save]);
|
||||
|
||||
/** Back to the page as the application ships it. Clears both tiers. */
|
||||
const reset = React.useCallback(async () => {
|
||||
setPreview(emptyPatch(page));
|
||||
setProblems([]);
|
||||
setFocus(null);
|
||||
if (saved.ops.length) await save(page, clearOps(saved));
|
||||
return { ok: true };
|
||||
}, [page, saved, save]);
|
||||
|
||||
const value = React.useMemo(() => ({
|
||||
page,
|
||||
tree: composed.tree,
|
||||
/* Operations that no longer apply — a saved change naming a section a
|
||||
release has since removed. Surfaced so a page can say so quietly rather
|
||||
than leaving the person wondering why nothing happened. */
|
||||
skipped: composed.skipped,
|
||||
saved,
|
||||
preview,
|
||||
problems,
|
||||
previewing: preview.ops.length > 0,
|
||||
/**
|
||||
* The node this session last changed, and therefore what "it" means.
|
||||
*
|
||||
* Session state, not stored state: it is a fact about the conversation
|
||||
* rather than about the layout, so it is deliberately not persisted and is
|
||||
* gone on reload. Kept across an Apply — applying does not end the subject —
|
||||
* and cleared by Discard and Reset, which do.
|
||||
*/
|
||||
focus,
|
||||
customised: saved.ops.length > 0,
|
||||
saving,
|
||||
propose,
|
||||
discard,
|
||||
apply,
|
||||
undo,
|
||||
reset,
|
||||
}), [
|
||||
page, composed, saved, preview, problems, saving, focus, propose, discard, apply, undo, reset,
|
||||
]);
|
||||
|
||||
return <UiEditingContext.Provider value={value}>{children}</UiEditingContext.Provider>;
|
||||
}
|
||||
76
src/components/ui-tree/UiNodeBoundary.jsx
Normal file
76
src/components/ui-tree/UiNodeBoundary.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* One node's blast radius.
|
||||
*
|
||||
* A section is a component like any other, and a component can throw. Without
|
||||
* this, one that did took the entire application down: adding Hired History's
|
||||
* chronology to Candidates Analysis — which the picker offered, and which the
|
||||
* registry now refuses — left the section destructuring `hires` and `filtered`
|
||||
* from a render context that publishes neither, and `undefined.length` unmounted
|
||||
* the whole tree to a white screen with no way back but a reload.
|
||||
*
|
||||
* The scope fix means that particular node can no longer be placed there. This
|
||||
* exists because that was never the only way to get here: a page can rename
|
||||
* what it publishes, a release can change a section's data shape, and a saved
|
||||
* layout is replayed months after it was made. A layout a person saved must
|
||||
* never be able to cost them the page.
|
||||
*
|
||||
* So a node that throws renders as a node that could not be drawn — in place,
|
||||
* named, and still in the tree, so the editor can still select it and the
|
||||
* conversation can still hide or remove it. Everything around it keeps working.
|
||||
*
|
||||
* Deliberately not a retry: the same props render the same failure, and a
|
||||
* boundary that re-throws in a loop is worse than one that stops. It resets when
|
||||
* the node it is holding changes, which is what makes removing the broken node
|
||||
* put the page right without a reload.
|
||||
*/
|
||||
export class UiNodeBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { failed: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset when the node changes.
|
||||
*
|
||||
* Without this, hiding or removing a failed node would leave the boundary
|
||||
* latched and the placeholder on screen — the fix applied, and invisible.
|
||||
*/
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (state.failed && state.forNode !== props.node?.id) return { failed: false, forNode: props.node?.id };
|
||||
return state.forNode === props.node?.id ? null : { ...state, forNode: props.node?.id };
|
||||
}
|
||||
|
||||
componentDidCatch(error) {
|
||||
/* The node, not just the stack: the stack names React, and what a person
|
||||
debugging this needs is which section and which page. */
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[ui] node "${this.props.node?.id}" (${this.props.node?.type}) failed to render`, error);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.failed) return this.props.children;
|
||||
|
||||
const { node } = this.props;
|
||||
return (
|
||||
<section
|
||||
data-ui-node={node?.id}
|
||||
data-ui-type={node?.type}
|
||||
data-ui-failed="true"
|
||||
className="rounded-2xl border border-dashed border-border bg-surface-subtle p-4"
|
||||
>
|
||||
<p className="text-body-sm font-semibold text-ink-2">
|
||||
{node?.props?.title || 'This section could not be shown'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-caption text-ink-4">
|
||||
It is still on the page and can be hidden or removed from Customise layout.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
308
src/components/ui-tree/UiTreeRenderer.jsx
Normal file
308
src/components/ui-tree/UiTreeRenderer.jsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
import { UiNodeBoundary } from './UiNodeBoundary';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { useUiEditing } from './UiEditingProvider';
|
||||
|
||||
/**
|
||||
* Draws a UI tree.
|
||||
*
|
||||
* One recursive renderer for every node type there will ever be. It looks a
|
||||
* type up in the registry and renders the component that registration named —
|
||||
* and that is the whole of its knowledge. There is no branch on a type here, no
|
||||
* component name held as a string, no dynamic import, and no path by which
|
||||
* configuration becomes code. A node names a key; the key was registered by a
|
||||
* module the bundler resolved at build time; anything else renders nothing.
|
||||
*
|
||||
* That is what makes agent-authored UI safe. The worst a malformed or hostile
|
||||
* configuration can do is name a type that does not exist, and the answer to
|
||||
* that is an empty space — never an evaluated string, never injected markup.
|
||||
*
|
||||
* **It adds no markup of its own.** A node's component renders its own root and
|
||||
* receives `attrs` to spread onto it, so a migrated page emits the elements it
|
||||
* always did. The one exception is a type that declares `wrap`, for components
|
||||
* that cannot forward unknown props; those get a bare `div` whose only purpose
|
||||
* is to carry the node's identity.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What a page hands its own sections.
|
||||
*
|
||||
* A page's built-in nodes need the page's own state — its filtered rows, its
|
||||
* loading flag, its handlers — and threading that through the tree as props
|
||||
* would make the renderer know what a page contains. So the page publishes one
|
||||
* opaque bag and its sections read what they need out of it. The renderer never
|
||||
* looks inside.
|
||||
*
|
||||
* Deliberately separate from `PageContext`, which is the panel's read-only view
|
||||
* of a page's *records*. This is a page talking to its own parts.
|
||||
*/
|
||||
const UiRenderContext = React.createContext(null);
|
||||
|
||||
/** The bag the page published. Empty when a component is rendered outside a tree. */
|
||||
export const useUiContext = () => React.useContext(UiRenderContext) || {};
|
||||
|
||||
/**
|
||||
* The node currently being drawn.
|
||||
*
|
||||
* Lets a section know its own id without being passed it — which is what a
|
||||
* future selection affordance needs, and what keeps the identity in one place
|
||||
* rather than repeated in every registration.
|
||||
*/
|
||||
const UiNodeContext = React.createContext(null);
|
||||
export const useUiNode = () => React.useContext(UiNodeContext);
|
||||
|
||||
/**
|
||||
* The DOM attributes that make a node addressable.
|
||||
*
|
||||
* Two, not one: the id answers "which node is this", and the type answers "what
|
||||
* is it" without a lookup. Both are `data-` attributes, so they carry no
|
||||
* styling and cannot collide with anything the design system uses.
|
||||
*/
|
||||
export const nodeAttrs = (node) => ({
|
||||
'data-ui-node': node.id,
|
||||
'data-ui-type': node.type,
|
||||
});
|
||||
|
||||
/**
|
||||
* The grid a container arranges its children in.
|
||||
*
|
||||
* Only emitted when a node actually asks for columns. A node with no layout
|
||||
* renders its children exactly as a page would have written them, which is what
|
||||
* lets an existing page migrate without its markup changing.
|
||||
*/
|
||||
function layoutClass(layout) {
|
||||
if (!layout?.columns) return null;
|
||||
/* Written out rather than interpolated: Tailwind scans source text for class
|
||||
names, and `grid-cols-${n}` is invisible to that scan and absent from the
|
||||
build. One column is not a grid at all. */
|
||||
const columns = {
|
||||
1: null,
|
||||
2: 'grid grid-cols-1 sm:grid-cols-2',
|
||||
3: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid grid-cols-2 lg:grid-cols-4',
|
||||
6: 'grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-6',
|
||||
12: 'grid grid-cols-2 sm:grid-cols-4 xl:grid-cols-6',
|
||||
}[layout.columns] || 'grid grid-cols-1 sm:grid-cols-2';
|
||||
|
||||
const gap = { none: 'gap-0', sm: 'gap-2', md: 'gap-4', lg: 'gap-6' }[layout.gap] || 'gap-4';
|
||||
return cn(columns, columns && gap);
|
||||
}
|
||||
|
||||
/**
|
||||
* The margin a node asks for above or below itself.
|
||||
*
|
||||
* Written out rather than interpolated, for the same reason the column classes
|
||||
* are: Tailwind scans source text, and a class assembled at run time is absent
|
||||
* from the build. Four steps, each a class the app already uses.
|
||||
*/
|
||||
export function spacingClasses(layout) {
|
||||
/**
|
||||
* Marked important, and that is not a shortcut.
|
||||
*
|
||||
* Pages stack their sections with `space-y-*`, which Tailwind implements as
|
||||
* `.space-y-6 > :not([hidden]) ~ :not([hidden]) { margin-top: … }` — two
|
||||
* classes and a pseudo-class, so it outranks a plain `mt-6` on the child no
|
||||
* matter which is written last. The class landed on the element, the computed
|
||||
* margin never moved, and the page looked identical: exactly the failure this
|
||||
* whole change exists to end, one level further down.
|
||||
*
|
||||
* `none` is therefore a real value rather than the absence of one. Asking for
|
||||
* no space above has to be able to say so, or the container's default is
|
||||
* simply unopposable.
|
||||
*
|
||||
* Still a closed set: four steps, each written out, each a class Tailwind can
|
||||
* see in this file. There is no path here from a stored value to arbitrary
|
||||
* CSS — an unknown value maps to nothing.
|
||||
*/
|
||||
const before = {
|
||||
none: '!mt-0', sm: '!mt-2', md: '!mt-4', lg: '!mt-6',
|
||||
}[layout?.spacingBefore];
|
||||
const after = {
|
||||
none: '!mb-0', sm: '!mb-2', md: '!mb-4', lg: '!mb-6',
|
||||
}[layout?.spacingAfter];
|
||||
return cn(before, after) || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a node's own layout asks of the element it is drawn as.
|
||||
*
|
||||
* `layoutClass` above is the other half and answers a different question: it is
|
||||
* the grid a *container* arranges its children in. This is what a node asks for
|
||||
* *itself* — the margin above and below it, how many columns of its parent's
|
||||
* grid it occupies, and how it sits in the row.
|
||||
*
|
||||
* It existed only as spacing, and only one component ever called it. Everything
|
||||
* else — the nine readings a person can add, and every section a definition
|
||||
* draws — took `layout` as a prop and dropped it, so an operation validated,
|
||||
* changed the tree, persisted, and produced no visible change at all. A layout
|
||||
* value that cannot be seen is worse than one that is refused.
|
||||
*
|
||||
* **Every class is written out.** Tailwind scans source text, so a class
|
||||
* assembled at run time is absent from the build and would silently do nothing —
|
||||
* the same failure in a new place. That is also what keeps this closed: these
|
||||
* are the only classes a layout value can ever produce, there is no path from
|
||||
* configuration to arbitrary CSS, and a value outside the vocabulary maps to
|
||||
* nothing rather than to itself.
|
||||
*/
|
||||
export function layoutClasses(layout) {
|
||||
const span = {
|
||||
1: 'col-span-1', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4',
|
||||
5: 'col-span-5', 6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8',
|
||||
9: 'col-span-9', 10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
|
||||
}[layout?.span];
|
||||
|
||||
const align = {
|
||||
start: 'self-start', center: 'self-center', end: 'self-end', stretch: 'self-stretch',
|
||||
}[layout?.align];
|
||||
|
||||
return cn(spacingClasses(layout), span, align) || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel a node draws itself as.
|
||||
*
|
||||
* The closed half of variant and density: two short maps, every class written
|
||||
* out so Tailwind can see it, and nothing that reads a value from configuration
|
||||
* into a class name. A value outside the vocabulary yields the default, so the
|
||||
* worst a malformed patch can do is look ordinary.
|
||||
*
|
||||
* `default` and `comfortable` reproduce **exactly** the panel every reading
|
||||
* section has always drawn — that is what lets this ship without changing a
|
||||
* single existing page, and what the migration baselines check.
|
||||
*
|
||||
* Returned as parts rather than one string because a component composes them
|
||||
* with its own classes and needs to control the order.
|
||||
*/
|
||||
export function presentationClasses(presentation) {
|
||||
const variant = {
|
||||
default: 'border-border bg-surface shadow-xs',
|
||||
subtle: 'border-border bg-surface-subtle shadow-none',
|
||||
emphasis: 'border-krow-blue/40 bg-krow-blue-tint/30 shadow-md ring-1 ring-krow-blue/20',
|
||||
}[presentation?.variant] || 'border-border bg-surface shadow-xs';
|
||||
|
||||
const density = {
|
||||
comfortable: { padding: 'p-5', gap: 'mb-3', title: 'text-body' },
|
||||
compact: { padding: 'p-3', gap: 'mb-1.5', title: 'text-body-sm' },
|
||||
}[presentation?.density] || { padding: 'p-5', gap: 'mb-3', title: 'text-body' };
|
||||
|
||||
return { variant, ...density };
|
||||
}
|
||||
|
||||
/** One node: its component, its identity, and its children if it holds any. */
|
||||
function UiNode({ node, registry }) {
|
||||
const entry = registry.get(node.type);
|
||||
|
||||
/* Hidden is a user's decision and is honoured before anything else, so a
|
||||
hidden node costs nothing to render. */
|
||||
if (node.hidden) return null;
|
||||
|
||||
/* Validation refuses an unknown type long before a tree is rendered, so
|
||||
reaching here means the registry and a stored patch have drifted — a type
|
||||
removed by a release, most likely. Render nothing rather than throw: one
|
||||
stale node must not take the page down. */
|
||||
if (!entry) return null;
|
||||
|
||||
const Component = entry.component;
|
||||
const attrs = nodeAttrs(node);
|
||||
|
||||
const children = entry.container
|
||||
? <UiTree nodes={node.children} registry={registry} layout={node.layout} />
|
||||
: null;
|
||||
|
||||
const drawn = (
|
||||
<Component
|
||||
node={node}
|
||||
attrs={entry.wrap ? {} : attrs}
|
||||
layout={node.layout}
|
||||
presentation={node.presentation}
|
||||
{...node.props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
|
||||
/* Wrapped so a section that throws costs the page that section, not the
|
||||
application. See `UiNodeBoundary`. */
|
||||
return (
|
||||
<UiNodeContext.Provider value={node}>
|
||||
<UiNodeBoundary node={node}>
|
||||
{entry.wrap ? <div {...attrs}>{drawn}</div> : drawn}
|
||||
</UiNodeBoundary>
|
||||
</UiNodeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of nodes.
|
||||
*
|
||||
* Renders a bare fragment unless a layout was asked for, so the nodes sit
|
||||
* directly inside whatever container the page already had — and a page that
|
||||
* migrates its sections into the tree keeps the spacing it always had.
|
||||
*/
|
||||
function UiTree({ nodes, registry, layout = null }) {
|
||||
const grid = layoutClass(layout);
|
||||
const drawn = (nodes || []).map((node) => (
|
||||
<UiNode key={node.id} node={node} registry={registry} />
|
||||
));
|
||||
return grid ? <div className={grid}>{drawn}</div> : <>{drawn}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a composed page.
|
||||
*
|
||||
* `context` is the bag the page publishes for its own sections. `nodes` is the
|
||||
* merged tree — built-ins with the user's saved changes and any preview already
|
||||
* folded in by `composePage`, so this component neither reads storage nor knows
|
||||
* that a patch exists.
|
||||
*/
|
||||
export function UiTreeRenderer({ nodes = [], context = null, registry = nodeRegistry }) {
|
||||
/* Identity-stable across renders so a page's sections do not remount every
|
||||
time the page re-renders for an unrelated reason. */
|
||||
const value = React.useMemo(() => context || {}, [context]);
|
||||
|
||||
return (
|
||||
<UiRenderContext.Provider value={value}>
|
||||
<UiTree nodes={nodes} registry={registry} />
|
||||
</UiRenderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One node of a page's tree, drawn where the page puts it.
|
||||
*
|
||||
* For a page that is only **partly** composed. Positions is the case this
|
||||
* exists for: its two page-level extension slots sit at fixed points in a large
|
||||
* hand-written layout, and migrating that whole layout is a separate piece of
|
||||
* work. Until then the page keeps its own JSX and renders the composed nodes it
|
||||
* does have, each in the place it has always been.
|
||||
*
|
||||
* The consequence is worth being clear about: nodes anchored this way can be
|
||||
* hidden, and their children moved and reordered, but reordering the page's
|
||||
* *roots* has nowhere to happen — there is no single container drawing them in
|
||||
* sequence. A fully composed page has no such limit.
|
||||
*
|
||||
* `page` is named rather than inferred so the component works during a baseline
|
||||
* capture, where no editing session is mounted.
|
||||
*/
|
||||
export function UiNodeSlot({ page, id, context = null, registry = nodeRegistry }) {
|
||||
const editing = useUiEditing();
|
||||
|
||||
const node = React.useMemo(() => {
|
||||
const tree = editing?.page === page && editing.tree
|
||||
? editing.tree
|
||||
: composePage(page, { registry }).tree;
|
||||
return (tree || []).find((candidate) => candidate.id === id) || null;
|
||||
}, [editing, page, id, registry]);
|
||||
|
||||
if (!node || node.hidden) return null;
|
||||
|
||||
/* A container with nothing in it draws nothing — the rule `SkillSurface` has
|
||||
always followed, and the reason an empty extension point costs no space on
|
||||
the pages that have no definitions for it. */
|
||||
const entry = registry.get(node.type);
|
||||
if (entry?.container && !node.children?.length) return null;
|
||||
|
||||
return <UiTreeRenderer nodes={[node]} context={context} registry={registry} />;
|
||||
}
|
||||
298
src/components/ui-tree/chartNodeTypes.jsx
Normal file
298
src/components/ui-tree/chartNodeTypes.jsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart,
|
||||
ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AXIS_PROPS, CHART_COLORS, CHART_TONES } from '@/components/ds/ChartContainer';
|
||||
import { SECTION_TYPES } from '@/lib/skills/surfaces';
|
||||
import { SegmentedToggle } from '@/components/ds/Toggle';
|
||||
import { useSkillDataContext } from '@/components/skills/SkillSurface';
|
||||
import { resolveSkillData } from '@/lib/skills/dataResolver';
|
||||
import { DENSITY_VALUES, VARIANT_VALUES } from '@/lib/ui/node';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { controlOfBinding, readSeries } from '@/lib/ui/series';
|
||||
import { layoutClasses, presentationClasses, useUiContext } from './UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* Five ways of drawing one series, and one table that says which is which.
|
||||
*
|
||||
* These are the node types a person reaches by asking for them — "show this as
|
||||
* a bar chart" — and everything that decides whether they may is declared here
|
||||
* as data. There is no branch anywhere on which chart is being drawn: the
|
||||
* registry compares `seriesKinds` to what the binding means, and a sixth chart
|
||||
* added to `VISUALIZATIONS` below is addressable by name, in conversation and
|
||||
* in the editor, with no other file touched.
|
||||
*
|
||||
* **Nothing is transformed and nothing is invented.** Every one of these reads
|
||||
* `readSeries`, which either resolves a skill reading through the ordinary
|
||||
* resolver or calls a page's own registered `read` — and then relabels neither.
|
||||
* A bar chart of hiring activity draws the same four numbers per day that the
|
||||
* page's own composed chart draws, because it is literally the same array.
|
||||
*
|
||||
* The interesting column is `seriesKinds`, and it is the whole reason a person
|
||||
* can be told *why* rather than merely *no*. `flow` cannot express it — it is,
|
||||
* in `SECTION_TYPES`' own words, "a sequence of stages **or** periods" — so a
|
||||
* pie was structurally free to slice twelve days into twelve wedges. Saying
|
||||
* `parts` here is what makes that impossible, and the refusal explains itself
|
||||
* in terms of the reading rather than the component.
|
||||
*/
|
||||
|
||||
/** The series this node is bound to, resolved through whichever binding it has. */
|
||||
function useSeries(node) {
|
||||
const skillContext = useSkillDataContext(null);
|
||||
const pageContext = useUiContext();
|
||||
|
||||
const resolveSource = React.useCallback((binding) => resolveSkillData({
|
||||
id: node.id,
|
||||
/* `flow` because that is the shape whose payload these draw. Resolvers are
|
||||
keyed by source and never read this, but a section without a type is not
|
||||
a section, and naming the shape it consumes keeps that honest. */
|
||||
type: 'flow',
|
||||
source: binding.source,
|
||||
periods: binding.params?.periods || [],
|
||||
limit: binding.params?.limit || null,
|
||||
}, skillContext), [node.id, skillContext]);
|
||||
|
||||
return React.useMemo(
|
||||
() => readSeries(node.data, { context: pageContext, resolveSource }),
|
||||
[node.data, pageContext, resolveSource]
|
||||
);
|
||||
}
|
||||
|
||||
/** How tall the plot is, and therefore how big a pie fits in it. */
|
||||
const plotHeight = (presentation) => (presentation?.density === 'compact' ? 180 : 260);
|
||||
|
||||
/**
|
||||
* The panel every one of them shares — the same chrome a reading section draws.
|
||||
*
|
||||
* `control` is whatever the *reading* publishes, drawn here so that replacing
|
||||
* one visualization with another does not quietly take a control away. Nothing
|
||||
* about it is known to this file beyond the shape declared in `series.js`: a
|
||||
* label, a closed set of options, the value the page is publishing, and the
|
||||
* page's own setter. A reading that publishes none renders none.
|
||||
*/
|
||||
function ChartPanel({ attrs, layout, presentation, title, children, empty, control = null }) {
|
||||
const look = presentationClasses(presentation);
|
||||
|
||||
return (
|
||||
<section
|
||||
{...attrs}
|
||||
aria-label={title || undefined}
|
||||
className={cn('rounded-2xl border', look.variant, look.padding, layoutClasses(layout))}
|
||||
>
|
||||
{(title || control) && (
|
||||
<div className={cn('flex flex-wrap items-center justify-between gap-x-3 gap-y-2', look.gap)}>
|
||||
{title
|
||||
? <h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>{title}</h3>
|
||||
: <span />}
|
||||
{control && (
|
||||
<SegmentedToggle
|
||||
options={control.options}
|
||||
value={control.value}
|
||||
onChange={control.set}
|
||||
size="sm"
|
||||
ariaLabel={control.label}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{empty
|
||||
? <p className="text-caption text-ink-3">{empty}</p>
|
||||
: <div style={{ height: plotHeight(presentation) }}>{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The axes, grid, tooltip and legend every cartesian chart here draws alike. */
|
||||
function Cartesian({ measures }) {
|
||||
return (
|
||||
<>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={16} height={32} />
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={32} />
|
||||
<Tooltip cursor={{ fill: CHART_TONES.mint, fillOpacity: 0.25 }} />
|
||||
{/* One series needs no key: the title already names it. */}
|
||||
{measures.length > 1 && (
|
||||
<Legend
|
||||
verticalAlign="top" align="right" height={24}
|
||||
iconType="circle" iconSize={8}
|
||||
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const colour = (i) => CHART_COLORS[i % CHART_COLORS.length];
|
||||
|
||||
/**
|
||||
* The five, as data.
|
||||
*
|
||||
* `draw` returns the recharts element for a resolved series; everything else —
|
||||
* the panel, the empty state, the identity, layout and presentation — is shared
|
||||
* above and below. Adding one is adding a row.
|
||||
*/
|
||||
const VISUALIZATIONS = [
|
||||
{
|
||||
type: 'bar-chart',
|
||||
label: 'Bar chart',
|
||||
summary: 'A bar for each point in a reading.',
|
||||
/* Bars compare magnitudes and claim nothing about order or wholeness, which
|
||||
is why this is the one drawing every meaning can take. */
|
||||
seriesKinds: ['periodic', 'cumulative', 'parts'],
|
||||
draw: ({ rows, measures }) => (
|
||||
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
||||
<Cartesian measures={measures} />
|
||||
{measures.map((measure, i) => (
|
||||
<Bar key={measure.key} dataKey={measure.key} name={measure.label}
|
||||
fill={colour(i)} radius={[4, 4, 0, 0]} />
|
||||
))}
|
||||
</BarChart>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'line-chart',
|
||||
label: 'Line chart',
|
||||
summary: 'The points joined in order.',
|
||||
/* A line asserts that the gap between two points means something, so it may
|
||||
only draw readings that are actually ordered. */
|
||||
seriesKinds: ['periodic', 'cumulative'],
|
||||
draw: ({ rows, measures }) => (
|
||||
<LineChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
||||
<Cartesian measures={measures} />
|
||||
{measures.map((measure, i) => (
|
||||
<Line key={measure.key} type="monotone" dataKey={measure.key} name={measure.label}
|
||||
stroke={colour(i)} strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
))}
|
||||
</LineChart>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'area-chart',
|
||||
label: 'Area chart',
|
||||
summary: 'The points joined in order, filled to the axis.',
|
||||
seriesKinds: ['periodic', 'cumulative'],
|
||||
draw: ({ rows, measures }) => (
|
||||
<AreaChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
||||
<Cartesian measures={measures} />
|
||||
{measures.map((measure, i) => (
|
||||
<Area key={measure.key} type="monotone" dataKey={measure.key} name={measure.label}
|
||||
stroke={colour(i)} strokeWidth={2} fill={colour(i)} fillOpacity={0.18}
|
||||
isAnimationActive={false} />
|
||||
))}
|
||||
</AreaChart>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'pie-chart',
|
||||
label: 'Pie chart',
|
||||
summary: 'The points as parts of one whole.',
|
||||
/* The narrow one, and deliberately. A slice is a share of a total, which is
|
||||
only true when the points are disjoint parts of that total. */
|
||||
seriesKinds: ['parts'],
|
||||
draw: ({ rows, measures }) => <Slices rows={rows} measure={measures[0]} inner={0} />,
|
||||
/* A disc of zeroes is a blank panel that every automated check calls a
|
||||
success because the elements are there. */
|
||||
guard: ({ rows, measures }) => (
|
||||
rows.reduce((sum, row) => sum + (row[measures[0]?.key] || 0), 0) === 0
|
||||
? 'Every value here is zero.' : null
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'donut-chart',
|
||||
label: 'Donut chart',
|
||||
summary: 'The same parts, drawn as a ring.',
|
||||
seriesKinds: ['parts'],
|
||||
draw: ({ rows, measures }) => <Slices rows={rows} measure={measures[0]} inner={0.55} />,
|
||||
guard: ({ rows, measures }) => (
|
||||
rows.reduce((sum, row) => sum + (row[measures[0]?.key] || 0), 0) === 0
|
||||
? 'Every value here is zero.' : null
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The wedges a pie and a donut share.
|
||||
*
|
||||
* Centre and radii given explicitly, in pixels. Percentage radii with no
|
||||
* `cx`/`cy` produced sectors that existed in the DOM, carried no fill and drew
|
||||
* nothing — the failure the guard above is about. The one pie already in this
|
||||
* repository does it this way, and it works.
|
||||
*/
|
||||
function Slices({ rows, measure, inner }) {
|
||||
const outer = 92;
|
||||
return (
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={rows}
|
||||
dataKey={measure?.key || 'value'}
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={Math.round(outer * inner)}
|
||||
outerRadius={outer}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{rows.map((row, i) => <Cell key={row.id} fill={colour(i)} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
);
|
||||
}
|
||||
|
||||
/** One visualization node — identical for all five but for the `draw` it was given. */
|
||||
function Visualization({ spec, node, attrs = {}, layout = null, presentation = null, title = null }) {
|
||||
const series = useSeries(node);
|
||||
const pageContext = useUiContext();
|
||||
const empty = !series.rows.length || !series.measures.length
|
||||
? (series.emptyNote || 'Nothing to chart yet.')
|
||||
: (spec.guard?.(series) || null);
|
||||
|
||||
/**
|
||||
* The control the reading publishes, and the reading's own name.
|
||||
*
|
||||
* Both exist so that a replacement is a change of renderer rather than a
|
||||
* quiet loss of what was on the panel. The title falls back to the name of
|
||||
* the thing being drawn — not to a made-up one — so a built-in section
|
||||
* replaced by a chart still says what it is showing.
|
||||
*/
|
||||
const control = controlOfBinding(node.data, pageContext);
|
||||
|
||||
return (
|
||||
<ChartPanel attrs={attrs} layout={layout} presentation={presentation}
|
||||
title={title || series.label || null} empty={empty} control={control}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{spec.draw(series)}
|
||||
</ResponsiveContainer>
|
||||
</ChartPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/* The shapes whose payload these draw, read from the vocabulary rather than
|
||||
written out, so a shape renamed there cannot leave these claiming one that no
|
||||
longer exists. */
|
||||
const SERIES_SHAPES = ['flow', 'stats'].filter((shape) => SECTION_TYPES.some((t) => t.id === shape));
|
||||
|
||||
for (const spec of VISUALIZATIONS) {
|
||||
const Component = (props) => <Visualization spec={spec} {...props} />;
|
||||
Component.displayName = spec.label;
|
||||
|
||||
registerNodeType({
|
||||
type: spec.type,
|
||||
label: spec.label,
|
||||
summary: spec.summary,
|
||||
component: Component,
|
||||
dataShapes: SERIES_SHAPES,
|
||||
seriesKinds: spec.seriesKinds,
|
||||
dataRequired: true,
|
||||
propSchema: { title: { type: 'string', label: 'Title' } },
|
||||
variants: VARIANT_VALUES,
|
||||
/* Both densities change the plot height, which is the one thing a chart has
|
||||
to give. Declared because they are honoured, not because they exist. */
|
||||
densities: DENSITY_VALUES,
|
||||
capabilities: ['update', 'remove', 'move', 'replace', 'hide'],
|
||||
});
|
||||
}
|
||||
|
||||
export { VISUALIZATIONS };
|
||||
102
src/components/ui-tree/nodeTypes.jsx
Normal file
102
src/components/ui-tree/nodeTypes.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SUPPORTED_SECTION_TYPES } from '@/lib/skills/surfaces';
|
||||
import { layoutClasses } from './UiTreeRenderer';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
/* The nine reading components, registered from the skill vocabulary. Imported
|
||||
here so one import gives a page every type it can offer. */
|
||||
import './sectionNodeTypes';
|
||||
/* The two visualisation types. Not skill sections — see the note in the file. */
|
||||
import './chartNodeTypes';
|
||||
|
||||
/**
|
||||
* Node types every page can use.
|
||||
*
|
||||
* Page-specific sections register beside their own page; this file is for the
|
||||
* types that belong to no page in particular. Today that is one: the slot a
|
||||
* skill definition renders into.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A skill surface, as a node.
|
||||
*
|
||||
* This is the join between the two systems, and it is deliberately thin. A
|
||||
* `ui:` block in a skill definition is still normalized by `uiConfig.js`,
|
||||
* resolved by `dataResolver.js` and drawn by `SkillSurface` exactly as it is
|
||||
* today — nothing about Board skills changes. What changes is that the *slot*
|
||||
* is now a node, so a person can move the extension point up the page or hide
|
||||
* it, using the same operations that move a built-in section.
|
||||
*
|
||||
* It renders **nothing of its own**. `SkillSurface` already returns `null` when
|
||||
* no definition claims the placement, and that must stay true: a slot that
|
||||
* rendered an empty wrapper would add a gap to every page it sits on, on every
|
||||
* account that has authored no skills — which is nearly all of them. So this
|
||||
* type does not declare `wrap`, and accepts having no DOM identity while it is
|
||||
* empty over changing what an empty page looks like.
|
||||
*/
|
||||
/**
|
||||
* The slot, as a container.
|
||||
*
|
||||
* It draws nothing of its own and holds no logic: the sections that belong to
|
||||
* it are children in the tree, put there by `composePage`, and the renderer
|
||||
* walks them like any other children. That is what makes a Board skill's card
|
||||
* addressable — it is a node, not something a component conjured up while
|
||||
* rendering.
|
||||
*
|
||||
* Empty means **nothing**, not an empty box. `SkillSurface` has always returned
|
||||
* `null` where no definition claims a placement, and every page that has not
|
||||
* migrated still calls it directly. A slot that rendered a wrapper regardless
|
||||
* would put a gap on every page of every account that has authored no skills,
|
||||
* which is nearly all of them.
|
||||
*/
|
||||
function SkillSurfaceNode({ node, children, layout }) {
|
||||
if (!node.children?.length) return null;
|
||||
/* The same spacing `SkillSurface` puts between sections, plus whatever margin
|
||||
the page's composition asked for — which is how a slot keeps the exact
|
||||
separation it had before it became a node. */
|
||||
return <div className={cn('space-y-4', layoutClasses(layout))}>{children}</div>;
|
||||
}
|
||||
|
||||
registerNodeType({
|
||||
type: 'skill-surface',
|
||||
label: 'Skill sections',
|
||||
summary: 'Where definitions authored for this page render.',
|
||||
component: SkillSurfaceNode,
|
||||
container: true,
|
||||
/* Only readings may sit in a slot — the nine the skill format already allows.
|
||||
Derived from the vocabulary rather than listed, so the two cannot drift. */
|
||||
accepts: SUPPORTED_SECTION_TYPES,
|
||||
/* Moving and hiding the slot is meaningful; replacing it with a chart is not,
|
||||
and neither is deleting the only way a page can be extended. Its children
|
||||
are separately addressable and carry their own capabilities. */
|
||||
capabilities: ['move', 'hide', 'reorder'],
|
||||
/**
|
||||
* Never something a person adds.
|
||||
*
|
||||
* A slot exists because a page offered an extension point at a particular
|
||||
* spot, and a second one conjured up by a picker would anchor nothing — no
|
||||
* definition names it, so it would render empty forever. It was offered and
|
||||
* then refused with "'Skill sections' cannot be added to", which is the
|
||||
* product asking a question it already knew the answer to. It is a container
|
||||
* and an anchor; the two are not the same claim.
|
||||
*/
|
||||
addable: false,
|
||||
/**
|
||||
* Which slot this is.
|
||||
*
|
||||
* A page mounts several, and they all share one label — so an outline listed
|
||||
* "Skill sections" twice with nothing to choose between them, and "hide the
|
||||
* skill sections" had no answer. The placement is the only thing that
|
||||
* distinguishes them and it is already on the node, so it is what they are
|
||||
* called: "Skill sections (after header)".
|
||||
*/
|
||||
describe: (node) => {
|
||||
const placement = String(node?.props?.placement || '').trim();
|
||||
if (!placement) return 'Skill sections';
|
||||
return `Skill sections (${placement.replace(/-/g, ' ')})`;
|
||||
},
|
||||
propSchema: {
|
||||
page: { type: 'string', required: true, label: 'Page' },
|
||||
placement: { type: 'string', required: true, label: 'Placement' },
|
||||
},
|
||||
});
|
||||
155
src/components/ui-tree/sectionNodeTypes.jsx
Normal file
155
src/components/ui-tree/sectionNodeTypes.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
|
||||
import { useSkillDataContext } from '@/components/skills/SkillSurface';
|
||||
import { usePageAction } from '@/components/ai-assistant/PageContext';
|
||||
import { resolveSkillData } from '@/lib/skills/dataResolver';
|
||||
import { SECTION_TYPES, dataSourceFor } from '@/lib/skills/surfaces';
|
||||
import { DENSITY_VALUES, VARIANT_VALUES } from '@/lib/ui/node';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { layoutClasses, presentationClasses } from './UiTreeRenderer';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The nine reading components, registered as node types.
|
||||
*
|
||||
* These are the same components a Board skill draws with — `SECTION_COMPONENTS`
|
||||
* from `SkillSections.jsx`, resolved through the same `resolveSkillData` and the
|
||||
* same closed data vocabulary. Registering them here is what makes "add a card"
|
||||
* and "change this to a table" mean something: a person can put one of the
|
||||
* product's own readings on a page without authoring a skill for it.
|
||||
*
|
||||
* Nothing about skills changes. A `ui:` block still renders through
|
||||
* `SkillSurface` exactly as before; this is a second consumer of the same
|
||||
* renderer, which is the arrangement `SkillSections.jsx` was already built for —
|
||||
* the page and the chat panel were the first two.
|
||||
*
|
||||
* **A node of these types cannot invent data.** It carries a data *binding*, not
|
||||
* data: a source id from the closed vocabulary, resolved at render time against
|
||||
* records the caller already has. A binding naming something that is not a
|
||||
* source is refused by validation before it can be previewed, let alone saved.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One reading, drawn.
|
||||
*
|
||||
* The `section` shape is assembled from the node rather than from a Markdown
|
||||
* definition, but it is the same shape `normalizeSection` produces — which is
|
||||
* why the resolver and the component take it without knowing which of the two
|
||||
* built it.
|
||||
*/
|
||||
function ReadingNode({
|
||||
node, attrs = {}, layout = null, presentation = null, title = null, description = null,
|
||||
attribution = null, editable = false, type,
|
||||
}) {
|
||||
const Component = SECTION_COMPONENTS[type];
|
||||
const context = useSkillDataContext(null);
|
||||
|
||||
const section = React.useMemo(() => ({
|
||||
id: node.id,
|
||||
type,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
source: node.data?.source || '',
|
||||
periods: node.data?.params?.periods || [],
|
||||
limit: node.data?.params?.limit || null,
|
||||
/* What the reading needs the page to have open. Read off the source, so a
|
||||
node cannot claim a context its source never declared. */
|
||||
context: dataSourceFor(node.data?.source)?.context,
|
||||
editable: Boolean(editable),
|
||||
}), [node.id, node.data, title, description, editable, type]);
|
||||
|
||||
const data = React.useMemo(() => resolveSkillData(section, context), [section, context]);
|
||||
|
||||
/* How this section writes back, if the page is offering that write at all —
|
||||
the same rule `SkillSurface` applies, so a section declared editable on a
|
||||
page that does not own the data stays an honest read-out. */
|
||||
const apply = usePageAction(section.editable ? section.source : null);
|
||||
|
||||
/* Validation refuses a binding-less reading long before this, so reaching here
|
||||
without one means a stored patch outlived a vocabulary change. Draw nothing
|
||||
rather than an empty panel with a title. */
|
||||
/* The closed map, read once. Not a hook, so it sits with the other derived
|
||||
values and changes nothing about when this component re-renders. */
|
||||
const look = presentationClasses(presentation);
|
||||
|
||||
if (!Component || !node.data?.source) return null;
|
||||
|
||||
/**
|
||||
* Two chromes, one component.
|
||||
*
|
||||
* A section contributed by a skill is drawn exactly as `SkillSurface` has
|
||||
* always drawn it — same panel, same heading, same attribution pill — because
|
||||
* moving a Board card into the node tree must not change how it looks. A node
|
||||
* a person added has no skill to attribute, so it gets the plain panel. The
|
||||
* difference is a property, not a branch on where the node came from.
|
||||
*/
|
||||
return (
|
||||
<section
|
||||
{...attrs}
|
||||
aria-label={section.title || attribution || undefined}
|
||||
/* The panel this draws, as its own presentation describes it, plus
|
||||
whatever its layout asks for. Both helpers return the existing values
|
||||
when a node asks for nothing, so a section nobody has customised
|
||||
renders exactly the markup it did before — which is what the migration
|
||||
baselines check. */
|
||||
className={cn('rounded-2xl border', look.variant, look.padding, layoutClasses(layout))}
|
||||
>
|
||||
{(section.title || attribution) && (
|
||||
<div className={cn(look.gap, 'flex flex-wrap items-start justify-between gap-2')}>
|
||||
<div className="min-w-0">
|
||||
<h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>
|
||||
{section.title}
|
||||
</h3>
|
||||
{section.description && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{section.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{attribution && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-krow-blue-tint px-2 py-0.5 text-[10px] font-semibold text-krow-blue">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
{attribution}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Component data={data} section={section} onApply={apply} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
for (const { id, label, summary } of SECTION_TYPES) {
|
||||
registerNodeType({
|
||||
type: id,
|
||||
label,
|
||||
summary,
|
||||
/* Bound per type so the component does not have to read its own name back
|
||||
out of the node it was handed. */
|
||||
component: (props) => <ReadingNode {...props} type={id} />,
|
||||
/* All nine draw the same panel through `ReadingNode`, so all nine can
|
||||
honour the whole vocabulary. Declared from it rather than listed, so a
|
||||
value added to the product reaches every reading without an edit here —
|
||||
and a value removed cannot be left behind claiming support. */
|
||||
variants: VARIANT_VALUES,
|
||||
densities: DENSITY_VALUES,
|
||||
/* The one shape this component draws. Validation pairs it against what a
|
||||
source can fill, so `table` accepts only sources that have a table in
|
||||
them — the same rule `normalizeSection` already applies to a skill. */
|
||||
dataShapes: [id],
|
||||
dataRequired: true,
|
||||
propSchema: {
|
||||
title: { type: 'string', label: 'Title' },
|
||||
description: { type: 'string', label: 'Description' },
|
||||
/* The skill that contributed this section, when one did. Carried as a
|
||||
property rather than inferred from `origin`, so the renderer stays
|
||||
ignorant of provenance. */
|
||||
attribution: { type: 'string', label: 'Contributed by' },
|
||||
editable: { type: 'boolean', label: 'Editable' },
|
||||
},
|
||||
/* Everything, because unlike a built-in page section these are nodes a
|
||||
person put there: they can be removed as well as hidden. */
|
||||
capabilities: ['update', 'remove', 'move', 'replace', 'hide'],
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant';
|
||||
import { UiEditingProvider } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { pageKeyForRoute } from '@/lib/skills/registry';
|
||||
import { endAdminSession } from '@/lib/admin/session';
|
||||
import { useCurrentUser } from '@/lib/krowHooks';
|
||||
|
||||
@@ -177,8 +179,22 @@ export default function AdminLayout() {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The layout session for whichever page is open.
|
||||
*
|
||||
* Mounted here rather than inside a page because both the page and the panel
|
||||
* beside it need the same one: the page renders the tree, and Owliver
|
||||
* proposes changes to it. Two providers would be two trees, and the preview
|
||||
* shown in chat would be of a page nobody was looking at.
|
||||
*
|
||||
* A page that has registered no composition simply has an empty tree, so this
|
||||
* costs nothing on the surfaces that have not migrated yet.
|
||||
*/
|
||||
const uiPage = pageKeyForRoute(location.pathname) || '';
|
||||
|
||||
return (
|
||||
<AssistantPanelProvider role="admin" pathname={location.pathname}>
|
||||
<UiEditingProvider page={uiPage}>
|
||||
{/* Transparent so the ambient canvas painted behind the app reads through.
|
||||
An opaque shell here would cover it and every Admin page would lose the
|
||||
tint at once — which is exactly why the canvas is one layer and not a
|
||||
@@ -354,6 +370,7 @@ export default function AdminLayout() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UiEditingProvider>
|
||||
</AssistantPanelProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,6 +282,40 @@ export function resolveAgentForTurn(agents = [], activeId, contextId) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Skill ownership ────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Whether this agent context permits a skill's UI.
|
||||
*
|
||||
* Ownership is **opt-in**, and the asymmetry is the whole rule:
|
||||
*
|
||||
* - a skill claimed by at least one agent belongs to those agents, and its
|
||||
* sections are drawn only where one of them is answering;
|
||||
* - a skill claimed by nobody is unowned, and unowned means unchanged — the
|
||||
* pages it declares and the account switch decide it, exactly as before.
|
||||
*
|
||||
* Enforcing ownership on every skill instead would have removed every skill
|
||||
* section in the product on the day it shipped: the definitions that draw page
|
||||
* UI are authored on an account and claimed by no agent, while every skill this
|
||||
* build ships is conversation-only. Attaching a skill to an agent is therefore
|
||||
* the act that brings it under that agent's control — a decision an author
|
||||
* makes in Agent Configure, not one taken on their behalf.
|
||||
*
|
||||
* With no agents loaded — outside a provider, or before the registry has
|
||||
* answered — nothing is owned and nothing is constrained, which is the safe
|
||||
* reading rather than a permissive one: it can only ever show what the page and
|
||||
* the account already allow.
|
||||
*
|
||||
* This answers one question only. What pages a skill declares and what the
|
||||
* account has switched off are separate rules, checked separately.
|
||||
*/
|
||||
export function agentPermitsSkill(skillId, { agents = [], agent = null } = {}) {
|
||||
if (!skillId) return false;
|
||||
const owned = (agents || []).some((a) => (a?.skills || []).includes(skillId));
|
||||
if (!owned) return true;
|
||||
return (agent?.skills || []).includes(skillId);
|
||||
}
|
||||
|
||||
/* ── Starters ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
|
||||
99
src/lib/employeeRoleModel.js
Normal file
99
src/lib/employeeRoleModel.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* A worker's declared professional role.
|
||||
*
|
||||
* The supply side of `positionModel.js`: that one describes what an
|
||||
* organization needs filled, this one describes what a person says they do.
|
||||
* They share a vocabulary — a role category, an English level, certifications —
|
||||
* and almost nothing else, which is why the pay fields are named for what the
|
||||
* worker WANTS rather than what a posting OFFERS.
|
||||
*/
|
||||
|
||||
/** English levels, in the order the schema declares them. */
|
||||
export const EMPLOYEE_ROLE_STATUSES = ['seeking', 'placed', 'inactive'];
|
||||
|
||||
/** When somebody can work. Free text in the column; these are the common ones. */
|
||||
export const AVAILABILITY_OPTIONS = ['Weekdays', 'Weekends', 'Evenings', 'Full time'];
|
||||
|
||||
/** Everything the record needs, before the conversation has said anything. */
|
||||
export const defaultEmployeeRole = () => ({
|
||||
worker_profile_id: null,
|
||||
worker_email: '',
|
||||
worker_name: '',
|
||||
role_category: '',
|
||||
experience_years: 0,
|
||||
english_level: 'basic',
|
||||
certifications: [],
|
||||
desired_pay_min: 0,
|
||||
desired_pay_max: 0,
|
||||
availability: [],
|
||||
notes: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* "$25–$35/hr", or "from $25/hr" when only a floor was given.
|
||||
*
|
||||
* A maximum of zero means "no ceiling stated" rather than "free", which is why
|
||||
* it is not rendered as a range ending at nothing.
|
||||
*/
|
||||
export function desiredPayLabel(record = {}) {
|
||||
const min = Number(record.desired_pay_min) || 0;
|
||||
const max = Number(record.desired_pay_max) || 0;
|
||||
if (!min && !max) return null;
|
||||
if (min && max) return `$${min}–$${max}/hr`;
|
||||
return min ? `From $${min}/hr` : `Up to $${max}/hr`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body for creating a NEW employee together with their first declared role.
|
||||
*
|
||||
* Two nested records, because they are two rows and the endpoint writes them in
|
||||
* one transaction: the worker on the outside, the role under `role`. Nesting
|
||||
* rather than flattening is what stops a key meant for one landing on the
|
||||
* other — `experience_years` means something different on a profile than on a
|
||||
* declared role, and a flat body would have to guess.
|
||||
*
|
||||
* The email is passed through as the caller typed it. Nothing here derives one,
|
||||
* defaults one, or falls back to another record's; an absent email reaches the
|
||||
* server absent, and the server refuses it.
|
||||
*/
|
||||
export function toNewWorkerWithRolePayload(draft = {}) {
|
||||
const role = toEmployeeRolePayload(draft);
|
||||
/* The identity fields belong to the worker. The server copies them onto the
|
||||
role from the row it just created, so sending them twice would let the two
|
||||
disagree. */
|
||||
const { worker_email: email, worker_name: name, worker_profile_id: _ignored, ...roleOnly } = role;
|
||||
|
||||
return {
|
||||
full_name: name,
|
||||
email,
|
||||
availability: role.availability,
|
||||
certifications: role.certifications,
|
||||
experience_years: role.experience_years,
|
||||
role: roleOnly,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The record the API is asked to create.
|
||||
*
|
||||
* Numbers are coerced here rather than at the field, because the conversation
|
||||
* collects strings and the column is an int — and a string in an int column is
|
||||
* a 400 the reader cannot act on. `org_id` and `created_by` are absent
|
||||
* deliberately: both are the server's, derived from the session, and a value
|
||||
* sent for either is dropped before the insert.
|
||||
*/
|
||||
export function toEmployeeRolePayload(draft = {}) {
|
||||
const record = { ...defaultEmployeeRole(), ...draft };
|
||||
return {
|
||||
...record,
|
||||
worker_email: String(record.worker_email || '').trim(),
|
||||
worker_name: String(record.worker_name || '').trim(),
|
||||
role_category: String(record.role_category || '').trim(),
|
||||
experience_years: Number(record.experience_years) || 0,
|
||||
desired_pay_min: Number(record.desired_pay_min) || 0,
|
||||
desired_pay_max: Number(record.desired_pay_max) || 0,
|
||||
certifications: Array.isArray(record.certifications) ? record.certifications : [],
|
||||
availability: Array.isArray(record.availability) ? record.availability : [],
|
||||
notes: String(record.notes || '').trim(),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { mergeLayouts, normalizeLayouts } from '@/lib/ui/patch';
|
||||
import { API_BASE_URL, base44 } from '@/api/base44Client';
|
||||
import { request } from '@/api/httpClient';
|
||||
import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi';
|
||||
import { recalcProfilePatch } from './krowScore';
|
||||
import { logActivity } from './userTracking';
|
||||
@@ -62,6 +65,52 @@ export function useUpdatePreferences() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A person's own UI layout changes, per page.
|
||||
*
|
||||
* Stored beside `customSkills` in the account's preferences, which is a store
|
||||
* that already exists, is already per-user, and already reaches Postgres
|
||||
* through `PATCH /api/v1/me/preferences`. No new endpoint, no new table, and
|
||||
* nothing to deploy — which is the whole point: a layout change is a runtime
|
||||
* change, and a runtime change that needed a release would not be one.
|
||||
*
|
||||
* What is written is the **operation list** the UI engine validated, never a
|
||||
* copy of the rendered tree and never Markdown. A stored tree would be a
|
||||
* photograph of the page on the day it was saved; an operation still means what
|
||||
* it said after a release moves the built-ins around it.
|
||||
*
|
||||
* The whole `uiLayouts` object is sent on every write, because the endpoint
|
||||
* shallow-merges its top-level keys: sending one page would replace the map and
|
||||
* silently drop every other page's layout.
|
||||
*/
|
||||
export function useUiLayouts() {
|
||||
const preferences = usePreferences();
|
||||
const update = useUpdatePreferences();
|
||||
|
||||
const layouts = React.useMemo(
|
||||
() => normalizeLayouts(preferences.uiLayouts).layouts,
|
||||
[preferences.uiLayouts]
|
||||
);
|
||||
|
||||
/**
|
||||
* Store one page's patch.
|
||||
*
|
||||
* A patch with no operations is removed rather than stored empty — that is
|
||||
* the same state as never having customised the page, and keeping the key
|
||||
* would grow the blob with a record of every page somebody once opened.
|
||||
*/
|
||||
const save = React.useCallback(async (page, patch) => {
|
||||
const key = String(page || '').trim();
|
||||
if (!key) return null;
|
||||
/* Only `uiLayouts` is sent. Every other preference — `customSkills` above
|
||||
all — is left for the endpoint's shallow merge to preserve, so a layout
|
||||
change can never disturb an authored skill. */
|
||||
return update.mutateAsync({ uiLayouts: mergeLayouts(layouts, key, patch) });
|
||||
}, [layouts, update]);
|
||||
|
||||
return { layouts, save, saving: update.isPending };
|
||||
}
|
||||
|
||||
export function useJobPostings() {
|
||||
return useQuery({
|
||||
queryKey: ['jobPostings'],
|
||||
@@ -317,6 +366,51 @@ export function useCreateJobPosting() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what a worker declares they do.
|
||||
*
|
||||
* The supply-side twin of `useCreateJobPosting`. It invalidates the worker
|
||||
* queries as well as its own, because a declared role changes what the Talent
|
||||
* Pool shows about that person — and the Owliver context, because the
|
||||
* organization now has one more worker offering that role and what is worth
|
||||
* asking has changed with it.
|
||||
*/
|
||||
/**
|
||||
* Record a NEW employee and their first declared role, in one transaction.
|
||||
*
|
||||
* One request, not two. Creating the worker and then the role as separate calls
|
||||
* leaves a worker nobody meant to create when the second fails — indistinguishable
|
||||
* from a real one and with nothing to say why it is there. The endpoint writes
|
||||
* both inside a transaction and rolls the worker back if the role cannot be
|
||||
* written, so a refused create leaves the database exactly as it was.
|
||||
*/
|
||||
export function useCreateWorkerWithRole() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: /** @param {any} data */ (data) => request('POST', '/worker-profiles/with-role', { body: data }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['employeeRoles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
|
||||
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||
logActivity('create_employee_role');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Record another role for somebody already on file. A different request. */
|
||||
export function useCreateEmployeeRole() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.EmployeeRole.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['employeeRoles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
|
||||
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||
logActivity('create_employee_role');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateJobPosting() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -11,7 +11,65 @@
|
||||
* `useCreateJobPosting`, in both paths.
|
||||
*/
|
||||
|
||||
/** Certifications a position can require, in the order they are offered. */
|
||||
/**
|
||||
* Which certifications matter for a role, from what this organization has
|
||||
* actually asked for.
|
||||
*
|
||||
* THE SOURCE OF TRUTH, and it is worth being explicit about why it is this one.
|
||||
* The schema has no role-to-certification relationship at all: `role_categories`
|
||||
* and `certifications` are both bare `(id, org_id, name)` lists with nothing
|
||||
* joining them. So relevance cannot be looked up — but it can be OBSERVED, and
|
||||
* the observation is real organizational behaviour rather than a guess:
|
||||
* `job_postings` carries `role_category` and `certifications_required` on the
|
||||
* same row, so every posting this organization has written is a statement that
|
||||
* these certifications matter for that role.
|
||||
*
|
||||
* That makes the answer tenant-specific for free. A staffing company that puts
|
||||
* `Guard Card` on its Security postings gets Guard Card; one that does not,
|
||||
* does not. Nothing here carries a list of roles or a list of certifications,
|
||||
* and adding either to the product changes this function's output without
|
||||
* changing this function.
|
||||
*
|
||||
* `postings` is the caller's own already-loaded set, so this widens nobody's
|
||||
* view: the API scoped it before it reached the browser.
|
||||
*
|
||||
* Returns `null` when the postings have not loaded, and `[]` when they have and
|
||||
* the role genuinely has none. Those are different answers — "we do not know
|
||||
* yet" must not render as "there are none" — and the caller is expected to tell
|
||||
* them apart rather than treating both as empty.
|
||||
*/
|
||||
export function certificationsForRole(role, postings) {
|
||||
if (!Array.isArray(postings)) return null;
|
||||
|
||||
const want = String(role || '').trim().toLowerCase();
|
||||
if (!want) return [];
|
||||
|
||||
const found = new Set();
|
||||
for (const posting of postings) {
|
||||
if (String(posting?.role_category || '').trim().toLowerCase() !== want) continue;
|
||||
for (const cert of posting.certifications_required || []) {
|
||||
const name = String(cert || '').trim();
|
||||
if (name) found.add(name);
|
||||
}
|
||||
}
|
||||
return [...found];
|
||||
}
|
||||
|
||||
/**
|
||||
* Certifications the CREATE POSITION FORM offers, in the order they are offered.
|
||||
*
|
||||
* A fixed list, and it should not be one — the organization keeps its own in
|
||||
* the `certifications` table, which `useCertifications()` already reads and
|
||||
* which nothing in this product currently consults. Against the live tenant
|
||||
* this list is wrong twice over: it offers `ABC License`, which that
|
||||
* organization does not use, and spells `CPR/First Aid` where the record says
|
||||
* `CPR / First Aid`, so the two can never match.
|
||||
*
|
||||
* Left in place deliberately rather than quietly rewired: the form is a
|
||||
* multi-select over a fixed vocabulary and changing its source is a change to
|
||||
* how positions are authored, which is a product decision and not a bug fix.
|
||||
* The conversational flows no longer read it — see `certificationsForRole`.
|
||||
*/
|
||||
export const CERT_OPTIONS = [
|
||||
'Food Handler Card',
|
||||
'ServSafe',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { toNewWorkerWithRolePayload } from '@/lib/employeeRoleModel';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS, toPositionPayload } from '@/lib/positionModel';
|
||||
import { routeForPageKey } from './registry';
|
||||
|
||||
@@ -24,6 +25,34 @@ import { routeForPageKey } from './registry';
|
||||
* that category — and a category added in Create Position is understood without
|
||||
* a change to this file.
|
||||
*/
|
||||
/**
|
||||
* Words that introduce a role rather than name one.
|
||||
*
|
||||
* "Create new position" marks "new" as the role by the same grammar that marks
|
||||
* "sous chef" in "create sous chef position", and the phrase pattern below
|
||||
* cannot tell them apart. Without this, that request opened the conversation
|
||||
* with the title already set to "New" — a value nobody typed, on the one field
|
||||
* a position cannot be created without, which the reader then had to notice and
|
||||
* correct. An empty title is the honest answer to a request that named no role.
|
||||
*
|
||||
* These are stripped to decide whether a phrase named anything, and the ORIGINAL
|
||||
* phrase is what is returned when it did. That distinction is the whole design:
|
||||
* a rule that returned the stripped words instead would turn "second chef" into
|
||||
* "Chef" and "open kitchen lead" into "Kitchen Lead", quietly renaming real
|
||||
* roles to fix a problem those roles do not have. Strip to TEST, never to
|
||||
* rewrite.
|
||||
*
|
||||
* Only determiners, quantifiers and intensifiers belong here. Nothing that
|
||||
* could be part of a job title, which is what makes the strip-and-test safe:
|
||||
* a phrase is rejected only when EVERY word in it is one of these.
|
||||
*/
|
||||
const GENERIC_ROLE_WORDS = new Set([
|
||||
'a', 'an', 'the', 'this', 'that', 'these', 'those', 'it',
|
||||
'new', 'brand', 'fresh', 'another', 'other', 'more', 'extra', 'additional',
|
||||
'further', 'second', 'third', 'next', 'one', 'couple', 'few', 'several',
|
||||
'some', 'any', 'open', 'spare', 'whole', 'just', 'quick',
|
||||
]);
|
||||
|
||||
export function extractRole(question, categories = []) {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
@@ -37,12 +66,24 @@ export function extractRole(question, categories = []) {
|
||||
|
||||
/* "…for a Sous Chef", "…a Line Cook position" — take the phrase the sentence
|
||||
itself marks as the role when it matches no known category. */
|
||||
const phrase = /(?:create|open|post|add|new|hiring|hire)\s+(?:a|an)?\s*([a-z][a-z\s/-]{2,40}?)\s*(?:position|role|job|opening)\b/i.exec(question)
|
||||
|| /\b(?:position|role|job)\s+for\s+(?:a|an)?\s*([a-z][a-z\s/-]{2,40})/i.exec(question);
|
||||
/* The article is optional but must be a WHOLE word when present. Written as
|
||||
`(?:a|an)?\s*` it matched the "a" of "another", so "create another new
|
||||
position" captured "nother new" and offered the position a title that is
|
||||
not even a word. */
|
||||
const phrase = /(?:create|open|post|add|new|hiring|hire)\s+(?:(?:an?)\s+)?([a-z][a-z\s/-]{2,40}?)\s*(?:position|role|job|opening)\b/i.exec(question)
|
||||
|| /\b(?:position|role|job)\s+for\s+(?:(?:an?)\s+)?([a-z][a-z\s/-]{2,40})/i.exec(question);
|
||||
if (!phrase) return null;
|
||||
|
||||
const cleaned = phrase[1].trim().replace(/\s+/g, ' ');
|
||||
return cleaned ? cleaned.replace(/\b\w/g, (c) => c.toUpperCase()) : null;
|
||||
if (!cleaned) return null;
|
||||
|
||||
/* Named nothing if every word was scaffolding — "a brand new", "one more".
|
||||
A single-word check missed all of those, because the scaffolding is a
|
||||
PHRASE at least as often as it is one word. */
|
||||
const named = cleaned.split(' ').some((word) => !GENERIC_ROLE_WORDS.has(word.toLowerCase()));
|
||||
if (!named) return null;
|
||||
|
||||
return cleaned.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** A location, when the sentence names one with "in" or "at". */
|
||||
@@ -440,6 +481,29 @@ const HANDLERS = {
|
||||
data: toPositionPayload(draft || {}, status ? { status } : undefined),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Write the employee role the conversation collected.
|
||||
*
|
||||
* The supply-side twin of `create_position`. `status` is the WORKER's
|
||||
* situation rather than how finished the record is, so the conversation's one
|
||||
* verb commits it as `seeking` — there is no draft of a person's own role.
|
||||
*/
|
||||
/**
|
||||
* Record a NEW employee and their first declared role.
|
||||
*
|
||||
* The payload is two nested records because the endpoint writes two rows in
|
||||
* one transaction. Refused outright without a name and an email: those are
|
||||
* the person, and neither is ever derived from the other or from the caller.
|
||||
*/
|
||||
create_employee_role: ({ draft, status }) => {
|
||||
const record = { ...(draft || {}) };
|
||||
if (!String(record.worker_name || '').trim() || !String(record.worker_email || '').trim()) return null;
|
||||
return {
|
||||
type: 'create_employee_role',
|
||||
data: toNewWorkerWithRolePayload({ ...record, status: status || 'seeking' }),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the Add Skill Training flow, on the Forge page, with what the request
|
||||
* already answered. Routing to the page carries the intent in navigation
|
||||
|
||||
360
src/lib/skills/conversationFlow.js
Normal file
360
src/lib/skills/conversationFlow.js
Normal file
@@ -0,0 +1,360 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
|
||||
/**
|
||||
* A skill's questions, asked one at a time, as a conversation.
|
||||
*
|
||||
* This is the engine `positionFlow.js` used to be. It was five things in one
|
||||
* file — a field table, an `@`-token resolver, a sentence extractor, a commit
|
||||
* vocabulary and a set of outcome renderers — and only the control flow between
|
||||
* them was general. Everything else knew it was creating a job posting.
|
||||
*
|
||||
* So the control flow lives here and the five domain concerns live in a
|
||||
* REGISTRY, one per kind of record. Adding a second conversation is writing a
|
||||
* second registry; it is not editing this file. That is the same rule §3 states
|
||||
* for agents, applied one level down: the skill file declares the questions, the
|
||||
* registry says what each field means, and this decides what to say next.
|
||||
*
|
||||
* WHAT A REGISTRY PROVIDES
|
||||
*
|
||||
* fields { [field]: { label, settled, retry, parse, summary } }
|
||||
* resolve an `@token` from the skill file → the chips it stands for
|
||||
* prefill a first draft read out of the opening request
|
||||
* extract one sentence read against every field at once (see `restate`)
|
||||
* verbs what the reader can say at the summary, and what each commits
|
||||
* copy the wording of the summary, the cancel and the failure
|
||||
*
|
||||
* A flow is a plain serializable object: it survives sessionStorage between
|
||||
* turns and holds no component state, so a conversation resumes where it
|
||||
* stopped. Nothing here writes. The last step returns a draft and the panel
|
||||
* performs the mutation.
|
||||
*/
|
||||
|
||||
/* ── Reading answers ────────────────────────────────────────────────────── */
|
||||
|
||||
/** "Skip" is an answer to an optional question — it settles it, unanswered. */
|
||||
export const SKIP = /^(?:skip|skip this|skip it|no preference|not sure|does ?n(?:'|o)t matter|any|none of these)$/i;
|
||||
|
||||
/** A chip that means "let me type it" rather than an answer in itself. */
|
||||
export const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere else)$/i;
|
||||
|
||||
/** Title Case, for the free-text answers that name a proper noun. */
|
||||
export const titleCase = (value) => value.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
/** A short free-text answer, stripped of the words around it. */
|
||||
export function cleanPhrase(answer, max = 60) {
|
||||
const value = String(answer)
|
||||
.trim()
|
||||
.replace(/^(?:in|at|around|near|it is|its|it's)\s+/i, '')
|
||||
.replace(/[.!?,;]+$/, '')
|
||||
.replace(/\s+/g, ' ');
|
||||
return value && value.length <= max ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One spoken reply, reduced to the form the vocabularies below are matched on.
|
||||
*
|
||||
* This exists because the confirmation step used to test the raw answer against
|
||||
* an ANCHORED regex, which meant "create position" committed and "create
|
||||
* positions" — the plural, and the wording the Positions page itself uses —
|
||||
* fell through to the not-understood branch. The reader saw the summary again
|
||||
* with no indication of what was wrong with what they said.
|
||||
*
|
||||
* Lower case, single-spaced, trailing punctuation removed. Matching is then
|
||||
* exact set membership rather than a pattern, so a vocabulary is a list of
|
||||
* phrases somebody can read rather than an expression somebody has to parse.
|
||||
*/
|
||||
export const normalizeReply = (said) => String(said)
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[.!?,;]+$/, '')
|
||||
.trim();
|
||||
|
||||
/** Ending the conversation without finishing it. */
|
||||
const CANCEL = new Set([
|
||||
'cancel', 'stop', 'never mind', 'nevermind', 'forget it', 'quit', 'exit',
|
||||
]);
|
||||
|
||||
/** Going back to a detail already given. */
|
||||
const CHANGE = new Set([
|
||||
'change', 'change details', 'edit', 'change something', 'change a detail', 'no',
|
||||
]);
|
||||
|
||||
/* ── Flow state ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The steps a skill declares, keeping only the fields this registry understands.
|
||||
*
|
||||
* A field the registry does not know is dropped, which is deliberate — but it
|
||||
* used to be dropped SILENTLY, and a skill whose steps were all unknown asked
|
||||
* nothing and went straight to a summary of an empty record. `npm test` now
|
||||
* refuses a conversation step whose field no registry defines, so the drop here
|
||||
* only ever removes a field a shipped skill does not have.
|
||||
*/
|
||||
export const stepsOf = (registry, skill) => (skill?.conversation || [])
|
||||
.filter((s) => registry.fields[s.field]);
|
||||
|
||||
/** Is this field answered, or deliberately passed over? */
|
||||
export const isSettled = (registry, flow, field) => registry.fields[field].settled(flow.draft)
|
||||
|| flow.skipped.includes(field);
|
||||
|
||||
/** The next question, or `null` when there is nothing left to ask. */
|
||||
export function nextStep(registry, flow, steps) {
|
||||
if (flow.editing) return steps.find((s) => s.field === flow.editing) || null;
|
||||
return steps.find((s) => !isSettled(registry, flow, s.field)) || null;
|
||||
}
|
||||
|
||||
/** Everything decided so far, one line each, in the order it is asked. */
|
||||
export function summaryLines(registry, flow, steps) {
|
||||
return steps
|
||||
.filter((s) => isSettled(registry, flow, s.field))
|
||||
.map((s) => registry.fields[s.field].summary(flow.draft))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** The required fields the record cannot be created without. */
|
||||
export const missingRequired = (registry, flow, steps) => steps
|
||||
.filter((s) => s.required && !registry.fields[s.field].settled(flow.draft));
|
||||
|
||||
/* ── Suggestions ────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A step's chips.
|
||||
*
|
||||
* `@`-prefixed entries in the skill file resolve through the registry to the
|
||||
* lists the application already owns, so the roles offered here are the roles
|
||||
* the form offers and a certification added to the record's options appears in
|
||||
* the conversation without this file or the skill file changing.
|
||||
*
|
||||
* An `@token` the registry cannot resolve contributes nothing rather than
|
||||
* appearing as the literal "@workers", which is what it did before registries
|
||||
* existed and each resolver knew every token.
|
||||
*/
|
||||
function suggestionsFor(registry, step, ctx, draft = {}) {
|
||||
const resolved = step.options.flatMap((option) => {
|
||||
if (!option.startsWith('@')) return [option];
|
||||
/* The DRAFT is passed as well as the context, and it is what makes an
|
||||
option list able to depend on an earlier answer. Certifications are the
|
||||
case that forced it: which ones matter is a fact about the role chosen
|
||||
two questions ago, and a resolver that only saw the page's data could
|
||||
never know it. Recomputed on every ask, so changing the role from the
|
||||
change menu recomputes rather than reusing what the last role produced. */
|
||||
return registry.resolve?.(option, ctx, draft) || [];
|
||||
});
|
||||
|
||||
const capped = [...new Set(resolved.filter(Boolean))].slice(0, 6);
|
||||
/* An optional question needs a way past it that is not a typed sentence. */
|
||||
if (!step.required) capped.push('Skip');
|
||||
return capped.map((label) => ({ label, prompt: label }));
|
||||
}
|
||||
|
||||
/* ── Replies ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Ask the next question, or read the record back when there is none left. */
|
||||
function ask(registry, flow, steps, ctx, { preamble = null, retry = null } = {}) {
|
||||
const step = nextStep(registry, flow, steps);
|
||||
|
||||
if (!step) return review(registry, flow, steps);
|
||||
|
||||
const known = summaryLines(registry, flow, steps);
|
||||
return {
|
||||
flow: { ...flow, stage: 'collect', step: step.field },
|
||||
doc: doc(
|
||||
preamble ? text(preamble) : null,
|
||||
preamble && known.length ? list(known) : null,
|
||||
text(step.question),
|
||||
retry ? note(retry) : null
|
||||
),
|
||||
followUp: suggestionsFor(registry, step, ctx, flow.draft),
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole record, before anything is written. */
|
||||
function review(registry, flow, steps) {
|
||||
const missing = missingRequired(registry, flow, steps);
|
||||
/* Only reachable if a required answer was cleared — ask for it rather than
|
||||
offering to create something incomplete. */
|
||||
if (missing.length) {
|
||||
return {
|
||||
flow: { ...flow, stage: 'collect', step: missing[0].field, editing: null },
|
||||
doc: doc(text(missing[0].question)),
|
||||
followUp: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
flow: { ...flow, stage: 'review', step: null, editing: null },
|
||||
doc: doc(
|
||||
text(registry.copy.reviewQuestion),
|
||||
list(summaryLines(registry, flow, steps)),
|
||||
note(registry.copy.reviewNote)
|
||||
),
|
||||
followUp: registry.verbs
|
||||
.filter((v) => v.chip)
|
||||
.map((v) => ({ label: v.chip, prompt: v.chip }))
|
||||
.concat([{ label: 'Change details', prompt: 'Change details' }]),
|
||||
};
|
||||
}
|
||||
|
||||
/** Which detail to change — the answers already given, as chips. */
|
||||
function changeMenu(registry, flow, steps) {
|
||||
const settled = steps.filter((s) => isSettled(registry, flow, s.field));
|
||||
return {
|
||||
flow: { ...flow, stage: 'change', step: null },
|
||||
doc: doc(text('What should I change?')),
|
||||
followUp: settled.map((s) => ({
|
||||
label: registry.fields[s.field].label,
|
||||
prompt: registry.fields[s.field].label,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A sentence read against every field at once.
|
||||
*
|
||||
* This is what makes the conversation forgiving: an answer that arrives out of
|
||||
* order, or a correction stated rather than chosen from a menu, lands in the
|
||||
* right field instead of being rejected for not answering the question asked.
|
||||
* Returns an updated flow, or `null` when the sentence settles nothing.
|
||||
*
|
||||
* The reading itself is the registry's — a sentence about a job posting and a
|
||||
* sentence about a worker's role name different things — and only the bookkeeping
|
||||
* around it is general.
|
||||
*/
|
||||
function restate(registry, flow, steps, said, ctx) {
|
||||
const fields = new Set(steps.map((s) => s.field));
|
||||
const result = registry.extract?.(said, ctx, fields);
|
||||
if (!result || !result.touched?.size) return null;
|
||||
|
||||
return {
|
||||
...flow,
|
||||
draft: { ...flow.draft, ...result.patch },
|
||||
/* Step names, not record fields — a step the sentence settled must come off
|
||||
the skipped list so the summary shows it. */
|
||||
skipped: flow.skipped.filter((f) => !result.touched.has(f)),
|
||||
editing: null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Entry points ───────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Start collecting, from whatever the request already said.
|
||||
*
|
||||
* "Create a bartender position in Chennai paying $30–$40/hr" answers three
|
||||
* questions before the first one is asked, and those are not asked again.
|
||||
*/
|
||||
export function beginFlow({ registry, question, skill, ctx = {} }) {
|
||||
const steps = stepsOf(registry, skill);
|
||||
|
||||
const flow = {
|
||||
flowId: registry.id,
|
||||
skillId: skill.id,
|
||||
draft: registry.prefill(question, ctx),
|
||||
skipped: [],
|
||||
editing: null,
|
||||
stage: 'collect',
|
||||
step: null,
|
||||
};
|
||||
|
||||
const known = summaryLines(registry, flow, steps);
|
||||
return ask(registry, flow, steps, ctx, {
|
||||
preamble: known.length ? 'Got it — here is what I have so far:' : null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One answer, and whatever it makes the next thing to say.
|
||||
*
|
||||
* Returns `{ flow, doc, followUp }`, and on the confirmation step additionally
|
||||
* `create: { draft, status }` — the panel writes it, this module does not.
|
||||
*/
|
||||
export function advanceFlow({ registry, flow, answer, skill, ctx = {} }) {
|
||||
const steps = stepsOf(registry, skill);
|
||||
const said = String(answer).trim();
|
||||
const reply = normalizeReply(said);
|
||||
|
||||
/* A way out that does not require finishing. `flow: null` ends it, and the
|
||||
next question is answered by the page as usual. */
|
||||
if (CANCEL.has(reply)) {
|
||||
return {
|
||||
flow: null,
|
||||
doc: doc(text(registry.copy.cancelled), note(registry.copy.cancelledNote)),
|
||||
followUp: [],
|
||||
};
|
||||
}
|
||||
|
||||
/* The confirmation step. A verb is the only path to a record.
|
||||
Change is tested before the verbs so that a bare "no" reaches the change
|
||||
menu rather than being read as a refusal to commit. */
|
||||
if (flow.stage === 'review') {
|
||||
if (CHANGE.has(reply)) return changeMenu(registry, flow, steps);
|
||||
|
||||
const verb = registry.verbs.find((v) => v.says.has(reply));
|
||||
if (verb) {
|
||||
if (missingRequired(registry, flow, steps).length) return review(registry, flow, steps);
|
||||
return {
|
||||
flow: { ...flow, stage: 'creating' },
|
||||
create: { draft: flow.draft, status: verb.status },
|
||||
};
|
||||
}
|
||||
|
||||
/* Anything else at the confirmation step is a correction stated outright —
|
||||
"make it Bengaluru", "$32–$40". Read it against every field and apply
|
||||
what it settles, rather than making the user find the menu. */
|
||||
const revised = restate(registry, flow, steps, said, ctx);
|
||||
if (revised) return review(registry, revised, steps);
|
||||
|
||||
return {
|
||||
...review(registry, flow, steps),
|
||||
doc: doc(
|
||||
text(`I did not catch that. ${registry.copy.reviewQuestion}`),
|
||||
list(summaryLines(registry, flow, steps)),
|
||||
note(registry.copy.reviewRetryNote)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/* Choosing which detail to revisit. */
|
||||
if (flow.stage === 'change') {
|
||||
const target = steps.find((s) => registry.fields[s.field].label.toLowerCase() === reply);
|
||||
if (!target) {
|
||||
const revised = restate(registry, flow, steps, said, ctx);
|
||||
if (revised) return review(registry, revised, steps);
|
||||
return changeMenu(registry, flow, steps);
|
||||
}
|
||||
return ask(registry, { ...flow, editing: target.field }, steps, ctx);
|
||||
}
|
||||
|
||||
/* Answering the question that was asked. */
|
||||
const step = steps.find((s) => s.field === flow.step) || nextStep(registry, flow, steps);
|
||||
if (!step) return review(registry, flow, steps);
|
||||
|
||||
const field = registry.fields[step.field];
|
||||
|
||||
if (SKIP.test(said) && !step.required) {
|
||||
const next = { ...flow, skipped: [...flow.skipped, step.field], editing: null };
|
||||
return ask(registry, next, steps, ctx);
|
||||
}
|
||||
|
||||
const patch = field.parse(said, ctx);
|
||||
if (!patch) {
|
||||
/* Not an answer to this question — but it may still be a fact about the
|
||||
record ("in Chennai" while being asked for pay). Take it if so. */
|
||||
const revised = restate(registry, flow, steps, said, ctx);
|
||||
if (revised) return ask(registry, revised, steps, ctx);
|
||||
return ask(registry, flow, steps, ctx, { retry: field.retry });
|
||||
}
|
||||
|
||||
const next = {
|
||||
...flow,
|
||||
draft: { ...flow.draft, ...patch },
|
||||
skipped: flow.skipped.filter((f) => f !== step.field),
|
||||
editing: null,
|
||||
};
|
||||
|
||||
/* A detail revisited from the change menu goes straight back to the summary
|
||||
rather than walking the rest of the questions again. */
|
||||
if (flow.editing) return review(registry, next, steps);
|
||||
|
||||
return ask(registry, next, steps, ctx);
|
||||
}
|
||||
388
src/lib/skills/flows/employeeRole.js
Normal file
388
src/lib/skills/flows/employeeRole.js
Normal file
@@ -0,0 +1,388 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
import { ENGLISH_LEVELS, certificationsForRole } from '@/lib/positionModel';
|
||||
import {
|
||||
AVAILABILITY_OPTIONS, desiredPayLabel,
|
||||
} from '@/lib/employeeRoleModel';
|
||||
import {
|
||||
extractCertifications, extractEnglish, extractExperience, extractPay, extractRole,
|
||||
} from '../actions';
|
||||
import { cleanPhrase, titleCase } from '../conversationFlow';
|
||||
|
||||
/**
|
||||
* What an employee role's questions mean.
|
||||
*
|
||||
* The supply side. Its sibling registry, `position.js`, is the demand side —
|
||||
* what the organization needs filled. The distinction matters more than the
|
||||
* shared vocabulary suggests: "3 years" on a posting is a MINIMUM the applicant
|
||||
* must clear, and the same words here are what the person HAS. Collapsing them
|
||||
* into one registry with a mode flag would put that difference in a branch
|
||||
* rather than in a field.
|
||||
*
|
||||
* The worker is never derived from the session. An operator records a role on
|
||||
* somebody's behalf, so the subject is answered explicitly — which is also why
|
||||
* `employee-roles` grants Create to operators only. See the policy note in
|
||||
* `internal/domain/policy.go`.
|
||||
*/
|
||||
const fields = {
|
||||
/**
|
||||
* Whose role this is.
|
||||
*
|
||||
* Settled by the email, not the name: the email is what the row is scoped on
|
||||
* and what survives a worker profile being removed. A name with no email
|
||||
* behind it is not an answer, so `parse` refuses one it cannot resolve.
|
||||
*/
|
||||
/**
|
||||
* The new employee's name.
|
||||
*
|
||||
* A name and nothing more. It is NOT looked up, because a name cannot select
|
||||
* anybody: an organization may employ any number of people who share one, and
|
||||
* the previous version of this field searched the existing workers for a name
|
||||
* match and attached the role to the first hit. With several people of the
|
||||
* same name that silently filed the role against the wrong person; with a
|
||||
* name nobody had, it understood nothing and asked the same question again,
|
||||
* which is the loop this flow was stuck in.
|
||||
*/
|
||||
worker_name: {
|
||||
label: 'Name',
|
||||
settled: (draft) => Boolean(String(draft.worker_name || '').trim()),
|
||||
retry: 'Type the new employee\u2019s full name.',
|
||||
parse: (answer) => {
|
||||
const name = String(answer).trim().replace(/\s+/g, ' ');
|
||||
return name.length >= 2 ? { worker_name: name } : null;
|
||||
},
|
||||
summary: (draft) => `Name: ${draft.worker_name}`,
|
||||
},
|
||||
|
||||
/**
|
||||
* The new employee's email, which is their identity.
|
||||
*
|
||||
* Asked outright and never derived. There is no rule anywhere that turns a
|
||||
* name into an address, no fallback to the operator's own account, and no
|
||||
* reuse of anything an earlier conversation collected — an invented address
|
||||
* is a real person's record filed under something they do not own.
|
||||
*
|
||||
* `worker_profiles` carries UNIQUE (org_id, email) over a `citext` column, so
|
||||
* this value is what decides whether the person already exists. The check is
|
||||
* the database's, not this field's: two operators recording the same person
|
||||
* at the same moment cannot both win, whatever either browser believed.
|
||||
*/
|
||||
worker_email: {
|
||||
label: 'Email',
|
||||
settled: (draft) => Boolean(String(draft.worker_email || '').trim()),
|
||||
retry: 'Type the employee\u2019s email address — it is how the record is identified.',
|
||||
parse: (answer) => {
|
||||
const said = String(answer).trim();
|
||||
/* The whole answer must be the address. Pulling one out of a sentence
|
||||
would accept "I don't know, maybe bob@x.com" as a considered answer. */
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(said)
|
||||
? { worker_email: said }
|
||||
: null;
|
||||
},
|
||||
summary: (draft) => `Email: ${draft.worker_email}`,
|
||||
},
|
||||
|
||||
role_category: {
|
||||
label: 'Role',
|
||||
settled: (draft) => Boolean(String(draft.role_category || '').trim()),
|
||||
retry: 'Name the role they work as — "bartender", "server", "line cook".',
|
||||
parse: (answer, { roles = [] }) => {
|
||||
const role = extractRole(answer, roles) || cleanPhrase(answer, 40);
|
||||
if (!role || role.length < 2) return null;
|
||||
const known = roles.find((r) => String(r).toLowerCase() === role.toLowerCase());
|
||||
return { role_category: known || titleCase(role) };
|
||||
},
|
||||
summary: (draft) => `Role: ${draft.role_category}`,
|
||||
},
|
||||
|
||||
experience_years: {
|
||||
label: 'Experience',
|
||||
settled: (draft) => draft.experience_years !== undefined && draft.experience_years !== null,
|
||||
retry: 'How many years — or "no experience".',
|
||||
parse: (answer) => {
|
||||
const years = extractExperience(answer);
|
||||
if (years !== null) return { experience_years: years };
|
||||
const bare = /^(\d{1,2})\s*\+?$/.exec(String(answer).trim());
|
||||
if (bare) return { experience_years: Number(bare[1]) };
|
||||
return /^(?:none|no experience|new|fresher)$/i.test(String(answer).trim())
|
||||
? { experience_years: 0 }
|
||||
: null;
|
||||
},
|
||||
summary: (draft) => (draft.experience_years
|
||||
? `${draft.experience_years} years experience`
|
||||
: 'No experience yet'),
|
||||
},
|
||||
|
||||
english_level: {
|
||||
label: 'English',
|
||||
settled: (draft) => Boolean(draft.english_level),
|
||||
retry: `One of ${ENGLISH_LEVELS.map((l) => l.label).join(', ')}.`,
|
||||
parse: (answer) => {
|
||||
const level = extractEnglish(answer)
|
||||
|| ENGLISH_LEVELS.find((l) => l.label.toLowerCase() === String(answer).trim().toLowerCase())?.value;
|
||||
return level ? { english_level: level } : null;
|
||||
},
|
||||
summary: (draft) => {
|
||||
const level = ENGLISH_LEVELS.find((l) => l.value === draft.english_level);
|
||||
return level ? `English: ${level.label}` : null;
|
||||
},
|
||||
},
|
||||
|
||||
certifications: {
|
||||
label: 'Certifications',
|
||||
settled: (draft) => Array.isArray(draft.certifications),
|
||||
retry: 'Name a certification they hold, or "none".',
|
||||
parse: (answer) => {
|
||||
const found = extractCertifications(answer);
|
||||
if (found) return { certifications: found };
|
||||
return /^(?:none|no|no certifications?)$/i.test(String(answer).trim())
|
||||
? { certifications: [] }
|
||||
: null;
|
||||
},
|
||||
summary: (draft) => (draft.certifications?.length
|
||||
? `Certifications: ${draft.certifications.join(', ')}`
|
||||
: null),
|
||||
},
|
||||
|
||||
desired_pay: {
|
||||
label: 'Desired pay',
|
||||
settled: (draft) => Number(draft.desired_pay_min) > 0 || Number(draft.desired_pay_max) > 0,
|
||||
retry: 'Give a range like "$25–$35/hr", or a single rate.',
|
||||
parse: (answer) => {
|
||||
const pay = extractPay(answer);
|
||||
return pay ? { desired_pay_min: pay.min, desired_pay_max: pay.max } : null;
|
||||
},
|
||||
summary: (draft) => {
|
||||
const label = desiredPayLabel(draft);
|
||||
return label ? `Looking for ${label}` : null;
|
||||
},
|
||||
},
|
||||
|
||||
availability: {
|
||||
label: 'Availability',
|
||||
settled: (draft) => Array.isArray(draft.availability),
|
||||
retry: 'When can they work — weekdays, weekends, evenings, full time?',
|
||||
parse: (answer) => {
|
||||
const said = String(answer).toLowerCase();
|
||||
const found = AVAILABILITY_OPTIONS.filter((o) => said.includes(o.toLowerCase()));
|
||||
if (found.length) return { availability: found };
|
||||
const value = cleanPhrase(answer, 40);
|
||||
return value ? { availability: [titleCase(value)] } : null;
|
||||
},
|
||||
summary: (draft) => (draft.availability?.length
|
||||
? `Available: ${draft.availability.join(', ')}`
|
||||
: null),
|
||||
},
|
||||
|
||||
notes: {
|
||||
label: 'Notes',
|
||||
settled: (draft) => typeof draft.notes === 'string' && draft.notes !== '',
|
||||
retry: 'Anything worth recording, or "skip".',
|
||||
parse: (answer) => {
|
||||
const value = cleanPhrase(answer, 280);
|
||||
return value ? { notes: value } : null;
|
||||
},
|
||||
summary: (draft) => (draft.notes ? `Notes: ${draft.notes}` : null),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The `@` tokens this conversation's skill file may use.
|
||||
*
|
||||
* `@workers` is the worker profiles the panel has already loaded for this
|
||||
* caller — org-scoped by the API, and nothing here widens that view.
|
||||
*/
|
||||
const resolve = (token, { roles = [], postings = null }, draft = {}) => {
|
||||
switch (token) {
|
||||
case '@roles': return roles;
|
||||
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
|
||||
/**
|
||||
* Only the certifications that matter for the role just chosen.
|
||||
*
|
||||
* Never the global list. A worker declaring themselves a Chef is asked
|
||||
* about the certifications this organization puts on its Chef postings, and
|
||||
* about nothing else — offering `Guard Card` there is the assistant
|
||||
* inventing a requirement, and a chip is a suggestion the reader is
|
||||
* entitled to read as informed.
|
||||
*
|
||||
* `null` back from the helper means the postings have not arrived; `[]`
|
||||
* means this role genuinely has none. Both produce no chips here, and
|
||||
* neither falls back to a global list — the step is optional, so `Skip` is
|
||||
* offered by the engine either way and the reader can still type one.
|
||||
*/
|
||||
case '@certifications': return certificationsForRole(draft.role_category, postings) || [];
|
||||
case '@availability': return AVAILABILITY_OPTIONS;
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* One sentence read against every field at once.
|
||||
*
|
||||
* Deliberately narrower than the posting's. A worker is never inferred from a
|
||||
* loose sentence: "bartender, weekends, $30/hr" settles three fields and leaves
|
||||
* the subject alone, because guessing WHO a record is about from a fragment is
|
||||
* how a role gets filed against the wrong person.
|
||||
*/
|
||||
function extract(said, ctx, fieldNames) {
|
||||
const patch = {};
|
||||
const touched = new Set();
|
||||
|
||||
if (fieldNames.has('role_category')) {
|
||||
const role = extractRole(said, ctx.roles || []);
|
||||
if (role) {
|
||||
patch.role_category = role;
|
||||
touched.add('role_category');
|
||||
}
|
||||
}
|
||||
if (fieldNames.has('desired_pay')) {
|
||||
const pay = extractPay(said);
|
||||
if (pay) {
|
||||
patch.desired_pay_min = pay.min;
|
||||
patch.desired_pay_max = pay.max;
|
||||
touched.add('desired_pay');
|
||||
}
|
||||
}
|
||||
if (fieldNames.has('experience_years')) {
|
||||
const years = extractExperience(said);
|
||||
if (years !== null) {
|
||||
patch.experience_years = years;
|
||||
touched.add('experience_years');
|
||||
}
|
||||
}
|
||||
if (fieldNames.has('english_level')) {
|
||||
const level = extractEnglish(said);
|
||||
if (level) {
|
||||
patch.english_level = level;
|
||||
touched.add('english_level');
|
||||
}
|
||||
}
|
||||
if (fieldNames.has('certifications')) {
|
||||
const certs = extractCertifications(said);
|
||||
if (certs) {
|
||||
patch.certifications = certs;
|
||||
touched.add('certifications');
|
||||
}
|
||||
}
|
||||
if (fieldNames.has('availability')) {
|
||||
const lower = String(said).toLowerCase();
|
||||
const found = AVAILABILITY_OPTIONS.filter((o) => lower.includes(o.toLowerCase()));
|
||||
if (found.length) {
|
||||
patch.availability = found;
|
||||
touched.add('availability');
|
||||
}
|
||||
}
|
||||
|
||||
return { patch, touched };
|
||||
}
|
||||
|
||||
/* ── Outcomes ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The role exists. Said plainly, with what was recorded. */
|
||||
const createdReply = (record) => doc(
|
||||
text('Employee role recorded.'),
|
||||
list([
|
||||
record.worker_name || record.worker_email,
|
||||
record.role_category,
|
||||
record.experience_years ? `${record.experience_years} years experience` : null,
|
||||
desiredPayLabel(record),
|
||||
].filter(Boolean)),
|
||||
note('It is on the Talent Pool now — this worker can be matched against open positions by this role.')
|
||||
);
|
||||
|
||||
/**
|
||||
* The write failed. The answers are kept, so nothing has to be retyped.
|
||||
*
|
||||
* The server's own wording is said out loud when there is one, for the same
|
||||
* reason it is on the posting side: a validation error naming a field, a
|
||||
* refused role and an API that is not running are three different problems and
|
||||
* one sentence cannot tell them apart.
|
||||
*/
|
||||
const failedReply = (reason = null) => {
|
||||
const said = String(reason || '').trim();
|
||||
return doc(
|
||||
text('I could not record that employee role.'),
|
||||
said ? note(said) : null,
|
||||
note('Nothing was saved. Choose Create employee role to try again.')
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* What is worth asking once the role exists.
|
||||
*
|
||||
* The obvious next question is which open positions this person now matches,
|
||||
* and that is an ordinary question the workforce engine already answers — so it
|
||||
* is offered as words rather than as a handler of its own.
|
||||
*/
|
||||
const createdFollowUp = (record) => [
|
||||
{ label: 'Match positions', prompt: `Which positions suit ${record.worker_name || record.worker_email}?` },
|
||||
];
|
||||
|
||||
/* ── The registry ───────────────────────────────────────────────────────── */
|
||||
|
||||
export const employeeRoleRegistry = {
|
||||
id: 'employee-role',
|
||||
fields,
|
||||
resolve,
|
||||
extract,
|
||||
|
||||
/**
|
||||
* No prefill from the opening request, and an EMPTY draft rather than a
|
||||
* defaulted one.
|
||||
*
|
||||
* "Create an employee role" names nobody, and the posting flow's habit of
|
||||
* reading a role out of the request would settle `role_category` from the
|
||||
* word "role" in the phrase that started the conversation. The first question
|
||||
* is who this is about, and it is asked.
|
||||
*
|
||||
* Returning `defaultEmployeeRole()` here — the record's write-time defaults —
|
||||
* was worse than it looks. Every `settled` test asks whether a field HAS a
|
||||
* value, and the defaults give all of them one: `certifications: []` is an
|
||||
* array, `notes: ''` is a string, `experience_years: 0` is a number. So five
|
||||
* of the eight questions were answered before they were asked, and the
|
||||
* conversation went worker → role → pay and stopped. The certification step
|
||||
* could not be reached at all.
|
||||
*
|
||||
* The two are different things wearing the same shape: write-time defaults
|
||||
* are what a MISSING answer becomes, and a conversation must be able to tell
|
||||
* missing from answered. `toEmployeeRolePayload` still applies them at the
|
||||
* write, so nothing is lost by starting empty.
|
||||
*/
|
||||
prefill: () => ({}),
|
||||
|
||||
/**
|
||||
* One verb, because there is one outcome. A declared role has no draft state:
|
||||
* `status` is about the WORKER's situation — seeking, placed, inactive — not
|
||||
* about how finished the record is, which is why 'draft' has no meaning here
|
||||
* and is not offered.
|
||||
*/
|
||||
verbs: [
|
||||
{
|
||||
chip: 'Create employee role',
|
||||
status: 'seeking',
|
||||
says: new Set([
|
||||
'create employee role', 'create an employee role', 'create the employee role',
|
||||
'create employee roles', 'add employee role', 'add an employee role',
|
||||
'create role', 'create it', 'create', 'save', 'save it',
|
||||
'yes', 'confirm', 'looks good', 'go ahead',
|
||||
]),
|
||||
},
|
||||
],
|
||||
|
||||
outcome: {
|
||||
created: createdReply,
|
||||
failed: failedReply,
|
||||
followUp: createdFollowUp,
|
||||
retryChips: [
|
||||
{ label: 'Create employee role', prompt: 'Create employee role' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
},
|
||||
|
||||
copy: {
|
||||
reviewQuestion: 'Ready to record this employee role?',
|
||||
reviewNote: 'Nothing is saved until you choose. The worker can hold more than one role — recording this does not replace an existing one.',
|
||||
reviewRetryNote: 'Choose Create employee role, or tell me what to change.',
|
||||
cancelled: 'Stopped — nothing was recorded.',
|
||||
cancelledNote: 'Ask me to create an employee role whenever you are ready.',
|
||||
},
|
||||
};
|
||||
18
src/lib/skills/flows/index.js
Normal file
18
src/lib/skills/flows/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { employeeRoleRegistry } from './employeeRole';
|
||||
import { positionRegistry } from './position';
|
||||
|
||||
/**
|
||||
* Every conversation a skill can be bound to, by the id its `flow:` names.
|
||||
*
|
||||
* A skill declares `flow: position`; this is what that word resolves to. Adding
|
||||
* a conversation is adding a registry here and a `flow:` line in the skill file
|
||||
* — there is no branch in the router, and nothing in the engine learns a new
|
||||
* name. That is §3's "specs are data" applied to conversations.
|
||||
*/
|
||||
export const FLOWS = {
|
||||
[positionRegistry.id]: positionRegistry,
|
||||
[employeeRoleRegistry.id]: employeeRoleRegistry,
|
||||
};
|
||||
|
||||
/** The registry a parsed skill is bound to, or `null` if it declares none. */
|
||||
export const flowFor = (skill) => (skill?.flow ? FLOWS[skill.flow] || null : null);
|
||||
326
src/lib/skills/flows/position.js
Normal file
326
src/lib/skills/flows/position.js
Normal file
@@ -0,0 +1,326 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
import { ENGLISH_LEVELS, certificationsForRole, payLabel } from '@/lib/positionModel';
|
||||
import {
|
||||
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
|
||||
extractLocation, extractPay, extractRole,
|
||||
} from '../actions';
|
||||
import { FREE_TEXT, cleanPhrase, titleCase } from '../conversationFlow';
|
||||
|
||||
/**
|
||||
* What a job posting's questions mean.
|
||||
*
|
||||
* The demand side: what the organization needs filled. Its sibling registry,
|
||||
* `employeeRole.js`, is the supply side — what a worker says they do. They share
|
||||
* a vocabulary and almost nothing else, which is why they are two registries
|
||||
* over one engine rather than one registry with a mode flag.
|
||||
*
|
||||
* `parse` returns a patch for the draft, or `null` when the answer was not
|
||||
* understood — which re-asks with `retry` rather than storing a guess.
|
||||
*/
|
||||
const fields = {
|
||||
/**
|
||||
* The client this role is being staffed for.
|
||||
*
|
||||
* A "client" is not a record of its own in this product — it is the `company`
|
||||
* on the position, which is the field the Create Position form writes, the
|
||||
* Positions card leads with, and Hired History reports against. So the
|
||||
* conversation collects it into the same field rather than into a store of
|
||||
* its own, and asking Owliver to create a client starts here.
|
||||
*
|
||||
* Blueprint decision D2 (a `clients` table) is still open and this does not
|
||||
* pre-empt it: promoting company to a record later adds a nullable reference
|
||||
* beside this column and changes no endpoint.
|
||||
*/
|
||||
company: {
|
||||
label: 'Company',
|
||||
settled: (draft) => Boolean(String(draft.company || '').trim()),
|
||||
retry: 'Type the client or company name — "Fairmont San Jose".',
|
||||
parse: (answer) => {
|
||||
const value = cleanPhrase(answer, 60);
|
||||
return value && value.length > 1 ? { company: titleCase(value) } : null;
|
||||
},
|
||||
summary: (draft) => (draft.company ? `Company: ${draft.company}` : null),
|
||||
},
|
||||
|
||||
role_category: {
|
||||
label: 'Role',
|
||||
settled: (draft) => Boolean(draft.title),
|
||||
retry: 'Name the role — "bartender", "line cook", "event staff".',
|
||||
parse: (answer, { roles = [] }) => {
|
||||
const role = extractRole(answer, roles) || cleanPhrase(answer, 40);
|
||||
if (!role || role.length < 2) return null;
|
||||
const known = roles.find((r) => String(r).toLowerCase() === role.toLowerCase());
|
||||
/* A known category files the position; anything else becomes the title and
|
||||
leaves the category at its default, exactly as the form behaves. */
|
||||
return known
|
||||
? { role_category: known, title: known }
|
||||
: { title: titleCase(role) };
|
||||
},
|
||||
summary: (draft) => (draft.role_category && draft.role_category !== draft.title
|
||||
? `${draft.title} · ${draft.role_category}`
|
||||
: draft.title),
|
||||
},
|
||||
|
||||
location: {
|
||||
label: 'Location',
|
||||
settled: (draft) => Boolean(draft.location),
|
||||
retry: 'Type the city or area this role is based in.',
|
||||
parse: (answer) => {
|
||||
if (FREE_TEXT.test(String(answer).trim())) return null;
|
||||
const value = extractLocation(answer) || cleanPhrase(answer);
|
||||
return value ? { location: titleCase(value) } : null;
|
||||
},
|
||||
summary: (draft) => draft.location,
|
||||
},
|
||||
|
||||
pay: {
|
||||
label: 'Pay',
|
||||
settled: (draft) => Number(draft.pay_range_min) > 0 || Number(draft.pay_range_max) > 0,
|
||||
retry: 'Give a range like "$28–$36/hr", or a single rate.',
|
||||
parse: (answer) => {
|
||||
const pay = extractPay(answer);
|
||||
return pay ? { pay_range_min: String(pay.min), pay_range_max: String(pay.max) } : null;
|
||||
},
|
||||
summary: (draft) => payLabel(draft),
|
||||
},
|
||||
|
||||
min_experience_years: {
|
||||
label: 'Experience',
|
||||
settled: (draft) => draft.min_experience_years !== undefined && draft.min_experience_years !== null,
|
||||
retry: 'How many years — or "no minimum".',
|
||||
parse: (answer) => {
|
||||
const years = extractExperience(answer);
|
||||
if (years !== null) return { min_experience_years: years };
|
||||
const bare = /^(\d{1,2})\s*\+?$/.exec(String(answer).trim());
|
||||
return bare ? { min_experience_years: Number(bare[1]) } : null;
|
||||
},
|
||||
summary: (draft) => (draft.min_experience_years
|
||||
? `${draft.min_experience_years}+ years experience`
|
||||
: 'No minimum experience'),
|
||||
},
|
||||
|
||||
english_required: {
|
||||
label: 'English',
|
||||
settled: (draft) => Boolean(draft.english_required),
|
||||
retry: `One of ${ENGLISH_LEVELS.map((l) => l.label).join(', ')}.`,
|
||||
parse: (answer) => {
|
||||
const level = extractEnglish(answer)
|
||||
|| ENGLISH_LEVELS.find((l) => l.label.toLowerCase() === String(answer).trim().toLowerCase())?.value;
|
||||
return level ? { english_required: level } : null;
|
||||
},
|
||||
summary: (draft) => {
|
||||
const level = ENGLISH_LEVELS.find((l) => l.value === draft.english_required);
|
||||
return level ? `English: ${level.label}` : null;
|
||||
},
|
||||
},
|
||||
|
||||
certifications_required: {
|
||||
label: 'Certifications',
|
||||
settled: (draft) => Array.isArray(draft.certifications_required),
|
||||
retry: 'Name a certification, or "none".',
|
||||
parse: (answer) => {
|
||||
const found = extractCertifications(answer);
|
||||
if (found) return { certifications_required: found };
|
||||
if (/^(?:none|no|no certifications?)$/i.test(String(answer).trim())) {
|
||||
return { certifications_required: [] };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
summary: (draft) => (draft.certifications_required?.length
|
||||
? `Certifications: ${draft.certifications_required.join(', ')}`
|
||||
: null),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The `@` tokens this conversation's skill file may use.
|
||||
*
|
||||
* `@companies` is the clients this organization already staffs for, read off the
|
||||
* postings the caller can already see — so it offers an existing client without
|
||||
* a new endpoint, and typing a name that is not on the list is still how a new
|
||||
* client is named. Which is the whole of "create a company": there is no
|
||||
* company record to create, and creating an `organizations` row instead would
|
||||
* provision a TENANT the operator cannot then see, because every read is
|
||||
* predicated on the session's own org_id.
|
||||
*/
|
||||
const resolve = (token, { roles = [], companies = [], postings = null }, draft = {}) => {
|
||||
switch (token) {
|
||||
case '@roles': return roles;
|
||||
case '@companies': return companies;
|
||||
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
|
||||
/* The same rule as the role flow, from the same helper: what this
|
||||
organization already asks for on postings of this category. A position
|
||||
being written for a role it has never posted before offers none, which is
|
||||
honest — there is nothing to go on yet. */
|
||||
case '@certifications': return certificationsForRole(draft.role_category || draft.title, postings) || [];
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** One sentence read against every field of a posting at once. */
|
||||
function extract(said, { roles = [] }, fieldNames) {
|
||||
const { prefill } = buildPositionPrefill(said, roles);
|
||||
const patch = {};
|
||||
const touched = new Set();
|
||||
|
||||
if (fieldNames.has('role_category') && prefill.title) {
|
||||
if (prefill.role_category) patch.role_category = prefill.role_category;
|
||||
patch.title = prefill.title;
|
||||
touched.add('role_category');
|
||||
}
|
||||
if (fieldNames.has('location') && prefill.location) {
|
||||
patch.location = prefill.location;
|
||||
touched.add('location');
|
||||
}
|
||||
if (fieldNames.has('pay') && prefill.pay_range_min) {
|
||||
patch.pay_range_min = prefill.pay_range_min;
|
||||
patch.pay_range_max = prefill.pay_range_max;
|
||||
touched.add('pay');
|
||||
}
|
||||
if (fieldNames.has('min_experience_years') && prefill.min_experience_years !== undefined) {
|
||||
patch.min_experience_years = prefill.min_experience_years;
|
||||
touched.add('min_experience_years');
|
||||
}
|
||||
if (fieldNames.has('english_required') && prefill.english_required) {
|
||||
patch.english_required = prefill.english_required;
|
||||
touched.add('english_required');
|
||||
}
|
||||
if (fieldNames.has('certifications_required') && prefill.certifications_required) {
|
||||
patch.certifications_required = prefill.certifications_required;
|
||||
touched.add('certifications_required');
|
||||
}
|
||||
|
||||
return { patch, touched };
|
||||
}
|
||||
|
||||
/* ── Outcomes ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The position exists. Said plainly, with what was created. */
|
||||
function createdReply(position) {
|
||||
const isDraft = position.status === 'draft';
|
||||
|
||||
return doc(
|
||||
/* Draft and published are different outcomes, so they are named
|
||||
differently: one was saved, the other went live. */
|
||||
text(isDraft ? 'Saved as a draft.' : 'Position published successfully.'),
|
||||
list([
|
||||
position.company,
|
||||
position.title,
|
||||
position.location,
|
||||
payLabel(position),
|
||||
].filter(Boolean)),
|
||||
note(isDraft
|
||||
? 'It is on the Positions list as a draft — nobody can apply until it is published, and it stays a draft until you publish it.'
|
||||
: 'It is on the Positions list now — applications will start appearing against it.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The write failed. The draft is kept, so the answers are not lost.
|
||||
*
|
||||
* The server's own message is said out loud when there is one. This used to be
|
||||
* a fixed sentence, and a fixed sentence is the wrong answer to three different
|
||||
* failures: a validation error naming a field, a permission refusal, and an API
|
||||
* that is not running all read as "I could not create that position", leaving
|
||||
* the reader to guess which of the three they are looking at and what to change.
|
||||
*
|
||||
* The message comes from `KrowApiError.message`, which `httpClient` sets to the
|
||||
* server's wording verbatim — so the reason is the API's, not one invented here
|
||||
* from a status code.
|
||||
*/
|
||||
function failedReply(reason = null) {
|
||||
const said = String(reason || '').trim();
|
||||
|
||||
return doc(
|
||||
text('I could not create that position.'),
|
||||
said ? note(said) : null,
|
||||
note('Nothing was saved. Choose Create position to try again.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the panel offers after a position is created.
|
||||
*
|
||||
* A published role has an obvious next question — who can fill it — so it is
|
||||
* offered here rather than left to be typed. The chip carries the position's
|
||||
* own title and the wording the workforce engine already answers, so it is an
|
||||
* ordinary question resolved by the path that was already there: no handler,
|
||||
* no navigation, and the same answer as asking it by hand.
|
||||
*/
|
||||
const createdFollowUp = (position) => (position.status === 'draft'
|
||||
/**
|
||||
* A saved draft offers no action, and that is the fix.
|
||||
*
|
||||
* This used to offer "Continue to save", routing back to the Create Position
|
||||
* form. It made the completed state look unfinished: the reader had just been
|
||||
* told the position was saved, and was immediately asked to save it again —
|
||||
* by a button that put them back in the form they had just left. The write
|
||||
* has happened, the record exists with `status: draft`, and the way to finish
|
||||
* a draft later is its own card on the Positions list.
|
||||
*/
|
||||
? []
|
||||
: [
|
||||
{ label: 'View position', route: `/admin/positions/${position.id}` },
|
||||
{ label: 'Match candidates', prompt: `Who matches ${position.title}?` },
|
||||
]);
|
||||
|
||||
/* ── The registry ───────────────────────────────────────────────────────── */
|
||||
|
||||
export const positionRegistry = {
|
||||
id: 'position',
|
||||
fields,
|
||||
resolve,
|
||||
extract,
|
||||
prefill: (question, { roles = [] }) => buildPositionPrefill(question, roles).prefill,
|
||||
|
||||
/**
|
||||
* The two the form offers, in the same words and the same order, so the
|
||||
* conversation and the page commit a position the same two ways.
|
||||
*
|
||||
* `says` is exact membership against a normalized reply, not a pattern. The
|
||||
* anchored regex this replaced accepted "create position" and rejected
|
||||
* "create positions" — the plural the Positions page itself uses — with no
|
||||
* indication of what was wrong.
|
||||
*/
|
||||
verbs: [
|
||||
{
|
||||
chip: 'Save as Draft',
|
||||
status: 'draft',
|
||||
says: new Set(['save as draft', 'save draft', 'draft', 'save it as a draft']),
|
||||
},
|
||||
{
|
||||
chip: 'Publish Job Posting',
|
||||
status: 'active',
|
||||
says: new Set([
|
||||
'publish job posting', 'publish', 'publish it',
|
||||
'create position', 'create positions', 'create a position',
|
||||
'create the position', 'create this position', 'create it', 'create',
|
||||
'yes', 'confirm', 'looks good', 'go ahead',
|
||||
]),
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* How the outcome is said. The panel renders these without knowing what kind
|
||||
* of record it just wrote — which is what lets a second conversation report
|
||||
* its own result instead of borrowing a job posting's wording.
|
||||
*/
|
||||
outcome: {
|
||||
created: createdReply,
|
||||
failed: failedReply,
|
||||
followUp: createdFollowUp,
|
||||
/* Offered beside the failure, so a retry does not need retyping. */
|
||||
retryChips: [
|
||||
{ label: 'Create position', prompt: 'Create position' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
},
|
||||
|
||||
copy: {
|
||||
reviewQuestion: 'Ready to create this position?',
|
||||
reviewNote: 'Nothing is saved until you choose one. Save as Draft keeps it unpublished — the same as the button on the form.',
|
||||
reviewRetryNote: 'Choose Save as Draft or Publish Job Posting, or tell me what to change.',
|
||||
cancelled: 'Stopped — nothing was created.',
|
||||
cancelledNote: 'Ask me to create a position whenever you are ready.',
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,5 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
|
||||
import {
|
||||
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
|
||||
extractLocation, extractPay, extractRole,
|
||||
} from './actions';
|
||||
import { advanceFlow, beginFlow } from './conversationFlow';
|
||||
import { positionRegistry } from './flows/position';
|
||||
|
||||
/**
|
||||
* Creating a position, as a conversation.
|
||||
@@ -13,515 +9,44 @@ import {
|
||||
* described the job in one sentence to type the rest of it into a drawer. The
|
||||
* form was doing the asking, and the assistant was doing the paperwork.
|
||||
*
|
||||
* This module turns that round. The skill file lists the questions; this decides
|
||||
* what each field name means — how an answer is read, whether it is already
|
||||
* settled, and how it reads back in the summary. Same split as `actions.js`:
|
||||
* Markdown declares, code interprets, and a field the code does not know is
|
||||
* simply not asked about rather than being handled arbitrarily.
|
||||
* This module turns that round. The skill file lists the questions,
|
||||
* `flows/position.js` says what each field name means, and `conversationFlow.js`
|
||||
* decides what to say next. Same split as `actions.js`: Markdown declares, code
|
||||
* interprets, and a field the code does not know is simply not asked about.
|
||||
*
|
||||
* The flow is a plain serializable object. It survives being written to
|
||||
* sessionStorage between turns, and it holds no component state, so the
|
||||
* conversation can be resumed exactly where it stopped.
|
||||
* WHAT IS LEFT HERE. The two entry points the panel calls, bound to the posting
|
||||
* registry. The outcome renderers moved into that registry with everything else
|
||||
* that knows what a job posting is; they are re-exported below because the
|
||||
* panel and its tests have always imported them from here.
|
||||
*
|
||||
* Nothing here writes. The last step returns a draft, and the panel creates the
|
||||
* position with the same mutation the form uses.
|
||||
*/
|
||||
|
||||
/* ── Reading answers ────────────────────────────────────────────────────── */
|
||||
|
||||
/** Title Case, for the free-text answers that name a proper noun. */
|
||||
const titleCase = (value) => value.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
/** A short free-text answer, stripped of the words around it. */
|
||||
function cleanPhrase(answer, max = 60) {
|
||||
const value = String(answer)
|
||||
.trim()
|
||||
.replace(/^(?:in|at|around|near|it is|its|it's)\s+/i, '')
|
||||
.replace(/[.!?,;]+$/, '')
|
||||
.replace(/\s+/g, ' ');
|
||||
return value && value.length <= max ? value : null;
|
||||
}
|
||||
|
||||
/** "Skip" is an answer to an optional question — it settles it, unanswered. */
|
||||
const SKIP = /^(?:skip|skip this|skip it|no preference|not sure|does ?n(?:'|o)t matter|any|none of these)$/i;
|
||||
|
||||
/** A chip that means "let me type it" rather than an answer in itself. */
|
||||
const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere else)$/i;
|
||||
|
||||
/**
|
||||
* What each field means: when it is already settled, how an answer is read, and
|
||||
* how it reads back.
|
||||
*
|
||||
* `parse` returns a patch for the draft, or `null` when the answer was not
|
||||
* understood — which re-asks with `retry` rather than storing a guess.
|
||||
*/
|
||||
const FIELDS = {
|
||||
/**
|
||||
* The client this role is being staffed for.
|
||||
*
|
||||
* A "client" is not a record of its own in this product — it is the `company`
|
||||
* on the position, which is the field the Create Position form writes, the
|
||||
* Positions card leads with, and Hired History reports against. So the
|
||||
* conversation collects it into the same field rather than into a store of
|
||||
* its own, and asking Owliver to create a client starts here.
|
||||
*/
|
||||
company: {
|
||||
label: 'Company',
|
||||
settled: (draft) => Boolean(String(draft.company || '').trim()),
|
||||
retry: 'Type the client or company name — "Fairmont San Jose".',
|
||||
parse: (answer) => {
|
||||
const value = cleanPhrase(answer, 60);
|
||||
return value && value.length > 1 ? { company: titleCase(value) } : null;
|
||||
},
|
||||
summary: (draft) => (draft.company ? `Company: ${draft.company}` : null),
|
||||
},
|
||||
|
||||
role_category: {
|
||||
label: 'Role',
|
||||
settled: (draft) => Boolean(draft.title),
|
||||
retry: 'Name the role — "bartender", "line cook", "event staff".',
|
||||
parse: (answer, { roles }) => {
|
||||
const role = extractRole(answer, roles) || cleanPhrase(answer, 40);
|
||||
if (!role || role.length < 2) return null;
|
||||
const known = roles.find((r) => String(r).toLowerCase() === role.toLowerCase());
|
||||
/* A known category files the position; anything else becomes the title and
|
||||
leaves the category at its default, exactly as the form behaves. */
|
||||
return known
|
||||
? { role_category: known, title: known }
|
||||
: { title: titleCase(role) };
|
||||
},
|
||||
summary: (draft) => (draft.role_category && draft.role_category !== draft.title
|
||||
? `${draft.title} · ${draft.role_category}`
|
||||
: draft.title),
|
||||
},
|
||||
|
||||
location: {
|
||||
label: 'Location',
|
||||
settled: (draft) => Boolean(draft.location),
|
||||
retry: 'Type the city or area this role is based in.',
|
||||
parse: (answer) => {
|
||||
if (FREE_TEXT.test(String(answer).trim())) return null;
|
||||
const value = extractLocation(answer) || cleanPhrase(answer);
|
||||
return value ? { location: titleCase(value) } : null;
|
||||
},
|
||||
summary: (draft) => draft.location,
|
||||
},
|
||||
|
||||
pay: {
|
||||
label: 'Pay',
|
||||
settled: (draft) => Number(draft.pay_range_min) > 0 || Number(draft.pay_range_max) > 0,
|
||||
retry: 'Give a range like "$28–$36/hr", or a single rate.',
|
||||
parse: (answer) => {
|
||||
const pay = extractPay(answer);
|
||||
return pay ? { pay_range_min: String(pay.min), pay_range_max: String(pay.max) } : null;
|
||||
},
|
||||
summary: (draft) => payLabel(draft),
|
||||
},
|
||||
|
||||
min_experience_years: {
|
||||
label: 'Experience',
|
||||
settled: (draft) => draft.min_experience_years !== undefined && draft.min_experience_years !== null,
|
||||
retry: 'How many years — or "no minimum".',
|
||||
parse: (answer) => {
|
||||
const years = extractExperience(answer);
|
||||
if (years !== null) return { min_experience_years: years };
|
||||
const bare = /^(\d{1,2})\s*\+?$/.exec(String(answer).trim());
|
||||
return bare ? { min_experience_years: Number(bare[1]) } : null;
|
||||
},
|
||||
summary: (draft) => (draft.min_experience_years
|
||||
? `${draft.min_experience_years}+ years experience`
|
||||
: 'No minimum experience'),
|
||||
},
|
||||
|
||||
english_required: {
|
||||
label: 'English',
|
||||
settled: (draft) => Boolean(draft.english_required),
|
||||
retry: `One of ${ENGLISH_LEVELS.map((l) => l.label).join(', ')}.`,
|
||||
parse: (answer) => {
|
||||
const level = extractEnglish(answer)
|
||||
|| ENGLISH_LEVELS.find((l) => l.label.toLowerCase() === String(answer).trim().toLowerCase())?.value;
|
||||
return level ? { english_required: level } : null;
|
||||
},
|
||||
summary: (draft) => {
|
||||
const level = ENGLISH_LEVELS.find((l) => l.value === draft.english_required);
|
||||
return level ? `English: ${level.label}` : null;
|
||||
},
|
||||
},
|
||||
|
||||
certifications_required: {
|
||||
label: 'Certifications',
|
||||
settled: (draft) => Array.isArray(draft.certifications_required),
|
||||
retry: 'Name a certification, or "none".',
|
||||
parse: (answer) => {
|
||||
const found = extractCertifications(answer);
|
||||
if (found) return { certifications_required: found };
|
||||
if (/^(?:none|no|no certifications?)$/i.test(String(answer).trim())) {
|
||||
return { certifications_required: [] };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
summary: (draft) => (draft.certifications_required?.length
|
||||
? `Certifications: ${draft.certifications_required.join(', ')}`
|
||||
: null),
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Suggestions ────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A step's chips.
|
||||
*
|
||||
* `@`-prefixed entries in the skill file resolve to the lists the application
|
||||
* already owns, so the roles offered here are the roles the form offers and a
|
||||
* certification added to the record's options appears in the conversation
|
||||
* without this file or the skill file changing.
|
||||
*/
|
||||
function suggestionsFor(step, { roles }) {
|
||||
const resolved = step.options.flatMap((option) => {
|
||||
if (option === '@roles') return roles;
|
||||
if (option === '@english') return ENGLISH_LEVELS.map((l) => l.label);
|
||||
if (option === '@certifications') return CERT_OPTIONS;
|
||||
return [option];
|
||||
});
|
||||
|
||||
const capped = [...new Set(resolved)].slice(0, 6);
|
||||
/* An optional question needs a way past it that is not a typed sentence. */
|
||||
if (!step.required) capped.push('Skip');
|
||||
return capped.map((label) => ({ label, prompt: label }));
|
||||
}
|
||||
|
||||
/* ── Flow state ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The steps a skill declares, keeping only the fields this module understands. */
|
||||
const stepsOf = (skill) => (skill?.conversation || []).filter((s) => FIELDS[s.field]);
|
||||
|
||||
/** Is this field answered, or deliberately passed over? */
|
||||
const isSettled = (flow, field) => FIELDS[field].settled(flow.draft) || flow.skipped.includes(field);
|
||||
|
||||
/** The next question, or `null` when there is nothing left to ask. */
|
||||
function nextStep(flow, steps) {
|
||||
if (flow.editing) return steps.find((s) => s.field === flow.editing) || null;
|
||||
return steps.find((s) => !isSettled(flow, s.field)) || null;
|
||||
}
|
||||
|
||||
/** Everything decided so far, one line each, in the order it is asked. */
|
||||
function summaryLines(flow, steps) {
|
||||
return steps
|
||||
.filter((s) => isSettled(flow, s.field))
|
||||
.map((s) => FIELDS[s.field].summary(flow.draft))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** The required fields a position cannot be created without. */
|
||||
const missingRequired = (flow, steps) => steps.filter((s) => s.required && !FIELDS[s.field].settled(flow.draft));
|
||||
|
||||
/* ── Replies ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Ask the next question, or read the position back when there is none left. */
|
||||
function ask(flow, steps, { roles, preamble = null, retry = null }) {
|
||||
const step = nextStep(flow, steps);
|
||||
|
||||
if (!step) return review(flow, steps);
|
||||
|
||||
return {
|
||||
flow: { ...flow, stage: 'collect', step: step.field },
|
||||
doc: doc(
|
||||
preamble ? text(preamble) : null,
|
||||
preamble && summaryLines(flow, steps).length ? list(summaryLines(flow, steps)) : null,
|
||||
text(step.question),
|
||||
retry ? note(retry) : null
|
||||
),
|
||||
followUp: suggestionsFor(step, { roles }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole position, before anything is written. */
|
||||
function review(flow, steps) {
|
||||
const missing = missingRequired(flow, steps);
|
||||
/* Only reachable if a required answer was cleared — ask for it rather than
|
||||
offering to create something incomplete. */
|
||||
if (missing.length) {
|
||||
return {
|
||||
flow: { ...flow, stage: 'collect', step: missing[0].field, editing: null },
|
||||
doc: doc(text(missing[0].question)),
|
||||
followUp: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
flow: { ...flow, stage: 'review', step: null, editing: null },
|
||||
doc: doc(
|
||||
text('Ready to create this position?'),
|
||||
list(summaryLines(flow, steps)),
|
||||
note('Nothing is saved until you choose one. Save as Draft keeps it unpublished — the same as the button on the form.')
|
||||
),
|
||||
/* The two the form offers, in the same words and the same order, so the
|
||||
conversation and the page commit a position the same two ways. */
|
||||
followUp: [
|
||||
{ label: 'Save as Draft', prompt: 'Save as draft' },
|
||||
{ label: 'Publish Job Posting', prompt: 'Publish job posting' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Which detail to change — the answers already given, as chips. */
|
||||
function changeMenu(flow, steps) {
|
||||
const settled = steps.filter((s) => isSettled(flow, s.field));
|
||||
return {
|
||||
flow: { ...flow, stage: 'change', step: null },
|
||||
doc: doc(text('What should I change?')),
|
||||
followUp: settled.map((s) => ({ label: FIELDS[s.field].label, prompt: FIELDS[s.field].label })),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Entry points ───────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Start collecting, from whatever the request already said.
|
||||
*
|
||||
* "Create a bartender position in Chennai paying $30–$40/hr" answers three
|
||||
* questions before the first one is asked, and those are not asked again.
|
||||
*/
|
||||
export function beginPositionFlow({ question, skill, roles = [] }) {
|
||||
const { prefill } = buildPositionPrefill(question, roles);
|
||||
const steps = stepsOf(skill);
|
||||
/** Start collecting, from whatever the request already said. */
|
||||
export const beginPositionFlow = ({ question, skill, roles = [], companies = [] }) => beginFlow({
|
||||
registry: positionRegistry, question, skill, ctx: { roles, companies },
|
||||
});
|
||||
|
||||
const flow = {
|
||||
skillId: skill.id,
|
||||
draft: prefill,
|
||||
skipped: [],
|
||||
editing: null,
|
||||
stage: 'collect',
|
||||
step: null,
|
||||
};
|
||||
/** One answer, and whatever it makes the next thing to say. */
|
||||
export const advancePositionFlow = ({ flow, answer, skill, roles = [], companies = [] }) => advanceFlow({
|
||||
registry: positionRegistry, flow, answer, skill, ctx: { roles, companies },
|
||||
});
|
||||
|
||||
const known = summaryLines(flow, steps);
|
||||
return ask(flow, steps, {
|
||||
roles,
|
||||
preamble: known.length ? `Got it — here is what I have so far:` : null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One answer, and whatever it makes the next thing to say.
|
||||
*
|
||||
* Returns `{ flow, doc, followUp }`, and on the confirmation step additionally
|
||||
* `create: { draft }` — the panel writes it, this module does not.
|
||||
*/
|
||||
export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
|
||||
const steps = stepsOf(skill);
|
||||
const said = String(answer).trim();
|
||||
|
||||
/* A way out that does not require finishing. `flow: null` ends it, and the
|
||||
next question is answered by the page as usual. */
|
||||
if (/^(?:cancel|stop|never ?mind|nevermind|forget it|quit|exit)$/i.test(said)) {
|
||||
return {
|
||||
flow: null,
|
||||
doc: doc(
|
||||
text('Stopped — nothing was created.'),
|
||||
note('Ask me to create a position whenever you are ready.')
|
||||
),
|
||||
followUp: [],
|
||||
};
|
||||
}
|
||||
|
||||
/* The confirmation step. "Create position" is the only path to a record. */
|
||||
if (flow.stage === 'review') {
|
||||
/* Saving unpublished. The same write, with the status the form's own
|
||||
"Save as Draft" button sets — one create path, two statuses. */
|
||||
if (/^(?:save as draft|save draft|draft|save it as a draft)$/i.test(said)) {
|
||||
if (missingRequired(flow, steps).length) return review(flow, steps);
|
||||
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'draft' } };
|
||||
}
|
||||
if (/^(?:create position|publish job posting|publish|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) {
|
||||
if (missingRequired(flow, steps).length) return review(flow, steps);
|
||||
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'active' } };
|
||||
}
|
||||
if (/^(?:change|change details|edit|change something|no)$/i.test(said)) return changeMenu(flow, steps);
|
||||
|
||||
/* Anything else at the confirmation step is a correction stated outright —
|
||||
"make it Bengaluru", "$32–$40". Read it against every field and apply
|
||||
what it settles, rather than making the user find the menu. */
|
||||
const revised = applyStatement(flow, steps, said, roles);
|
||||
if (revised) return review(revised, steps);
|
||||
|
||||
return {
|
||||
...review(flow, steps),
|
||||
doc: doc(
|
||||
text('I did not catch that. Ready to create this position?'),
|
||||
list(summaryLines(flow, steps)),
|
||||
note('Choose Save as Draft or Publish Job Posting, or tell me what to change.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/* Choosing which detail to revisit. */
|
||||
if (flow.stage === 'change') {
|
||||
const target = steps.find((s) => FIELDS[s.field].label.toLowerCase() === said.toLowerCase());
|
||||
if (!target) {
|
||||
const revised = applyStatement(flow, steps, said, roles);
|
||||
if (revised) return review(revised, steps);
|
||||
return changeMenu(flow, steps);
|
||||
}
|
||||
return ask({ ...flow, editing: target.field }, steps, { roles });
|
||||
}
|
||||
|
||||
/* Answering the question that was asked. */
|
||||
const step = steps.find((s) => s.field === flow.step) || nextStep(flow, steps);
|
||||
if (!step) return review(flow, steps);
|
||||
|
||||
const field = FIELDS[step.field];
|
||||
|
||||
if (SKIP.test(said) && !step.required) {
|
||||
const next = { ...flow, skipped: [...flow.skipped, step.field], editing: null };
|
||||
return ask(next, steps, { roles });
|
||||
}
|
||||
|
||||
const patch = field.parse(said, { roles });
|
||||
if (!patch) {
|
||||
/* Not an answer to this question — but it may still be a fact about the
|
||||
position ("in Chennai" while being asked for pay). Take it if so. */
|
||||
const revised = applyStatement(flow, steps, said, roles);
|
||||
if (revised) return ask(revised, steps, { roles });
|
||||
return ask(flow, steps, { roles, retry: field.retry });
|
||||
}
|
||||
|
||||
const next = {
|
||||
...flow,
|
||||
draft: { ...flow.draft, ...patch },
|
||||
skipped: flow.skipped.filter((f) => f !== step.field),
|
||||
editing: null,
|
||||
};
|
||||
|
||||
/* A detail revisited from the change menu goes straight back to the summary
|
||||
rather than walking the rest of the questions again. */
|
||||
if (flow.editing) return review(next, steps);
|
||||
|
||||
return ask(next, steps, { roles });
|
||||
}
|
||||
|
||||
/**
|
||||
* A sentence read against every field at once.
|
||||
*
|
||||
* This is what makes the conversation forgiving: an answer that arrives out of
|
||||
* order, or a correction stated rather than chosen from a menu, lands in the
|
||||
* right field instead of being rejected for not answering the question asked.
|
||||
* Returns an updated flow, or `null` when the sentence settles nothing.
|
||||
*/
|
||||
function applyStatement(flow, steps, said, roles) {
|
||||
const { prefill } = buildPositionPrefill(said, roles);
|
||||
const fields = new Set(steps.map((s) => s.field));
|
||||
|
||||
const patch = {};
|
||||
/* Step names, not record fields — a step the sentence settled must come off
|
||||
the skipped list so the summary shows it. */
|
||||
const touched = new Set();
|
||||
|
||||
if (fields.has('role_category') && prefill.title) {
|
||||
if (prefill.role_category) patch.role_category = prefill.role_category;
|
||||
patch.title = prefill.title;
|
||||
touched.add('role_category');
|
||||
}
|
||||
if (fields.has('location') && prefill.location) {
|
||||
patch.location = prefill.location;
|
||||
touched.add('location');
|
||||
}
|
||||
if (fields.has('pay') && prefill.pay_range_min) {
|
||||
patch.pay_range_min = prefill.pay_range_min;
|
||||
patch.pay_range_max = prefill.pay_range_max;
|
||||
touched.add('pay');
|
||||
}
|
||||
if (fields.has('min_experience_years') && prefill.min_experience_years !== undefined) {
|
||||
patch.min_experience_years = prefill.min_experience_years;
|
||||
touched.add('min_experience_years');
|
||||
}
|
||||
if (fields.has('english_required') && prefill.english_required) {
|
||||
patch.english_required = prefill.english_required;
|
||||
touched.add('english_required');
|
||||
}
|
||||
if (fields.has('certifications_required') && prefill.certifications_required) {
|
||||
patch.certifications_required = prefill.certifications_required;
|
||||
touched.add('certifications_required');
|
||||
}
|
||||
|
||||
if (!touched.size) return null;
|
||||
|
||||
return {
|
||||
...flow,
|
||||
draft: { ...flow.draft, ...patch },
|
||||
skipped: flow.skipped.filter((f) => !touched.has(f)),
|
||||
editing: null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Outcomes ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The position exists. Said plainly, with what was created. */
|
||||
export function positionCreatedReply(position) {
|
||||
const isDraft = position.status === 'draft';
|
||||
|
||||
return doc(
|
||||
/* Draft and published are different outcomes, so they are named
|
||||
differently: one was saved, the other went live. */
|
||||
text(isDraft ? 'Saved as a draft.' : 'Position published successfully.'),
|
||||
list([
|
||||
position.company,
|
||||
position.title,
|
||||
position.location,
|
||||
payLabel(position),
|
||||
].filter(Boolean)),
|
||||
note(isDraft
|
||||
? 'It is on the Positions list as a draft — nobody can apply until it is published, and it stays a draft until you publish it.'
|
||||
: 'It is on the Positions list now — applications will start appearing against it.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The write failed. The draft is kept, so the answers are not lost.
|
||||
* Re-exported from the registry, where they now live beside the field table.
|
||||
*
|
||||
* The server's own message is said out loud when there is one. This used to be
|
||||
* a fixed sentence, and a fixed sentence is the wrong answer to three different
|
||||
* failures: a validation error naming a field, a permission refusal, and an API
|
||||
* that is not running all read as "I could not create that position", leaving
|
||||
* the reader to guess which of the three they are looking at and what to change.
|
||||
*
|
||||
* The message comes from `KrowApiError.message`, which `httpClient` sets to the
|
||||
* server's wording verbatim — so the reason is the API's, not one invented here
|
||||
* from a status code.
|
||||
* The panel reads these off `registry.outcome` and no longer names a position
|
||||
* to render one. These names stay so that nothing importing them has to change
|
||||
* in the same commit as the split.
|
||||
*/
|
||||
export function positionFailedReply(reason = null) {
|
||||
const said = String(reason || '').trim();
|
||||
|
||||
return doc(
|
||||
text('I could not create that position.'),
|
||||
said ? note(said) : null,
|
||||
note('Nothing was saved. Choose Create position to try again.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the panel offers after a position is created.
|
||||
*
|
||||
* A published role has an obvious next question — who can fill it — so it is
|
||||
* offered here rather than left to be typed. The chip carries the position's
|
||||
* own title and the wording the workforce engine already answers, so it is an
|
||||
* ordinary question resolved by the path that was already there: no handler,
|
||||
* no navigation, and the same answer as asking it by hand.
|
||||
*/
|
||||
export const createdFollowUp = (position) => (position.status === 'draft'
|
||||
/**
|
||||
* A saved draft offers no action, and that is the fix.
|
||||
*
|
||||
* This used to offer "Continue to save", routing back to the Create Position
|
||||
* form. It made the completed state look unfinished: the reader had just been
|
||||
* told the position was saved, and was immediately asked to save it again —
|
||||
* by a button that put them back in the form they had just left. The write
|
||||
* has happened, the record exists with `status: draft`, and the way to finish
|
||||
* a draft later is its own card on the Positions list.
|
||||
*/
|
||||
? []
|
||||
: [
|
||||
{ label: 'View position', route: `/admin/positions/${position.id}` },
|
||||
{ label: 'Match candidates', prompt: `Who matches ${position.title}?` },
|
||||
]);
|
||||
export const {
|
||||
created: positionCreatedReply,
|
||||
failed: positionFailedReply,
|
||||
followUp: createdFollowUp,
|
||||
} = positionRegistry.outcome;
|
||||
|
||||
@@ -427,6 +427,16 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
capabilities: sectionBullets(body, 'Capabilities'),
|
||||
purpose: sectionBullets(body, 'Purpose'),
|
||||
conversation,
|
||||
/**
|
||||
* Which conversation registry reads this skill's questions.
|
||||
*
|
||||
* Data, not a branch. `routing.js` used to name `create-position` in an
|
||||
* `if`, which meant a second conversational skill was a code change in
|
||||
* the router rather than a file on disk — the same shape §3 rules out for
|
||||
* agents, one level down. A skill with a `## Conversation` block and no
|
||||
* `flow:` defaults to `position`, so nothing that shipped changes.
|
||||
*/
|
||||
flow: data.flow ? String(data.flow) : (conversation.length ? 'position' : null),
|
||||
path,
|
||||
body,
|
||||
custom,
|
||||
|
||||
@@ -366,11 +366,36 @@ function blockEnd(lines, start, indent) {
|
||||
return end;
|
||||
}
|
||||
|
||||
/**
|
||||
* How wide a line's indentation is.
|
||||
*
|
||||
* The same measurement the parser makes — `yaml.js` reads a leading run of
|
||||
* whitespace and counts a tab as two — and it has to be, because a key the
|
||||
* parser can see and the
|
||||
* writer cannot is a key the writer will decide is missing and add a second
|
||||
* copy of.
|
||||
*
|
||||
* That is not hypothetical: a definition stored on this account indents with
|
||||
* U+00A0. JavaScript's `\s` matches it, so the parser read the file correctly
|
||||
* and every screen showed the right values; the writer compared against literal
|
||||
* spaces, found no `title:` inside `ui:`, and appended a whole second `ui:`
|
||||
* block on the first edit. The Go port agrees with the parser here too — see
|
||||
* `jsIsSpace` in `internal/definition/jsvalue.go`, which lists `0x00A0` — so the
|
||||
* writer was the only thing in the chain using a narrower idea of a space.
|
||||
*/
|
||||
const indentWidth = (line) => (line.match(/^\s*/)?.[0] || '').replace(/\t/g, ' ').length;
|
||||
|
||||
/** The index of `key` at `indent` within `[from, to)`, or -1. */
|
||||
function findKey(lines, key, indent, from, to) {
|
||||
const pattern = new RegExp(`^${' '.repeat(indent)}${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
|
||||
const pattern = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
|
||||
for (let i = from; i < to; i += 1) {
|
||||
if (pattern.test(lines[i])) return i;
|
||||
const line = lines[i];
|
||||
if (line === undefined || indentWidth(line) !== indent) continue;
|
||||
/* Measured, then matched on what is left — so the comparison is about how
|
||||
deep the key sits, never about which characters were used to put it
|
||||
there. Identical for ASCII input, which is every definition this
|
||||
repository ships. */
|
||||
if (pattern.test(line.replace(/^\s*/, ''))) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -524,6 +524,20 @@ export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label ||
|
||||
* position id, a candidate id, or nothing. A source is resolved by
|
||||
* `dataResolver.js`; a definition cannot reach a store directly, cannot write,
|
||||
* and cannot name a field that is not offered here.
|
||||
*
|
||||
* **`series`** is the optional half that says what a reading's figures *mean*,
|
||||
* as one of the closed kinds in `SERIES_KINDS` — `periodic` (ordered in time),
|
||||
* `cumulative` (each step drawn from the one before) or `parts` (disjoint
|
||||
* shares of one whole). `shapes` answers whether a component *can* draw a
|
||||
* reading; this answers whether doing so would be true, and it is what refuses
|
||||
* a pie chart of a timeline.
|
||||
*
|
||||
* It is declared only where the reading's own definition already states the
|
||||
* meaning, and it is deliberately absent from most of them. An absent `series`
|
||||
* is "not established", and a component that has declared which meanings it
|
||||
* draws refuses rather than guesses — which is the safe direction. Adding one
|
||||
* is a statement about what the resolver actually returns, so it is added when
|
||||
* that is known and not before.
|
||||
*/
|
||||
export const DATA_SOURCES = [
|
||||
{
|
||||
@@ -532,6 +546,9 @@ export const DATA_SOURCES = [
|
||||
context: 'positionId',
|
||||
summary: 'Applications to this position, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
|
||||
/* "counted over time": the resolver buckets by period and returns
|
||||
the buckets in order, so the order is part of the reading. */
|
||||
series: 'periodic',
|
||||
options: ['periods'],
|
||||
},
|
||||
{
|
||||
@@ -540,6 +557,9 @@ export const DATA_SOURCES = [
|
||||
context: 'positionId',
|
||||
summary: 'Applied → screened → shortlisted → interviewed → hired.',
|
||||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||||
/* A funnel: everyone screened applied first, so the stages overlap
|
||||
and do not add up to a whole. */
|
||||
series: 'cumulative',
|
||||
},
|
||||
{
|
||||
id: 'position.candidates',
|
||||
@@ -594,6 +614,9 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'Every candidate, counted by stage.',
|
||||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||||
/* The same funnel across the workspace, and cumulative for the same
|
||||
reason. */
|
||||
series: 'cumulative',
|
||||
},
|
||||
{
|
||||
id: 'candidates.activity',
|
||||
@@ -601,6 +624,8 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'Applications across the workspace, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
|
||||
/* "counted over time", as above. */
|
||||
series: 'periodic',
|
||||
options: ['periods'],
|
||||
},
|
||||
{
|
||||
@@ -738,6 +763,9 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'Events by type and by account.',
|
||||
shapes: ['stats', 'table', 'list', 'progress', 'flow', 'card'],
|
||||
/* Every event has exactly one type and one account, so counting by
|
||||
either partitions the same total: these are shares of one whole. */
|
||||
series: 'parts',
|
||||
options: ['periods', 'limit'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -42,6 +42,18 @@ export const TOOLS = [
|
||||
/* Writes a record other people will act on. Always confirmed. */
|
||||
requiresApproval: true,
|
||||
},
|
||||
{
|
||||
name: 'create_employee_role',
|
||||
label: 'Create employee role',
|
||||
summary: "Records what a worker declares they do — role, experience, desired pay and availability — from a conversation.",
|
||||
params: ['draft', 'status'],
|
||||
readOnly: false,
|
||||
mutates: 'EmployeeRole',
|
||||
/* Writes a record ABOUT SOMEBODY ELSE, which is the stronger case for a
|
||||
confirmation rather than the weaker one: the person it names is not the
|
||||
person approving it. */
|
||||
requiresApproval: true,
|
||||
},
|
||||
{
|
||||
name: 'open_create_skill_training',
|
||||
label: 'Open Add Skill Training',
|
||||
@@ -175,3 +187,70 @@ export function toolsForContext(contextId, disabled = [], customSkills = []) {
|
||||
*/
|
||||
export const toolAllowed = (name, allowed = []) =>
|
||||
allowed.some((tool) => tool.name === name);
|
||||
|
||||
/* ── Typed action intent ────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* How much has to be typed before a partial word counts as an intent.
|
||||
*
|
||||
* Three, and the number is the whole point of the rule. One or two characters
|
||||
* cannot say what somebody meant — "wh" is the start of four unrelated
|
||||
* questions — and offering anything on them is how the composer ended up
|
||||
* interrupting every reader who had already decided what to ask. Three is the
|
||||
* shortest prefix of a real verb, and it still matches nothing unless it is
|
||||
* genuinely the beginning of an action this page can perform.
|
||||
*/
|
||||
const ACTION_INTENT_MIN = 3;
|
||||
|
||||
/**
|
||||
* A phrase reduced to what it means, so matching is about intent not typing.
|
||||
*
|
||||
* Case, surrounding space and an article are not differences: "Create",
|
||||
* "create" and "create a" are all the beginning of the same request. Kept local
|
||||
* and deliberately tiny — it exists to compare two short phrases, and anything
|
||||
* cleverer would start deciding what a reader meant.
|
||||
*/
|
||||
const canon = (value) => String(value || '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w && !/^(?:a|an|the)$/.test(w))
|
||||
.join(' ');
|
||||
|
||||
/**
|
||||
* The actions this page can perform that the typed text is starting to name.
|
||||
*
|
||||
* Derived, never listed. The phrases come from the skills themselves — a
|
||||
* definition's `prompt:` is the sentence its author wrote for exactly this
|
||||
* purpose — and the candidate set is whatever `skillsForContext` already
|
||||
* resolved, which has the page filter and the agent's scoping applied to it.
|
||||
* So a skill added tomorrow is offered here without this file changing, a skill
|
||||
* switched off in Settings is not offered at all, and there is no second list
|
||||
* of action names to keep in step with the first.
|
||||
*
|
||||
* Only skills that DECLARE an action are eligible. A reading skill has nothing
|
||||
* to autocomplete towards: "which positions are in draft" is a question, and
|
||||
* offering it while somebody types is the generic-catalogue behaviour this
|
||||
* replaced.
|
||||
*
|
||||
* Matched on `canon`, so "Create" reaches "Create a position" — the article and
|
||||
* the case are not differences — and "create position" reaches it too. A prefix
|
||||
* rather than a substring: typing the middle of a phrase is not evidence of
|
||||
* intent, and substring matching is what made every keystroke produce chips.
|
||||
*/
|
||||
export function actionSuggestions(typed, skills = []) {
|
||||
const query = canon(typed);
|
||||
if (query.length < ACTION_INTENT_MIN) return [];
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const skill of skills) {
|
||||
if (!skill?.prompt || !skill.actions?.length) continue;
|
||||
const phrase = canon(skill.prompt);
|
||||
if (!phrase.startsWith(query)) continue;
|
||||
if (seen.has(phrase)) continue;
|
||||
seen.add(phrase);
|
||||
out.push({ label: skill.prompt, prompt: skill.prompt });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -32,13 +32,51 @@ const has = (q, ...terms) => terms.some((t) => q.includes(t));
|
||||
* enquiry. "Assign them" after a preview must not be read as a fresh request
|
||||
* for recommendations.
|
||||
*/
|
||||
/**
|
||||
* The words that name a GROUP of people rather than one person.
|
||||
*
|
||||
* A detail request is about somebody: "show Maria Gonzalez" opens a record.
|
||||
* A question naming a group is a search, and answering it with one person's
|
||||
* record is the wrong answer to a different question.
|
||||
*/
|
||||
const GROUP_WORDS = [
|
||||
'candidate', 'candidates', 'applicant', 'applicants',
|
||||
'employee', 'employees', 'worker', 'workers', 'people', 'talent',
|
||||
];
|
||||
|
||||
/**
|
||||
* Words that place a question in the WORKFORCE rather than the hiring pipeline.
|
||||
*
|
||||
* These are two different sources and the product keeps them apart: a candidate
|
||||
* is somebody in `job_applications`, and the talent pool is `worker_profiles`.
|
||||
* Every intent below answers from the pipeline — `poolFor` is built from
|
||||
* applications and a profile only ever enriches a candidate it already found —
|
||||
* so a question about employees must not reach any of them.
|
||||
*
|
||||
* Returned as NO workforce intent, which hands the question to the Talent Pool
|
||||
* responder to answer from the workforce. That is the correct source, and it is
|
||||
* the direction this file used to get wrong: "show employees who match this
|
||||
* role" was caught by a bare `show ` test and answered as a candidate lookup.
|
||||
*/
|
||||
const WORKFORCE_WORDS = ['talent pool', 'talent directory', 'employee', 'employees', 'worker', 'workers'];
|
||||
const PIPELINE_WORDS = ['candidate', 'applicant', 'application'];
|
||||
|
||||
export function matchWorkforceIntent(question) {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
/* Source before intent. A question that names the workforce and not the
|
||||
pipeline is not answered from the pipeline, whatever else it says. */
|
||||
if (has(q, ...WORKFORCE_WORDS) && !has(q, ...PIPELINE_WORDS)) return null;
|
||||
|
||||
/* Inspection and record-opening are checked before assignment, so "show X"
|
||||
and "open the full profile for X" never read as a request to assign. */
|
||||
if (has(q, 'open the full profile', 'view full profile', 'full profile for')) return 'open_profile';
|
||||
if (has(q, 'show ', "'s details", 'candidate details', 'tell me about ')) return 'candidate_detail';
|
||||
if (has(q, "'s details", 'candidate details', 'tell me about ')) return 'candidate_detail';
|
||||
/* `show X` is a detail request only when X is a person. Bare `show ` was
|
||||
checked here unguarded and swallowed every collection query in the
|
||||
product — "show candidates matching bartender" resolved to one candidate's
|
||||
record rather than to a search. */
|
||||
if (has(q, 'show ') && !has(q, ...GROUP_WORDS)) return 'candidate_detail';
|
||||
if (has(q, 'can ', 'why not eligible', 'be assigned')) return 'eligibility';
|
||||
|
||||
if (has(q, 'confirm interview')) return 'confirm_interview';
|
||||
@@ -53,11 +91,17 @@ export function matchWorkforceIntent(question) {
|
||||
|
||||
if (has(q, 'confirm assignment', 'yes, assign', 'confirm the assignment')) return 'confirm_assign';
|
||||
if (has(q, 'assign ', 'fill this position', 'fill the position', 'assign the best', 'assign them')) return 'assign';
|
||||
if (has(q, 'ready for interview', 'who should i interview', 'interview ready')) return 'interview_ready';
|
||||
if (has(q, 'ready for interview', 'should i interview', 'interview ready')) return 'interview_ready';
|
||||
if (has(q, 'applied today', "today's applicant", 'new applicant', 'new application')) return 'applied_today';
|
||||
if (has(q, 'available', 'start earliest', 'can start', 'availability', 'free now')) return 'availability';
|
||||
/* Candidate matching, in the words people actually use for it. The list was
|
||||
narrow enough that "who is a match for this role", "find the best
|
||||
candidate" and "find strong candidates" all fell through to no intent at
|
||||
all. Every one of these is answered from the pipeline. */
|
||||
if (has(q, 'who matches', 'who can fill', 'find candidates', 'best match', 'strongest candidate',
|
||||
'recommend candidate', 'suitable candidate', 'who is suitable', 'candidates for')) return 'matches';
|
||||
'recommend candidate', 'suitable candidate', 'who is suitable', 'candidates for',
|
||||
'is a match', 'match for', 'best candidate', 'strong candidate', 'strongest match',
|
||||
'which candidate', 'candidates matching', 'candidate matching', 'matching ')) return 'matches';
|
||||
if (has(q, 'needs people first', 'biggest gap', 'largest gap', 'most understaffed',
|
||||
'which position should i fill', 'priority')) return 'priority';
|
||||
return null;
|
||||
|
||||
127
src/lib/ui/composition.js
Normal file
127
src/lib/ui/composition.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* What a page is made of, and how a user's changes are folded into it.
|
||||
*
|
||||
* A page registers its own composition — the nodes it ships with, in order —
|
||||
* and this module holds the table. The engine reads the table; it never learns
|
||||
* a page name, and there is no switch here that would have to grow a case per
|
||||
* surface. Registering is how a page opts in, exactly as `registerNodeType` is
|
||||
* how a component does.
|
||||
*
|
||||
* The merge is the other half:
|
||||
*
|
||||
* built-in composition what the application ships
|
||||
* ⊕ saved user patch the person's own changes, persisted
|
||||
* ⊕ preview patch what they are trying, not yet saved
|
||||
* = the tree that renders
|
||||
*
|
||||
* Both patches are **lists of operations**, replayed onto a freshly computed
|
||||
* base. That is what makes a saved layout survive a release: a stored tree
|
||||
* would be a photograph of the page on the day it was saved, and every
|
||||
* improvement afterwards would be invisible to whoever had customised it.
|
||||
*
|
||||
* Skill sections are deliberately **not** merged here. A skill's `ui:` block is
|
||||
* still rendered by `SkillSurface`, exactly as it is today, and a surface is
|
||||
* simply one of the nodes a page composes. That keeps the existing Board-skill
|
||||
* behaviour byte-for-byte unchanged while still putting it in the tree, where
|
||||
* it can be hidden and reordered like anything else.
|
||||
*/
|
||||
|
||||
import { makeNode } from './node';
|
||||
import { applyPatch } from './patch';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/** @type {Map<string, any[]>} */
|
||||
const compositions = new Map();
|
||||
|
||||
/**
|
||||
* A container node's placement, as its own registration declared it.
|
||||
*
|
||||
* Read off `props` rather than from a field the engine knows about, so the
|
||||
* composition layer needs no concept of what a placement is — only that a node
|
||||
* may name one and that skill sections are grouped by the same name.
|
||||
*/
|
||||
const placementOf = (node) => String(node?.props?.placement || '');
|
||||
|
||||
/**
|
||||
* The composition with each slot's skill sections hung underneath it.
|
||||
*
|
||||
* Done here, before any patch is replayed, so a person's saved operations act
|
||||
* on the same tree they were made against — including the skill sections. A
|
||||
* patch that hides a Board card keeps working; a patch naming a card whose
|
||||
* skill has since been switched off is skipped, like any other stale operation.
|
||||
*/
|
||||
function attachSkillNodes(base, byPlacement) {
|
||||
if (!byPlacement) return base;
|
||||
return base.map((node) => {
|
||||
const placement = placementOf(node);
|
||||
const children = placement ? byPlacement[placement] : null;
|
||||
if (!children?.length) return node;
|
||||
return { ...node, children };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the nodes a page ships with.
|
||||
*
|
||||
* Called once, at module scope, beside the page it describes — so a page and
|
||||
* its composition move together and neither can be deployed without the other.
|
||||
* Re-registering replaces, which is what a hot module reload needs; a duplicate
|
||||
* is not an error the way a duplicate *type* is, because the second call is the
|
||||
* same page saying the same thing again.
|
||||
*/
|
||||
export function registerPageComposition(page, nodes) {
|
||||
const key = String(page ?? '').trim();
|
||||
if (!key) throw new Error('registerPageComposition: a composition needs a page.');
|
||||
compositions.set(key, (nodes || []).map((node) => makeNode({ origin: 'builtin', ...node })));
|
||||
return compositions.get(key);
|
||||
}
|
||||
|
||||
/** The nodes a page ships with, or an empty list for a page that has not opted in. */
|
||||
export const compositionFor = (page) => compositions.get(String(page ?? '').trim()) || [];
|
||||
|
||||
/** Whether this page composes through the node system yet. */
|
||||
export const hasComposition = (page) => compositions.has(String(page ?? '').trim());
|
||||
|
||||
/** Every page that has registered. Used by tests and, later, by the editor. */
|
||||
export const composedPages = () => [...compositions.keys()];
|
||||
|
||||
/** Forget everything. Tests only. */
|
||||
export const resetCompositions = () => compositions.clear();
|
||||
|
||||
/**
|
||||
* The tree to render for a page.
|
||||
*
|
||||
* `skipped` carries the operations that no longer apply — a saved change naming
|
||||
* a node a release has since removed. They are reported rather than thrown:
|
||||
* that is not the user's mistake, and it must not cost them the rest of their
|
||||
* layout.
|
||||
*/
|
||||
export function composePage(page, {
|
||||
patch = null, preview = null, registry = nodeRegistry, role = null,
|
||||
/**
|
||||
* The sections this page's definitions contribute, grouped by placement.
|
||||
*
|
||||
* Passed in rather than read here, because resolving them needs the account's
|
||||
* custom skills and disabled list — React state, which this module must stay
|
||||
* free of to remain a pure function two callers can trust equally.
|
||||
*/
|
||||
skillNodes = null,
|
||||
} = {}) {
|
||||
const base = attachSkillNodes(compositionFor(page), skillNodes);
|
||||
const context = { registry, role };
|
||||
const skipped = [];
|
||||
|
||||
let tree = base;
|
||||
|
||||
/* Saved first, then preview. Order matters: a preview is composed against
|
||||
what the person has already saved, so what they see while deciding is what
|
||||
they will get if they keep it. */
|
||||
for (const layer of [patch, preview]) {
|
||||
if (!layer?.ops?.length) continue;
|
||||
const result = applyPatch(tree, layer, context);
|
||||
tree = result.tree;
|
||||
skipped.push(...result.skipped);
|
||||
}
|
||||
|
||||
return { tree, skipped };
|
||||
}
|
||||
294
src/lib/ui/inspect.js
Normal file
294
src/lib/ui/inspect.js
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* What the UI looks like, described rather than drawn.
|
||||
*
|
||||
* The agent must never guess what "this card" or "that section" refers to. This
|
||||
* module turns a tree into a flat, addressable inventory — every node with its
|
||||
* id, what it is, what it is showing, and what may be done to it — so a request
|
||||
* is resolved against what is actually on the page rather than against what the
|
||||
* model remembers about the product.
|
||||
*
|
||||
* It is a *read*. Nothing here mutates, and nothing here decides: resolving an
|
||||
* ambiguous phrase to a single node is refused in favour of returning the
|
||||
* candidates, because picking one and being wrong edits the thing the user was
|
||||
* looking at while they were looking at something else.
|
||||
*
|
||||
* Everything is derived from the registry and the data vocabulary. No node
|
||||
* name, page name or component name appears below.
|
||||
*/
|
||||
|
||||
import { dataSourceFor } from '@/lib/skills/surfaces';
|
||||
import { kindOfBinding, labelOfBinding, seriesFor } from './series';
|
||||
import { locate, walk } from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/**
|
||||
* One node, as the agent and the editor see it.
|
||||
*
|
||||
* `editable` is the answer to "what could I change here" — derived from the
|
||||
* type's declared prop schema rather than from a list kept in parallel, so a
|
||||
* property added to a registration becomes offerable without an edit here.
|
||||
*/
|
||||
export function describeNode(node, { registry = nodeRegistry, parent = null, index = 0 } = {}) {
|
||||
const entry = registry.get(node.type);
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
label: entry?.label || node.type,
|
||||
/**
|
||||
* The words on screen, which is how a person refers to a node out loud.
|
||||
*
|
||||
* Falls back to what the type can say about *this* node. Two skill slots on
|
||||
* one page both rendered as "Skill sections" — indistinguishable in the
|
||||
* outline, and unresolvable by name, because a slot carries no title of its
|
||||
* own and its type label is the same for every one of them. The type
|
||||
* supplies a `describe` and says where this one is; nothing here knows what
|
||||
* a placement is.
|
||||
*/
|
||||
/**
|
||||
* Last, the name of what it is reading.
|
||||
*
|
||||
* Identity has to survive a replacement. A built-in section carries its
|
||||
* name in its type label — "Hiring activity" — and replacing it with a
|
||||
* chart left a node whose only name was "Bar chart", so the very phrase
|
||||
* that had just worked stopped resolving and "change it back" answered
|
||||
* that there was no such thing on the page.
|
||||
*
|
||||
* The binding is the continuous fact across a replacement: the node is
|
||||
* still reading the same series, and the series has a name. Using it is
|
||||
* not a fallback invented for charts — a node is named by what it shows,
|
||||
* which is how a person refers to it either way.
|
||||
*/
|
||||
title: String(node.props?.title || '').trim()
|
||||
|| (typeof entry?.describe === 'function' ? String(entry.describe(node) || '').trim() || null : null)
|
||||
|| (node.data ? String(labelOfBinding(node.data) || '').trim() || null : null),
|
||||
known: Boolean(entry),
|
||||
container: Boolean(entry?.container),
|
||||
origin: node.origin,
|
||||
hidden: node.hidden === true,
|
||||
locked: node.locked === true,
|
||||
parent: parent?.id ?? null,
|
||||
index,
|
||||
/**
|
||||
* The binding, in whichever of the two kinds it is.
|
||||
*
|
||||
* A described node is what the conversation and the editor both reason
|
||||
* about, so it has to carry enough to answer "what can this become" — and
|
||||
* that now includes a page's own series. Projected rather than passed
|
||||
* through so a consumer still cannot reach a resolver from here.
|
||||
*/
|
||||
data: node.data
|
||||
? {
|
||||
source: node.data.source || null,
|
||||
series: node.data.series || null,
|
||||
label: labelOfBinding(node.data),
|
||||
kind: kindOfBinding(node.data),
|
||||
params: node.data.params || {},
|
||||
known: node.data.series
|
||||
? Boolean(seriesFor(node.data.series))
|
||||
: Boolean(dataSourceFor(node.data.source)),
|
||||
}
|
||||
: null,
|
||||
layout: { ...(node.layout || {}) },
|
||||
/* What this node looks like, and what it *could* look like. Both, because
|
||||
every consumer needs the pair: the editor draws pickers from the second
|
||||
and marks the first, and the conversation refuses a value that is not in
|
||||
the second by name. One reading, so the two cannot disagree. */
|
||||
presentation: { ...(node.presentation || {}) },
|
||||
variants: entry ? [...entry.variants] : [],
|
||||
densities: entry ? [...entry.densities] : [],
|
||||
capabilities: entry ? [...entry.capabilities] : [],
|
||||
editable: entry
|
||||
? Object.entries(entry.propSchema).map(([key, rule]) => ({
|
||||
key,
|
||||
label: rule.label || key,
|
||||
kind: Array.isArray(rule.enum) ? 'enum' : rule.type || 'string',
|
||||
options: Array.isArray(rule.enum) ? [...rule.enum] : null,
|
||||
required: rule.required === true,
|
||||
value: node.props?.[key] ?? null,
|
||||
}))
|
||||
: [],
|
||||
children: (node.children || []).map((child) => child.id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole tree, flattened.
|
||||
*
|
||||
* Flat rather than nested because every question the agent asks is "which node
|
||||
* is this" — and a nested shape makes that a traversal at every call site.
|
||||
* Parentage survives as `parent` and `index`, so the structure is still
|
||||
* recoverable.
|
||||
*/
|
||||
export function inspectTree(nodes, { registry = nodeRegistry } = {}) {
|
||||
const out = [];
|
||||
|
||||
const visit = (list, parent) => {
|
||||
(list || []).forEach((node, index) => {
|
||||
out.push(describeNode(node, { registry, parent, index }));
|
||||
if (node.children?.length) visit(node.children, node);
|
||||
});
|
||||
};
|
||||
|
||||
visit(nodes, null);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, readable rendering of the inventory.
|
||||
*
|
||||
* What a person is shown when they ask what is on the page, and what a
|
||||
* conversation quotes back when a phrase matched more than one node. Indented
|
||||
* by depth so the structure reads without drawing it.
|
||||
*/
|
||||
export function outlineTree(nodes, { registry = nodeRegistry } = {}) {
|
||||
const lines = [];
|
||||
|
||||
const visit = (list, depth) => {
|
||||
for (const node of list || []) {
|
||||
const entry = registry.get(node.type);
|
||||
const title = String(node.props?.title || '').trim();
|
||||
/* Named by the reading when it has one, for the same reason `describeNode`
|
||||
is: it is what a person calls the thing, and it survives a change of
|
||||
component. */
|
||||
const reading = node.data ? String(labelOfBinding(node.data) || '').trim() : '';
|
||||
const bits = [
|
||||
`${' '.repeat(depth)}${title || reading || entry?.label || node.type}`,
|
||||
`(${node.id})`,
|
||||
node.hidden ? '· hidden' : '',
|
||||
reading && reading !== (title || reading) ? `· ${reading}` : '',
|
||||
].filter(Boolean);
|
||||
lines.push(bits.join(' '));
|
||||
if (node.children?.length) visit(node.children, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
visit(nodes, 0);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nodes a phrase could mean, best first.
|
||||
*
|
||||
* Deliberately a *list*. The caller decides what to do with two candidates, and
|
||||
* the right answer in a conversation is to ask — so this never collapses a tie,
|
||||
* and never returns a node on no evidence at all.
|
||||
*
|
||||
* Scoring is over what the node itself says: its id, its title, its type label
|
||||
* and the label of the reading it shows. There is no dictionary of component
|
||||
* names here, so a type registered tomorrow is matchable by its own label
|
||||
* without this function changing.
|
||||
*/
|
||||
export function resolveTarget(nodes, phrase, {
|
||||
registry = nodeRegistry, type = null,
|
||||
/**
|
||||
* Narrow to nodes that are, or are not, currently hidden.
|
||||
*
|
||||
* A hidden node stays in the tree and stays addressable — the renderer skips
|
||||
* it, nothing else does. This filter exists so a caller can ask the question
|
||||
* that actually disambiguates "show the timeline": is there something by that
|
||||
* name which is currently not on screen? `null` means do not care.
|
||||
*/
|
||||
hidden = null,
|
||||
} = {}) {
|
||||
const want = canon(phrase);
|
||||
if (!want) return [];
|
||||
|
||||
/* Words that carry no evidence about which node is meant. Matching on them
|
||||
would make every phrase fit every node. */
|
||||
const tokens = want.split(' ').filter((word) => word.length > 2 && !STOP.has(word));
|
||||
|
||||
const candidates = walk(nodes)
|
||||
.filter((node) => (type ? node.type === type : true))
|
||||
.filter((node) => (hidden === null ? true : Boolean(node.hidden) === hidden))
|
||||
.map((node) => {
|
||||
const entry = registry.get(node.type);
|
||||
const title = canon(node.props?.title);
|
||||
const label = canon(entry?.label || node.type);
|
||||
/* `labelOfBinding`, not `dataSourceLabel`: a node bound to a page's own
|
||||
series has no source id, and scoring only the source made every such
|
||||
node unfindable by the name of the thing it draws. */
|
||||
const source = canon(node.data ? labelOfBinding(node.data) : '');
|
||||
const id = canon(node.id);
|
||||
|
||||
let score = 0;
|
||||
/* An id said verbatim is not a guess — it is the address, and it wins. */
|
||||
if (id && id === want) score += 100;
|
||||
if (title && title === want) score += 60;
|
||||
if (title && want.includes(title)) score += 40;
|
||||
if (title && title.includes(want)) score += 24;
|
||||
if (label && want.includes(label)) score += 18;
|
||||
if (source && want.includes(source)) score += 14;
|
||||
if (id && want.includes(id)) score += 10;
|
||||
|
||||
/**
|
||||
* Part of a name is still a name.
|
||||
*
|
||||
* "the notice" has to reach "Privileged actions notice", and nobody says
|
||||
* a section's full label out loud. Scored per matching word and below
|
||||
* every whole-name rule above, so a partial match never outranks somebody
|
||||
* naming the thing properly — which is what keeps "the audit section"
|
||||
* pointing at the audit log rather than tying with "Skill sections".
|
||||
*/
|
||||
for (const token of tokens) {
|
||||
if (title && title.includes(token)) score += 8;
|
||||
else if (label && label.includes(token)) score += 6;
|
||||
else if (id && id.includes(token)) score += 4;
|
||||
}
|
||||
|
||||
return { node, score };
|
||||
})
|
||||
.filter((row) => row.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
return candidates.map((row) => ({
|
||||
...describeNode(row.node, {
|
||||
registry,
|
||||
parent: locate(nodes, row.node.id)?.parent || null,
|
||||
index: locate(nodes, row.node.id)?.index || 0,
|
||||
}),
|
||||
/* Carried out so a caller can tell a clear winner from a tie. Deciding that
|
||||
here would be deciding what to do about ambiguity, which belongs to
|
||||
whoever has somebody to ask. */
|
||||
score: row.score,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Words too common to distinguish one node from another. */
|
||||
const STOP = new Set([
|
||||
'the', 'this', 'that', 'these', 'those', 'and', 'for', 'with', 'from', 'into',
|
||||
'show', 'hide', 'move', 'make', 'add', 'put', 'change', 'turn', 'switch',
|
||||
'above', 'below', 'under', 'over', 'before', 'after', 'top', 'bottom',
|
||||
'please', 'section', 'sections', 'panel', 'panels', 'page', 'here',
|
||||
]);
|
||||
|
||||
/** Lower-case, punctuation-free, single-spaced. The one normaliser for matching. */
|
||||
const canon = (value) => String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* What could be added inside a container, and what each could show.
|
||||
*
|
||||
* The picker's source of truth, for the agent and the visual editor alike.
|
||||
* Reads the registry and the data vocabulary, so a newly registered type is
|
||||
* offered without this being touched.
|
||||
*/
|
||||
export function addableTypes(parentType, { registry = nodeRegistry, role = null, page = null } = {}) {
|
||||
return registry.all()
|
||||
/* `offersChild`, not `acceptsChild`: what may structurally sit here is only
|
||||
half the question. The other half — is this a type a person may add, and
|
||||
does it belong to this page — is what kept every page's private sections
|
||||
out of every other page's picker. */
|
||||
.filter((entry) => registry.offersChild(parentType, entry.type, { page }))
|
||||
.filter((entry) => !entry.roles || !role || entry.roles.includes(role))
|
||||
.map((entry) => ({
|
||||
type: entry.type,
|
||||
label: entry.label,
|
||||
summary: entry.summary,
|
||||
container: entry.container,
|
||||
dataShapes: [...entry.dataShapes],
|
||||
dataRequired: entry.dataRequired,
|
||||
}));
|
||||
}
|
||||
884
src/lib/ui/intent.js
Normal file
884
src/lib/ui/intent.js
Normal file
@@ -0,0 +1,884 @@
|
||||
/**
|
||||
* A request in words, turned into one validated operation.
|
||||
*
|
||||
* This is the whole of Owliver's UI-editing understanding, and it is
|
||||
* deliberately small. It reads the verbs from a table, the type names from the
|
||||
* node registry, the data sources from the closed vocabulary, and the targets
|
||||
* from the tree that is actually on screen. There is no page in it, no
|
||||
* component name, and no branch on what a node happens to be — a type
|
||||
* registered tomorrow is addressable tomorrow, by its own label, with this file
|
||||
* unchanged.
|
||||
*
|
||||
* What it can produce is an **operation**, never markup. The model — when there
|
||||
* is one — is not in this path at all: matching is deterministic, which is what
|
||||
* makes it impossible for a hallucinated component name or an invented data
|
||||
* source to reach the engine. The worst a request can do is fail to match.
|
||||
*
|
||||
* Every outcome is one of a small set, and two of them are questions rather
|
||||
* than actions:
|
||||
*
|
||||
* - `plan` an operation, ready to preview
|
||||
* - `inspect` a description of what is on the page
|
||||
* - `apply` / `discard` acting on a preview already shown
|
||||
* - `ambiguous` more than one node fits, so the caller must ask
|
||||
* - `unknown` a target that matches nothing on the page
|
||||
* - `refused` understood, and not allowed — with the reason
|
||||
*/
|
||||
|
||||
import { SUPPORTED_DATA_SOURCES, dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
import { addableTypes, describeNode, resolveTarget } from './inspect';
|
||||
import { freeNodeId, walk } from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
import { kindOfBinding, labelOfBinding, refuseSeries } from './series';
|
||||
|
||||
/** Lower-case, punctuation-free. The one normaliser, shared with `inspect`. */
|
||||
const canon = (value) => String(value ?? '')
|
||||
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
|
||||
const has = (text, ...words) => words.some((w) => text.includes(canon(w)));
|
||||
|
||||
/**
|
||||
* The verbs, as data.
|
||||
*
|
||||
* Each names an operation and the words that ask for it. Ordered: the first
|
||||
* match wins, so the more specific readings are declared before the general
|
||||
* ones. Nothing here names a component or a page.
|
||||
*/
|
||||
const VERBS = [
|
||||
{ op: 'inspect', words: ['what is on this page', 'whats on this page', 'what sections', 'list the sections', 'show me the layout', 'what can i change'] },
|
||||
{ op: 'apply', words: ['apply', 'keep it', 'keep that', 'save it', 'save that', 'yes apply'] },
|
||||
{ op: 'discard', words: ['discard', 'cancel that', 'undo that', 'never mind', 'nevermind', 'revert that'] },
|
||||
/* Unambiguously about the interface: nobody says "unhide" about data. */
|
||||
{ op: 'unhide', words: ['unhide', 'show again', 'bring back', 'bring it back', 'restore', 'put back', 'reveal'] },
|
||||
|
||||
{ op: 'hide', words: ['hide', 'remove', 'delete', 'get rid of', 'take off', 'take away'] },
|
||||
{ op: 'move', words: ['move', 'put', 'place', 'reorder', 'bring'] },
|
||||
/**
|
||||
* How something looks, rather than what it is.
|
||||
*
|
||||
* Before `replace`, because "change this card to compact" names a
|
||||
* presentation value and `replace` would read the same sentence as a request
|
||||
* for a different type. The words themselves are the gate: none of them is
|
||||
* something a person says about data.
|
||||
*/
|
||||
{ op: 'present', words: ['compact', 'comfortable', 'spacious', 'emphasis', 'emphasise', 'emphasize', 'subtle', 'denser', 'tighter', 'roomier'] },
|
||||
|
||||
/**
|
||||
* "I don't like this. What else could it be?"
|
||||
*
|
||||
* Before `replace`, because the sentences overlap — "show me other options"
|
||||
* and "show me some other designs" both contain words `replace` and `show`
|
||||
* would claim — and only this one is a question rather than an instruction.
|
||||
*
|
||||
* Every phrase is two words or more on purpose. A bare "options" is what a
|
||||
* person says about a position or a shift, and stealing it would turn an
|
||||
* ordinary question into a layout answer.
|
||||
*/
|
||||
{ op: 'options', words: [
|
||||
'other options', 'other option', 'more options', 'some other options',
|
||||
'other designs', 'another design', 'different design', 'other design',
|
||||
'alternatives', 'alternative', 'other ways', 'another way', 'different way',
|
||||
'other visualisations', 'other visualizations', 'other visualisation',
|
||||
'other visualization', 'another visualisation', 'another visualization',
|
||||
'different visualisation', 'different visualization',
|
||||
'other look', 'another look', 'different look',
|
||||
'what else can', 'what else could', 'what else is', 'something else',
|
||||
] },
|
||||
|
||||
{ op: 'replace', words: ['change', 'turn', 'switch', 'convert', 'make it a', 'show as', 'show it as'] },
|
||||
{ op: 'add', words: ['add', 'insert', 'create a', 'put a new'] },
|
||||
{ op: 'layout', words: ['columns', 'column', 'side by side', 'two up', 'wider', 'narrower'] },
|
||||
/**
|
||||
* Plain "show" is the hard one, and it is matched last.
|
||||
*
|
||||
* "Show me the candidates" is a reading; "show the timeline" — when the
|
||||
* timeline is hidden — is a layout change. The word cannot tell them apart,
|
||||
* so the *tree* does: this becomes a UI edit only when the phrase names
|
||||
* something currently hidden, and names it convincingly. Last in the list
|
||||
* because the word appears inside other requests — "add a card showing
|
||||
* candidate activity" is an `add`, and would be stolen by an earlier `show`.
|
||||
*/
|
||||
{ op: 'show', words: ['show', 'display'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Words that say the request is about the interface rather than about the data.
|
||||
*
|
||||
* "chart" used to be here and is not any more: it names a registered type now,
|
||||
* so `namedType` recognises it and the gate already opens on that. Keeping it
|
||||
* would have been the language layer holding a component name of its own —
|
||||
* which is the thing the check script greps for, and rightly.
|
||||
*/
|
||||
const UI_WORDS = [
|
||||
'section', 'sections', 'panel', 'panels', 'block', 'blocks', 'widget', 'widgets',
|
||||
'layout', 'page', 'column', 'columns',
|
||||
'above', 'below', 'top', 'bottom', 'first', 'last', 'order',
|
||||
];
|
||||
|
||||
const SHOW_CONFIDENCE = 18;
|
||||
|
||||
/**
|
||||
* The words that mean "the thing we were just talking about".
|
||||
*
|
||||
* Deliberately a closed list of pronouns and demonstratives, and deliberately
|
||||
* not a general reference resolver. A phrase carrying one of these, that names
|
||||
* nothing else on the page, is asking about whatever was last acted on — and
|
||||
* saying so out loud is what makes the fallback safe: a request that *does*
|
||||
* name something is never redirected, and a request that names nothing and
|
||||
* says nothing pronominal is still unknown.
|
||||
*
|
||||
* "back" and "again" are here because "change it back" and "do that again" are
|
||||
* how the follow-up is actually said.
|
||||
*/
|
||||
const ANAPHORA = new Set([
|
||||
'it', 'this', 'that', 'these', 'those', 'them', 'same',
|
||||
'back', 'again', 'instead',
|
||||
]);
|
||||
|
||||
/** Does this phrase refer to something already under discussion? */
|
||||
const refersBack = (text) => text.split(' ').some((word) => ANAPHORA.has(word));
|
||||
|
||||
const NUMBER_WORDS = { one: 1, two: 2, three: 3, four: 4, six: 6, twelve: 12 };
|
||||
|
||||
/**
|
||||
* Read a request.
|
||||
*
|
||||
* `tree` is what is on screen. Nothing is matched against a remembered page —
|
||||
* a target is only resolvable if it is really there, which is what stops a
|
||||
* confident answer about a section that does not exist.
|
||||
*/
|
||||
export function matchUiEdit(question, {
|
||||
tree = [], registry = nodeRegistry, role = null, previewing = false,
|
||||
/* The page being edited. Only ever compared, never interpreted — it is what
|
||||
keeps "add a recent hiring timeline" from being answerable on a page that
|
||||
publishes none of the records such a section reads. */
|
||||
page = null,
|
||||
/**
|
||||
* The node this conversation last acted on, if any.
|
||||
*
|
||||
* The whole of "it". A conversation about a page has a subject, and asking a
|
||||
* person to re-name it in every sentence is not how anyone speaks — "change
|
||||
* Hiring activity to a bar chart" followed by "change it back to a line
|
||||
* chart" is one thought in two sentences, and the second one used to be
|
||||
* answered with "I could not find that on this page."
|
||||
*
|
||||
* An id, and only an id: the caller records which node an operation named
|
||||
* and hands it back next turn. Nothing is remembered here, nothing is
|
||||
* inferred from the model, and a focus that is no longer in the tree simply
|
||||
* does not resolve.
|
||||
*/
|
||||
focus = null,
|
||||
} = {}) {
|
||||
const text = canon(question);
|
||||
if (!text) return null;
|
||||
|
||||
/**
|
||||
* The verb, from the table — or from the registry's own nouns.
|
||||
*
|
||||
* "I want a different chart" asks the same question as "show me other
|
||||
* options" and shares not one word with it. The phrase table cannot grow to
|
||||
* cover it without writing component names into the language layer, so the
|
||||
* second reading derives the noun from the registry instead. See
|
||||
* `asksToRedraw`.
|
||||
*/
|
||||
const verb = VERBS.find((v) => has(text, ...v.words))
|
||||
|| (asksToRedraw(text, registry) ? VERBS.find((v) => v.op === 'options') : null);
|
||||
if (!verb) return null;
|
||||
|
||||
/**
|
||||
* Apply and discard.
|
||||
*
|
||||
* While something is being previewed these are unambiguous. With nothing
|
||||
* previewed they are not: "apply" is an ordinary word — applying for a role,
|
||||
* applying a filter — and this must not swallow it.
|
||||
*
|
||||
* But it must not hand back the panel's *own* words either. The chips this
|
||||
* panel offers say "Apply the layout change" and "Discard the layout change",
|
||||
* and a person who clicks one a second time, or after a re-render has dropped
|
||||
* the preview, was previously answered by the model: an agent scoped to open
|
||||
* roles explaining that layout changes are not in its scope. A request about
|
||||
* the interface must never be answered by something that does not know the
|
||||
* interface exists.
|
||||
*
|
||||
* So the phrase is claimed when it names the interface — the same word gate
|
||||
* the other verbs use — and answered with the plain fact that there is
|
||||
* nothing to act on. Everything else still falls through untouched.
|
||||
*/
|
||||
if (verb.op === 'apply' || verb.op === 'discard') {
|
||||
if (previewing) return { kind: verb.op };
|
||||
return has(text, ...UI_WORDS) ? { kind: 'nothing-previewed', op: verb.op } : null;
|
||||
}
|
||||
|
||||
if (verb.op === 'inspect') return { kind: 'inspect' };
|
||||
|
||||
/**
|
||||
* The gate that keeps ordinary questions ordinary.
|
||||
*
|
||||
* A verb alone is not enough — "show me the candidates" is a reading, not a
|
||||
* layout change. The request has to also name something on this page, a type
|
||||
* the registry knows, or a word about the interface itself.
|
||||
*/
|
||||
/**
|
||||
* Bringing something back is decided by the tree, before the word gate.
|
||||
*
|
||||
* `show` deliberately does not consult the interface-words gate: the evidence
|
||||
* that it means the interface is that a hidden node answers to the phrase,
|
||||
* and requiring "section" or "panel" as well would make the only way to undo
|
||||
* a hide harder to say than the hide was.
|
||||
*/
|
||||
if (verb.op === 'show') return planShow(text, tree, registry);
|
||||
if (verb.op === 'unhide') return planUnhide(text, tree, registry);
|
||||
|
||||
/**
|
||||
* "Show me something else" is asked before the interface-words gate.
|
||||
*
|
||||
* The gate wants a request to name a node, a registered type, or a word like
|
||||
* "section" — and the sentence people actually type names none of the three.
|
||||
* "I don't like this design. Show me other options." was refused by the gate
|
||||
* and fell through to the model, which is exactly the wrong place for it: the
|
||||
* answer is a list of registered components, and a model does not have one.
|
||||
*
|
||||
* Safe to run early because this verb decides for itself whether it has a
|
||||
* subject, and returns null when it does not — so "what are my options for
|
||||
* this position?" is still nobody's layout request.
|
||||
*/
|
||||
if (verb.op === 'options') return planOptions(text, tree, registry, page, focus);
|
||||
|
||||
const named = namedType(text, registry, page);
|
||||
const mentionsUi = has(text, ...UI_WORDS) || Boolean(named);
|
||||
const anyTarget = walk(tree).some((node) => resolveTarget(tree, text, { registry }).length > 0);
|
||||
/* A phrase that refers back to what was just changed names its subject
|
||||
without naming it, so the focus is evidence in its own right. */
|
||||
const carriesOn = Boolean(focus) && refersBack(text);
|
||||
if (!mentionsUi && !anyTarget && !carriesOn) return null;
|
||||
|
||||
switch (verb.op) {
|
||||
case 'hide': return planVisibility(text, tree, registry, true, focus);
|
||||
case 'move': return planMove(text, tree, registry);
|
||||
case 'replace': return planReplace(text, tree, registry, page, focus);
|
||||
case 'add': return planAdd(text, tree, registry, named, role, page);
|
||||
case 'layout': return planLayout(text, tree, registry, focus);
|
||||
case 'present': return planPresent(text, tree, registry, focus);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The node a phrase means, or the question to ask instead.
|
||||
*
|
||||
* Ambiguity is never resolved by picking the first: editing the wrong section
|
||||
* while somebody is looking at another one is the failure this exists to
|
||||
* prevent. Two candidates come back as a question with both named.
|
||||
*/
|
||||
function target(text, tree, registry, { exclude = [], preferHidden = false, focus = null } = {}) {
|
||||
const all = resolveTarget(tree, text, { registry })
|
||||
.filter((node) => !exclude.includes(node.id));
|
||||
/* "Bring back the timeline" means the hidden one, when a hidden one fits.
|
||||
Only a preference: with nothing hidden, the phrase still resolves. */
|
||||
const hiddenOnly = all.filter((node) => node.hidden);
|
||||
const hits = preferHidden && hiddenOnly.length ? hiddenOnly : all;
|
||||
|
||||
/**
|
||||
* Nothing named, but something referred to.
|
||||
*
|
||||
* The last resort, and it is fenced on three sides: the phrase has to carry
|
||||
* a pronoun or a demonstrative, the caller has to have recorded a subject,
|
||||
* and that subject has to still be on the page. Any of the three missing and
|
||||
* this is an unknown target exactly as before.
|
||||
*
|
||||
* It runs only when the words resolved to nothing, so a request that names a
|
||||
* section is never quietly redirected to a different one — which would be the
|
||||
* failure this whole resolver exists to prevent, reintroduced by the back
|
||||
* door.
|
||||
*/
|
||||
if (!hits.length && focus && refersBack(text) && !exclude.includes(focus)) {
|
||||
const carried = resolveTarget(tree, focus, { registry }).find((node) => node.id === focus);
|
||||
if (carried) return { node: carried };
|
||||
}
|
||||
|
||||
if (!hits.length) return { kind: 'unknown', phrase: text };
|
||||
|
||||
/**
|
||||
* A tie is two nodes the phrase fits equally well.
|
||||
*
|
||||
* Judged on the score the resolver assigned rather than on a second reading
|
||||
* of the words here: one place decides how well a phrase fits a node, and
|
||||
* this only decides what to do when two fit the same. A clear winner is acted
|
||||
* on; anything else is a question back.
|
||||
*/
|
||||
if (hits.length > 1 && hits[0].score === hits[1].score) {
|
||||
return { kind: 'ambiguous', candidates: hits.filter((h) => h.score === hits[0].score).slice(0, 4) };
|
||||
}
|
||||
return { node: hits[0] };
|
||||
}
|
||||
|
||||
|
||||
/** Hide or show a node the phrase named. */
|
||||
function planVisibility(text, tree, registry, hidden, focus = null) {
|
||||
const found = target(text, tree, registry, { focus });
|
||||
if (found.kind) return found;
|
||||
return visibilityPlan(found.node, hidden);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan, or the reason there is nothing to do.
|
||||
*
|
||||
* A node already in the state being asked for produces no operation. Saying so
|
||||
* is better than storing a second `hide` on something hidden: the patch stays
|
||||
* the record of decisions a person actually made, and undo steps back through
|
||||
* changes rather than through no-ops.
|
||||
*/
|
||||
function visibilityPlan(node, hidden) {
|
||||
if (!node.capabilities.includes('hide')) {
|
||||
return { kind: 'refused', message: `\`${node.label}\` cannot be ${hidden ? 'hidden' : 'shown'}.` };
|
||||
}
|
||||
if (Boolean(node.hidden) === hidden) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: `\`${node.title || node.label}\` is already ${hidden ? 'hidden' : 'showing'}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'hide', target: node.id, hidden },
|
||||
summary: `${hidden ? 'Hide' : 'Show'} ${node.title || node.label}`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain "show", resolved only against what is hidden.
|
||||
*
|
||||
* Returning null is the important branch: it is what leaves "show me the
|
||||
* candidates" to the rest of Owliver untouched. A hidden node answering to the
|
||||
* phrase is the whole of the evidence that the interface was meant.
|
||||
*/
|
||||
function planShow(text, tree, registry) {
|
||||
const hits = resolveTarget(tree, text, { registry, hidden: true })
|
||||
/**
|
||||
* Enough evidence to outweigh the ordinary meaning of the word.
|
||||
*
|
||||
* `SHOW_CONFIDENCE` is the score a phrase earns by naming a node properly —
|
||||
* containing its whole label or title. A single shared word does not reach
|
||||
* it, which is what keeps "show me the recent hires" a question about hires
|
||||
* rather than a request to reveal the Recent hiring timeline.
|
||||
*/
|
||||
.filter((node) => node.score >= SHOW_CONFIDENCE);
|
||||
if (!hits.length) return null;
|
||||
if (hits.length > 1 && hits[0].score === hits[1].score) {
|
||||
return { kind: 'ambiguous', candidates: hits.filter((h) => h.score === hits[0].score).slice(0, 4) };
|
||||
}
|
||||
return visibilityPlan(hits[0], false);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Unhide", "bring it back", "restore".
|
||||
*
|
||||
* Always a layout request, so this resolves against the whole tree and reports
|
||||
* when the thing named is already on screen — which is more useful than
|
||||
* silently matching nothing.
|
||||
*/
|
||||
function planUnhide(text, tree, registry) {
|
||||
const found = target(text, tree, registry, { preferHidden: true });
|
||||
if (found.kind) return found;
|
||||
return visibilityPlan(found.node, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one node relative to another, or to an end of the page.
|
||||
*
|
||||
* Expressed as a `reorder` of the root rather than a `move`, because that is
|
||||
* what "above the notice" means on a flat page: the node stays where it lives
|
||||
* and the order around it changes.
|
||||
*/
|
||||
function planMove(text, tree, registry) {
|
||||
const roots = tree.map((node) => describeNode(node, { registry }));
|
||||
|
||||
const before = has(text, 'above', 'before', 'over', 'on top of', 'to the top', 'first');
|
||||
const after = has(text, 'below', 'under', 'beneath', 'after', 'to the bottom', 'last', 'end');
|
||||
|
||||
/* Split on the positional word so the two halves name two different nodes:
|
||||
"move timeline above the notice" is a subject and a reference. */
|
||||
const pivot = ['above', 'before', 'below', 'under', 'beneath', 'after', 'over']
|
||||
.map((w) => ({ w, at: text.indexOf(` ${w} `) }))
|
||||
.filter((p) => p.at > 0)
|
||||
.sort((a, b) => a.at - b.at)[0];
|
||||
|
||||
const subjectText = pivot ? text.slice(0, pivot.at) : text;
|
||||
const subject = target(subjectText, tree, registry);
|
||||
if (subject.kind) return subject;
|
||||
|
||||
const order = roots.map((n) => n.id);
|
||||
const from = order.indexOf(subject.node.id);
|
||||
if (from < 0) return { kind: 'unknown', phrase: subjectText };
|
||||
|
||||
if (!subject.node.capabilities.includes('move')) {
|
||||
return { kind: 'refused', message: `\`${subject.node.label}\` cannot be moved.` };
|
||||
}
|
||||
|
||||
let to;
|
||||
if (pivot) {
|
||||
const referenceText = text.slice(pivot.at + pivot.w.length + 2);
|
||||
const reference = target(referenceText, tree, registry, { exclude: [subject.node.id] });
|
||||
if (reference.kind) return reference;
|
||||
const at = order.indexOf(reference.node.id);
|
||||
if (at < 0) return { kind: 'unknown', phrase: referenceText };
|
||||
to = ['above', 'before', 'over'].includes(pivot.w) ? at : at + 1;
|
||||
} else if (before) {
|
||||
to = 0;
|
||||
} else if (after) {
|
||||
to = order.length;
|
||||
} else {
|
||||
return { kind: 'unknown', phrase: text };
|
||||
}
|
||||
|
||||
const next = [...order];
|
||||
next.splice(from, 1);
|
||||
next.splice(to > from ? to - 1 : to, 0, subject.node.id);
|
||||
|
||||
if (next.join() === order.join()) {
|
||||
return { kind: 'refused', message: `\`${subject.node.title || subject.node.label}\` is already there.` };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'reorder', parent: null, order: next },
|
||||
summary: `Move ${subject.node.title || subject.node.label}`,
|
||||
node: subject.node,
|
||||
};
|
||||
}
|
||||
|
||||
/** The registered type a phrase names, by type id or by its label. */
|
||||
function namedType(text, registry, page = null) {
|
||||
const hit = registry.all()
|
||||
/* Only what this page could actually hold. A type named in a sentence that
|
||||
does not belong here is not a type this reader can mean. */
|
||||
.filter((entry) => registry.offersChild(null, entry.type, { page }))
|
||||
.map((entry) => ({ entry, word: canon(entry.label) }))
|
||||
.filter(({ entry, word }) => has(text, entry.type) || (word && has(text, word)))
|
||||
/* Longest label first, so "audit log" is not read as "log". */
|
||||
.sort((a, b) => b.word.length - a.word.length)[0];
|
||||
return hit?.entry || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this node could be shown as, and what to call it.
|
||||
*
|
||||
* The single place either half of the product asks the question. `planReplace`
|
||||
* uses it to check one answer and `planOptions` to list them all, so a type
|
||||
* offered in a list can never be one the same request would refuse — which is
|
||||
* the bug that two copies of this rule would eventually produce.
|
||||
*/
|
||||
function alternatives(node, registry, page) {
|
||||
const binding = node.data || null;
|
||||
const shapes = binding?.source ? dataSourceFor(binding.source)?.shapes || [] : null;
|
||||
const types = registry.replacements(node.type, {
|
||||
shapes,
|
||||
page,
|
||||
seriesKind: kindOfBinding(binding),
|
||||
seriesBound: Boolean(binding?.series),
|
||||
/* Whether there is a reading at all — which is what tells the registry
|
||||
apart "nothing to be wrong about" from "a reading that never said what
|
||||
it means". */
|
||||
bound: Boolean(binding),
|
||||
});
|
||||
return { binding, types, entries: types.map((type) => registry.get(type)).filter(Boolean) };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Show me something else" — answered from the registry, never invented.
|
||||
*
|
||||
* This is the request that most invites a model to make something up, so it is
|
||||
* the one most firmly deterministic: the answer is the same list `planReplace`
|
||||
* would check a single name against, in the same order, produced by the same
|
||||
* function. Nothing here proposes a design; it reports which registered
|
||||
* components can honestly draw what this node is already reading.
|
||||
*
|
||||
* The target is the harder half, because the sentence rarely names one — "I
|
||||
* don't like this, show me other options" names nothing at all. So: resolve it
|
||||
* from the words if the words say; otherwise let the *tree* answer, and only
|
||||
* when the tree's answer is unambiguous. One node with alternatives is the
|
||||
* subject. Several is a question back, never a guess.
|
||||
*/
|
||||
function planOptions(text, tree, registry, page = null, focus = null) {
|
||||
/* "An alternative candidate" is not a layout request, whatever else the
|
||||
sentence contains. Checked first, so no amount of page state can turn it
|
||||
into one. */
|
||||
if (qualifiedElsewhere(text, registry)) return null;
|
||||
|
||||
const named = target(text, tree, registry, { focus });
|
||||
const subject = named.kind ? null : named.node;
|
||||
|
||||
if (!subject) {
|
||||
const changeable = walk(tree)
|
||||
.filter((node) => !node.hidden)
|
||||
.map((node) => describeNode(node, { registry }))
|
||||
.filter((node) => node.capabilities.includes('replace'))
|
||||
.filter((node) => alternatives(node, registry, page).entries.length > 0);
|
||||
|
||||
/**
|
||||
* Nothing on this page could be drawn another way.
|
||||
*
|
||||
* Answered by *not* answering. This verb now runs before the
|
||||
* interface-words gate, so it sees sentences that were never about the
|
||||
* layout, and the only honest thing to do with one of those, on a page with
|
||||
* nothing to offer, is leave it to whoever else can answer it. It used to
|
||||
* say "Nothing on this page can be drawn another way yet." — a true
|
||||
* sentence, and the wrong reply to a question about candidates.
|
||||
*/
|
||||
if (!changeable.length) return null;
|
||||
/* One thing it could be about is not ambiguity; several is a question. */
|
||||
if (changeable.length > 1) {
|
||||
return { kind: 'ambiguous', candidates: changeable.slice(0, 4) };
|
||||
}
|
||||
return offer(changeable[0], registry, page);
|
||||
}
|
||||
|
||||
if (!subject.capabilities.includes('replace')) {
|
||||
return { kind: 'refused', message: `\`${subject.title || subject.label}\` cannot be drawn another way.` };
|
||||
}
|
||||
return offer(subject, registry, page);
|
||||
}
|
||||
|
||||
/**
|
||||
* "A different chart", "another table" — with the nouns read from the registry.
|
||||
*
|
||||
* The phrase list above cannot cover this, because the noun a person reaches
|
||||
* for is the name of a kind of component, and writing those out here would put
|
||||
* component names back into the language layer — the one thing this file is
|
||||
* checked for. So the nouns are derived: the last word of every registered
|
||||
* type's own label, which is "chart" for five of them and "table", "card" or
|
||||
* "timeline" for the others. A type registered tomorrow is askable for by its
|
||||
* own noun, tomorrow, with this unchanged.
|
||||
*/
|
||||
function asksToRedraw(text, registry) {
|
||||
return [...redrawNouns(registry)].some((noun) => (
|
||||
new RegExp(`\\b(another|different|other)\\s+(?:\\w+\\s+)?${noun}\\b`).test(text)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* The nouns a person can ask for another *of*.
|
||||
*
|
||||
* The last word of every registered type's own label — "chart", "table",
|
||||
* "card", "timeline" — so the vocabulary is the registry's rather than a list
|
||||
* kept here. Page-bound types are left out on purpose: their labels are the
|
||||
* names of readings ("Hiring activity", "Position candidates"), and "another
|
||||
* candidate" is a question about people, not about panels.
|
||||
*/
|
||||
function redrawNouns(registry) {
|
||||
return new Set(
|
||||
registry.all()
|
||||
.filter((entry) => !entry.page)
|
||||
.map((entry) => canon(entry.label).split(' ').pop())
|
||||
.filter((noun) => noun && noun.length > 2)
|
||||
);
|
||||
}
|
||||
|
||||
/** The words a bare "another" is about when it is about the interface. */
|
||||
const REDRAW_WORDS = [
|
||||
'design', 'designs', 'look', 'looks', 'option', 'options', 'way', 'ways',
|
||||
'alternative', 'alternatives', 'version', 'versions', 'one', 'ones',
|
||||
'visualisation', 'visualisations', 'visualization', 'visualizations',
|
||||
];
|
||||
|
||||
/** The words that carry no evidence about what is being asked for. */
|
||||
const FILLER = new Set([
|
||||
'the', 'this', 'that', 'for', 'and', 'with', 'from', 'some', 'any', 'you',
|
||||
'can', 'could', 'are', 'was', 'all', 'out', 'into', 'please', 'give', 'show',
|
||||
'want', 'like', 'have', 'use', 'about', 'here',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Is the "other" in this sentence about something that is not the interface?
|
||||
*
|
||||
* The one test that keeps "give me an alternative candidate" a question about
|
||||
* people. Both sentences say "alternative"; the difference is the noun that
|
||||
* follows it, and it is decided by looking rather than by guessing:
|
||||
*
|
||||
* - nothing follows → about the interface ("give me alternatives")
|
||||
* - one of the interface words → about the interface ("other options")
|
||||
* - a registered type's noun → about the interface ("a different chart")
|
||||
* - anything else → not ours, and left entirely alone
|
||||
*
|
||||
* A false positive here costs a person a wrong answer about their candidates,
|
||||
* which is worse than a layout question going unanswered — so the doubtful case
|
||||
* falls through rather than being claimed.
|
||||
*/
|
||||
function qualifiedElsewhere(text, registry) {
|
||||
const ours = new Set([...REDRAW_WORDS, ...UI_WORDS, ...redrawNouns(registry)]);
|
||||
const words = text.split(' ');
|
||||
|
||||
return words.some((word, i) => {
|
||||
if (!['other', 'another', 'different', 'alternative', 'alternatives'].includes(word)) {
|
||||
return false;
|
||||
}
|
||||
const following = words.slice(i + 1, i + 3)
|
||||
.filter((next) => next.length > 2 && !FILLER.has(next));
|
||||
return following.length > 0 && !ours.has(following[0]);
|
||||
});
|
||||
}
|
||||
|
||||
/** The list, or the reason there is not one. */
|
||||
function offer(node, registry, page) {
|
||||
const { binding, entries } = alternatives(node, registry, page);
|
||||
if (!entries.length) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: `There is no other way to draw ${binding ? labelOfBinding(binding) : node.title || node.label} yet.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'options',
|
||||
node,
|
||||
subject: binding ? labelOfBinding(binding) : node.title || node.label,
|
||||
/* Four, because a choice a person reads at a glance is a choice they make.
|
||||
The cap is on what is offered, never on what is possible — the operation
|
||||
path accepts any registered type the same rule allows. */
|
||||
options: entries.slice(0, 4).map((entry) => ({
|
||||
type: entry.type, label: entry.label, summary: entry.summary,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Turn one node into another type. */
|
||||
function planReplace(text, tree, registry, page = null, focus = null) {
|
||||
/**
|
||||
* The type asked for is the one after the connector.
|
||||
*
|
||||
* "change the audit log to a table" names two things the registry knows — the
|
||||
* section being changed and the shape it should become — and reading the whole
|
||||
* sentence for a type finds whichever label happens to be longer. The
|
||||
* connector is what tells them apart, and it is how people say it.
|
||||
*/
|
||||
const split = /\b(?:in)?to\s+(?:an?\s+)?|\bas\s+(?:an?\s+)?/.exec(text);
|
||||
const wanted = split ? text.slice(split.index + split[0].length) : text;
|
||||
/* Page-scoped, like every other reading of a type name. Without it a request
|
||||
could name a section belonging to another page, and the refusal listed the
|
||||
whole registry back — every page's private sections, to a person who can
|
||||
use none of them. */
|
||||
const named = namedType(wanted, registry, page);
|
||||
|
||||
if (!named) {
|
||||
return {
|
||||
kind: 'unknown-type',
|
||||
phrase: text,
|
||||
offered: addableTypes(null, { registry, page }).map((t) => t.type),
|
||||
};
|
||||
}
|
||||
|
||||
/* The subject is whatever came before the connector; with no connector, the
|
||||
sentence minus the type name. */
|
||||
const subject = split ? text.slice(0, split.index) : text.replace(canon(named.label), ' ');
|
||||
/**
|
||||
* "Change **it** back to a line chart."
|
||||
*
|
||||
* The half of the sentence before the connector is a pronoun, which names
|
||||
* nothing and resolves to nothing — so the subject is whatever this
|
||||
* conversation last changed. That is the ordinary reading of the sentence,
|
||||
* and producing "I could not find that on this page" instead was the gap.
|
||||
*/
|
||||
const found = target(subject, tree, registry, { focus });
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
if (!node.capabilities.includes('replace')) {
|
||||
return { kind: 'refused', message: `\`${node.label}\` cannot be changed into something else.` };
|
||||
}
|
||||
if (node.type === named.type) {
|
||||
return { kind: 'refused', message: `\`${node.title || node.label}\` is already a ${named.label}.` };
|
||||
}
|
||||
|
||||
/* The binding decides what it can become. Asking the registry rather than
|
||||
deciding here is what keeps this free of type knowledge. */
|
||||
const { binding, entries } = alternatives(node, registry, page);
|
||||
if (!entries.some((entry) => entry.type === named.type)) {
|
||||
/**
|
||||
* Refused, with the reason and the way forward.
|
||||
*
|
||||
* A bare no is the worst of the three things this could say. The reason
|
||||
* comes from what the reading *means* — computed once, in `series.js`, so
|
||||
* the editor's refusal and this one are the same sentence — and the
|
||||
* alternatives are the very list the next question would produce.
|
||||
*/
|
||||
const why = binding
|
||||
? refuseSeries(named, binding)
|
||||
: `\`${node.label}\` cannot become a ${named.label}.`;
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: why || (binding?.source
|
||||
? `\`${dataSourceLabel(binding.source)}\` cannot be shown as a ${named.label}.`
|
||||
: `\`${node.label}\` cannot become a ${named.label}.`),
|
||||
node,
|
||||
alternatives: entries.map((entry) => ({ type: entry.type, label: entry.label })),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'replace', target: node.id, type: named.type },
|
||||
summary: `Change ${node.title || node.label} to a ${named.label}`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reading.
|
||||
*
|
||||
* A new node needs a type and a source, and both must be named — a source is
|
||||
* never guessed. Asked for without one, this returns the choices rather than
|
||||
* inventing a binding, which is the difference between a product that offers
|
||||
* what it has and one that makes something up.
|
||||
*/
|
||||
function planAdd(text, tree, registry, named, role, page = null) {
|
||||
if (!named) {
|
||||
return {
|
||||
kind: 'unknown-type',
|
||||
phrase: text,
|
||||
offered: addableTypes(null, { registry, role, page }).map((t) => t.type),
|
||||
};
|
||||
}
|
||||
if (!registry.offersChild(null, named.type, { page })) {
|
||||
return { kind: 'refused', message: `A ${named.label} cannot be added to this page.` };
|
||||
}
|
||||
if (named.roles && role && !named.roles.includes(role)) {
|
||||
return { kind: 'refused', message: `You do not have access to ${named.label}.` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which readings could fill this shape *here*.
|
||||
*
|
||||
* Filtered by what the page can answer, not just by shape. A reading that
|
||||
* needs a record — "Position activity" — placed on a page that is inside no
|
||||
* record renders "This section needs a position to read." forever, and
|
||||
* offering it is the same mistake the visual picker was making.
|
||||
*/
|
||||
const fits = (id) => {
|
||||
const entry = dataSourceFor(id);
|
||||
if (!entry || !(entry.shapes || []).includes(named.type)) return false;
|
||||
return !entry.context;
|
||||
};
|
||||
|
||||
const source = namedSource(text, named, fits);
|
||||
if (!source) {
|
||||
const options = SUPPORTED_DATA_SOURCES.filter(fits);
|
||||
return { kind: 'needs-source', type: named, options: options.slice(0, 6) };
|
||||
}
|
||||
if (!fits(source)) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: `${dataSourceLabel(source)} needs a record this page is not showing.`,
|
||||
};
|
||||
}
|
||||
|
||||
const id = freeNodeId(tree, named.type);
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: {
|
||||
op: 'add',
|
||||
parent: null,
|
||||
node: { id, type: named.type, data: { source }, props: { title: dataSourceLabel(source) } },
|
||||
},
|
||||
summary: `Add a ${named.label} showing ${dataSourceLabel(source)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The data source a phrase names, restricted to what this type can draw. */
|
||||
function namedSource(text, named, answerable = null) {
|
||||
const hits = SUPPORTED_DATA_SOURCES
|
||||
.filter((id) => (dataSourceFor(id)?.shapes || []).includes(named.type))
|
||||
.map((id) => ({ id, words: canon(dataSourceLabel(id)) }))
|
||||
.filter(({ id, words }) => has(text, words) || has(text, canon(id)))
|
||||
.sort((a, b) => b.words.length - a.words.length);
|
||||
|
||||
if (!hits.length) return null;
|
||||
|
||||
/**
|
||||
* Two readings can answer to the same words.
|
||||
*
|
||||
* `candidate.activity` and `candidates.activity` are both labelled "Candidate
|
||||
* activity" — one is what happened on one candidate's record, the other is
|
||||
* applications across the workspace. Nothing in the phrase separates them, so
|
||||
* a sort decided, and on a page showing no candidate the sort could pick the
|
||||
* one that can never resolve: a panel reading "This section needs a candidate
|
||||
* to read." for as long as it is kept.
|
||||
*
|
||||
* So where the words do not decide, what the page can answer does. A reading
|
||||
* that fits is preferred over one that cannot; with nothing to choose
|
||||
* between, the longest match still wins and the caller refuses it by name.
|
||||
*/
|
||||
const fits = answerable || (() => true);
|
||||
return (hits.find((hit) => fits(hit.id)) || hits[0]).id;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a node presents itself.
|
||||
*
|
||||
* The words map to values from the closed vocabulary and nothing else — there
|
||||
* is no path from a sentence to a class name. "Reset" is included because
|
||||
* putting something back is the request people actually make after trying
|
||||
* something, and it has to be sayable.
|
||||
*/
|
||||
function planPresent(text, tree, registry, focus = null) {
|
||||
const wants = {};
|
||||
if (has(text, 'compact', 'denser', 'tighter')) wants.density = 'compact';
|
||||
if (has(text, 'comfortable', 'spacious', 'roomier')) wants.density = 'comfortable';
|
||||
if (has(text, 'emphasis', 'emphasise', 'emphasize')) wants.variant = 'emphasis';
|
||||
if (has(text, 'subtle')) wants.variant = 'subtle';
|
||||
if (has(text, 'default', 'reset', 'normal')) wants.variant = 'default';
|
||||
if (!Object.keys(wants).length) return { kind: 'unknown', phrase: text };
|
||||
|
||||
const found = target(text, tree, registry, { focus });
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
if (!node.capabilities.includes('update')) {
|
||||
return { kind: 'refused', message: `\`${node.label}\` cannot be changed.` };
|
||||
}
|
||||
|
||||
/* Refused by name where the type cannot draw it, rather than stored as a
|
||||
setting the component ignores — the same rule the validator applies, said
|
||||
earlier so the person hears it instead of seeing nothing happen. */
|
||||
for (const [key, value] of Object.entries(wants)) {
|
||||
const supported = key === 'variant' ? node.variants : node.densities;
|
||||
if (!supported.includes(value)) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: supported.length
|
||||
? `\`${node.title || node.label}\` supports ${key}: ${supported.join(', ')}.`
|
||||
: `\`${node.title || node.label}\` has no ${key} to set.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const said = Object.entries(wants).map(([k, v]) => `${k} ${v}`).join(' and ');
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'update', target: node.id, presentation: wants },
|
||||
summary: `Set ${node.title || node.label} to ${said}`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
/** Column counts. */
|
||||
function planLayout(text, tree, registry, focus = null) {
|
||||
const digit = /(\d+)\s*(?:column|col)/.exec(text)?.[1];
|
||||
const word = Object.keys(NUMBER_WORDS).find((w) => has(text, `${w} column`));
|
||||
const columns = Number(digit) || NUMBER_WORDS[word] || (has(text, 'side by side', 'two up') ? 2 : null);
|
||||
if (!columns) return { kind: 'unknown', phrase: text };
|
||||
|
||||
const found = target(text, tree, registry, { focus });
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
if (!node.capabilities.includes('update')) {
|
||||
return { kind: 'refused', message: `\`${node.label}\` cannot be changed.` };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'update', target: node.id, layout: { columns } },
|
||||
summary: `Make ${node.title || node.label} ${columns} columns`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
351
src/lib/ui/node.js
Normal file
351
src/lib/ui/node.js
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* A UI node — the unit the agent and the editor address.
|
||||
*
|
||||
* This is the whole shape of a piece of interface as far as the mutation engine
|
||||
* is concerned. It is deliberately dumb: a node names a *type* and carries
|
||||
* *configuration*, and it never carries a component, a class name, markup, or
|
||||
* anything that could be evaluated. Turning a node into pixels is the
|
||||
* renderer's job, and the renderer only ever looks the type up in a registry of
|
||||
* components the application already ships.
|
||||
*
|
||||
* That split is the security boundary. `type` is a key, not an import; `props`
|
||||
* are checked against a schema the type declares; `data` names a reading from
|
||||
* the closed vocabulary in `lib/skills/surfaces.js`. There is no field here
|
||||
* through which a definition can introduce code, and no field the engine passes
|
||||
* through without checking. It is the same guarantee `SkillSections.jsx` and
|
||||
* `uiConfig.js` already make for skill sections, widened from one slot to a
|
||||
* whole tree.
|
||||
*
|
||||
* Nothing in this module knows a page name, a component name, or a product
|
||||
* feature. Everything specific lives in the registry (`registry.js`) or in the
|
||||
* composition a page publishes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Where a node came from, which is what keeps the three persistence tiers
|
||||
* separate.
|
||||
*
|
||||
* `builtin` is the application's own composition; `skill` is adapted from a
|
||||
* definition's `ui:` block; `user` is something a person added at runtime. The
|
||||
* distinction matters at save time — a user patch is stored as operations
|
||||
* against the other two, never as a copy of them — and at delete time, because
|
||||
* removing a built-in is hiding it, while removing a node a user added is
|
||||
* really removing it.
|
||||
*/
|
||||
export const NODE_ORIGINS = ['builtin', 'skill', 'user'];
|
||||
|
||||
/**
|
||||
* What may be done to a node, declared per type rather than assumed.
|
||||
*
|
||||
* A type opts in. The engine never infers a capability from a type's name or
|
||||
* shape, so a component that must not be moved says so once, in its
|
||||
* registration, and every operation honours it without knowing what it is.
|
||||
*
|
||||
* `add` and `reorder` are about a node's *children* and only mean anything on a
|
||||
* container. The rest are about the node itself.
|
||||
*/
|
||||
export const NODE_CAPABILITIES = [
|
||||
'add', 'update', 'remove', 'move', 'replace', 'hide', 'reorder',
|
||||
];
|
||||
|
||||
/** Ids are addresses. Same rule as a section id, so the two can never disagree. */
|
||||
export const NODE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/**
|
||||
* How wide a node may be, as a count of grid columns.
|
||||
*
|
||||
* Bounded here rather than per type so that "make this two columns" has one
|
||||
* answer everywhere; a type narrows it further through `constraints`.
|
||||
* Twelve because that is the largest arrangement the grid utilities express,
|
||||
* and an unbounded number is a layout that breaks below a phone width.
|
||||
*/
|
||||
export const MIN_COLUMNS = 1;
|
||||
export const MAX_COLUMNS = 12;
|
||||
|
||||
/** The layout keys a node may carry. Anything else an author writes is dropped. */
|
||||
export const LAYOUT_KEYS = ['columns', 'span', 'gap', 'align', 'spacingBefore', 'spacingAfter'];
|
||||
|
||||
export const GAP_VALUES = ['none', 'sm', 'md', 'lg'];
|
||||
export const ALIGN_VALUES = ['start', 'center', 'end', 'stretch'];
|
||||
|
||||
/**
|
||||
* Space above or below a node, as a step on a scale rather than a measurement.
|
||||
*
|
||||
* A closed set of four, each mapping to one class the design system already
|
||||
* ships. This exists because a page sometimes needs a node to sit further from
|
||||
* what precedes it than the page's own rhythm gives — and the alternative,
|
||||
* letting configuration carry a class name, would put arbitrary CSS into a
|
||||
* schema a person can edit. A step cannot say anything the product has not
|
||||
* already decided it can say.
|
||||
*/
|
||||
export const SPACING_VALUES = ['none', 'sm', 'md', 'lg'];
|
||||
|
||||
/**
|
||||
* How a node presents itself, as distinct from where it sits.
|
||||
*
|
||||
* `layout` answers *arrangement* — columns, span, the space around a node.
|
||||
* This answers *treatment*: how tight the node is and how much weight it
|
||||
* carries. They are kept apart because they are edited for different reasons
|
||||
* and because calling this "layout" would make the word mean everything.
|
||||
*
|
||||
* Two closed scales, and closed is the point. A person can say "compact" and
|
||||
* the product decides what compact means; there is no value here that carries a
|
||||
* class name, a measurement or a colour, so nothing a person or an agent writes
|
||||
* can reach the stylesheet. A type that has not declared it supports a value
|
||||
* refuses it — see `variants` and `densities` on a registration.
|
||||
*/
|
||||
export const PRESENTATION_KEYS = ['variant', 'density'];
|
||||
|
||||
/** The weight a node carries. `default` is the panel every section already draws. */
|
||||
export const VARIANT_VALUES = ['default', 'subtle', 'emphasis'];
|
||||
|
||||
/** How tightly a node is packed. `comfortable` is today's spacing, unchanged. */
|
||||
export const DENSITY_VALUES = ['comfortable', 'compact'];
|
||||
|
||||
/**
|
||||
* What a numeric series *means*, as a closed vocabulary.
|
||||
*
|
||||
* Shape and meaning are different questions and the engine had only the first.
|
||||
* `flow` says a reading is a labelled numeric series; it does not say whether
|
||||
* the labels are days, funnel stages or disjoint categories — and that is
|
||||
* exactly the difference between a pie chart that is true and one that is a
|
||||
* lie. `SECTION_TYPES` even admits it: a flow is "a sequence of stages **or**
|
||||
* periods".
|
||||
*
|
||||
* - `periodic` points ordered in time. The order carries the meaning and
|
||||
* the values do not add up to a whole.
|
||||
* - `cumulative` a funnel: each step is a subset of the one before it, so
|
||||
* the steps overlap and summing them counts people twice.
|
||||
* - `parts` disjoint categories that together make up one total. The
|
||||
* only meaning for which "share of the whole" is true.
|
||||
*
|
||||
* A type declares which of these it can honestly draw (`seriesKinds`); a
|
||||
* binding is asked what it is. Neither is inferred from a component name, and a
|
||||
* binding that cannot say stays unknown — which leaves it exactly as permissive
|
||||
* as it was before this vocabulary existed, rather than guessing.
|
||||
*/
|
||||
export const SERIES_KINDS = ['periodic', 'cumulative', 'parts'];
|
||||
|
||||
/**
|
||||
* A node, with every field settled.
|
||||
*
|
||||
* Callers hand in whatever they have; this decides what the rest of the system
|
||||
* sees. It does **not** validate — `validate.js` does that, against the
|
||||
* registry, and keeping the two apart is what lets an invalid node exist long
|
||||
* enough to be reported with a message instead of vanishing.
|
||||
*
|
||||
* Unknown keys are dropped rather than carried. A node that survived with an
|
||||
* extra field would eventually have that field read by something, and then the
|
||||
* closed vocabulary would be closed only by convention.
|
||||
*/
|
||||
/** @param {any} raw @returns {any} */
|
||||
export function makeNode(raw = {}) {
|
||||
/** @type {any} */
|
||||
const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
||||
|
||||
return {
|
||||
id: String(source.id ?? '').trim(),
|
||||
type: String(source.type ?? '').trim(),
|
||||
props: plainObject(source.props),
|
||||
data: normalizeBinding(source.data),
|
||||
layout: normalizeLayout(source.layout),
|
||||
presentation: normalizePresentation(source.presentation),
|
||||
children: Array.isArray(source.children) ? source.children.map(makeNode) : [],
|
||||
hidden: source.hidden === true,
|
||||
origin: NODE_ORIGINS.includes(source.origin) ? source.origin : 'builtin',
|
||||
/* Locked is the type's word, not the node's, but it is stored on the node so
|
||||
a composition can pin one instance — a page may legitimately want its
|
||||
header fixed while the same type is movable elsewhere. */
|
||||
locked: source.locked === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** A shallow copy of a plain object, or an empty one. Never an array, never null. */
|
||||
function plainObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The data binding, or null.
|
||||
*
|
||||
* `source` is an id in the closed data-source vocabulary and is checked there,
|
||||
* not here. `params` is the small bag of options a source declares it reads —
|
||||
* periods, limit — and is likewise checked at validation. What this does is
|
||||
* make the shape predictable: a binding is either absent or an object with a
|
||||
* string source, so nothing downstream has to test both `data.source` and a
|
||||
* bare `source`.
|
||||
*/
|
||||
function normalizeBinding(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string') {
|
||||
const source = value.trim();
|
||||
return source ? { source, params: {} } : null;
|
||||
}
|
||||
if (typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
/**
|
||||
* A page's own series, rather than a skill's reading.
|
||||
*
|
||||
* The second kind of binding, and the reason built-in charts can be changed
|
||||
* at all. A page section's figures are not in the skill data vocabulary —
|
||||
* they are derived by the page from what it already loaded — so a node that
|
||||
* draws them can only name them, the same way a skill node names a source.
|
||||
* Which one a binding is, is decided by which key it carries; both are
|
||||
* resolved through a registry and neither can carry a value.
|
||||
*/
|
||||
const series = String(value.series ?? '').trim();
|
||||
if (series) return { series, params: plainObject(value.params) };
|
||||
const source = String(value.source ?? '').trim();
|
||||
if (!source) return null;
|
||||
return { source, params: plainObject(value.params) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout, reduced to the four keys that mean something.
|
||||
*
|
||||
* Coerced rather than refused: `columns: "2"` is what a form control produces
|
||||
* and a conversation says, and treating that as an authoring error would make
|
||||
* the format precious about typing. Out-of-range values are clamped at
|
||||
* validation, where the type's own constraints are known — not here, where they
|
||||
* are not.
|
||||
*/
|
||||
function normalizeLayout(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const layout = {};
|
||||
for (const key of LAYOUT_KEYS) {
|
||||
if (value[key] == null || value[key] === '') continue;
|
||||
if (key === 'columns' || key === 'span') {
|
||||
const n = Number(value[key]);
|
||||
if (Number.isFinite(n)) layout[key] = Math.round(n);
|
||||
continue;
|
||||
}
|
||||
layout[key] = String(value[key]).trim();
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* The presentation keys a node may carry, and nothing else.
|
||||
*
|
||||
* Values are not checked here — `validate.js` does that against the type's own
|
||||
* declared support, so an unsupported value survives long enough to be reported
|
||||
* by name rather than disappearing silently.
|
||||
*/
|
||||
function normalizePresentation(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const presentation = {};
|
||||
for (const key of PRESENTATION_KEYS) {
|
||||
if (value[key] == null || value[key] === '') continue;
|
||||
presentation[key] = String(value[key]).trim();
|
||||
}
|
||||
return presentation;
|
||||
}
|
||||
|
||||
/* ── Reading a tree ─────────────────────────────────────────────────────────
|
||||
A tree is an array of root nodes, not a single node with a synthetic root.
|
||||
A page is a list of things, and inventing a root would give the engine one
|
||||
node that every rule then has to except. */
|
||||
|
||||
/** Every node, parents before children. Order is the reading order of the page. */
|
||||
export function walk(nodes) {
|
||||
const out = [];
|
||||
const visit = (list) => {
|
||||
for (const node of list || []) {
|
||||
out.push(node);
|
||||
if (node.children?.length) visit(node.children);
|
||||
}
|
||||
};
|
||||
visit(nodes);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The node with this id, or null. */
|
||||
export function findNode(nodes, id) {
|
||||
const want = String(id ?? '').trim();
|
||||
if (!want) return null;
|
||||
return walk(nodes).find((node) => node.id === want) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a node sits: its parent (null at the root) and its index among siblings.
|
||||
*
|
||||
* Every structural operation needs this, and every one of them computing it
|
||||
* separately is how `move` and `reorder` end up disagreeing about what an index
|
||||
* means.
|
||||
*/
|
||||
export function locate(nodes, id) {
|
||||
const want = String(id ?? '').trim();
|
||||
if (!want) return null;
|
||||
|
||||
const search = (list, parent) => {
|
||||
for (let index = 0; index < list.length; index += 1) {
|
||||
if (list[index].id === want) return { parent, siblings: list, index, node: list[index] };
|
||||
const deeper = list[index].children?.length ? search(list[index].children, list[index]) : null;
|
||||
if (deeper) return deeper;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return search(nodes || [], null);
|
||||
}
|
||||
|
||||
/** Every id in the tree, including duplicates — the caller decides what that means. */
|
||||
export const idsOf = (nodes) => walk(nodes).map((node) => node.id);
|
||||
|
||||
/**
|
||||
* A tree with one node replaced by the result of `fn`, and everything else
|
||||
* shared.
|
||||
*
|
||||
* Structural sharing rather than a deep clone: React re-renders what changed,
|
||||
* and a wholesale copy would repaint a page for a hidden flag. Returning `null`
|
||||
* from `fn` removes the node, which is what makes `remove` a special case of
|
||||
* this rather than a second traversal.
|
||||
*/
|
||||
export function mapNode(nodes, id, fn) {
|
||||
const want = String(id ?? '').trim();
|
||||
let touched = false;
|
||||
|
||||
const visit = (list) => list.reduce((acc, node) => {
|
||||
if (node.id === want) {
|
||||
touched = true;
|
||||
const next = fn(node);
|
||||
if (next) acc.push(next);
|
||||
return acc;
|
||||
}
|
||||
if (node.children?.length) {
|
||||
const children = visit(node.children);
|
||||
acc.push(children === node.children ? node : { ...node, children });
|
||||
return acc;
|
||||
}
|
||||
acc.push(node);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const next = visit(nodes || []);
|
||||
return touched ? next : nodes;
|
||||
}
|
||||
|
||||
/** A tree with `fn` applied to one node's children list. */
|
||||
export function mapChildren(nodes, parentId, fn) {
|
||||
const want = String(parentId ?? '').trim();
|
||||
/* The root is addressed by a null parent, so a caller does not need a
|
||||
different function to reorder top-level nodes than nested ones. */
|
||||
if (!want) return fn(nodes || []);
|
||||
return mapNode(nodes, want, (node) => ({ ...node, children: fn(node.children || []) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* An id nothing in the tree is using, derived from the type.
|
||||
*
|
||||
* Shared, because Owliver and the editor both add nodes and two id schemes
|
||||
* would mean the same action produced different addresses depending on which
|
||||
* surface asked for it.
|
||||
*/
|
||||
export function freeNodeId(nodes, type) {
|
||||
const taken = new Set(walk(nodes).map((node) => node.id));
|
||||
let n = 1;
|
||||
while (taken.has(`${type}-${n}`)) n += 1;
|
||||
return `${type}-${n}`;
|
||||
}
|
||||
|
||||
/** A node and everything under it, as a fresh tree. Used by move and by undo. */
|
||||
export const cloneNode = (node) => makeNode(node);
|
||||
497
src/lib/ui/operations.js
Normal file
497
src/lib/ui/operations.js
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* The mutation engine.
|
||||
*
|
||||
* Eight operations over a UI tree, and **not one of them branches on a node
|
||||
* type**. Every decision an operation makes — may this move, may that hold a
|
||||
* child, is this column count legal, may this person do it at all — is read
|
||||
* from the type's registration or from the closed data vocabulary. That is the
|
||||
* property that makes a new UI type one `registerNodeType` call instead of an
|
||||
* edit here, and it is worth checking on any change to this file: a `card`,
|
||||
* `chart`, `table`, `positions` or `control-center` appearing below is a bug in
|
||||
* the design, not a special case.
|
||||
*
|
||||
* Every operation is pure — `(tree, op) → { ok, tree, problems, diff }` — and
|
||||
* returns a **new** tree, sharing everything it did not touch. Purity is what
|
||||
* lets preview and apply run the identical code path: a preview is the result
|
||||
* held in memory, an apply is the same result persisted. There is no second
|
||||
* implementation for either, so they cannot disagree.
|
||||
*
|
||||
* An operation that fails returns the tree it was given, unchanged, with the
|
||||
* problems that stopped it. Nothing is half-applied.
|
||||
*/
|
||||
|
||||
import { dataSourceFor, sourceSupportsShape } from '@/lib/skills/surfaces';
|
||||
import {
|
||||
cloneNode, findNode, locate, makeNode, mapChildren, mapNode,
|
||||
} from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
import { refuseSeries } from './series';
|
||||
import { takenIds, validateTree } from './validate';
|
||||
|
||||
/** The operation names the engine understands. Anything else is refused by name. */
|
||||
export const OPERATIONS = [
|
||||
'add', 'update', 'remove', 'move', 'replace', 'hide', 'reorder',
|
||||
];
|
||||
|
||||
const fail = (tree, ...problems) => ({
|
||||
ok: false,
|
||||
tree,
|
||||
problems: problems.flat().map((p) => (typeof p === 'string' ? { at: null, message: p } : p)),
|
||||
diff: null,
|
||||
});
|
||||
|
||||
const done = (tree, diff) => ({ ok: true, tree, problems: [], diff });
|
||||
|
||||
/**
|
||||
* Apply one operation.
|
||||
*
|
||||
* The candidate tree is built first and validated whole, rather than each
|
||||
* operation checking its own effects. A structural change can invalidate a node
|
||||
* it did not touch — moving a chart into a container that does not accept it,
|
||||
* or leaving a container below its minimum — and only a whole-tree check sees
|
||||
* that.
|
||||
*/
|
||||
export function applyOperation(tree, op, { registry = nodeRegistry, role = null } = {}) {
|
||||
const nodes = Array.isArray(tree) ? tree : [];
|
||||
const name = String(op?.op ?? '').trim();
|
||||
|
||||
if (!OPERATIONS.includes(name)) {
|
||||
return fail(nodes, `Unknown operation: ${name || '(none)'}. Known: ${OPERATIONS.join(', ')}.`);
|
||||
}
|
||||
|
||||
const context = { registry, role };
|
||||
const built = BUILDERS[name](nodes, op, context);
|
||||
if (!built.ok) return built;
|
||||
|
||||
/* One gate, after the change is composed and before it is anybody's. */
|
||||
const { ok, problems } = validateTree(built.tree, context);
|
||||
if (!ok) return fail(nodes, problems);
|
||||
|
||||
return done(built.tree, built.diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a list, stopping at the first failure.
|
||||
*
|
||||
* All-or-nothing: a patch that applied its first three operations and refused
|
||||
* the fourth would leave a page in a state the user never asked for and cannot
|
||||
* name. The caller gets the original tree back and the problems from the one
|
||||
* that stopped it.
|
||||
*/
|
||||
export function applyOperations(tree, ops, context = {}) {
|
||||
const nodes = Array.isArray(tree) ? tree : [];
|
||||
let current = nodes;
|
||||
const diffs = [];
|
||||
|
||||
for (const [index, op] of (ops || []).entries()) {
|
||||
const result = applyOperation(current, op, context);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
tree: nodes,
|
||||
problems: result.problems.map((p) => ({ ...p, opIndex: index })),
|
||||
diff: null,
|
||||
};
|
||||
}
|
||||
current = result.tree;
|
||||
diffs.push(result.diff);
|
||||
}
|
||||
|
||||
return { ok: true, tree: current, problems: [], diff: diffs };
|
||||
}
|
||||
|
||||
/* ── Shared checks ──────────────────────────────────────────────────────────
|
||||
Written once because an operation that resolved its own target would be the
|
||||
place a rule silently differs. */
|
||||
|
||||
/** The node an operation names, or a refusal that says which id was not found. */
|
||||
function target(nodes, id) {
|
||||
const found = findNode(nodes, id);
|
||||
if (!found) return { problem: `No UI node with the id \`${id ?? '(none)'}\`.` };
|
||||
return { node: found };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the type behind this node permits this capability.
|
||||
*
|
||||
* Two refusals, deliberately different: a node the registry does not know is an
|
||||
* unknown component, while a node whose type declines the capability is a
|
||||
* component that exists and will not do this. Collapsing them would tell a user
|
||||
* their chart does not exist.
|
||||
*/
|
||||
function permits(registry, node, capability) {
|
||||
const entry = registry.get(node.type);
|
||||
if (!entry) return `Unsupported UI type: ${node.type}.`;
|
||||
if (node.locked) return `\`${entry.label}\` is fixed here and cannot be changed.`;
|
||||
if (!entry.capabilities.includes(capability)) {
|
||||
return `\`${entry.label}\` cannot be ${PARTICIPLE[capability]}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const PARTICIPLE = {
|
||||
add: 'added to', update: 'changed', remove: 'removed', move: 'moved',
|
||||
replace: 'replaced', hide: 'hidden', reorder: 'reordered',
|
||||
};
|
||||
|
||||
/** The container an operation places into: the root, or a node that accepts children. */
|
||||
function container(nodes, parentId, registry) {
|
||||
if (parentId == null || parentId === '') return { parent: null, children: nodes };
|
||||
const found = findNode(nodes, parentId);
|
||||
if (!found) return { problem: `No UI node with the id \`${parentId}\`.` };
|
||||
const entry = registry.get(found.type);
|
||||
if (!entry?.container) return { problem: `\`${entry?.label || found.type}\` cannot hold other nodes.` };
|
||||
return { parent: found, children: found.children || [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a node's own origin says it may be deleted.
|
||||
*
|
||||
* Removing something the application ships is not removal, it is hiding: the
|
||||
* built-in comes back with the next release, and a patch that claimed to have
|
||||
* deleted it would silently stop matching. A node a person added is genuinely
|
||||
* theirs to remove.
|
||||
*
|
||||
* Origin, not type — which is what keeps this rule generic.
|
||||
*/
|
||||
const isRemovable = (node) => node.origin === 'user';
|
||||
|
||||
/** A record reduced to the keys a type declared. Anything else is left behind. */
|
||||
const keep = (source, allowed) => Object.fromEntries(
|
||||
Object.entries(source || {}).filter(([key]) => allowed.includes(key))
|
||||
);
|
||||
|
||||
/* ── The operations ─────────────────────────────────────────────────────── */
|
||||
|
||||
const BUILDERS = {
|
||||
/** Put a new node into a container, at an index or at the end. */
|
||||
add(nodes, op, { registry }) {
|
||||
const spot = container(nodes, op.parent, registry);
|
||||
if (spot.problem) return fail(nodes, spot.problem);
|
||||
|
||||
if (spot.parent) {
|
||||
const refusal = permits(registry, spot.parent, 'add');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
}
|
||||
|
||||
const node = makeNode({ ...op.node, origin: op.node?.origin || 'user' });
|
||||
|
||||
if (!node.id) return fail(nodes, 'A new node needs an `id`.');
|
||||
if (takenIds(nodes).has(node.id)) {
|
||||
return fail(nodes, `A node with the id \`${node.id}\` already exists.`);
|
||||
}
|
||||
if (!registry.has(node.type)) {
|
||||
return fail(
|
||||
nodes,
|
||||
`Unsupported UI type: ${node.type || '(none)'}. Supported types: ${registry.list().join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
const at = index(op.index, spot.children.length);
|
||||
const next = mapChildren(nodes, spot.parent?.id ?? null, (list) => [
|
||||
...list.slice(0, at), node, ...list.slice(at),
|
||||
]);
|
||||
|
||||
return done(next, {
|
||||
op: 'add', target: node.id, parent: spot.parent?.id ?? null, index: at,
|
||||
summary: `Add ${registry.get(node.type).label}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Change a node's configuration in place.
|
||||
*
|
||||
* Props and layout merge rather than replace, so "make it two columns" does
|
||||
* not clear a title nobody mentioned. A key set to `null` is removed, which is
|
||||
* how a value gets *unset* — otherwise there would be no way to say "no
|
||||
* limit" that was distinguishable from not mentioning it.
|
||||
*
|
||||
* `data` is different: it replaces, because a binding is one decision and a
|
||||
* half-merged one would name a source with another source's options.
|
||||
*/
|
||||
update(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'update');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
const before = found.node;
|
||||
const next = mapNode(nodes, before.id, (node) => makeNode({
|
||||
...node,
|
||||
props: 'props' in op ? merge(node.props, op.props) : node.props,
|
||||
layout: 'layout' in op ? merge(node.layout, op.layout) : node.layout,
|
||||
/* Merged, not replaced, for the same reason layout is: setting a density
|
||||
must not clear a variant nobody mentioned. `null` still unsets. */
|
||||
presentation: 'presentation' in op ? merge(node.presentation, op.presentation) : node.presentation,
|
||||
data: 'data' in op ? op.data : node.data,
|
||||
}));
|
||||
|
||||
return done(next, {
|
||||
op: 'update', target: before.id,
|
||||
changed: [
|
||||
...('props' in op ? Object.keys(op.props || {}) : []),
|
||||
...('layout' in op ? Object.keys(op.layout || {}) : []),
|
||||
...('presentation' in op ? Object.keys(op.presentation || {}) : []),
|
||||
...('data' in op ? ['data'] : []),
|
||||
],
|
||||
summary: `Change ${registry.get(before.type)?.label || before.type}`,
|
||||
});
|
||||
},
|
||||
|
||||
/** Take a node out of the tree. Only where its origin allows it. */
|
||||
remove(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'remove');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
if (!isRemovable(found.node)) {
|
||||
return fail(
|
||||
nodes,
|
||||
`\`${registry.get(found.node.type)?.label || found.node.type}\` is part of the page and `
|
||||
+ 'cannot be deleted. Hide it instead.'
|
||||
);
|
||||
}
|
||||
|
||||
return done(mapNode(nodes, found.node.id, () => null), {
|
||||
op: 'remove', target: found.node.id,
|
||||
summary: `Remove ${registry.get(found.node.type)?.label || found.node.type}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Move a node to another container, or to another position in its own.
|
||||
*
|
||||
* Detach then insert, computing the index against the list *after* removal so
|
||||
* that moving a node down within its own parent lands where a reader expects.
|
||||
* Getting this wrong is the classic off-by-one that makes "move it to the
|
||||
* end" stop one short.
|
||||
*/
|
||||
move(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'move');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
const parentId = op.parent === undefined ? locate(nodes, found.node.id)?.parent?.id ?? null : op.parent;
|
||||
|
||||
/* A container cannot be moved inside itself; the tree would stop being one. */
|
||||
if (parentId && findNode([found.node], parentId)) {
|
||||
return fail(nodes, 'A node cannot be moved inside itself.');
|
||||
}
|
||||
|
||||
const spot = container(nodes, parentId, registry);
|
||||
if (spot.problem) return fail(nodes, spot.problem);
|
||||
if (spot.parent) {
|
||||
const parentRefusal = permits(registry, spot.parent, 'add');
|
||||
if (parentRefusal) return fail(nodes, parentRefusal);
|
||||
}
|
||||
|
||||
const moved = cloneNode(found.node);
|
||||
const detached = mapNode(nodes, found.node.id, () => null);
|
||||
|
||||
const siblings = parentId == null
|
||||
? detached
|
||||
: findNode(detached, parentId)?.children || [];
|
||||
const at = index(op.index, siblings.length);
|
||||
|
||||
const next = mapChildren(detached, parentId, (list) => [
|
||||
...list.slice(0, at), moved, ...list.slice(at),
|
||||
]);
|
||||
|
||||
return done(next, {
|
||||
op: 'move', target: moved.id, parent: parentId, index: at,
|
||||
summary: `Move ${registry.get(moved.type)?.label || moved.type}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Turn a node into another type, in place.
|
||||
*
|
||||
* The id, position and binding are kept — which is what makes "show this as a
|
||||
* table" mean *this* reading as a table, rather than a new empty table where
|
||||
* a chart used to be. Props are dropped unless the operation supplies new
|
||||
* ones, because props belong to a type and carrying a chart's variant onto a
|
||||
* table is how an invalid prop arrives without anyone writing one.
|
||||
*/
|
||||
replace(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'replace');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
const type = String(op.type ?? '').trim();
|
||||
const entry = registry.get(type);
|
||||
if (!entry) {
|
||||
return fail(
|
||||
nodes,
|
||||
`Unsupported UI type: ${type || '(none)'}. Supported types: ${registry.list().join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
const before = found.node;
|
||||
if (before.children?.length && !entry.container) {
|
||||
return fail(nodes, `\`${entry.label}\` cannot hold the nodes already inside \`${before.id}\`.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can the new type draw what this node is already reading?
|
||||
*
|
||||
* Asked here, and refused by name, rather than left to the tree validator.
|
||||
* Both would stop it, but only one can say *why*: "a Table cannot draw
|
||||
* Candidate activity" is a sentence a person can act on, and "this node's
|
||||
* binding is incoherent" is not. The binding itself is never touched — a
|
||||
* replacement that cannot read what the node reads is refused, never
|
||||
* repointed at a source that happens to fit.
|
||||
*/
|
||||
const binding = 'data' in op ? op.data : before.data;
|
||||
if (binding?.source && entry.dataShapes.length) {
|
||||
const drawable = entry.dataShapes.some((shape) => sourceSupportsShape(binding.source, shape));
|
||||
if (!drawable) {
|
||||
const label = dataSourceFor(binding.source)?.label || binding.source;
|
||||
return fail(nodes, `\`${entry.label}\` cannot show ${label}.`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* A page's own series is compatible with the types that draw one.
|
||||
*
|
||||
* The structural question for this kind of binding is not "which shapes"
|
||||
* — a page series is not in the shape vocabulary and should not be — it is
|
||||
* whether the candidate draws a series at all, which is what a non-empty
|
||||
* `seriesKinds` says.
|
||||
*/
|
||||
if (binding?.series && !entry.seriesKinds.length) {
|
||||
return fail(nodes, `\`${entry.label}\` cannot draw a series.`);
|
||||
}
|
||||
/**
|
||||
* And the semantic question, for either kind of binding.
|
||||
*
|
||||
* Refused by what the reading *is*, not by what the component is called, so
|
||||
* the sentence a person gets back tells them why rather than merely that.
|
||||
* Silence — an unknown kind — is not a refusal.
|
||||
*/
|
||||
const untrue = binding ? refuseSeries(entry, binding) : null;
|
||||
if (untrue && entry.seriesKinds.length) return fail(nodes, untrue);
|
||||
|
||||
if (!binding?.source && !binding?.series && entry.dataRequired) {
|
||||
return fail(nodes, `\`${entry.label}\` needs a reading, and \`${before.id}\` has none.`);
|
||||
}
|
||||
|
||||
const next = mapNode(nodes, before.id, (node) => makeNode({
|
||||
...node,
|
||||
type,
|
||||
/**
|
||||
* What survives is what the new type has said it understands.
|
||||
*
|
||||
* Props were dropped wholesale, which is safe and also loses a title the
|
||||
* person typed even when the new type has the very same field. Carried
|
||||
* per key against the new type's own `propSchema` instead: a property both
|
||||
* types declare is theirs to keep, one that belonged to the old type
|
||||
* alone goes. Presentation is filtered the same way, so replacing into a
|
||||
* type that cannot draw `emphasis` quietly drops it rather than failing
|
||||
* the whole operation over a value nobody asked to keep.
|
||||
*
|
||||
* Id, layout, visibility, origin and children pass through untouched —
|
||||
* the node is the same node, drawn differently.
|
||||
*/
|
||||
props: keep('props' in op ? merge({}, op.props) : node.props, Object.keys(entry.propSchema)),
|
||||
presentation: {
|
||||
...(entry.variants.includes(node.presentation?.variant) ? { variant: node.presentation.variant } : {}),
|
||||
...(entry.densities.includes(node.presentation?.density) ? { density: node.presentation.density } : {}),
|
||||
},
|
||||
data: binding,
|
||||
}));
|
||||
|
||||
return done(next, {
|
||||
op: 'replace', target: before.id, from: before.type, to: type,
|
||||
summary: `Change ${registry.get(before.type)?.label || before.type} to ${entry.label}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide or show a node.
|
||||
*
|
||||
* Its own operation rather than an `update` of a flag, because it is its own
|
||||
* capability: a component may reasonably be hideable and not otherwise
|
||||
* editable, and a page's header is the opposite. Idempotent — hiding what is
|
||||
* already hidden is not an error, it is the state the user asked for.
|
||||
*/
|
||||
hide(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'hide');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
const hidden = op.hidden !== false;
|
||||
const next = mapNode(nodes, found.node.id, (node) => ({ ...node, hidden }));
|
||||
|
||||
return done(next, {
|
||||
op: 'hide', target: found.node.id, hidden,
|
||||
summary: `${hidden ? 'Hide' : 'Show'} ${registry.get(found.node.type)?.label || found.node.type}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Rearrange one container's children.
|
||||
*
|
||||
* Takes the ids in their new order. A partial list is honoured — the named
|
||||
* nodes take the order given, and anything unnamed keeps its relative
|
||||
* position after them — so "put the funnel first" does not require restating
|
||||
* the whole page.
|
||||
*/
|
||||
reorder(nodes, op, { registry }) {
|
||||
const spot = container(nodes, op.parent, registry);
|
||||
if (spot.problem) return fail(nodes, spot.problem);
|
||||
|
||||
if (spot.parent) {
|
||||
const refusal = permits(registry, spot.parent, 'reorder');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
}
|
||||
|
||||
const order = Array.isArray(op.order) ? op.order.map((id) => String(id).trim()) : null;
|
||||
if (!order?.length) return fail(nodes, '`reorder` needs an `order` of node ids.');
|
||||
|
||||
const present = new Set(spot.children.map((node) => node.id));
|
||||
const stranger = order.find((id) => !present.has(id));
|
||||
if (stranger) {
|
||||
return fail(nodes, `\`${stranger}\` is not inside \`${op.parent ?? 'the page'}\`.`);
|
||||
}
|
||||
if (new Set(order).size !== order.length) {
|
||||
return fail(nodes, '`order` names the same node twice.');
|
||||
}
|
||||
|
||||
const next = mapChildren(nodes, spot.parent?.id ?? null, (list) => {
|
||||
const named = order.map((id) => list.find((node) => node.id === id));
|
||||
const rest = list.filter((node) => !order.includes(node.id));
|
||||
return [...named, ...rest];
|
||||
});
|
||||
|
||||
return done(next, {
|
||||
op: 'reorder', parent: spot.parent?.id ?? null, order,
|
||||
summary: `Reorder ${spot.parent ? registry.get(spot.parent.type)?.label || spot.parent.type : 'the page'}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** An insertion point, clamped. Absent means the end, which is what "add" means. */
|
||||
function index(value, length) {
|
||||
if (value == null) return length;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return length;
|
||||
return Math.max(0, Math.min(length, Math.round(n)));
|
||||
}
|
||||
|
||||
/** Shallow merge where an explicit `null` deletes the key. */
|
||||
function merge(base, patch) {
|
||||
const next = { ...(base || {}) };
|
||||
for (const [key, value] of Object.entries(patch || {})) {
|
||||
if (value === null) delete next[key];
|
||||
else next[key] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
203
src/lib/ui/patch.js
Normal file
203
src/lib/ui/patch.js
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* A user's UI changes, as an ordered list of operations.
|
||||
*
|
||||
* **Operations are stored; trees are not.** That is the decision this module
|
||||
* exists to hold, and it is what makes a saved layout survive the application
|
||||
* changing underneath it. A stored tree is a photograph: the day a release adds
|
||||
* a section to a page, every saved photograph is missing it, and the user's
|
||||
* page silently stops receiving product improvements. A stored operation is an
|
||||
* instruction — "hide `cc-activity`", "put `funnel` first" — which still means
|
||||
* what it said after the built-ins around it move.
|
||||
*
|
||||
* It also gives undo and rollback for free. Applying is `push`, undoing is
|
||||
* `pop`, and resetting is the empty list. There is no inverse-operation
|
||||
* machinery to get wrong, because the base tree is recomputed rather than
|
||||
* mutated.
|
||||
*
|
||||
* The stored shape is **normalized structured configuration** — never JSX,
|
||||
* never a component, never Markdown. What is written here is what
|
||||
* `operations.js` already validated.
|
||||
*/
|
||||
|
||||
import { applyOperations, OPERATIONS } from './operations';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/**
|
||||
* The stored format's version.
|
||||
*
|
||||
* Read on load and written on save, so a future change to the operation
|
||||
* vocabulary can migrate rather than misread. A patch whose version this build
|
||||
* does not know is dropped with a reason rather than half-applied — a partly
|
||||
* understood layout is worse than the default one.
|
||||
*/
|
||||
export const PATCH_SCHEMA = 1;
|
||||
|
||||
/** An empty patch for a page. The shape a page gets when nobody has changed it. */
|
||||
export const emptyPatch = (page) => ({
|
||||
schema: PATCH_SCHEMA,
|
||||
page: String(page ?? '').trim(),
|
||||
ops: [],
|
||||
updatedAt: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Only the fields an operation is allowed to carry, per operation.
|
||||
*
|
||||
* A whitelist rather than a pass-through, because this is the boundary where
|
||||
* stored data becomes engine input. Anything else a client wrote — or anything
|
||||
* that arrived in `user_preferences.extra` from an older build or another tab —
|
||||
* is dropped before it reaches the engine.
|
||||
*/
|
||||
const OP_FIELDS = {
|
||||
add: ['parent', 'index', 'node'],
|
||||
update: ['target', 'props', 'layout', 'presentation', 'data'],
|
||||
remove: ['target'],
|
||||
move: ['target', 'parent', 'index'],
|
||||
replace: ['target', 'type', 'props', 'data'],
|
||||
hide: ['target', 'hidden'],
|
||||
reorder: ['parent', 'order'],
|
||||
};
|
||||
|
||||
/**
|
||||
* One stored operation, reduced to what the engine reads.
|
||||
*
|
||||
* Returns null for anything unrecognised. The caller reports how many were
|
||||
* dropped rather than failing the whole patch: one unreadable operation from a
|
||||
* newer build should not cost a user the other nine they made.
|
||||
*/
|
||||
export function normalizeOp(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const op = String(raw.op ?? '').trim();
|
||||
if (!OPERATIONS.includes(op)) return null;
|
||||
|
||||
const next = { op };
|
||||
for (const field of OP_FIELDS[op]) {
|
||||
if (raw[field] === undefined) continue;
|
||||
next[field] = raw[field];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** A stored patch, checked and reduced. `dropped` says what was not understood. */
|
||||
export function normalizePatch(raw, page) {
|
||||
const base = emptyPatch(page);
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { patch: base, dropped: 0 };
|
||||
|
||||
if (Number(raw.schema) !== PATCH_SCHEMA) {
|
||||
/* Unknown version: keep nothing rather than guess. The page renders as the
|
||||
application ships it, which is a defensible state; a half-read layout is
|
||||
not. */
|
||||
return { patch: base, dropped: Array.isArray(raw.ops) ? raw.ops.length : 0 };
|
||||
}
|
||||
|
||||
const ops = [];
|
||||
let dropped = 0;
|
||||
for (const candidate of Array.isArray(raw.ops) ? raw.ops : []) {
|
||||
const op = normalizeOp(candidate);
|
||||
if (op) ops.push(op);
|
||||
else dropped += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
patch: {
|
||||
schema: PATCH_SCHEMA,
|
||||
page: base.page,
|
||||
ops,
|
||||
updatedAt: raw.updatedAt || null,
|
||||
},
|
||||
dropped,
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole store: `{ [page]: patch }`, as it sits in preferences. */
|
||||
export function normalizeLayouts(raw) {
|
||||
const layouts = {};
|
||||
let dropped = 0;
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { layouts, dropped };
|
||||
|
||||
for (const [page, value] of Object.entries(raw)) {
|
||||
const key = String(page ?? '').trim();
|
||||
if (!key) continue;
|
||||
const result = normalizePatch(value, key);
|
||||
dropped += result.dropped;
|
||||
/* An empty patch is not stored. A key mapping to no operations is the same
|
||||
state as no key, and keeping it would grow the preferences blob with a
|
||||
record of pages a user once looked at. */
|
||||
if (result.patch.ops.length) layouts[key] = result.patch;
|
||||
}
|
||||
|
||||
return { layouts, dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an operation to a patch.
|
||||
*
|
||||
* Appends rather than folding into what is there. Two updates to the same node
|
||||
* stay two operations: replaying them costs nothing, and collapsing them would
|
||||
* mean undo skipped a step the user remembers making.
|
||||
*/
|
||||
export const pushOp = (patch, op) => ({
|
||||
...patch,
|
||||
ops: [...patch.ops, op],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
/** Undo: drop the last operation. The tree is recomputed, never reversed. */
|
||||
export const popOp = (patch) => ({
|
||||
...patch,
|
||||
ops: patch.ops.slice(0, -1),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
/** Reset: back to what the application ships. */
|
||||
export const clearOps = (patch) => ({ ...patch, ops: [], updatedAt: new Date().toISOString() });
|
||||
|
||||
/**
|
||||
* The whole layout store with one page's patch set, cleared, or replaced.
|
||||
*
|
||||
* Pure, and separate from the hook that calls it, because this is where a
|
||||
* mistake would be expensive and invisible: the preferences endpoint
|
||||
* shallow-merges its top-level keys, so writing `uiLayouts` replaces the entire
|
||||
* map. Sending one page's entry would silently delete every other page the
|
||||
* person had customised, and they would only find out by visiting one.
|
||||
*
|
||||
* A patch with no operations removes its key rather than storing an empty one:
|
||||
* that is the same state as never having customised the page.
|
||||
*/
|
||||
export function mergeLayouts(layouts, page, patch) {
|
||||
const key = String(page ?? '').trim();
|
||||
const next = { ...(layouts || {}) };
|
||||
if (!key) return next;
|
||||
if (patch?.ops?.length) next[key] = { ...patch, schema: PATCH_SCHEMA, page: key };
|
||||
else delete next[key];
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree a patch produces from a base.
|
||||
*
|
||||
* **Operations that no longer apply are skipped, not fatal.** A release that
|
||||
* removes a section leaves any patch that mentioned it naming a node that is
|
||||
* not there — which is not the user's mistake and must not cost them the rest
|
||||
* of their layout. `skipped` reports them so a surface can say so quietly.
|
||||
*
|
||||
* This is the one place tolerance is right. Everywhere else — composing a new
|
||||
* change, saving one — a refusal is correct, because there is a person present
|
||||
* to be told.
|
||||
*/
|
||||
export function applyPatch(base, patch, { registry = nodeRegistry, role = null } = {}) {
|
||||
const ops = patch?.ops || [];
|
||||
let tree = base;
|
||||
const skipped = [];
|
||||
|
||||
for (const [index, op] of ops.entries()) {
|
||||
const result = applyOperations(tree, [op], { registry, role });
|
||||
if (result.ok) {
|
||||
tree = result.tree;
|
||||
continue;
|
||||
}
|
||||
skipped.push({ index, op, problems: result.problems });
|
||||
}
|
||||
|
||||
return { tree, skipped };
|
||||
}
|
||||
442
src/lib/ui/registry.js
Normal file
442
src/lib/ui/registry.js
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* The node type registry — the only extension point in the UI system.
|
||||
*
|
||||
* A type is a name, a component the application already ships, and the metadata
|
||||
* that says what may be done to it. Adding a UI type is one `register` call:
|
||||
* the mutation engine, the validator, the renderer and the agent's conversation
|
||||
* are untouched, because none of them contains a branch on a type. They ask
|
||||
* this table instead.
|
||||
*
|
||||
* That is the whole reason this file exists. The alternative — an engine that
|
||||
* knows `card` from `chart` — puts every future component into the engine, and
|
||||
* the engine then has to be edited to add a UI type, which is the thing the
|
||||
* design is meant to prevent.
|
||||
*
|
||||
* **The registry never receives a component name as a string.** A registration
|
||||
* hands over a component *reference*, resolved by the module graph at build
|
||||
* time. There is no dynamic import here and no lookup from configuration to
|
||||
* code — configuration only ever names a key that is already in this table.
|
||||
*/
|
||||
|
||||
import {
|
||||
DENSITY_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_CAPABILITIES, NODE_ID_PATTERN, SERIES_KINDS,
|
||||
VARIANT_VALUES,
|
||||
} from './node';
|
||||
|
||||
/**
|
||||
* A registration, with every field settled.
|
||||
*
|
||||
* Defaults are deliberately conservative: a type that says nothing about its
|
||||
* capabilities gets the read-only set, because a component whose author has not
|
||||
* thought about being moved is one that should not be moved yet. Opting in is
|
||||
* one word; opting out after a bug is a migration.
|
||||
*/
|
||||
const DEFAULT_CAPABILITIES = ['update', 'remove', 'move', 'replace', 'hide'];
|
||||
|
||||
/** Containers can also be added to and reordered — that is what makes them containers. */
|
||||
const CONTAINER_CAPABILITIES = [...DEFAULT_CAPABILITIES, 'add', 'reorder'];
|
||||
|
||||
export class NodeTypeRegistry {
|
||||
constructor() {
|
||||
/** @type {Map<string, any>} */
|
||||
this.types = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare a node type.
|
||||
*
|
||||
* Refuses at registration rather than at render. A registry that accepted a
|
||||
* malformed type would fail later, inside a component, with a stack that
|
||||
* names React rather than the registration that caused it — and by then the
|
||||
* validator has already told a user their change was fine.
|
||||
*/
|
||||
register(definition) {
|
||||
const type = String(definition?.type ?? '').trim();
|
||||
if (!type) throw new Error('registerNodeType: a type needs a `type`.');
|
||||
if (!NODE_ID_PATTERN.test(type)) {
|
||||
throw new Error(`registerNodeType: \`${type}\` must be lower-case letters, numbers and dashes.`);
|
||||
}
|
||||
if (this.types.has(type)) {
|
||||
throw new Error(`registerNodeType: \`${type}\` is already registered.`);
|
||||
}
|
||||
if (typeof definition.component !== 'function' && typeof definition.component !== 'object') {
|
||||
throw new Error(`registerNodeType: \`${type}\` needs a \`component\`.`);
|
||||
}
|
||||
|
||||
const container = definition.container === true;
|
||||
const declared = Array.isArray(definition.capabilities) ? definition.capabilities : null;
|
||||
const capabilities = declared || (container ? CONTAINER_CAPABILITIES : DEFAULT_CAPABILITIES);
|
||||
|
||||
const unknown = capabilities.find((c) => !NODE_CAPABILITIES.includes(c));
|
||||
if (unknown) {
|
||||
throw new Error(
|
||||
`registerNodeType: \`${type}\` declares unknown capability \`${unknown}\`. `
|
||||
+ `Known: ${NODE_CAPABILITIES.join(', ')}.`
|
||||
);
|
||||
}
|
||||
/* A registration may choose from the vocabulary; it may not invent one.
|
||||
Caught at boot, where the author can see it, rather than at validation
|
||||
where it would look like the *user's* value was wrong. */
|
||||
const refuseUnknown = (word, declared, allowed) => {
|
||||
const bad = (declared || []).find((value) => !allowed.includes(value));
|
||||
if (bad) {
|
||||
throw new Error(
|
||||
`registerNodeType: \`${type}\` declares unknown ${word} \`${bad}\`. `
|
||||
+ `Known: ${allowed.join(', ')}.`
|
||||
);
|
||||
}
|
||||
};
|
||||
refuseUnknown('variant', definition.variants, VARIANT_VALUES);
|
||||
refuseUnknown('density', definition.densities, DENSITY_VALUES);
|
||||
refuseUnknown('series kind', definition.seriesKinds, SERIES_KINDS);
|
||||
|
||||
/* A type offered by a picker and unable to be removed is a dead end: a
|
||||
person creates a node and has no way to take it back. Said here rather
|
||||
than discovered in the editor. */
|
||||
if (definition.addable === true && !capabilities.includes('remove')) {
|
||||
throw new Error(
|
||||
`registerNodeType: \`${type}\` is declared addable but cannot be removed. `
|
||||
+ 'Anything a person can add, they must be able to remove.'
|
||||
);
|
||||
}
|
||||
|
||||
/* A non-container claiming `add` or `reorder` is a registration that will
|
||||
never do what its author expects: there is nowhere to put a child. */
|
||||
if (!container) {
|
||||
const childOnly = capabilities.find((c) => c === 'add' || c === 'reorder');
|
||||
if (childOnly) {
|
||||
throw new Error(
|
||||
`registerNodeType: \`${type}\` declares \`${childOnly}\` but is not a container.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const entry = Object.freeze({
|
||||
type,
|
||||
label: String(definition.label ?? type).trim(),
|
||||
summary: String(definition.summary ?? '').trim(),
|
||||
component: definition.component,
|
||||
container,
|
||||
capabilities: Object.freeze([...capabilities]),
|
||||
/* `['*']` accepts anything, which is what a page-level container wants.
|
||||
An empty list on a container accepts nothing, which is a leaf that
|
||||
merely renders its own children — legitimate, and worth being able to
|
||||
say. */
|
||||
accepts: Object.freeze([...(definition.accepts || (container ? ['*'] : []))]),
|
||||
propSchema: Object.freeze({ ...(definition.propSchema || {}) }),
|
||||
/* Which readings this type can draw. Empty means the type takes no data
|
||||
at all — a layout container, a divider — and validation then refuses a
|
||||
binding rather than resolving one nothing will read. */
|
||||
dataShapes: Object.freeze([...(definition.dataShapes || [])]),
|
||||
dataRequired: definition.dataRequired === true,
|
||||
constraints: Object.freeze({
|
||||
minColumns: MIN_COLUMNS,
|
||||
maxColumns: MAX_COLUMNS,
|
||||
...(definition.constraints || {}),
|
||||
}),
|
||||
/**
|
||||
* Whether the renderer supplies identity through a wrapper element.
|
||||
*
|
||||
* Most components render a root they control and can spread the node's
|
||||
* attributes onto it. Some — the shared design-system primitives, which
|
||||
* destructure their props explicitly — cannot, and for those the renderer
|
||||
* puts a bare `div` around the component so the node is still addressable
|
||||
* in the DOM.
|
||||
*
|
||||
* Declared per type rather than detected, because there is no way to ask
|
||||
* a React component whether it forwards unknown props, and guessing wrong
|
||||
* either loses the identity or adds an element nobody asked for.
|
||||
*/
|
||||
wrap: definition.wrap === true,
|
||||
/* `null` means every role. A list narrows it, and is checked against the
|
||||
caller's role at validation — the same three roles the API enforces. */
|
||||
roles: definition.roles ? Object.freeze([...definition.roles]) : null,
|
||||
|
||||
/**
|
||||
* The presentation values this type can actually draw.
|
||||
*
|
||||
* Empty means the type supports none, and that is the default on purpose:
|
||||
* a presentation value is a promise that the component renders something
|
||||
* different, and a type that has not made that promise must refuse it
|
||||
* rather than store a setting nobody honours. Opting in is one list; the
|
||||
* cost of the other default would be a person setting "compact" on a
|
||||
* section that stays exactly as it was.
|
||||
*
|
||||
* Each value is also checked against the closed vocabulary in `node.js`,
|
||||
* so a registration cannot widen what the product can say — only choose
|
||||
* from it.
|
||||
*/
|
||||
variants: Object.freeze([...(definition.variants || [])]),
|
||||
densities: Object.freeze([...(definition.densities || [])]),
|
||||
|
||||
/**
|
||||
* The series *meanings* this type can honestly draw.
|
||||
*
|
||||
* Empty means two things at once, and both are the conservative reading:
|
||||
* this type is not a visualization of a series, and it makes no claim
|
||||
* about meaning. So a section registered before this field existed is
|
||||
* unaffected — it is neither offered as a chart nor vetoed as one.
|
||||
*
|
||||
* A non-empty list is a promise. `pie-chart` says `parts` and nothing
|
||||
* else, and that single word is what refuses to draw twelve days of
|
||||
* hiring as twelve slices of a whole. The alternative — letting the
|
||||
* shape vocabulary decide — cannot express it: `flow` is, in its own
|
||||
* words, "a sequence of stages *or* periods".
|
||||
*/
|
||||
seriesKinds: Object.freeze([...(definition.seriesKinds || [])]),
|
||||
|
||||
/**
|
||||
* The page this type belongs to, or `null` for one that belongs anywhere.
|
||||
*
|
||||
* The registry is global — one table, so a page can be composed before
|
||||
* anything about it is known here — but most types are not. A page's own
|
||||
* sections read that page's published render context: Hired History's
|
||||
* chronology destructures `hires` and `filtered`, and on any other page
|
||||
* those are simply absent. Offered there and added, it threw on
|
||||
* `undefined.length` and took the whole application down with it.
|
||||
*
|
||||
* So a registration says where it belongs, and the engine only ever
|
||||
* compares this string to the page being composed. It still knows no page
|
||||
* names — `null` here is "anywhere", which is what the nine reading types
|
||||
* and any future generic component declare by saying nothing.
|
||||
*/
|
||||
page: definition.page ? String(definition.page).trim() : null,
|
||||
|
||||
/**
|
||||
* What one *instance* of this type should be called.
|
||||
*
|
||||
* Optional. Most types are named well enough by their label — there is
|
||||
* one Hiring funnel on Analytics. Types a page mounts more than once are
|
||||
* not: every skill slot is a "Skill sections", and two of them in an
|
||||
* outline are two identical rows nobody can tell apart or name out loud.
|
||||
* The type answers for its own instances; the engine only calls it.
|
||||
*/
|
||||
describe: typeof definition.describe === 'function' ? definition.describe : null,
|
||||
|
||||
/**
|
||||
* Whether a person may add one of these.
|
||||
*
|
||||
* Two things have to be true, and the second is derived rather than
|
||||
* declared: **anything a person can add, they must be able to remove.**
|
||||
*
|
||||
* The editor offered a page's own sections, which declare `move` and
|
||||
* `hide` and not `remove` — so one could be added and then never deleted.
|
||||
* The placeholder for a failed node said it "can be hidden or removed"
|
||||
* and the Remove button was not there, because the inspector asks the
|
||||
* type while the engine asks the node's origin. Deriving it here settles
|
||||
* the disagreement in the one place both of them read.
|
||||
*
|
||||
* What is left addable is what a person actually adds: the readings. A
|
||||
* page's own sections are composed by the page and a skill surface is an
|
||||
* anchor the page owns — both are moved, hidden and reordered, never
|
||||
* conjured up by a picker.
|
||||
*/
|
||||
addable: definition.addable !== false && capabilities.includes('remove'),
|
||||
});
|
||||
|
||||
this.types.set(type, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The registration, or null. Every consumer goes through this. */
|
||||
get(type) {
|
||||
return this.types.get(String(type ?? '').trim()) || null;
|
||||
}
|
||||
|
||||
has(type) {
|
||||
return this.types.has(String(type ?? '').trim());
|
||||
}
|
||||
|
||||
/** Every registered type name, in registration order. */
|
||||
list() {
|
||||
return [...this.types.keys()];
|
||||
}
|
||||
|
||||
/** Every registration, for a picker that has to describe what it is offering. */
|
||||
all() {
|
||||
return [...this.types.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this node type do this?
|
||||
*
|
||||
* The single question every operation asks. A type that is absent can do
|
||||
* nothing — an unregistered type is not a permissive default, it is an
|
||||
* unknown component, and the engine refuses it.
|
||||
*/
|
||||
allows(type, capability) {
|
||||
const entry = this.get(type);
|
||||
return Boolean(entry && entry.capabilities.includes(capability));
|
||||
}
|
||||
|
||||
/**
|
||||
* May a node of `childType` sit inside `parentType`?
|
||||
*
|
||||
* A null parent is the page root, which accepts anything registered — the
|
||||
* page's own composition decides what is actually there, and refusing at the
|
||||
* root would mean the root needed a registration of its own.
|
||||
*/
|
||||
acceptsChild(parentType, childType) {
|
||||
if (!this.has(childType)) return false;
|
||||
if (parentType == null) return true;
|
||||
const parent = this.get(parentType);
|
||||
if (!parent || !parent.container) return false;
|
||||
return parent.accepts.includes('*') || parent.accepts.includes(childType);
|
||||
}
|
||||
|
||||
/**
|
||||
* May a person put one of these on this page, here?
|
||||
*
|
||||
* `acceptsChild` answers the structural half — does this container hold that
|
||||
* kind of thing. This answers the rest, and the rest is what was missing:
|
||||
* whether the type is one a person may add at all, and whether it belongs to
|
||||
* the page they are standing on.
|
||||
*
|
||||
* Both halves have to hold. Without the first, the picker offered the slot it
|
||||
* renders into; without the second, Candidates Analysis offered every private
|
||||
* section of every other page in the application — forty-three types on a
|
||||
* page that has seven — and adding one crashed the app.
|
||||
*/
|
||||
offersChild(parentType, childType, { page = null } = {}) {
|
||||
if (!this.acceptsChild(parentType, childType)) return false;
|
||||
const child = this.get(childType);
|
||||
if (!child.addable) return false;
|
||||
/* A type that names no page belongs anywhere. A page that is not named
|
||||
cannot vouch for anything page-bound, so it is offered only the generic
|
||||
types — which is the safe reading, not a permissive one. */
|
||||
return child.page === null || child.page === page;
|
||||
}
|
||||
|
||||
/**
|
||||
* The types a node could be turned into, given what it is bound to.
|
||||
*
|
||||
* Derived rather than declared, so "change this chart to a table" is answered
|
||||
* by the same compatibility rule that refuses an impossible section — a type
|
||||
* is a candidate when it can draw at least one shape the current binding
|
||||
* supports. A node with no binding may become any type that needs no data.
|
||||
*
|
||||
* `shapes` is passed in rather than looked up because the shape vocabulary
|
||||
* belongs to `lib/skills/surfaces.js`, and this module deliberately does not
|
||||
* import the product's data vocabulary: the registry is about components, and
|
||||
* coupling it to data sources would make a UI type impossible to register
|
||||
* without one.
|
||||
*/
|
||||
replacements(type, {
|
||||
shapes = null, page = null, seriesKind = null, seriesBound = false,
|
||||
/**
|
||||
* Whether this node reads anything at all.
|
||||
*
|
||||
* Separate from `seriesKind`, because "no binding" and "a binding that
|
||||
* never said what it means" are different situations with different right
|
||||
* answers. An unbound node has nothing to be untrue about; a bound one
|
||||
* whose meaning was never established is exactly the case the semantic
|
||||
* veto exists for.
|
||||
*/
|
||||
bound = false,
|
||||
} = {}) {
|
||||
const current = this.get(type);
|
||||
if (!current) return [];
|
||||
|
||||
/* What the candidate has to be able to draw: the binding's shapes when a
|
||||
binding was named, otherwise whatever this type itself draws. */
|
||||
const wanted = shapes || current.dataShapes;
|
||||
|
||||
return this.all()
|
||||
.filter((entry) => entry.type !== type)
|
||||
/**
|
||||
* The same scope that governs adding.
|
||||
*
|
||||
* Offering a replacement is offering to put that type on this page, so it
|
||||
* answers to the same two rules: a type bound to another page belongs to
|
||||
* that page, and a type nobody may add is not a thing to turn something
|
||||
* into. This was safe only by accident — every page-bound section happens
|
||||
* to declare no shapes, so the shape filter below excluded them — and an
|
||||
* accident is not a rule. A section that declared one would have appeared
|
||||
* in every page's "Show as" list.
|
||||
*/
|
||||
.filter((entry) => this.offersChild(null, entry.type, { page }))
|
||||
/**
|
||||
* The semantic veto, applied to every binding.
|
||||
*
|
||||
* A type that has said which meanings it can draw may only draw those.
|
||||
* This is the one filter that is about truth rather than about structure,
|
||||
* and it is why "show hiring activity as a pie chart" is refused while
|
||||
* "as a bar chart" is not — both are structurally possible and only one
|
||||
* of them is honest. A kind of `null` is "not known", and vetoes nothing.
|
||||
*/
|
||||
.filter((entry) => {
|
||||
/* A type that claims no meanings makes no claim to be wrong about. */
|
||||
if (!entry.seriesKinds.length) return true;
|
||||
/* Nothing bound yet: the binding decides this, and there is not one. */
|
||||
if (!bound) return true;
|
||||
/**
|
||||
* Bound, and only offerable if the meaning is known and drawable.
|
||||
*
|
||||
* The `!seriesKind` case is the one that changed: an undeclared
|
||||
* meaning used to fall through as "no objection", which is how a card
|
||||
* reading `Recent hires` came to be offered a pie chart. Not knowing
|
||||
* is not permission — see `refuseSeries`, which produces the sentence
|
||||
* a person is given when they ask for one of these by name.
|
||||
*/
|
||||
return Boolean(seriesKind) && entry.seriesKinds.includes(seriesKind);
|
||||
})
|
||||
.filter((entry) => (
|
||||
/**
|
||||
* The structural half, in the currency of whichever binding this is.
|
||||
*
|
||||
* A node bound to a page's own series is compatible with the types that
|
||||
* draw a series — which is what `seriesKinds` being non-empty means.
|
||||
* A node bound to a skill reading is compared shape to shape, as it has
|
||||
* always been. A type that reads no data can only be swapped for
|
||||
* another that reads none: a divider is not an alternative rendering of
|
||||
* a chart.
|
||||
*/
|
||||
seriesBound
|
||||
? entry.seriesKinds.length > 0
|
||||
: (wanted.length
|
||||
? entry.dataShapes.some((shape) => wanted.includes(shape))
|
||||
: entry.dataShapes.length === 0)
|
||||
))
|
||||
.map((entry) => entry.type);
|
||||
}
|
||||
|
||||
/** Forget everything. Tests only — a fresh registry per case beats shared state. */
|
||||
reset() {
|
||||
this.types.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry the application uses.
|
||||
*
|
||||
* A module singleton because the type table is the application's, not a
|
||||
* component's, and threading it through every call site would be ceremony. Every
|
||||
* function that reads it takes an override, so a test can register a throwaway
|
||||
* type without touching this one.
|
||||
*/
|
||||
export const nodeRegistry = new NodeTypeRegistry();
|
||||
|
||||
/**
|
||||
* Declare a node type on the application registry.
|
||||
*
|
||||
* Re-declaring **replaces**, which `register` itself refuses to do. The
|
||||
* difference is deliberate and the two are for different callers:
|
||||
*
|
||||
* - `nodeRegistry.register` is strict, because a registry that quietly
|
||||
* accepted two definitions of one type would render whichever module
|
||||
* happened to load second.
|
||||
* - This is what a module-scope registration wants. Types are declared as a
|
||||
* side effect of importing the module that owns them, and the dev server
|
||||
* re-runs that module every time the file is saved. Strictness there would
|
||||
* throw on every edit, and keeping the first registration instead would
|
||||
* leave the page drawing the component as it was before the edit — which is
|
||||
* worse, because it looks like the change did not work.
|
||||
*
|
||||
* The cost is that two modules claiming one type name silently agree on the
|
||||
* last one loaded. That is a real risk and the reason type names are prefixed
|
||||
* with the surface that owns them.
|
||||
*/
|
||||
export const registerNodeType = (definition) => {
|
||||
nodeRegistry.types.delete(String(definition?.type ?? '').trim());
|
||||
return nodeRegistry.register(definition);
|
||||
};
|
||||
300
src/lib/ui/series.js
Normal file
300
src/lib/ui/series.js
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* What a series is, what it means, and where a page publishes one.
|
||||
*
|
||||
* Two things live here, and they are the two halves of "can this reading be
|
||||
* drawn that way".
|
||||
*
|
||||
* **The series registry.** A page derives figures from the records it has
|
||||
* already loaded — Control Center buckets applications, screenings, interviews
|
||||
* and hires by day — and those figures are not in the skill data vocabulary and
|
||||
* should not be: nothing outside that page can compute them. So a page declares
|
||||
* them here, by name, with a pure `read` that takes the bag the page publishes
|
||||
* and returns rows. A node then *names* the series, exactly as a skill node
|
||||
* names a source. **A binding never carries values**, so there is still nowhere
|
||||
* for an invented figure to live.
|
||||
*
|
||||
* **The meaning.** `dataShapes` answers whether a component can draw a shape;
|
||||
* it cannot answer whether doing so would be true. A pie of a time series is
|
||||
* structurally fine and semantically a lie — it throws the timeline away and
|
||||
* presents days as slices of a whole. `SERIES_KINDS` is the missing half, and
|
||||
* `kindOfBinding` is where a binding is asked which one it is.
|
||||
*
|
||||
* The rule for not knowing is deliberate, and it is strict: **an unknown kind
|
||||
* is a refusal.** A component that has declared which meanings it can draw may
|
||||
* only draw a meaning that was actually declared — by a page's series, by a
|
||||
* reading in the data-source vocabulary, or by the binding's own periods. This
|
||||
* is the one place the system could otherwise produce something structurally
|
||||
* valid and factually untrue, and silence is not evidence that it would be
|
||||
* true. Declaring a meaning is one field; guessing one is not offered.
|
||||
*
|
||||
* Components that declare no meanings at all — a table, a list, a card — are
|
||||
* untouched by any of this. They make no claim about what their rows mean, so
|
||||
* there is nothing for them to be wrong about.
|
||||
*/
|
||||
|
||||
import { SERIES_KINDS } from './node';
|
||||
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
|
||||
export { SERIES_KINDS };
|
||||
|
||||
/** Why a kind is what it is, in words a refusal can use. */
|
||||
const KIND_REASON = {
|
||||
periodic: 'it is ordered in time, and that order is the reading',
|
||||
cumulative: 'each step is part of the one before it, so the steps overlap',
|
||||
parts: 'it is a set of parts that make up one whole',
|
||||
};
|
||||
|
||||
/** @type {Map<string, any>} */
|
||||
const providers = new Map();
|
||||
|
||||
/** A series id looks like a node id: lower-case, dashes, and a dotted namespace. */
|
||||
const SERIES_ID_PATTERN = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/;
|
||||
|
||||
/**
|
||||
* Declare a series a page publishes.
|
||||
*
|
||||
* Called at module scope beside the page that computes it, the same way a page
|
||||
* registers its composition — so a series and the page that can answer for it
|
||||
* are deployed together or not at all.
|
||||
*
|
||||
* Re-registering replaces, because the dev server re-runs the module on every
|
||||
* save and throwing there would make the file uneditable.
|
||||
*/
|
||||
/**
|
||||
* A control a series publishes, checked at registration.
|
||||
*
|
||||
* The missing half of "replacing a visualization must not remove what was
|
||||
* there". A page's built-in chart often ships with a control that governs the
|
||||
* reading itself — Control Center's 7D/30D/90D toggle changes *which rows the
|
||||
* series returns*, not how they are drawn — and replacing the component threw
|
||||
* it away, because the control lived inside the component.
|
||||
*
|
||||
* Declaring it on the **series** is what fixes that generically: the control
|
||||
* belongs to the reading, so every component that can draw the reading draws
|
||||
* the control too, and nothing anywhere names a range, a page or a chart. A
|
||||
* series that publishes none is unaffected, and no control is ever invented —
|
||||
* `read` and `write` are the page's own, so a page that stops publishing the
|
||||
* state stops publishing the control with it.
|
||||
*/
|
||||
function normalizeControl(id, control) {
|
||||
const controlId = String(control?.id ?? '').trim();
|
||||
if (!controlId) throw new Error(`registerSeries: \`${id}\` declares a control with no \`id\`.`);
|
||||
|
||||
const options = (control.options || []).map((option) => ({
|
||||
value: String(option?.value ?? '').trim(),
|
||||
label: String(option?.label ?? '').trim(),
|
||||
}));
|
||||
if (!options.length) {
|
||||
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` needs at least one option.`);
|
||||
}
|
||||
const blank = options.find((option) => !option.value || !option.label);
|
||||
if (blank) {
|
||||
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` has an option with no value or label.`);
|
||||
}
|
||||
if (typeof control.read !== 'function' || typeof control.write !== 'function') {
|
||||
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` needs a \`read\` and a \`write\`.`);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
id: controlId,
|
||||
label: String(control.label ?? controlId).trim(),
|
||||
options: Object.freeze(options.map(Object.freeze)),
|
||||
read: control.read,
|
||||
write: control.write,
|
||||
});
|
||||
}
|
||||
|
||||
export function registerSeries(definition) {
|
||||
const id = String(definition?.id ?? '').trim();
|
||||
if (!id) throw new Error('registerSeries: a series needs an `id`.');
|
||||
if (!SERIES_ID_PATTERN.test(id)) {
|
||||
throw new Error(`registerSeries: \`${id}\` must be lower-case letters, numbers, dashes and dots.`);
|
||||
}
|
||||
const kind = String(definition.kind ?? '').trim();
|
||||
if (!SERIES_KINDS.includes(kind)) {
|
||||
throw new Error(
|
||||
`registerSeries: \`${id}\` declares unknown kind \`${kind || '(none)'}\`. `
|
||||
+ `Known: ${SERIES_KINDS.join(', ')}.`
|
||||
);
|
||||
}
|
||||
if (typeof definition.read !== 'function') {
|
||||
throw new Error(`registerSeries: \`${id}\` needs a \`read(context)\`.`);
|
||||
}
|
||||
|
||||
const measures = (definition.measures || []).map((measure) => ({
|
||||
key: String(measure?.key ?? '').trim(),
|
||||
label: String(measure?.label ?? '').trim(),
|
||||
}));
|
||||
if (!measures.length) {
|
||||
throw new Error(`registerSeries: \`${id}\` needs at least one measure.`);
|
||||
}
|
||||
const blank = measures.find((measure) => !measure.key || !measure.label);
|
||||
if (blank) throw new Error(`registerSeries: \`${id}\` has a measure with no key or label.`);
|
||||
|
||||
const entry = Object.freeze({
|
||||
id,
|
||||
label: String(definition.label ?? id).trim(),
|
||||
kind,
|
||||
measures: Object.freeze(measures.map(Object.freeze)),
|
||||
/* The key on each row that names the point — the x axis, or the slice. */
|
||||
labelKey: String(definition.labelKey ?? 'label').trim() || 'label',
|
||||
read: definition.read,
|
||||
emptyNote: String(definition.emptyNote ?? 'Nothing to chart yet.').trim(),
|
||||
/* Optional, and absent for every series that does not publish one. */
|
||||
control: definition.control ? normalizeControl(id, definition.control) : null,
|
||||
});
|
||||
|
||||
providers.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The registration, or null. */
|
||||
export const seriesFor = (id) => providers.get(String(id ?? '').trim()) || null;
|
||||
|
||||
/** Every registered series. Used by tests and by anything that has to list them. */
|
||||
export const listSeries = () => [...providers.values()];
|
||||
|
||||
/** Forget everything. Tests only. */
|
||||
export const resetSeries = () => providers.clear();
|
||||
|
||||
/**
|
||||
* What this binding means, or null for a binding that cannot say.
|
||||
*
|
||||
* Three answers, in order of how much they actually know:
|
||||
*
|
||||
* 1. A registered series declared its kind. That is a fact its author wrote.
|
||||
* 2. A reading bound to time periods is periodic. Also a fact — `periods` is
|
||||
* in the closed option vocabulary and the resolver buckets by it.
|
||||
* 3. Otherwise unknown, and unknown vetoes nothing.
|
||||
*/
|
||||
export function kindOfBinding(binding) {
|
||||
if (!binding) return null;
|
||||
if (binding.series) return seriesFor(binding.series)?.kind || null;
|
||||
if (binding.source) {
|
||||
if (binding.params?.periods?.length) return 'periodic';
|
||||
return dataSourceFor(binding.source)?.series || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** What to call this binding in a sentence. */
|
||||
export function labelOfBinding(binding) {
|
||||
if (!binding) return 'this';
|
||||
if (binding.series) return seriesFor(binding.series)?.label || binding.series;
|
||||
if (binding.source) return dataSourceLabel(binding.source) || binding.source;
|
||||
return 'this';
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a type may not draw this binding — one sentence, or null if it may.
|
||||
*
|
||||
* Written here rather than at the call sites so the editor and the conversation
|
||||
* refuse in the same words, and so the reason is about the *data* rather than
|
||||
* about the component. "A pie chart cannot show Hiring activity" tells nobody
|
||||
* anything; saying that the reading is ordered in time does.
|
||||
*/
|
||||
export function refuseSeries(entry, binding) {
|
||||
if (!entry.seriesKinds.length) {
|
||||
return `\`${entry.label}\` cannot draw ${labelOfBinding(binding)}.`;
|
||||
}
|
||||
const kind = kindOfBinding(binding);
|
||||
/**
|
||||
* Not knowing is a refusal, not a permission.
|
||||
*
|
||||
* This used to be the other way round — an unknown kind vetoed nothing — and
|
||||
* the consequence was visible in the product: a card reading `Recent hires`
|
||||
* was offered a pie chart, because nothing had ever said what those figures
|
||||
* mean and silence was read as consent. A component that has declared which
|
||||
* meanings it can honestly draw cannot draw one that was never declared, so
|
||||
* the honest answer is no, with the reason.
|
||||
*
|
||||
* Saying yes here is the only way this system can produce a chart that is
|
||||
* structurally valid and factually a lie, which is why the default is the
|
||||
* strict one. Establishing compatibility is one field on the reading — see
|
||||
* `series` in the data-source vocabulary — and is deliberately the author's
|
||||
* statement rather than a guess made here from a label or a shape.
|
||||
*/
|
||||
if (!kind) {
|
||||
return (
|
||||
`\`${entry.label}\` cannot show ${labelOfBinding(binding)}: `
|
||||
+ 'it does not say what its figures mean, so there is no way to know '
|
||||
+ 'that drawing them this way would be true.'
|
||||
);
|
||||
}
|
||||
if (entry.seriesKinds.includes(kind)) return null;
|
||||
return (
|
||||
`\`${entry.label}\` cannot show ${labelOfBinding(binding)}: `
|
||||
+ `${KIND_REASON[kind]}.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The control this binding publishes, resolved against the page, or null.
|
||||
*
|
||||
* Two facts have to line up before a control exists: the series declared one,
|
||||
* and the page is actually publishing the state it names. Either missing means
|
||||
* no control — nothing is drawn from a default, and nothing is fabricated to
|
||||
* fill the gap a replaced component left behind.
|
||||
*/
|
||||
export function controlOfBinding(binding, context = {}) {
|
||||
const entry = binding?.series ? seriesFor(binding.series) : null;
|
||||
const control = entry?.control;
|
||||
if (!control) return null;
|
||||
|
||||
const value = control.read(context || {});
|
||||
if (value == null) return null;
|
||||
|
||||
return {
|
||||
id: control.id,
|
||||
label: control.label,
|
||||
options: control.options,
|
||||
value,
|
||||
set: (next) => control.write(context || {}, next),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows a binding resolves to, in the one shape every visualization reads.
|
||||
*
|
||||
* `{ rows, measures, emptyNote }` — rows carry a label and one number per
|
||||
* measure, so a single-measure skill reading and a four-measure page series are
|
||||
* the same object by the time a chart sees them. Nothing is aggregated,
|
||||
* re-bucketed or re-ordered on the way: a chart draws what the page or the
|
||||
* resolver already computed, and a transformation that changed the meaning
|
||||
* would have to be written somewhere, and there is nowhere.
|
||||
*
|
||||
* `resolve` is passed in rather than imported so this stays free of React and
|
||||
* of the skill resolver — the component knows how to get a skill reading, and
|
||||
* this knows what to do with either kind.
|
||||
*/
|
||||
export function readSeries(binding, { context = {}, resolveSource = null } = {}) {
|
||||
const empty = { rows: [], measures: [], emptyNote: 'Nothing to chart yet.', label: '' };
|
||||
if (!binding) return empty;
|
||||
|
||||
if (binding.series) {
|
||||
const entry = seriesFor(binding.series);
|
||||
if (!entry) return empty;
|
||||
const raw = entry.read(context || {}) || [];
|
||||
const rows = (Array.isArray(raw) ? raw : []).map((row, i) => {
|
||||
const point = { id: String(row?.id ?? i), label: String(row?.[entry.labelKey] ?? '') };
|
||||
for (const measure of entry.measures) point[measure.key] = Number(row?.[measure.key]) || 0;
|
||||
return point;
|
||||
}).filter((row) => row.label);
|
||||
return { rows, measures: entry.measures, emptyNote: entry.emptyNote, label: entry.label };
|
||||
}
|
||||
|
||||
if (!binding.source || typeof resolveSource !== 'function') return empty;
|
||||
const data = resolveSource(binding) || {};
|
||||
const rows = (data.steps || data.items || []).map((step, i) => ({
|
||||
id: String(step?.id ?? i),
|
||||
label: String(step?.label || step?.title || ''),
|
||||
value: Number(step?.value) || 0,
|
||||
})).filter((row) => row.label);
|
||||
|
||||
return {
|
||||
rows,
|
||||
measures: [{ key: 'value', label: dataSourceLabel(binding.source) || 'Value' }],
|
||||
emptyNote: data.emptyNote || 'Nothing to chart yet.',
|
||||
label: dataSourceLabel(binding.source) || binding.source,
|
||||
};
|
||||
}
|
||||
83
src/lib/ui/skillNodes.js
Normal file
83
src/lib/ui/skillNodes.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* A skill's declared section, as a node in the page tree.
|
||||
*
|
||||
* This is the join between the two halves of the UI system. A Board skill's
|
||||
* `ui:` block is still parsed by `uiConfig.normalizeSection`, still validated
|
||||
* against the closed vocabulary, and still drawn by the same nine components —
|
||||
* nothing about definitions changes. What this adds is *identity*: the section
|
||||
* becomes an addressable node, so it can be hidden, moved and reordered by the
|
||||
* same operations that move a built-in.
|
||||
*
|
||||
* **Nothing here writes back to Markdown.** A node carries `origin: 'skill'`,
|
||||
* and a change to it is stored as an operation in the person's own layout
|
||||
* patch. The definition on disk, and the definition in the account's custom
|
||||
* skills, are read-only from here — which is what keeps a layout preference
|
||||
* from silently editing something another user also sees.
|
||||
*/
|
||||
|
||||
import { dataSourceFor, sourceSupportsOption } from '@/lib/skills/surfaces';
|
||||
import { makeNode } from './node';
|
||||
|
||||
/** Ids are `skill-<skill>-<section>`, so provenance is legible in the DOM. */
|
||||
export const skillNodeId = (skillId, sectionId) => [
|
||||
'skill', slug(skillId), slug(sectionId),
|
||||
].filter(Boolean).join('-');
|
||||
|
||||
const slug = (value) => String(value || '')
|
||||
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
|
||||
/**
|
||||
* One normalized section, adapted.
|
||||
*
|
||||
* Only options the source actually declares are carried across. The skill
|
||||
* format checks that a period is a real period; this checks that the reading
|
||||
* takes periods at all — a stricter rule, and one a definition written against
|
||||
* the looser one could fail. Dropping the option is right where refusing the
|
||||
* node would not be: a Board card must never disappear because of a parameter
|
||||
* that was doing nothing anyway.
|
||||
*/
|
||||
export function skillSectionNode(skill, section) {
|
||||
const params = {};
|
||||
if (section.periods?.length && sourceSupportsOption(section.source, 'periods')) {
|
||||
params.periods = [...section.periods];
|
||||
}
|
||||
if (section.limit && sourceSupportsOption(section.source, 'limit')) {
|
||||
params.limit = section.limit;
|
||||
}
|
||||
|
||||
return makeNode({
|
||||
id: skillNodeId(skill.id, section.id),
|
||||
type: section.type,
|
||||
origin: 'skill',
|
||||
data: { source: section.source, params },
|
||||
props: {
|
||||
/* The same fallback the surface uses, so a section with no title of its
|
||||
own is still named by the skill that contributed it. */
|
||||
title: section.title || skill.name,
|
||||
...(section.description ? { description: section.description } : {}),
|
||||
/* Attribution is what lets a reader tell an extension from a built-in
|
||||
panel, and which skill to switch off. */
|
||||
attribution: skill.name,
|
||||
...(section.editable ? { editable: true } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every section a page's definitions contribute, grouped by placement.
|
||||
*
|
||||
* Grouped because that is how the composition consumes them: each
|
||||
* `skill-surface` node names one placement and takes the sections that declared
|
||||
* it. A placement no slot offers simply has nowhere to render, which is the
|
||||
* same outcome as today.
|
||||
*/
|
||||
export function skillNodesByPlacement(sections = []) {
|
||||
const out = {};
|
||||
for (const { skill, section } of sections) {
|
||||
if (!dataSourceFor(section.source)) continue;
|
||||
const key = section.placement || '';
|
||||
if (!out[key]) out[key] = [];
|
||||
out[key].push(skillSectionNode(skill, section));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
432
src/lib/ui/validate.js
Normal file
432
src/lib/ui/validate.js
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* What a UI tree has to satisfy before anything is applied or saved.
|
||||
*
|
||||
* This is the gate the plan runs through, and it is deliberately the *only*
|
||||
* place that decides whether a change is allowed. Operations build a candidate
|
||||
* tree; this says whether the product can honour it. Keeping the two apart is
|
||||
* what makes a preview trustworthy: the tree a user is shown is the tree that
|
||||
* passed, not a tree that will be checked again differently on the way to
|
||||
* storage.
|
||||
*
|
||||
* Every refusal names what it refused and why. A validator that returns a
|
||||
* boolean pushes the explaining into whichever caller happens to be nearest,
|
||||
* and the caller does not know which rule fired.
|
||||
*
|
||||
* The rules split in two:
|
||||
*
|
||||
* - **Structure**, which this module owns: ids, types, props, layout,
|
||||
* containment, capability, role.
|
||||
* - **Data**, which it borrows from `lib/skills/surfaces.js` — the same
|
||||
* closed source vocabulary and the same source/shape compatibility rule
|
||||
* that `uiConfig.normalizeSection` already refuses on. Borrowed rather than
|
||||
* restated: two readings of what a source can draw is how a form composes
|
||||
* what the normalizer rejects.
|
||||
*/
|
||||
|
||||
import {
|
||||
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, dataSourceFor, sourceSupportsOption,
|
||||
sourceSupportsShape,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import { refuseSeries, seriesFor } from './series';
|
||||
import {
|
||||
ALIGN_VALUES, DENSITY_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_ID_PATTERN,
|
||||
PRESENTATION_KEYS, SPACING_VALUES, VARIANT_VALUES, walk,
|
||||
} from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/** One refusal. `at` is the node it concerns, so an editor can point at it. */
|
||||
const problem = (at, message) => ({ at: at || null, message });
|
||||
|
||||
/**
|
||||
* Check one node in isolation, given where it sits.
|
||||
*
|
||||
* `parentType` is null at the root. Returns a list of problems, empty when the
|
||||
* node is fine — never throws, because a tree with three bad nodes should
|
||||
* report three, not the first.
|
||||
*/
|
||||
export function validateNode(node, {
|
||||
parentType = null, registry = nodeRegistry, role = null,
|
||||
} = {}) {
|
||||
const problems = [];
|
||||
const at = node?.id || null;
|
||||
|
||||
if (!node || typeof node !== 'object') {
|
||||
return [problem(null, 'A node must be a mapping of options.')];
|
||||
}
|
||||
|
||||
/* ── Identity ─────────────────────────────────────────────────────────── */
|
||||
if (!node.id) {
|
||||
problems.push(problem(null, 'A node needs an `id`.'));
|
||||
} else if (!NODE_ID_PATTERN.test(node.id)) {
|
||||
problems.push(problem(at, `\`${node.id}\`: an id must be lower-case letters, numbers and dashes.`));
|
||||
}
|
||||
|
||||
/* ── Type ─────────────────────────────────────────────────────────────── */
|
||||
if (!node.type) {
|
||||
problems.push(problem(at, 'A node needs a `type`.'));
|
||||
return problems;
|
||||
}
|
||||
|
||||
const entry = registry.get(node.type);
|
||||
if (!entry) {
|
||||
/* The hallucinated-component gate. A type the application does not ship
|
||||
cannot be rendered, and naming the supported set is what turns a refusal
|
||||
into something the asker can act on. */
|
||||
problems.push(problem(
|
||||
at,
|
||||
`Unsupported UI type: ${node.type}. Supported types: ${registry.list().join(', ') || 'none registered'}.`
|
||||
));
|
||||
return problems;
|
||||
}
|
||||
|
||||
/* ── Containment ──────────────────────────────────────────────────────── */
|
||||
if (!registry.acceptsChild(parentType, node.type)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${node.type}\` cannot sit inside \`${parentType}\`.`
|
||||
));
|
||||
}
|
||||
if (node.children?.length && !entry.container) {
|
||||
problems.push(problem(at, `\`${node.type}\` cannot hold other nodes.`));
|
||||
}
|
||||
|
||||
const { minChildren, maxChildren } = entry.constraints;
|
||||
const count = node.children?.length || 0;
|
||||
if (Number.isFinite(minChildren) && count < minChildren) {
|
||||
problems.push(problem(at, `\`${node.type}\` needs at least ${minChildren} node(s); it has ${count}.`));
|
||||
}
|
||||
if (Number.isFinite(maxChildren) && count > maxChildren) {
|
||||
problems.push(problem(at, `\`${node.type}\` holds at most ${maxChildren} node(s); it has ${count}.`));
|
||||
}
|
||||
|
||||
/* ── Permission ───────────────────────────────────────────────────────── */
|
||||
if (entry.roles && role && !entry.roles.includes(role)) {
|
||||
/* Named without describing the component, because the refusal is about the
|
||||
caller and not about what they are missing. */
|
||||
problems.push(problem(at, `You do not have access to \`${entry.label}\`.`));
|
||||
}
|
||||
|
||||
problems.push(...validateProps(node, entry));
|
||||
problems.push(...validateLayout(node, entry));
|
||||
problems.push(...validatePresentation(node, entry));
|
||||
problems.push(...validateBinding(node, entry));
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props, against the schema the type declared.
|
||||
*
|
||||
* Unknown keys are refused rather than dropped. Dropping is right when reading a
|
||||
* definition an author wrote by hand — `uiConfig` does exactly that — but this
|
||||
* path is a *mutation*, and a silently discarded prop is a change the user
|
||||
* asked for, was told had been applied, and then did not happen.
|
||||
*/
|
||||
function validateProps(node, entry) {
|
||||
const problems = [];
|
||||
const schema = entry.propSchema || {};
|
||||
const at = node.id;
|
||||
|
||||
for (const [key, value] of Object.entries(node.props || {})) {
|
||||
const rule = schema[key];
|
||||
if (!rule) {
|
||||
const known = Object.keys(schema);
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${entry.type}\` has no property \`${key}\`.`
|
||||
+ (known.length ? ` It accepts: ${known.join(', ')}.` : '')
|
||||
));
|
||||
continue;
|
||||
}
|
||||
problems.push(...checkValue(at, entry.type, key, value, rule));
|
||||
}
|
||||
|
||||
for (const [key, rule] of Object.entries(schema)) {
|
||||
if (rule?.required && node.props?.[key] == null) {
|
||||
problems.push(problem(at, `\`${entry.type}\` needs \`${key}\`.`));
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** One prop against one rule. Kept separate so the rule vocabulary has one reader. */
|
||||
function checkValue(at, type, key, value, rule) {
|
||||
const problems = [];
|
||||
|
||||
if (Array.isArray(rule.enum)) {
|
||||
if (!rule.enum.includes(value)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${type}.${key}\` must be one of: ${rule.enum.join(', ')}. Got \`${value}\`.`
|
||||
));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
const kind = rule.type || 'string';
|
||||
const actual = Array.isArray(value) ? 'array' : typeof value;
|
||||
|
||||
if (kind === 'number') {
|
||||
if (!Number.isFinite(Number(value))) {
|
||||
problems.push(problem(at, `\`${type}.${key}\` must be a number. Got \`${value}\`.`));
|
||||
return problems;
|
||||
}
|
||||
const n = Number(value);
|
||||
if (Number.isFinite(rule.min) && n < rule.min) {
|
||||
problems.push(problem(at, `\`${type}.${key}\` must be at least ${rule.min}.`));
|
||||
}
|
||||
if (Number.isFinite(rule.max) && n > rule.max) {
|
||||
problems.push(problem(at, `\`${type}.${key}\` must be at most ${rule.max}.`));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
if (kind !== actual) {
|
||||
problems.push(problem(at, `\`${type}.${key}\` must be a ${kind}. Got ${actual}.`));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout, bounded globally and then by the type.
|
||||
*
|
||||
* The responsive gate. A column count is the one layout value that can make a
|
||||
* page unusable on a phone rather than merely ugly, so it is bounded twice —
|
||||
* once by the grid the application can express at all, and once by what this
|
||||
* particular component stays readable in.
|
||||
*/
|
||||
function validateLayout(node, entry) {
|
||||
const problems = [];
|
||||
const at = node.id;
|
||||
const layout = node.layout || {};
|
||||
const { minColumns, maxColumns } = entry.constraints;
|
||||
|
||||
for (const key of ['columns', 'span']) {
|
||||
if (layout[key] == null) continue;
|
||||
const n = Number(layout[key]);
|
||||
if (!Number.isInteger(n)) {
|
||||
problems.push(problem(at, `\`${key}\` must be a whole number. Got \`${layout[key]}\`.`));
|
||||
continue;
|
||||
}
|
||||
if (n < MIN_COLUMNS || n > MAX_COLUMNS) {
|
||||
problems.push(problem(at, `\`${key}\` must be between ${MIN_COLUMNS} and ${MAX_COLUMNS}.`));
|
||||
continue;
|
||||
}
|
||||
if (key === 'columns' && (n < minColumns || n > maxColumns)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${entry.type}\` supports ${minColumns}–${maxColumns} columns. Got ${n}.`
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (layout.gap != null && !GAP_VALUES.includes(layout.gap)) {
|
||||
problems.push(problem(at, `\`gap\` must be one of: ${GAP_VALUES.join(', ')}.`));
|
||||
}
|
||||
if (layout.align != null && !ALIGN_VALUES.includes(layout.align)) {
|
||||
problems.push(problem(at, `\`align\` must be one of: ${ALIGN_VALUES.join(', ')}.`));
|
||||
}
|
||||
|
||||
for (const key of ['spacingBefore', 'spacingAfter']) {
|
||||
if (layout[key] != null && !SPACING_VALUES.includes(layout[key])) {
|
||||
problems.push(problem(at, `\`${key}\` must be one of: ${SPACING_VALUES.join(', ')}.`));
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentation, checked twice.
|
||||
*
|
||||
* Once against the product's vocabulary — a value that is not a value cannot be
|
||||
* stored — and once against what *this type* declared it can draw. The second
|
||||
* check is the one that matters: a setting a component ignores is a change a
|
||||
* person made and cannot see, which is worse than being told no.
|
||||
*
|
||||
* The refusal names what is available, because the person is choosing from a
|
||||
* closed set and the set is short enough to say out loud.
|
||||
*/
|
||||
function validatePresentation(node, entry) {
|
||||
const problems = [];
|
||||
const at = node.id;
|
||||
const presentation = node.presentation || {};
|
||||
|
||||
for (const key of Object.keys(presentation)) {
|
||||
if (!PRESENTATION_KEYS.includes(key)) {
|
||||
problems.push(problem(at, `\`${key}\` is not a presentation setting.`));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, allowed, supported] of [
|
||||
['variant', VARIANT_VALUES, entry.variants],
|
||||
['density', DENSITY_VALUES, entry.densities],
|
||||
]) {
|
||||
const value = presentation[key];
|
||||
if (value == null) continue;
|
||||
|
||||
if (!allowed.includes(value)) {
|
||||
problems.push(problem(at, `\`${key}\` must be one of: ${allowed.join(', ')}.`));
|
||||
continue;
|
||||
}
|
||||
if (!supported.includes(value)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
supported.length
|
||||
? `\`${entry.type}\` supports ${key}: ${supported.join(', ')}. Got \`${value}\`.`
|
||||
: `\`${entry.type}\` has no ${key} to set.`
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* The data binding — the gate that stops invented figures.
|
||||
*
|
||||
* A node cannot carry data; it can only name a reading the application already
|
||||
* offers. So a fabricated metric has nowhere to live: the resolver either
|
||||
* returns real rows for a real source or the section draws its empty note. The
|
||||
* three checks are that the source exists, that this component can draw it, and
|
||||
* that any options named are ones the source actually reads.
|
||||
*/
|
||||
function validateBinding(node, entry) {
|
||||
const problems = [];
|
||||
const at = node.id;
|
||||
const binding = node.data;
|
||||
|
||||
if (!binding) {
|
||||
if (entry.dataRequired) {
|
||||
problems.push(problem(at, `\`${entry.type}\` needs a data source.`));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page's own series.
|
||||
*
|
||||
* Checked against the series registry rather than the data-source vocabulary,
|
||||
* because it is a different closed table — but the gate is the same one and
|
||||
* for the same reason: a binding may only *name* something already
|
||||
* registered, so there is still nowhere for a fabricated figure to live. The
|
||||
* meaning is checked too, so a stored patch that turned a time series into a
|
||||
* pie is refused on load rather than drawn.
|
||||
*/
|
||||
if (binding.series) {
|
||||
const series = seriesFor(binding.series);
|
||||
if (!series) {
|
||||
problems.push(problem(at, `Unsupported series: ${binding.series}.`));
|
||||
return problems;
|
||||
}
|
||||
if (!entry.seriesKinds.length) {
|
||||
problems.push(problem(at, `\`${entry.type}\` does not draw a series.`));
|
||||
return problems;
|
||||
}
|
||||
const untrue = refuseSeries(entry, binding);
|
||||
if (untrue) problems.push(problem(at, untrue));
|
||||
return problems;
|
||||
}
|
||||
|
||||
if (!entry.dataShapes.length) {
|
||||
problems.push(problem(at, `\`${entry.type}\` does not read data, so it cannot take a source.`));
|
||||
return problems;
|
||||
}
|
||||
|
||||
if (!SUPPORTED_DATA_SOURCES.includes(binding.source)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`Unsupported data source: ${binding.source}. `
|
||||
+ `Supported sources: ${SUPPORTED_DATA_SOURCES.join(', ')}.`
|
||||
));
|
||||
return problems;
|
||||
}
|
||||
|
||||
/* A component draws one or more shapes; a source can fill some of them. The
|
||||
node is only coherent where the two overlap. */
|
||||
const drawable = entry.dataShapes.filter((shape) => sourceSupportsShape(binding.source, shape));
|
||||
if (!drawable.length) {
|
||||
const source = dataSourceFor(binding.source);
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${binding.source}\` cannot be shown as \`${entry.type}\`. `
|
||||
+ `It supports: ${(source?.shapes || []).join(', ')}.`
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* And the semantic half, for a skill reading as well as for a page series.
|
||||
*
|
||||
* The same call, the same sentence, one rule — so a stored patch that binds a
|
||||
* component to a reading it cannot honestly draw is refused on load exactly
|
||||
* as the operation that would have created it is refused, rather than being
|
||||
* replayed into the tree because nobody re-asked the question here.
|
||||
*/
|
||||
if (entry.seriesKinds.length) {
|
||||
const untrue = refuseSeries(entry, binding);
|
||||
if (untrue) problems.push(problem(at, untrue));
|
||||
}
|
||||
|
||||
const params = binding.params || {};
|
||||
|
||||
if (params.periods != null) {
|
||||
if (!Array.isArray(params.periods)) {
|
||||
problems.push(problem(at, '`periods` must be a list.'));
|
||||
} else {
|
||||
const unknown = params.periods.find((p) => !SUPPORTED_PERIODS.includes(p));
|
||||
if (unknown) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`Unsupported period: ${unknown}. Supported periods: ${SUPPORTED_PERIODS.join(', ')}.`
|
||||
));
|
||||
} else if (params.periods.length && !sourceSupportsOption(binding.source, 'periods')) {
|
||||
problems.push(problem(at, `\`${binding.source}\` does not read periods.`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (params.limit != null) {
|
||||
const limit = Number(params.limit);
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
|
||||
problems.push(problem(at, '`limit` must be a whole number between 1 and 50.'));
|
||||
} else if (!sourceSupportsOption(binding.source, 'limit')) {
|
||||
problems.push(problem(at, `\`${binding.source}\` does not read a limit.`));
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole tree.
|
||||
*
|
||||
* Two things can only be seen from up here: that no id is used twice, and that
|
||||
* containment holds all the way down. Both are checked once, over one walk, so
|
||||
* a large page is not traversed per rule.
|
||||
*/
|
||||
export function validateTree(nodes, { registry = nodeRegistry, role = null } = {}) {
|
||||
const problems = [];
|
||||
const seen = new Set();
|
||||
|
||||
const visit = (list, parentType) => {
|
||||
for (const node of list || []) {
|
||||
if (node?.id) {
|
||||
/* The duplicate-node gate. Two nodes with one id means every operation
|
||||
after this point addresses whichever the traversal reached first. */
|
||||
if (seen.has(node.id)) {
|
||||
problems.push(problem(node.id, `Two nodes share the id \`${node.id}\`.`));
|
||||
}
|
||||
seen.add(node.id);
|
||||
}
|
||||
problems.push(...validateNode(node, { parentType, registry, role }));
|
||||
if (node?.children?.length) visit(node.children, node.type);
|
||||
}
|
||||
};
|
||||
|
||||
visit(nodes, null);
|
||||
return { ok: problems.length === 0, problems };
|
||||
}
|
||||
|
||||
/** Every id already in use, so a new node can be given one that is not. */
|
||||
export const takenIds = (nodes) => new Set(walk(nodes).map((node) => node.id));
|
||||
@@ -4,7 +4,7 @@ import { recommendJobs, recommendNextCourse } from '@/lib/krowScore';
|
||||
import TalentHero from '@/components/krow/talent/TalentHero';
|
||||
import IncreaseMarketValue from '@/components/krow/talent/IncreaseMarketValue';
|
||||
import CareerGrowth from '@/components/krow/talent/CareerGrowth';
|
||||
import MarketOffers from '@/components/krow/talent/MarketOffers';
|
||||
import Opportunities from '@/components/krow/talent/Opportunities';
|
||||
import ExperienceNetwork from '@/components/krow/talent/ExperienceNetwork';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function EmployeeDashboard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TalentHero profile={profile} jobRecs={jobRecs} nextCourse={nextCourse} openOwliver={openOwliver} />
|
||||
<MarketOffers profile={profile} jobRecs={jobRecs} />
|
||||
<Opportunities jobRecs={jobRecs} />
|
||||
<IncreaseMarketValue courses={courses} />
|
||||
<CareerGrowth profile={profile} />
|
||||
<ExperienceNetwork profile={profile} />
|
||||
|
||||
208
src/pages/OpportunityDetail.jsx
Normal file
208
src/pages/OpportunityDetail.jsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, MapPin, Wallet, Sparkles, CheckCircle2, Award,
|
||||
Briefcase, Clock, Loader2, Compass,
|
||||
} from 'lucide-react';
|
||||
import { useWorkerProfile, useJobPostings } from '@/lib/krowHooks';
|
||||
import { computeJobMatch } from '@/lib/krowScore';
|
||||
|
||||
/**
|
||||
* OpportunityDetail — the full-page Employee job detail.
|
||||
*
|
||||
* A dedicated route (/opportunities/:id), NOT a drawer/sheet/modal. It continues
|
||||
* the Opportunities grid: the same title-first identity, the same match, and an
|
||||
* Apply action that hands off to the existing /apply?job=<id> flow (which runs
|
||||
* the AI interview). It reuses the existing matching — useWorkerProfile,
|
||||
* useJobPostings and computeJobMatch — and changes none of it.
|
||||
*
|
||||
* COMPANY PRIVACY: never renders job.company. Only title, role_category,
|
||||
* location, pay, status, description/responsibilities/qualifications and the
|
||||
* match signals are shown.
|
||||
*/
|
||||
|
||||
/* The skills the profile brings that this role asks for — the same
|
||||
case-insensitive overlap computeJobMatch scores, shown here for the reader
|
||||
rather than recomputed into a number. Read-only; the algorithm is untouched. */
|
||||
function matchedSkills(profile, job) {
|
||||
const have = (profile?.skills || []).map((s) => String(s).toLowerCase());
|
||||
const want = job?.qualifications || [];
|
||||
return want.filter((q) => {
|
||||
const ql = String(q).toLowerCase();
|
||||
return have.some((ps) => ql.includes(ps) || ps.includes(ql));
|
||||
});
|
||||
}
|
||||
|
||||
function matchedCerts(profile, job) {
|
||||
const have = (profile?.earned_badges || []).map((b) => String(b?.name || '').toLowerCase()).filter(Boolean);
|
||||
const want = job?.certifications_required || [];
|
||||
return want.filter((c) => {
|
||||
const cl = String(c).toLowerCase();
|
||||
return have.some((pc) => cl.includes(pc) || pc.includes(cl));
|
||||
});
|
||||
}
|
||||
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-bold text-[#0F172A] mb-3">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OpportunityDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: profile, isLoading: loadingProfile } = useWorkerProfile();
|
||||
const { data: postings = [], isLoading: loadingJobs } = useJobPostings();
|
||||
|
||||
if (loadingProfile || loadingJobs) {
|
||||
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0838E0]" /></div>;
|
||||
}
|
||||
|
||||
const job = postings.find((p) => p.id === id);
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto text-center py-20">
|
||||
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3">
|
||||
<Compass className="w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-[15px] font-semibold text-[#0F172A]">This opportunity is no longer available.</p>
|
||||
<button
|
||||
onClick={() => navigate('/employee')}
|
||||
className="mt-5 inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0838E0] hover:text-[#062BAF]"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Opportunities
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const match = computeJobMatch(profile, job);
|
||||
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : null;
|
||||
const isHiring = job.status === 'active';
|
||||
const skills = matchedSkills(profile, job);
|
||||
const certs = matchedCerts(profile, job);
|
||||
const exp = profile?.experience_years || 0;
|
||||
const reqExp = job.min_experience_years || 0;
|
||||
const meetsExp = exp >= reqExp;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-5 pb-10">
|
||||
<button
|
||||
onClick={() => navigate('/employee')}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#6B7280] hover:text-[#111827]"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Opportunities
|
||||
</button>
|
||||
|
||||
{/* Header — title-first identity, no company name. */}
|
||||
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center shrink-0">
|
||||
<Briefcase className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-[22px] font-bold text-[#0F172A] leading-tight">{job.title}</h1>
|
||||
{isHiring && (
|
||||
<span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full">Hiring</span>
|
||||
)}
|
||||
</div>
|
||||
{job.role_category && <p className="mt-1 text-[14px] text-[#6B7280] capitalize">{job.role_category}</p>}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<span className="inline-flex items-center gap-1.5 text-[14px] font-bold text-[#0838E0]">
|
||||
<Sparkles className="w-4 h-4" /> {match}% match
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-[13px] text-[#6B7280]">
|
||||
<MapPin className="w-4 h-4 text-[#9CA3AF]" /> {job.location || 'Remote'}
|
||||
</span>
|
||||
{pay && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#111827]">
|
||||
<Wallet className="w-4 h-4 text-[#9CA3AF]" /> {pay}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About this opportunity */}
|
||||
<Section title="About this opportunity">
|
||||
{job.description ? (
|
||||
<p className="text-[13px] text-[#6B7280] whitespace-pre-wrap leading-relaxed">{job.description}</p>
|
||||
) : (
|
||||
<p className="text-[13px] text-[#9CA3AF]">No description provided for this opportunity yet.</p>
|
||||
)}
|
||||
{job.responsibilities?.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">What you'll do</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-[13px] text-[#6B7280]">
|
||||
{job.responsibilities.map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{job.qualifications?.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">What they're looking for</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-[13px] text-[#6B7280]">
|
||||
{job.qualifications.map((q, i) => <li key={i}>{q}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Why you're a match */}
|
||||
<Section title="Why you're a match">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden">
|
||||
<div className="h-full rounded-full bg-[#0838E0]" style={{ width: `${match}%` }} />
|
||||
</div>
|
||||
<span className="text-[14px] font-bold text-[#0838E0]">{match}%</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2.5">
|
||||
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
|
||||
<Clock className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{exp} yr{exp === 1 ? '' : 's'} of experience
|
||||
{reqExp > 0 && <> · role asks for {reqExp}+ {meetsExp ? '— you qualify' : ''}</>}
|
||||
</span>
|
||||
</li>
|
||||
{skills.length > 0 && (
|
||||
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#16A34A] mt-0.5 shrink-0" />
|
||||
<span>Your skills match: {skills.join(', ')}</span>
|
||||
</li>
|
||||
)}
|
||||
{certs.length > 0 && (
|
||||
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
|
||||
<Award className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
|
||||
<span>Certifications you hold: {certs.join(', ')}</span>
|
||||
</li>
|
||||
)}
|
||||
{skills.length === 0 && certs.length === 0 && (
|
||||
<li className="flex items-start gap-2 text-[13px] text-[#6B7280]">
|
||||
<Sparkles className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
|
||||
<span>You showed up here because your profile aligns with this role. Add skills to strengthen the match.</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
{/* Apply */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => navigate(`/apply?job=${job.id}`)}
|
||||
className="inline-flex items-center justify-center gap-2 h-11 px-8 rounded-full bg-[#0838E0] text-white text-[14px] font-semibold hover:bg-[#062BAF] transition-colors
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
|
||||
>
|
||||
Apply <Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert, UserCheck, UserPlus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
|
||||
} from '@/components/ds';
|
||||
import { useUserActivity } from '@/lib/krowHooks';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/activity/nodes';
|
||||
|
||||
/**
|
||||
* Admin Activity — an audit centre.
|
||||
@@ -33,35 +30,9 @@ const SEVERITY = {
|
||||
apply_job: 'low',
|
||||
};
|
||||
|
||||
const SEVERITY_META = {
|
||||
high: { label: 'High', variant: 'destructive' },
|
||||
medium: { label: 'Medium', variant: 'warning' },
|
||||
low: { label: 'Low', variant: 'neutral' },
|
||||
};
|
||||
|
||||
/** Severity carried through to the timeline node, so the two views agree. */
|
||||
const TIMELINE_TONE = { high: 'destructive', medium: 'warning', low: 'neutral' };
|
||||
|
||||
/** How many events the timeline shows before the table takes over. */
|
||||
const TIMELINE_LIMIT = 12;
|
||||
|
||||
/**
|
||||
* An icon per event type. Worth the table: on a chronology the glyph is what
|
||||
* makes a run of hires distinguishable from a run of logins at a glance, which is
|
||||
* the whole reason to show a timeline rather than another list of rows.
|
||||
*/
|
||||
const EVENT_ICON = {
|
||||
hire_candidate: UserCheck,
|
||||
assign_employee: Users,
|
||||
create_position: FilePlus2,
|
||||
screen_candidate: ScanSearch,
|
||||
start_interview: Mic,
|
||||
apply_job: Send,
|
||||
signup: UserPlus,
|
||||
login: LogIn,
|
||||
logout: LogOut,
|
||||
};
|
||||
|
||||
/** Stable pseudo-IP from the email, so the demo shows metadata without inventing
|
||||
* a value that changes on every render. */
|
||||
const ipFor = (email) => {
|
||||
@@ -143,162 +114,42 @@ export default function AdminActivity() {
|
||||
return [...groups.entries()];
|
||||
}, [recent]);
|
||||
|
||||
/**
|
||||
* What this page's sections read.
|
||||
*
|
||||
* Published once, as one bag. The renderer passes it down untouched and never
|
||||
* looks inside, which is what keeps it ignorant of what an Activity page
|
||||
* contains — and what lets the next page publish something entirely
|
||||
* different without the renderer changing.
|
||||
*/
|
||||
const context = useMemo(() => ({
|
||||
isLoading, events, users, actions, filtered, isFiltered, clearFilters,
|
||||
search, setSearch, user, setUser, action, setAction, severity, setSeverity, range, setRange,
|
||||
highRisk, last24h, recent, byDay,
|
||||
}), [
|
||||
isLoading, events, users, actions, filtered, isFiltered,
|
||||
search, user, action, severity, range, highRisk, last24h, recent, byDay,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Activity"
|
||||
subtitle="Monitor workforce operations, user actions and system events."
|
||||
>
|
||||
<SkillSurface page="activity" placement="after-header" />
|
||||
<MetricStrip
|
||||
columns={4}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{ label: 'Total events', value: events.length },
|
||||
{ label: 'Last 24 hours', value: last24h.length, tone: last24h.length ? 'default' : 'warning' },
|
||||
{ label: 'Privileged actions', value: highRisk.length, tone: 'warning', sub: 'hires and position changes' },
|
||||
{ label: 'Distinct users', value: users.length },
|
||||
]}
|
||||
/>
|
||||
|
||||
{highRisk.length > 0 && (
|
||||
<Surface variant="solid" radius="lg" padding="sm" elevation="xs" className="border-warning/30 bg-warning-muted">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden="true" />
|
||||
<p className="text-body-sm text-ink-2">
|
||||
<span className="font-semibold text-ink-1">{highRisk.length} privileged actions</span> in this log —
|
||||
hires and position changes. These should always trace to a named person.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
{/* The recent timeline. Deliberately above the log and deliberately not
|
||||
filtered: this answers "what just happened", which is a different
|
||||
question from the one the toolbar below exists to ask. Reading a
|
||||
chronology is also how an auditor starts — sequence first, then
|
||||
interrogate the specifics. */}
|
||||
<section aria-labelledby="timeline" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="timeline"
|
||||
title="Operational timeline"
|
||||
meta={`Most recent ${recent.length} events`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{recent.length ? (
|
||||
<div className="divide-y divide-border">
|
||||
{byDay.map(([day, dayEvents]) => (
|
||||
<div key={day} className="px-4 py-3.5">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
{day}
|
||||
</p>
|
||||
<Timeline
|
||||
compact
|
||||
items={dayEvents.map((e) => ({
|
||||
id: e.id,
|
||||
icon: EVENT_ICON[e.event_type] || Activity,
|
||||
tone: TIMELINE_TONE[e.severity],
|
||||
title: e.event_type.replace(/_/g, ' '),
|
||||
description: e.details || undefined,
|
||||
meta: `${e.user_name || e.user_email}${e.account_type ? ` · ${e.account_type}` : ''}`,
|
||||
timestamp: new Date(e.created_date).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
}),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
No activity has been logged yet.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="audit" className="space-y-3">
|
||||
<SectionTitle id="audit" title="Audit log" meta={`${filtered.length} of ${events.length} events`} />
|
||||
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search events" size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={user} onChange={setUser} label="User"
|
||||
options={[{ value: 'all', label: 'All users' }, ...users.map((u) => ({ value: u, label: u }))]} />
|
||||
<FilterSelect value={action} onChange={setAction} label="Action"
|
||||
options={[{ value: 'all', label: 'All actions' }, ...actions.map((a) => ({ value: a, label: a.replace(/_/g, ' ') }))]} />
|
||||
<FilterSelect value={severity} onChange={setSeverity} label="Severity" options={[
|
||||
{ value: 'all', label: 'All severities' }, { value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' },
|
||||
]} />
|
||||
<FilterSelect value={range} onChange={setRange} label="Date" options={[
|
||||
{ value: 'all', label: 'All time' }, { value: '24h', label: 'Last 24 hours' },
|
||||
{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' },
|
||||
]} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={filtered}
|
||||
pageSize={20}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
caption="Platform audit log"
|
||||
rowClassName={(e) => (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)}
|
||||
columns={[
|
||||
{
|
||||
key: 'user_name', header: 'User', sortable: true,
|
||||
cell: (e) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={e.user_name || e.user_email} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{e.user_name || '—'}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{e.user_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'event_type', header: 'Action', sortable: true,
|
||||
cell: (e) => <span className="whitespace-nowrap">{e.event_type.replace(/_/g, ' ')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'details', header: 'Entity', hideBelow: 'md',
|
||||
cell: (e) => <span className="line-clamp-1 text-ink-3">{e.details || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'account_type', header: 'Role', hideBelow: 'lg',
|
||||
cell: (e) => <Badge variant={e.account_type === 'employer' ? 'info' : 'neutral'} size="sm">{e.account_type || 'unknown'}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'ip', header: 'Source IP', hideBelow: 'lg',
|
||||
cell: (e) => <span className="font-mono text-[11px] text-ink-4">{e.ip}</span>,
|
||||
},
|
||||
{
|
||||
key: 'created_date', header: 'Timestamp', align: 'right', sortable: true,
|
||||
sortValue: (e) => new Date(e.created_date).getTime(),
|
||||
cell: (e) => (
|
||||
<span className="whitespace-nowrap text-[11px] text-ink-4">
|
||||
{new Date(e.created_date).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity', header: 'Severity', align: 'right',
|
||||
cell: (e) => (
|
||||
<Badge variant={SEVERITY_META[e.severity].variant} size="sm">
|
||||
{SEVERITY_META[e.severity].label}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
<SkillSurface page="activity" placement="before-footer" />
|
||||
{/* The editing session is mounted by the layout, so the panel beside this
|
||||
page shares it. The page only renders what it is handed, which is why
|
||||
a preview in chat and the applied state cannot diverge. */}
|
||||
<UiEditor />
|
||||
<ActivityComposition context={context} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. Split out so it can read the tree the provider computed. */
|
||||
function ActivityComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
/* Outside a layout — a test, a storybook — there is no session, and the page
|
||||
still has to draw. It falls back to the composition as shipped. */
|
||||
const tree = editing?.tree || composePage('activity').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ export default function AdminAgentDetail() {
|
||||
/* A new agent needs an id before it has an address. Derived from the name
|
||||
so the author never has to invent one. */
|
||||
const composed = applyAgentFields(baseSource, fields);
|
||||
|
||||
const problem = validateAgentSource(composed);
|
||||
if (problem) { toast.error(problem); return null; }
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Activity, Award, Clock, Gauge, Lightbulb, Target, TrendingUp, TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge, MetricStrip, Surface } from '@/components/ds';
|
||||
|
||||
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
|
||||
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
|
||||
import { HiringFlow } from '@/components/charts/HiringFlow';
|
||||
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
|
||||
import { useSize } from '@/hooks/use-size';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/analytics/nodes';
|
||||
import {
|
||||
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,
|
||||
hiresWithFill, summarise,
|
||||
@@ -38,101 +35,6 @@ import {
|
||||
* cannot disagree.
|
||||
*/
|
||||
|
||||
const INSIGHT_TONE = {
|
||||
success: { ring: 'border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20', dot: 'text-emerald-600 dark:text-emerald-400', Icon: TrendingUp },
|
||||
warning: { ring: 'border-amber-500/30 bg-amber-50/40 dark:bg-amber-950/20', dot: 'text-amber-600 dark:text-amber-400', Icon: TriangleAlert },
|
||||
risk: { ring: 'border-red-500/30 bg-red-50/40 dark:bg-red-950/20', dot: 'text-red-600 dark:text-red-400', Icon: TriangleAlert },
|
||||
info: { ring: 'border-border bg-surface', dot: 'text-krow-blue', Icon: Lightbulb },
|
||||
};
|
||||
|
||||
/** One finding, with the evidence underneath it. */
|
||||
function Insight({ item }) {
|
||||
const tone = INSIGHT_TONE[item.tone] || INSIGHT_TONE.info;
|
||||
const { Icon } = tone;
|
||||
|
||||
return (
|
||||
<li className={cn('flex items-start gap-2.5 rounded-xl border p-3.5', tone.ring)}>
|
||||
<Icon className={cn('mt-0.5 h-4 w-4 shrink-0', tone.dot)} aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-heading text-body-sm font-semibold text-ink-1">{item.title}</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The funnel, drawn once its container can be measured.
|
||||
*
|
||||
* `HiringFlow` renders an MUI bar chart with no explicit width, which means the
|
||||
* chart measures its parent on mount. On this page that first measure lands
|
||||
* before the layout has resolved a width — the shell's `main` is a `flex-1`
|
||||
* column beside the Owliver panel — and the chart warns that it has nothing to
|
||||
* size itself against. Gating on the measured width means the chart mounts once,
|
||||
* already knowing how wide it is, instead of mounting into nothing and
|
||||
* recovering.
|
||||
*
|
||||
* The reserved height keeps the section from collapsing and reflowing the page
|
||||
* on the frame between measure and draw.
|
||||
*/
|
||||
function MeasuredFunnel({ funnel }) {
|
||||
const ref = React.useRef(null);
|
||||
const size = useSize(ref);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full">
|
||||
{size?.width ? (
|
||||
<HiringFlow
|
||||
stages={funnel.stages}
|
||||
transitions={funnel.transitions}
|
||||
weakestKey={funnel.weakestKey}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[280px] rounded-xl border border-border bg-surface" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A ranked comparison row — used for both fastest and slowest to fill. */
|
||||
function VelocityList({ items, median, tone }) {
|
||||
if (!items.length) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
|
||||
No role has enough dated hires to measure velocity yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{items.map((role) => {
|
||||
const delta = median ? role.avgDays - median : 0;
|
||||
return (
|
||||
<li key={role.role} className="flex items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">{role.role}</p>
|
||||
<p className="text-[10px] text-ink-4">
|
||||
{role.count} hire{role.count === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="font-heading text-body-sm font-bold tabular-nums text-ink-1">
|
||||
{role.avgDays}d
|
||||
</span>
|
||||
{median > 0 && delta !== 0 && (
|
||||
<Badge variant={tone === 'fast' ? 'success' : 'warning'} size="sm" className="tabular-nums">
|
||||
{delta > 0 ? '+' : ''}{delta}d
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminAnalytics() {
|
||||
const { data: staff = [], isLoading } = useStaff();
|
||||
const { data: applications = [] } = useApplications();
|
||||
@@ -154,211 +56,24 @@ export default function AdminAnalytics() {
|
||||
[hires, departments, positions, funnel, efficiency]
|
||||
);
|
||||
|
||||
const context = useMemo(() => ({
|
||||
summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications,
|
||||
}), [summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Analytics"
|
||||
subtitle="How hiring is performing — rates, trends, comparison and what to act on."
|
||||
>
|
||||
<SkillSurface page="analytics" placement="after-header" />
|
||||
|
||||
{/* 1. Hiring performance — the four figures the rest of the page explains. */}
|
||||
<section aria-labelledby="performance" className="space-y-3">
|
||||
<SectionTitle id="performance" title="Hiring performance" meta="Across the workspace" />
|
||||
<MetricStrip
|
||||
columns={4}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{ label: 'Total hires', value: summary.total, icon: Award, tone: 'brand' },
|
||||
{
|
||||
label: 'Avg time-to-hire',
|
||||
value: summary.speed ? `${summary.speed}d` : '—',
|
||||
icon: Clock,
|
||||
sub: efficiency.median ? `${efficiency.median}d median` : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Quality of hire',
|
||||
value: summary.quality || '—',
|
||||
icon: TrendingUp,
|
||||
tone: summary.quality >= 80 ? 'success' : 'default',
|
||||
sub: 'avg AI score',
|
||||
},
|
||||
{
|
||||
label: 'Conversion rate',
|
||||
value: `${funnel.conversion}%`,
|
||||
icon: Target,
|
||||
sub: `${funnel.stages[4].count} of ${funnel.stages[0].count} applicants`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 2. The funnel — where candidates are lost, which is the finding this
|
||||
page exists to surface. */}
|
||||
<section aria-labelledby="funnel" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="funnel"
|
||||
title="Hiring funnel"
|
||||
meta="Applied → Screened → Shortlisted → Interview → Hired"
|
||||
/>
|
||||
{funnel.stages[0].count ? (
|
||||
<MeasuredFunnel funnel={funnel} />
|
||||
) : (
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<p className="text-body-sm leading-relaxed text-ink-3">
|
||||
No applications on file yet. The funnel appears once the first candidate applies.
|
||||
</p>
|
||||
</Surface>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 3. Trend over time. */}
|
||||
<section aria-labelledby="trend" className="space-y-3">
|
||||
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
|
||||
<HiringTrendChart
|
||||
points={trend}
|
||||
emptyState={(
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">
|
||||
Not enough history for a trend
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
|
||||
{hires.length
|
||||
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
|
||||
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 4. Department comparison. */}
|
||||
<section aria-labelledby="departments" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="departments"
|
||||
title="Department performance"
|
||||
meta={`${departments.length} department${departments.length === 1 ? '' : 's'} compared`}
|
||||
/>
|
||||
<DepartmentPerformance items={departments} />
|
||||
</section>
|
||||
|
||||
{/* 5. Position comparison — the same question one level down. */}
|
||||
<section aria-labelledby="positions" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="positions"
|
||||
title="Position performance"
|
||||
meta={`${positions.length} role${positions.length === 1 ? '' : 's'} filled`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[36rem] border-collapse text-body-sm">
|
||||
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'whitespace-nowrap px-3.5 py-2.5 text-[10px] font-bold uppercase tracking-wider text-ink-4',
|
||||
i ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{positions.map((r) => (
|
||||
<tr key={r.role} className="border-b border-border/60 last:border-0 transition-colors hover:bg-surface-subtle/80">
|
||||
<td className="px-3.5 py-2.5 font-medium text-ink-1">{r.role}</td>
|
||||
<td className="px-3.5 py-2.5 text-right font-semibold tabular-nums text-ink-2">{r.count}</td>
|
||||
<td className="px-3.5 py-2.5 text-right font-bold tabular-nums text-blue-600 dark:text-blue-400">{r.avgScore || '—'}</td>
|
||||
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
|
||||
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
|
||||
<td className="px-3.5 py-2.5 text-right">
|
||||
{r.avgRating != null
|
||||
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
|
||||
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* 6. Efficiency — velocity against this workspace's own median, so the
|
||||
comparison is one an operator can check. */}
|
||||
<section aria-labelledby="efficiency" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="efficiency"
|
||||
title="Hiring efficiency"
|
||||
meta={efficiency.median ? `${efficiency.median}d median time-to-hire` : 'Not enough dated hires'}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<div className="flex items-center gap-2 text-krow-blue">
|
||||
<Gauge className="h-4 w-4" aria-hidden="true" />
|
||||
<h3 className="font-heading text-body-sm font-bold text-ink-1">Velocity</h3>
|
||||
</div>
|
||||
<p className="mt-2 font-heading text-title font-bold tabular-nums text-ink-1">
|
||||
{efficiency.median ? `${efficiency.median}d` : '—'}
|
||||
<span className="ml-1.5 text-caption font-normal text-ink-3">median</span>
|
||||
</p>
|
||||
<p className="mt-1 text-caption leading-relaxed text-ink-3">
|
||||
{efficiency.within48h
|
||||
? `${efficiency.within48h}% of roles close within 48 hours.`
|
||||
: 'Velocity appears once hires carry an application date.'}
|
||||
</p>
|
||||
</Surface>
|
||||
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
|
||||
<Activity className="h-4 w-4" aria-hidden="true" />
|
||||
<h3 className="font-heading text-body-sm font-bold text-ink-1">Fastest to fill</h3>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<VelocityList items={efficiency.fastest} median={efficiency.median} tone="fast" />
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<Clock className="h-4 w-4" aria-hidden="true" />
|
||||
<h3 className="font-heading text-body-sm font-bold text-ink-1">Slowest to fill</h3>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<VelocityList items={efficiency.slowest} median={efficiency.median} tone="slow" />
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 7. What the numbers mean. Only findings the data actually supports —
|
||||
an insight list that is always the same length is decoration. */}
|
||||
<section aria-labelledby="insights" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="insights"
|
||||
title="AI hiring insights"
|
||||
meta={insights.length ? `${insights.length} finding${insights.length === 1 ? '' : 's'}` : undefined}
|
||||
/>
|
||||
{insights.length ? (
|
||||
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{insights.map((item) => <Insight key={item.title} item={item} />)}
|
||||
</ul>
|
||||
) : (
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<p className="text-body-sm leading-relaxed text-ink-3">
|
||||
There is not enough hiring on file to draw a finding from yet. Insights appear as
|
||||
applications, hires and reviews accumulate.
|
||||
</p>
|
||||
</Surface>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<SkillSurface page="analytics" placement="before-footer" />
|
||||
<UiEditor />
|
||||
<AnalyticsComposition context={context} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. */
|
||||
function AnalyticsComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
const tree = editing?.tree || composePage('analytics').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Button, SearchInput } from '@/components/ds';
|
||||
|
||||
import { useApplications, useInterviews, useJobPostings, useUpdateApplication, useHireCandidate } from '@/lib/krowHooks';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { AdminPage, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import CandidateCard from '@/components/krow/CandidateCard';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { SORTS } from '@/pages/admin/candidates/nodes';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/candidates/nodes';
|
||||
import AIInterviewModal from '@/components/krow/AIInterviewModal';
|
||||
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
|
||||
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
|
||||
@@ -19,12 +23,6 @@ const SCORE_BANDS = {
|
||||
unscored: (s) => !s,
|
||||
};
|
||||
|
||||
const SORTS = {
|
||||
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
|
||||
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime() },
|
||||
name: { label: 'Name A–Z', compare: (a, b) => a.applicant_name.localeCompare(b.applicant_name) },
|
||||
experience: { label: 'Most experience', compare: (a, b) => (b.years_experience || 0) - (a.years_experience || 0) },
|
||||
};
|
||||
|
||||
export default function AdminCandidates() {
|
||||
const { data: applications = [], isLoading } = useApplications();
|
||||
@@ -103,74 +101,21 @@ export default function AdminCandidates() {
|
||||
const postingById = useMemo(() => Object.fromEntries(postings.map(p => [p.id, p])), [postings]);
|
||||
const resolveTitle = (a) => a.job_title || postingById[a.job_posting_id]?.title || '';
|
||||
|
||||
const context = useMemo(() => ({
|
||||
applications, filtered, isLoading, isFiltered, clearFilters, positions, resolveTitle,
|
||||
search, setSearch, position, setPosition, stage, setStage, band, setBand, sort, setSort,
|
||||
handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire,
|
||||
}), [applications, filtered, isLoading, isFiltered, positions, search, position, stage, band, sort]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Candidates"
|
||||
subtitle="Review, compare and manage the candidate pipeline."
|
||||
>
|
||||
<SkillSurface page="candidates" placement="after-header" />
|
||||
<UiEditor />
|
||||
<CandidatesComposition context={context} />
|
||||
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={position} onChange={setPosition} label="Position"
|
||||
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} />
|
||||
<FilterSelect value={stage} onChange={setStage} label="Stage" options={[
|
||||
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' },
|
||||
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'interview', label: 'Interviewing' },
|
||||
{ value: 'hired', label: 'Hired' }, { value: 'rejected', label: 'Declined' },
|
||||
]} />
|
||||
<FilterSelect value={band} onChange={setBand} label="Score" options={[
|
||||
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
|
||||
{ value: 'strong', label: '60–79 Strong' }, { value: 'weak', label: 'Under 60' },
|
||||
{ value: 'unscored', label: 'Unscored' },
|
||||
]} />
|
||||
<FilterSelect value={sort} onChange={setSort} label="Sort"
|
||||
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
|
||||
</>
|
||||
}
|
||||
meta={`${filtered.length} of ${applications.length} candidates${isFiltered ? ' · filtered' : ''}`}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-body-sm text-ink-3">No candidates match your filters</p>
|
||||
{isFiltered && (
|
||||
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filtered.map((app, idx) => (
|
||||
<CandidateCard
|
||||
key={app.id}
|
||||
application={app}
|
||||
jobTitle={resolveTitle(app)}
|
||||
rank={idx + 1}
|
||||
onAction={handleAction}
|
||||
onMessage={setMessageApp}
|
||||
onCall={setMessageApp}
|
||||
onSchedule={setScheduleApp}
|
||||
onDecline={handleDecline}
|
||||
onDelete={handleDelete}
|
||||
onHire={handleHire}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SkillSurface page="candidates" placement="before-footer" />
|
||||
|
||||
{/* Modals */}
|
||||
{/* Modals — overlays rather than sections, so they stay outside the tree. */}
|
||||
{interviewApp && (
|
||||
<AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={postings.find(p => p.id === interviewApp.job_posting_id)} />
|
||||
)}
|
||||
@@ -183,3 +128,10 @@ export default function AdminCandidates() {
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. */
|
||||
function CandidatesComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
const tree = editing?.tree || composePage('candidates').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, InsightList, InsightRow,
|
||||
MetricStrip, ProgressBar, ProgressRing, Surface,
|
||||
|
||||
import { CHART_TONES,
|
||||
} from '@/components/ds';
|
||||
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { buildFacts } from '@/components/ai-assistant/insights';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/candidates-analysis/nodes';
|
||||
|
||||
/**
|
||||
* Admin Candidates Analysis — the analytical counterpart to Candidates.
|
||||
@@ -161,262 +161,24 @@ export default function AdminCandidatesAnalysis() {
|
||||
},
|
||||
].filter(Boolean), [f, skillGaps]);
|
||||
|
||||
const context = useMemo(() => ({ navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations }), [
|
||||
navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Candidates Analysis"
|
||||
subtitle="Talent supply, quality distribution and pipeline risk."
|
||||
>
|
||||
<SkillSurface page="candidates-analysis" placement="after-header" />
|
||||
<MetricStrip
|
||||
columns={5}
|
||||
items={[
|
||||
{ label: 'In pipeline', value: f.total },
|
||||
{ label: 'Scored', value: f.scored.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
|
||||
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'brand' },
|
||||
{ label: 'Avg score', value: f.avgScore || '—' },
|
||||
{ label: 'Risk flags', value: risks.length, tone: risks.length ? 'warning' : 'success' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
<section aria-labelledby="dist" className="space-y-3 lg:col-span-2">
|
||||
<SectionTitle id="dist" title="Quality distribution" meta={`${f.total} candidates`} />
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie data={bands} dataKey="value" nameKey="name" innerRadius={48} outerRadius={78} paddingAngle={2} strokeWidth={0}>
|
||||
{bands.map((b) => <Cell key={b.name} fill={b.color} />)}
|
||||
</Pie>
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="space-y-1.5">
|
||||
|
||||
{bands.map((b) => (
|
||||
<div key={b.name} className="flex items-center gap-2 text-caption">
|
||||
<span className="h-2 w-2 shrink-0 rounded-sm" style={{ background: b.color }} aria-hidden="true" />
|
||||
<span className="flex-1 text-ink-3">{b.name}</span>
|
||||
<span className="font-semibold tabular-nums text-ink-1">{b.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="strongest" className="space-y-3 lg:col-span-3">
|
||||
<SectionTitle
|
||||
id="strongest"
|
||||
title="Strongest candidates"
|
||||
meta="By AI score"
|
||||
action={{ label: 'All candidates', onClick: () => navigate('/admin/candidates') }}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
|
||||
{top.length ? top.map((c, i) => (
|
||||
<div key={c.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className="w-4 shrink-0 text-caption font-bold text-ink-4">{i + 1}</span>
|
||||
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">{c.applicant_name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{c.job_title}</p>
|
||||
</div>
|
||||
{c.ai_match_label && (
|
||||
<Badge variant="soft" size="sm" className="hidden sm:inline-flex">{c.ai_match_label}</Badge>
|
||||
)}
|
||||
<div className="w-20 shrink-0">
|
||||
<ProgressBar value={c.ai_score} tone="score" size="xs" />
|
||||
</div>
|
||||
<ProgressRing value={c.ai_score} size={30} strokeWidth={3} tone="score" />
|
||||
</div>
|
||||
)) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No scored candidates yet.</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="risk" className="space-y-3">
|
||||
<SectionTitle id="risk" title="Candidate risk" meta={risks.length ? `${risks.length} flags` : 'No flags'} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{risks.length ? (
|
||||
<InsightList>{risks.map((r, i) => <InsightRow key={i} {...r} />)}</InsightList>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
No material risk flags. Credentials, availability and interview integrity all check out.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
<p className="text-caption text-ink-4">
|
||||
Flags are questions for a human, not rejections.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Skill supply and the gaps in it, side by side: what the pool has, and
|
||||
what the open roles ask for and nobody offers. */}
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
<section aria-labelledby="skills" className="space-y-3 lg:col-span-3">
|
||||
<SectionTitle
|
||||
id="skills"
|
||||
title="Skill supply"
|
||||
meta={`Top ${skillSupply.length} across the scored pool`}
|
||||
/>
|
||||
{skillSupply.length ? (
|
||||
<ResponsiveContainer width="100%" height={Math.max(180, skillSupply.length * 30)}>
|
||||
<BarChart data={skillSupply} layout="vertical" margin={{ top: 0, right: 28, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} horizontal={false} />
|
||||
<XAxis type="number" {...AXIS_PROPS} allowDecimals={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="skill"
|
||||
{...AXIS_PROPS}
|
||||
width={128}
|
||||
tick={{ fontSize: 11, fill: CHART_TONES.axis }}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Bar dataKey="count" name="Candidates" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||
{/* Tinted by the average score of the people holding the skill,
|
||||
so breadth and quality read together. */}
|
||||
{skillSupply.map((s) => (
|
||||
<Cell
|
||||
key={s.skill}
|
||||
fill={s.avgScore >= 75 ? CHART_TONES.brand : s.avgScore >= 60 ? CHART_TONES.accentPale : CHART_TONES.mint}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
Nothing is scored yet, so there is no skill supply to read.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-caption text-ink-4">
|
||||
Bar length is how many candidates claim the skill. Darker blue means the
|
||||
people holding it average 75 or above.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="gaps" className="space-y-3 lg:col-span-2">
|
||||
<SectionTitle
|
||||
id="gaps"
|
||||
title="Skill gaps"
|
||||
meta={skillGaps.length ? `${skillGaps.length} unmet` : 'None'}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{skillGaps.length ? (
|
||||
<ul className="divide-y divide-border">
|
||||
{skillGaps.map((g) => (
|
||||
<li key={g.name} className="px-4 py-2.5">
|
||||
<p className="text-body-sm font-medium text-ink-1">{g.name}</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
|
||||
Required by {g.roles.join(', ')} — held by nobody in the scored pool.
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
Every credential an open role requires is held by someone in the scored pool.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Position fit — which roles can actually be filled from current supply. */}
|
||||
<section aria-labelledby="fit" className="space-y-3">
|
||||
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[40rem] border-collapse text-body-sm">
|
||||
<caption className="sr-only">
|
||||
Applicant supply against candidates clearing the bar, per open role
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{['Role', 'Applied', 'Qualified', 'Avg score', 'Fit'].map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positionFit.map((r) => (
|
||||
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
|
||||
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
|
||||
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>
|
||||
{r.applied}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="ml-auto w-24">
|
||||
<ProgressBar
|
||||
value={r.fit}
|
||||
tone={r.fit >= 50 ? 'success' : r.fit > 0 ? 'warning' : 'destructive'}
|
||||
size="xs"
|
||||
/>
|
||||
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{r.fit}% qualified</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* Recommendations — the actions the analysis above implies. */}
|
||||
{recommendations.length > 0 && (
|
||||
<section aria-labelledby="recommend" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="recommend"
|
||||
title="Recommendations"
|
||||
meta={`${recommendations.length} ordered by impact`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
<ol className="divide-y divide-border">
|
||||
{recommendations.map((r, i) => (
|
||||
<li key={r.title} className="flex gap-3 px-4 py-3">
|
||||
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-medium text-ink-1">{r.title}</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{r.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Surface>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section aria-labelledby="screening" className="space-y-3">
|
||||
<SectionTitle id="screening" title="Screening efficiency" />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: 'Coverage', value: `${f.standardizedPct}%`, detail: `${f.scored.length} of ${f.total} scored`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
|
||||
{ label: 'Interview completion', value: `${f.interviewCompletion}%`, detail: `${f.completedInterviews.length} of ${f.interviews.length} scored` },
|
||||
{ label: 'Quality lift', value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—', detail: 'hires vs pool average', tone: 'success' },
|
||||
].map((s) => (
|
||||
<Surface key={s.label} variant="solid" radius="lg" padding="default" elevation="xs">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-ink-4">{s.label}</p>
|
||||
<p className={`mt-1 font-heading text-title-lg font-bold tabular-nums ${s.tone === 'success' ? 'text-success' : s.tone === 'warning' ? 'text-warning' : 'text-ink-1'}`}>
|
||||
{s.value}
|
||||
</p>
|
||||
<p className="mt-0.5 text-caption text-ink-3">{s.detail}</p>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/analytics')} className="w-fit">
|
||||
Full analytics <ArrowRight aria-hidden="true" />
|
||||
</Button>
|
||||
</section>
|
||||
<SkillSurface page="candidates-analysis" placement="before-footer" />
|
||||
<UiEditor />
|
||||
<CandidatesAnalysisComposition context={context} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. */
|
||||
function CandidatesAnalysisComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
const tree = editing?.tree || composePage('candidates-analysis').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Area, Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, ResponsiveContainer,
|
||||
Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import {
|
||||
AlertTriangle, ArrowRight, Building2, CalendarCheck, CheckCircle2, ChevronRight, Clock, Download, Filter, Sparkles, Users, Zap,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
|
||||
Skeleton, StatusBadge, toast,
|
||||
import { Button, EmptyState, toast,
|
||||
} from '@/components/ds';
|
||||
import {
|
||||
useApplications, useInterviews, useJobPostings, useStaff, useUserActivity, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { buildFacts } from '@/components/ai-assistant/insights';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/control-center/nodes';
|
||||
|
||||
/**
|
||||
* Control Center — the KROW Admin command centre.
|
||||
@@ -577,6 +577,10 @@ export default function ControlCenter() {
|
||||
toast.success('Operations snapshot exported');
|
||||
};
|
||||
|
||||
const context = useMemo(() => ({ navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot }), [
|
||||
navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Control Center"
|
||||
@@ -587,397 +591,15 @@ export default function ControlCenter() {
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<SkillSurface page="control-center" placement="after-header" />
|
||||
{/* 1 ── Operations snapshot */}
|
||||
<Snapshot metrics={metrics} loading={isLoading} />
|
||||
|
||||
{/* 2 ── Hiring activity */}
|
||||
<Band
|
||||
id="cc-activity"
|
||||
title="Hiring activity"
|
||||
meta={
|
||||
RANGES[range].step === 1
|
||||
? `Applications, screening, interviews and hires · last ${RANGES[range].days} days, daily`
|
||||
: `Applications, screening, interviews and hires · last ${RANGES[range].days} days, ${RANGES[range].step}-day totals`
|
||||
}
|
||||
action={
|
||||
<SegmentedToggle
|
||||
options={Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label }))}
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
size="sm"
|
||||
ariaLabel="Hiring activity range"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="rounded-xl border border-border bg-surface px-2 py-4">
|
||||
{isLoading ? (
|
||||
<Skeleton className="mx-2 h-[272px] rounded-lg" />
|
||||
) : f.total ? (
|
||||
<ResponsiveContainer width="100%" height={272}>
|
||||
<ComposedChart data={activitySeries} margin={{ top: 4, right: 12, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="ccApplications" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={20} />
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
align="right"
|
||||
height={28}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
|
||||
/>
|
||||
|
||||
{/* Applications are the volume everything else is drawn from, so
|
||||
they take the filled area and the downstream stages are lines
|
||||
over it. */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="applications"
|
||||
name="Applications"
|
||||
stroke={CHART_TONES.brand}
|
||||
strokeWidth={2}
|
||||
fill="url(#ccApplications)"
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="screened"
|
||||
name="AI Screened"
|
||||
stroke={CHART_TONES.navy}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 3"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="interviews"
|
||||
name="Interviews"
|
||||
stroke={CHART_TONES.navy}
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.55}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
{/* Hires are sparse and are the outcome, so they take the accent
|
||||
and a bar — a line at this volume reads as flat. */}
|
||||
<Bar
|
||||
dataKey="hires"
|
||||
name="Hires"
|
||||
fill={CHART_TONES.accent}
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={14}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="px-2 py-8">
|
||||
<EmptyState
|
||||
title="No applications yet"
|
||||
description="Once candidates start applying, daily hiring activity appears here."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Band>
|
||||
|
||||
{/* 3 ── Pipeline intelligence */}
|
||||
<Band
|
||||
id="cc-pipeline"
|
||||
title="Pipeline intelligence"
|
||||
meta={`${f.total} entered · conversion and drop-off by stage`}
|
||||
>
|
||||
<PipelineFunnel
|
||||
funnel={f.funnel}
|
||||
transitions={f.transitions}
|
||||
weakest={f.bottleneck && f.bottleneck.rate < WEAK_TRANSITION ? f.bottleneck : null}
|
||||
total={f.total}
|
||||
/>
|
||||
</Band>
|
||||
|
||||
{/* 4 ── Position performance */}
|
||||
<Band
|
||||
id="cc-positions"
|
||||
title="Position performance"
|
||||
meta={strongest ? `Strongest conversion: ${strongest}` : undefined}
|
||||
action={<ViewAll label="View all positions" onClick={() => navigate('/admin/positions')} />}
|
||||
>
|
||||
<div className="rounded-xl border border-border bg-surface px-2 py-4">
|
||||
{positionPerformance.length ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={236}>
|
||||
<ComposedChart
|
||||
data={positionPerformance}
|
||||
barGap={3}
|
||||
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
{...AXIS_PROPS}
|
||||
interval={0}
|
||||
tick={{ fontSize: 10, fill: CHART_TONES.axis }}
|
||||
/>
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
align="right"
|
||||
height={28}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="applicants"
|
||||
name="Applicants"
|
||||
fill={CHART_TONES.brand}
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={24}
|
||||
/>
|
||||
<Bar dataKey="qualified" name="Qualified (70+)" radius={[3, 3, 0, 0]} maxBarSize={24}>
|
||||
{/* The best converter takes the accent at full strength and the
|
||||
worst is left pale, so both ends of the comparison are
|
||||
visible without a callout. */}
|
||||
{positionPerformance.map((r) => (
|
||||
<Cell
|
||||
key={r.name}
|
||||
fill={r.name === strongest ? CHART_TONES.accent
|
||||
: r.name === weakestRole ? CHART_TONES.mint : CHART_TONES.accentPale}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
<Bar
|
||||
dataKey="hired"
|
||||
name="Hired"
|
||||
fill={CHART_TONES.navy}
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={24}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{weakestRole && (
|
||||
<p className="px-2 pt-2 text-caption leading-relaxed text-ink-3">
|
||||
<span className="font-semibold text-ink-1">{strongest}</span> converts applicants
|
||||
into qualified candidates best;{' '}
|
||||
<span className="font-semibold text-ink-1">{weakestRole}</span> converts worst and
|
||||
is where sourcing — or the bar itself — is worth a look.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-2 py-8">
|
||||
<EmptyState
|
||||
title="No open positions"
|
||||
description="Publish a role and its hiring performance appears here."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Band>
|
||||
|
||||
{/* 5 ── Action center */}
|
||||
<Band
|
||||
id="cc-actions"
|
||||
title="Action center"
|
||||
meta={actions.length ? `${actions.length} items, most consequential first` : 'All clear'}
|
||||
>
|
||||
<ActionQueue items={actions} />
|
||||
</Band>
|
||||
|
||||
{/* 6 / 7 ── Active positions and top talent. Two lists of comparable weight,
|
||||
so they share a row rather than each taking one. */}
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Band
|
||||
id="cc-active"
|
||||
title="Active positions"
|
||||
meta={`${f.openPositions.length} hiring`}
|
||||
action={<ViewAll label="View all" onClick={() => navigate('/admin/positions')} />}
|
||||
>
|
||||
{activePositions.length ? (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||||
{activePositions.map((r) => {
|
||||
const coverage = r.applied ? Math.round((r.screened / r.applied) * 100) : 0;
|
||||
return (
|
||||
<li key={r.title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/admin/positions')}
|
||||
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
|
||||
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">
|
||||
{r.title}
|
||||
</span>
|
||||
<span className="block truncate text-caption text-ink-3">
|
||||
{r.applied} applicant{r.applied === 1 ? '' : 's'} · {r.screened} screened
|
||||
{' · '}{r.hired} hired
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Screening coverage as a small bar — the one number that
|
||||
says whether this role is actually being worked. */}
|
||||
<span className="hidden w-16 shrink-0 sm:block">
|
||||
<span className="block h-1.5 overflow-hidden rounded-full bg-surface-sunken">
|
||||
<span
|
||||
className={cn(
|
||||
'block h-full rounded-full',
|
||||
coverage === 100 ? 'bg-success'
|
||||
: coverage >= 50 ? 'bg-krow-blue' : 'bg-warning'
|
||||
)}
|
||||
style={{ width: `${Math.max(coverage, r.applied ? 4 : 0)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="mt-1 block text-right text-[10px] tabular-nums text-ink-4">
|
||||
{coverage}%
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<StatusBadge status={r.posting.status} size="sm" />
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState title="No open positions" description="Publish a role to start hiring." />
|
||||
)}
|
||||
</Band>
|
||||
|
||||
<Band
|
||||
id="cc-talent"
|
||||
title="Top talent"
|
||||
meta={topTalent.length ? 'By KROW Score' : undefined}
|
||||
action={<ViewAll label="View all" onClick={() => navigate('/admin/candidates')} />}
|
||||
>
|
||||
{topTalent.length ? (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||||
{topTalent.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/admin/candidates')}
|
||||
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
|
||||
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{/* The seeded portrait where there is one, otherwise the design
|
||||
system's initials avatar. Never an invented face. */}
|
||||
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">
|
||||
{c.applicant_name}
|
||||
</span>
|
||||
<span className="block truncate text-caption text-ink-3">{c.job_title}</span>
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
|
||||
{c.ai_score}
|
||||
</span>
|
||||
<span className="block text-[10px] text-ink-4">KROW</span>
|
||||
</span>
|
||||
|
||||
<StatusBadge status={c.status} size="sm" />
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Nobody scored yet"
|
||||
description="Screen the pipeline and the strongest candidates appear here."
|
||||
/>
|
||||
)}
|
||||
</Band>
|
||||
</div>
|
||||
|
||||
{/* 8 ── Recent activity */}
|
||||
<Band
|
||||
id="cc-recent"
|
||||
title="Recent activity"
|
||||
meta={`${activity.length} events logged`}
|
||||
action={<ViewAll label="View all" onClick={() => navigate('/admin/activity')} />}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-surface">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[36rem] border-collapse">
|
||||
<caption className="sr-only">Most recent platform activity</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{['User', 'Action', 'Entity', 'Time', 'Role'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
|
||||
h === 'Role' ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((event) => (
|
||||
<tr key={event.id} className="border-b border-border last:border-0 hover:bg-surface-subtle">
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar name={event.user_name || event.user_email} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">
|
||||
{event.user_name || '—'}
|
||||
</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{event.user_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-body-sm text-ink-2">
|
||||
{String(event.event_type).replace(/_/g, ' ')}
|
||||
</td>
|
||||
<td className="max-w-[24rem] px-4 py-2.5 text-body-sm text-ink-3">
|
||||
<span className="line-clamp-1">{event.details || '—'}</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-caption text-ink-4">
|
||||
{new Date(event.created_date).toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric',
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<Badge variant={event.account_type === 'employer' ? 'info' : 'neutral'} size="sm">
|
||||
{event.account_type || 'unknown'}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Band>
|
||||
<SkillSurface page="control-center" placement="before-footer" />
|
||||
<UiEditor />
|
||||
<ControlCenterComposition context={context} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. */
|
||||
function ControlCenterComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
const tree = editing?.tree || composePage('control-center').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Award, Building2, CalendarDays, Clock, Mail, Phone, UserCheck } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Badge, Button, DataTable, Drawer, EmptyState, FilterBar, MetricStrip, StatusBadge,
|
||||
Surface, Timeline,
|
||||
} from '@/components/ds';
|
||||
import { Mail, Phone, UserCheck } from 'lucide-react';
|
||||
import { Avatar, Badge, Drawer, StatusBadge, Surface } from '@/components/ds';
|
||||
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { hiresWithFill, summarise } from '@/lib/hiringRecords';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { DAY, RANGES, formatDate } from '@/pages/admin/hired-history/nodes';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/hired-history/nodes';
|
||||
|
||||
/**
|
||||
* Admin Hired History — the record of who was hired.
|
||||
@@ -29,20 +32,6 @@ import { hiresWithFill, summarise } from '@/lib/hiringRecords';
|
||||
* Both pages read `lib/hiringRecords.js`, so the totals cannot disagree.
|
||||
*/
|
||||
|
||||
/** Date-range windows, expressed as days back from today. */
|
||||
const RANGES = [
|
||||
{ value: 'all', label: 'Any time', days: null },
|
||||
{ value: '7', label: 'Last 7 days', days: 7 },
|
||||
{ value: '30', label: 'Last 30 days', days: 30 },
|
||||
{ value: '90', label: 'Last 90 days', days: 90 },
|
||||
];
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const formatDate = (value) => (value
|
||||
? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—');
|
||||
|
||||
/** One label/value row in the detail panel. */
|
||||
function Fact({ label, children }) {
|
||||
return (
|
||||
@@ -181,165 +170,39 @@ export default function AdminHiredHistory() {
|
||||
setFilters({ position: 'all', department: 'all', range: 'all' });
|
||||
};
|
||||
|
||||
/* What this page's sections read. Published once; the renderer passes it
|
||||
down untouched and never looks inside. */
|
||||
const context = useMemo(() => ({
|
||||
isLoading, hires, filtered, recent, summary, isFiltered,
|
||||
search, setSearch, filters, setFilters,
|
||||
positionOptions, departmentOptions, clearFilters, setSelected,
|
||||
}), [
|
||||
isLoading, hires, filtered, recent, summary, isFiltered,
|
||||
search, filters, positionOptions, departmentOptions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Hired History"
|
||||
subtitle="Who was hired, for which position, and what has happened since."
|
||||
meta={`${hires.length} on record`}
|
||||
>
|
||||
<SkillSurface page="hired-history" placement="after-header" />
|
||||
<UiEditor />
|
||||
<HiredComposition context={context} />
|
||||
|
||||
{/* 1. Search and filters — the page is a record, so finding one is the
|
||||
first thing it has to do well. */}
|
||||
<FilterBar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search by name, position, client or department…"
|
||||
filters={[
|
||||
{ key: 'position', label: 'Position', type: 'select', options: [{ value: 'all', label: 'All positions' }, ...positionOptions.map((p) => ({ value: p, label: p }))] },
|
||||
{ key: 'department', label: 'Department', type: 'select', options: [{ value: 'all', label: 'All departments' }, ...departmentOptions.map((d) => ({ value: d, label: d }))] },
|
||||
{ key: 'range', label: 'Hired', type: 'select', options: RANGES.map((r) => ({ value: r.value, label: r.label })) },
|
||||
]}
|
||||
values={filters}
|
||||
onChange={setFilters}
|
||||
/>
|
||||
|
||||
{/* 2. What the current selection contains. A count of the record, not a
|
||||
performance verdict — that reading is Analytics'. */}
|
||||
<MetricStrip
|
||||
columns={3}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: isFiltered ? 'Hires matching' : 'Total hires',
|
||||
value: summary.total,
|
||||
icon: Award,
|
||||
tone: 'brand',
|
||||
sub: isFiltered ? `of ${hires.length} on record` : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Most recent hire',
|
||||
value: recent[0] ? formatDate(recent[0].hire_date) : '—',
|
||||
icon: CalendarDays,
|
||||
sub: recent[0]?.name,
|
||||
},
|
||||
{
|
||||
label: 'Avg time-to-hire',
|
||||
value: summary.speed ? `${summary.speed}d` : '—',
|
||||
icon: Clock,
|
||||
sub: `${summary.active} still active`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 3. The chronology. */}
|
||||
{recent.length > 0 && (
|
||||
<section aria-labelledby="chronology" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="chronology"
|
||||
title="Recent hiring timeline"
|
||||
meta={`Last ${recent.length} of ${filtered.length}`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<Timeline
|
||||
items={recent.map((h, i) => ({
|
||||
id: h.id,
|
||||
title: h.name,
|
||||
description: [h.role, h.company !== '—' ? h.company : null]
|
||||
.filter(Boolean).join(' · '),
|
||||
timestamp: formatDate(h.hire_date),
|
||||
tone: i === 0 ? 'brand' : 'neutral',
|
||||
current: i === 0,
|
||||
icon: UserCheck,
|
||||
}))}
|
||||
/>
|
||||
</Surface>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 4. The records themselves — every field a hire carries, one row each. */}
|
||||
<section aria-labelledby="records" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="records"
|
||||
title="Hired candidate records"
|
||||
meta={isFiltered ? `${filtered.length} of ${hires.length}` : `${hires.length} people`}
|
||||
/>
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={filtered}
|
||||
pageSize={12}
|
||||
caption="Everyone hired, with position, client, score and time-to-hire"
|
||||
onRowClick={setSelected}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
emptyState={(
|
||||
<EmptyState
|
||||
title={isFiltered ? 'No hires match these filters' : 'No hires recorded yet'}
|
||||
description={
|
||||
isFiltered
|
||||
? 'Try a broader search, or clear the filters to see the whole record.'
|
||||
: 'Hires appear here as positions close.'
|
||||
}
|
||||
action={isFiltered
|
||||
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
columns={[
|
||||
{
|
||||
key: 'name', header: 'Candidate', sortable: true,
|
||||
cell: (h) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={h.name} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{h.name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{h.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'role', header: 'Position', sortable: true, hideBelow: 'md' },
|
||||
{
|
||||
key: 'company', header: 'Company', sortable: true, hideBelow: 'lg',
|
||||
cell: (h) => (h.company && h.company !== '—'
|
||||
? (
|
||||
<span className="inline-flex items-center gap-1.5 text-ink-2">
|
||||
<Building2 className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
<span className="truncate">{h.company}</span>
|
||||
</span>
|
||||
)
|
||||
: '—'),
|
||||
},
|
||||
{ key: 'department', header: 'Department', sortable: true, hideBelow: 'lg' },
|
||||
{
|
||||
key: 'hire_date', header: 'Hired', align: 'right', sortable: true,
|
||||
cell: (h) => (
|
||||
<span className="whitespace-nowrap text-[11px] text-ink-4">{formatDate(h.hire_date)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'score', header: 'AI score', align: 'right', sortable: true,
|
||||
cell: (h) => (h.score
|
||||
? <span className="font-semibold tabular-nums text-ink-1">{h.score}</span>
|
||||
: '—'),
|
||||
},
|
||||
{
|
||||
key: 'timeToHire', header: 'Time to hire', align: 'right', sortable: true, hideBelow: 'lg',
|
||||
cell: (h) => (h.timeToHire ? `${h.timeToHire}d` : '—'),
|
||||
},
|
||||
{
|
||||
key: 'status', header: 'Status', align: 'right',
|
||||
cell: (h) => <StatusBadge status={h.status || 'hired'} size="sm" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className={cn('text-caption text-ink-4')}>
|
||||
Select a row to open the full record.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 5. One person, in full. */}
|
||||
{/* One person, in full. An overlay rather than a section of the page, so
|
||||
it stays outside the tree — there is nothing to reorder about a
|
||||
drawer. */}
|
||||
<HireDetail hire={selected} onClose={() => setSelected(null)} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. Split out so it can read the tree the provider computed. */
|
||||
function HiredComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
/* Outside a layout — a test, a baseline capture — there is no session, and
|
||||
the page still has to draw. It falls back to the composition as shipped. */
|
||||
const tree = editing?.tree || composePage('hired-history').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
PositionCustomRequirements, PositionOverview, PositionRequirements,
|
||||
} from '@/components/krow/PositionDetails';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { UiNodeSlot } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/positions/nodes';
|
||||
import { useAssistantPanel, usePublishPageContext } from '@/components/ai-assistant';
|
||||
import { continueDraftRequest } from '@/lib/skills/draftFlow';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
@@ -1128,11 +1132,17 @@ export default function AdminPositions() {
|
||||
{isFiltered ? `${filtered.length} of ${rows.length} positions · filtered` : summary}
|
||||
</p>
|
||||
|
||||
{/* The editor for what this page composes. Positions is migrated at page
|
||||
level only, so the tree it offers is the two extension slots and
|
||||
whatever definitions have claimed them — which is exactly what a
|
||||
person needs in order to move or hide a Board card. */}
|
||||
<UiEditor />
|
||||
|
||||
{/* The list's own extension points. These render once for the page — no
|
||||
position in context — which is what a definition reporting across
|
||||
every role needs. The per-card and per-drawer placements are separate
|
||||
slots, so one skill can address the board and another one role. */}
|
||||
<SkillSurface page="positions" placement="after-position-list-summary" className="mb-4" />
|
||||
<UiNodeSlot page="positions" id="positions-extensions-summary" />
|
||||
|
||||
{/* Three columns on desktop, two on tablet, one on mobile. */}
|
||||
{isLoading ? (
|
||||
@@ -1173,7 +1183,7 @@ export default function AdminPositions() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SkillSurface page="positions" placement="after-position-list" className="mt-6" />
|
||||
<UiNodeSlot page="positions" id="positions-extensions-list" />
|
||||
|
||||
<PositionDrawer
|
||||
position={selected}
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
|
||||
} from '@/components/ds';
|
||||
import { toast } from '@/components/ds';
|
||||
import { useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { SORTS } from '@/pages/admin/talent-pool/nodes';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/talent-pool/nodes';
|
||||
|
||||
/**
|
||||
* Admin Talent Pool — talent intelligence, not a card gallery.
|
||||
*/
|
||||
|
||||
const SORTS = {
|
||||
score: { label: 'Highest score', compare: (a, b) => (b.krow_score || 0) - (a.krow_score || 0) },
|
||||
experience: { label: 'Most experience', compare: (a, b) => (b.experience_years || 0) - (a.experience_years || 0) },
|
||||
name: { label: 'Name A–Z', compare: (a, b) => a.full_name.localeCompare(b.full_name) },
|
||||
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime() },
|
||||
};
|
||||
|
||||
const BANDS = {
|
||||
all: () => true,
|
||||
@@ -30,53 +24,6 @@ const BANDS = {
|
||||
unscored: (s) => !s,
|
||||
};
|
||||
|
||||
const SEGMENT_CONFIG = {
|
||||
Elite: {
|
||||
key: 'elite',
|
||||
icon: Trophy,
|
||||
bg: 'bg-blue-50/80 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
|
||||
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
|
||||
activeBorder: 'border-blue-600 ring-2 ring-blue-500/20',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
accent: 'from-blue-500 to-indigo-500',
|
||||
},
|
||||
Excellent: {
|
||||
key: 'excellent',
|
||||
icon: Star,
|
||||
bg: 'bg-emerald-50/80 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
|
||||
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
|
||||
activeBorder: 'border-emerald-600 ring-2 ring-emerald-500/20',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
accent: 'from-emerald-500 to-teal-500',
|
||||
},
|
||||
Solid: {
|
||||
key: 'solid',
|
||||
icon: CheckCircle2,
|
||||
bg: 'bg-indigo-50/80 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
|
||||
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
|
||||
activeBorder: 'border-indigo-600 ring-2 ring-indigo-500/20',
|
||||
text: 'text-indigo-600 dark:text-indigo-400',
|
||||
accent: 'from-indigo-500 to-purple-500',
|
||||
},
|
||||
Building: {
|
||||
key: 'building',
|
||||
icon: TrendingUp,
|
||||
bg: 'bg-amber-50/80 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
|
||||
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
|
||||
activeBorder: 'border-amber-600 ring-2 ring-amber-500/20',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
accent: 'from-amber-500 to-orange-500',
|
||||
},
|
||||
Unscored: {
|
||||
key: 'unscored',
|
||||
icon: Sparkles,
|
||||
bg: 'bg-orange-50/80 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400',
|
||||
border: 'border-orange-200/50 dark:border-orange-800/40 hover:border-orange-400',
|
||||
activeBorder: 'border-orange-600 ring-2 ring-orange-500/20',
|
||||
text: 'text-orange-600 dark:text-orange-400',
|
||||
accent: 'from-orange-500 to-red-500',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AdminTalentPool() {
|
||||
const { data: profiles = [], isLoading } = useWorkerProfiles();
|
||||
@@ -159,214 +106,30 @@ export default function AdminTalentPool() {
|
||||
});
|
||||
};
|
||||
|
||||
const context = useMemo(() => ({
|
||||
profiles, segments, filtered, isLoading, isFiltered, clearFilters, saved, toggleSave,
|
||||
search, setSearch, skill, setSkill, experience, setExperience, location, setLocation,
|
||||
band, setBand, availability, setAvailability, sort, setSort,
|
||||
skills, locations, availabilities,
|
||||
}), [
|
||||
profiles, segments, filtered, isLoading, isFiltered, saved,
|
||||
search, skill, experience, location, band, availability, sort, skills, locations, availabilities,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Talent Pool"
|
||||
subtitle="Discover and manage high-potential talent for future hiring."
|
||||
>
|
||||
<SkillSurface page="talent-pool" placement="after-header" />
|
||||
{/* Segments — executive metric cards */}
|
||||
<section aria-labelledby="segments" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="segments"
|
||||
title="Talent segments"
|
||||
meta={`${profiles.length} in the pool`}
|
||||
/>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
|
||||
{segments.map((s) => {
|
||||
const cfg = SEGMENT_CONFIG[s.label] || SEGMENT_CONFIG.Elite;
|
||||
const Icon = cfg.icon;
|
||||
const isSelected = band === cfg.key;
|
||||
const empty = s.count === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.label}
|
||||
onClick={() => setBand(isSelected ? 'all' : cfg.key)}
|
||||
className={cn(
|
||||
'group relative flex flex-col justify-between cursor-pointer rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isSelected ? cfg.activeBorder : cfg.border
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{/* Header Row: Label, Hint & Icon */}
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-1">
|
||||
{s.label}
|
||||
</span>
|
||||
<span className="truncate text-[10px] font-medium text-ink-4">
|
||||
{s.hint}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg)}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main count */}
|
||||
<div className="mt-2.5 flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'font-heading text-title-xl font-bold leading-none tabular-nums',
|
||||
empty ? 'text-ink-4' : cfg.text
|
||||
)}
|
||||
>
|
||||
{s.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub pill & Accent bar */}
|
||||
<div className="mt-3.5 space-y-2">
|
||||
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
|
||||
{empty
|
||||
? 'No profiles'
|
||||
: [
|
||||
`${s.available} available`,
|
||||
s.label === 'Unscored'
|
||||
? 'needs verification'
|
||||
: s.avgExperience > 0
|
||||
? `${s.avgExperience}y avg`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
|
||||
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 border-t border-border/60 pt-3 text-caption leading-relaxed text-ink-3">
|
||||
{segments[4].count > segments[0].count + segments[1].count
|
||||
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
|
||||
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search name, role or skill" size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={skill} onChange={setSkill} label="Skills"
|
||||
options={[{ value: 'all', label: 'All skills' }, ...skills.map((s) => ({ value: s, label: s }))]} />
|
||||
<FilterSelect value={experience} onChange={setExperience} label="Experience" options={[
|
||||
{ value: 'all', label: 'Any experience' }, { value: '5plus', label: '5+ years' },
|
||||
{ value: '2to5', label: '2–5 years' }, { value: 'under2', label: 'Under 2 years' },
|
||||
]} />
|
||||
<FilterSelect value={location} onChange={setLocation} label="Location"
|
||||
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
|
||||
<FilterSelect value={band} onChange={setBand} label="Score" options={[
|
||||
{ value: 'all', label: 'All scores' }, { value: 'elite', label: 'Elite 90+' },
|
||||
{ value: 'excellent', label: 'Excellent 75+' }, { value: 'solid', label: 'Solid 60+' },
|
||||
{ value: 'building', label: 'Building' }, { value: 'unscored', label: 'Unscored' },
|
||||
]} />
|
||||
<FilterSelect value={availability} onChange={setAvailability} label="Availability"
|
||||
options={[{ value: 'all', label: 'Any availability' }, ...availabilities.map((a) => ({ value: a, label: a }))]} />
|
||||
<FilterSelect value={sort} onChange={setSort} label="Sort"
|
||||
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
|
||||
</>
|
||||
}
|
||||
meta={`${filtered.length} of ${profiles.length} talents${isFiltered ? ' · filtered' : ''}${saved.length ? ` · ${saved.length} saved` : ''}`}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={filtered}
|
||||
pageSize={15}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
caption="Talent pool ranked by career score"
|
||||
columns={[
|
||||
{
|
||||
key: 'full_name', header: 'Candidate', sortable: true,
|
||||
cell: (p) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={p.full_name} src={p.selfie_url} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{p.address || p.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role', header: 'Role', hideBelow: 'md',
|
||||
cell: (p) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-ink-2">{p.current_position || p.desired_position || '—'}</p>
|
||||
{p.desired_position && p.current_position && (
|
||||
<p className="truncate text-[10px] text-ink-4">wants {p.desired_position}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'skills', header: 'Skills', hideBelow: 'lg',
|
||||
cell: (p) => (p.skills?.length ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
|
||||
{p.skills.length > 1 && <span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>}
|
||||
</div>
|
||||
) : <span className="text-[11px] text-ink-4">—</span>),
|
||||
},
|
||||
{
|
||||
key: 'experience_years', header: 'Exp', align: 'right', sortable: true, hideBelow: 'md',
|
||||
cell: (p) => (p.experience_years ? `${p.experience_years}y` : '—'),
|
||||
},
|
||||
{
|
||||
key: 'availability', header: 'Availability', hideBelow: 'lg',
|
||||
cell: (p) => (p.availability?.length
|
||||
? <span className="text-[11px] text-ink-3">{p.availability.join(', ')}</span>
|
||||
: <Badge variant="warning" size="sm">Not set</Badge>),
|
||||
},
|
||||
{
|
||||
key: 'krow_score', header: 'Career score', align: 'right', sortable: true,
|
||||
cell: (p) => {
|
||||
const score = p.krow_score || 0;
|
||||
const bandInfo = getScoreBand(score);
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="text-right">
|
||||
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{toFICO(score)}</p>
|
||||
<p className="text-[10px] text-ink-4">{bandInfo.label}</p>
|
||||
</div>
|
||||
<ProgressRing value={score} size={26} strokeWidth={3} tone="score" label="" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions', header: '', align: 'right', width: 92,
|
||||
cell: (p) => (
|
||||
<div className="flex items-center justify-end gap-0.5" onClick={(e) => e.stopPropagation()} role="presentation">
|
||||
<IconButton
|
||||
icon={saved.includes(p.id) ? Star : Bookmark}
|
||||
label={saved.includes(p.id) ? `Remove ${p.full_name} from saved` : `Save ${p.full_name}`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleSave(p.id, p.full_name)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={UserRound}
|
||||
label={`View ${p.full_name}'s profile`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toast.info(`Opening ${p.full_name}'s KROW Identity`)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SkillSurface page="talent-pool" placement="before-footer" />
|
||||
<UiEditor />
|
||||
<TalentPoolComposition context={context} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. */
|
||||
function TalentPoolComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
const tree = editing?.tree || composePage('talent-pool').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
|
||||
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
|
||||
{shipped
|
||||
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
|
||||
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
|
||||
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Remove</>}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
@@ -310,7 +310,7 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
|
||||
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
|
||||
{shipped
|
||||
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
|
||||
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
|
||||
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Remove</>}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
@@ -330,7 +330,12 @@ export default function AdminWorkspaceAgents() {
|
||||
} = useAgents();
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
/* The default view is the agents in service. Removing an agent archives it,
|
||||
and an archived agent that stayed in the default list would make Remove
|
||||
look like it had not worked — while hiding it anywhere but a tab the
|
||||
reader can open would make it look destroyed. Archived is one click away
|
||||
and is where restoring happens. */
|
||||
const [status, setStatus] = useState('active');
|
||||
const [view, setView] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('krow_agents_view') || 'list';
|
||||
@@ -351,16 +356,35 @@ export default function AdminWorkspaceAgents() {
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const found = searchAgents(agents, query);
|
||||
return status === 'all' ? found : found.filter((a) => a.status === status);
|
||||
if (status === 'active') return found.filter((a) => a.status !== 'archived');
|
||||
return found.filter((a) => a.status === status);
|
||||
}, [agents, query, status]);
|
||||
|
||||
const activeCount = agents.filter((a) => a.status !== 'archived').length;
|
||||
const publishedCount = agents.filter((a) => a.status === 'published').length;
|
||||
const draftCount = agents.filter((a) => a.status === 'draft').length;
|
||||
const archivedCount = agents.filter((a) => a.status === 'archived').length;
|
||||
|
||||
/** Runs a lifecycle action and reports honestly when it is refused. */
|
||||
/**
|
||||
* Runs a lifecycle action and reports honestly when it is refused.
|
||||
*
|
||||
* `remove` resolves to two different operations, and the difference is the
|
||||
* whole of what makes removal safe:
|
||||
*
|
||||
* authored agent → archive. The definition is kept, under the same id,
|
||||
* with its instructions, tools and skills untouched. It
|
||||
* leaves the default list and can be restored.
|
||||
* shipped agent → remove, which deletes THIS ACCOUNT'S override row and
|
||||
* nothing else. The product's own definition takes over
|
||||
* again — that is the "Revert to shipped" wording the
|
||||
* menu already uses, and it destroys no shipped agent.
|
||||
*
|
||||
* Resolved here rather than in the menu so both the card and the table row
|
||||
* cannot drift apart on what Remove means.
|
||||
*/
|
||||
const run = async (action, agent) => {
|
||||
const fn = { publish, republish: publish, archive, restore, duplicate, remove }[action];
|
||||
const resolved = action === 'remove' && !isShipped(agent.id) ? 'archive' : action;
|
||||
const fn = { publish, republish: publish, archive, restore, duplicate, remove }[resolved];
|
||||
const result = await fn(agent.id);
|
||||
if (result?.conflict) toast.error(result.conflict.message);
|
||||
else if (result?.error) toast.error(result.error);
|
||||
@@ -368,7 +392,9 @@ export default function AdminWorkspaceAgents() {
|
||||
};
|
||||
|
||||
const onAction = (action, agent) => {
|
||||
/* Destructive and irreversible-looking actions confirm; the rest run. */
|
||||
/* Anything that takes an agent out of service confirms first, even though
|
||||
none of it is destructive — a reader should choose to stop an agent
|
||||
answering, not discover it. The rest run. */
|
||||
if (action === 'remove' || action === 'archive') {
|
||||
setConfirming({ action, agent });
|
||||
return;
|
||||
@@ -410,15 +436,15 @@ export default function AdminWorkspaceAgents() {
|
||||
<div className="inline-flex items-center rounded-xl border border-border/80 bg-surface-subtle/80 p-1 text-caption">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus('all')}
|
||||
onClick={() => setStatus('active')}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-1 font-medium transition-all',
|
||||
status === 'all'
|
||||
status === 'active'
|
||||
? 'bg-surface text-ink-1 shadow-2xs font-semibold'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
All <span className="text-ink-4 font-normal">({agents.length})</span>
|
||||
Active <span className="text-ink-4 font-normal">({activeCount})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -446,20 +472,26 @@ export default function AdminWorkspaceAgents() {
|
||||
Drafts <span className="text-ink-4 font-normal">({draftCount})</span>
|
||||
</button>
|
||||
)}
|
||||
{archivedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus('archived')}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-1 font-medium transition-all',
|
||||
status === 'archived'
|
||||
? 'bg-surface text-amber-700 shadow-2xs font-semibold'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
Archived <span className="text-ink-4 font-normal">({archivedCount})</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Always rendered, empty or not. Archived is where removing an
|
||||
agent puts it and where restoring it happens, and the remove
|
||||
confirmation says so in as many words — a tab that appears
|
||||
only once something is already in it tells a reader where
|
||||
their agent went strictly after they needed to know. The
|
||||
count answers "is anything in here?" without hiding the
|
||||
answer. Drafts stays conditional: an empty Drafts tab
|
||||
teaches nothing, because nothing is ever sent there. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus('archived')}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-1 font-medium transition-all',
|
||||
status === 'archived'
|
||||
? 'bg-surface text-amber-700 shadow-2xs font-semibold'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
Archived <span className="text-ink-4 font-normal">({archivedCount})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -546,16 +578,23 @@ export default function AdminWorkspaceAgents() {
|
||||
title={
|
||||
confirming?.action === 'archive'
|
||||
? `Archive ${confirming?.agent.name}?`
|
||||
: isShipped(confirming?.agent?.id) ? 'Revert to the shipped definition?' : `Delete ${confirming?.agent.name}?`
|
||||
: isShipped(confirming?.agent?.id) ? 'Revert to the shipped definition?' : 'Remove agent?'
|
||||
}
|
||||
description={
|
||||
confirming?.action === 'archive'
|
||||
? 'It stops answering and disappears from the switcher. Its definition is kept, and it can be restored as a draft.'
|
||||
: isShipped(confirming?.agent?.id)
|
||||
? 'Your changes to this agent are discarded and the shipped definition takes over again.'
|
||||
: 'This definition is removed. It cannot be recovered.'
|
||||
: 'This agent will be removed from the active Agents list. Its configuration and '
|
||||
+ 'skills will be preserved and it can be restored later from Archived.'
|
||||
}
|
||||
confirmLabel={
|
||||
confirming?.action === 'archive'
|
||||
? 'Archive'
|
||||
/* Reverting a shipped agent is not a removal and must not read as
|
||||
one: the shipped definition takes over, nothing is destroyed. */
|
||||
: isShipped(confirming?.agent?.id) ? 'Confirm' : 'Remove Agent'
|
||||
}
|
||||
confirmLabel={confirming?.action === 'archive' ? 'Archive' : 'Confirm'}
|
||||
busy={saving}
|
||||
onConfirm={async () => {
|
||||
const pending = confirming;
|
||||
|
||||
313
src/pages/admin/activity/nodes.jsx
Normal file
313
src/pages/admin/activity/nodes.jsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Activity as ActivityIcon, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert,
|
||||
UserCheck, UserPlus, Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
|
||||
} from '@/components/ds';
|
||||
import { SectionTitle, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* The Activity page, as addressable nodes.
|
||||
*
|
||||
* This is the first page composed through the UI node system, and it is the
|
||||
* pattern every other page follows. Nothing about the sections changed: the
|
||||
* markup below was moved here verbatim from `Activity.jsx`, which is what lets
|
||||
* the page render exactly as it did while becoming something a person can
|
||||
* reorder and hide.
|
||||
*
|
||||
* Two things are worth understanding before copying the pattern.
|
||||
*
|
||||
* **The page still owns its state.** Filters, the loading flag, the derived
|
||||
* rows — all of it stays in the page component and is published once through
|
||||
* `useUiContext`. A section reads what it needs. The renderer never looks
|
||||
* inside the bag, so it stays ignorant of what any page contains.
|
||||
*
|
||||
* **Each section is its own node type.** They are singletons — there is no
|
||||
* sense in which a page could have two audit logs — so they declare only the
|
||||
* capabilities that mean something for a built-in: `move` and `hide`. A page
|
||||
* section is not addable, removable or replaceable, and saying so in the
|
||||
* registration is what stops the engine from ever offering it.
|
||||
*/
|
||||
|
||||
/** Severity carried through to the timeline node, so the two views agree. */
|
||||
const TIMELINE_TONE = { high: 'destructive', medium: 'warning', low: 'neutral' };
|
||||
|
||||
const SEVERITY_META = {
|
||||
high: { label: 'High', variant: 'destructive' },
|
||||
medium: { label: 'Medium', variant: 'warning' },
|
||||
low: { label: 'Low', variant: 'neutral' },
|
||||
};
|
||||
|
||||
/**
|
||||
* An icon per event type. Worth the table: on a chronology the glyph is what
|
||||
* makes a run of hires distinguishable from a run of logins at a glance, which is
|
||||
* the whole reason to show a timeline rather than another list of rows.
|
||||
*/
|
||||
const EVENT_ICON = {
|
||||
hire_candidate: UserCheck,
|
||||
assign_employee: Users,
|
||||
create_position: FilePlus2,
|
||||
screen_candidate: ScanSearch,
|
||||
start_interview: Mic,
|
||||
apply_job: Send,
|
||||
signup: UserPlus,
|
||||
login: LogIn,
|
||||
logout: LogOut,
|
||||
};
|
||||
|
||||
/** The counted figures across the top. */
|
||||
function ActivitySummary() {
|
||||
const { isLoading, events, last24h, highRisk, users } = useUiContext();
|
||||
return (
|
||||
<MetricStrip
|
||||
columns={4}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{ label: 'Total events', value: events.length },
|
||||
{ label: 'Last 24 hours', value: last24h.length, tone: last24h.length ? 'default' : 'warning' },
|
||||
{ label: 'Privileged actions', value: highRisk.length, tone: 'warning', sub: 'hires and position changes' },
|
||||
{ label: 'Distinct users', value: users.length },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The privileged-action notice. Absent, rather than empty, when there are none. */
|
||||
function ActivityPrivilegedNotice({ attrs = {} }) {
|
||||
const { highRisk } = useUiContext();
|
||||
if (!highRisk.length) return null;
|
||||
|
||||
return (
|
||||
<Surface variant="solid" radius="lg" padding="sm" elevation="xs" className="border-warning/30 bg-warning-muted" {...attrs}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden="true" />
|
||||
<p className="text-body-sm text-ink-2">
|
||||
<span className="font-semibold text-ink-1">{highRisk.length} privileged actions</span> in this log —
|
||||
hires and position changes. These should always trace to a named person.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
/* The recent timeline. Deliberately above the log and deliberately not
|
||||
filtered: this answers "what just happened", which is a different
|
||||
question from the one the toolbar below exists to ask. Reading a
|
||||
chronology is also how an auditor starts — sequence first, then
|
||||
interrogate the specifics. */
|
||||
function ActivityTimeline({ attrs = {} }) {
|
||||
const { recent, byDay } = useUiContext();
|
||||
|
||||
return (
|
||||
<section aria-labelledby="timeline" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="timeline"
|
||||
title="Operational timeline"
|
||||
meta={`Most recent ${recent.length} events`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{recent.length ? (
|
||||
<div className="divide-y divide-border">
|
||||
{byDay.map(([day, dayEvents]) => (
|
||||
<div key={day} className="px-4 py-3.5">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
{day}
|
||||
</p>
|
||||
<Timeline
|
||||
compact
|
||||
items={dayEvents.map((e) => ({
|
||||
id: e.id,
|
||||
icon: EVENT_ICON[e.event_type] || ActivityIcon,
|
||||
tone: TIMELINE_TONE[e.severity],
|
||||
title: e.event_type.replace(/_/g, ' '),
|
||||
description: e.details || undefined,
|
||||
meta: `${e.user_name || e.user_email}${e.account_type ? ` · ${e.account_type}` : ''}`,
|
||||
timestamp: new Date(e.created_date).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
}),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
No activity has been logged yet.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The filterable audit log. */
|
||||
function ActivityAuditLog({ attrs = {} }) {
|
||||
const {
|
||||
isLoading, events, filtered, users, actions, isFiltered, clearFilters,
|
||||
search, setSearch, user, setUser, action, setAction, severity, setSeverity, range, setRange,
|
||||
} = useUiContext();
|
||||
|
||||
return (
|
||||
<section aria-labelledby="audit" className="space-y-3" {...attrs}>
|
||||
<SectionTitle id="audit" title="Audit log" meta={`${filtered.length} of ${events.length} events`} />
|
||||
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search events" size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={user} onChange={setUser} label="User"
|
||||
options={[{ value: 'all', label: 'All users' }, ...users.map((u) => ({ value: u, label: u }))]} />
|
||||
<FilterSelect value={action} onChange={setAction} label="Action"
|
||||
options={[{ value: 'all', label: 'All actions' }, ...actions.map((a) => ({ value: a, label: a.replace(/_/g, ' ') }))]} />
|
||||
<FilterSelect value={severity} onChange={setSeverity} label="Severity" options={[
|
||||
{ value: 'all', label: 'All severities' }, { value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' },
|
||||
]} />
|
||||
<FilterSelect value={range} onChange={setRange} label="Date" options={[
|
||||
{ value: 'all', label: 'All time' }, { value: '24h', label: 'Last 24 hours' },
|
||||
{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' },
|
||||
]} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={filtered}
|
||||
pageSize={20}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
caption="Platform audit log"
|
||||
rowClassName={(e) => (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)}
|
||||
columns={[
|
||||
{
|
||||
key: 'user_name', header: 'User', sortable: true,
|
||||
cell: (e) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={e.user_name || e.user_email} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{e.user_name || '—'}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{e.user_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'event_type', header: 'Action', sortable: true,
|
||||
cell: (e) => <span className="whitespace-nowrap">{e.event_type.replace(/_/g, ' ')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'details', header: 'Entity', hideBelow: 'md',
|
||||
cell: (e) => <span className="line-clamp-1 text-ink-3">{e.details || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'account_type', header: 'Role', hideBelow: 'lg',
|
||||
cell: (e) => <Badge variant={e.account_type === 'employer' ? 'info' : 'neutral'} size="sm">{e.account_type || 'unknown'}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'ip', header: 'Source IP', hideBelow: 'lg',
|
||||
cell: (e) => <span className="font-mono text-[11px] text-ink-4">{e.ip}</span>,
|
||||
},
|
||||
{
|
||||
key: 'created_date', header: 'Timestamp', align: 'right', sortable: true,
|
||||
sortValue: (e) => new Date(e.created_date).getTime(),
|
||||
cell: (e) => (
|
||||
<span className="whitespace-nowrap text-[11px] text-ink-4">
|
||||
{new Date(e.created_date).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity', header: 'Severity', align: 'right',
|
||||
cell: (e) => (
|
||||
<Badge variant={SEVERITY_META[e.severity].variant} size="sm">
|
||||
{SEVERITY_META[e.severity].label}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Registration ───────────────────────────────────────────────────────────
|
||||
A page section is a singleton: `move` and `hide` are the only operations that
|
||||
mean anything for one, and declaring exactly those is what makes the engine
|
||||
refuse the rest without knowing what an audit log is. */
|
||||
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'activity-summary',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'activity',
|
||||
label: 'Activity summary',
|
||||
summary: 'Total events, recent volume, privileged actions and distinct users.',
|
||||
component: ActivitySummary,
|
||||
capabilities: SECTION,
|
||||
/* `MetricStrip` destructures its props, so it cannot carry the node's
|
||||
identity itself. The renderer supplies a bare wrapper instead — no styling,
|
||||
no layout of its own, and the page's own vertical rhythm applies to it
|
||||
exactly as it applied to the grid before. */
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'activity-privileged-notice',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'activity',
|
||||
label: 'Privileged actions notice',
|
||||
summary: 'A warning banner when the log contains hires or position changes.',
|
||||
component: ActivityPrivilegedNotice,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'activity-timeline',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'activity',
|
||||
label: 'Operational timeline',
|
||||
summary: 'The most recent events, grouped by day.',
|
||||
component: ActivityTimeline,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'activity-audit-log',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'activity',
|
||||
label: 'Audit log',
|
||||
summary: 'The filterable table of every recorded event.',
|
||||
component: ActivityAuditLog,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
/**
|
||||
* The page as it ships.
|
||||
*
|
||||
* The order here is the order on screen, and the ids are the page's stable
|
||||
* addresses. `timeline` and `audit` are the ids the headings already carried in
|
||||
* the DOM, so a node's identity is continuous with what the page has always
|
||||
* said about itself rather than a second naming invented alongside it.
|
||||
*/
|
||||
registerPageComposition('activity', [
|
||||
{ id: 'activity-extensions-top', type: 'skill-surface', props: { page: 'activity', placement: 'after-header' } },
|
||||
{ id: 'activity-summary', type: 'activity-summary' },
|
||||
{ id: 'activity-privileged-notice', type: 'activity-privileged-notice' },
|
||||
{ id: 'timeline', type: 'activity-timeline' },
|
||||
{ id: 'audit', type: 'activity-audit-log' },
|
||||
{ id: 'activity-extensions-bottom', type: 'skill-surface', props: { page: 'activity', placement: 'before-footer' } },
|
||||
]);
|
||||
424
src/pages/admin/analytics/nodes.jsx
Normal file
424
src/pages/admin/analytics/nodes.jsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Activity, Award, Clock, Gauge, Lightbulb, Target, TrendingUp, TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge, MetricStrip, Surface } from '@/components/ds';
|
||||
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
|
||||
import { HiringFlow } from '@/components/charts/HiringFlow';
|
||||
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
|
||||
import { useSize } from '@/hooks/use-size';
|
||||
import { SectionTitle } from '@/components/admin/PageShell';
|
||||
|
||||
|
||||
/**
|
||||
* Analytics, as addressable nodes.
|
||||
*
|
||||
* Seven readings, moved verbatim. Each keeps the id its heading already carried
|
||||
* in the DOM, so what a person names in conversation and what the markup says
|
||||
* are the same string.
|
||||
*/
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
const INSIGHT_TONE = {
|
||||
success: { ring: 'border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20', dot: 'text-emerald-600 dark:text-emerald-400', Icon: TrendingUp },
|
||||
warning: { ring: 'border-amber-500/30 bg-amber-50/40 dark:bg-amber-950/20', dot: 'text-amber-600 dark:text-amber-400', Icon: TriangleAlert },
|
||||
risk: { ring: 'border-red-500/30 bg-red-50/40 dark:bg-red-950/20', dot: 'text-red-600 dark:text-red-400', Icon: TriangleAlert },
|
||||
info: { ring: 'border-border bg-surface', dot: 'text-krow-blue', Icon: Lightbulb },
|
||||
};
|
||||
|
||||
/** One finding, with the evidence underneath it. */
|
||||
function Insight({ item }) {
|
||||
const tone = INSIGHT_TONE[item.tone] || INSIGHT_TONE.info;
|
||||
const { Icon } = tone;
|
||||
|
||||
return (
|
||||
<li className={cn('flex items-start gap-2.5 rounded-xl border p-3.5', tone.ring)}>
|
||||
<Icon className={cn('mt-0.5 h-4 w-4 shrink-0', tone.dot)} aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-heading text-body-sm font-semibold text-ink-1">{item.title}</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The funnel, drawn once its container can be measured.
|
||||
*
|
||||
* `HiringFlow` renders an MUI bar chart with no explicit width, which means the
|
||||
* chart measures its parent on mount. On this page that first measure lands
|
||||
* before the layout has resolved a width — the shell's `main` is a `flex-1`
|
||||
* column beside the Owliver panel — and the chart warns that it has nothing to
|
||||
* size itself against. Gating on the measured width means the chart mounts once,
|
||||
* already knowing how wide it is, instead of mounting into nothing and
|
||||
* recovering.
|
||||
*
|
||||
* The reserved height keeps the section from collapsing and reflowing the page
|
||||
* on the frame between measure and draw.
|
||||
*/
|
||||
function MeasuredFunnel({ funnel }) {
|
||||
const ref = React.useRef(null);
|
||||
const size = useSize(ref);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full">
|
||||
{size?.width ? (
|
||||
<HiringFlow
|
||||
stages={funnel.stages}
|
||||
transitions={funnel.transitions}
|
||||
weakestKey={funnel.weakestKey}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[280px] rounded-xl border border-border bg-surface" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A ranked comparison row — used for both fastest and slowest to fill. */
|
||||
function VelocityList({ items, median, tone }) {
|
||||
if (!items.length) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
|
||||
No role has enough dated hires to measure velocity yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{items.map((role) => {
|
||||
const delta = median ? role.avgDays - median : 0;
|
||||
return (
|
||||
<li key={role.role} className="flex items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">{role.role}</p>
|
||||
<p className="text-[10px] text-ink-4">
|
||||
{role.count} hire{role.count === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="font-heading text-body-sm font-bold tabular-nums text-ink-1">
|
||||
{role.avgDays}d
|
||||
</span>
|
||||
{median > 0 && delta !== 0 && (
|
||||
<Badge variant={tone === 'fast' ? 'success' : 'warning'} size="sm" className="tabular-nums">
|
||||
{delta > 0 ? '+' : ''}{delta}d
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsPerformance({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="performance" className="space-y-3" {...attrs}>
|
||||
<SectionTitle id="performance" title="Hiring performance" meta="Across the workspace" />
|
||||
<MetricStrip
|
||||
columns={4}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{ label: 'Total hires', value: summary.total, icon: Award, tone: 'brand' },
|
||||
{
|
||||
label: 'Avg time-to-hire',
|
||||
value: summary.speed ? `${summary.speed}d` : '—',
|
||||
icon: Clock,
|
||||
sub: efficiency.median ? `${efficiency.median}d median` : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Quality of hire',
|
||||
value: summary.quality || '—',
|
||||
icon: TrendingUp,
|
||||
tone: summary.quality >= 80 ? 'success' : 'default',
|
||||
sub: 'avg AI score',
|
||||
},
|
||||
{
|
||||
label: 'Conversion rate',
|
||||
value: `${funnel.conversion}%`,
|
||||
icon: Target,
|
||||
sub: `${funnel.stages[4].count} of ${funnel.stages[0].count} applicants`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsFunnel({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="funnel" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="funnel"
|
||||
title="Hiring funnel"
|
||||
meta="Applied → Screened → Shortlisted → Interview → Hired"
|
||||
/>
|
||||
{funnel.stages[0].count ? (
|
||||
<MeasuredFunnel funnel={funnel} />
|
||||
) : (
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<p className="text-body-sm leading-relaxed text-ink-3">
|
||||
No applications on file yet. The funnel appears once the first candidate applies.
|
||||
</p>
|
||||
</Surface>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsTrend({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="trend" className="space-y-3" {...attrs}>
|
||||
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
|
||||
<HiringTrendChart
|
||||
points={trend}
|
||||
emptyState={(
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">
|
||||
Not enough history for a trend
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
|
||||
{hires.length
|
||||
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
|
||||
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsDepartments({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="departments" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="departments"
|
||||
title="Department performance"
|
||||
meta={`${departments.length} department${departments.length === 1 ? '' : 's'} compared`}
|
||||
/>
|
||||
<DepartmentPerformance items={departments} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsPositions({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="positions" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="positions"
|
||||
title="Position performance"
|
||||
meta={`${positions.length} role${positions.length === 1 ? '' : 's'} filled`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[36rem] border-collapse text-body-sm">
|
||||
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'whitespace-nowrap px-3.5 py-2.5 text-[10px] font-bold uppercase tracking-wider text-ink-4',
|
||||
i ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{positions.map((r) => (
|
||||
<tr key={r.role} className="border-b border-border/60 last:border-0 transition-colors hover:bg-surface-subtle/80">
|
||||
<td className="px-3.5 py-2.5 font-medium text-ink-1">{r.role}</td>
|
||||
<td className="px-3.5 py-2.5 text-right font-semibold tabular-nums text-ink-2">{r.count}</td>
|
||||
<td className="px-3.5 py-2.5 text-right font-bold tabular-nums text-blue-600 dark:text-blue-400">{r.avgScore || '—'}</td>
|
||||
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
|
||||
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
|
||||
<td className="px-3.5 py-2.5 text-right">
|
||||
{r.avgRating != null
|
||||
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
|
||||
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsEfficiency({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="efficiency" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="efficiency"
|
||||
title="Hiring efficiency"
|
||||
meta={efficiency.median ? `${efficiency.median}d median time-to-hire` : 'Not enough dated hires'}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<div className="flex items-center gap-2 text-krow-blue">
|
||||
<Gauge className="h-4 w-4" aria-hidden="true" />
|
||||
<h3 className="font-heading text-body-sm font-bold text-ink-1">Velocity</h3>
|
||||
</div>
|
||||
<p className="mt-2 font-heading text-title font-bold tabular-nums text-ink-1">
|
||||
{efficiency.median ? `${efficiency.median}d` : '—'}
|
||||
<span className="ml-1.5 text-caption font-normal text-ink-3">median</span>
|
||||
</p>
|
||||
<p className="mt-1 text-caption leading-relaxed text-ink-3">
|
||||
{efficiency.within48h
|
||||
? `${efficiency.within48h}% of roles close within 48 hours.`
|
||||
: 'Velocity appears once hires carry an application date.'}
|
||||
</p>
|
||||
</Surface>
|
||||
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
|
||||
<Activity className="h-4 w-4" aria-hidden="true" />
|
||||
<h3 className="font-heading text-body-sm font-bold text-ink-1">Fastest to fill</h3>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<VelocityList items={efficiency.fastest} median={efficiency.median} tone="fast" />
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<Clock className="h-4 w-4" aria-hidden="true" />
|
||||
<h3 className="font-heading text-body-sm font-bold text-ink-1">Slowest to fill</h3>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<VelocityList items={efficiency.slowest} median={efficiency.median} tone="slow" />
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsInsights({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="insights" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="insights"
|
||||
title="AI hiring insights"
|
||||
meta={insights.length ? `${insights.length} finding${insights.length === 1 ? '' : 's'}` : undefined}
|
||||
/>
|
||||
{insights.length ? (
|
||||
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{insights.map((item) => <Insight key={item.title} item={item} />)}
|
||||
</ul>
|
||||
) : (
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<p className="text-body-sm leading-relaxed text-ink-3">
|
||||
There is not enough hiring on file to draw a finding from yet. Insights appear as
|
||||
applications, hires and reviews accumulate.
|
||||
</p>
|
||||
</Surface>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-performance',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Hiring performance',
|
||||
component: AnalyticsPerformance,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-funnel',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Hiring funnel',
|
||||
component: AnalyticsFunnel,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-trend',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Hiring trend',
|
||||
component: AnalyticsTrend,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-departments',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Departments',
|
||||
component: AnalyticsDepartments,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-positions',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Position comparison',
|
||||
component: AnalyticsPositions,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-efficiency',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Hiring efficiency',
|
||||
component: AnalyticsEfficiency,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-insights',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Insights',
|
||||
component: AnalyticsInsights,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerPageComposition('analytics', [
|
||||
{ id: 'analytics-extensions-top', type: 'skill-surface', props: { page: 'analytics', placement: 'after-header' } },
|
||||
{ id: 'performance', type: 'analytics-performance' },
|
||||
{ id: 'funnel', type: 'analytics-funnel' },
|
||||
{ id: 'trend', type: 'analytics-trend' },
|
||||
{ id: 'departments', type: 'analytics-departments' },
|
||||
{ id: 'positions', type: 'analytics-positions' },
|
||||
{ id: 'efficiency', type: 'analytics-efficiency' },
|
||||
{ id: 'insights', type: 'analytics-insights' },
|
||||
{ id: 'analytics-extensions-bottom', type: 'skill-surface', props: { page: 'analytics', placement: 'before-footer' } },
|
||||
]);
|
||||
415
src/pages/admin/candidates-analysis/nodes.jsx
Normal file
415
src/pages/admin/candidates-analysis/nodes.jsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, InsightList, InsightRow,
|
||||
MetricStrip, ProgressBar, ProgressRing, Surface,
|
||||
} from '@/components/ds';
|
||||
import { SectionTitle } from '@/components/admin/PageShell';
|
||||
|
||||
/**
|
||||
* Admin Candidates Analysis — the analytical counterpart to Candidates.
|
||||
*
|
||||
* Candidates is for working through people one by one; this page is for reading
|
||||
* the pool as a whole: supply, quality distribution, and where the risk sits.
|
||||
* It is one of the three pages with the contextual Owliver panel, because these
|
||||
* are the questions worth asking in language rather than filters.
|
||||
*/
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* Candidates Analysis, as addressable nodes.
|
||||
*
|
||||
* Two of its readings are laid out side by side inside a grid, so each grid is
|
||||
* one node holding both — splitting them would let somebody move half a pair
|
||||
* out of its own layout.
|
||||
*/
|
||||
|
||||
function CASupply({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
<MetricStrip
|
||||
columns={5}
|
||||
items={[
|
||||
{ label: 'In pipeline', value: f.total },
|
||||
{ label: 'Scored', value: f.scored.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
|
||||
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'brand' },
|
||||
{ label: 'Avg score', value: f.avgScore || '—' },
|
||||
{ label: 'Risk flags', value: risks.length, tone: risks.length ? 'warning' : 'success' },
|
||||
]}
|
||||
/>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CADistribution({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
<section aria-labelledby="dist" className="space-y-3 lg:col-span-2">
|
||||
<SectionTitle id="dist" title="Quality distribution" meta={`${f.total} candidates`} />
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie data={bands} dataKey="value" nameKey="name" innerRadius={48} outerRadius={78} paddingAngle={2} strokeWidth={0}>
|
||||
{bands.map((b) => <Cell key={b.name} fill={b.color} />)}
|
||||
</Pie>
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="space-y-1.5">
|
||||
|
||||
{bands.map((b) => (
|
||||
<div key={b.name} className="flex items-center gap-2 text-caption">
|
||||
<span className="h-2 w-2 shrink-0 rounded-sm" style={{ background: b.color }} aria-hidden="true" />
|
||||
<span className="flex-1 text-ink-3">{b.name}</span>
|
||||
<span className="font-semibold tabular-nums text-ink-1">{b.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="strongest" className="space-y-3 lg:col-span-3">
|
||||
<SectionTitle
|
||||
id="strongest"
|
||||
title="Strongest candidates"
|
||||
meta="By AI score"
|
||||
action={{ label: 'All candidates', onClick: () => navigate('/admin/candidates') }}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
|
||||
{top.length ? top.map((c, i) => (
|
||||
<div key={c.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className="w-4 shrink-0 text-caption font-bold text-ink-4">{i + 1}</span>
|
||||
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">{c.applicant_name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{c.job_title}</p>
|
||||
</div>
|
||||
{c.ai_match_label && (
|
||||
<Badge variant="soft" size="sm" className="hidden sm:inline-flex">{c.ai_match_label}</Badge>
|
||||
)}
|
||||
<div className="w-20 shrink-0">
|
||||
<ProgressBar value={c.ai_score} tone="score" size="xs" />
|
||||
</div>
|
||||
<ProgressRing value={c.ai_score} size={30} strokeWidth={3} tone="score" />
|
||||
</div>
|
||||
)) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No scored candidates yet.</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CARisk({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="risk" className="space-y-3" {...attrs}>
|
||||
<SectionTitle id="risk" title="Candidate risk" meta={risks.length ? `${risks.length} flags` : 'No flags'} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{risks.length ? (
|
||||
<InsightList>{risks.map((r, i) => <InsightRow key={i} {...r} />)}</InsightList>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
No material risk flags. Credentials, availability and interview integrity all check out.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
<p className="text-caption text-ink-4">
|
||||
Flags are questions for a human, not rejections.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CASkills({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* Skill supply and the gaps in it, side by side: what the pool has, and
|
||||
what the open roles ask for and nobody offers. */}
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
<section aria-labelledby="skills" className="space-y-3 lg:col-span-3">
|
||||
<SectionTitle
|
||||
id="skills"
|
||||
title="Skill supply"
|
||||
meta={`Top ${skillSupply.length} across the scored pool`}
|
||||
/>
|
||||
{skillSupply.length ? (
|
||||
<ResponsiveContainer width="100%" height={Math.max(180, skillSupply.length * 30)}>
|
||||
<BarChart data={skillSupply} layout="vertical" margin={{ top: 0, right: 28, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} horizontal={false} />
|
||||
<XAxis type="number" {...AXIS_PROPS} allowDecimals={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="skill"
|
||||
{...AXIS_PROPS}
|
||||
width={128}
|
||||
tick={{ fontSize: 11, fill: CHART_TONES.axis }}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Bar dataKey="count" name="Candidates" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||
{/* Tinted by the average score of the people holding the skill,
|
||||
so breadth and quality read together. */}
|
||||
{skillSupply.map((s) => (
|
||||
<Cell
|
||||
key={s.skill}
|
||||
fill={s.avgScore >= 75 ? CHART_TONES.brand : s.avgScore >= 60 ? CHART_TONES.accentPale : CHART_TONES.mint}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
Nothing is scored yet, so there is no skill supply to read.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-caption text-ink-4">
|
||||
Bar length is how many candidates claim the skill. Darker blue means the
|
||||
people holding it average 75 or above.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="gaps" className="space-y-3 lg:col-span-2">
|
||||
<SectionTitle
|
||||
id="gaps"
|
||||
title="Skill gaps"
|
||||
meta={skillGaps.length ? `${skillGaps.length} unmet` : 'None'}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{skillGaps.length ? (
|
||||
<ul className="divide-y divide-border">
|
||||
{skillGaps.map((g) => (
|
||||
<li key={g.name} className="px-4 py-2.5">
|
||||
<p className="text-body-sm font-medium text-ink-1">{g.name}</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
|
||||
Required by {g.roles.join(', ')} — held by nobody in the scored pool.
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
Every credential an open role requires is held by someone in the scored pool.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CAFit({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* Position fit — which roles can actually be filled from current supply. */}
|
||||
<section aria-labelledby="fit" className="space-y-3" {...attrs}>
|
||||
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[40rem] border-collapse text-body-sm">
|
||||
<caption className="sr-only">
|
||||
Applicant supply against candidates clearing the bar, per open role
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{['Role', 'Applied', 'Qualified', 'Avg score', 'Fit'].map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positionFit.map((r) => (
|
||||
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
|
||||
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
|
||||
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>
|
||||
{r.applied}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="ml-auto w-24">
|
||||
<ProgressBar
|
||||
value={r.fit}
|
||||
tone={r.fit >= 50 ? 'success' : r.fit > 0 ? 'warning' : 'destructive'}
|
||||
size="xs"
|
||||
/>
|
||||
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{r.fit}% qualified</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CARecommend({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* Recommendations — the actions the analysis above implies. */}
|
||||
{recommendations.length > 0 && (
|
||||
<section aria-labelledby="recommend" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="recommend"
|
||||
title="Recommendations"
|
||||
meta={`${recommendations.length} ordered by impact`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
<ol className="divide-y divide-border">
|
||||
{recommendations.map((r, i) => (
|
||||
<li key={r.title} className="flex gap-3 px-4 py-3">
|
||||
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-medium text-ink-1">{r.title}</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{r.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Surface>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CAScreening({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="screening" className="space-y-3" {...attrs}>
|
||||
<SectionTitle id="screening" title="Screening efficiency" />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: 'Coverage', value: `${f.standardizedPct}%`, detail: `${f.scored.length} of ${f.total} scored`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
|
||||
{ label: 'Interview completion', value: `${f.interviewCompletion}%`, detail: `${f.completedInterviews.length} of ${f.interviews.length} scored` },
|
||||
{ label: 'Quality lift', value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—', detail: 'hires vs pool average', tone: 'success' },
|
||||
].map((s) => (
|
||||
<Surface key={s.label} variant="solid" radius="lg" padding="default" elevation="xs">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-ink-4">{s.label}</p>
|
||||
<p className={`mt-1 font-heading text-title-lg font-bold tabular-nums ${s.tone === 'success' ? 'text-success' : s.tone === 'warning' ? 'text-warning' : 'text-ink-1'}`}>
|
||||
{s.value}
|
||||
</p>
|
||||
<p className="mt-0.5 text-caption text-ink-3">{s.detail}</p>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/analytics')} className="w-fit">
|
||||
Full analytics <ArrowRight aria-hidden="true" />
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-supply',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Supply summary',
|
||||
component: CASupply,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-distribution',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Score distribution',
|
||||
component: CADistribution,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-risk',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Pipeline risk',
|
||||
component: CARisk,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-skills',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Skill supply and gaps',
|
||||
component: CASkills,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-fit',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Position fit',
|
||||
component: CAFit,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-recommend',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Recommendations',
|
||||
component: CARecommend,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-screening',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Screening',
|
||||
component: CAScreening,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerPageComposition('candidates-analysis', [
|
||||
{ id: 'ca-extensions-top', type: 'skill-surface', props: { page: 'candidates-analysis', placement: 'after-header' } },
|
||||
{ id: 'supply', type: 'ca-supply' },
|
||||
{ id: 'distribution', type: 'ca-distribution' },
|
||||
{ id: 'risk', type: 'ca-risk' },
|
||||
{ id: 'skills', type: 'ca-skills' },
|
||||
{ id: 'fit', type: 'ca-fit' },
|
||||
{ id: 'recommend', type: 'ca-recommend' },
|
||||
{ id: 'screening', type: 'ca-screening' },
|
||||
{ id: 'ca-extensions-bottom', type: 'skill-surface', props: { page: 'candidates-analysis', placement: 'before-footer' } },
|
||||
]);
|
||||
137
src/pages/admin/candidates/nodes.jsx
Normal file
137
src/pages/admin/candidates/nodes.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { Button, SearchInput } from '@/components/ds';
|
||||
import { Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import CandidateCard from '@/components/krow/CandidateCard';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* Candidates, as addressable nodes.
|
||||
*
|
||||
* The markup was moved here verbatim. The page keeps its state, its mutations
|
||||
* and its modals; what moved is only the part of it that is *layout*.
|
||||
*/
|
||||
|
||||
export const SORTS = {
|
||||
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
|
||||
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime() },
|
||||
name: { label: 'Name A–Z', compare: (a, b) => a.applicant_name.localeCompare(b.applicant_name) },
|
||||
experience: { label: 'Most experience', compare: (a, b) => (b.years_experience || 0) - (a.years_experience || 0) },
|
||||
};
|
||||
|
||||
function CandidatesToolbar() {
|
||||
const {
|
||||
search, setSearch, position, setPosition, stage, setStage, band, setBand, sort, setSort,
|
||||
positions, filtered, applications, isFiltered,
|
||||
} = useUiContext();
|
||||
|
||||
return (
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={position} onChange={setPosition} label="Position"
|
||||
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} />
|
||||
<FilterSelect value={stage} onChange={setStage} label="Stage" options={[
|
||||
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' },
|
||||
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'interview', label: 'Interviewing' },
|
||||
{ value: 'hired', label: 'Hired' }, { value: 'rejected', label: 'Declined' },
|
||||
]} />
|
||||
<FilterSelect value={band} onChange={setBand} label="Score" options={[
|
||||
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
|
||||
{ value: 'strong', label: '60–79 Strong' }, { value: 'weak', label: 'Under 60' },
|
||||
{ value: 'unscored', label: 'Unscored' },
|
||||
]} />
|
||||
<FilterSelect value={sort} onChange={setSort} label="Sort"
|
||||
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
|
||||
</>
|
||||
}
|
||||
meta={`${filtered.length} of ${applications.length} candidates${isFiltered ? ' · filtered' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidatesList() {
|
||||
const {
|
||||
isLoading, filtered, isFiltered, clearFilters, resolveTitle,
|
||||
handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire,
|
||||
} = useUiContext();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-body-sm text-ink-3">No candidates match your filters</p>
|
||||
{isFiltered && (
|
||||
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{filtered.map((app, idx) => (
|
||||
<CandidateCard
|
||||
key={app.id}
|
||||
application={app}
|
||||
jobTitle={resolveTitle(app)}
|
||||
rank={idx + 1}
|
||||
onAction={handleAction}
|
||||
onMessage={setMessageApp}
|
||||
onCall={setMessageApp}
|
||||
onSchedule={setScheduleApp}
|
||||
onDecline={handleDecline}
|
||||
onDelete={handleDelete}
|
||||
onHire={handleHire}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'candidates-toolbar',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates',
|
||||
label: 'Search and filters',
|
||||
summary: 'Search, filter and sort the candidate pipeline.',
|
||||
component: CandidatesToolbar,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'candidates-list',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates',
|
||||
label: 'Candidate list',
|
||||
summary: 'Every candidate matching the current filters, ranked.',
|
||||
component: CandidatesList,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerPageComposition('candidates', [
|
||||
{ id: 'candidates-extensions-top', type: 'skill-surface', props: { page: 'candidates', placement: 'after-header' } },
|
||||
{ id: 'candidates-toolbar', type: 'candidates-toolbar' },
|
||||
{ id: 'candidates-list', type: 'candidates-list' },
|
||||
{ id: 'candidates-extensions-bottom', type: 'skill-surface', props: { page: 'candidates', placement: 'before-footer' } },
|
||||
]);
|
||||
971
src/pages/admin/control-center/nodes.jsx
Normal file
971
src/pages/admin/control-center/nodes.jsx
Normal file
@@ -0,0 +1,971 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Area, Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, ResponsiveContainer,
|
||||
Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
AlertTriangle, ArrowRight, Building2, CalendarCheck, CheckCircle2, ChevronRight, Clock, Filter, Sparkles, Users, Zap,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AXIS_PROPS, Avatar, Badge, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
|
||||
Skeleton, StatusBadge,
|
||||
} from '@/components/ds';
|
||||
|
||||
|
||||
/**
|
||||
* Control Center — the KROW Admin command centre.
|
||||
*/
|
||||
|
||||
const RANGES = {
|
||||
'7d': { label: '7D', days: 7, step: 1 },
|
||||
'30d': { label: '30D', days: 30, step: 3 },
|
||||
'90d': { label: '90D', days: 90, step: 9 },
|
||||
};
|
||||
|
||||
const WEAK_TRANSITION = 60;
|
||||
|
||||
/** @param {any} props */
|
||||
function Band({ id, title, meta = '', children, action = null }) {
|
||||
return (
|
||||
<section aria-labelledby={id} className="space-y-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
|
||||
{meta && <span className="text-caption text-ink-4">{meta}</span>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewAll({ label, onClick }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-0.5 rounded text-caption font-semibold text-krow-blue transition-colors
|
||||
hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{label}
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 1. Operations snapshot ─────────────────────────────────────────────── */
|
||||
|
||||
const snapshotConfig = {
|
||||
'Open positions': {
|
||||
icon: Building2,
|
||||
bg: 'bg-indigo-50/80 dark:bg-indigo-950/50',
|
||||
iconColor: 'text-indigo-600 dark:text-indigo-400',
|
||||
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
|
||||
text: 'text-indigo-600 dark:text-indigo-400',
|
||||
accent: 'from-indigo-500 to-blue-500',
|
||||
},
|
||||
Candidates: {
|
||||
icon: Users,
|
||||
bg: 'bg-blue-50/80 dark:bg-blue-950/50',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
accent: 'from-blue-500 to-cyan-500',
|
||||
},
|
||||
'In review': {
|
||||
icon: Clock,
|
||||
bg: 'bg-amber-50/80 dark:bg-amber-950/50',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
accent: 'from-amber-500 to-orange-500',
|
||||
},
|
||||
'AI screened': {
|
||||
icon: Sparkles,
|
||||
bg: 'bg-purple-50/80 dark:bg-purple-950/50',
|
||||
iconColor: 'text-purple-600 dark:text-purple-400',
|
||||
border: 'border-purple-200/50 dark:border-purple-800/40 hover:border-purple-400',
|
||||
text: 'text-purple-600 dark:text-purple-400',
|
||||
accent: 'from-purple-500 to-indigo-500',
|
||||
},
|
||||
Hired: {
|
||||
icon: CheckCircle2,
|
||||
bg: 'bg-emerald-50/80 dark:bg-emerald-950/50',
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
accent: 'from-emerald-500 to-teal-500',
|
||||
},
|
||||
'Avg KROW score': {
|
||||
icon: Zap,
|
||||
bg: 'bg-blue-50/80 dark:bg-blue-950/50',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
accent: 'from-blue-600 to-indigo-600',
|
||||
},
|
||||
};
|
||||
|
||||
function Snapshot({ metrics, loading }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{metrics.map((m) => {
|
||||
const cfg = snapshotConfig[m.label] || snapshotConfig['Candidates'];
|
||||
const Icon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.label}
|
||||
className={cn(
|
||||
'group relative flex flex-col justify-between rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
cfg.border
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<span className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-4">
|
||||
{m.label}
|
||||
</span>
|
||||
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg, cfg.iconColor)}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-2.5 h-8 w-16 animate-pulse rounded-lg bg-surface-sunken" />
|
||||
) : (
|
||||
<div className="mt-2 flex items-baseline gap-1.5">
|
||||
<span className={cn('font-heading text-title-xl font-bold leading-none tabular-nums', cfg.text)}>
|
||||
{m.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{m.sub && (
|
||||
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
|
||||
{m.sub}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 3. Pipeline intelligence ───────────────────────────────────────────── */
|
||||
|
||||
function PipelineFunnel({ funnel, transitions, weakest, total }) {
|
||||
if (!total) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Nothing in the pipeline"
|
||||
description="Once candidates apply, stage conversion and drop-off appear here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const stageIcons = [Users, Sparkles, Filter, CalendarCheck, CheckCircle2];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* 5 Stage Funnel Cards */}
|
||||
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{funnel.map((stage, i) => {
|
||||
const transition = i === 0 ? null : transitions[i - 1];
|
||||
const isWeak = transition && weakest
|
||||
&& transition.from === weakest.from && transition.to === weakest.to;
|
||||
const share = Math.round((stage.count / total) * 100);
|
||||
const StageIcon = stageIcons[i] || Users;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stage.key}
|
||||
className={cn(
|
||||
'group relative flex flex-col justify-between rounded-xl border p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isWeak
|
||||
? 'border-amber-500/50 bg-gradient-to-br from-amber-50/70 via-surface to-amber-50/20 dark:from-amber-950/40 dark:to-surface'
|
||||
: 'border-border/80 bg-surface hover:border-blue-500/40'
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{/* Header: Stage Name & Icon */}
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[10px] font-extrabold text-ink-4 tabular-nums">0{i + 1}</span>
|
||||
<h4 className="truncate font-heading text-body-sm font-bold text-ink-1">
|
||||
{stage.label}
|
||||
</h4>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs',
|
||||
isWeak
|
||||
? 'bg-amber-500 text-white shadow-amber-500/25'
|
||||
: i === funnel.length - 1
|
||||
? 'bg-emerald-600 text-white shadow-emerald-500/25'
|
||||
: 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
|
||||
)}
|
||||
>
|
||||
<StageIcon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Candidate Count & Pool Share */}
|
||||
<div className="mt-3 flex items-baseline justify-between">
|
||||
<span className="font-heading text-title-xl font-bold leading-none tabular-nums text-ink-1">
|
||||
{stage.count}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums border',
|
||||
isWeak
|
||||
? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300'
|
||||
: 'bg-surface-subtle text-ink-3 border-border/60'
|
||||
)}
|
||||
>
|
||||
{share}% pool
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Fill Bar & Transition Metrics */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-sunken">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-500',
|
||||
isWeak
|
||||
? 'bg-gradient-to-r from-amber-500 to-orange-500'
|
||||
: i === funnel.length - 1
|
||||
? 'bg-gradient-to-r from-emerald-500 to-teal-500'
|
||||
: 'bg-gradient-to-r from-blue-600 to-indigo-600'
|
||||
)}
|
||||
style={{ width: `${Math.max(share, stage.count ? 5 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] text-ink-4 pt-0.5">
|
||||
{transition ? (
|
||||
<>
|
||||
<span className={cn(isWeak ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-ink-3')}>
|
||||
{transition.rate}% pass
|
||||
</span>
|
||||
<span>{transition.lost ? `−${transition.lost} lost` : '0 lost'}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-ink-4">Entry Stage</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Primary Pipeline Bottleneck Alert Banner */}
|
||||
{weakest && weakest.lost > 0 && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl border border-amber-500/40 bg-gradient-to-r from-amber-50/90 via-surface to-orange-50/40 dark:from-amber-950/40 dark:via-surface dark:to-orange-950/30 p-4 shadow-xs">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-amber-500 text-white shadow-md shadow-amber-500/20">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400">
|
||||
Primary Pipeline Bottleneck
|
||||
</span>
|
||||
<span className="rounded-full bg-amber-200/60 dark:bg-amber-900/60 px-2 py-0.5 text-[10px] font-bold text-amber-800 dark:text-amber-300">
|
||||
{weakest.from} → {weakest.to}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-body-sm text-ink-2">
|
||||
Only <span className="font-bold text-amber-700 dark:text-amber-400">{weakest.rate}%</span> pass through and{' '}
|
||||
<span className="font-bold text-ink-1">{weakest.lost} candidates</span> are lost at this transition. Recovering this conversion yields the highest ROI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 5. Action center ───────────────────────────────────────────────────── */
|
||||
|
||||
const SEVERITY = {
|
||||
critical: { dot: 'bg-destructive', label: 'Critical' },
|
||||
warning: { dot: 'bg-warning', label: 'Warning' },
|
||||
info: { dot: 'bg-krow-blue', label: 'For review' },
|
||||
};
|
||||
|
||||
/** One operational queue, divided rows. Not five cards in a grid. */
|
||||
function ActionQueue({ items }) {
|
||||
if (!items.length) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface px-4 py-8 text-center">
|
||||
<p className="text-body-sm text-ink-3">
|
||||
Nothing needs intervention. Every role has candidates, every applicant is scored,
|
||||
and no hire is awaiting review.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||||
{items.map((item) => (
|
||||
<li key={item.title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.onClick}
|
||||
className="group flex w-full items-center gap-3.5 px-4 py-3 text-left transition-colors
|
||||
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<span
|
||||
className={cn('h-2 w-2 shrink-0 rounded-full', SEVERITY[item.severity].dot)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{SEVERITY[item.severity].label}.</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">{item.title}</span>
|
||||
<span className="block truncate text-caption text-ink-3">{item.detail}</span>
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
|
||||
{item.metric}
|
||||
</span>
|
||||
<span className="block text-[10px] text-ink-4">{item.metricLabel}</span>
|
||||
</span>
|
||||
|
||||
<span className="hidden shrink-0 items-center gap-0.5 text-caption font-semibold text-krow-blue sm:inline-flex">
|
||||
Review
|
||||
<ChevronRight
|
||||
className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Page ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerSeries } from '@/lib/ui/series';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* Control Center, as addressable nodes.
|
||||
*
|
||||
* The largest composition in the product, and migrated last for that reason.
|
||||
* Every band was moved verbatim. Each is wrapped so it carries its node
|
||||
* identity: `Band` and `Snapshot` render their own roots and do not forward
|
||||
* unknown props, and the wrapper is a bare div that paints nothing.
|
||||
*/
|
||||
|
||||
function CCSnapshot({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 1 ── Operations snapshot */}
|
||||
<Snapshot metrics={metrics} loading={isLoading} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CCActivity({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 2 ── Hiring activity */}
|
||||
<Band
|
||||
id="cc-activity"
|
||||
title="Hiring activity"
|
||||
meta={
|
||||
RANGES[range].step === 1
|
||||
? `Applications, screening, interviews and hires · last ${RANGES[range].days} days, daily`
|
||||
: `Applications, screening, interviews and hires · last ${RANGES[range].days} days, ${RANGES[range].step}-day totals`
|
||||
}
|
||||
action={
|
||||
<SegmentedToggle
|
||||
options={Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label }))}
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
size="sm"
|
||||
ariaLabel="Hiring activity range"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="rounded-xl border border-border bg-surface px-2 py-4">
|
||||
{isLoading ? (
|
||||
<Skeleton className="mx-2 h-[272px] rounded-lg" />
|
||||
) : f.total ? (
|
||||
<ResponsiveContainer width="100%" height={272}>
|
||||
<ComposedChart data={activitySeries} margin={{ top: 4, right: 12, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="ccApplications" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={20} />
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
align="right"
|
||||
height={28}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
|
||||
/>
|
||||
|
||||
{/* Applications are the volume everything else is drawn from, so
|
||||
they take the filled area and the downstream stages are lines
|
||||
over it. */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="applications"
|
||||
name="Applications"
|
||||
stroke={CHART_TONES.brand}
|
||||
strokeWidth={2}
|
||||
fill="url(#ccApplications)"
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="screened"
|
||||
name="AI Screened"
|
||||
stroke={CHART_TONES.navy}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 3"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="interviews"
|
||||
name="Interviews"
|
||||
stroke={CHART_TONES.navy}
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.55}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
{/* Hires are sparse and are the outcome, so they take the accent
|
||||
and a bar — a line at this volume reads as flat. */}
|
||||
<Bar
|
||||
dataKey="hires"
|
||||
name="Hires"
|
||||
fill={CHART_TONES.accent}
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={14}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="px-2 py-8">
|
||||
<EmptyState
|
||||
title="No applications yet"
|
||||
description="Once candidates start applying, daily hiring activity appears here."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Band>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CCPipeline({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 3 ── Pipeline intelligence */}
|
||||
<Band
|
||||
id="cc-pipeline"
|
||||
title="Pipeline intelligence"
|
||||
meta={`${f.total} entered · conversion and drop-off by stage`}
|
||||
>
|
||||
<PipelineFunnel
|
||||
funnel={f.funnel}
|
||||
transitions={f.transitions}
|
||||
weakest={f.bottleneck && f.bottleneck.rate < WEAK_TRANSITION ? f.bottleneck : null}
|
||||
total={f.total}
|
||||
/>
|
||||
</Band>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CCPositions({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 4 ── Position performance */}
|
||||
<Band
|
||||
id="cc-positions"
|
||||
title="Position performance"
|
||||
meta={strongest ? `Strongest conversion: ${strongest}` : undefined}
|
||||
action={<ViewAll label="View all positions" onClick={() => navigate('/admin/positions')} />}
|
||||
>
|
||||
<div className="rounded-xl border border-border bg-surface px-2 py-4">
|
||||
{positionPerformance.length ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={236}>
|
||||
<ComposedChart
|
||||
data={positionPerformance}
|
||||
barGap={3}
|
||||
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
{...AXIS_PROPS}
|
||||
interval={0}
|
||||
tick={{ fontSize: 10, fill: CHART_TONES.axis }}
|
||||
/>
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
align="right"
|
||||
height={28}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="applicants"
|
||||
name="Applicants"
|
||||
fill={CHART_TONES.brand}
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={24}
|
||||
/>
|
||||
<Bar dataKey="qualified" name="Qualified (70+)" radius={[3, 3, 0, 0]} maxBarSize={24}>
|
||||
{/* The best converter takes the accent at full strength and the
|
||||
worst is left pale, so both ends of the comparison are
|
||||
visible without a callout. */}
|
||||
{positionPerformance.map((r) => (
|
||||
<Cell
|
||||
key={r.name}
|
||||
fill={r.name === strongest ? CHART_TONES.accent
|
||||
: r.name === weakestRole ? CHART_TONES.mint : CHART_TONES.accentPale}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
<Bar
|
||||
dataKey="hired"
|
||||
name="Hired"
|
||||
fill={CHART_TONES.navy}
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={24}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{weakestRole && (
|
||||
<p className="px-2 pt-2 text-caption leading-relaxed text-ink-3">
|
||||
<span className="font-semibold text-ink-1">{strongest}</span> converts applicants
|
||||
into qualified candidates best;{' '}
|
||||
<span className="font-semibold text-ink-1">{weakestRole}</span> converts worst and
|
||||
is where sourcing — or the bar itself — is worth a look.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-2 py-8">
|
||||
<EmptyState
|
||||
title="No open positions"
|
||||
description="Publish a role and its hiring performance appears here."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Band>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CCActions({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 5 ── Action center */}
|
||||
<Band
|
||||
id="cc-actions"
|
||||
title="Action center"
|
||||
meta={actions.length ? `${actions.length} items, most consequential first` : 'All clear'}
|
||||
>
|
||||
<ActionQueue items={actions} />
|
||||
</Band>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CCLists({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 6 / 7 ── Active positions and top talent. Two lists of comparable weight,
|
||||
so they share a row rather than each taking one. */}
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Band
|
||||
id="cc-active"
|
||||
title="Active positions"
|
||||
meta={`${f.openPositions.length} hiring`}
|
||||
action={<ViewAll label="View all" onClick={() => navigate('/admin/positions')} />}
|
||||
>
|
||||
{activePositions.length ? (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||||
{activePositions.map((r) => {
|
||||
const coverage = r.applied ? Math.round((r.screened / r.applied) * 100) : 0;
|
||||
return (
|
||||
<li key={r.title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/admin/positions')}
|
||||
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
|
||||
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">
|
||||
{r.title}
|
||||
</span>
|
||||
<span className="block truncate text-caption text-ink-3">
|
||||
{r.applied} applicant{r.applied === 1 ? '' : 's'} · {r.screened} screened
|
||||
{' · '}{r.hired} hired
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Screening coverage as a small bar — the one number that
|
||||
says whether this role is actually being worked. */}
|
||||
<span className="hidden w-16 shrink-0 sm:block">
|
||||
<span className="block h-1.5 overflow-hidden rounded-full bg-surface-sunken">
|
||||
<span
|
||||
className={cn(
|
||||
'block h-full rounded-full',
|
||||
coverage === 100 ? 'bg-success'
|
||||
: coverage >= 50 ? 'bg-krow-blue' : 'bg-warning'
|
||||
)}
|
||||
style={{ width: `${Math.max(coverage, r.applied ? 4 : 0)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="mt-1 block text-right text-[10px] tabular-nums text-ink-4">
|
||||
{coverage}%
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<StatusBadge status={r.posting.status} size="sm" />
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState title="No open positions" description="Publish a role to start hiring." />
|
||||
)}
|
||||
</Band>
|
||||
|
||||
<Band
|
||||
id="cc-talent"
|
||||
title="Top talent"
|
||||
meta={topTalent.length ? 'By KROW Score' : undefined}
|
||||
action={<ViewAll label="View all" onClick={() => navigate('/admin/candidates')} />}
|
||||
>
|
||||
{topTalent.length ? (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||||
{topTalent.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/admin/candidates')}
|
||||
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
|
||||
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{/* The seeded portrait where there is one, otherwise the design
|
||||
system's initials avatar. Never an invented face. */}
|
||||
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">
|
||||
{c.applicant_name}
|
||||
</span>
|
||||
<span className="block truncate text-caption text-ink-3">{c.job_title}</span>
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
|
||||
{c.ai_score}
|
||||
</span>
|
||||
<span className="block text-[10px] text-ink-4">KROW</span>
|
||||
</span>
|
||||
|
||||
<StatusBadge status={c.status} size="sm" />
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Nobody scored yet"
|
||||
description="Screen the pipeline and the strongest candidates appear here."
|
||||
/>
|
||||
)}
|
||||
</Band>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CCRecent({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 8 ── Recent activity */}
|
||||
<Band
|
||||
id="cc-recent"
|
||||
title="Recent activity"
|
||||
meta={`${activity.length} events logged`}
|
||||
action={<ViewAll label="View all" onClick={() => navigate('/admin/activity')} />}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-surface">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[36rem] border-collapse">
|
||||
<caption className="sr-only">Most recent platform activity</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{['User', 'Action', 'Entity', 'Time', 'Role'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
|
||||
h === 'Role' ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((event) => (
|
||||
<tr key={event.id} className="border-b border-border last:border-0 hover:bg-surface-subtle">
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar name={event.user_name || event.user_email} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">
|
||||
{event.user_name || '—'}
|
||||
</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{event.user_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-body-sm text-ink-2">
|
||||
{String(event.event_type).replace(/_/g, ' ')}
|
||||
</td>
|
||||
<td className="max-w-[24rem] px-4 py-2.5 text-body-sm text-ink-3">
|
||||
<span className="line-clamp-1">{event.details || '—'}</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-caption text-ink-4">
|
||||
{new Date(event.created_date).toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric',
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<Badge variant={event.account_type === 'employer' ? 'info' : 'neutral'} size="sm">
|
||||
{event.account_type || 'unknown'}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Band>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-snapshot',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Operations snapshot',
|
||||
component: CCSnapshot,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* The figures behind this page's hiring chart, named so something else can draw
|
||||
* them.
|
||||
*
|
||||
* The page already computed this array — it is the same `activitySeries` the
|
||||
* composed chart above reads, taken from the same context, unaggregated and
|
||||
* unreordered. Declaring it here is what lets a person say "show this as a bar
|
||||
* chart" and get *these* numbers rather than a new empty panel, and it is the
|
||||
* only thing that had to be added to make a built-in chart changeable at all.
|
||||
*
|
||||
* `periodic` is the honest kind: these are days in order. It is also what
|
||||
* refuses the pie.
|
||||
*/
|
||||
registerSeries({
|
||||
id: 'control-center.hiring-activity',
|
||||
label: 'Hiring activity',
|
||||
kind: 'periodic',
|
||||
measures: [
|
||||
{ key: 'applications', label: 'Applications' },
|
||||
{ key: 'screened', label: 'AI Screened' },
|
||||
{ key: 'interviews', label: 'Interviews' },
|
||||
{ key: 'hires', label: 'Hires' },
|
||||
],
|
||||
read: (context) => context?.activitySeries || [],
|
||||
emptyNote: 'No applications yet.',
|
||||
/**
|
||||
* The range control, declared on the reading rather than on the component.
|
||||
*
|
||||
* It is the reason "show this as a bar chart" used to be a downgrade: the
|
||||
* 7D/30D/90D toggle lived inside this page's own chart section, so replacing
|
||||
* the section removed a control the person had been using — and the figures
|
||||
* it governs are the very figures the replacement draws.
|
||||
*
|
||||
* Declaring it here moves it to where it actually belongs. The control does
|
||||
* not change how the series is drawn; it changes which rows the series *is*,
|
||||
* which is a property of the reading. Every visualization draws it because
|
||||
* every visualization asks the binding for it, and none of them knows what a
|
||||
* range is. `options` is the same `RANGES` table the built-in section reads,
|
||||
* so the two cannot drift apart.
|
||||
*/
|
||||
control: {
|
||||
id: 'range',
|
||||
label: 'Hiring activity range',
|
||||
options: Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label })),
|
||||
/* The page's own state, both ways. Nothing is stored here, and a page that
|
||||
stops publishing `range` stops publishing the control with it. */
|
||||
read: (context) => context?.range ?? null,
|
||||
write: (context, value) => context?.setRange?.(value),
|
||||
},
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-activity',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Hiring activity',
|
||||
component: CCActivity,
|
||||
/**
|
||||
* The one section on this page that can become something else.
|
||||
*
|
||||
* It draws a series, it says which series, and it says what that series
|
||||
* means — which is the whole of what the engine needs to offer a bar, a line
|
||||
* or an area in its place and to refuse a pie. Nothing about charts is
|
||||
* written here: the candidates are computed by the registry from these three
|
||||
* facts.
|
||||
*/
|
||||
capabilities: [...SECTION, 'replace'],
|
||||
seriesKinds: ['periodic'],
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-pipeline',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Pipeline intelligence',
|
||||
component: CCPipeline,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-positions',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Position performance',
|
||||
component: CCPositions,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-actions',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Action center',
|
||||
component: CCActions,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-lists',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Active positions and top talent',
|
||||
component: CCLists,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-recent',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Recent activity',
|
||||
component: CCRecent,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerPageComposition('control-center', [
|
||||
{ id: 'cc-extensions-top', type: 'skill-surface', props: { page: 'control-center', placement: 'after-header' } },
|
||||
{ id: 'cc-snapshot', type: 'cc-snapshot' },
|
||||
{ id: 'cc-activity', type: 'cc-activity', data: { series: 'control-center.hiring-activity' } },
|
||||
{ id: 'cc-pipeline', type: 'cc-pipeline' },
|
||||
{ id: 'cc-positions', type: 'cc-positions' },
|
||||
{ id: 'cc-actions', type: 'cc-actions' },
|
||||
{ id: 'cc-lists', type: 'cc-lists' },
|
||||
{ id: 'cc-recent', type: 'cc-recent' },
|
||||
{ id: 'cc-extensions-bottom', type: 'skill-surface', props: { page: 'control-center', placement: 'before-footer' } },
|
||||
]);
|
||||
275
src/pages/admin/hired-history/nodes.jsx
Normal file
275
src/pages/admin/hired-history/nodes.jsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import React from 'react';
|
||||
import { Award, Building2, CalendarDays, Clock, UserCheck } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Button, DataTable, EmptyState, FilterBar, MetricStrip, StatusBadge, Surface, Timeline,
|
||||
} from '@/components/ds';
|
||||
import { SectionTitle } from '@/components/admin/PageShell';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* Hired History, as addressable nodes.
|
||||
*
|
||||
* The second page composed through the UI node tree, and it follows Activity's
|
||||
* pattern exactly: the markup below was moved here verbatim, the page keeps its
|
||||
* own state and publishes it once, and each section is a singleton type that
|
||||
* declares only the operations that mean something for a built-in.
|
||||
*
|
||||
* `chronology` is the Recent Hiring Timeline. It keeps the id its heading has
|
||||
* always carried in the DOM, so a person naming it in conversation and the
|
||||
* markup on screen refer to the same thing.
|
||||
*/
|
||||
|
||||
/** Date-range windows, expressed as days back from today. */
|
||||
export const RANGES = [
|
||||
{ value: 'all', label: 'Any time', days: null },
|
||||
{ value: '7', label: 'Last 7 days', days: 7 },
|
||||
{ value: '30', label: 'Last 30 days', days: 30 },
|
||||
{ value: '90', label: 'Last 90 days', days: 90 },
|
||||
];
|
||||
|
||||
export const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const formatDate = (value) => (value
|
||||
? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—');
|
||||
|
||||
/* 1. Search and filters — the page is a record, so finding one is the
|
||||
first thing it has to do well. */
|
||||
function HiredFilters() {
|
||||
const { search, setSearch, filters, setFilters, positionOptions, departmentOptions } = useUiContext();
|
||||
return (
|
||||
<FilterBar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search by name, position, client or department…"
|
||||
filters={[
|
||||
{ key: 'position', label: 'Position', type: 'select', options: [{ value: 'all', label: 'All positions' }, ...positionOptions.map((p) => ({ value: p, label: p }))] },
|
||||
{ key: 'department', label: 'Department', type: 'select', options: [{ value: 'all', label: 'All departments' }, ...departmentOptions.map((d) => ({ value: d, label: d }))] },
|
||||
{ key: 'range', label: 'Hired', type: 'select', options: RANGES.map((r) => ({ value: r.value, label: r.label })) },
|
||||
]}
|
||||
values={filters}
|
||||
onChange={setFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* 2. What the current selection contains. A count of the record, not a
|
||||
performance verdict — that reading is Analytics'. */
|
||||
function HiredSummary() {
|
||||
const { isLoading, hires, summary, recent, isFiltered } = useUiContext();
|
||||
return (
|
||||
<MetricStrip
|
||||
columns={3}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: isFiltered ? 'Hires matching' : 'Total hires',
|
||||
value: summary.total,
|
||||
icon: Award,
|
||||
tone: 'brand',
|
||||
sub: isFiltered ? `of ${hires.length} on record` : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Most recent hire',
|
||||
value: recent[0] ? formatDate(recent[0].hire_date) : '—',
|
||||
icon: CalendarDays,
|
||||
sub: recent[0]?.name,
|
||||
},
|
||||
{
|
||||
label: 'Avg time-to-hire',
|
||||
value: summary.speed ? `${summary.speed}d` : '—',
|
||||
icon: Clock,
|
||||
sub: `${summary.active} still active`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* 3. The chronology. */
|
||||
function HiredChronology({ attrs = {} }) {
|
||||
const { recent, filtered } = useUiContext();
|
||||
if (!recent.length) return null;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="chronology" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="chronology"
|
||||
title="Recent hiring timeline"
|
||||
meta={`Last ${recent.length} of ${filtered.length}`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<Timeline
|
||||
items={recent.map((h, i) => ({
|
||||
id: h.id,
|
||||
title: h.name,
|
||||
description: [h.role, h.company !== '—' ? h.company : null]
|
||||
.filter(Boolean).join(' · '),
|
||||
timestamp: formatDate(h.hire_date),
|
||||
tone: i === 0 ? 'brand' : 'neutral',
|
||||
current: i === 0,
|
||||
icon: UserCheck,
|
||||
}))}
|
||||
/>
|
||||
</Surface>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* 4. The records themselves — every field a hire carries, one row each. */
|
||||
function HiredRecords({ attrs = {} }) {
|
||||
const { isLoading, hires, filtered, isFiltered, clearFilters, setSelected } = useUiContext();
|
||||
|
||||
return (
|
||||
<section aria-labelledby="records" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="records"
|
||||
title="Hired candidate records"
|
||||
meta={isFiltered ? `${filtered.length} of ${hires.length}` : `${hires.length} people`}
|
||||
/>
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={filtered}
|
||||
pageSize={12}
|
||||
caption="Everyone hired, with position, client, score and time-to-hire"
|
||||
onRowClick={setSelected}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
emptyState={(
|
||||
<EmptyState
|
||||
title={isFiltered ? 'No hires match these filters' : 'No hires recorded yet'}
|
||||
description={
|
||||
isFiltered
|
||||
? 'Try a broader search, or clear the filters to see the whole record.'
|
||||
: 'Hires appear here as positions close.'
|
||||
}
|
||||
action={isFiltered
|
||||
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
columns={[
|
||||
{
|
||||
key: 'name', header: 'Candidate', sortable: true,
|
||||
cell: (h) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={h.name} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{h.name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{h.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'role', header: 'Position', sortable: true, hideBelow: 'md' },
|
||||
{
|
||||
key: 'company', header: 'Company', sortable: true, hideBelow: 'lg',
|
||||
cell: (h) => (h.company && h.company !== '—'
|
||||
? (
|
||||
<span className="inline-flex items-center gap-1.5 text-ink-2">
|
||||
<Building2 className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
<span className="truncate">{h.company}</span>
|
||||
</span>
|
||||
)
|
||||
: '—'),
|
||||
},
|
||||
{ key: 'department', header: 'Department', sortable: true, hideBelow: 'lg' },
|
||||
{
|
||||
key: 'hire_date', header: 'Hired', align: 'right', sortable: true,
|
||||
cell: (h) => (
|
||||
<span className="whitespace-nowrap text-[11px] text-ink-4">{formatDate(h.hire_date)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'score', header: 'AI score', align: 'right', sortable: true,
|
||||
cell: (h) => (h.score
|
||||
? <span className="font-semibold tabular-nums text-ink-1">{h.score}</span>
|
||||
: '—'),
|
||||
},
|
||||
{
|
||||
key: 'timeToHire', header: 'Time to hire', align: 'right', sortable: true, hideBelow: 'lg',
|
||||
cell: (h) => (h.timeToHire ? `${h.timeToHire}d` : '—'),
|
||||
},
|
||||
{
|
||||
key: 'status', header: 'Status', align: 'right',
|
||||
cell: (h) => <StatusBadge status={h.status || 'hired'} size="sm" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className={cn('text-caption text-ink-4')}>
|
||||
Select a row to open the full record.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* A page section is a singleton: `move` and `hide` are the only operations that
|
||||
mean anything for one. */
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'hired-filters',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'hired-history',
|
||||
label: 'Search and filters',
|
||||
summary: 'Search across every hire, and the fields worth filtering by.',
|
||||
component: HiredFilters,
|
||||
capabilities: SECTION,
|
||||
/* `FilterBar` destructures its props, so the renderer supplies a bare wrapper
|
||||
to carry the node's identity. No styling, no layout of its own. */
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'hired-summary',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'hired-history',
|
||||
label: 'Hire summary',
|
||||
summary: 'How much of the record the current filters select.',
|
||||
component: HiredSummary,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'hired-chronology',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'hired-history',
|
||||
label: 'Recent hiring timeline',
|
||||
summary: 'The most recent hires, newest first.',
|
||||
component: HiredChronology,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'hired-records',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'hired-history',
|
||||
label: 'Hired candidate records',
|
||||
summary: 'Every hire, one row each, with position, client and score.',
|
||||
component: HiredRecords,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
/**
|
||||
* The page as it ships.
|
||||
*
|
||||
* `chronology` and `records` keep the ids their headings already carried, so a
|
||||
* node's identity is continuous with what the page has always said about
|
||||
* itself. The detail drawer is deliberately absent: it is an overlay, not a
|
||||
* section of the page, and nothing sensible would come of reordering it.
|
||||
*/
|
||||
registerPageComposition('hired-history', [
|
||||
{ id: 'hired-extensions-top', type: 'skill-surface', props: { page: 'hired-history', placement: 'after-header' } },
|
||||
{ id: 'hired-filters', type: 'hired-filters' },
|
||||
{ id: 'hired-summary', type: 'hired-summary' },
|
||||
{ id: 'chronology', type: 'hired-chronology' },
|
||||
{ id: 'records', type: 'hired-records' },
|
||||
]);
|
||||
36
src/pages/admin/positions/nodes.js
Normal file
36
src/pages/admin/positions/nodes.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
|
||||
/**
|
||||
* Positions, page level only.
|
||||
*
|
||||
* Positions has three scopes and only one of them is flat. These two slots
|
||||
* render **once for the page**, with no position in context, which is what a
|
||||
* definition reporting across every role needs — and it is where the Board
|
||||
* skill's section lives.
|
||||
*
|
||||
* The other seven slots are *instanced*: `after-position-card` renders inside
|
||||
* each card, and five more render inside the drawer for one open position, each
|
||||
* with `context={{ position }}`. A single tree cannot address those, because
|
||||
* there is no one node for them — there are as many renderings as there are
|
||||
* records. Giving them identity needs a repeating-subtree model and a decision
|
||||
* about whether a change to one card means every card; that is deliberately not
|
||||
* attempted here, and the page keeps rendering them exactly as it always has.
|
||||
*
|
||||
* So this composition is honest about being partial: two nodes, drawn where
|
||||
* they have always been drawn, addressable like anything else.
|
||||
*/
|
||||
registerPageComposition('positions', [
|
||||
{
|
||||
id: 'positions-extensions-summary',
|
||||
type: 'skill-surface',
|
||||
props: { page: 'positions', placement: 'after-position-list-summary' },
|
||||
/* The separation this slot has always had from the grid below it. */
|
||||
layout: { spacingAfter: 'md' },
|
||||
},
|
||||
{
|
||||
id: 'positions-extensions-list',
|
||||
type: 'skill-surface',
|
||||
props: { page: 'positions', placement: 'after-position-list' },
|
||||
layout: { spacingBefore: 'lg' },
|
||||
},
|
||||
]);
|
||||
332
src/pages/admin/talent-pool/nodes.jsx
Normal file
332
src/pages/admin/talent-pool/nodes.jsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import React from 'react';
|
||||
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
|
||||
} from '@/components/ds';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
import { SectionTitle, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* Talent Pool, as addressable nodes.
|
||||
*
|
||||
* Moved verbatim from the page. The directory — its heading, its toolbar and
|
||||
* its table — is one node rather than three: hiding a table and leaving its
|
||||
* title behind is not a state anybody wants, and the three move together.
|
||||
*/
|
||||
|
||||
export const SORTS = {
|
||||
score: { label: 'Highest score', compare: (a, b) => (b.krow_score || 0) - (a.krow_score || 0) },
|
||||
experience: { label: 'Most experience', compare: (a, b) => (b.experience_years || 0) - (a.experience_years || 0) },
|
||||
name: { label: 'Name A\u2013Z', compare: (a, b) => a.full_name.localeCompare(b.full_name) },
|
||||
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime() },
|
||||
};
|
||||
|
||||
export const SEGMENT_CONFIG = {
|
||||
Elite: {
|
||||
key: 'elite',
|
||||
icon: Trophy,
|
||||
bg: 'bg-blue-50/80 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
|
||||
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
|
||||
activeBorder: 'border-blue-600 ring-2 ring-blue-500/20',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
accent: 'from-blue-500 to-indigo-500',
|
||||
},
|
||||
Excellent: {
|
||||
key: 'excellent',
|
||||
icon: Star,
|
||||
bg: 'bg-emerald-50/80 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
|
||||
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
|
||||
activeBorder: 'border-emerald-600 ring-2 ring-emerald-500/20',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
accent: 'from-emerald-500 to-teal-500',
|
||||
},
|
||||
Solid: {
|
||||
key: 'solid',
|
||||
icon: CheckCircle2,
|
||||
bg: 'bg-indigo-50/80 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
|
||||
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
|
||||
activeBorder: 'border-indigo-600 ring-2 ring-indigo-500/20',
|
||||
text: 'text-indigo-600 dark:text-indigo-400',
|
||||
accent: 'from-indigo-500 to-purple-500',
|
||||
},
|
||||
Building: {
|
||||
key: 'building',
|
||||
icon: TrendingUp,
|
||||
bg: 'bg-amber-50/80 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
|
||||
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
|
||||
activeBorder: 'border-amber-600 ring-2 ring-amber-500/20',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
accent: 'from-amber-500 to-orange-500',
|
||||
},
|
||||
Unscored: {
|
||||
key: 'unscored',
|
||||
icon: Sparkles,
|
||||
bg: 'bg-orange-50/80 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400',
|
||||
border: 'border-orange-200/50 dark:border-orange-800/40 hover:border-orange-400',
|
||||
activeBorder: 'border-orange-600 ring-2 ring-orange-500/20',
|
||||
text: 'text-orange-600 dark:text-orange-400',
|
||||
accent: 'from-orange-500 to-red-500',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/* Segments — executive metric cards */
|
||||
function TalentSegments({ attrs = {} }) {
|
||||
const { profiles, segments, band, setBand } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="segments" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="segments"
|
||||
title="Talent segments"
|
||||
meta={`${profiles.length} in the pool`}
|
||||
/>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
|
||||
{segments.map((s) => {
|
||||
const cfg = SEGMENT_CONFIG[s.label] || SEGMENT_CONFIG.Elite;
|
||||
const Icon = cfg.icon;
|
||||
const isSelected = band === cfg.key;
|
||||
const empty = s.count === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.label}
|
||||
onClick={() => setBand(isSelected ? 'all' : cfg.key)}
|
||||
className={cn(
|
||||
'group relative flex flex-col justify-between cursor-pointer rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isSelected ? cfg.activeBorder : cfg.border
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{/* Header Row: Label, Hint & Icon */}
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-1">
|
||||
{s.label}
|
||||
</span>
|
||||
<span className="truncate text-[10px] font-medium text-ink-4">
|
||||
{s.hint}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg)}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main count */}
|
||||
<div className="mt-2.5 flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'font-heading text-title-xl font-bold leading-none tabular-nums',
|
||||
empty ? 'text-ink-4' : cfg.text
|
||||
)}
|
||||
>
|
||||
{s.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub pill & Accent bar */}
|
||||
<div className="mt-3.5 space-y-2">
|
||||
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
|
||||
{empty
|
||||
? 'No profiles'
|
||||
: [
|
||||
`${s.available} available`,
|
||||
s.label === 'Unscored'
|
||||
? 'needs verification'
|
||||
: s.avgExperience > 0
|
||||
? `${s.avgExperience}y avg`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
|
||||
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 border-t border-border/60 pt-3 text-caption leading-relaxed text-ink-3">
|
||||
{segments[4].count > segments[0].count + segments[1].count
|
||||
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
|
||||
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The directory: its heading, its filters and the ranked table. */
|
||||
function TalentDirectory({ attrs = {} }) {
|
||||
const {
|
||||
profiles, filtered, isLoading, isFiltered, clearFilters, saved, toggleSave,
|
||||
search, setSearch, skill, setSkill, experience, setExperience, location, setLocation,
|
||||
band, setBand, availability, setAvailability, sort, setSort,
|
||||
skills, locations, availabilities,
|
||||
} = useUiContext();
|
||||
|
||||
/* `space-y-6` because these three were direct children of the page's own
|
||||
stack and drew their separation from it. Grouping them into one node has to
|
||||
carry that rhythm inward, or the heading, the filters and the table close
|
||||
up against each other. */
|
||||
return (
|
||||
<div className="space-y-6" {...attrs}>
|
||||
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search name, role or skill" size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={skill} onChange={setSkill} label="Skills"
|
||||
options={[{ value: 'all', label: 'All skills' }, ...skills.map((s) => ({ value: s, label: s }))]} />
|
||||
<FilterSelect value={experience} onChange={setExperience} label="Experience" options={[
|
||||
{ value: 'all', label: 'Any experience' }, { value: '5plus', label: '5+ years' },
|
||||
{ value: '2to5', label: '2–5 years' }, { value: 'under2', label: 'Under 2 years' },
|
||||
]} />
|
||||
<FilterSelect value={location} onChange={setLocation} label="Location"
|
||||
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
|
||||
<FilterSelect value={band} onChange={setBand} label="Score" options={[
|
||||
{ value: 'all', label: 'All scores' }, { value: 'elite', label: 'Elite 90+' },
|
||||
{ value: 'excellent', label: 'Excellent 75+' }, { value: 'solid', label: 'Solid 60+' },
|
||||
{ value: 'building', label: 'Building' }, { value: 'unscored', label: 'Unscored' },
|
||||
]} />
|
||||
<FilterSelect value={availability} onChange={setAvailability} label="Availability"
|
||||
options={[{ value: 'all', label: 'Any availability' }, ...availabilities.map((a) => ({ value: a, label: a }))]} />
|
||||
<FilterSelect value={sort} onChange={setSort} label="Sort"
|
||||
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
|
||||
</>
|
||||
}
|
||||
meta={`${filtered.length} of ${profiles.length} talents${isFiltered ? ' · filtered' : ''}${saved.length ? ` · ${saved.length} saved` : ''}`}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={filtered}
|
||||
pageSize={15}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
caption="Talent pool ranked by career score"
|
||||
columns={[
|
||||
{
|
||||
key: 'full_name', header: 'Candidate', sortable: true,
|
||||
cell: (p) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={p.full_name} src={p.selfie_url} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{p.address || p.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role', header: 'Role', hideBelow: 'md',
|
||||
cell: (p) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-ink-2">{p.current_position || p.desired_position || '—'}</p>
|
||||
{p.desired_position && p.current_position && (
|
||||
<p className="truncate text-[10px] text-ink-4">wants {p.desired_position}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'skills', header: 'Skills', hideBelow: 'lg',
|
||||
cell: (p) => (p.skills?.length ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
|
||||
{p.skills.length > 1 && <span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>}
|
||||
</div>
|
||||
) : <span className="text-[11px] text-ink-4">—</span>),
|
||||
},
|
||||
{
|
||||
key: 'experience_years', header: 'Exp', align: 'right', sortable: true, hideBelow: 'md',
|
||||
cell: (p) => (p.experience_years ? `${p.experience_years}y` : '—'),
|
||||
},
|
||||
{
|
||||
key: 'availability', header: 'Availability', hideBelow: 'lg',
|
||||
cell: (p) => (p.availability?.length
|
||||
? <span className="text-[11px] text-ink-3">{p.availability.join(', ')}</span>
|
||||
: <Badge variant="warning" size="sm">Not set</Badge>),
|
||||
},
|
||||
{
|
||||
key: 'krow_score', header: 'Career score', align: 'right', sortable: true,
|
||||
cell: (p) => {
|
||||
const score = p.krow_score || 0;
|
||||
const bandInfo = getScoreBand(score);
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="text-right">
|
||||
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{toFICO(score)}</p>
|
||||
<p className="text-[10px] text-ink-4">{bandInfo.label}</p>
|
||||
</div>
|
||||
<ProgressRing value={score} size={26} strokeWidth={3} tone="score" label="" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions', header: '', align: 'right', width: 92,
|
||||
cell: (p) => (
|
||||
<div className="flex items-center justify-end gap-0.5" onClick={(e) => e.stopPropagation()} role="presentation">
|
||||
<IconButton
|
||||
icon={saved.includes(p.id) ? Star : Bookmark}
|
||||
label={saved.includes(p.id) ? `Remove ${p.full_name} from saved` : `Save ${p.full_name}`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleSave(p.id, p.full_name)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={UserRound}
|
||||
label={`View ${p.full_name}'s profile`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toast.info(`Opening ${p.full_name}'s KROW Identity`)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'talent-segments',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'talent-pool',
|
||||
label: 'Talent segments',
|
||||
summary: 'The pool split into score bands, each a filter.',
|
||||
component: TalentSegments,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'talent-directory',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'talent-pool',
|
||||
label: 'Talent directory',
|
||||
summary: 'Every profile, filtered and ranked by career score.',
|
||||
component: TalentDirectory,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerPageComposition('talent-pool', [
|
||||
{ id: 'talent-extensions-top', type: 'skill-surface', props: { page: 'talent-pool', placement: 'after-header' } },
|
||||
{ id: 'segments', type: 'talent-segments' },
|
||||
{ id: 'directory', type: 'talent-directory' },
|
||||
{ id: 'talent-extensions-bottom', type: 'skill-surface', props: { page: 'talent-pool', placement: 'before-footer' } },
|
||||
]);
|
||||
82
src/skills/owliver/create-employee-role.md
Normal file
82
src/skills/owliver/create-employee-role.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: create-employee-role
|
||||
name: Create Employee Role
|
||||
description: Record what a worker does — their role, experience, pay and availability — by answering a few questions in the chat.
|
||||
pages:
|
||||
- talent-pool
|
||||
- positions
|
||||
status: active
|
||||
version: 1
|
||||
prompt: Create an employee role
|
||||
flow: employee-role
|
||||
triggers:
|
||||
- create an employee role
|
||||
- create employee role
|
||||
- create employee roles
|
||||
- add an employee role
|
||||
- add employee role
|
||||
- new employee role
|
||||
- create a worker role
|
||||
- create worker role
|
||||
- record a role for
|
||||
- add a worker role
|
||||
actions:
|
||||
- create_employee_role
|
||||
---
|
||||
|
||||
# Create Employee Role
|
||||
|
||||
## Purpose
|
||||
|
||||
Record a worker's declared professional role without leaving the page. Owliver
|
||||
asks one question at a time, offers the answers as chips, and reads the whole
|
||||
thing back before anything is written.
|
||||
|
||||
**This is not Create Position, and the difference is the point.** A position is
|
||||
what the ORGANIZATION needs filled — a company, a title, a pay range it will
|
||||
pay. An employee role is what a WORKER says they do — the role they present
|
||||
themselves as, the experience they have, and the pay they are looking for. The
|
||||
two share a vocabulary and nothing else: "3 years" on a position is a minimum an
|
||||
applicant must clear, and the same words here are what this person has.
|
||||
|
||||
They are never joined by a column. Supply and demand meet through applications,
|
||||
which already carry the funnel, the interview and the outcome.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Understand requests to record what a worker does.
|
||||
- Ask who the role is for, and resolve the answer to a real worker profile.
|
||||
- Read the role, experience, English level, certifications, desired pay and
|
||||
availability out of a single sentence.
|
||||
- Ask only for what the request did not already answer.
|
||||
- Offer each answer as a suggestion, so the whole flow can be clicked.
|
||||
- Read the role back for confirmation before recording it.
|
||||
|
||||
## Conversation
|
||||
|
||||
Each line is `field | question | suggestions | required?`. Suggestions beginning
|
||||
with `@` come from the application's own data.
|
||||
|
||||
This creates a NEW employee. The name is not looked up — an organization may
|
||||
employ several people who share one, so a name selects nobody — and the email is
|
||||
asked for outright and never derived from the name, from the operator's account
|
||||
or from an earlier conversation. The email is the identity: `worker_profiles`
|
||||
carries UNIQUE (org_id, email), so the database decides whether this person
|
||||
already exists.
|
||||
|
||||
Recording another role for somebody who is already on file is a different
|
||||
request and is not this flow.
|
||||
|
||||
- worker_name | What is the new employee’s full name? | | required
|
||||
- worker_email | What is their email address? | | required
|
||||
- role_category | What role do they work as? | @roles | required
|
||||
- experience_years | How much experience do they have? | No experience; 1 year; 2 years; 3+ years | optional
|
||||
- english_level | What is their English level? | @english | optional
|
||||
- certifications | Any certifications they hold? | @certifications; None | optional
|
||||
- desired_pay | What pay are they looking for? | $18–$28/hr; $25–$35/hr; $30–$40/hr; Custom | optional
|
||||
- availability | When are they available? | @availability | optional
|
||||
- notes | Anything else worth recording? | | optional
|
||||
|
||||
## Actions
|
||||
|
||||
- create_employee_role
|
||||
@@ -56,7 +56,12 @@ Each line is `field | question | suggestions | required?`. Suggestions beginning
|
||||
with `@` come from the application's own data, so a role category added in the
|
||||
form is offered here without this file changing.
|
||||
|
||||
- company | Which client is this role for? Type the company name. | | required
|
||||
`@companies` is the clients this organization already staffs for, read off the
|
||||
postings already on screen. Picking one is a tap; typing a name that is not on
|
||||
the list is how a new client is named, which is all "create a client" has ever
|
||||
meant here — the company is a field on the position, not a record of its own.
|
||||
|
||||
- company | Which client is this role for? | @companies | required
|
||||
- role_category | What role are you hiring for? | @roles | required
|
||||
- location | Where will this role be based? | Chennai; Bengaluru; Coimbatore; Bay Area; Other | required
|
||||
- pay | What is the pay range? | $18–$28/hr; $25–$35/hr; $30–$40/hr; Custom | required
|
||||
|
||||
Reference in New Issue
Block a user