Merge branch 'master' into perf/activity-events-eventtype-createdat
This commit is contained in:
@@ -108,6 +108,17 @@ It will:
|
||||
- add `Authorization: Bearer <token>` automatically from local mode token or Clerk session
|
||||
- parse errors into an `ApiError` with status + parsed response body
|
||||
|
||||
## Mobile / responsive UI validation
|
||||
|
||||
When changing UI intended to be mobile-ready, validate in Chrome (or similar) using the device toolbar at common widths (e.g. **320px**, **375px**, **768px**).
|
||||
|
||||
Quick checklist:
|
||||
|
||||
- No horizontal scroll
|
||||
- Primary actions reachable without precision taps
|
||||
- Focus rings visible when tabbing
|
||||
- Modals/popovers not clipped
|
||||
|
||||
## Common commands
|
||||
|
||||
From `frontend/`:
|
||||
|
||||
1829
frontend/src/api/generated/board-webhooks/board-webhooks.ts
Normal file
1829
frontend/src/api/generated/board-webhooks/board-webhooks.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,10 @@ export interface ApprovalCreate {
|
||||
task_id?: string | null;
|
||||
task_ids?: string[];
|
||||
payload?: ApprovalCreatePayload;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 100
|
||||
*/
|
||||
confidence: number;
|
||||
rubric_scores?: ApprovalCreateRubricScores;
|
||||
status?: ApprovalCreateStatus;
|
||||
|
||||
@@ -16,11 +16,16 @@ export interface ApprovalRead {
|
||||
task_id?: string | null;
|
||||
task_ids?: string[];
|
||||
payload?: ApprovalReadPayload;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 100
|
||||
*/
|
||||
confidence: number;
|
||||
rubric_scores?: ApprovalReadRubricScores;
|
||||
status?: ApprovalReadStatus;
|
||||
id: string;
|
||||
board_id: string;
|
||||
task_titles?: string[];
|
||||
agent_id?: string | null;
|
||||
created_at: string;
|
||||
resolved_at?: string | null;
|
||||
|
||||
@@ -21,4 +21,7 @@ export interface BoardCreate {
|
||||
target_date?: string | null;
|
||||
goal_confirmed?: boolean;
|
||||
goal_source?: string | null;
|
||||
require_approval_for_done?: boolean;
|
||||
require_review_before_done?: boolean;
|
||||
block_status_changes_with_pending_approval?: boolean;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface BoardRead {
|
||||
target_date?: string | null;
|
||||
goal_confirmed?: boolean;
|
||||
goal_source?: string | null;
|
||||
require_approval_for_done?: boolean;
|
||||
require_review_before_done?: boolean;
|
||||
block_status_changes_with_pending_approval?: boolean;
|
||||
id: string;
|
||||
organization_id: string;
|
||||
created_at: string;
|
||||
|
||||
@@ -21,4 +21,7 @@ export interface BoardUpdate {
|
||||
target_date?: string | null;
|
||||
goal_confirmed?: boolean | null;
|
||||
goal_source?: string | null;
|
||||
require_approval_for_done?: boolean | null;
|
||||
require_review_before_done?: boolean | null;
|
||||
block_status_changes_with_pending_approval?: boolean | null;
|
||||
}
|
||||
|
||||
15
frontend/src/api/generated/model/boardWebhookCreate.ts
Normal file
15
frontend/src/api/generated/model/boardWebhookCreate.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.3.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Payload for creating a board webhook.
|
||||
*/
|
||||
export interface BoardWebhookCreate {
|
||||
/** @minLength 1 */
|
||||
description: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Generated by orval v8.3.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Response payload for inbound webhook ingestion.
|
||||
*/
|
||||
export interface BoardWebhookIngestResponse {
|
||||
board_id: string;
|
||||
ok?: boolean;
|
||||
payload_id: string;
|
||||
webhook_id: string;
|
||||
}
|
||||
22
frontend/src/api/generated/model/boardWebhookPayloadRead.ts
Normal file
22
frontend/src/api/generated/model/boardWebhookPayloadRead.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Generated by orval v8.3.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { BoardWebhookPayloadReadHeaders } from "./boardWebhookPayloadReadHeaders";
|
||||
import type { BoardWebhookPayloadReadPayload } from "./boardWebhookPayloadReadPayload";
|
||||
|
||||
/**
|
||||
* Serialized stored webhook payload.
|
||||
*/
|
||||
export interface BoardWebhookPayloadRead {
|
||||
board_id: string;
|
||||
content_type?: string | null;
|
||||
headers?: BoardWebhookPayloadReadHeaders;
|
||||
id: string;
|
||||
payload?: BoardWebhookPayloadReadPayload;
|
||||
received_at: string;
|
||||
source_ip?: string | null;
|
||||
webhook_id: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v8.3.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type BoardWebhookPayloadReadHeaders = { [key: string]: string } | null;
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.3.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type BoardWebhookPayloadReadPayload =
|
||||
| { [key: string]: unknown }
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
20
frontend/src/api/generated/model/boardWebhookRead.ts
Normal file
20
frontend/src/api/generated/model/boardWebhookRead.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Generated by orval v8.3.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Serialized board webhook configuration.
|
||||
*/
|
||||
export interface BoardWebhookRead {
|
||||
board_id: string;
|
||||
created_at: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
endpoint_path: string;
|
||||
endpoint_url?: string | null;
|
||||
id: string;
|
||||
updated_at: string;
|
||||
}
|
||||
14
frontend/src/api/generated/model/boardWebhookUpdate.ts
Normal file
14
frontend/src/api/generated/model/boardWebhookUpdate.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.3.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Payload for updating a board webhook.
|
||||
*/
|
||||
export interface BoardWebhookUpdate {
|
||||
description?: string | null;
|
||||
enabled?: boolean | null;
|
||||
}
|
||||
@@ -8,4 +8,6 @@ import type { DashboardMetricsApiV1MetricsDashboardGetRangeKey } from "./dashboa
|
||||
|
||||
export type DashboardMetricsApiV1MetricsDashboardGetParams = {
|
||||
range_key?: DashboardMetricsApiV1MetricsDashboardGetRangeKey;
|
||||
board_id?: string | null;
|
||||
group_id?: string | null;
|
||||
};
|
||||
|
||||
@@ -64,6 +64,13 @@ export * from "./boardReadSuccessMetrics";
|
||||
export * from "./boardSnapshot";
|
||||
export * from "./boardUpdate";
|
||||
export * from "./boardUpdateSuccessMetrics";
|
||||
export * from "./boardWebhookCreate";
|
||||
export * from "./boardWebhookIngestResponse";
|
||||
export * from "./boardWebhookPayloadRead";
|
||||
export * from "./boardWebhookPayloadReadHeaders";
|
||||
export * from "./boardWebhookPayloadReadPayload";
|
||||
export * from "./boardWebhookRead";
|
||||
export * from "./boardWebhookUpdate";
|
||||
export * from "./dashboardKpis";
|
||||
export * from "./dashboardMetrics";
|
||||
export * from "./dashboardMetricsApiV1MetricsDashboardGetParams";
|
||||
@@ -115,6 +122,8 @@ export * from "./limitOffsetPageTypeVarCustomizedBoardGroupMemoryRead";
|
||||
export * from "./limitOffsetPageTypeVarCustomizedBoardGroupRead";
|
||||
export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead";
|
||||
export * from "./limitOffsetPageTypeVarCustomizedBoardRead";
|
||||
export * from "./limitOffsetPageTypeVarCustomizedBoardWebhookPayloadRead";
|
||||
export * from "./limitOffsetPageTypeVarCustomizedBoardWebhookRead";
|
||||
export * from "./limitOffsetPageTypeVarCustomizedGatewayRead";
|
||||
export * from "./limitOffsetPageTypeVarCustomizedOrganizationInviteRead";
|
||||
export * from "./limitOffsetPageTypeVarCustomizedOrganizationMemberRead";
|
||||
@@ -133,6 +142,8 @@ export * from "./listBoardMemoryApiV1AgentBoardsBoardIdMemoryGetParams";
|
||||
export * from "./listBoardMemoryApiV1BoardsBoardIdMemoryGetParams";
|
||||
export * from "./listBoardsApiV1AgentBoardsGetParams";
|
||||
export * from "./listBoardsApiV1BoardsGetParams";
|
||||
export * from "./listBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayloadsGetParams";
|
||||
export * from "./listBoardWebhooksApiV1BoardsBoardIdWebhooksGetParams";
|
||||
export * from "./listGatewaysApiV1GatewaysGetParams";
|
||||
export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams";
|
||||
export * from "./listOrgInvitesApiV1OrganizationsMeInvitesGetParams";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generated by orval v8.3.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { BoardWebhookPayloadRead } from "./boardWebhookPayloadRead";
|
||||
|
||||
export interface LimitOffsetPageTypeVarCustomizedBoardWebhookPayloadRead {
|
||||
items: BoardWebhookPayloadRead[];
|
||||
/** @minimum 1 */
|
||||
limit: number;
|
||||
/** @minimum 0 */
|
||||
offset: number;
|
||||
/** @minimum 0 */
|
||||
total: number;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generated by orval v8.3.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { BoardWebhookRead } from "./boardWebhookRead";
|
||||
|
||||
export interface LimitOffsetPageTypeVarCustomizedBoardWebhookRead {
|
||||
items: BoardWebhookRead[];
|
||||
/** @minimum 1 */
|
||||
limit: number;
|
||||
/** @minimum 0 */
|
||||
offset: number;
|
||||
/** @minimum 0 */
|
||||
total: number;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Generated by orval v8.3.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ListBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayloadsGetParams =
|
||||
{
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Generated by orval v8.3.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ListBoardWebhooksApiV1BoardsBoardIdWebhooksGetParams = {
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
@@ -139,6 +139,7 @@ const SSE_RECONNECT_BACKOFF = {
|
||||
jitter: 0.2,
|
||||
maxMs: 5 * 60_000,
|
||||
} as const;
|
||||
const HAS_ALL_MENTION_RE = /(^|\s)@all\b/i;
|
||||
|
||||
type HeartbeatUnit = "s" | "m" | "h" | "d";
|
||||
|
||||
@@ -231,6 +232,17 @@ export default function BoardGroupDetailPage() {
|
||||
});
|
||||
return ids;
|
||||
}, [boards]);
|
||||
const groupMentionSuggestions = useMemo(() => {
|
||||
const options = new Set<string>(["lead", "all"]);
|
||||
boards.forEach((item) => {
|
||||
(item.tasks ?? []).forEach((task) => {
|
||||
if (task.assignee) {
|
||||
options.add(task.assignee);
|
||||
}
|
||||
});
|
||||
});
|
||||
return [...options];
|
||||
}, [boards]);
|
||||
|
||||
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
@@ -599,7 +611,9 @@ export default function BoardGroupDetailPage() {
|
||||
setIsChatSending(true);
|
||||
setChatError(null);
|
||||
try {
|
||||
const tags = ["chat", ...(chatBroadcast ? ["broadcast"] : [])];
|
||||
const shouldBroadcast =
|
||||
chatBroadcast || HAS_ALL_MENTION_RE.test(trimmed);
|
||||
const tags = ["chat", ...(shouldBroadcast ? ["broadcast"] : [])];
|
||||
const result =
|
||||
await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost(
|
||||
groupId,
|
||||
@@ -641,7 +655,9 @@ export default function BoardGroupDetailPage() {
|
||||
setIsNoteSending(true);
|
||||
setNoteSendError(null);
|
||||
try {
|
||||
const tags = ["note", ...(notesBroadcast ? ["broadcast"] : [])];
|
||||
const shouldBroadcast =
|
||||
notesBroadcast || HAS_ALL_MENTION_RE.test(trimmed);
|
||||
const tags = ["note", ...(shouldBroadcast ? ["broadcast"] : [])];
|
||||
const result =
|
||||
await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost(
|
||||
groupId,
|
||||
@@ -1156,6 +1172,7 @@ export default function BoardGroupDetailPage() {
|
||||
isSending={isChatSending}
|
||||
onSend={sendGroupChat}
|
||||
disabled={!canWriteGroup}
|
||||
mentionSuggestions={groupMentionSuggestions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1242,6 +1259,7 @@ export default function BoardGroupDetailPage() {
|
||||
isSending={isNoteSending}
|
||||
onSend={sendGroupNote}
|
||||
disabled={!canWriteGroup}
|
||||
mentionSuggestions={groupMentionSuggestions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/auth/clerk";
|
||||
import { X } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import {
|
||||
@@ -14,6 +15,14 @@ import {
|
||||
useGetBoardApiV1BoardsBoardIdGet,
|
||||
useUpdateBoardApiV1BoardsBoardIdPatch,
|
||||
} from "@/api/generated/boards/boards";
|
||||
import {
|
||||
getListBoardWebhooksApiV1BoardsBoardIdWebhooksGetQueryKey,
|
||||
type listBoardWebhooksApiV1BoardsBoardIdWebhooksGetResponse,
|
||||
useCreateBoardWebhookApiV1BoardsBoardIdWebhooksPost,
|
||||
useDeleteBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdDelete,
|
||||
useListBoardWebhooksApiV1BoardsBoardIdWebhooksGet,
|
||||
useUpdateBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdPatch,
|
||||
} from "@/api/generated/board-webhooks/board-webhooks";
|
||||
import {
|
||||
type listBoardGroupsApiV1BoardGroupsGetResponse,
|
||||
useListBoardGroupsApiV1BoardGroupsGet,
|
||||
@@ -25,6 +34,7 @@ import {
|
||||
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
||||
import type {
|
||||
BoardGroupRead,
|
||||
BoardWebhookRead,
|
||||
BoardRead,
|
||||
BoardUpdate,
|
||||
} from "@/api/generated/model";
|
||||
@@ -51,8 +61,147 @@ const slugify = (value: string) =>
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "") || "board";
|
||||
|
||||
type WebhookCardProps = {
|
||||
webhook: BoardWebhookRead;
|
||||
isLoading: boolean;
|
||||
isWebhookCreating: boolean;
|
||||
isDeletingWebhook: boolean;
|
||||
isUpdatingWebhook: boolean;
|
||||
copiedWebhookId: string | null;
|
||||
onCopy: (webhook: BoardWebhookRead) => void;
|
||||
onDelete: (webhookId: string) => void;
|
||||
onViewPayloads: (webhookId: string) => void;
|
||||
onUpdate: (webhookId: string, description: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
function WebhookCard({
|
||||
webhook,
|
||||
isLoading,
|
||||
isWebhookCreating,
|
||||
isDeletingWebhook,
|
||||
isUpdatingWebhook,
|
||||
copiedWebhookId,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onViewPayloads,
|
||||
onUpdate,
|
||||
}: WebhookCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draftDescription, setDraftDescription] = useState(webhook.description);
|
||||
|
||||
const isBusy =
|
||||
isLoading || isWebhookCreating || isDeletingWebhook || isUpdatingWebhook;
|
||||
const trimmedDescription = draftDescription.trim();
|
||||
const isDescriptionChanged =
|
||||
trimmedDescription !== webhook.description.trim();
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!trimmedDescription) return;
|
||||
if (!isDescriptionChanged) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
const saved = await onUpdate(webhook.id, trimmedDescription);
|
||||
if (saved) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={webhook.id}
|
||||
className="space-y-3 rounded-lg border border-slate-200 px-4 py-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">
|
||||
Webhook {webhook.id.slice(0, 8)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onCopy(webhook)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{copiedWebhookId === webhook.id ? "Copied" : "Copy endpoint"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onViewPayloads(webhook.id)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
View payloads
|
||||
</Button>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDraftDescription(webhook.description);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isBusy || !trimmedDescription}
|
||||
>
|
||||
{isUpdatingWebhook ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDraftDescription(webhook.description);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(webhook.id)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{isDeletingWebhook ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
value={draftDescription}
|
||||
onChange={(event) => setDraftDescription(event.target.value)}
|
||||
placeholder="Describe exactly what the lead agent should do when payloads arrive."
|
||||
className="min-h-[90px]"
|
||||
disabled={isBusy}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-slate-700">{webhook.description}</p>
|
||||
)}
|
||||
<div className="rounded-md bg-slate-50 px-3 py-2">
|
||||
<code className="break-all text-xs text-slate-700">
|
||||
{webhook.endpoint_url ?? webhook.endpoint_path}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditBoardPage() {
|
||||
const { isSignedIn } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const params = useParams();
|
||||
@@ -72,6 +221,16 @@ export default function EditBoardPage() {
|
||||
);
|
||||
const [boardType, setBoardType] = useState<string | undefined>(undefined);
|
||||
const [objective, setObjective] = useState<string | undefined>(undefined);
|
||||
const [requireApprovalForDone, setRequireApprovalForDone] = useState<
|
||||
boolean | undefined
|
||||
>(undefined);
|
||||
const [requireReviewBeforeDone, setRequireReviewBeforeDone] = useState<
|
||||
boolean | undefined
|
||||
>(undefined);
|
||||
const [
|
||||
blockStatusChangesWithPendingApproval,
|
||||
setBlockStatusChangesWithPendingApproval,
|
||||
] = useState<boolean | undefined>(undefined);
|
||||
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -79,6 +238,9 @@ export default function EditBoardPage() {
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [metricsError, setMetricsError] = useState<string | null>(null);
|
||||
const [webhookDescription, setWebhookDescription] = useState("");
|
||||
const [webhookError, setWebhookError] = useState<string | null>(null);
|
||||
const [copiedWebhookId, setCopiedWebhookId] = useState<string | null>(null);
|
||||
|
||||
const onboardingParam = searchParams.get("onboarding");
|
||||
const searchParamsString = searchParams.toString();
|
||||
@@ -160,6 +322,20 @@ export default function EditBoardPage() {
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
const webhooksQuery = useListBoardWebhooksApiV1BoardsBoardIdWebhooksGet<
|
||||
listBoardWebhooksApiV1BoardsBoardIdWebhooksGetResponse,
|
||||
ApiError
|
||||
>(
|
||||
boardId ?? "",
|
||||
{ limit: 50 },
|
||||
{
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn && isAdmin && boardId),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const updateBoardMutation = useUpdateBoardApiV1BoardsBoardIdPatch<ApiError>({
|
||||
mutation: {
|
||||
@@ -173,6 +349,58 @@ export default function EditBoardPage() {
|
||||
},
|
||||
},
|
||||
});
|
||||
const createWebhookMutation =
|
||||
useCreateBoardWebhookApiV1BoardsBoardIdWebhooksPost<ApiError>({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
if (!boardId) return;
|
||||
setWebhookDescription("");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
getListBoardWebhooksApiV1BoardsBoardIdWebhooksGetQueryKey(
|
||||
boardId,
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
setWebhookError(err.message || "Unable to create webhook.");
|
||||
},
|
||||
},
|
||||
});
|
||||
const deleteWebhookMutation =
|
||||
useDeleteBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdDelete<ApiError>({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
if (!boardId) return;
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
getListBoardWebhooksApiV1BoardsBoardIdWebhooksGetQueryKey(
|
||||
boardId,
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
setWebhookError(err.message || "Unable to delete webhook.");
|
||||
},
|
||||
},
|
||||
});
|
||||
const updateWebhookMutation =
|
||||
useUpdateBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdPatch<ApiError>({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
if (!boardId) return;
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
getListBoardWebhooksApiV1BoardsBoardIdWebhooksGetQueryKey(
|
||||
boardId,
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
setWebhookError(err.message || "Unable to update webhook.");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const gateways = useMemo(() => {
|
||||
if (gatewaysQuery.data?.status !== 200) return [];
|
||||
@@ -189,6 +417,14 @@ export default function EditBoardPage() {
|
||||
boardGroupId ?? baseBoard?.board_group_id ?? "none";
|
||||
const resolvedBoardType = boardType ?? baseBoard?.board_type ?? "goal";
|
||||
const resolvedObjective = objective ?? baseBoard?.objective ?? "";
|
||||
const resolvedRequireApprovalForDone =
|
||||
requireApprovalForDone ?? baseBoard?.require_approval_for_done ?? true;
|
||||
const resolvedRequireReviewBeforeDone =
|
||||
requireReviewBeforeDone ?? baseBoard?.require_review_before_done ?? false;
|
||||
const resolvedBlockStatusChangesWithPendingApproval =
|
||||
blockStatusChangesWithPendingApproval ??
|
||||
baseBoard?.block_status_changes_with_pending_approval ??
|
||||
false;
|
||||
const resolvedSuccessMetrics =
|
||||
successMetrics ??
|
||||
(baseBoard?.success_metrics
|
||||
@@ -198,6 +434,19 @@ export default function EditBoardPage() {
|
||||
targetDate ?? toLocalDateInput(baseBoard?.target_date);
|
||||
|
||||
const displayGatewayId = resolvedGatewayId || gateways[0]?.id || "";
|
||||
const isWebhookCreating = createWebhookMutation.isPending;
|
||||
const deletingWebhookId =
|
||||
deleteWebhookMutation.isPending && deleteWebhookMutation.variables
|
||||
? deleteWebhookMutation.variables.webhookId
|
||||
: null;
|
||||
const updatingWebhookId =
|
||||
updateWebhookMutation.isPending && updateWebhookMutation.variables
|
||||
? updateWebhookMutation.variables.webhookId
|
||||
: null;
|
||||
const isWebhookBusy =
|
||||
isWebhookCreating ||
|
||||
deleteWebhookMutation.isPending ||
|
||||
updateWebhookMutation.isPending;
|
||||
|
||||
const isLoading =
|
||||
gatewaysQuery.isLoading ||
|
||||
@@ -210,6 +459,8 @@ export default function EditBoardPage() {
|
||||
groupsQuery.error?.message ??
|
||||
boardQuery.error?.message ??
|
||||
null;
|
||||
const webhookErrorMessage =
|
||||
webhookError ?? webhooksQuery.error?.message ?? null;
|
||||
|
||||
const isFormReady = Boolean(
|
||||
resolvedName.trim() && resolvedDescription.trim() && displayGatewayId,
|
||||
@@ -232,12 +483,21 @@ export default function EditBoardPage() {
|
||||
],
|
||||
[groups],
|
||||
);
|
||||
const webhooks = useMemo<BoardWebhookRead[]>(() => {
|
||||
if (webhooksQuery.data?.status !== 200) return [];
|
||||
return webhooksQuery.data.data.items ?? [];
|
||||
}, [webhooksQuery.data]);
|
||||
|
||||
const handleOnboardingConfirmed = (updated: BoardRead) => {
|
||||
setBoard(updated);
|
||||
setDescription(updated.description ?? "");
|
||||
setBoardType(updated.board_type ?? "goal");
|
||||
setObjective(updated.objective ?? "");
|
||||
setRequireApprovalForDone(updated.require_approval_for_done ?? true);
|
||||
setRequireReviewBeforeDone(updated.require_review_before_done ?? false);
|
||||
setBlockStatusChangesWithPendingApproval(
|
||||
updated.block_status_changes_with_pending_approval ?? false,
|
||||
);
|
||||
setSuccessMetrics(
|
||||
updated.success_metrics
|
||||
? JSON.stringify(updated.success_metrics, null, 2)
|
||||
@@ -271,7 +531,7 @@ export default function EditBoardPage() {
|
||||
setMetricsError(null);
|
||||
|
||||
let parsedMetrics: Record<string, unknown> | null = null;
|
||||
if (resolvedSuccessMetrics.trim()) {
|
||||
if (resolvedBoardType !== "general" && resolvedSuccessMetrics.trim()) {
|
||||
try {
|
||||
parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record<
|
||||
string,
|
||||
@@ -291,14 +551,92 @@ export default function EditBoardPage() {
|
||||
board_group_id:
|
||||
resolvedBoardGroupId === "none" ? null : resolvedBoardGroupId,
|
||||
board_type: resolvedBoardType,
|
||||
objective: resolvedObjective.trim() || null,
|
||||
success_metrics: parsedMetrics,
|
||||
target_date: localDateInputToUtcIso(resolvedTargetDate),
|
||||
objective:
|
||||
resolvedBoardType === "general"
|
||||
? null
|
||||
: resolvedObjective.trim() || null,
|
||||
require_approval_for_done: resolvedRequireApprovalForDone,
|
||||
require_review_before_done: resolvedRequireReviewBeforeDone,
|
||||
block_status_changes_with_pending_approval:
|
||||
resolvedBlockStatusChangesWithPendingApproval,
|
||||
success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
|
||||
target_date:
|
||||
resolvedBoardType === "general"
|
||||
? null
|
||||
: localDateInputToUtcIso(resolvedTargetDate),
|
||||
};
|
||||
|
||||
updateBoardMutation.mutate({ boardId, data: payload });
|
||||
};
|
||||
|
||||
const handleCreateWebhook = () => {
|
||||
if (!boardId) return;
|
||||
const trimmedDescription = webhookDescription.trim();
|
||||
if (!trimmedDescription) {
|
||||
setWebhookError("Webhook instruction is required.");
|
||||
return;
|
||||
}
|
||||
setWebhookError(null);
|
||||
createWebhookMutation.mutate({
|
||||
boardId,
|
||||
data: {
|
||||
description: trimmedDescription,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteWebhook = (webhookId: string) => {
|
||||
if (!boardId) return;
|
||||
if (deleteWebhookMutation.isPending) return;
|
||||
setWebhookError(null);
|
||||
deleteWebhookMutation.mutate({ boardId, webhookId });
|
||||
};
|
||||
|
||||
const handleUpdateWebhook = async (
|
||||
webhookId: string,
|
||||
description: string,
|
||||
): Promise<boolean> => {
|
||||
if (!boardId) return false;
|
||||
if (updateWebhookMutation.isPending) return false;
|
||||
const trimmedDescription = description.trim();
|
||||
if (!trimmedDescription) {
|
||||
setWebhookError("Webhook instruction is required.");
|
||||
return false;
|
||||
}
|
||||
setWebhookError(null);
|
||||
try {
|
||||
await updateWebhookMutation.mutateAsync({
|
||||
boardId,
|
||||
webhookId,
|
||||
data: { description: trimmedDescription },
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyWebhookEndpoint = async (webhook: BoardWebhookRead) => {
|
||||
const endpoint = (webhook.endpoint_url ?? webhook.endpoint_path).trim();
|
||||
try {
|
||||
await navigator.clipboard.writeText(endpoint);
|
||||
setCopiedWebhookId(webhook.id);
|
||||
window.setTimeout(() => {
|
||||
setCopiedWebhookId((current) =>
|
||||
current === webhook.id ? null : current,
|
||||
);
|
||||
}, 1500);
|
||||
} catch {
|
||||
setWebhookError("Unable to copy webhook endpoint.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewWebhookPayloads = (webhookId: string) => {
|
||||
if (!boardId) return;
|
||||
router.push(`/boards/${boardId}/webhooks/${webhookId}/payloads`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPageLayout
|
||||
@@ -408,17 +746,19 @@ export default function EditBoardPage() {
|
||||
agents.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Target date
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={resolvedTargetDate}
|
||||
onChange={(event) => setTargetDate(event.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{resolvedBoardType !== "general" ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Target date
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={resolvedTargetDate}
|
||||
onChange={(event) => setTargetDate(event.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -434,37 +774,157 @@ export default function EditBoardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Objective
|
||||
</label>
|
||||
<Textarea
|
||||
value={resolvedObjective}
|
||||
onChange={(event) => setObjective(event.target.value)}
|
||||
placeholder="What should this board achieve?"
|
||||
className="min-h-[120px]"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{resolvedBoardType !== "general" ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Objective
|
||||
</label>
|
||||
<Textarea
|
||||
value={resolvedObjective}
|
||||
onChange={(event) => setObjective(event.target.value)}
|
||||
placeholder="What should this board achieve?"
|
||||
className="min-h-[120px]"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Success metrics (JSON)
|
||||
</label>
|
||||
<Textarea
|
||||
value={resolvedSuccessMetrics}
|
||||
onChange={(event) => setSuccessMetrics(event.target.value)}
|
||||
placeholder='e.g. { "target": "Launch by week 2" }'
|
||||
className="min-h-[140px] font-mono text-xs"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Add key outcomes so the lead agent can measure progress.
|
||||
</p>
|
||||
{metricsError ? (
|
||||
<p className="text-xs text-red-500">{metricsError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Success metrics (JSON)
|
||||
</label>
|
||||
<Textarea
|
||||
value={resolvedSuccessMetrics}
|
||||
onChange={(event) => setSuccessMetrics(event.target.value)}
|
||||
placeholder='e.g. { "target": "Launch by week 2" }'
|
||||
className="min-h-[140px] font-mono text-xs"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Add key outcomes so the lead agent can measure progress.
|
||||
</p>
|
||||
{metricsError ? (
|
||||
<p className="text-xs text-red-500">{metricsError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-3 border-t border-slate-200 pt-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900">
|
||||
Rules
|
||||
</h2>
|
||||
<p className="text-xs text-slate-600">
|
||||
Configure board-level workflow enforcement.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-lg border border-slate-200 px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={resolvedRequireApprovalForDone}
|
||||
aria-label="Require approval"
|
||||
onClick={() =>
|
||||
setRequireApprovalForDone(!resolvedRequireApprovalForDone)
|
||||
}
|
||||
disabled={isLoading}
|
||||
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
||||
resolvedRequireApprovalForDone
|
||||
? "border-emerald-600 bg-emerald-600"
|
||||
: "border-slate-300 bg-slate-200"
|
||||
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
|
||||
resolvedRequireApprovalForDone
|
||||
? "translate-x-5"
|
||||
: "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-medium text-slate-900">
|
||||
Require approval
|
||||
</span>
|
||||
<span className="block text-xs text-slate-600">
|
||||
Require at least one linked approval in{" "}
|
||||
<code>approved</code> state before a task can be marked{" "}
|
||||
<code>done</code>.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-lg border border-slate-200 px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={resolvedRequireReviewBeforeDone}
|
||||
aria-label="Require review before done"
|
||||
onClick={() =>
|
||||
setRequireReviewBeforeDone(!resolvedRequireReviewBeforeDone)
|
||||
}
|
||||
disabled={isLoading}
|
||||
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
||||
resolvedRequireReviewBeforeDone
|
||||
? "border-emerald-600 bg-emerald-600"
|
||||
: "border-slate-300 bg-slate-200"
|
||||
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
|
||||
resolvedRequireReviewBeforeDone
|
||||
? "translate-x-5"
|
||||
: "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-medium text-slate-900">
|
||||
Require review before done
|
||||
</span>
|
||||
<span className="block text-xs text-slate-600">
|
||||
Tasks must move to <code>review</code> before they can be
|
||||
marked <code>done</code>.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-lg border border-slate-200 px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={resolvedBlockStatusChangesWithPendingApproval}
|
||||
aria-label="Block status changes with pending approval"
|
||||
onClick={() =>
|
||||
setBlockStatusChangesWithPendingApproval(
|
||||
!resolvedBlockStatusChangesWithPendingApproval,
|
||||
)
|
||||
}
|
||||
disabled={isLoading}
|
||||
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
||||
resolvedBlockStatusChangesWithPendingApproval
|
||||
? "border-emerald-600 bg-emerald-600"
|
||||
: "border-slate-300 bg-slate-200"
|
||||
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
|
||||
resolvedBlockStatusChangesWithPendingApproval
|
||||
? "translate-x-5"
|
||||
: "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-medium text-slate-900">
|
||||
Block status changes with pending approval
|
||||
</span>
|
||||
<span className="block text-xs text-slate-600">
|
||||
Prevent status transitions while any linked approval is in{" "}
|
||||
<code>pending</code> state.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{gateways.length === 0 ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
@@ -494,6 +954,84 @@ export default function EditBoardPage() {
|
||||
{isLoading ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4 border-t border-slate-200 pt-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900">
|
||||
Webhooks
|
||||
</h2>
|
||||
<p className="text-xs text-slate-600">
|
||||
Add inbound webhook endpoints so the lead agent can react to
|
||||
external events.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-lg border border-slate-200 px-4 py-4">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Lead agent instruction
|
||||
</label>
|
||||
<Textarea
|
||||
value={webhookDescription}
|
||||
onChange={(event) =>
|
||||
setWebhookDescription(event.target.value)
|
||||
}
|
||||
placeholder="Describe exactly what the lead agent should do when payloads arrive."
|
||||
className="min-h-[90px]"
|
||||
disabled={isLoading || isWebhookBusy}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreateWebhook}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isWebhookBusy ||
|
||||
!baseBoard ||
|
||||
!webhookDescription.trim()
|
||||
}
|
||||
>
|
||||
{createWebhookMutation.isPending
|
||||
? "Creating webhook…"
|
||||
: "Create webhook"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{webhookErrorMessage ? (
|
||||
<p className="text-sm text-red-500">{webhookErrorMessage}</p>
|
||||
) : null}
|
||||
|
||||
{webhooksQuery.isLoading ? (
|
||||
<p className="text-sm text-slate-500">Loading webhooks…</p>
|
||||
) : null}
|
||||
|
||||
{!webhooksQuery.isLoading && webhooks.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-slate-300 px-4 py-3 text-sm text-slate-600">
|
||||
No webhooks configured yet.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{webhooks.map((webhook) => {
|
||||
const isDeletingWebhook = deletingWebhookId === webhook.id;
|
||||
const isUpdatingWebhook = updatingWebhookId === webhook.id;
|
||||
return (
|
||||
<WebhookCard
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
isLoading={isLoading}
|
||||
isWebhookCreating={isWebhookCreating}
|
||||
isDeletingWebhook={isDeletingWebhook}
|
||||
isUpdatingWebhook={isUpdatingWebhook}
|
||||
copiedWebhookId={copiedWebhookId}
|
||||
onCopy={handleCopyWebhookEndpoint}
|
||||
onDelete={handleDeleteWebhook}
|
||||
onViewPayloads={handleViewWebhookPayloads}
|
||||
onUpdate={handleUpdateWebhook}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardPageLayout>
|
||||
|
||||
@@ -92,7 +92,12 @@ import type {
|
||||
TaskRead,
|
||||
} from "@/api/generated/model";
|
||||
import { createExponentialBackoff } from "@/lib/backoff";
|
||||
import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime";
|
||||
import {
|
||||
apiDatetimeToMs,
|
||||
localDateInputToUtcIso,
|
||||
parseApiDatetime,
|
||||
toLocalDateInput,
|
||||
} from "@/lib/datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePageActive } from "@/hooks/usePageActive";
|
||||
|
||||
@@ -738,8 +743,6 @@ export default function BoardDetailPage() {
|
||||
const liveFeedHistoryLoadedRef = useRef(false);
|
||||
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
||||
const [commentsError, setCommentsError] = useState<string | null>(null);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const taskCommentInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [isPostingComment, setIsPostingComment] = useState(false);
|
||||
const [postCommentError, setPostCommentError] = useState<string | null>(null);
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
@@ -1001,6 +1004,7 @@ export default function BoardDetailPage() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [priority, setPriority] = useState("medium");
|
||||
const [createDueDate, setCreateDueDate] = useState("");
|
||||
const [createTagIds, setCreateTagIds] = useState<string[]>([]);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
@@ -1009,6 +1013,7 @@ export default function BoardDetailPage() {
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editStatus, setEditStatus] = useState<TaskStatus>("inbox");
|
||||
const [editPriority, setEditPriority] = useState("medium");
|
||||
const [editDueDate, setEditDueDate] = useState("");
|
||||
const [editAssigneeId, setEditAssigneeId] = useState("");
|
||||
const [editTagIds, setEditTagIds] = useState<string[]>([]);
|
||||
const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState<string[]>(
|
||||
@@ -1484,6 +1489,7 @@ export default function BoardDetailPage() {
|
||||
setEditDescription("");
|
||||
setEditStatus("inbox");
|
||||
setEditPriority("medium");
|
||||
setEditDueDate("");
|
||||
setEditAssigneeId("");
|
||||
setEditTagIds([]);
|
||||
setEditDependsOnTaskIds([]);
|
||||
@@ -1494,6 +1500,7 @@ export default function BoardDetailPage() {
|
||||
setEditDescription(selectedTask.description ?? "");
|
||||
setEditStatus(selectedTask.status);
|
||||
setEditPriority(selectedTask.priority);
|
||||
setEditDueDate(toLocalDateInput(selectedTask.due_at));
|
||||
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
|
||||
setEditTagIds(selectedTask.tag_ids ?? []);
|
||||
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
|
||||
@@ -1802,6 +1809,7 @@ export default function BoardDetailPage() {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setPriority("medium");
|
||||
setCreateDueDate("");
|
||||
setCreateTagIds([]);
|
||||
setCreateError(null);
|
||||
};
|
||||
@@ -1821,6 +1829,7 @@ export default function BoardDetailPage() {
|
||||
description: description.trim() || null,
|
||||
status: "inbox",
|
||||
priority,
|
||||
due_at: localDateInputToUtcIso(createDueDate),
|
||||
tag_ids: createTagIds,
|
||||
});
|
||||
if (result.status !== 200) throw new Error("Unable to create task.");
|
||||
@@ -1973,6 +1982,15 @@ export default function BoardDetailPage() {
|
||||
() => agents.filter((agent) => !agent.is_board_lead),
|
||||
[agents],
|
||||
);
|
||||
const boardChatMentionSuggestions = useMemo(() => {
|
||||
const options = new Set<string>(["lead"]);
|
||||
agents.forEach((agent) => {
|
||||
if (agent.name) {
|
||||
options.add(agent.name);
|
||||
}
|
||||
});
|
||||
return [...options];
|
||||
}, [agents]);
|
||||
|
||||
const tagById = useMemo(() => {
|
||||
const map = new Map<string, TagRead>();
|
||||
@@ -2045,6 +2063,7 @@ export default function BoardDetailPage() {
|
||||
const normalizedTitle = editTitle.trim();
|
||||
const normalizedDescription = editDescription.trim();
|
||||
const currentDescription = (selectedTask.description ?? "").trim();
|
||||
const currentDueDate = toLocalDateInput(selectedTask.due_at);
|
||||
const currentAssignee = selectedTask.assigned_agent_id ?? "";
|
||||
const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|");
|
||||
const nextTags = [...editTagIds].sort().join("|");
|
||||
@@ -2057,12 +2076,14 @@ export default function BoardDetailPage() {
|
||||
normalizedDescription !== currentDescription ||
|
||||
editStatus !== selectedTask.status ||
|
||||
editPriority !== selectedTask.priority ||
|
||||
editDueDate !== currentDueDate ||
|
||||
editAssigneeId !== currentAssignee ||
|
||||
currentTags !== nextTags ||
|
||||
currentDeps !== nextDeps
|
||||
);
|
||||
}, [
|
||||
editAssigneeId,
|
||||
editDueDate,
|
||||
editTagIds,
|
||||
editDependsOnTaskIds,
|
||||
editDescription,
|
||||
@@ -2205,7 +2226,6 @@ export default function BoardDetailPage() {
|
||||
setSelectedTask(null);
|
||||
setComments([]);
|
||||
setCommentsError(null);
|
||||
setNewComment("");
|
||||
setPostCommentError(null);
|
||||
setIsEditDialogOpen(false);
|
||||
};
|
||||
@@ -2237,12 +2257,12 @@ export default function BoardDetailPage() {
|
||||
setIsLiveFeedOpen(false);
|
||||
};
|
||||
|
||||
const handlePostComment = async () => {
|
||||
if (!selectedTask || !boardId || !isSignedIn) return;
|
||||
const trimmed = newComment.trim();
|
||||
const handlePostComment = async (message: string): Promise<boolean> => {
|
||||
if (!selectedTask || !boardId || !isSignedIn) return false;
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) {
|
||||
setPostCommentError("Write a message before sending.");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
setIsPostingComment(true);
|
||||
setPostCommentError(null);
|
||||
@@ -2256,14 +2276,14 @@ export default function BoardDetailPage() {
|
||||
if (result.status !== 200) throw new Error("Unable to send message.");
|
||||
const created = result.data;
|
||||
setComments((prev) => [created, ...prev]);
|
||||
setNewComment("");
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = formatActionError(err, "Unable to send message.");
|
||||
setPostCommentError(message);
|
||||
pushToast(message);
|
||||
return false;
|
||||
} finally {
|
||||
setIsPostingComment(false);
|
||||
taskCommentInputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2285,6 +2305,8 @@ export default function BoardDetailPage() {
|
||||
const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|");
|
||||
const nextTags = [...editTagIds].sort().join("|");
|
||||
const tagsChanged = currentTags !== nextTags;
|
||||
const currentDueDate = toLocalDateInput(selectedTask.due_at);
|
||||
const dueDateChanged = editDueDate !== currentDueDate;
|
||||
|
||||
const updatePayload: Parameters<
|
||||
typeof updateTaskApiV1BoardsBoardIdTasksTaskIdPatch
|
||||
@@ -2302,6 +2324,9 @@ export default function BoardDetailPage() {
|
||||
if (tagsChanged) {
|
||||
updatePayload.tag_ids = editTagIds;
|
||||
}
|
||||
if (dueDateChanged) {
|
||||
updatePayload.due_at = localDateInputToUtcIso(editDueDate);
|
||||
}
|
||||
|
||||
const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch(
|
||||
boardId,
|
||||
@@ -2362,6 +2387,7 @@ export default function BoardDetailPage() {
|
||||
setEditDescription(selectedTask.description ?? "");
|
||||
setEditStatus(selectedTask.status);
|
||||
setEditPriority(selectedTask.priority);
|
||||
setEditDueDate(toLocalDateInput(selectedTask.due_at));
|
||||
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
|
||||
setEditTagIds(selectedTask.tag_ids ?? []);
|
||||
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
|
||||
@@ -3520,27 +3546,16 @@ export default function BoardDetailPage() {
|
||||
Comments
|
||||
</p>
|
||||
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<Textarea
|
||||
ref={taskCommentInputRef}
|
||||
value={newComment}
|
||||
onChange={(event) => setNewComment(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.shiftKey) return;
|
||||
if (!canWrite) return;
|
||||
event.preventDefault();
|
||||
if (isPostingComment) return;
|
||||
if (!newComment.trim()) return;
|
||||
void handlePostComment();
|
||||
}}
|
||||
<BoardChatComposer
|
||||
placeholder={
|
||||
canWrite
|
||||
? "Write a message for the assigned agent…"
|
||||
? "Write a message for the assigned agent. Tag @lead or @name."
|
||||
: "Read-only access. Comments are disabled."
|
||||
}
|
||||
className="min-h-[80px] bg-white"
|
||||
disabled={!canWrite || isPostingComment}
|
||||
isSending={isPostingComment}
|
||||
onSend={handlePostComment}
|
||||
disabled={!canWrite}
|
||||
mentionSuggestions={boardChatMentionSuggestions}
|
||||
/>
|
||||
{postCommentError ? (
|
||||
<p className="text-xs text-rose-600">{postCommentError}</p>
|
||||
@@ -3550,18 +3565,6 @@ export default function BoardDetailPage() {
|
||||
Read-only access. You cannot post comments on this board.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handlePostComment}
|
||||
disabled={
|
||||
!canWrite || isPostingComment || !newComment.trim()
|
||||
}
|
||||
title={canWrite ? "Send message" : "Read-only access"}
|
||||
>
|
||||
{isPostingComment ? "Sending…" : "Send message"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isCommentsLoading ? (
|
||||
<p className="text-sm text-slate-500">Loading comments…</p>
|
||||
@@ -3638,6 +3641,7 @@ export default function BoardDetailPage() {
|
||||
isSending={isChatSending}
|
||||
onSend={handleSendChat}
|
||||
disabled={!canWrite}
|
||||
mentionSuggestions={boardChatMentionSuggestions}
|
||||
placeholder={
|
||||
canWrite
|
||||
? "Message the board lead. Tag agents with @name."
|
||||
@@ -3803,6 +3807,17 @@ export default function BoardDetailPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Due date
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={editDueDate}
|
||||
onChange={(event) => setEditDueDate(event.target.value)}
|
||||
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
@@ -4094,6 +4109,17 @@ export default function BoardDetailPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-strong">
|
||||
Due date
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={createDueDate}
|
||||
onChange={(event) => setCreateDueDate(event.target.value)}
|
||||
disabled={!canWrite || isCreating}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-sm font-medium text-strong">Tags</label>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/auth/clerk";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import {
|
||||
type getBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdGetResponse,
|
||||
type listBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayloadsGetResponse,
|
||||
useGetBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdGet,
|
||||
useListBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayloadsGet,
|
||||
} from "@/api/generated/board-webhooks/board-webhooks";
|
||||
import type { BoardWebhookPayloadRead } from "@/api/generated/model";
|
||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
||||
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
const stringifyPayload = (value: unknown) => {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
export default function WebhookPayloadsPage() {
|
||||
const { isSignedIn } = useAuth();
|
||||
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
|
||||
const boardIdParam = params?.boardId;
|
||||
const webhookIdParam = params?.webhookId;
|
||||
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
|
||||
const webhookId = Array.isArray(webhookIdParam)
|
||||
? webhookIdParam[0]
|
||||
: webhookIdParam;
|
||||
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
const webhookQuery = useGetBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdGet<
|
||||
getBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdGetResponse,
|
||||
ApiError
|
||||
>(boardId ?? "", webhookId ?? "", {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn && isAdmin && boardId && webhookId),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
|
||||
const payloadsQuery =
|
||||
useListBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayloadsGet<
|
||||
listBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayloadsGetResponse,
|
||||
ApiError
|
||||
>(
|
||||
boardId ?? "",
|
||||
webhookId ?? "",
|
||||
{ limit: PAGE_LIMIT, offset },
|
||||
{
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn && isAdmin && boardId && webhookId),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const webhook =
|
||||
webhookQuery.data?.status === 200 ? webhookQuery.data.data : null;
|
||||
const payloadPage =
|
||||
payloadsQuery.data?.status === 200 ? payloadsQuery.data.data : null;
|
||||
const payloads = payloadPage?.items ?? [];
|
||||
|
||||
const total = payloadPage?.total ?? 0;
|
||||
const currentPage = Math.floor(offset / PAGE_LIMIT) + 1;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_LIMIT));
|
||||
const hasPrev = offset > 0;
|
||||
const hasNext = offset + PAGE_LIMIT < total;
|
||||
|
||||
const errorMessage =
|
||||
payloadsQuery.error?.message ?? webhookQuery.error?.message ?? null;
|
||||
const isLoading = payloadsQuery.isLoading || webhookQuery.isLoading;
|
||||
|
||||
const payloadTitle = useMemo(() => {
|
||||
if (!webhook) return "Webhook payloads";
|
||||
return `Webhook ${webhook.id.slice(0, 8)} payloads`;
|
||||
}, [webhook]);
|
||||
|
||||
return (
|
||||
<DashboardPageLayout
|
||||
signedOut={{
|
||||
message: "Sign in to view webhook payloads.",
|
||||
forceRedirectUrl: `/boards/${boardId}/webhooks/${webhookId}/payloads`,
|
||||
signUpForceRedirectUrl: `/boards/${boardId}/webhooks/${webhookId}/payloads`,
|
||||
}}
|
||||
title="Webhook payloads"
|
||||
description="Review payloads received by this webhook."
|
||||
isAdmin={isAdmin}
|
||||
adminOnlyMessage="Only organization owners and admins can view webhook payloads."
|
||||
>
|
||||
<div className="space-y-4 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold text-slate-900">
|
||||
{payloadTitle}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
{webhook?.description ?? "Loading webhook details..."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push(`/boards/${boardId}/edit`)}
|
||||
>
|
||||
Back to board settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{webhook ? (
|
||||
<div className="rounded-md bg-slate-50 px-3 py-2">
|
||||
<code className="break-all text-xs text-slate-700">
|
||||
{webhook.endpoint_url ?? webhook.endpoint_path}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2">
|
||||
<p className="text-sm text-slate-700">
|
||||
{total} payload{total === 1 ? "" : "s"} total
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setOffset((current) => Math.max(0, current - PAGE_LIMIT))
|
||||
}
|
||||
disabled={!hasPrev || isLoading}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-xs text-slate-600">
|
||||
Page {currentPage} of {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOffset((current) => current + PAGE_LIMIT)}
|
||||
disabled={!hasNext || isLoading}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-slate-500">Loading payloads...</p>
|
||||
) : null}
|
||||
|
||||
{!isLoading && payloads.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-slate-300 px-4 py-3 text-sm text-slate-600">
|
||||
No payloads received for this webhook yet.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{payloads.map((payload: BoardWebhookPayloadRead) => (
|
||||
<div
|
||||
key={payload.id}
|
||||
className="space-y-3 rounded-lg border border-slate-200 px-4 py-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">
|
||||
Payload {payload.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{new Date(payload.received_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2 text-xs text-slate-600 md:grid-cols-2">
|
||||
<p>
|
||||
Content type:{" "}
|
||||
<code>{payload.content_type ?? "not provided"}</code>
|
||||
</p>
|
||||
<p>
|
||||
Source IP: <code>{payload.source_ip ?? "not provided"}</code>
|
||||
</p>
|
||||
</div>
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-slate-900/95 p-3 text-xs text-slate-100">
|
||||
{stringifyPayload(payload.payload)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||
@@ -24,9 +25,19 @@ import { Activity, PenSquare, Timer, Users } from "lucide-react";
|
||||
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import DropdownSelect from "@/components/ui/dropdown-select";
|
||||
import DropdownSelect, {
|
||||
type DropdownSelectOption,
|
||||
} from "@/components/ui/dropdown-select";
|
||||
import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import {
|
||||
type listBoardGroupsApiV1BoardGroupsGetResponse,
|
||||
useListBoardGroupsApiV1BoardGroupsGet,
|
||||
} from "@/api/generated/board-groups/board-groups";
|
||||
import {
|
||||
type listBoardsApiV1BoardsGetResponse,
|
||||
useListBoardsApiV1BoardsGet,
|
||||
} from "@/api/generated/boards/boards";
|
||||
import {
|
||||
type dashboardMetricsApiV1MetricsDashboardGetResponse,
|
||||
useDashboardMetricsApiV1MetricsDashboardGet,
|
||||
@@ -85,6 +96,7 @@ const DASHBOARD_RANGE_OPTIONS: Array<{ value: RangeKey; label: string }> = [
|
||||
const DASHBOARD_RANGE_SET = new Set<RangeKey>(
|
||||
DASHBOARD_RANGE_OPTIONS.map((option) => option.value),
|
||||
);
|
||||
const ALL_FILTER_VALUE = "all";
|
||||
const DEFAULT_RANGE: RangeKey = "7d";
|
||||
|
||||
const formatPeriod = (value: string, bucket: BucketKey) => {
|
||||
@@ -251,15 +263,111 @@ export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const selectedRangeParam = searchParams.get("range");
|
||||
const selectedGroupParam = searchParams.get("group");
|
||||
const selectedBoardParam = searchParams.get("board");
|
||||
const selectedRange: RangeKey =
|
||||
selectedRangeParam && DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey)
|
||||
selectedRangeParam &&
|
||||
DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey)
|
||||
? (selectedRangeParam as RangeKey)
|
||||
: DEFAULT_RANGE;
|
||||
const selectedGroupId =
|
||||
selectedGroupParam && selectedGroupParam !== ALL_FILTER_VALUE
|
||||
? selectedGroupParam
|
||||
: null;
|
||||
const selectedBoardId =
|
||||
selectedBoardParam && selectedBoardParam !== ALL_FILTER_VALUE
|
||||
? selectedBoardParam
|
||||
: null;
|
||||
|
||||
const boardsQuery = useListBoardsApiV1BoardsGet<
|
||||
listBoardsApiV1BoardsGetResponse,
|
||||
ApiError
|
||||
>(
|
||||
{ limit: 200 },
|
||||
{
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: "always",
|
||||
},
|
||||
},
|
||||
);
|
||||
const boardGroupsQuery = useListBoardGroupsApiV1BoardGroupsGet<
|
||||
listBoardGroupsApiV1BoardGroupsGetResponse,
|
||||
ApiError
|
||||
>(
|
||||
{ limit: 200 },
|
||||
{
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: "always",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const boards = useMemo(
|
||||
() =>
|
||||
boardsQuery.data?.status === 200
|
||||
? [...(boardsQuery.data.data.items ?? [])].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
: [],
|
||||
[boardsQuery.data],
|
||||
);
|
||||
const boardGroups = useMemo(
|
||||
() =>
|
||||
boardGroupsQuery.data?.status === 200
|
||||
? [...(boardGroupsQuery.data.data.items ?? [])].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
: [],
|
||||
[boardGroupsQuery.data],
|
||||
);
|
||||
|
||||
const filteredBoards = useMemo(
|
||||
() =>
|
||||
selectedGroupId
|
||||
? boards.filter((board) => board.board_group_id === selectedGroupId)
|
||||
: boards,
|
||||
[boards, selectedGroupId],
|
||||
);
|
||||
const selectedBoard = useMemo(
|
||||
() => boards.find((board) => board.id === selectedBoardId) ?? null,
|
||||
[boards, selectedBoardId],
|
||||
);
|
||||
const selectedGroup = useMemo(
|
||||
() => boardGroups.find((group) => group.id === selectedGroupId) ?? null,
|
||||
[boardGroups, selectedGroupId],
|
||||
);
|
||||
|
||||
const boardGroupOptions = useMemo<DropdownSelectOption[]>(
|
||||
() => [
|
||||
{ value: ALL_FILTER_VALUE, label: "All groups" },
|
||||
...boardGroups.map((group) => ({ value: group.id, label: group.name })),
|
||||
],
|
||||
[boardGroups],
|
||||
);
|
||||
const boardOptions = useMemo<DropdownSelectOption[]>(
|
||||
() => [
|
||||
{ value: ALL_FILTER_VALUE, label: "All boards" },
|
||||
...filteredBoards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
})),
|
||||
],
|
||||
[filteredBoards],
|
||||
);
|
||||
|
||||
const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet<
|
||||
dashboardMetricsApiV1MetricsDashboardGetResponse,
|
||||
ApiError
|
||||
>(
|
||||
{ range_key: selectedRange },
|
||||
{
|
||||
range_key: selectedRange,
|
||||
board_id: selectedBoardId ?? undefined,
|
||||
group_id: selectedGroupId ?? undefined,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
@@ -355,6 +463,75 @@ export default function DashboardPage() {
|
||||
triggerClassName="h-9 min-w-[150px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
contentClassName="rounded-lg border border-slate-200"
|
||||
/>
|
||||
<DropdownSelect
|
||||
value={selectedGroupId ?? ALL_FILTER_VALUE}
|
||||
onValueChange={(value) => {
|
||||
const nextGroupId =
|
||||
value === ALL_FILTER_VALUE ? null : value;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (nextGroupId) {
|
||||
params.set("group", nextGroupId);
|
||||
} else {
|
||||
params.delete("group");
|
||||
}
|
||||
if (selectedBoardId) {
|
||||
const selectedBoardRecord = boards.find(
|
||||
(board) => board.id === selectedBoardId,
|
||||
);
|
||||
const boardVisibleInScope = nextGroupId
|
||||
? selectedBoardRecord?.board_group_id === nextGroupId
|
||||
: true;
|
||||
if (!boardVisibleInScope) {
|
||||
params.delete("board");
|
||||
}
|
||||
}
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
}}
|
||||
options={boardGroupOptions}
|
||||
ariaLabel="Dashboard board group filter"
|
||||
placeholder="All groups"
|
||||
triggerClassName="h-9 min-w-[170px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
contentClassName="rounded-lg border border-slate-200"
|
||||
searchEnabled={false}
|
||||
disabled={boardGroupsQuery.isLoading}
|
||||
/>
|
||||
<DropdownSelect
|
||||
value={selectedBoardId ?? ALL_FILTER_VALUE}
|
||||
onValueChange={(value) => {
|
||||
const nextBoardId =
|
||||
value === ALL_FILTER_VALUE ? null : value;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (nextBoardId) {
|
||||
params.set("board", nextBoardId);
|
||||
} else {
|
||||
params.delete("board");
|
||||
}
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
}}
|
||||
options={boardOptions}
|
||||
ariaLabel="Dashboard board filter"
|
||||
placeholder="All boards"
|
||||
triggerClassName="h-9 min-w-[170px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
contentClassName="rounded-lg border border-slate-200"
|
||||
searchEnabled={false}
|
||||
disabled={boardsQuery.isLoading || boardOptions.length <= 1}
|
||||
/>
|
||||
{selectedGroup ? (
|
||||
<Link
|
||||
href={`/board-groups/${selectedGroup.id}`}
|
||||
className="inline-flex h-9 items-center rounded-lg border border-slate-300 bg-white px-3 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50"
|
||||
>
|
||||
Open group
|
||||
</Link>
|
||||
) : null}
|
||||
{selectedBoard ? (
|
||||
<Link
|
||||
href={`/boards/${selectedBoard.id}`}
|
||||
className="inline-flex h-9 items-center rounded-lg border border-slate-300 bg-white px-3 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50"
|
||||
>
|
||||
Open board
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -401,10 +578,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<ChartCard
|
||||
title="Completed Tasks"
|
||||
subtitle="Throughput"
|
||||
>
|
||||
<ChartCard title="Completed Tasks" subtitle="Throughput">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={throughputSeries}
|
||||
@@ -449,10 +623,7 @@ export default function DashboardPage() {
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Avg Hours to Review"
|
||||
subtitle="Cycle time"
|
||||
>
|
||||
<ChartCard title="Avg Hours to Review" subtitle="Cycle time">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={cycleSeries}
|
||||
@@ -501,10 +672,7 @@ export default function DashboardPage() {
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Failed Events"
|
||||
subtitle="Error rate"
|
||||
>
|
||||
<ChartCard title="Failed Events" subtitle="Error rate">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={errorSeries}
|
||||
|
||||
@@ -52,9 +52,14 @@ describe("BoardApprovalsPanel", () => {
|
||||
linked_request: {
|
||||
tasks: [
|
||||
{
|
||||
task_id: "task-1",
|
||||
title: "Launch onboarding checklist",
|
||||
description: "Create and validate the v1 onboarding checklist.",
|
||||
},
|
||||
{
|
||||
task_id: "task-2",
|
||||
title: "Publish onboarding checklist",
|
||||
},
|
||||
],
|
||||
task_ids: ["task-1", "task-2"],
|
||||
},
|
||||
@@ -84,7 +89,46 @@ describe("BoardApprovalsPanel", () => {
|
||||
expect(
|
||||
screen.getByText("Needs explicit sign-off before rollout."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("62% score")).toBeInTheDocument();
|
||||
expect(screen.getByText(/related tasks/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Launch onboarding checklist" }),
|
||||
).toHaveAttribute("href", "/boards/board-1?taskId=task-1");
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Publish onboarding checklist" }),
|
||||
).toHaveAttribute("href", "/boards/board-1?taskId=task-2");
|
||||
expect(screen.getByText(/rubric scores/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Clarity")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses schema task_titles for related task links when payload titles are missing", () => {
|
||||
const approval = {
|
||||
id: "approval-2",
|
||||
board_id: "board-1",
|
||||
action_type: "task.update",
|
||||
confidence: 88,
|
||||
status: "pending",
|
||||
task_id: "task-a",
|
||||
task_ids: ["task-a", "task-b"],
|
||||
task_titles: ["Prepare release notes", "Publish release notes"],
|
||||
created_at: "2026-02-12T11:00:00Z",
|
||||
resolved_at: null,
|
||||
payload: {
|
||||
task_ids: ["task-a", "task-b"],
|
||||
reason: "Needs sign-off before publishing.",
|
||||
},
|
||||
rubric_scores: null,
|
||||
} as ApprovalRead;
|
||||
|
||||
renderWithQueryClient(
|
||||
<BoardApprovalsPanel boardId="board-1" approvals={[approval]} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Prepare release notes" }),
|
||||
).toHaveAttribute("href", "/boards/board-1?taskId=task-a");
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Publish release notes" }),
|
||||
).toHaveAttribute("href", "/boards/board-1?taskId=task-b");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useAuth } from "@/auth/clerk";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -28,9 +29,16 @@ import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Approval = ApprovalRead & { status: string };
|
||||
|
||||
const normalizeScore = (value: unknown): number => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizeApproval = (approval: ApprovalRead): Approval => ({
|
||||
...approval,
|
||||
status: approval.status ?? "pending",
|
||||
confidence: normalizeScore(approval.confidence),
|
||||
});
|
||||
|
||||
type BoardApprovalsPanelProps = {
|
||||
@@ -237,6 +245,79 @@ const approvalTaskIds = (approval: Approval) => {
|
||||
return [...new Set(merged)];
|
||||
};
|
||||
|
||||
type RelatedTaskSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const approvalRelatedTasks = (approval: Approval): RelatedTaskSummary[] => {
|
||||
const payload = approval.payload ?? {};
|
||||
const taskIds = approvalTaskIds(approval);
|
||||
if (taskIds.length === 0) return [];
|
||||
const apiTaskTitles = (
|
||||
approval as Approval & { task_titles?: string[] | null }
|
||||
).task_titles;
|
||||
|
||||
const titleByTaskId = new Map<string, string>();
|
||||
const orderedTitles: string[] = [];
|
||||
|
||||
const collectTaskTitles = (path: string[]) => {
|
||||
const tasks = payloadAtPath(payload, path);
|
||||
if (!Array.isArray(tasks)) return;
|
||||
for (const task of tasks) {
|
||||
if (!isRecord(task)) continue;
|
||||
const rawTitle = task["title"];
|
||||
const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
|
||||
if (!title) continue;
|
||||
orderedTitles.push(title);
|
||||
const taskId =
|
||||
typeof task["task_id"] === "string"
|
||||
? task["task_id"]
|
||||
: typeof task["taskId"] === "string"
|
||||
? task["taskId"]
|
||||
: typeof task["id"] === "string"
|
||||
? task["id"]
|
||||
: null;
|
||||
if (taskId && taskId.trim()) {
|
||||
titleByTaskId.set(taskId, title);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collectTaskTitles(["linked_request", "tasks"]);
|
||||
collectTaskTitles(["linkedRequest", "tasks"]);
|
||||
|
||||
const indexedTitles = [
|
||||
...(Array.isArray(apiTaskTitles) ? apiTaskTitles : []),
|
||||
...orderedTitles,
|
||||
...payloadValues(payload, "task_titles"),
|
||||
...payloadValues(payload, "taskTitles"),
|
||||
...payloadNestedValues(payload, ["linked_request", "task_titles"]),
|
||||
...payloadNestedValues(payload, ["linked_request", "taskTitles"]),
|
||||
...payloadNestedValues(payload, ["linkedRequest", "task_titles"]),
|
||||
...payloadNestedValues(payload, ["linkedRequest", "taskTitles"]),
|
||||
]
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
|
||||
const singleTitle =
|
||||
payloadValue(payload, "title") ??
|
||||
payloadNestedValue(payload, ["task", "title"]) ??
|
||||
payloadFirstLinkedTaskValue(payload, "title");
|
||||
|
||||
return taskIds.map((taskId, index) => {
|
||||
const resolvedTitle =
|
||||
titleByTaskId.get(taskId) ??
|
||||
indexedTitles[index] ??
|
||||
(taskIds.length === 1 ? singleTitle : null) ??
|
||||
"Untitled task";
|
||||
return { id: taskId, title: resolvedTitle };
|
||||
});
|
||||
};
|
||||
|
||||
const taskHref = (boardId: string, taskId: string) =>
|
||||
`/boards/${encodeURIComponent(boardId)}?taskId=${encodeURIComponent(taskId)}`;
|
||||
|
||||
const approvalSummary = (approval: Approval, boardLabel?: string | null) => {
|
||||
const payload = approval.payload ?? {};
|
||||
const taskIds = approvalTaskIds(approval);
|
||||
@@ -544,6 +625,9 @@ export function BoardApprovalsPanel({
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500">
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 font-semibold text-slate-700">
|
||||
{approval.confidence}% score
|
||||
</span>
|
||||
<Clock className="h-3.5 w-3.5 opacity-60" />
|
||||
<span>{formatTimestamp(approval.created_at)}</span>
|
||||
</div>
|
||||
@@ -582,10 +666,12 @@ export function BoardApprovalsPanel({
|
||||
const titleText = titleRow?.value?.trim() ?? "";
|
||||
const descriptionText = summary.description?.trim() ?? "";
|
||||
const reasoningText = summary.reason?.trim() ?? "";
|
||||
const relatedTasks = approvalRelatedTasks(selectedApproval);
|
||||
const extraRows = summary.rows.filter((row) => {
|
||||
const normalized = row.label.toLowerCase();
|
||||
if (normalized === "title") return false;
|
||||
if (normalized === "task") return false;
|
||||
if (normalized === "tasks") return false;
|
||||
if (normalized === "assignee") return false;
|
||||
return true;
|
||||
});
|
||||
@@ -733,6 +819,28 @@ export function BoardApprovalsPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{relatedTasks.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
|
||||
Related tasks
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relatedTasks.map((task) => (
|
||||
<Link
|
||||
key={`${selectedApproval.id}-task-${task.id}`}
|
||||
href={taskHref(
|
||||
selectedApproval.board_id,
|
||||
task.id,
|
||||
)}
|
||||
className="rounded-md border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700 underline-offset-2 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 hover:underline"
|
||||
>
|
||||
{task.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{extraRows.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
|
||||
|
||||
@@ -1,27 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const MENTION_MAX_OPTIONS = 8;
|
||||
const MENTION_PATTERN = /(?:^|\s)@([A-Za-z0-9_-]{0,31})$/;
|
||||
|
||||
type MentionTarget = {
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
type BoardChatComposerProps = {
|
||||
placeholder?: string;
|
||||
isSending?: boolean;
|
||||
disabled?: boolean;
|
||||
mentionSuggestions?: string[];
|
||||
onSend: (content: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const normalizeMentionHandle = (raw: string): string | null => {
|
||||
const trimmed = raw.trim().replace(/^@+/, "");
|
||||
if (!trimmed) return null;
|
||||
const token = trimmed.split(/\s+/)[0]?.replace(/[^A-Za-z0-9_-]/g, "") ?? "";
|
||||
if (!token) return null;
|
||||
if (!/^[A-Za-z]/.test(token)) return null;
|
||||
return token.slice(0, 32).toLowerCase();
|
||||
};
|
||||
|
||||
const findMentionTarget = (
|
||||
text: string,
|
||||
caret: number,
|
||||
): MentionTarget | null => {
|
||||
if (caret < 0 || caret > text.length) return null;
|
||||
const prefix = text.slice(0, caret);
|
||||
const match = prefix.match(MENTION_PATTERN);
|
||||
if (!match) return null;
|
||||
const query = (match[1] ?? "").toLowerCase();
|
||||
const start = caret - query.length - 1;
|
||||
return { start, end: caret, query };
|
||||
};
|
||||
|
||||
function BoardChatComposerImpl({
|
||||
placeholder = "Message the board lead. Tag agents with @name.",
|
||||
isSending = false,
|
||||
disabled = false,
|
||||
mentionSuggestions,
|
||||
onSend,
|
||||
}: BoardChatComposerProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const [mentionTarget, setMentionTarget] = useState<MentionTarget | null>(
|
||||
null,
|
||||
);
|
||||
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const closeMenuTimeoutRef = useRef<number | null>(null);
|
||||
const shouldFocusAfterSendRef = useRef(false);
|
||||
|
||||
const mentionOptions = useMemo(() => {
|
||||
const handles = new Set<string>(["lead"]);
|
||||
(mentionSuggestions ?? []).forEach((candidate) => {
|
||||
const handle = normalizeMentionHandle(candidate);
|
||||
if (handle) {
|
||||
handles.add(handle);
|
||||
}
|
||||
});
|
||||
return [...handles];
|
||||
}, [mentionSuggestions]);
|
||||
|
||||
const filteredMentionOptions = useMemo(() => {
|
||||
if (!mentionTarget) return [];
|
||||
const query = mentionTarget.query;
|
||||
const startsWithMatches = mentionOptions.filter((option) =>
|
||||
option.startsWith(query),
|
||||
);
|
||||
return startsWithMatches.slice(0, MENTION_MAX_OPTIONS);
|
||||
}, [mentionOptions, mentionTarget]);
|
||||
|
||||
const activeIndex =
|
||||
filteredMentionOptions.length > 0
|
||||
? Math.min(activeMentionIndex, filteredMentionOptions.length - 1)
|
||||
: 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSending) return;
|
||||
if (!shouldFocusAfterSendRef.current) return;
|
||||
@@ -29,6 +92,43 @@ function BoardChatComposerImpl({
|
||||
textareaRef.current?.focus();
|
||||
}, [isSending]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeMenuTimeoutRef.current !== null) {
|
||||
window.clearTimeout(closeMenuTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshMentionTarget = useCallback(
|
||||
(nextValue: string, caret: number) => {
|
||||
const nextTarget = findMentionTarget(nextValue, caret);
|
||||
setMentionTarget(nextTarget);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const applyMentionSelection = useCallback(
|
||||
(handle: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || !mentionTarget) return;
|
||||
const replacement = `@${handle} `;
|
||||
const nextValue =
|
||||
value.slice(0, mentionTarget.start) +
|
||||
replacement +
|
||||
value.slice(mentionTarget.end);
|
||||
setValue(nextValue);
|
||||
setMentionTarget(null);
|
||||
setActiveMentionIndex(0);
|
||||
window.requestAnimationFrame(() => {
|
||||
const nextCaret = mentionTarget.start + replacement.length;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(nextCaret, nextCaret);
|
||||
});
|
||||
},
|
||||
[mentionTarget, value],
|
||||
);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
if (isSending || disabled) return;
|
||||
const trimmed = value.trim();
|
||||
@@ -37,26 +137,120 @@ function BoardChatComposerImpl({
|
||||
shouldFocusAfterSendRef.current = true;
|
||||
if (ok) {
|
||||
setValue("");
|
||||
setMentionTarget(null);
|
||||
setActiveMentionIndex(0);
|
||||
}
|
||||
}, [disabled, isSending, onSend, value]);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[120px]"
|
||||
disabled={isSending || disabled}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setValue(nextValue);
|
||||
refreshMentionTarget(
|
||||
nextValue,
|
||||
event.target.selectionStart ?? nextValue.length,
|
||||
);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
refreshMentionTarget(
|
||||
value,
|
||||
event.currentTarget.selectionStart ?? value.length,
|
||||
);
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
refreshMentionTarget(
|
||||
value,
|
||||
event.currentTarget.selectionStart ?? value.length,
|
||||
);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (closeMenuTimeoutRef.current !== null) {
|
||||
window.clearTimeout(closeMenuTimeoutRef.current);
|
||||
}
|
||||
closeMenuTimeoutRef.current = window.setTimeout(() => {
|
||||
setMentionTarget(null);
|
||||
setActiveMentionIndex(0);
|
||||
}, 120);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
refreshMentionTarget(
|
||||
value,
|
||||
event.currentTarget.selectionStart ?? value.length,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (filteredMentionOptions.length > 0 && mentionTarget) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveMentionIndex(
|
||||
(prev) => (prev + 1) % filteredMentionOptions.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveMentionIndex(
|
||||
(prev) =>
|
||||
(prev - 1 + filteredMentionOptions.length) %
|
||||
filteredMentionOptions.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
const selected = filteredMentionOptions[activeIndex];
|
||||
if (selected) {
|
||||
applyMentionSelection(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setMentionTarget(null);
|
||||
setActiveMentionIndex(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key !== "Enter") return;
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[120px]"
|
||||
disabled={isSending || disabled}
|
||||
/>
|
||||
{mentionTarget && filteredMentionOptions.length > 0 ? (
|
||||
<div className="absolute bottom-full left-0 z-20 mb-2 w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<div className="max-h-52 overflow-y-auto py-1">
|
||||
{filteredMentionOptions.map((option, index) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
applyMentionSelection(option);
|
||||
}}
|
||||
className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm transition ${
|
||||
index === activeIndex
|
||||
? "bg-slate-100 text-slate-900"
|
||||
: "text-slate-700 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono">@{option}</span>
|
||||
<span className="text-xs text-slate-400">mention</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => void send()}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { memo, type HTMLAttributes } from "react";
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
memo,
|
||||
type HTMLAttributes,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
@@ -13,6 +21,89 @@ type MarkdownCodeProps = HTMLAttributes<HTMLElement> & {
|
||||
inline?: boolean;
|
||||
};
|
||||
|
||||
const MENTION_PATTERN =
|
||||
/(^|[^A-Za-z0-9_])(@[A-Za-z0-9_](?:[A-Za-z0-9_.-]*[A-Za-z0-9_])?)/g;
|
||||
|
||||
const renderMentionsInText = (text: string, keyPrefix: string): ReactNode => {
|
||||
let lastIndex = 0;
|
||||
let mentionCount = 0;
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
for (const match of text.matchAll(MENTION_PATTERN)) {
|
||||
const matchIndex = match.index ?? 0;
|
||||
const prefix = match[1] ?? "";
|
||||
const mention = match[2] ?? "";
|
||||
const mentionStart = matchIndex + prefix.length;
|
||||
|
||||
if (matchIndex > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, matchIndex));
|
||||
}
|
||||
|
||||
if (prefix) {
|
||||
nodes.push(prefix);
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<span
|
||||
key={`${keyPrefix}-${mentionCount}`}
|
||||
className="font-semibold text-cyan-700"
|
||||
>
|
||||
{mention}
|
||||
</span>,
|
||||
);
|
||||
|
||||
lastIndex = mentionStart + mention.length;
|
||||
mentionCount += 1;
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
const renderMentions = (
|
||||
content: ReactNode,
|
||||
keyPrefix = "mention",
|
||||
): ReactNode => {
|
||||
if (typeof content === "string") {
|
||||
return renderMentionsInText(content, keyPrefix);
|
||||
}
|
||||
if (
|
||||
content === null ||
|
||||
content === undefined ||
|
||||
typeof content === "boolean" ||
|
||||
typeof content === "number"
|
||||
) {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return Children.map(content, (child, index) =>
|
||||
renderMentions(child, `${keyPrefix}-${index}`),
|
||||
);
|
||||
}
|
||||
if (isValidElement(content)) {
|
||||
if (typeof content.type === "string" && content.type === "code") {
|
||||
return content;
|
||||
}
|
||||
const childProps = content.props as { children?: ReactNode };
|
||||
if (childProps.children === undefined) {
|
||||
return content;
|
||||
}
|
||||
return cloneElement(
|
||||
content as ReactElement<{ children?: ReactNode }>,
|
||||
undefined,
|
||||
renderMentions(childProps.children, keyPrefix),
|
||||
);
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
const MARKDOWN_CODE_COMPONENTS: Components = {
|
||||
pre: ({ node: _node, className, ...props }) => (
|
||||
<pre
|
||||
@@ -77,28 +168,47 @@ const MARKDOWN_TABLE_COMPONENTS: Components = {
|
||||
tr: ({ node: _node, className, ...props }) => (
|
||||
<tr className={cn("align-top", className)} {...props} />
|
||||
),
|
||||
th: ({ node: _node, className, ...props }) => (
|
||||
th: ({ node: _node, className, children, ...props }) => (
|
||||
<th
|
||||
className={cn(
|
||||
"border border-slate-200 px-3 py-2 text-left text-xs font-semibold",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{renderMentions(children)}
|
||||
</th>
|
||||
),
|
||||
td: ({ node: _node, className, ...props }) => (
|
||||
td: ({ node: _node, className, children, ...props }) => (
|
||||
<td
|
||||
className={cn("border border-slate-200 px-3 py-2 align-top", className)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{renderMentions(children)}
|
||||
</td>
|
||||
),
|
||||
};
|
||||
|
||||
const MARKDOWN_COMPONENTS_BASIC: Components = {
|
||||
...MARKDOWN_TABLE_COMPONENTS,
|
||||
...MARKDOWN_CODE_COMPONENTS,
|
||||
p: ({ node: _node, className, ...props }) => (
|
||||
<p className={cn("mb-2 last:mb-0", className)} {...props} />
|
||||
a: ({ node: _node, className, children, ...props }) => (
|
||||
<a
|
||||
className={cn(
|
||||
"font-medium text-sky-700 underline decoration-sky-400 underline-offset-2 transition-colors hover:text-sky-800 hover:decoration-sky-600",
|
||||
className,
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{renderMentions(children)}
|
||||
</a>
|
||||
),
|
||||
p: ({ node: _node, className, children, ...props }) => (
|
||||
<p className={cn("mb-2 last:mb-0", className)} {...props}>
|
||||
{renderMentions(children)}
|
||||
</p>
|
||||
),
|
||||
ul: ({ node: _node, className, ...props }) => (
|
||||
<ul className={cn("mb-2 list-disc pl-5", className)} {...props} />
|
||||
@@ -106,27 +216,39 @@ const MARKDOWN_COMPONENTS_BASIC: Components = {
|
||||
ol: ({ node: _node, className, ...props }) => (
|
||||
<ol className={cn("mb-2 list-decimal pl-5", className)} {...props} />
|
||||
),
|
||||
li: ({ node: _node, className, ...props }) => (
|
||||
<li className={cn("mb-1", className)} {...props} />
|
||||
li: ({ node: _node, className, children, ...props }) => (
|
||||
<li className={cn("mb-1", className)} {...props}>
|
||||
{renderMentions(children)}
|
||||
</li>
|
||||
),
|
||||
strong: ({ node: _node, className, ...props }) => (
|
||||
<strong className={cn("font-semibold", className)} {...props} />
|
||||
strong: ({ node: _node, className, children, ...props }) => (
|
||||
<strong className={cn("font-semibold", className)} {...props}>
|
||||
{renderMentions(children)}
|
||||
</strong>
|
||||
),
|
||||
};
|
||||
|
||||
const MARKDOWN_COMPONENTS_DESCRIPTION: Components = {
|
||||
...MARKDOWN_COMPONENTS_BASIC,
|
||||
p: ({ node: _node, className, ...props }) => (
|
||||
<p className={cn("mb-3 last:mb-0", className)} {...props} />
|
||||
p: ({ node: _node, className, children, ...props }) => (
|
||||
<p className={cn("mb-3 last:mb-0", className)} {...props}>
|
||||
{renderMentions(children)}
|
||||
</p>
|
||||
),
|
||||
h1: ({ node: _node, className, ...props }) => (
|
||||
<h1 className={cn("mb-2 text-base font-semibold", className)} {...props} />
|
||||
h1: ({ node: _node, className, children, ...props }) => (
|
||||
<h1 className={cn("mb-2 text-base font-semibold", className)} {...props}>
|
||||
{renderMentions(children)}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ node: _node, className, ...props }) => (
|
||||
<h2 className={cn("mb-2 text-sm font-semibold", className)} {...props} />
|
||||
h2: ({ node: _node, className, children, ...props }) => (
|
||||
<h2 className={cn("mb-2 text-sm font-semibold", className)} {...props}>
|
||||
{renderMentions(children)}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ node: _node, className, ...props }) => (
|
||||
<h3 className={cn("mb-2 text-sm font-semibold", className)} {...props} />
|
||||
h3: ({ node: _node, className, children, ...props }) => (
|
||||
<h3 className={cn("mb-2 text-sm font-semibold", className)} {...props}>
|
||||
{renderMentions(children)}
|
||||
</h3>
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ interface TaskCardProps {
|
||||
priority?: string;
|
||||
assignee?: string;
|
||||
due?: string;
|
||||
isOverdue?: boolean;
|
||||
approvalsPendingCount?: number;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
isBlocked?: boolean;
|
||||
@@ -27,6 +28,7 @@ export function TaskCard({
|
||||
priority,
|
||||
assignee,
|
||||
due,
|
||||
isOverdue = false,
|
||||
approvalsPendingCount = 0,
|
||||
tags = [],
|
||||
isBlocked = false,
|
||||
@@ -157,8 +159,18 @@ export function TaskCard({
|
||||
<span>{assignee ?? "Unassigned"}</span>
|
||||
</div>
|
||||
{due ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarClock className="h-4 w-4 text-slate-400" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
isOverdue && "font-semibold text-rose-600",
|
||||
)}
|
||||
>
|
||||
<CalendarClock
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
isOverdue ? "text-rose-500" : "text-slate-400",
|
||||
)}
|
||||
/>
|
||||
<span>{due}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -57,10 +57,14 @@ export function DashboardSidebar() {
|
||||
return (
|
||||
<aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white">
|
||||
<div className="flex-1 px-3 py-4">
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">Navigation</p>
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Navigation
|
||||
</p>
|
||||
<nav className="mt-3 space-y-4 text-sm">
|
||||
<div>
|
||||
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">Overview</p>
|
||||
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
Overview
|
||||
</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
@@ -90,7 +94,9 @@ export function DashboardSidebar() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">Boards</p>
|
||||
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
Boards
|
||||
</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
<Link
|
||||
href="/board-groups"
|
||||
|
||||
51
frontend/src/components/organisms/TaskBoard.test.tsx
Normal file
51
frontend/src/components/organisms/TaskBoard.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TaskBoard } from "./TaskBoard";
|
||||
|
||||
describe("TaskBoard", () => {
|
||||
it("uses a mobile-first stacked layout (no horizontal scroll) with responsive kanban columns on larger screens", () => {
|
||||
render(
|
||||
<TaskBoard
|
||||
tasks={[
|
||||
{
|
||||
id: "t1",
|
||||
title: "Inbox item",
|
||||
status: "inbox",
|
||||
priority: "medium",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const board = screen.getByTestId("task-board");
|
||||
|
||||
expect(board.className).toContain("overflow-x-hidden");
|
||||
expect(board.className).toContain("sm:overflow-x-auto");
|
||||
expect(board.className).toContain("grid-cols-1");
|
||||
expect(board.className).toContain("sm:grid-flow-col");
|
||||
});
|
||||
|
||||
it("only sticks column headers on larger screens (avoids weird stacked sticky headers on mobile)", () => {
|
||||
render(
|
||||
<TaskBoard
|
||||
tasks={[
|
||||
{
|
||||
id: "t1",
|
||||
title: "Inbox item",
|
||||
status: "inbox",
|
||||
priority: "medium",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const header = screen
|
||||
.getByRole("heading", { name: "Inbox" })
|
||||
.closest(".column-header");
|
||||
expect(header?.className).toContain("sm:sticky");
|
||||
expect(header?.className).toContain("sm:top-0");
|
||||
// Ensure we didn't accidentally keep unscoped sticky behavior.
|
||||
expect(header?.className).not.toContain("sticky top-0");
|
||||
});
|
||||
});
|
||||
@@ -82,14 +82,20 @@ const columns: Array<{
|
||||
},
|
||||
];
|
||||
|
||||
const formatDueDate = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
const date = parseApiDatetime(value);
|
||||
if (!date) return undefined;
|
||||
return date.toLocaleDateString(undefined, {
|
||||
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,
|
||||
isOverdue,
|
||||
};
|
||||
};
|
||||
|
||||
type CardPosition = { left: number; top: number };
|
||||
@@ -149,6 +155,7 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
return positions;
|
||||
}, []);
|
||||
|
||||
// Animate card reordering smoothly by applying FLIP whenever layout positions change.
|
||||
useLayoutEffect(() => {
|
||||
const cardRefsSnapshot = cardRefs.current;
|
||||
if (animationRafRef.current !== null) {
|
||||
@@ -269,6 +276,7 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
return buckets;
|
||||
}, [tasks]);
|
||||
|
||||
// Keep drag/drop state and payload handling centralized for column move interactions.
|
||||
const handleDragStart =
|
||||
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (readOnly) {
|
||||
@@ -328,10 +336,17 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
return (
|
||||
<div
|
||||
ref={boardRef}
|
||||
className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6"
|
||||
data-testid="task-board"
|
||||
className={cn(
|
||||
// Mobile-first: stack columns vertically to avoid horizontal scrolling.
|
||||
"grid grid-cols-1 gap-4 overflow-x-hidden pb-6",
|
||||
// Desktop/tablet: switch back to horizontally scrollable kanban columns.
|
||||
"sm:grid-flow-col sm:auto-cols-[minmax(260px,320px)] sm:grid-cols-none sm:overflow-x-auto",
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const columnTasks = grouped[column.status] ?? [];
|
||||
// Derive review tab counts and the active subset from one canonical task list.
|
||||
const reviewCounts =
|
||||
column.status === "review"
|
||||
? columnTasks.reduce(
|
||||
@@ -377,7 +392,10 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
<div
|
||||
key={column.title}
|
||||
className={cn(
|
||||
"kanban-column min-h-[calc(100vh-260px)]",
|
||||
// On mobile, columns are stacked, so avoid forcing tall fixed heights.
|
||||
"kanban-column min-h-0",
|
||||
// On larger screens, keep columns tall to reduce empty space during drag.
|
||||
"sm:min-h-[calc(100vh-260px)]",
|
||||
activeColumn === column.status &&
|
||||
!readOnly &&
|
||||
"ring-2 ring-slate-200",
|
||||
@@ -386,7 +404,7 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
|
||||
onDragLeave={readOnly ? undefined : handleDragLeave(column.status)}
|
||||
>
|
||||
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
|
||||
<div className="column-header z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white px-4 py-3 sm:sticky sm:top-0 sm:bg-white/80 sm:backdrop-blur">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
|
||||
@@ -445,26 +463,32 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
</div>
|
||||
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
|
||||
<div className="space-y-3">
|
||||
{filteredTasks.map((task) => (
|
||||
<div key={task.id} ref={setCardRef(task.id)}>
|
||||
<TaskCard
|
||||
title={task.title}
|
||||
status={task.status}
|
||||
priority={task.priority}
|
||||
assignee={task.assignee ?? undefined}
|
||||
due={formatDueDate(task.due_at)}
|
||||
approvalsPendingCount={task.approvals_pending_count}
|
||||
tags={task.tags}
|
||||
isBlocked={task.is_blocked}
|
||||
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
|
||||
onClick={() => onTaskSelect?.(task)}
|
||||
draggable={!readOnly && !task.is_blocked}
|
||||
isDragging={draggingId === task.id}
|
||||
onDragStart={readOnly ? undefined : handleDragStart(task)}
|
||||
onDragEnd={readOnly ? undefined : handleDragEnd}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{filteredTasks.map((task) => {
|
||||
const dueState = resolveDueState(task);
|
||||
return (
|
||||
<div key={task.id} ref={setCardRef(task.id)}>
|
||||
<TaskCard
|
||||
title={task.title}
|
||||
status={task.status}
|
||||
priority={task.priority}
|
||||
assignee={task.assignee ?? undefined}
|
||||
due={dueState.due}
|
||||
isOverdue={dueState.isOverdue}
|
||||
approvalsPendingCount={task.approvals_pending_count}
|
||||
tags={task.tags}
|
||||
isBlocked={task.is_blocked}
|
||||
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
|
||||
onClick={() => onTaskSelect?.(task)}
|
||||
draggable={!readOnly && !task.is_blocked}
|
||||
isDragging={draggingId === task.id}
|
||||
onDragStart={
|
||||
readOnly ? undefined : handleDragStart(task)
|
||||
}
|
||||
onDragEnd={readOnly ? undefined : handleDragEnd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,7 @@ type DropdownSelectProps = {
|
||||
emptyMessage?: string;
|
||||
};
|
||||
|
||||
// Resolve trigger placeholder text with explicit prop override first, then accessible fallback.
|
||||
const resolvePlaceholder = (ariaLabel: string, placeholder?: string) => {
|
||||
if (placeholder) {
|
||||
return placeholder;
|
||||
@@ -51,6 +52,7 @@ const resolvePlaceholder = (ariaLabel: string, placeholder?: string) => {
|
||||
return trimmed.endsWith("...") ? trimmed : `${trimmed}...`;
|
||||
};
|
||||
|
||||
// Resolve search input placeholder from explicit override or a normalized aria label.
|
||||
const resolveSearchPlaceholder = (
|
||||
ariaLabel: string,
|
||||
searchPlaceholder?: string,
|
||||
@@ -107,6 +109,7 @@ export default function DropdownSelect({
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
// Reset list scroll when opening or refining search so results start at the top.
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user