docs(frontend): add JSDoc for complex UI utilities
This commit is contained in:
@@ -148,9 +148,15 @@ const formatRubricTooltipValue = (
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrow unknown values to a plain record.
|
||||
*
|
||||
* Used for defensive parsing of `approval.payload` (schema can evolve).
|
||||
*/
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Safely read any value at a nested path inside an approval payload. */
|
||||
const payloadAtPath = (payload: Approval["payload"], path: string[]) => {
|
||||
let current: unknown = payload;
|
||||
for (const key of path) {
|
||||
@@ -160,6 +166,12 @@ const payloadAtPath = (payload: Approval["payload"], path: string[]) => {
|
||||
return current ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely read a simple scalar value from an approval payload.
|
||||
*
|
||||
* The backend payload shape can evolve (camelCase vs snake_case). Keeping these
|
||||
* helpers centralized makes it easier to support older approvals.
|
||||
*/
|
||||
const payloadValue = (payload: Approval["payload"], key: string) => {
|
||||
const value = payloadAtPath(payload, [key]);
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
@@ -168,12 +180,18 @@ const payloadValue = (payload: Approval["payload"], key: string) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely read a string[] value from an approval payload.
|
||||
*
|
||||
* Filters non-string entries to keep UI rendering predictable.
|
||||
*/
|
||||
const payloadValues = (payload: Approval["payload"], key: string) => {
|
||||
const value = payloadAtPath(payload, [key]);
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
};
|
||||
|
||||
/** Safely read a scalar value from an approval payload at a nested path. */
|
||||
const payloadNestedValue = (payload: Approval["payload"], path: string[]) => {
|
||||
const value = payloadAtPath(payload, path);
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
@@ -182,6 +200,7 @@ const payloadNestedValue = (payload: Approval["payload"], path: string[]) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Safely read a string[] value from an approval payload at a nested path. */
|
||||
const payloadNestedValues = (payload: Approval["payload"], path: string[]) => {
|
||||
const value = payloadAtPath(payload, path);
|
||||
if (!Array.isArray(value)) return [];
|
||||
@@ -222,6 +241,15 @@ const normalizeRubricScores = (raw: unknown): Record<string, number> => {
|
||||
const payloadRubricScores = (payload: Approval["payload"]) =>
|
||||
normalizeRubricScores(payloadAtPath(payload, ["analytics", "rubric_scores"]));
|
||||
|
||||
/**
|
||||
* Extract task ids referenced by an approval.
|
||||
*
|
||||
* Approvals can reference tasks in multiple places depending on the producer:
|
||||
* - top-level `task_id` / `task_ids` fields
|
||||
* - nested payload keys (task_id/taskId/taskIDs, etc.)
|
||||
*
|
||||
* We merge/dedupe to get a best-effort list for UI deep links.
|
||||
*/
|
||||
const approvalTaskIds = (approval: Approval) => {
|
||||
const payload = approval.payload ?? {};
|
||||
const linkedTaskIds = (approval as Approval & { task_ids?: string[] | null })
|
||||
@@ -318,6 +346,12 @@ const approvalRelatedTasks = (approval: Approval): RelatedTaskSummary[] => {
|
||||
const taskHref = (boardId: string, taskId: string) =>
|
||||
`/boards/${encodeURIComponent(boardId)}?taskId=${encodeURIComponent(taskId)}`;
|
||||
|
||||
/**
|
||||
* Create a small, human-readable summary of an approval request.
|
||||
*
|
||||
* Used by the approvals panel modal: it prefers explicit fields but falls back
|
||||
* to payload-derived values so older approvals still render well.
|
||||
*/
|
||||
const approvalSummary = (approval: Approval, boardLabel?: string | null) => {
|
||||
const payload = approval.payload ?? {};
|
||||
const taskIds = approvalTaskIds(approval);
|
||||
|
||||
@@ -30,6 +30,12 @@ type NormalizedMessage = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize backend onboarding messages into a strict `{role, content}` list.
|
||||
*
|
||||
* The server stores messages as untyped JSON; this protects the UI from partial
|
||||
* or malformed entries.
|
||||
*/
|
||||
const normalizeMessages = (
|
||||
value?: BoardOnboardingReadMessages,
|
||||
): NormalizedMessage[] | null => {
|
||||
@@ -59,6 +65,16 @@ const FREE_TEXT_OPTION_RE =
|
||||
|
||||
const isFreeTextOption = (label: string) => FREE_TEXT_OPTION_RE.test(label);
|
||||
|
||||
/**
|
||||
* Best-effort parser for assistant-produced question payloads.
|
||||
*
|
||||
* During onboarding, the assistant can respond with either:
|
||||
* - raw JSON (ideal)
|
||||
* - a fenced ```json block
|
||||
* - slightly-structured objects
|
||||
*
|
||||
* This function validates shape and normalizes option ids/labels.
|
||||
*/
|
||||
const normalizeQuestion = (value: unknown): Question | null => {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const data = value as { question?: unknown; options?: unknown };
|
||||
@@ -90,6 +106,12 @@ const normalizeQuestion = (value: unknown): Question | null => {
|
||||
return { question: data.question, options };
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the most recent assistant question from the transcript.
|
||||
*
|
||||
* We intentionally only inspect the last assistant message: the user may have
|
||||
* typed arbitrary text between questions.
|
||||
*/
|
||||
const parseQuestion = (messages?: NormalizedMessage[] | null) => {
|
||||
if (!messages?.length) return null;
|
||||
const lastAssistant = [...messages]
|
||||
|
||||
@@ -40,6 +40,12 @@ function useChart() {
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recharts wrapper that:
|
||||
* - Provides a shared `ChartConfig` via context (labels/icons/colors)
|
||||
* - Exposes a small legend state (hide/toggle series)
|
||||
* - Injects CSS variables (`--color-*`) scoped to this chart instance
|
||||
*/
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
@@ -108,6 +114,10 @@ function ChartContainer({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits scoped theme-aware CSS variables so Recharts series can use
|
||||
* `var(--color-<key>)` without hardcoding colors in every chart.
|
||||
*/
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
|
||||
@@ -82,15 +82,25 @@ const columns: Array<{
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Build compact due-date UI state for a task card.
|
||||
*
|
||||
* - Returns `due: undefined` when the task has no due date (or it's invalid), so
|
||||
* callers can omit the due-date UI entirely.
|
||||
* - Treats a task as overdue only if it is not `done` (so "Done" tasks don't
|
||||
* keep showing as overdue forever).
|
||||
*/
|
||||
const resolveDueState = (
|
||||
task: Task,
|
||||
): { due: string | undefined; isOverdue: boolean } => {
|
||||
const date = parseApiDatetime(task.due_at);
|
||||
if (!date) return { due: undefined, isOverdue: false };
|
||||
|
||||
const dueLabel = date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const isOverdue = task.status !== "done" && date.getTime() < Date.now();
|
||||
return {
|
||||
due: isOverdue ? `Overdue · ${dueLabel}` : dueLabel,
|
||||
@@ -103,6 +113,16 @@ type CardPosition = { left: number; top: number };
|
||||
const KANBAN_MOVE_ANIMATION_MS = 240;
|
||||
const KANBAN_MOVE_EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)";
|
||||
|
||||
/**
|
||||
* Kanban-style task board with 4 columns.
|
||||
*
|
||||
* Notes:
|
||||
* - Uses a lightweight FLIP animation (via `useLayoutEffect`) to animate cards
|
||||
* to their new positions when tasks move between columns.
|
||||
* - Drag interactions can temporarily fight browser-managed drag images; the
|
||||
* animation is disabled while a card is being dragged.
|
||||
* - Respects `prefers-reduced-motion`.
|
||||
*/
|
||||
export const TaskBoard = memo(function TaskBoard({
|
||||
tasks,
|
||||
onTaskSelect,
|
||||
@@ -131,6 +151,12 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Snapshot each card's position relative to the scroll container.
|
||||
*
|
||||
* We store these measurements so we can compute deltas (prev - next) and
|
||||
* apply the FLIP technique on the next render.
|
||||
*/
|
||||
const measurePositions = useCallback((): Map<string, CardPosition> => {
|
||||
const positions = new Map<string, CardPosition>();
|
||||
const container = boardRef.current;
|
||||
|
||||
@@ -40,7 +40,11 @@ type DropdownSelectProps = {
|
||||
emptyMessage?: string;
|
||||
};
|
||||
|
||||
// Resolve trigger placeholder text with explicit prop override first, then accessible fallback.
|
||||
/**
|
||||
* Derive a human-friendly trigger placeholder from an accessible `ariaLabel`.
|
||||
*
|
||||
* Keeps placeholder strings consistent even when callers only provide aria text.
|
||||
*/
|
||||
const resolvePlaceholder = (ariaLabel: string, placeholder?: string) => {
|
||||
if (placeholder) {
|
||||
return placeholder;
|
||||
@@ -52,7 +56,11 @@ const resolvePlaceholder = (ariaLabel: string, placeholder?: string) => {
|
||||
return trimmed.endsWith("...") ? trimmed : `${trimmed}...`;
|
||||
};
|
||||
|
||||
// Resolve search input placeholder from explicit override or a normalized aria label.
|
||||
/**
|
||||
* Search input placeholder derived from `ariaLabel`.
|
||||
*
|
||||
* Example: ariaLabel="Select agent" -> "Search agent...".
|
||||
*/
|
||||
const resolveSearchPlaceholder = (
|
||||
ariaLabel: string,
|
||||
searchPlaceholder?: string,
|
||||
|
||||
Reference in New Issue
Block a user