Split issue request code into api module

- Move HTTP and query-option helpers out of -issues.helpers.ts.
- Keep -issues.helpers.ts focused on pure normalization and formatting helpers.
- Update issue route and modal callers to import request code from -issues.api.ts.
pull/388/head
Antti Kettunen 2 weeks ago
parent 9cde9442b7
commit ffd989d0f3
No known key found for this signature in database
GPG Key ID: C6B2A3D250359BD7

@ -0,0 +1,150 @@
import { queryOptions } from '@tanstack/react-query';
import { apiClient, readJson } from '@/app/api-client';
import type {
CreateIssuePayload,
IssueCounts,
IssueCountsResponse,
IssueDetailResponse,
IssueListResponse,
IssueRecord,
} from './-issues.types';
import type { NormalizedIssuesSearch } from './-issues.helpers';
const DEFAULT_LIMIT = 100;
function createIssueHeaders(profileId: number, extra?: HeadersInit): Headers {
const headers = new Headers(extra);
headers.set('X-Profile-Id', String(profileId || 1));
return headers;
}
export async function fetchIssueCounts(profileId: number): Promise<IssueCounts> {
const payload = await readJson<IssueCountsResponse>(
apiClient.get('issues/counts', {
headers: createIssueHeaders(profileId),
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to load issue counts');
}
return payload.counts;
}
export async function fetchIssueList(
profileId: number,
search: Pick<NormalizedIssuesSearch, 'status' | 'category'>,
): Promise<IssueListResponse> {
const params = new URLSearchParams();
params.set('limit', String(DEFAULT_LIMIT));
if (search.status !== 'all') {
params.set('status', search.status);
}
if (search.category !== 'all') {
params.set('category', search.category);
}
const payload = await readJson<IssueListResponse>(
apiClient.get('issues', {
headers: createIssueHeaders(profileId),
searchParams: params,
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to load issues');
}
return payload;
}
export async function fetchIssue(profileId: number, issueId: number): Promise<IssueRecord> {
const payload = await readJson<IssueDetailResponse>(
apiClient.get(`issues/${issueId}`, {
headers: createIssueHeaders(profileId),
}),
);
if (!payload.success || !payload.issue) {
throw new Error(payload.error || 'Issue not found');
}
return payload.issue;
}
export async function updateIssue(
profileId: number,
issueId: number,
updates: { status?: string; admin_response?: string },
): Promise<void> {
const payload = await readJson<{ success: boolean; error?: string }>(
apiClient.put(`issues/${issueId}`, {
headers: createIssueHeaders(profileId),
json: updates,
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to update issue');
}
}
export async function createIssue(
profileId: number,
payload: CreateIssuePayload,
): Promise<IssueRecord | null> {
const response = await readJson<{
success: boolean;
issue?: IssueRecord;
error?: string;
}>(
apiClient.post('issues', {
headers: createIssueHeaders(profileId, { 'Content-Type': 'application/json' }),
json: {
entity_type: payload.entity_type,
entity_id: String(payload.entity_id),
category: payload.category,
title: payload.title,
description: payload.description || '',
priority: payload.priority || 'normal',
},
}),
);
if (!response.success) {
throw new Error(response.error || 'Failed to submit issue');
}
return response.issue ?? null;
}
export async function deleteIssue(profileId: number, issueId: number): Promise<void> {
const payload = await readJson<{ success: boolean; error?: string }>(
apiClient.delete(`issues/${issueId}`, {
headers: createIssueHeaders(profileId),
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to delete issue');
}
}
export function issueCountsQueryOptions(profileId: number) {
return queryOptions({
queryKey: ['issues', 'counts', profileId],
queryFn: () => fetchIssueCounts(profileId),
});
}
export function issueListQueryOptions(
profileId: number,
search: Pick<NormalizedIssuesSearch, 'status' | 'category'>,
) {
return queryOptions({
queryKey: ['issues', 'list', profileId, search.status, search.category],
queryFn: () => fetchIssueList(profileId, search),
});
}
export function issueDetailQueryOptions(profileId: number, issueId: number) {
return queryOptions({
queryKey: ['issues', 'detail', profileId, issueId],
queryFn: () => fetchIssue(profileId, issueId),
enabled: issueId > 0,
});
}

@ -1,20 +1,9 @@
import { queryOptions } from '@tanstack/react-query';
import { apiClient, readJson } from '@/app/api-client';
import type {
IssueCounts,
IssueCountsResponse,
IssueDetailResponse,
IssueListResponse,
IssueRecord,
CreateIssuePayload,
IssuesSearch,
IssueSnapshot,
} from './-issues.types';
const DEFAULT_LIMIT = 100;
export const REFRESH_EVENT = 'ss:issues-refresh';
export const CLOSE_EVENT = 'ss:issues-close-detail';
@ -102,12 +91,6 @@ export const ISSUE_STATUS_META: Record<string, { label: string; className: strin
dismissed: { label: 'Dismissed', className: 'is-dismissed' },
};
function createIssueHeaders(profileId: number, extra?: HeadersInit): Headers {
const headers = new Headers(extra);
headers.set('X-Profile-Id', String(profileId || 1));
return headers;
}
export function getIssueCategoriesForEntity(entityType: IssueRecord['entity_type']) {
return Object.entries(ISSUE_CATEGORY_META).filter(([, category]) =>
category.applies.includes(entityType),
@ -145,134 +128,6 @@ export function normalizeIssuesSearch(search: IssuesSearch | undefined): Normali
};
}
export async function fetchIssueCounts(profileId: number): Promise<IssueCounts> {
const payload = await readJson<IssueCountsResponse>(
apiClient.get('issues/counts', {
headers: createIssueHeaders(profileId),
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to load issue counts');
}
return payload.counts;
}
export async function fetchIssueList(
profileId: number,
search: Pick<NormalizedIssuesSearch, 'status' | 'category'>,
): Promise<IssueListResponse> {
const params = new URLSearchParams();
params.set('limit', String(DEFAULT_LIMIT));
if (search.status !== 'all') {
params.set('status', search.status);
}
if (search.category !== 'all') {
params.set('category', search.category);
}
const payload = await readJson<IssueListResponse>(
apiClient.get('issues', {
headers: createIssueHeaders(profileId),
searchParams: params,
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to load issues');
}
return payload;
}
export async function fetchIssue(profileId: number, issueId: number): Promise<IssueRecord> {
const payload = await readJson<IssueDetailResponse>(
apiClient.get(`issues/${issueId}`, {
headers: createIssueHeaders(profileId),
}),
);
if (!payload.success || !payload.issue) {
throw new Error(payload.error || 'Issue not found');
}
return payload.issue;
}
export async function updateIssue(
profileId: number,
issueId: number,
updates: { status?: string; admin_response?: string },
): Promise<void> {
const payload = await readJson<{ success: boolean; error?: string }>(
apiClient.put(`issues/${issueId}`, {
headers: createIssueHeaders(profileId),
json: updates,
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to update issue');
}
}
export async function createIssue(
profileId: number,
payload: CreateIssuePayload,
): Promise<IssueRecord | null> {
const response = await readJson<{
success: boolean;
issue?: IssueRecord;
error?: string;
}>(
apiClient.post('issues', {
headers: createIssueHeaders(profileId, { 'Content-Type': 'application/json' }),
json: {
entity_type: payload.entity_type,
entity_id: String(payload.entity_id),
category: payload.category,
title: payload.title,
description: payload.description || '',
priority: payload.priority || 'normal',
},
}),
);
if (!response.success) {
throw new Error(response.error || 'Failed to submit issue');
}
return response.issue ?? null;
}
export async function deleteIssue(profileId: number, issueId: number): Promise<void> {
const payload = await readJson<{ success: boolean; error?: string }>(
apiClient.delete(`issues/${issueId}`, {
headers: createIssueHeaders(profileId),
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to delete issue');
}
}
export function issueCountsQueryOptions(profileId: number) {
return queryOptions({
queryKey: ['issues', 'counts', profileId],
queryFn: () => fetchIssueCounts(profileId),
});
}
export function issueListQueryOptions(
profileId: number,
search: Pick<NormalizedIssuesSearch, 'status' | 'category'>,
) {
return queryOptions({
queryKey: ['issues', 'list', profileId, search.status, search.category],
queryFn: () => fetchIssueList(profileId, search),
});
}
export function issueDetailQueryOptions(profileId: number, issueId: number) {
return queryOptions({
queryKey: ['issues', 'detail', profileId, issueId],
queryFn: () => fetchIssue(profileId, issueId),
enabled: issueId > 0,
});
}
export function dispatchIssuesRefreshEvent() {
window.dispatchEvent(new CustomEvent(REFRESH_EVENT));
}

@ -10,15 +10,14 @@ import {
import type { IssueRecord } from '../-issues.types';
import {
deleteIssue,
formatIssueDate,
formatStatusLabel,
getIssueArtwork,
getPriorityClassName,
ISSUE_CATEGORY_META,
parseSnapshot,
updateIssue,
} from '../-issues.helpers';
import { deleteIssue, updateIssue } from '../-issues.api';
import styles from './issue-detail-modal.module.css';
export function IssueDetailModal({

@ -23,10 +23,12 @@ import type { IssuePriority, IssueReportPayload } from '../-issues.types';
import {
REFRESH_EVENT,
createDefaultIssueTitle,
createIssue,
getIssueCategoriesForEntity,
issueCountsQueryOptions,
} from '../-issues.helpers';
import {
createIssue,
issueCountsQueryOptions,
} from '../-issues.api';
import styles from './issue-detail-modal.module.css';
const ISSUE_DOMAIN_QUERY_KEY = ['issues'] as const;

@ -17,15 +17,17 @@ import {
getEntityName,
getIssueArtwork,
getPriorityClassName,
issueCountsQueryOptions,
issueDetailQueryOptions,
issueListQueryOptions,
formatIssueDate,
ISSUE_CATEGORY_META,
ISSUE_STATUS_META,
normalizeIssuesSearch,
parseSnapshot,
formatIssueDate,
} from '../-issues.helpers';
import {
issueCountsQueryOptions,
issueDetailQueryOptions,
issueListQueryOptions,
} from '../-issues.api';
import { Route } from '../route';
import { IssueDetailModal } from './issue-detail-modal';
import styles from './issues-page.module.css';

@ -6,8 +6,8 @@ import {
issueCountsQueryOptions,
issueDetailQueryOptions,
issueListQueryOptions,
normalizeIssuesSearch,
} from './-issues.helpers';
} from './-issues.api';
import { normalizeIssuesSearch } from './-issues.helpers';
import { IssuesPage } from './-ui/issues-page';
export const Route = createFileRoute('/issues')({

Loading…
Cancel
Save