project-bifrost-platform/src/admin/components/ResourceEditPanel.astro
Jonathan Hvid 4aaf0957dd fix: nine test-note follow-ups
1. Markdown preview in the admin edit panel now re-renders from the
   textarea's current value on every toggle (dynamic-imports marked on
   the client). Previously the panel showed the server-rendered seed
   value forever, so new dispatches always previewed empty.

2. Pulse sub-form drops the opens_at field (opens on dispatch publish
   automatically) and changes closes_at to a date input — the chosen
   day is treated as end-of-day in the DB.

3. /dispatches/[slug] reading width widened 50% (720 → 1080px).

4. Roadmap display_order cascades on insert / update / delete:
   inserting at N bumps N..end up by 1, deleting N pulls N+1..end
   down by 1, moving from A to B shifts the intermediate range by 1
   in the appropriate direction. Order stays dense — no gaps, no
   collisions. All three transitions run in a transaction.

5. /roadmap always anchors at scrollLeft=0 on mount so the first
   milestone aligns with the content-column left edge. Previously
   the page jumped to the last-shipping milestone, which felt random
   once items past the viewport landed.

6. Events admin list shows the actual date (fmtDateTime) instead of
   "in 3 days" — easier to scan when planning across months.

7. duration_label is auto-computed from starts_at + ends_at on save
   (minutes < 90, hours < 4, "Half day", "Full day", "N days").
   The manual field is gone from the admin form; the column on the
   member-facing event pages keeps reading the stored value as before.

8. Pulse hero still skips office hours per the existing logic — no
   change. Confirmed via the test note's clarification.

9. Pulse "also coming up" strip relabeled to Previous + Upcoming.
   Previous = most recent past non-office-hours event. Upcoming =
   next non-office-hours event after the hero. Each card now carries
   a small terracotta eyebrow with the label.

Typecheck clean, build clean, 147/147 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:46:06 +02:00

