loopable / web /src /customer-grid /RecordComments.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
ea7b176 verified
Raw
History Blame Contribute Delete
6.92 kB
import { useEffect, useReducer, useRef } from "react";
import type { FormEvent } from "react";
import type { SurfaceScope } from "./apiBridge";
import type { Viewer } from "./types";
import {
deleteRecordComment,
fetchRecordComments,
postRecordComment,
} from "./recordCommentsApi";
import type { RecordComment } from "./recordCommentsApi";
import {
initialRecordCommentsState,
recordCommentsReducer,
} from "./recordCommentsState";
function initials(name: string): string {
return (
name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || "?"
);
}
function when(value: string): string {
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
export default function RecordComments({
scope,
pid,
viewer,
}: {
/**
* ⭐ WAVE 19 (owner item 12) — WHICH DATABASE THIS RECORD BELONGS TO. Required, with no
* default, for the reason the Insights tab states at `RecordDetail.tsx`'s `scope` prop: a pid
* is only meaningful inside one topic, and asking the customer endpoint about a product's
* CRC32 hash either 403s on a screen showing a product (the reported bug) or — on a
* collision — answers 200 and files the comment against somebody's customer.
*/
scope: SurfaceScope;
pid: number;
viewer?: Viewer;
}) {
const [state, dispatch] = useReducer(
recordCommentsReducer,
pid,
initialRecordCommentsState
);
const { comments, draft, loading, posting, error } = state;
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const controller = new AbortController();
dispatch({ type: "record", pid });
fetchRecordComments(scope, pid, controller.signal)
.then((comments) => dispatch({ type: "loaded", pid, comments }))
.catch((reason: unknown) => {
if (reason instanceof DOMException && reason.name === "AbortError") return;
dispatch({
type: "load-failed",
pid,
error: reason instanceof Error ? reason.message : "Comments could not be loaded.",
});
});
return () => controller.abort();
// ⚠ `scope` is in the deps beside `pid`: the reducer's stale-completion guard keys on the
// PID alone, so a surface switch that landed on the same pid would otherwise accept the old
// topic's feed. The mount is also keyed by scope+pid (RecordDetail), which makes this belt
// and braces — kept because the guard here is the one that survives a host that forgets.
}, [scope, pid]);
const post = async (event: FormEvent) => {
event.preventDefault();
const body = draft.trim();
if (!body || posting) return;
const requestPid = pid;
dispatch({ type: "posting", pid: requestPid });
try {
const comment = await postRecordComment(scope, requestPid, body);
dispatch({ type: "posted", pid: requestPid, comment });
if (requestPid === pid) textareaRef.current?.focus();
} catch (reason) {
dispatch({
type: "post-failed",
pid: requestPid,
error: reason instanceof Error ? reason.message : "That comment was not saved.",
});
}
};
const remove = async (comment: RecordComment) => {
const requestPid = pid;
try {
await deleteRecordComment(scope, requestPid, comment.id);
dispatch({ type: "deleted", pid: requestPid, commentId: comment.id });
} catch (reason) {
dispatch({
type: "delete-failed",
pid: requestPid,
error: reason instanceof Error ? reason.message : "That comment was not deleted.",
});
}
};
return (
<aside className="cg-record-comments" aria-label="Record comments">
<div className="cg-record-comments-head">
<h2>Comments</h2>
<span>{comments.length.toLocaleString()}</span>
</div>
<div className="cg-record-comment-feed" aria-live="polite">
{/* wave17 GRID — item 3 / R6. The feed is `aria-live="polite"`, so the spinner's
`aria-label` is what it announces; dropping it would make the wait silent. */}
{loading && (
<div className="cg-record-comment-state">
<span className="lp-spin" role="status" aria-label="Loading" />
</div>
)}
{!loading && comments.length === 0 && !error && (
<div className="cg-record-comment-state">
No comments yet. Add context for the team here.
</div>
)}
{comments.map((comment) => {
const canDelete =
viewer?.isAdmin === true ||
viewer?.name?.trim().toLowerCase() === comment.authorKey.toLowerCase();
return (
<article className="cg-record-comment" key={comment.id}>
<div className="cg-record-comment-avatar" aria-hidden>
{initials(comment.author)}
</div>
<div className="cg-record-comment-content">
<div className="cg-record-comment-meta">
<strong>{comment.author}</strong>
<time dateTime={comment.createdAt}>{when(comment.createdAt)}</time>
{canDelete && (
<button
type="button"
className="cg-record-comment-delete"
aria-label={`Delete comment by ${comment.author}`}
title="Delete comment"
onClick={() => void remove(comment)}
>
×
</button>
)}
</div>
<p>{comment.body}</p>
</div>
</article>
);
})}
{error && <div className="cg-record-comment-error">{error}</div>}
</div>
<form className="cg-record-comment-compose" onSubmit={(event) => void post(event)}>
<label htmlFor={`cg-comment-${pid}`}>Add a comment</label>
<textarea
ref={textareaRef}
id={`cg-comment-${pid}`}
value={draft}
maxLength={4000}
placeholder="Share an update or leave context…"
onChange={(event) =>
dispatch({ type: "draft", pid, draft: event.target.value })
}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<div className="cg-record-comment-compose-foot">
<span>Ctrl/⌘ + Enter to post</span>
<button type="submit" disabled={posting || !draft.trim()}>
{posting ? "Posting…" : "Comment"}
</button>
</div>
</form>
</aside>
);
}