feat: paginate ticket workflow lanes
This commit is contained in:
@@ -218,19 +218,23 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
"Tickets and Objectives should each be a single sidebar link",
|
||||
);
|
||||
assert(
|
||||
ticketsLoad.includes("?limit=1000") &&
|
||||
ticketsPage.includes("data.tickets.data?.items") &&
|
||||
ticketsPage.includes("ticketLanes(tickets)") &&
|
||||
!ticketsLoad.includes("?limit=1000") &&
|
||||
ticketsLoad.includes('workspaceApiPath(workspaceId, "/tickets/query")') &&
|
||||
ticketsLoad.includes("ticketLaneDefinitions()") &&
|
||||
ticketsLoad.includes("ticketLaneQuery(lane)") &&
|
||||
ticketsPage.includes('class="ticket-kanban"') &&
|
||||
ticketsPage.includes("lane.tickets"),
|
||||
"Tickets list should load every workflow state and render a workspace Kanban board",
|
||||
ticketsPage.includes('class="ticket-lane-cards"') &&
|
||||
ticketsPage.includes("handleLaneScroll") &&
|
||||
ticketsPage.includes("Loading 30 more"),
|
||||
"Tickets list should query and incrementally scroll each Kanban lane",
|
||||
);
|
||||
assert(
|
||||
ticketPanelModel.includes('label: "Ready + Planning"') &&
|
||||
ticketPanelModel.includes('label: "In progress + Queued"') &&
|
||||
ticketPanelModel.includes('label: "Done + Closed"') &&
|
||||
ticketPanelModel.includes("updatedAt(right) - updatedAt(left)"),
|
||||
"Ticket Kanban should combine related states and sort state priority before recency",
|
||||
ticketPanelModel.includes("TICKET_LANE_PAGE_SIZE = 30") &&
|
||||
ticketPanelModel.includes('sort: "updated_desc"'),
|
||||
"Ticket Kanban should combine related states into independent cursor pages",
|
||||
);
|
||||
assert(
|
||||
generatedTicketApi.includes("Generated from yoi-workspace-server") &&
|
||||
@@ -249,7 +253,9 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||
ticketDetailPage.includes('mutate("queue", "/queue"') &&
|
||||
!ticketDetailPage.includes("/merge-request/merge") &&
|
||||
ticketDetailPage.includes("merged_result_commit") &&
|
||||
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
||||
ticketDetailPage.includes("currentReview?.kind") &&
|
||||
ticketDetailPage.includes("mergeEvent?.kind") &&
|
||||
!ticketDetailPage.includes('mutate("review", "/review"') &&
|
||||
ticketDetailPage.includes('mutate("close", "/close"') &&
|
||||
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
|
||||
|
||||
@@ -121,18 +121,25 @@
|
||||
}
|
||||
.ticket-kanban {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
grid-template-columns: repeat(3, minmax(18rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-3);
|
||||
}
|
||||
.ticket-lane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100dvh - 15rem);
|
||||
min-height: 24rem;
|
||||
max-height: 48rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
background: color-mix(in srgb, var(--bg-raised) 55%, transparent);
|
||||
}
|
||||
.ticket-lane-header {
|
||||
flex: 0 0 auto;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
@@ -167,9 +174,29 @@
|
||||
}
|
||||
.ticket-lane-cards {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
align-content: start;
|
||||
gap: 0.55rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.6rem;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.ticket-lane-load-status {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
padding: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
.ticket-lane-load-error {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
color: #d66;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.ticket-lane-load-error button {
|
||||
justify-self: start;
|
||||
}
|
||||
.ticket-card {
|
||||
display: grid;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import {
|
||||
appendUniqueTicketSummaries,
|
||||
TICKET_LANE_PAGE_SIZE,
|
||||
ticketLaneDefinitions,
|
||||
ticketLaneQuery,
|
||||
ticketLanes,
|
||||
ticketWorkerLaunchHref,
|
||||
ticketWorkerMessage,
|
||||
@@ -79,6 +83,49 @@ Deno.test("ticketLanes combines workflow states and sorts by state then update t
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("ticket lane queries request independent pages of 30", () => {
|
||||
const [readyPlanning, inprogressQueued, doneClosed] = ticketLaneDefinitions();
|
||||
|
||||
assertEquals(TICKET_LANE_PAGE_SIZE, 30);
|
||||
assertEquals(ticketLaneQuery(readyPlanning).states, ["ready", "planning"]);
|
||||
assertEquals(ticketLaneQuery(inprogressQueued).states, [
|
||||
"inprogress",
|
||||
"queued",
|
||||
]);
|
||||
assertEquals(ticketLaneQuery(doneClosed, "next-page"), {
|
||||
attention: [],
|
||||
cursor: "next-page",
|
||||
event_kinds: [],
|
||||
evidence: [],
|
||||
limit: 30,
|
||||
linked_objective_id: null,
|
||||
query: null,
|
||||
related_ticket_id: null,
|
||||
relation_kind: null,
|
||||
review_status: null,
|
||||
sort: "updated_desc",
|
||||
states: ["done", "closed"],
|
||||
updated_after: null,
|
||||
updated_before: null,
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("incremental Ticket pages preserve order and discard duplicate ids", () => {
|
||||
const current = [
|
||||
{ id: "first", title: "First", state: "ready", priority: "1" },
|
||||
{ id: "second", title: "Second", state: "planning", priority: "2" },
|
||||
] as TicketSummary[];
|
||||
const incoming = [
|
||||
{ id: "second", title: "Duplicate", state: "planning", priority: "2" },
|
||||
{ id: "third", title: "Third", state: "planning", priority: "3" },
|
||||
] as TicketSummary[];
|
||||
|
||||
assertEquals(
|
||||
appendUniqueTicketSummaries(current, incoming).map((ticket) => ticket.id),
|
||||
["first", "second", "third"],
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => {
|
||||
const ticket = {
|
||||
id: "00001KYRRDVH9",
|
||||
@@ -113,10 +160,22 @@ Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestrati
|
||||
"src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||
);
|
||||
|
||||
assertIncludes(panelSource, 'workspaceApiPath(data.workspaceId, "/orchestrator")');
|
||||
assertIncludes(
|
||||
panelSource,
|
||||
'workspaceApiPath(data.workspaceId, "/orchestrator")',
|
||||
);
|
||||
assertIncludes(panelSource, '{ method: "POST" }');
|
||||
assertIncludes(panelSource, "Start Orchestrator");
|
||||
assertIncludes(panelSource, "orchestrator.data?.online");
|
||||
assertIncludes(
|
||||
panelSource,
|
||||
'workspaceApiPath(data.workspaceId, "/tickets/query")',
|
||||
);
|
||||
assertIncludes(
|
||||
panelSource,
|
||||
"onscroll={(event) => handleLaneScroll(event, lane.id)}",
|
||||
);
|
||||
assertIncludes(panelSource, "Loading 30 more…");
|
||||
assertIncludes(detailSource, "{#if orchestratorOnline}");
|
||||
assertIncludes(detailSource, "!orchestratorOnline");
|
||||
assertIncludes(detailSource, "Orchestrator offline");
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api";
|
||||
import type {
|
||||
TicketDetail,
|
||||
TicketQueryItem,
|
||||
TicketQueryRequest,
|
||||
TicketSummary,
|
||||
} from "$lib/generated/ticket-api";
|
||||
|
||||
export const TICKET_STATES = [
|
||||
"planning",
|
||||
@@ -47,6 +52,15 @@ const LANE_DEFINITIONS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const TICKET_LANE_PAGE_SIZE = 30;
|
||||
|
||||
export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number];
|
||||
export type TicketLaneId = TicketLaneDefinition["id"];
|
||||
export type TicketCardSummary = Pick<
|
||||
TicketSummary,
|
||||
"id" | "title" | "state" | "priority" | "updated_at"
|
||||
>;
|
||||
|
||||
const STATE_SORT_ORDER = new Map<string, number>([
|
||||
["ready", 0],
|
||||
["planning", 1],
|
||||
@@ -57,19 +71,74 @@ const STATE_SORT_ORDER = new Map<string, number>([
|
||||
]);
|
||||
|
||||
export type TicketLane = {
|
||||
id: string;
|
||||
id: TicketLaneId;
|
||||
label: string;
|
||||
states: readonly TicketState[];
|
||||
tickets: TicketSummary[];
|
||||
tickets: TicketCardSummary[];
|
||||
};
|
||||
|
||||
function updatedAt(ticket: TicketSummary): number {
|
||||
export function ticketLaneDefinitions(): readonly TicketLaneDefinition[] {
|
||||
return LANE_DEFINITIONS;
|
||||
}
|
||||
|
||||
export function ticketLaneQuery(
|
||||
lane: { states: readonly TicketState[] },
|
||||
cursor: string | null = null,
|
||||
): TicketQueryRequest {
|
||||
return {
|
||||
attention: [],
|
||||
cursor,
|
||||
event_kinds: [],
|
||||
evidence: [],
|
||||
limit: TICKET_LANE_PAGE_SIZE,
|
||||
linked_objective_id: null,
|
||||
query: null,
|
||||
related_ticket_id: null,
|
||||
relation_kind: null,
|
||||
review_status: null,
|
||||
sort: "updated_desc",
|
||||
states: [...lane.states],
|
||||
updated_after: null,
|
||||
updated_before: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function ticketSummaryFromQueryItem(
|
||||
item: TicketQueryItem,
|
||||
): TicketCardSummary {
|
||||
return {
|
||||
id: item.id,
|
||||
priority: item.priority === null ? "normal" : String(item.priority),
|
||||
state: item.state,
|
||||
title: item.title,
|
||||
updated_at: item.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendUniqueTicketSummaries(
|
||||
current: TicketCardSummary[],
|
||||
incoming: TicketCardSummary[],
|
||||
): TicketCardSummary[] {
|
||||
const ids = new Set(current.map((ticket) => ticket.id));
|
||||
return [
|
||||
...current,
|
||||
...incoming.filter((ticket) => {
|
||||
if (ids.has(ticket.id)) return false;
|
||||
ids.add(ticket.id);
|
||||
return true;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function updatedAt(ticket: TicketCardSummary): number {
|
||||
if (!ticket.updated_at) return 0;
|
||||
const parsed = Date.parse(ticket.updated_at);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
export function sortTickets(tickets: TicketSummary[]): TicketSummary[] {
|
||||
export function sortTickets(
|
||||
tickets: TicketCardSummary[],
|
||||
): TicketCardSummary[] {
|
||||
return [...tickets].sort((left, right) => {
|
||||
const stateDelta = (STATE_SORT_ORDER.get(left.state) ?? 99) -
|
||||
(STATE_SORT_ORDER.get(right.state) ?? 99);
|
||||
@@ -80,7 +149,7 @@ export function sortTickets(tickets: TicketSummary[]): TicketSummary[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function ticketLanes(tickets: TicketSummary[]): TicketLane[] {
|
||||
export function ticketLanes(tickets: TicketCardSummary[]): TicketLane[] {
|
||||
return LANE_DEFINITIONS.map((definition) => ({
|
||||
...definition,
|
||||
tickets: sortTickets(
|
||||
|
||||
@@ -1,31 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import type { TicketQueryResponse } from "$lib/generated/ticket-api";
|
||||
import type { ApiResult } from "$lib/workspace/api/http";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
ticketLanes,
|
||||
appendUniqueTicketSummaries,
|
||||
ticketLaneQuery,
|
||||
ticketSummaryFromQueryItem,
|
||||
type TicketCardSummary,
|
||||
type TicketLaneId,
|
||||
type TicketState,
|
||||
type WorkspaceOrchestratorStatus,
|
||||
} from "$lib/workspace/tickets/ticket-panel";
|
||||
import type {
|
||||
TicketListResponse,
|
||||
TicketSummary,
|
||||
} from "$lib/workspace/sidebar/types";
|
||||
import "$lib/workspace/styles/tickets.css";
|
||||
|
||||
type LanePage = {
|
||||
id: TicketLaneId;
|
||||
label: string;
|
||||
states: TicketState[];
|
||||
tickets: TicketCardSummary[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
const { data } = $props<{
|
||||
data: {
|
||||
workspaceId: string;
|
||||
tickets: ApiResult<TicketListResponse>;
|
||||
ticketLanePages: LanePage[];
|
||||
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
||||
};
|
||||
}>();
|
||||
|
||||
const initialTickets = untrack(() => data.tickets.data?.items ?? []);
|
||||
let tickets = $state<TicketSummary[]>(initialTickets);
|
||||
let lanes = $state<(LanePage & { loading: boolean })[]>(
|
||||
untrack(() =>
|
||||
data.ticketLanePages.map((lane: LanePage) => ({ ...lane, loading: false }))
|
||||
),
|
||||
);
|
||||
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
|
||||
untrack(() => data.orchestrator),
|
||||
);
|
||||
let orchestratorStarting = $state(false);
|
||||
let lanes = $derived(ticketLanes(tickets));
|
||||
const displayedTicketCount = $derived(
|
||||
lanes.reduce((count, lane) => count + lane.tickets.length, 0),
|
||||
);
|
||||
const LANE_LOAD_THRESHOLD_PX = 96;
|
||||
|
||||
async function startOrchestrator() {
|
||||
if (orchestratorStarting || orchestrator.data?.online) return;
|
||||
@@ -43,6 +62,75 @@
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function updateLane(
|
||||
laneId: TicketLaneId,
|
||||
update: (lane: LanePage & { loading: boolean }) =>
|
||||
LanePage & { loading: boolean },
|
||||
): void {
|
||||
lanes = lanes.map((lane) => lane.id === laneId ? update(lane) : lane);
|
||||
}
|
||||
|
||||
async function loadMoreTickets(laneId: TicketLaneId): Promise<void> {
|
||||
const lane = lanes.find((candidate) => candidate.id === laneId);
|
||||
if (!lane || lane.loading || (!lane.hasMore && !lane.error)) return;
|
||||
|
||||
updateLane(laneId, (current) => ({
|
||||
...current,
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
let result: ApiResult<TicketQueryResponse>;
|
||||
try {
|
||||
result = await loadJson<TicketQueryResponse>(
|
||||
fetch,
|
||||
workspaceApiPath(data.workspaceId, "/tickets/query"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(ticketLaneQuery(lane, lane.nextCursor)),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
updateLane(laneId, (current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load more Tickets.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
updateLane(laneId, (current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: result.error ?? "Unable to load more Tickets.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const incoming = result.data.items.map(ticketSummaryFromQueryItem);
|
||||
updateLane(laneId, (current) => ({
|
||||
...current,
|
||||
tickets: appendUniqueTicketSummaries(current.tickets, incoming),
|
||||
nextCursor: result.data?.page.next_cursor ?? null,
|
||||
hasMore: result.data?.page.has_more ?? false,
|
||||
loading: false,
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleLaneScroll(event: Event, laneId: TicketLaneId): void {
|
||||
const element = event.currentTarget as HTMLElement;
|
||||
const distanceFromBottom = element.scrollHeight - element.scrollTop -
|
||||
element.clientHeight;
|
||||
if (distanceFromBottom <= LANE_LOAD_THRESHOLD_PX) {
|
||||
void loadMoreTickets(laneId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Tickets · Yoi</title></svelte:head>
|
||||
@@ -50,11 +138,7 @@
|
||||
<div class="workspace-page ticket-panel-page">
|
||||
<header class="workspace-page-header ticket-panel-header">
|
||||
<div>
|
||||
<p class="workspace-eyebrow">Delivery</p>
|
||||
<h1>Tickets</h1>
|
||||
<p class="workspace-page-lede">
|
||||
Plan, route, review, and close work without leaving the workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div class="ticket-panel-controls">
|
||||
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
|
||||
@@ -75,8 +159,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ticket-panel-summary" aria-label="Ticket summary">
|
||||
<strong>{tickets.length}</strong>
|
||||
<span>tickets</span>
|
||||
<strong>{displayedTicketCount}</strong>
|
||||
<span>tickets loaded</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -99,10 +183,15 @@
|
||||
<span class="ticket-state-dot"></span>
|
||||
<h2>{lane.label}</h2>
|
||||
</div>
|
||||
<span class="ticket-lane-count">{lane.tickets.length}</span>
|
||||
<span class="ticket-lane-count">
|
||||
{lane.tickets.length}{lane.hasMore ? "+" : ""}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="ticket-lane-cards">
|
||||
<div
|
||||
class="ticket-lane-cards"
|
||||
onscroll={(event) => handleLaneScroll(event, lane.id)}
|
||||
>
|
||||
{#each lane.tickets as ticket (ticket.id)}
|
||||
<a
|
||||
class="ticket-card"
|
||||
@@ -116,8 +205,25 @@
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
{#if !lane.error}
|
||||
<div class="ticket-lane-empty">No tickets</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if lane.loading}
|
||||
<p class="ticket-lane-load-status" aria-live="polite">Loading 30 more…</p>
|
||||
{:else if lane.error}
|
||||
<div class="ticket-lane-load-error">
|
||||
<small>{lane.error}</small>
|
||||
<button
|
||||
class="workspace-secondary-button"
|
||||
type="button"
|
||||
onclick={() => loadMoreTickets(lane.id)}
|
||||
>Retry</button>
|
||||
</div>
|
||||
{:else if !lane.hasMore && lane.tickets.length > 0}
|
||||
<p class="ticket-lane-load-status">All tickets loaded.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -1,23 +1,63 @@
|
||||
import type { TicketQueryResponse } from "$lib/generated/ticket-api";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
||||
import type { TicketListResponse } from "$lib/workspace/sidebar/types";
|
||||
import {
|
||||
ticketLaneDefinitions,
|
||||
ticketLaneQuery,
|
||||
ticketSummaryFromQueryItem,
|
||||
type WorkspaceOrchestratorStatus,
|
||||
} from "$lib/workspace/tickets/ticket-panel";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
const [tickets, orchestrator] = await Promise.all([
|
||||
loadJson<TicketListResponse>(
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const ticketLanePagesPromise = Promise.all(
|
||||
ticketLaneDefinitions().map(async (lane) => {
|
||||
try {
|
||||
const page = await loadJson<TicketQueryResponse>(
|
||||
fetch,
|
||||
`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`,
|
||||
),
|
||||
workspaceApiPath(workspaceId, "/tickets/query"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(ticketLaneQuery(lane)),
|
||||
},
|
||||
);
|
||||
return {
|
||||
id: lane.id,
|
||||
label: lane.label,
|
||||
states: [...lane.states],
|
||||
tickets: page.data?.items.map(ticketSummaryFromQueryItem) ?? [],
|
||||
nextCursor: page.data?.page.next_cursor ?? null,
|
||||
hasMore: page.data?.page.has_more ?? false,
|
||||
error: page.error,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id: lane.id,
|
||||
label: lane.label,
|
||||
states: [...lane.states],
|
||||
tickets: [],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
error: error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load Tickets.",
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const [ticketLanePages, orchestrator] = await Promise.all([
|
||||
ticketLanePagesPromise,
|
||||
loadJson<WorkspaceOrchestratorStatus>(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/orchestrator"),
|
||||
workspaceApiPath(workspaceId, "/orchestrator"),
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
tickets,
|
||||
workspaceId,
|
||||
ticketLanePages,
|
||||
orchestrator,
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user