318 lines
12 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
/* ---------------------------------------------------------------------------
* ResourceEditPanel — right-slide panel for create + edit.
*
* Rendered alongside ResourceListView when the URL carries ?edit=<id> or
* ?new=1. POSTs back to the same URL; the route handler in step 7 reads
* _action (save | delete | <action.key>) and dispatches.
*
* Visible-when predicates are evaluated server-side against current values
* (existing item or form defaults). Live-toggle while editing is Phase 2.
* ------------------------------------------------------------------------- */
import FieldRenderer from './FieldRenderer.astro';
import PulseSubForm from '../embeds/PulseSubForm.astro';
import type { Field, FieldContext, Resource } from '../resource-types';
interface Props {
resource: Resource;
/** The item being edited, or null when creating. */
item: Record<string, unknown> | null;
/** Pre-validated form values from a failed prior submission (re-fill). */
formValues?: Record<string, unknown>;
errors?: Record<string, string>;
actingUserId: number;
}
const { resource, item, formValues, errors = {}, actingUserId } = Astro.props;
const isCreate = item === null;
// Review mode = the panel is showing an existing item AND either the resource
// has no form, OR it has a summary that should be preferred over the form
// (e.g. invitations are write-once: form is for create, summary is for edit).
const isReviewMode =
!isCreate &&
(resource.form === null || resource.summary !== undefined);
const singular = resource.singularLabel.toLowerCase();
const title = isReviewMode
? `Review ${singular}`
: isCreate
? `New ${singular}`
: `Edit ${singular}`;
// In review mode, the resource MUST have a summary function — that's what
// fills the panel body when the form is suppressed.
if (isReviewMode && !resource.summary) {
throw new Error(
`ResourceEditPanel: ${resource.key} is in review mode but has no summary — define resource.summary.`,
);
}
// And the create path needs a form.
if (isCreate && !resource.form) {
throw new Error(
`ResourceEditPanel: cannot render create panel for ${resource.key} — form is null.`,
);
}
// Initial form values: prior failed submission > existing item > defaults
const seedValues: Record<string, unknown> = { ...(item ?? {}), ...(formValues ?? {}) };
const ctx: FieldContext = {
formValues: seedValues,
item,
actingUserId,
};
function resolveDefault(field: Field): unknown {
if (field.defaultValue === undefined) return undefined;
if (typeof field.defaultValue === 'function') {
return (field.defaultValue as (c: FieldContext) => unknown)(ctx);
}
return field.defaultValue;
}
function valueFor(field: Field): unknown {
if (field.key in seedValues) return seedValues[field.key];
return resolveDefault(field);
}
const visibleFields = resource.form
? resource.form.fields.filter((f) => !f.visibleWhen || f.visibleWhen(ctx))
: [];
const embeds = resource.form?.embeds ?? [];
// Review-mode summary
const summaryEntries = isReviewMode && item
? resource.summary!(item)
: [];
// One-shot invite link surfaced after create/action — read from URL
const inviteUrl = Astro.url.searchParams.get('invite_url');
// Build the close URL — drop edit/new but keep filter/q/page
const closeUrl = (() => {
const next = new URLSearchParams(Astro.url.searchParams);
next.delete('edit');
next.delete('new');
const s = next.toString();
return s ? `${Astro.url.pathname}?${s}` : Astro.url.pathname;
})();
// Actions visible for this item — only when editing an existing item
const actions = isCreate
? []
: (resource.actions ?? []).filter(
(a) => !a.visibleWhen || a.visibleWhen(item!),
);
// Form action URL: keep the panel-state params so a re-render after a
// validation failure stays on the same item.
const formAction = Astro.url.pathname + Astro.url.search;
---
<div class="bs-panel-scrim">
<a href={closeUrl} class="bs-panel-scrim-link" aria-label="Close panel"></a>
<aside class="bs-panel" role="dialog" aria-modal="true" aria-label={title}>
<header class="bs-panel-head">
<h2 class="bs-panel-title">{title}</h2>
<a href={closeUrl} class="bs-panel-close" aria-label="Close">×</a>
</header>
<form method="POST" action={formAction} class="bs-panel-form" id="bs-panel-form">
<div class="bs-panel-body">
{inviteUrl && (
<section class="bs-invite-result" data-invite-block>
<p class="bs-invite-result-label">Magic link — copy and send now. It will not be shown again.</p>
<div class="bs-invite-link-row">
<code class="bs-invite-link" id="bs-invite-link">{inviteUrl}</code>
<button type="button" class="bs-copy-btn" data-copy-target="#bs-invite-link">Copy</button>
</div>
</section>
)}
{isReviewMode ? (
<dl class="bs-summary">
{summaryEntries.map((entry) => (
<div class="bs-summary-row">
<dt class="bs-summary-label">{entry.label}</dt>
<dd class="bs-summary-value">{entry.value}</dd>
</div>
))}
</dl>
) : (
<>
{visibleFields.map((field) => (
<FieldRenderer
field={field}
value={valueFor(field)}
error={errors[field.key]}
item={item}
/>
))}
{embeds.length > 0 && embeds.map((embed) => {
const show = !embed.visibleWhen || embed.visibleWhen(ctx);
if (!show) return null;
return (
<section class="bs-embed" data-embed={embed.key}>
<h3 class="bs-embed-title">{embed.title}</h3>
{embed.component === 'pulse-sub-form' && (
<PulseSubForm item={item} />
)}
</section>
);
})}
</>
)}
</div>
<footer class="bs-panel-foot">
<div class="bs-panel-foot-left">
{!isReviewMode && (
<button type="submit" name="_action" value="save" class="bs-panel-save">
{isCreate ? `Create ${singular}` : 'Save'}
</button>
)}
{actions.map((a) => (
<button
type="submit"
name="_action"
value={a.key}
class:list={['bs-panel-secondary', { destructive: a.destructive }]}
data-confirm={a.confirmText ?? null}
>
{a.label}
</button>
))}
</div>
{!isCreate && !isReviewMode && resource.ops.delete && (
<button
type="submit"
name="_action"
value="delete"
class="bs-panel-delete"
data-confirm={`Delete this ${singular}? This cannot be undone.`}
>
Delete
</button>
)}
</footer>
</form>
</aside>
</div>
<script>
// ── Confirm-before-submit for buttons with data-confirm ──────────────────
document.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement | null)?.closest('button[data-confirm]');
if (!(btn instanceof HTMLButtonElement)) return;
const text = btn.getAttribute('data-confirm');
if (text && !window.confirm(text)) {
e.preventDefault();
e.stopPropagation();
}
});
// ── Escape to close ──────────────────────────────────────────────────────
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const closer = document.querySelector('.bs-panel-close') as HTMLAnchorElement | null;
if (closer) closer.click();
});
// ── Markdown Write/Preview toggle ────────────────────────────────────────
// Re-renders preview from the textarea's *current* value on every toggle.
// The server-side initial render is only valid for the seed value — once
// the admin types, only client-side rendering reflects what's there.
document.querySelectorAll<HTMLElement>('.bs-md').forEach((root) => {
const tabs = root.querySelectorAll<HTMLButtonElement>('.bs-md-tab');
const input = root.querySelector<HTMLTextAreaElement>('.bs-md-input');
const preview = root.querySelector<HTMLElement>('.bs-md-preview');
if (!input || !preview) return;
tabs.forEach((tab) => {
tab.addEventListener('click', async () => {
const mode = tab.getAttribute('data-md-mode');
tabs.forEach((t) => t.classList.toggle('is-active', t === tab));
if (mode === 'preview') {
const value = input.value;
if (value.trim() === '') {
preview.innerHTML = '<p class="bs-md-empty">Nothing to preview yet.</p>';
} else {
const { marked } = await import('marked');
marked.setOptions({ breaks: true, gfm: true });
preview.innerHTML = await marked.parse(value);
}
input.hidden = true;
preview.hidden = false;
} else {
input.hidden = false;
preview.hidden = true;
}
});
});
});
// ── Copy-to-clipboard for invite-link blocks ─────────────────────────────
document.querySelectorAll<HTMLButtonElement>('.bs-copy-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const sel = btn.getAttribute('data-copy-target');
const target = sel ? document.querySelector(sel) : null;
const text = target?.textContent ?? '';
try {
await navigator.clipboard.writeText(text);
const orig = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => { btn.textContent = orig; }, 1400);
} catch {
// clipboard blocked — leave the link visible for manual copy
}
});
});
// ── MultiTextField add / remove ──────────────────────────────────────────
document.querySelectorAll<HTMLElement>('.bs-multitext').forEach((root) => {
const rows = root.querySelector<HTMLElement>('.bs-multitext-rows');
const addBtn = root.querySelector<HTMLButtonElement>('.bs-multitext-add');
if (!rows || !addBtn) return;
const fieldKey = root.dataset.multitext ?? 'option';
const min = Number(root.dataset.min ?? '1');
const max = Number(root.dataset.max ?? '10');
function updateButtons() {
const inputs = rows!.querySelectorAll<HTMLElement>('.bs-multitext-row');
addBtn!.disabled = inputs.length >= max;
inputs.forEach((row) => {
const rm = row.querySelector<HTMLButtonElement>('.bs-multitext-remove');
if (rm) rm.disabled = inputs.length <= min;
});
}
rows.addEventListener('click', (e) => {
const rm = (e.target as HTMLElement | null)?.closest('.bs-multitext-remove');
if (!rm) return;
const row = rm.closest('.bs-multitext-row');
if (row && rows.querySelectorAll('.bs-multitext-row').length > min) {
row.remove();
updateButtons();
}
});
addBtn.addEventListener('click', () => {
const inputs = rows.querySelectorAll('.bs-multitext-row');
if (inputs.length >= max) return;
const row = document.createElement('div');
row.className = 'bs-multitext-row';
row.innerHTML =
`<input type="text" name="${fieldKey}" class="bs-input" value="" placeholder="Option ${inputs.length + 1}" />` +
`<button type="button" class="bs-multitext-remove" aria-label="Remove option">×</button>`;
rows.appendChild(row);
updateButtons();
});
updateButtons();
});
</script>