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 {
|
import type {
|
||||||
IssueCounts,
|
|
||||||
IssueCountsResponse,
|
|
||||||
IssueDetailResponse,
|
|
||||||
IssueListResponse,
|
|
||||||
IssueRecord,
|
IssueRecord,
|
||||||
CreateIssuePayload,
|
|
||||||
IssuesSearch,
|
IssuesSearch,
|
||||||
IssueSnapshot,
|
IssueSnapshot,
|
||||||
} from './-issues.types';
|
} from './-issues.types';
|
||||||
|
|
||||||
const DEFAULT_LIMIT = 100;
|
|
||||||
|
|
||||||
export const REFRESH_EVENT = 'ss:issues-refresh';
|
export const REFRESH_EVENT = 'ss:issues-refresh';
|
||||||
export const CLOSE_EVENT = 'ss:issues-close-detail';
|
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' },
|
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']) {
|
export function getIssueCategoriesForEntity(entityType: IssueRecord['entity_type']) {
|
||||||
return Object.entries(ISSUE_CATEGORY_META).filter(([, category]) =>
|
return Object.entries(ISSUE_CATEGORY_META).filter(([, category]) =>
|
||||||
category.applies.includes(entityType),
|
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() {
|
export function dispatchIssuesRefreshEvent() {
|
||||||
window.dispatchEvent(new CustomEvent(REFRESH_EVENT));
|
window.dispatchEvent(new CustomEvent(REFRESH_EVENT));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,14 @@ import {
|
||||||
import type { IssueRecord } from '../-issues.types';
|
import type { IssueRecord } from '../-issues.types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deleteIssue,
|
|
||||||
formatIssueDate,
|
formatIssueDate,
|
||||||
formatStatusLabel,
|
formatStatusLabel,
|
||||||
getIssueArtwork,
|
getIssueArtwork,
|
||||||
getPriorityClassName,
|
getPriorityClassName,
|
||||||
ISSUE_CATEGORY_META,
|
ISSUE_CATEGORY_META,
|
||||||
parseSnapshot,
|
parseSnapshot,
|
||||||
updateIssue,
|
|
||||||
} from '../-issues.helpers';
|
} from '../-issues.helpers';
|
||||||
|
import { deleteIssue, updateIssue } from '../-issues.api';
|
||||||
import styles from './issue-detail-modal.module.css';
|
import styles from './issue-detail-modal.module.css';
|
||||||
|
|
||||||
export function IssueDetailModal({
|
export function IssueDetailModal({
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,12 @@ import type { IssuePriority, IssueReportPayload } from '../-issues.types';
|
||||||
import {
|
import {
|
||||||
REFRESH_EVENT,
|
REFRESH_EVENT,
|
||||||
createDefaultIssueTitle,
|
createDefaultIssueTitle,
|
||||||
createIssue,
|
|
||||||
getIssueCategoriesForEntity,
|
getIssueCategoriesForEntity,
|
||||||
issueCountsQueryOptions,
|
|
||||||
} from '../-issues.helpers';
|
} from '../-issues.helpers';
|
||||||
|
import {
|
||||||
|
createIssue,
|
||||||
|
issueCountsQueryOptions,
|
||||||
|
} from '../-issues.api';
|
||||||
import styles from './issue-detail-modal.module.css';
|
import styles from './issue-detail-modal.module.css';
|
||||||
|
|
||||||
const ISSUE_DOMAIN_QUERY_KEY = ['issues'] as const;
|
const ISSUE_DOMAIN_QUERY_KEY = ['issues'] as const;
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,17 @@ import {
|
||||||
getEntityName,
|
getEntityName,
|
||||||
getIssueArtwork,
|
getIssueArtwork,
|
||||||
getPriorityClassName,
|
getPriorityClassName,
|
||||||
issueCountsQueryOptions,
|
formatIssueDate,
|
||||||
issueDetailQueryOptions,
|
|
||||||
issueListQueryOptions,
|
|
||||||
ISSUE_CATEGORY_META,
|
ISSUE_CATEGORY_META,
|
||||||
ISSUE_STATUS_META,
|
ISSUE_STATUS_META,
|
||||||
normalizeIssuesSearch,
|
normalizeIssuesSearch,
|
||||||
parseSnapshot,
|
parseSnapshot,
|
||||||
formatIssueDate,
|
|
||||||
} from '../-issues.helpers';
|
} from '../-issues.helpers';
|
||||||
|
import {
|
||||||
|
issueCountsQueryOptions,
|
||||||
|
issueDetailQueryOptions,
|
||||||
|
issueListQueryOptions,
|
||||||
|
} from '../-issues.api';
|
||||||
import { Route } from '../route';
|
import { Route } from '../route';
|
||||||
import { IssueDetailModal } from './issue-detail-modal';
|
import { IssueDetailModal } from './issue-detail-modal';
|
||||||
import styles from './issues-page.module.css';
|
import styles from './issues-page.module.css';
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import {
|
||||||
issueCountsQueryOptions,
|
issueCountsQueryOptions,
|
||||||
issueDetailQueryOptions,
|
issueDetailQueryOptions,
|
||||||
issueListQueryOptions,
|
issueListQueryOptions,
|
||||||
normalizeIssuesSearch,
|
} from './-issues.api';
|
||||||
} from './-issues.helpers';
|
import { normalizeIssuesSearch } from './-issues.helpers';
|
||||||
import { IssuesPage } from './-ui/issues-page';
|
import { IssuesPage } from './-ui/issues-page';
|
||||||
|
|
||||||
export const Route = createFileRoute('/issues')({
|
export const Route = createFileRoute('/issues')({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue