|
| 1 | +<script lang="ts"> |
| 2 | + // Related organizations — the parent/child/peer section of the org card. |
| 3 | + // Three groups (Part of / Contains / Peers), each row click-through-able: |
| 4 | + // walking the Koch constellation edge by edge is the payoff interaction. |
| 5 | + // Add relates to EXISTING orgs only — creation stays doored through the |
| 6 | + // header's gated + New organization (OrgCreateInline). |
| 7 | + // Per context-v/plans/Org-Relations-Parent-Child-Peer-Plus-Org-Tags.md §2.1. |
| 8 | +
|
| 9 | + import OrgSearch from './OrgSearch.svelte'; |
| 10 | + import { fetchOrgRelations, relateOrg, unrelateOrg, patchOrgRelation } from './lib/org-client'; |
| 11 | + import type { OrgRelations, OrgRelKind, OrgSuggestion, RelatedOrg } from './lib/types'; |
| 12 | +
|
| 13 | + let { |
| 14 | + org_slug, |
| 15 | + client, |
| 16 | + onopen, |
| 17 | + }: { |
| 18 | + org_slug: string; |
| 19 | + client: string; |
| 20 | + onopen: (slug: string) => void; |
| 21 | + } = $props(); |
| 22 | +
|
| 23 | + // Open vocabulary — datalist suggestions, never enum-enforced. |
| 24 | + const KIND_SUGGESTIONS = [ |
| 25 | + 'initiative_of', |
| 26 | + 'fund_of', |
| 27 | + 'program_of', |
| 28 | + 'agency_of', |
| 29 | + 'chapter_of', |
| 30 | + 'funds', |
| 31 | + 'partners_with', |
| 32 | + ]; |
| 33 | +
|
| 34 | + let relations = $state<OrgRelations>({ parents: [], children: [], peers: [] }); |
| 35 | + let loading = $state(false); |
| 36 | + let error = $state<string | null>(null); |
| 37 | +
|
| 38 | + let adding = $state(false); |
| 39 | + let picked = $state<OrgSuggestion | null>(null); |
| 40 | + let addRel = $state<OrgRelKind>('parent'); |
| 41 | + let addKind = $state(''); |
| 42 | + let addDescription = $state(''); |
| 43 | + let busy = $state(false); |
| 44 | + let note = $state<string | null>(null); |
| 45 | +
|
| 46 | + let editingSlug = $state<string | null>(null); |
| 47 | + let editRel = $state<OrgRelKind>('parent'); |
| 48 | + let editKind = $state(''); |
| 49 | + let editDescription = $state(''); |
| 50 | + let pendingRemove = $state<RelatedOrg | null>(null); |
| 51 | +
|
| 52 | + const total = $derived( |
| 53 | + relations.parents.length + relations.children.length + relations.peers.length, |
| 54 | + ); |
| 55 | +
|
| 56 | + async function load() { |
| 57 | + loading = true; |
| 58 | + error = null; |
| 59 | + try { |
| 60 | + relations = await fetchOrgRelations(org_slug, client); |
| 61 | + } catch (err) { |
| 62 | + error = err instanceof Error ? err.message : String(err); |
| 63 | + } finally { |
| 64 | + loading = false; |
| 65 | + } |
| 66 | + } |
| 67 | +
|
| 68 | + // Fresh org card → fresh relations, and drop any in-flight edit state. |
| 69 | + $effect(() => { |
| 70 | + void org_slug; |
| 71 | + adding = false; |
| 72 | + picked = null; |
| 73 | + editingSlug = null; |
| 74 | + pendingRemove = null; |
| 75 | + note = null; |
| 76 | + void load(); |
| 77 | + }); |
| 78 | +
|
| 79 | + async function commitAdd(e: SubmitEvent) { |
| 80 | + e.preventDefault(); |
| 81 | + if (!picked) return; |
| 82 | + busy = true; |
| 83 | + error = null; |
| 84 | + try { |
| 85 | + const { created } = await relateOrg({ |
| 86 | + org_slug, |
| 87 | + other_slug: picked.slug, |
| 88 | + rel: addRel, |
| 89 | + kind: addKind.trim() || null, |
| 90 | + description: addDescription.trim() || null, |
| 91 | + client, |
| 92 | + }); |
| 93 | + note = created ? null : 'already related — edit the existing relation instead'; |
| 94 | + adding = false; |
| 95 | + picked = null; |
| 96 | + addKind = ''; |
| 97 | + addDescription = ''; |
| 98 | + await load(); |
| 99 | + } catch (err) { |
| 100 | + error = err instanceof Error ? err.message : String(err); |
| 101 | + } finally { |
| 102 | + busy = false; |
| 103 | + } |
| 104 | + } |
| 105 | +
|
| 106 | + function startEdit(r: RelatedOrg) { |
| 107 | + editingSlug = r.slug; |
| 108 | + editRel = r.rel; |
| 109 | + editKind = r.kind ?? ''; |
| 110 | + editDescription = r.description ?? ''; |
| 111 | + pendingRemove = null; |
| 112 | + } |
| 113 | +
|
| 114 | + async function commitEdit(e: SubmitEvent) { |
| 115 | + e.preventDefault(); |
| 116 | + if (!editingSlug) return; |
| 117 | + busy = true; |
| 118 | + error = null; |
| 119 | + try { |
| 120 | + await patchOrgRelation({ |
| 121 | + org_slug, |
| 122 | + other_slug: editingSlug, |
| 123 | + rel: editRel, |
| 124 | + kind: editKind.trim() || null, |
| 125 | + description: editDescription.trim() || null, |
| 126 | + client, |
| 127 | + }); |
| 128 | + editingSlug = null; |
| 129 | + await load(); |
| 130 | + } catch (err) { |
| 131 | + error = err instanceof Error ? err.message : String(err); |
| 132 | + } finally { |
| 133 | + busy = false; |
| 134 | + } |
| 135 | + } |
| 136 | +
|
| 137 | + async function commitRemove() { |
| 138 | + if (!pendingRemove) return; |
| 139 | + busy = true; |
| 140 | + error = null; |
| 141 | + try { |
| 142 | + await unrelateOrg({ org_slug, other_slug: pendingRemove.slug, client }); |
| 143 | + pendingRemove = null; |
| 144 | + await load(); |
| 145 | + } catch (err) { |
| 146 | + error = err instanceof Error ? err.message : String(err); |
| 147 | + } finally { |
| 148 | + busy = false; |
| 149 | + } |
| 150 | + } |
| 151 | +</script> |
| 152 | + |
| 153 | +{#snippet relRow(r: RelatedOrg)} |
| 154 | + <li class="ro-row"> |
| 155 | + {#if editingSlug === r.slug} |
| 156 | + <form class="ow-add ro-edit" onsubmit={commitEdit}> |
| 157 | + <span class="ro-edit-name">{r.display_name}</span> |
| 158 | + <select class="ow-add-kind" bind:value={editRel} disabled={busy}> |
| 159 | + <option value="parent">parent of this org</option> |
| 160 | + <option value="child">child of this org</option> |
| 161 | + <option value="peer">peer</option> |
| 162 | + </select> |
| 163 | + <input class="ow-add-kind" type="text" list="ro-kinds" placeholder="kind (initiative_of/…)" bind:value={editKind} disabled={busy} /> |
| 164 | + <input class="ow-add-url" type="text" placeholder="description (free text)" bind:value={editDescription} disabled={busy} /> |
| 165 | + <button type="submit" class="ow-add-go" disabled={busy}>{busy ? '…' : 'Save'}</button> |
| 166 | + <button type="button" class="ow-add-go" onclick={() => (editingSlug = null)} disabled={busy}>×</button> |
| 167 | + </form> |
| 168 | + {:else} |
| 169 | + <button type="button" class="ro-open" title="open {r.slug} in the workbench" onclick={() => onopen(r.slug)}> |
| 170 | + {r.display_name} |
| 171 | + </button> |
| 172 | + {#if r.kind}<span class="ro-kind">{r.kind}</span>{/if} |
| 173 | + {#if r.description}<span class="ro-desc">{r.description}</span>{/if} |
| 174 | + <span class="ro-actions"> |
| 175 | + <button type="button" class="ow-entry-action ow-micro" title="edit relation" onclick={() => startEdit(r)}>✎</button> |
| 176 | + <button type="button" class="ow-entry-action ow-micro" title="remove relation" onclick={() => (pendingRemove = r)}>×</button> |
| 177 | + </span> |
| 178 | + {/if} |
| 179 | + </li> |
| 180 | +{/snippet} |
| 181 | + |
| 182 | +<section class="ro-section"> |
| 183 | + <header class="ow-list-head"> |
| 184 | + <h3 class="ow-list-title">Related organizations{#if !loading} <span class="ow-list-count">{total}</span>{/if}</h3> |
| 185 | + <span class="ow-list-actions"> |
| 186 | + <button |
| 187 | + type="button" |
| 188 | + class="ow-plus" |
| 189 | + title="relate an existing organization (parent / child / peer)" |
| 190 | + onclick={() => { adding = !adding; note = null; }} |
| 191 | + > |
| 192 | + {adding ? '×' : '+'} |
| 193 | + </button> |
| 194 | + </span> |
| 195 | + </header> |
| 196 | + |
| 197 | + {#if note}<p class="ow-empty">{note}</p>{/if} |
| 198 | + {#if error}<div class="ow-error">{error}</div>{/if} |
| 199 | + |
| 200 | + {#if adding} |
| 201 | + <div class="ro-add"> |
| 202 | + {#if picked} |
| 203 | + <form class="ow-add" onsubmit={commitAdd}> |
| 204 | + <span class="ro-picked">{picked.complete_name ?? picked.conventional_name ?? picked.slug}</span> |
| 205 | + <select class="ow-add-kind" bind:value={addRel} disabled={busy}> |
| 206 | + <option value="parent">is the parent of this org</option> |
| 207 | + <option value="child">is a child of this org</option> |
| 208 | + <option value="peer">is a peer</option> |
| 209 | + </select> |
| 210 | + <input class="ow-add-kind" type="text" list="ro-kinds" placeholder="kind (initiative_of/…)" bind:value={addKind} disabled={busy} /> |
| 211 | + <input class="ow-add-url" type="text" placeholder="description (free text — the context humans hold)" bind:value={addDescription} disabled={busy} /> |
| 212 | + <button type="submit" class="ow-add-go" disabled={busy}>{busy ? '…' : 'Relate'}</button> |
| 213 | + <button type="button" class="ow-add-go" onclick={() => (picked = null)} disabled={busy}>×</button> |
| 214 | + </form> |
| 215 | + {:else} |
| 216 | + <OrgSearch {client} onpick={(s) => (picked = s)} /> |
| 217 | + <p class="ow-empty">relates to existing organizations only — create missing orgs via “+ New organization” first</p> |
| 218 | + {/if} |
| 219 | + </div> |
| 220 | + {/if} |
| 221 | + |
| 222 | + {#if loading} |
| 223 | + <p class="ow-empty">loading relations…</p> |
| 224 | + {:else if total === 0 && !adding} |
| 225 | + <p class="ow-empty">no related organizations yet</p> |
| 226 | + {:else} |
| 227 | + {#if relations.parents.length > 0} |
| 228 | + <h4 class="ro-group">Part of</h4> |
| 229 | + <ul class="ro-list">{#each relations.parents as r (r.slug)}{@render relRow(r)}{/each}</ul> |
| 230 | + {/if} |
| 231 | + {#if relations.children.length > 0} |
| 232 | + <h4 class="ro-group">Contains</h4> |
| 233 | + <ul class="ro-list">{#each relations.children as r (r.slug)}{@render relRow(r)}{/each}</ul> |
| 234 | + {/if} |
| 235 | + {#if relations.peers.length > 0} |
| 236 | + <h4 class="ro-group">Peers</h4> |
| 237 | + <ul class="ro-list">{#each relations.peers as r (r.slug)}{@render relRow(r)}{/each}</ul> |
| 238 | + {/if} |
| 239 | + {/if} |
| 240 | + |
| 241 | + {#if pendingRemove} |
| 242 | + <p class="ow-chip-confirm"> |
| 243 | + remove the relation to <strong>{pendingRemove.display_name}</strong>? (both orgs stay — only the edge goes) |
| 244 | + <button type="button" class="ow-add-go ow-remove-yes" disabled={busy} onclick={commitRemove}>{busy ? '…' : 'yes'}</button> |
| 245 | + <button type="button" class="ow-add-go" disabled={busy} onclick={() => (pendingRemove = null)}>keep</button> |
| 246 | + </p> |
| 247 | + {/if} |
| 248 | + |
| 249 | + <datalist id="ro-kinds"> |
| 250 | + {#each KIND_SUGGESTIONS as k (k)}<option value={k}></option>{/each} |
| 251 | + </datalist> |
| 252 | +</section> |
| 253 | + |
| 254 | +<style> |
| 255 | + .ro-section { display: flex; flex-direction: column; gap: 0.35rem; } |
| 256 | + .ro-group { margin: 0.35rem 0 0.1rem; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.65; } |
| 257 | + .ro-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; } |
| 258 | + .ro-row { display: flex; align-items: baseline; gap: 0.5rem; min-width: 0; } |
| 259 | + .ro-open { background: none; border: none; padding: 0; color: inherit; font: inherit; cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; } |
| 260 | + .ro-open:hover { text-decoration-style: solid; } |
| 261 | + .ro-kind { font-size: 0.72rem; padding: 0.05rem 0.4rem; border: 1px solid var(--color-border, #2a2c33); border-radius: 999px; opacity: 0.8; white-space: nowrap; } |
| 262 | + .ro-desc { font-size: 0.78rem; opacity: 0.6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } |
| 263 | + .ro-actions { margin-left: auto; display: inline-flex; gap: 0.25rem; } |
| 264 | + .ro-row:not(:hover) .ro-actions { visibility: hidden; } |
| 265 | + .ro-add { display: flex; flex-direction: column; gap: 0.25rem; } |
| 266 | + .ro-picked, .ro-edit-name { font-size: 0.85rem; font-weight: 600; white-space: nowrap; } |
| 267 | +</style> |
0 commit comments