Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useEffect } from 'react';
import { BOARD_PAGE_SIZE, type BoardColumnState } from '../hooks/use-tickets-board-query';
import { ticketService } from '../services';
import type { TicketsPage } from '../services/ticket-service.types';
import { isGraphQlValidationError } from '../utils/graphql';
import { dialogsQueryKeys } from '../utils/query-keys';

export interface BoardColumnUpdate {
Expand Down Expand Up @@ -73,7 +74,9 @@ export function BoardColumnSubscriber({ statusId, params, onUpdate, registerLoad
lastPage.pageInfo.hasNextPage ? (lastPage.pageInfo.endCursor ?? undefined) : undefined,
staleTime: 60_000,
gcTime: 5 * 60_000,
retry: 2,
// A schema-validation failure is deterministic — retrying it only delays the
// error. Transient failures still get the two attempts.
retry: (count, error) => !isGraphQlValidationError(error) && count < 2,
retryDelay: 1000,
refetchInterval: 15_000,
});
Expand Down
37 changes: 30 additions & 7 deletions src/app/(app)/tickets/components/tickets-board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import {
type BoardTicket,
} from '@flamingo-stack/openframe-frontend-core/components/features';
import { Filter02Icon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2';
import { Button, PageError, PageLayout } from '@flamingo-stack/openframe-frontend-core/components/ui';
import { Button, LoadError, PageLayout } from '@flamingo-stack/openframe-frontend-core/components/ui';
import { useDebounce, useToast } from '@flamingo-stack/openframe-frontend-core/hooks';
import { type InfiniteData, useQueryClient } from '@tanstack/react-query';
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useUserStatusMap } from '@/app/hooks/use-user-status-map';
import { featureFlags } from '@/lib/feature-flags';
import { appendImageHash } from '@/lib/image-url';
import { isOfflineError, loadErrorProps } from '@/lib/query-state';
import { routes } from '@/lib/routes';
import { useApprovalRequests } from '../hooks/use-approval-requests';
import { useMoveTicket, useMovingTicketIds } from '../hooks/use-move-ticket';
Expand Down Expand Up @@ -153,7 +154,12 @@ export function TicketsBoard({
const debouncedSearch = useDebounce(search, 300);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);

const { data: statusesData, isLoading: statusesLoading, error: statusesError } = useTicketStatusesQuery();
const {
data: statusesData,
isLoading: statusesLoading,
error: statusesError,
refetch: refetchStatuses,
} = useTicketStatusesQuery();
const { data: transitionRules } = useTicketStatusTransitionRules();
const { mutate: moveTicket } = useMoveTicket();
const movingIds = useMovingTicketIds();
Expand Down Expand Up @@ -392,11 +398,28 @@ export function TicketsBoard({

const actions = useMemo(() => emphasizeNewTicketAction(baseActions, showEmptyState), [baseActions, showEmptyState]);

if (statusesError) {
return <PageError message={statusesError.message} />;
}
if (columnError) {
return <PageError message={columnError.message} />;
// Clearing the column updates drops `columnError` and remounts the column
// subscribers, which refetch; the statuses query is retried alongside.
const retryLoad = () => {
setColumnUpdates({});
queryClient.resetQueries({ queryKey: dialogsQueryKeys.boardColumns() });
refetchStatuses();
};

// Keep the page chrome and offer a retry instead of a bare banner, and never
// show the raw server payload the failed load carried.
const loadError = statusesError ?? columnError;
if (loadError) {
return (
<PageLayout
title="Tickets"
selector={selector}
className="h-full px-[var(--spacing-system-l)] pb-[var(--spacing-system-l)]"
contentClassName="flex flex-col min-h-0"
>
<LoadError {...loadErrorProps(isOfflineError(loadError), "Couldn't load tickets.", retryLoad)} />
</PageLayout>
);
}

return (
Expand Down
24 changes: 20 additions & 4 deletions src/app/(app)/tickets/components/tickets-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import {
type ColumnFiltersState,
DataTable,
FilterModal,
LoadError,
type OnChangeFn,
PageError,
PageLayout,
} from '@flamingo-stack/openframe-frontend-core/components/ui';
import { useDebounce } from '@flamingo-stack/openframe-frontend-core/hooks';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { useStickyToolbar } from '@/app/hooks/use-sticky-toolbar';
import { loadErrorProps } from '@/lib/query-state';
import { emphasizeNewTicketAction, useTicketsActions } from '../hooks/use-tickets-actions';
import { useTicketsQuery } from '../hooks/use-tickets-query';
import { useTicketStatusesQuery } from '../statuses/hooks/use-ticket-statuses-query';
Expand Down Expand Up @@ -60,7 +61,9 @@ export function TicketsTable({
isFetchingNextPage,
hasNextPage,
fetchNextPage,
error,
isError,
isOffline,
refetch,
} = useTicketsQuery({
archived: isArchived,
search: debouncedSearch,
Expand Down Expand Up @@ -151,8 +154,21 @@ export function TicketsTable({

const actions = useMemo(() => emphasizeNewTicketAction(baseActions, showEmptyState), [baseActions, showEmptyState]);

if (error) {
return <PageError message={error} />;
// Keep the page chrome — title, back button, view switch — so a failed load
// leaves a working page with a retry, not a bare banner. The thrown message is
// never shown: it is a raw server payload the user cannot act on.
if (isError) {
return (
<PageLayout
title={title}
backButton={backButton}
selector={selector}
className="px-[var(--spacing-system-l)] pb-[var(--spacing-system-l)]"
contentClassName="flex flex-col"
>
<LoadError {...loadErrorProps(isOffline, "Couldn't load tickets.", () => refetch())} />
</PageLayout>
);
}

return (
Expand Down
28 changes: 13 additions & 15 deletions src/app/(app)/tickets/hooks/use-tickets-query.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
'use client';

import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { queryState } from '@/lib/query-state';
import { ticketService } from '../services';
import type { TicketsPage } from '../services/ticket-service.types';
import { useTicketStatusesQuery } from '../statuses/hooks/use-ticket-statuses-query';
import { isGraphQlValidationError } from '../utils/graphql';
import { type DialogsQueryParams, dialogsQueryKeys } from '../utils/query-keys';

const TICKETS_PAGE_SIZE = 20;
Expand All @@ -21,7 +22,6 @@ export function useTicketsQuery({
tagIds,
pageSize = TICKETS_PAGE_SIZE,
}: DialogsQueryParams) {
const { toast } = useToast();
const queryClient = useQueryClient();

const statusesQuery = useTicketStatusesQuery({ enabled: true });
Expand Down Expand Up @@ -77,20 +77,12 @@ export function useTicketsQuery({
initialPageParam: undefined as string | undefined,
staleTime: 60_000,
gcTime: 5 * 60_000,
retry: 2,
// A schema-validation failure is deterministic — retrying it only delays the
// error. Transient failures still get the two attempts.
retry: (count, error) => !isGraphQlValidationError(error) && count < 2,
retryDelay: 1000,
});

useEffect(() => {
if (query.error) {
toast({
title: 'Failed to Load Tickets',
description: query.error.message,
variant: 'destructive',
});
}
}, [query.error, toast]);

const dialogs = useMemo(() => query.data?.pages.flatMap(page => page.dialogs) ?? [], [query.data?.pages]);

const resetToFirstPage = useCallback(() => {
Expand All @@ -108,13 +100,19 @@ export function useTicketsQuery({
});
}, [queryClient, archived, search, statusFilters, statusIds, organizationIds, assigneeIds, tagIds, pageSize]);

// Only a first-load failure with no rows is an error state; a background
// refetch that fails behind cached rows keeps the stale rows on screen.
const state = queryState(query);

return {
dialogs,
isLoading: query.isLoading || waitingForStatusIds,
isFetchingNextPage: query.isFetchingNextPage,
hasNextPage: query.hasNextPage ?? false,
fetchNextPage: query.fetchNextPage,
error: query.error?.message ?? null,
isError: state.error !== null,
isOffline: state.isOffline,
refetch: query.refetch,
resetToFirstPage,
};
}
8 changes: 6 additions & 2 deletions src/app/(app)/tickets/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client';

import { ContentPageContainer } from '@flamingo-stack/openframe-frontend-core';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { ContentErrorBoundary } from '@/app/components/shared';
import { isSaasTenantMode } from '@/lib/app-mode';
import { routes } from '@/lib/routes';
import { TicketsView } from './components/tickets-view';
Expand All @@ -22,5 +22,9 @@ export default function Tickets() {
return null;
}

return <TicketsView />;
return (
<ContentErrorBoundary title="Tickets" message="Couldn't load tickets.">
<TicketsView />
</ContentErrorBoundary>
);
}
9 changes: 5 additions & 4 deletions src/app/(app)/tickets/queries/ticket-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,11 @@ export const GET_TICKETS_QUERY = `

/**
* `escalatedByUser` ships with the escalation backend, so it rides the
* `ai-escalation` flag: a field the server's schema does not declare fails
* validation for the entire document, and `extractGraphQlData` throws on the
* first GraphQL error — every board column would come back empty rather than
* merely missing a badge.
* `ai-escalation` flag to avoid an extra round-trip in the common case: a field
* the server's schema does not declare fails validation for the whole document.
* The flag can still lead the backend by a deploy, so `TicketService.fetchGraphQl`
* is the safety net — it prunes an undeclared leaf field and retries once, and
* the board loads with the badge simply absent rather than empty.
*/
const boardCardTicketFragment = () => `
fragment BoardCardTicket on Ticket {
Expand Down
58 changes: 35 additions & 23 deletions src/app/(app)/tickets/services/ticket-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from '../queries/ticket-queries';
import type { Dialog, DialogStatus, Message } from '../types/dialog.types';
import type { GraphQlResponse } from '../utils/graphql';
import { extractGraphQlData } from '../utils/graphql';
import { extractGraphQlData, pruneLeafFields, undefinedFieldNames } from '../utils/graphql';
import type {
FetchBoardColumnByStatusIdParams,
FetchMessagesParams,
Expand Down Expand Up @@ -210,6 +210,28 @@ function normalizeTicketToDialog(ticket: TicketNode): Dialog {
}

export class TicketService implements TicketServiceInterface {
/**
* Post a query and tolerate one field skew. A single field the deployed schema
* does not declare fails validation for the whole document, which would blank
* the list or every board column. When that happens, prune the undeclared leaf
* fields and retry once, so the surface loads with that value simply absent
* (e.g. no escalation badge) instead of empty.
*/
private async fetchGraphQl<T>(query: string, variables: Record<string, unknown>): Promise<T> {
const response = await apiClient.post<GraphQlResponse<T>>(API_ENDPOINTS.GRAPHQL, { query, variables });

const undefinedFields = response.data?.errors ? undefinedFieldNames(response.data.errors) : [];
if (undefinedFields.length > 0) {
const pruned = pruneLeafFields(query, undefinedFields);
if (pruned !== query) {
const retried = await apiClient.post<GraphQlResponse<T>>(API_ENDPOINTS.GRAPHQL, { query: pruned, variables });
return extractGraphQlData(retried);
}
}

return extractGraphQlData(response);
}

private async mutateTicketStatus(ticketId: string, mutation: string, responseKey: string): Promise<DialogStatus> {
const response = await apiClient.post<GraphQlResponse<Record<string, StatusMutationPayload>>>(
API_ENDPOINTS.GRAPHQL,
Expand Down Expand Up @@ -252,16 +274,11 @@ export class TicketService implements TicketServiceInterface {
filter.tagIds = params.tagIds;
}

const response = await apiClient.post<GraphQlResponse<TicketsResponse>>(API_ENDPOINTS.GRAPHQL, {
query: GET_TICKETS_QUERY,
variables: {
filter,
pagination: paginationVars,
search: params.search || undefined,
},
const data = await this.fetchGraphQl<TicketsResponse>(GET_TICKETS_QUERY, {
filter,
pagination: paginationVars,
search: params.search || undefined,
});

const data = extractGraphQlData(response);
const connection = data.tickets;

return {
Expand All @@ -277,20 +294,15 @@ export class TicketService implements TicketServiceInterface {
}

async fetchBoardColumnByStatusId(params: FetchBoardColumnByStatusIdParams): Promise<TicketsPage> {
const response = await apiClient.post<GraphQlResponse<TicketsResponse>>(API_ENDPOINTS.GRAPHQL, {
query: getBoardColumnTicketsQuery(),
variables: {
statusId: params.statusId,
limit: params.limit,
cursor: params.cursor,
search: params.search || undefined,
organizationIds: params.organizationIds?.length ? params.organizationIds : undefined,
assigneeIds: params.assigneeIds?.length ? params.assigneeIds : undefined,
tagIds: params.tagIds?.length ? params.tagIds : undefined,
},
const data = await this.fetchGraphQl<TicketsResponse>(getBoardColumnTicketsQuery(), {
statusId: params.statusId,
limit: params.limit,
cursor: params.cursor,
search: params.search || undefined,
organizationIds: params.organizationIds?.length ? params.organizationIds : undefined,
assigneeIds: params.assigneeIds?.length ? params.assigneeIds : undefined,
tagIds: params.tagIds?.length ? params.tagIds : undefined,
});

const data = extractGraphQlData(response);
const connection = data.tickets;

return {
Expand Down
Loading