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.
This commit is contained in:
parent
9cde9442b7
commit
ffd989d0f3
6 changed files with 163 additions and 155 deletions
150
webui/src/routes/issues/-issues.api.ts
Normal file
150
webui/src/routes/issues/-issues.api.ts
Normal file
|
|
@ -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…
Reference in a new issue