Skip to content

Commit 37f9ddb

Browse files
authored
Merge pull request #47 from lossless-group/feature/entity-card-edit-remove
Every entry gets an undo — ✎/× edit & remove affordances on the entity cards
2 parents 686cdf9 + f1de953 commit 37f9ddb

16 files changed

Lines changed: 963 additions & 77 deletions

apps/org-workbench/src/AddAffiliationInline.svelte

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,31 @@
11
<script lang="ts">
2-
// Promote a bio-page link to an affiliation — AddPersonInline's gate
3-
// pattern INVERTED: the person is fixed (the link row's owner), the ORG is
4-
// being resolved. Seeded from the link's hostname, candidates come from
5-
// resolver.search (whose D4 clause matches domains[*].domain), and the gate
6-
// is ALWAYS shown: pick an existing org or explicitly create a thin one
7-
// (name + the bio's domain, so it stays domain-matchable). The edge + its
8-
// affiliated_with observation come from person.affiliate with the bio URL
9-
// as source — the link row keeps its identity role; this adds the two
10-
// facts it was silently dropping.
11-
// Per context-v/issues/Person-Bio-Pages-Are-Affiliation-Signals-Not-Just-Identity-Links.md.
2+
// Resolve an org and affiliate a fixed person with it — AddPersonInline's
3+
// gate pattern INVERTED. Two doors share it:
4+
// 1. Bio-link promotion (entry set): seeded from the link's hostname,
5+
// candidates via resolver.search's D4 domain clause, the bio URL as
6+
// the write's source. Per context-v/issues/Person-Bio-Pages-Are-
7+
// Affiliation-Signals-Not-Just-Identity-Links.md.
8+
// 2. Manual re-affiliation (entry null): the "+ affiliate with another
9+
// org" action on the person card — no seed, the operator types the
10+
// org name and finds/creates. Per the Entity-Card-Edit-And-Remove-
11+
// Affordances spec's affiliation extension (the Marla Blow case).
12+
// Either way the gate is ALWAYS shown: pick an existing org or explicitly
13+
// create a thin, domain-matchable one.
1214
1315
import { searchOrgs, affiliatePerson } from './lib/org-client';
1416
import type { OrgSuggestion, ShapedLink } from './lib/types';
1517
1618
let {
1719
person_uuid,
1820
personName,
19-
entry,
21+
entry = null,
2022
client,
2123
onadded,
2224
oncancel,
2325
}: {
2426
person_uuid: string;
2527
personName: string;
26-
entry: ShapedLink;
28+
entry?: ShapedLink | null;
2729
client: string;
2830
onadded: () => void;
2931
oncancel: () => void;
@@ -37,7 +39,7 @@
3739
}
3840
}
3941
40-
const domain = $derived(hostOf(entry.url));
42+
const domain = $derived(entry ? hostOf(entry.url) : '');
4143
4244
let orgName = $state('');
4345
let role = $state('');
@@ -53,6 +55,7 @@
5355
});
5456
5557
async function find(q: string) {
58+
if (!q.trim()) return;
5659
error = null;
5760
try {
5861
candidates = await searchOrgs(q, client);
@@ -76,7 +79,7 @@
7679
org_domain: action === 'create' ? domain : undefined,
7780
role: role.trim() || null,
7881
client,
79-
source: entry.url,
82+
source: entry?.url ?? 'org-workbench-manual',
8083
});
8184
onadded(); // parent bumps + dispatches augment-it:entity-updated
8285
} catch (err) {
@@ -88,8 +91,12 @@
8891

8992
<div class="ow-addperson">
9093
<p class="ow-gate-note">
91-
Promote <strong>{domain || entry.url}</strong> to an affiliation for {personName} — pick the
92-
org this bio lives on, or create it:
94+
{#if entry}
95+
Promote <strong>{domain || entry.url}</strong> to an affiliation for {personName} — pick the
96+
org this bio lives on, or create it:
97+
{:else}
98+
Affiliate <strong>{personName}</strong> with another organization — find it by name, or create it:
99+
{/if}
93100
</p>
94101

95102
{#if phase === 'gate'}
@@ -105,8 +112,8 @@
105112
{/each}
106113
</ul>
107114
{:else if searched}
108-
<p class="ow-gate-note">No existing org matches “{domain}”.</p>
109-
{:else}
115+
<p class="ow-gate-note">No existing org matches “{orgName.trim() || domain}”.</p>
116+
{:else if entry}
110117
<p class="ow-gate-note">looking for orgs matching “{domain}”…</p>
111118
{/if}
112119

apps/org-workbench/src/AdditiveList.svelte

Lines changed: 113 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
// Generic additive list — the org card's repeated organ. Renders shaped
33
// entries (kind badge · name-or-host+path · date) with an inline ➕ form that
44
// hands the URL (+ optional kind, + optional name when nameable) to a
5-
// caller-supplied add function. Entries are additive: no delete — canonical
6-
// writes are append + dedup server-side. When the caller supplies onedit,
7-
// an entry's kind/name become patchable in place (✎ or the kind badge) —
8-
// fields on an entry are correctable; the entry itself is still additive.
9-
// Busy/error states are localized to this list; a failed add or edit never
10-
// disturbs the sibling lists.
5+
// caller-supplied add function. When the caller supplies onedit, an entry's
6+
// url/kind/name become patchable in place (✎ or the kind badge); onremove
7+
// adds the × with an inline confirm — the correction half of view-and-edit-
8+
// in-place, per context-v/specs/Entity-Card-Edit-And-Remove-Affordances.md.
9+
// The micro-buttons are hover/focus-revealed so rows rest quiet.
10+
// Busy/error states are localized to this list; a failed add, edit, or
11+
// remove never disturbs the sibling lists.
1112
1213
import type { ShapedLink } from './lib/types';
1314
@@ -22,6 +23,8 @@
2223
onsearch,
2324
oncrawl,
2425
onedit,
26+
onremove,
27+
removenote,
2528
entryaction,
2629
}: {
2730
title: string;
@@ -35,9 +38,15 @@
3538
// Optional 🤖 — didi's crawl for this whole list (v1.2): header-level,
3639
// because the list (not one entry) is the crawl's subject.
3740
oncrawl?: () => void;
38-
// Optional per-entry patch (kind/name matched by URL server-side) —
39-
// presence turns on the in-place editor.
40-
onedit?: (entry: Entry, patch: { kind?: string; name?: string }) => Promise<void>;
41+
// Optional per-entry patch (url/kind/name matched by current URL
42+
// server-side) — presence turns on the in-place editor.
43+
onedit?: (entry: Entry, patch: { url?: string; kind?: string; name?: string }) => Promise<void>;
44+
// Optional per-entry remove (matched by URL server-side) — presence
45+
// turns on the × with its inline confirm.
46+
onremove?: (entry: Entry) => Promise<void>;
47+
// Optional caller-supplied caution shown inside the remove confirm
48+
// (e.g. "this stream fed 3 corpus items — they stay").
49+
removenote?: (entry: Entry) => string | null;
4150
// Optional per-entry action (Phase 5 — "scan" on pulse streams).
4251
entryaction?: { label: string; fn: (entry: Entry) => void };
4352
} = $props();
@@ -52,11 +61,31 @@
5261
5362
// In-place editor — one row at a time, keyed by the entry's URL.
5463
let editUrl = $state<string | null>(null);
64+
let editNewUrl = $state('');
5565
let editKind = $state('');
5666
let editName = $state('');
5767
let editBusy = $state(false);
5868
let editError = $state<string | null>(null);
5969
70+
// Remove confirm — one row at a time, keyed by the entry's URL.
71+
let removeUrl = $state<string | null>(null);
72+
let removeBusy = $state(false);
73+
let removeError = $state<string | null>(null);
74+
75+
async function commitRemove(entry: Entry) {
76+
if (!onremove) return;
77+
removeBusy = true;
78+
removeError = null;
79+
try {
80+
await onremove(entry);
81+
removeUrl = null;
82+
} catch (err) {
83+
removeError = err instanceof Error ? err.message : String(err);
84+
} finally {
85+
removeBusy = false;
86+
}
87+
}
88+
6089
async function submit(e: SubmitEvent) {
6190
e.preventDefault();
6291
const trimmed = url.trim();
@@ -80,9 +109,11 @@
80109
81110
function startEdit(entry: Entry) {
82111
editUrl = entry.url;
112+
editNewUrl = entry.url;
83113
editKind = entry.kind;
84114
editName = entry.name ?? '';
85115
editError = null;
116+
removeUrl = null;
86117
}
87118
88119
function abortEdit() {
@@ -93,12 +124,14 @@
93124
async function commitEdit(e: SubmitEvent, entry: Entry) {
94125
e.preventDefault();
95126
if (!onedit) return;
96-
const patch: { kind?: string; name?: string } = {};
127+
const patch: { url?: string; kind?: string; name?: string } = {};
128+
const u = editNewUrl.trim();
97129
const k = editKind.trim();
98130
const n = editName.trim();
131+
if (u && u !== entry.url) patch.url = u;
99132
if (k && k !== entry.kind) patch.kind = k;
100133
if (n && n !== (entry.name ?? '')) patch.name = n;
101-
if (!patch.kind && !patch.name) {
134+
if (!patch.url && !patch.kind && !patch.name) {
102135
abortEdit();
103136
return;
104137
}
@@ -197,6 +230,14 @@
197230
<li class="ow-entry">
198231
{#if onedit && editUrl === e.url}
199232
<form class="ow-add" onsubmit={(ev) => commitEdit(ev, e)}>
233+
<input
234+
class="ow-add-url"
235+
type="url"
236+
placeholder="https://…"
237+
bind:value={editNewUrl}
238+
onkeydown={onEditKey}
239+
disabled={editBusy}
240+
/>
200241
<input
201242
class="ow-add-kind"
202243
type="text"
@@ -237,22 +278,50 @@
237278
<span class="ow-kind">{e.kind}</span>
238279
{/if}
239280
<a class="ow-url" href={e.url} target="_blank" rel="noreferrer">{e.name ?? display(e.url)}</a>
240-
{#if onedit}
241-
<button
242-
type="button"
243-
class="ow-entry-action"
244-
title="edit kind{nameable ? ' / name' : ''}"
245-
onclick={() => startEdit(e)}
246-
>
247-
248-
</button>
281+
{#if onremove && removeUrl === e.url}
282+
<span class="ow-remove-confirm">
283+
remove?{#if removenote?.(e)}&nbsp;<em class="ow-remove-note">{removenote(e)}</em>{/if}
284+
<button
285+
type="button"
286+
class="ow-add-go ow-remove-yes"
287+
disabled={removeBusy}
288+
onclick={() => commitRemove(e)}
289+
>
290+
{removeBusy ? '' : 'yes'}
291+
</button>
292+
<button type="button" class="ow-add-go" disabled={removeBusy} onclick={() => (removeUrl = null)}>
293+
keep
294+
</button>
295+
</span>
296+
{:else}
297+
{#if onedit}
298+
<button
299+
type="button"
300+
class="ow-entry-action ow-micro"
301+
title="edit url / kind{nameable ? ' / name' : ''}"
302+
onclick={() => startEdit(e)}
303+
>
304+
305+
</button>
306+
{/if}
307+
{#if onremove}
308+
<button
309+
type="button"
310+
class="ow-entry-action ow-micro"
311+
title="remove from {title}"
312+
onclick={() => (removeUrl = e.url)}
313+
>
314+
×
315+
</button>
316+
{/if}
249317
{/if}
250318
{#if entryaction}
251319
<button type="button" class="ow-entry-action" onclick={() => entryaction.fn(e)}>
252320
{entryaction.label}
253321
</button>
254322
{/if}
255323
<span class="ow-date">{(e.added_at ?? '').slice(0, 10)}</span>
324+
{#if removeError && removeUrl === e.url}<div class="ow-error">{removeError}</div>{/if}
256325
{/if}
257326
</li>
258327
{/each}
@@ -272,4 +341,28 @@
272341
.ow-kind-editable:hover {
273342
border-color: currentColor;
274343
}
344+
/* ✎/× rest invisible so rows stay quiet; hover or keyboard focus reveals
345+
them (spec D5). entryaction buttons ("scan") stay always-visible. */
346+
.ow-entry .ow-micro {
347+
opacity: 0;
348+
transition: opacity 0.1s ease;
349+
}
350+
.ow-entry:hover .ow-micro,
351+
.ow-entry:focus-within .ow-micro {
352+
opacity: 1;
353+
}
354+
.ow-remove-confirm {
355+
display: inline-flex;
356+
align-items: center;
357+
gap: 0.35rem;
358+
font-size: 0.72rem;
359+
color: var(--color-text-muted, #9aa0aa);
360+
}
361+
.ow-remove-note {
362+
font-style: italic;
363+
}
364+
.ow-remove-yes {
365+
border-color: var(--color-error-text, #f3a3a3);
366+
color: var(--color-error-text, #f3a3a3);
367+
}
275368
</style>

0 commit comments

Comments
 (0)