mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
feat: add promotion review UI
This commit is contained in:
+5
-2
@@ -2,17 +2,19 @@ import { useEffect, useState } from 'react';
|
||||
import LoginPage from './routes/LoginPage';
|
||||
import PrivateLinksPage from './routes/PrivateLinksPage';
|
||||
import PublicLinksPage from './routes/PublicLinksPage';
|
||||
import AdminReviewPage from './routes/AdminReviewPage';
|
||||
|
||||
type RouteId = 'private' | 'public' | 'login';
|
||||
type RouteId = 'private' | 'public' | 'admin' | 'login';
|
||||
|
||||
const ROUTES: readonly { id: RouteId; label: string }[] = [
|
||||
{ id: 'private', label: 'Private Links' },
|
||||
{ id: 'public', label: 'Public Directory' },
|
||||
{ id: 'admin', label: 'Admin Review' },
|
||||
{ id: 'login', label: 'Login' },
|
||||
];
|
||||
|
||||
function readRouteFromHash(): RouteId {
|
||||
const match = window.location.hash.match(/^#\/(private|public|login)/);
|
||||
const match = window.location.hash.match(/^#\/(private|public|admin|login)/);
|
||||
return (match?.[1] as RouteId) ?? 'private';
|
||||
}
|
||||
|
||||
@@ -54,6 +56,7 @@ export default function App() {
|
||||
<main className="app-main">
|
||||
{route === 'private' ? <PrivateLinksPage /> : null}
|
||||
{route === 'public' ? <PublicLinksPage /> : null}
|
||||
{route === 'admin' ? <AdminReviewPage /> : null}
|
||||
{route === 'login' ? <LoginPage /> : null}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Link } from '../lib/api';
|
||||
|
||||
interface PromotionDialogProps {
|
||||
/** Links selected for promotion in this submission. Exactly one must be chosen. */
|
||||
readonly links: Link[];
|
||||
readonly onSubmit: (input: { privateLinkId: string; proposedAlias: string; note?: string }) => Promise<void>;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_NOTE = '';
|
||||
|
||||
function defaultAlias(link: Link): string {
|
||||
return link.alias;
|
||||
}
|
||||
|
||||
export default function PromotionDialog({ links, onSubmit, onClose }: PromotionDialogProps) {
|
||||
const [selectedLinkId, setSelectedLinkId] = useState<string>(() => links[0]?.id ?? '');
|
||||
const [proposedAlias, setProposedAlias] = useState<string>(() => links[0]?.alias ?? '');
|
||||
const [note, setNote] = useState<string>(EMPTY_NOTE);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Re-seed selection/alias when the set of links changes (e.g. user toggles
|
||||
// checkboxes while the dialog is open).
|
||||
useEffect(() => {
|
||||
setSelectedLinkId((prev) => (links.some((l) => l.id === prev) ? prev : (links[0]?.id ?? '')));
|
||||
}, [links]);
|
||||
|
||||
useEffect(() => {
|
||||
const current = links.find((l) => l.id === selectedLinkId);
|
||||
setProposedAlias(current ? defaultAlias(current) : '');
|
||||
}, [selectedLinkId, links]);
|
||||
|
||||
const single = links.length === 1;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (submitting || !selectedLinkId) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
privateLinkId: selectedLinkId,
|
||||
proposedAlias: proposedAlias.trim(),
|
||||
note: note.trim() ? note.trim() : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to submit promotion');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (links.length === 0) {
|
||||
return (
|
||||
<div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label="Promote private link">
|
||||
<div className="dialog-card">
|
||||
<h2>Promote to public</h2>
|
||||
<p className="muted">Select at least one private link to promote.</p>
|
||||
<div className="form-actions">
|
||||
<button type="button" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label="Promote private link">
|
||||
<form className="dialog-card link-form" onSubmit={handleSubmit}>
|
||||
<div className="dialog-header">
|
||||
<h2>Promote to public directory</h2>
|
||||
<button type="button" className="dialog-close" aria-label="Close" onClick={onClose} disabled={submitting}>×</button>
|
||||
</div>
|
||||
|
||||
{!single ? (
|
||||
<div className="field">
|
||||
<label htmlFor="promotion-link">Private link</label>
|
||||
<select
|
||||
id="promotion-link"
|
||||
value={selectedLinkId}
|
||||
onChange={(e) => setSelectedLinkId(e.target.value)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{links.map((link) => (
|
||||
<option key={link.id} value={link.id}>{link.alias}</option>
|
||||
))}
|
||||
</select>
|
||||
<small className="hint">Choose which private link to submit for promotion.</small>
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">
|
||||
Promoting <code>{links[0].alias}</code>
|
||||
{links[0].targetUrl ? <> → <span className="truncate">{links[0].targetUrl}</span></> : null}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="promotion-alias">Proposed public alias</label>
|
||||
<input
|
||||
id="promotion-alias"
|
||||
name="proposedAlias"
|
||||
value={proposedAlias}
|
||||
onChange={(e) => setProposedAlias(e.target.value)}
|
||||
required
|
||||
maxLength={100}
|
||||
autoComplete="off"
|
||||
placeholder="public-alias"
|
||||
disabled={submitting}
|
||||
/>
|
||||
<small className="hint">The alias reviewers will publish this link under.</small>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="promotion-note">Note for reviewers (optional)</label>
|
||||
<textarea
|
||||
id="promotion-note"
|
||||
name="note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
placeholder="Why should this be public?"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" disabled={submitting || !proposedAlias.trim()}>
|
||||
{submitting ? 'Submitting…' : 'Submit for review'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} disabled={submitting}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -158,6 +158,94 @@ async function deleteLink(path: string): Promise<DeleteResponse> {
|
||||
return result ?? { ok: true };
|
||||
}
|
||||
|
||||
// ---- Promotion submissions ----
|
||||
|
||||
export type SubmissionStatus = 'pending' | 'approved' | 'rejected' | 'needs_changes';
|
||||
export type AdminSubmissionFilter = SubmissionStatus;
|
||||
|
||||
export interface PromotionSubmission {
|
||||
id: string;
|
||||
privateLinkId: string;
|
||||
submittedByUserId: string;
|
||||
proposedAlias: string;
|
||||
note: string | null;
|
||||
status: SubmissionStatus;
|
||||
reviewedByUserId: string | null;
|
||||
rejectionReason: string | null;
|
||||
publicLinkId: string | null;
|
||||
createdAt: string;
|
||||
reviewedAt: string | null;
|
||||
privateLinkAlias: string | null;
|
||||
privateLinkTargetUrl: string | null;
|
||||
}
|
||||
|
||||
export interface SubmissionListResponse {
|
||||
submissions: PromotionSubmission[];
|
||||
}
|
||||
|
||||
export interface SubmissionResponse {
|
||||
submission: PromotionSubmission;
|
||||
}
|
||||
|
||||
export interface ApproveSubmissionResponse extends SubmissionResponse {
|
||||
publicLink: Link;
|
||||
}
|
||||
|
||||
export interface PromoteLinkInput {
|
||||
privateLinkId: string;
|
||||
proposedAlias: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ReviewActionInput {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function promoteLink(input: PromoteLinkInput): Promise<SubmissionResponse> {
|
||||
return request<SubmissionResponse>('/api/promotions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
privateLinkId: input.privateLinkId,
|
||||
proposedAlias: input.proposedAlias,
|
||||
...(input.note !== undefined && input.note !== '' ? { note: input.note } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function listMySubmissions(): Promise<SubmissionListResponse> {
|
||||
return request<SubmissionListResponse>('/api/promotions/mine');
|
||||
}
|
||||
|
||||
export function listAdminSubmissions(status: AdminSubmissionFilter): Promise<SubmissionListResponse> {
|
||||
const query = `?status=${encodeURIComponent(status)}`;
|
||||
return request<SubmissionListResponse>(`/api/admin/promotions${query}`);
|
||||
}
|
||||
|
||||
export function approveSubmission(id: string, input: ReviewActionInput = {}): Promise<ApproveSubmissionResponse> {
|
||||
return request<ApproveSubmissionResponse>(`/api/admin/promotions/${encodeURIComponent(id)}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(reviewBody(input)),
|
||||
});
|
||||
}
|
||||
|
||||
export function rejectSubmission(id: string, input: ReviewActionInput = {}): Promise<SubmissionResponse> {
|
||||
return request<SubmissionResponse>(`/api/admin/promotions/${encodeURIComponent(id)}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(reviewBody(input)),
|
||||
});
|
||||
}
|
||||
|
||||
export function needsChangesSubmission(id: string, input: ReviewActionInput = {}): Promise<SubmissionResponse> {
|
||||
return request<SubmissionResponse>(`/api/admin/promotions/${encodeURIComponent(id)}/needs-changes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(reviewBody(input)),
|
||||
});
|
||||
}
|
||||
|
||||
function reviewBody(input: ReviewActionInput): Record<string, string> {
|
||||
return input.reason !== undefined && input.reason !== '' ? { reason: input.reason } : {};
|
||||
}
|
||||
|
||||
// ---- Pure helper: build a preview URL for parameterized redirect links ----
|
||||
// Returns null when the target URL is not usable as a template.
|
||||
export function buildPreviewUrl(targetUrl: string | null, params: Record<string, string> = {}): string | null {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
type AdminSubmissionFilter,
|
||||
type PromotionSubmission,
|
||||
type SubmissionStatus,
|
||||
approveSubmission,
|
||||
listAdminSubmissions,
|
||||
needsChangesSubmission,
|
||||
rejectSubmission,
|
||||
} from '../lib/api';
|
||||
|
||||
const STATUS_FILTERS: readonly AdminSubmissionFilter[] = ['pending', 'needs_changes', 'approved', 'rejected'];
|
||||
|
||||
const STATUS_LABELS: Record<SubmissionStatus, string> = {
|
||||
pending: 'Pending',
|
||||
approved: 'Approved',
|
||||
rejected: 'Rejected',
|
||||
needs_changes: 'Needs changes',
|
||||
};
|
||||
|
||||
interface PendingAction {
|
||||
readonly id: string;
|
||||
readonly kind: 'approve' | 'reject' | 'needs-changes';
|
||||
}
|
||||
|
||||
export default function AdminReviewPage() {
|
||||
const [status, setStatus] = useState<AdminSubmissionFilter>('pending');
|
||||
const [submissions, setSubmissions] = useState<PromotionSubmission[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState<PendingAction | null>(null);
|
||||
const [reason, setReason] = useState<string>('');
|
||||
|
||||
const refresh = useCallback(async (filter: AdminSubmissionFilter) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await listAdminSubmissions(filter);
|
||||
setSubmissions(result.submissions ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load submissions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh(status);
|
||||
}, [refresh, status]);
|
||||
|
||||
function startAction(id: string, kind: PendingAction['kind']) {
|
||||
setPending({ id, kind });
|
||||
setReason('');
|
||||
}
|
||||
|
||||
function cancelAction() {
|
||||
setPending(null);
|
||||
setReason('');
|
||||
}
|
||||
|
||||
async function confirmAction() {
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
const { id, kind } = pending;
|
||||
const trimmed = reason.trim() ? reason.trim() : undefined;
|
||||
try {
|
||||
if (kind === 'approve') {
|
||||
await approveSubmission(id, trimmed ? { reason: trimmed } : {});
|
||||
} else if (kind === 'reject') {
|
||||
await rejectSubmission(id, trimmed ? { reason: trimmed } : {});
|
||||
} else {
|
||||
await needsChangesSubmission(id, trimmed ? { reason: trimmed } : {});
|
||||
}
|
||||
setPending(null);
|
||||
setReason('');
|
||||
await refresh(status);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Review action failed');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<header className="panel-header">
|
||||
<div>
|
||||
<h1>Admin review</h1>
|
||||
<p className="muted">Review promotion submissions from private to public links.</p>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
{STATUS_FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
type="button"
|
||||
className={`tab-button${status === filter ? ' is-active' : ''}`}
|
||||
onClick={() => setStatus(filter)}
|
||||
>
|
||||
{STATUS_LABELS[filter]}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => void refresh(status)} disabled={loading}>Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="table-status" aria-busy="true">Loading submissions…</p>
|
||||
) : submissions.length === 0 ? (
|
||||
<p className="table-empty">No {STATUS_LABELS[status].toLowerCase()} submissions.</p>
|
||||
) : (
|
||||
<table className="link-table review-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Proposed alias</th>
|
||||
<th scope="col">Private link</th>
|
||||
<th scope="col">Submitted</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col" className="col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{submissions.map((submission) => (
|
||||
<tr key={submission.id}>
|
||||
<td>
|
||||
<code>{submission.proposedAlias}</code>
|
||||
{submission.note ? <small className="row-description">{submission.note}</small> : null}
|
||||
{submission.rejectionReason ? (
|
||||
<small className="row-description review-reason">Reason: {submission.rejectionReason}</small>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
{submission.privateLinkAlias ? <code>{submission.privateLinkAlias}</code> : <span className="muted">—</span>}
|
||||
{submission.privateLinkTargetUrl ? (
|
||||
<small className="row-description truncate">{submission.privateLinkTargetUrl}</small>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<time dateTime={submission.createdAt}>{new Date(submission.createdAt).toLocaleString()}</time>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${submission.status}`}>{STATUS_LABELS[submission.status]}</span>
|
||||
</td>
|
||||
<td className="col-actions">
|
||||
{pending?.id === submission.id ? (
|
||||
<div className="inline-review">
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Reason"
|
||||
placeholder="Reason (optional)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button type="button" className="link-action link-action--confirm" onClick={() => void confirmAction()} disabled={loading}>
|
||||
Confirm
|
||||
</button>
|
||||
<button type="button" className="link-action" onClick={cancelAction} disabled={loading}>Cancel</button>
|
||||
</div>
|
||||
) : submission.status === 'pending' ? (
|
||||
<>
|
||||
<button type="button" className="link-action link-action--approve" onClick={() => startAction(submission.id, 'approve')} disabled={loading}>
|
||||
Approve
|
||||
</button>
|
||||
<button type="button" className="link-action link-action--danger" onClick={() => startAction(submission.id, 'reject')} disabled={loading}>
|
||||
Reject
|
||||
</button>
|
||||
<button type="button" className="link-action" onClick={() => startAction(submission.id, 'needs-changes')} disabled={loading}>
|
||||
Needs changes
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import LinkForm from '../components/LinkForm';
|
||||
import LinkTable from '../components/LinkTable';
|
||||
import PromotionDialog from '../components/PromotionDialog';
|
||||
import {
|
||||
ApiClientError,
|
||||
type Link,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
createPrivateLink,
|
||||
deletePrivateLink,
|
||||
listPrivateLinks,
|
||||
promoteLink,
|
||||
updatePrivateLink,
|
||||
} from '../lib/api';
|
||||
|
||||
@@ -18,6 +20,13 @@ export default function PrivateLinksPage() {
|
||||
const [editing, setEditing] = useState<Link | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [showPromote, setShowPromote] = useState(false);
|
||||
const [promoteError, setPromoteError] = useState<string | null>(null);
|
||||
|
||||
const selectedLinks = useMemo(
|
||||
() => links.filter((link) => selected.has(link.id)),
|
||||
[links, selected],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -92,6 +101,18 @@ export default function PrivateLinksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePromote(input: { privateLinkId: string; proposedAlias: string; note?: string }) {
|
||||
setPromoteError(null);
|
||||
try {
|
||||
await promoteLink(input);
|
||||
setShowPromote(false);
|
||||
setSelected(new Set());
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
throw new Error(err instanceof ApiClientError ? err.message : 'Failed to submit promotion');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<header className="panel-header">
|
||||
@@ -108,11 +129,29 @@ export default function PrivateLinksPage() {
|
||||
</header>
|
||||
|
||||
{selected.size > 0 ? (
|
||||
<p className="selection-bar" role="status">
|
||||
{selected.size} selected. Promotion to public links is coming in a later task.
|
||||
</p>
|
||||
<div className="selection-bar" role="status">
|
||||
<span>{selected.size} selected.</span>
|
||||
<button
|
||||
type="button"
|
||||
className="link-action link-action--approve"
|
||||
onClick={() => { setPromoteError(null); setShowPromote(true); }}
|
||||
>
|
||||
Promote to public
|
||||
</button>
|
||||
<button type="button" className="link-action" onClick={() => setSelected(new Set())}>Clear</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showPromote ? (
|
||||
<PromotionDialog
|
||||
links={selectedLinks}
|
||||
onSubmit={handlePromote}
|
||||
onClose={() => setShowPromote(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{promoteError ? <p className="form-error" role="alert">{promoteError}</p> : null}
|
||||
|
||||
{showCreate ? (
|
||||
<div className="form-card">
|
||||
<h2>Create link</h2>
|
||||
|
||||
+121
@@ -315,6 +315,127 @@ button.link-action--danger:hover { background: var(--danger-soft); }
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.selection-bar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.selection-bar > span { font-weight: 600; }
|
||||
|
||||
/* ---- Promotion dialog ---- */
|
||||
|
||||
.dialog-backdrop {
|
||||
align-items: center;
|
||||
background: rgb(23 32 51 / 45%);
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
background: white;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 24px 60px rgb(23 32 51 / 18%);
|
||||
max-width: 480px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.dialog-header h2 { font-size: 1.15rem; margin: 0; }
|
||||
|
||||
.dialog-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.35rem;
|
||||
}
|
||||
|
||||
.dialog-close:hover { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
.dialog-card select {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
padding: 0.5rem 0.65rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---- Admin review table ---- */
|
||||
|
||||
.tab-button {
|
||||
background: white;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: #172033;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
}
|
||||
|
||||
.tab-button:hover { background: var(--accent-soft); }
|
||||
|
||||
.tab-button.is-active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.review-table .review-reason { color: var(--danger); }
|
||||
|
||||
.status-badge {
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.55rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status-pending { background: #fef3c7; color: #92400e; }
|
||||
.status-approved { background: #dcfce7; color: #166534; }
|
||||
.status-rejected { background: var(--danger-soft); color: var(--danger); }
|
||||
.status-needs_changes { background: #e0e7ff; color: #3730a3; }
|
||||
|
||||
.link-action--approve {
|
||||
border-color: #bbf7d0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.link-action--approve:hover { background: #dcfce7; }
|
||||
|
||||
.link-action--confirm {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.inline-review {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.inline-review input[type="text"] {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
padding: 0.3rem 0.5rem;
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
/* ---- Admin tools ---- */
|
||||
|
||||
.admin-tools {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApiClientError,
|
||||
approveSubmission,
|
||||
listAdminSubmissions,
|
||||
listMySubmissions,
|
||||
needsChangesSubmission,
|
||||
promoteLink,
|
||||
rejectSubmission,
|
||||
} from '../src/lib/api';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function mockFetch(responder: (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
statusText?: string;
|
||||
}) {
|
||||
const calls: { path: string; init?: RequestInit }[] = [];
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = typeof input === 'string' ? input : new URL(input.toString()).pathname;
|
||||
calls.push({ path, init });
|
||||
const { status = 200, body = {}, statusText = 'OK' } = responder(input, init);
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
statusText,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
return { calls, fetchMock };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('promotion api client', () => {
|
||||
it('promoteLink POSTs privateLinkId, proposedAlias, note to /api/promotions', async () => {
|
||||
const { calls } = mockFetch((_input, init) => ({
|
||||
status: 201,
|
||||
body: { submission: { id: 'sub_1', status: 'pending', proposedAlias: 'docs' } },
|
||||
}));
|
||||
|
||||
const result = await promoteLink({
|
||||
privateLinkId: 'priv_1',
|
||||
proposedAlias: 'docs',
|
||||
note: 'please review',
|
||||
});
|
||||
|
||||
expect(calls[0].path).toBe('/api/promotions');
|
||||
expect(calls[0].init?.method).toBe('POST');
|
||||
expect(calls[0].init?.credentials).toBe('include');
|
||||
const body = JSON.parse(calls[0].init?.body as string);
|
||||
expect(body).toEqual({ privateLinkId: 'priv_1', proposedAlias: 'docs', note: 'please review' });
|
||||
expect(result.submission.id).toBe('sub_1');
|
||||
});
|
||||
|
||||
it('promoteLink omits the note field when it is empty or undefined', async () => {
|
||||
const { calls } = mockFetch(() => ({
|
||||
status: 201,
|
||||
body: { submission: { id: 'sub_2', status: 'pending' } },
|
||||
}));
|
||||
|
||||
await promoteLink({ privateLinkId: 'priv_1', proposedAlias: 'docs', note: '' });
|
||||
|
||||
const body = JSON.parse(calls[0].init?.body as string);
|
||||
expect(body).not.toHaveProperty('note');
|
||||
});
|
||||
|
||||
it('listMySubmissions GETs /api/promotions/mine with credentials', async () => {
|
||||
const { calls } = mockFetch(() => ({
|
||||
body: { submissions: [{ id: 'sub_1', status: 'pending', proposedAlias: 'docs' }] },
|
||||
}));
|
||||
|
||||
const result = await listMySubmissions();
|
||||
|
||||
expect(calls[0].path).toBe('/api/promotions/mine');
|
||||
expect(calls[0].init?.method).toBeUndefined();
|
||||
expect(calls[0].init?.credentials).toBe('include');
|
||||
expect(result.submissions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('listAdminSubmissions encodes the status filter into the query string', async () => {
|
||||
const { calls } = mockFetch(() => ({ body: { submissions: [] } }));
|
||||
|
||||
await listAdminSubmissions('needs_changes');
|
||||
|
||||
expect(calls[0].path).toBe('/api/admin/promotions?status=needs_changes');
|
||||
});
|
||||
|
||||
it('approveSubmission POSTs to /api/admin/promotions/:id/approve and returns publicLink', async () => {
|
||||
const { calls } = mockFetch(() => ({
|
||||
body: {
|
||||
submission: { id: 'sub_1', status: 'approved' },
|
||||
publicLink: { id: 'pub_1', alias: 'docs', scope: 'public' },
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await approveSubmission('sub_1', { reason: 'lgtm' });
|
||||
|
||||
expect(calls[0].path).toBe('/api/admin/promotions/sub_1/approve');
|
||||
expect(calls[0].init?.method).toBe('POST');
|
||||
const body = JSON.parse(calls[0].init?.body as string);
|
||||
expect(body).toEqual({ reason: 'lgtm' });
|
||||
expect(result.publicLink.id).toBe('pub_1');
|
||||
});
|
||||
|
||||
it('rejectSubmission posts an empty object when no reason is given', async () => {
|
||||
const { calls } = mockFetch(() => ({
|
||||
body: { submission: { id: 'sub_1', status: 'rejected', rejectionReason: null } },
|
||||
}));
|
||||
|
||||
await rejectSubmission('sub_1');
|
||||
|
||||
expect(calls[0].path).toBe('/api/admin/promotions/sub_1/reject');
|
||||
const body = JSON.parse(calls[0].init?.body as string);
|
||||
expect(body).toEqual({});
|
||||
});
|
||||
|
||||
it('needsChangesSubmission targets the needs-changes endpoint', async () => {
|
||||
const { calls } = mockFetch(() => ({
|
||||
body: { submission: { id: 'sub_1', status: 'needs_changes' } },
|
||||
}));
|
||||
|
||||
await needsChangesSubmission('sub_1', { reason: 'tighten description' });
|
||||
|
||||
expect(calls[0].path).toBe('/api/admin/promotions/sub_1/needs-changes');
|
||||
const body = JSON.parse(calls[0].init?.body as string);
|
||||
expect(body).toEqual({ reason: 'tighten description' });
|
||||
});
|
||||
|
||||
it('encodes submission ids with special characters in review paths', async () => {
|
||||
const { calls } = mockFetch(() => ({ body: { submission: { id: 'a/b', status: 'approved' } } }));
|
||||
|
||||
await approveSubmission('a/b');
|
||||
|
||||
expect(calls[0].path).toBe('/api/admin/promotions/a%2Fb/approve');
|
||||
});
|
||||
|
||||
it('surfaces server errors as ApiClientError', async () => {
|
||||
mockFetch(() => ({ status: 409, body: { error: 'A pending submission for this link and alias already exists' } }));
|
||||
|
||||
await expect(promoteLink({ privateLinkId: 'priv_1', proposedAlias: 'docs' }))
|
||||
.rejects.toMatchObject({
|
||||
name: 'ApiClientError',
|
||||
status: 409,
|
||||
message: 'A pending submission for this link and alias already exists',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user