fix:第三章开发结束

This commit is contained in:
彭帅
2026-05-28 16:53:53 +08:00
parent 3bdd16cbd2
commit 50879a71da
60 changed files with 5558 additions and 361 deletions

View File

@@ -2,4 +2,15 @@
2.一般我只会让你加前端,或者加一些删除等简单的后端接口,千万不要破坏原接口的路径,入参定义,出参定义。
3.下拉框的数据要缓存下来,同一选项源不要重复请求接口。
4.前端首页(及同类入口页)加载首页数据时,副作用只触发一次查询,避免 Strict Mode 或重复 mount 导致多次请求。
5.保存、提交类操作要做防抖debounce防止连续点击重复提交。
5.保存、提交类操作要做防抖debounce防止连续点击重复提交。
6.每完成一份 `docs/dev/**` 下的开发文档对应功能(页面、接口、校验等与文档要求一致)后,在该文档末尾追加完成标注,格式如下:
```markdown
---
**状态:已完成**
```
- 仅在该文档描述的功能全部落地时标注;部分实现(如仅后端、缺页面或缺校验)不要标注。
- 若文档末尾已有「状态:已完成」,不要重复追加。
- `docs/dev/**/README.md` 等索引/说明类文档无需标注。

View File

@@ -38,3 +38,7 @@
1. `sample_id` 必须存在。
2. 删除 callset 前检查 `allele_call``callset_variant_sets`
3. 如果 callset 绑定多个 variantset查询和导出时要明确当前 variantset 范围。
---
**状态:已完成**

View File

@@ -37,3 +37,7 @@
1. `linkage_group_id``variant_id` 必须存在。
2. 同一 linkage group 下同一 variant 不应重复。
3. `position` 不应超过 linkage group 的 `max_marker_position`
---
**状态:已完成**

View File

@@ -32,3 +32,7 @@
1. `call_sets_id``variant_sets_id` 必须存在。
2. 同一 callset 与 variantset 关系不应重复。
3. 删除关系不应删除 callset 或 variantset 主数据。
---
**状态:已完成**

View File

@@ -43,3 +43,7 @@
1. `variant_set_id` 必须存在。
2. 删除 variantset 时需要先处理或级联处理 analysis。
3. `software` 如果是 URL前端可做 URL 格式提示。
---
**状态:已完成**

View File

@@ -40,3 +40,7 @@
1. `variant_set_id` 必须存在。
2. `fileurl` 如填写需通过 URL 格式校验。
3. 对矩阵格式,`sep_phased/sep_unphased/unknown_string` 会影响解析,应在导入预览时展示。
---
**状态:已完成**

View File

@@ -0,0 +1,318 @@
import { createCachedLoader } from "@/services/dropdownCache";
import { getAuthToken } from "@/utils/token";
import {
NONE_SELECT_VALUE,
type AlleleCallQuery,
type AlleleCallRecord,
type CallSetDetail,
type CallSetQuery,
type CallSetRecord,
type SelectOption,
} from "./types";
interface BrapiPagination {
currentPage: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
interface BrapiListResponse<T> {
metadata: {
pagination: BrapiPagination;
status: Array<Record<string, unknown>>;
datafiles: Array<Record<string, unknown>>;
};
result: {
data: T[];
};
}
interface BrapiSingleResponse<T> {
metadata: {
pagination: BrapiPagination;
status: Array<Record<string, unknown>>;
datafiles: Array<Record<string, unknown>>;
};
result: T;
}
interface SampleResponse {
sampleDbId: string;
sampleName: string | null;
}
interface VariantSetLookup {
value: string;
label: string;
}
type CallSetPayload = Partial<Record<
"id" | "call_set_name" | "sample_id" | "variant_set_ids",
unknown
>>;
type AlleleCallPayload = Partial<Record<
"call_set_id" | "variant_id" | "genotype" | "read_depth" | "phase_set",
unknown
>>;
const apiBase = () => {
if (typeof window !== "undefined") return "";
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken();
const response = await fetch(`${apiBase()}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers || {}),
},
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
const optionalText = (value: unknown) => {
const normalized = String(value ?? "").trim();
if (!normalized || normalized === NONE_SELECT_VALUE) return null;
return normalized;
};
const requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
return normalized;
};
const normalizeVariantSetIds = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return value
.map((item) => String(item ?? "").trim())
.filter(Boolean);
};
const sampleLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<SampleResponse>>("/brapi/v2/samples?page=0&pageSize=10");
return response.result.data.map((item) => ({
value: item.sampleDbId,
label: item.sampleName || item.sampleDbId,
}));
});
const variantSetLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<{ variantSetDbId: string; variantSetName: string | null }>>(
"/brapi/v2/variantsets?page=0&pageSize=10",
);
return response.result.data.map((item) => ({
value: item.variantSetDbId,
label: item.variantSetName || item.variantSetDbId,
}));
});
export function invalidateCallSetPageCache() {
sampleLoader.invalidate();
variantSetLoader.invalidate();
}
const mapCallSet = (
item: CallSetRecord,
samples: SelectOption[],
variantSets: VariantSetLookup[],
): CallSetRecord => {
const sampleDbId = item.sampleDbId || item.sample_id || null;
const sample = samples.find((entry) => entry.value === sampleDbId);
const variantSetIds = normalizeVariantSetIds(item.variantSetDbIds ?? item.variant_set_ids);
const variantSetNames = variantSetIds
.map((id) => variantSets.find((entry) => entry.value === id)?.label || id)
.join("、");
return {
...item,
id: item.callSetDbId || item.id,
call_set_name: item.call_set_name || item.callSetName || null,
sample_id: sampleDbId,
sample_name: item.sample_name || item.sampleName || sample?.label || null,
variant_set_ids: variantSetIds,
variant_set_names: variantSetNames || "—",
};
};
const filterCallSetRows = (
rows: CallSetRecord[],
query: CallSetQuery | undefined,
samples: SelectOption[],
variantSets: VariantSetLookup[],
): CallSetRecord[] => {
const nameFilter = String(query?.call_set_name ?? "").trim().toLowerCase();
const sampleId = optionalText(query?.sample_id);
const variantSetId = optionalText(query?.variant_set_id);
return rows
.map((item) => mapCallSet(item, samples, variantSets))
.filter((item) => {
if (nameFilter && !String(item.call_set_name ?? "").toLowerCase().includes(nameFilter)) return false;
if (sampleId && item.sample_id !== sampleId) return false;
if (variantSetId && !(item.variant_set_ids ?? []).includes(variantSetId)) return false;
return true;
});
};
const callSetBody = (payload: CallSetPayload) => ({
callSetName: requiredText(payload.call_set_name, "CallSet 名称不能为空"),
sampleDbId: requiredText(payload.sample_id, "Sample 不能为空"),
variantSetDbIds: normalizeVariantSetIds(payload.variant_set_ids),
});
export const normalizeCallSetFormData = (row: CallSetRecord) => ({
id: row.id,
call_set_name: row.call_set_name || row.callSetName || "",
sample_id: row.sample_id && row.sample_id !== NONE_SELECT_VALUE ? row.sample_id : NONE_SELECT_VALUE,
variant_set_ids: normalizeVariantSetIds(row.variant_set_ids ?? row.variantSetDbIds),
});
export async function loadCallSetPageData(params: {
query?: CallSetQuery;
force?: boolean;
} = {}): Promise<{
options: { samples: SelectOption[]; variantSets: SelectOption[] };
rows: CallSetRecord[];
}> {
const force = params.force ?? false;
const [samples, variantSets] = await Promise.all([
sampleLoader.load(force),
variantSetLoader.load(force),
]);
const searchParams = new URLSearchParams({ page: "0", pageSize: "10" });
const sampleId = optionalText(params.query?.sample_id);
const variantSetId = optionalText(params.query?.variant_set_id);
if (sampleId) searchParams.set("sampleDbId", sampleId);
if (variantSetId) searchParams.set("variantSetDbId", variantSetId);
const response = await request<BrapiListResponse<CallSetRecord>>(
`/brapi/v2/callsets?${searchParams.toString()}`,
);
return {
options: { samples, variantSets },
rows: filterCallSetRows(response.result.data, params.query, samples, variantSets),
};
}
export async function fetchCallSetDetail(callSetDbId: string): Promise<CallSetDetail> {
const [detail, samples, variantSets] = await Promise.all([
request<BrapiSingleResponse<CallSetRecord>>(`/brapi/v2/callsets/${encodeURIComponent(callSetDbId)}`),
sampleLoader.load(),
variantSetLoader.load(),
]);
return mapCallSet(detail.result, samples, variantSets) as CallSetDetail;
}
export async function createCallSetRow(payload: CallSetPayload): Promise<CallSetRecord> {
const response = await request<BrapiListResponse<CallSetRecord>>("/brapi/v2/callsets", {
method: "POST",
body: JSON.stringify({
callSetDbId: optionalText(payload.id),
...callSetBody(payload),
}),
});
invalidateCallSetPageCache();
const [samples, variantSets] = await Promise.all([
sampleLoader.load(true),
variantSetLoader.load(true),
]);
return mapCallSet(response.result.data[0], samples, variantSets);
}
export async function updateCallSetRow(id: string, payload: CallSetPayload): Promise<CallSetRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) {
throw new Error("CallSet ID 不可修改,请新建记录");
}
const response = await request<BrapiSingleResponse<CallSetRecord>>(
`/brapi/v2/callsets/${encodeURIComponent(id)}`,
{
method: "PUT",
body: JSON.stringify(callSetBody(payload)),
},
);
invalidateCallSetPageCache();
const [samples, variantSets] = await Promise.all([
sampleLoader.load(true),
variantSetLoader.load(true),
]);
return mapCallSet(response.result, samples, variantSets);
}
export async function deleteCallSetRow(id: string): Promise<void> {
await request(`/brapi/v2/callsets/${encodeURIComponent(id)}`, { method: "DELETE" });
invalidateCallSetPageCache();
}
const mapAlleleCall = (item: AlleleCallRecord & { additionalInfo?: Record<string, unknown> }): AlleleCallRecord => ({
...item,
id: String(item.additionalInfo?.callDbId ?? item.callDbId ?? item.id ?? ""),
call_set_id: item.callSetDbId || item.call_set_id || null,
call_set_name: item.callSetName || item.call_set_name || null,
variant_id: item.variantDbId || item.variant_id || null,
variant_name: item.variantName || item.variant_name || null,
variant_set_id: item.variantSetDbId || item.variant_set_id || null,
genotype: item.genotypeValue || item.genotype || null,
});
const filterAlleleCallRows = (rows: AlleleCallRecord[], query?: AlleleCallQuery) => {
const callSetId = optionalText(query?.call_set_id);
const variantId = optionalText(query?.variant_id);
const variantSetId = optionalText(query?.variant_set_id);
return rows
.map(mapAlleleCall)
.filter((item) => {
if (callSetId && item.call_set_id !== callSetId) return false;
if (variantId && item.variant_id !== variantId) return false;
if (variantSetId && item.variant_set_id !== variantSetId) return false;
return true;
});
};
export async function loadAlleleCallRows(query?: AlleleCallQuery): Promise<AlleleCallRecord[]> {
const searchParams = new URLSearchParams({ pageSize: "10" });
const callSetId = optionalText(query?.call_set_id);
const variantId = optionalText(query?.variant_id);
const variantSetId = optionalText(query?.variant_set_id);
if (callSetId) searchParams.set("callSetDbId", callSetId);
if (variantId) searchParams.set("variantDbId", variantId);
if (variantSetId) searchParams.set("variantSetDbId", variantSetId);
const response = await request<BrapiListResponse<AlleleCallRecord>>(
`/brapi/v2/calls?${searchParams.toString()}`,
);
return filterAlleleCallRows(response.result.data, query);
}
export async function importAlleleCallRows(payloads: AlleleCallPayload[]): Promise<AlleleCallRecord[]> {
const body = payloads.map((payload) => ({
callDbId: optionalText((payload as Record<string, unknown>).id),
callSetDbId: requiredText(payload.call_set_id, "CallSet 不能为空"),
variantDbId: requiredText(payload.variant_id, "Variant 不能为空"),
genotype: requiredText(payload.genotype, "Genotype 不能为空"),
readDepth: payload.read_depth === null || payload.read_depth === undefined || payload.read_depth === ""
? null
: Number(payload.read_depth),
phaseSet: optionalText(payload.phase_set),
}));
const response = await request<BrapiListResponse<AlleleCallRecord>>("/brapi/v2/calls/import", {
method: "POST",
body: JSON.stringify(body),
});
return response.result.data.map(mapAlleleCall);
}

View File

@@ -0,0 +1,182 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Dna, RotateCcw, Search } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { loadAlleleCallRows, loadCallSetPageData } from "../api";
import { NONE_SELECT_VALUE, type AlleleCallQuery, type AlleleCallRecord, type SelectOption } from "../types";
const emptyQuery = (): AlleleCallQuery => ({
call_set_id: NONE_SELECT_VALUE,
variant_id: NONE_SELECT_VALUE,
variant_set_id: NONE_SELECT_VALUE,
});
export function AlleleCallTab() {
const searchParams = useSearchParams();
const [callSetOptions, setCallSetOptions] = useState<SelectOption[]>([]);
const [variantSetOptions, setVariantSetOptions] = useState<SelectOption[]>([]);
const [draftQuery, setDraftQuery] = useState<AlleleCallQuery>(() => ({
...emptyQuery(),
variant_id: searchParams.get("variant_id") || NONE_SELECT_VALUE,
}));
const [appliedQuery, setAppliedQuery] = useState<AlleleCallQuery>(() => ({
...emptyQuery(),
variant_id: searchParams.get("variant_id") || NONE_SELECT_VALUE,
}));
const [rows, setRows] = useState<AlleleCallRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
loadCallSetPageData()
.then(({ options, rows: callSetRows }) => {
if (!mounted) return;
setCallSetOptions(callSetRows.map((item) => ({
value: String(item.id ?? item.callSetDbId ?? ""),
label: String(item.call_set_name ?? item.callSetName ?? item.id ?? ""),
})).filter((item) => item.value));
setVariantSetOptions(options.variantSets);
})
.catch(() => {
if (!mounted) return;
});
return () => { mounted = false; };
}, []);
const loadRows = useCallback(async () => {
return loadAlleleCallRows(appliedQuery);
}, [appliedQuery]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadRows()
.then((data) => {
if (!mounted) return;
setRows(data);
})
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载 allele_call 失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadRows]);
const hint = useMemo(() => {
if (appliedQuery.variant_id && appliedQuery.variant_id !== NONE_SELECT_VALUE) {
return "按 Variant 过滤查看该位点下的 genotype 调用结果。";
}
return "allele_call 通常通过 VCF/HapMap/矩阵导入;导入时会自动绑定 callset 与 variantset 关系。";
}, [appliedQuery.variant_id]);
return (
<div className="space-y-4">
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="mb-3 flex items-center gap-2">
<Dna className="h-4 w-4 text-emerald-500" />
<div>
<h2 className="text-base font-semibold">allele_call </h2>
<p className="text-xs text-slate-500">{hint}</p>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">CallSet</Label>
<Select
value={draftQuery.call_set_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, call_set_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{callSetOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">VariantSet</Label>
<Select
value={draftQuery.variant_set_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, variant_set_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{variantSetOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" variant="outline" className="gap-2" onClick={() => {
const reset = emptyQuery();
setDraftQuery(reset);
setAppliedQuery(reset);
}}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
{error ? (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
) : null}
<div className="rounded-xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950">
{loading ? (
<div className="p-4"><Skeleton className="h-64 w-full" /></div>
) : rows.length === 0 ? (
<p className="p-6 text-sm text-slate-500"> allele_call </p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>CallSet</TableHead>
<TableHead>Variant</TableHead>
<TableHead>VariantSet</TableHead>
<TableHead>Genotype</TableHead>
<TableHead>Read Depth</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={String(row.id || `${row.call_set_id}-${row.variant_id}`)}>
<TableCell>{row.call_set_name || row.call_set_id || "—"}</TableCell>
<TableCell>{row.variant_name || row.variant_id || "—"}</TableCell>
<TableCell>
<Badge variant="outline">{row.variant_set_id || "—"}</Badge>
</TableCell>
<TableCell>{row.genotype || "—"}</TableCell>
<TableCell>{row.read_depth ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,233 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Binary, RotateCcw, Search } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
createCallSetRow,
deleteCallSetRow,
fetchCallSetDetail,
loadCallSetPageData,
normalizeCallSetFormData,
updateCallSetRow,
} from "../api";
import { NONE_SELECT_VALUE, type CallSetQuery, type SelectOption } from "../types";
const emptyQuery = (): CallSetQuery => ({
call_set_name: "",
sample_id: NONE_SELECT_VALUE,
variant_set_id: NONE_SELECT_VALUE,
});
const optionOrNone = (label: string, options: SelectOption[]) => [
{ value: NONE_SELECT_VALUE, label },
...options,
];
function normalizeSelectedVariantSetIds(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item ?? "").trim()).filter(Boolean);
}
export function CallSetTab() {
const searchParams = useSearchParams();
const [sampleOptions, setSampleOptions] = useState<SelectOption[]>([]);
const [variantSetOptions, setVariantSetOptions] = useState<SelectOption[]>([]);
const [draftQuery, setDraftQuery] = useState<CallSetQuery>(emptyQuery);
const [appliedQuery, setAppliedQuery] = useState<CallSetQuery>(emptyQuery);
const urlDefaultFormValues = useMemo(() => {
const sampleId = searchParams.get("sample_id");
if (!sampleId) return undefined;
return { sample_id: sampleId };
}, [searchParams]);
const loadRows = useCallback(async () => {
const { options, rows } = await loadCallSetPageData({ query: appliedQuery });
setSampleOptions(options.samples);
setVariantSetOptions(options.variantSets);
return rows as unknown as Record<string, unknown>[];
}, [appliedQuery]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchCallSetDetail(id);
return normalizeCallSetFormData(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "CallSet ID", type: "text", placeholder: "留空则系统自动生成(导入时可指定)" },
{
key: "call_set_name",
label: "CallSet 名称",
type: "text",
required: true,
placeholder: "如 sample01_variantset1",
},
{
key: "sample_id",
label: "Sample",
type: "select",
required: true,
options: optionOrNone("请选择 Sample", sampleOptions),
},
], [sampleOptions]);
const renderFormExtra = useCallback(({ formData, updateFormBatch }: {
formData: Record<string, unknown>;
updateForm: (key: string, value: string) => void;
updateFormBatch: (patch: Record<string, unknown>) => void;
editingRow: Record<string, unknown> | null;
}) => {
const selectedIds = normalizeSelectedVariantSetIds(formData.variant_set_ids);
const toggleVariantSet = (variantSetId: string, checked: boolean) => {
const next = checked
? Array.from(new Set([...selectedIds, variantSetId]))
: selectedIds.filter((id) => id !== variantSetId);
updateFormBatch({ variant_set_ids: next });
};
return (
<>
<div className="md:col-span-2 space-y-2">
<Label className="text-sm text-slate-700 dark:text-slate-200">VariantSet</Label>
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-slate-200 p-3 dark:border-slate-800">
{variantSetOptions.length === 0 ? (
<p className="text-sm text-slate-500"> VariantSet VariantSet </p>
) : (
variantSetOptions.map((option) => {
const checked = selectedIds.includes(option.value);
return (
<label
key={option.value}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-1 text-sm hover:bg-slate-50 dark:hover:bg-slate-900"
>
<Checkbox
checked={checked}
onCheckedChange={(value) => toggleVariantSet(option.value, value === true)}
/>
<span>{option.label}</span>
</label>
);
})
)}
</div>
<p className="text-xs text-slate-500">
CallSet VariantSet allele_call callset_variant_sets
</p>
</div>
<div className="col-span-2 rounded-lg border border-sky-100 bg-sky-50/60 p-3 text-xs text-sky-900 dark:border-sky-900/40 dark:bg-sky-950/30 dark:text-sky-100">
<p className="font-medium">callset_variant_sets</p>
<p className="mt-1 text-sky-800/80 dark:text-sky-200/80">
callset variantset
</p>
</div>
</>
);
}, [variantSetOptions]);
const renderQueryForm = useCallback(() => (
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">CallSet </Label>
<Input
value={draftQuery.call_set_name ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, call_set_name: event.target.value }))}
placeholder="callSetName 模糊匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Sample</Label>
<Select
value={draftQuery.sample_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, sample_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{sampleOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">VariantSet</Label>
<Select
value={draftQuery.variant_set_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, variant_set_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{variantSetOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" variant="outline" className="gap-2" onClick={() => {
const reset = emptyQuery();
setDraftQuery(reset);
setAppliedQuery(reset);
}}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
), [draftQuery, sampleOptions, variantSetOptions]);
return (
<BrapiEntityPage
useEnhancedDialog
icon={Binary}
iconBg="bg-gradient-to-br from-sky-500 to-blue-600"
title="CallSet 调用集合"
description="维护 sample 的 genotype call 集合,并绑定其覆盖的 VariantSet。"
addLabel="新增 CallSet"
defaultFormValues={urlDefaultFormValues}
columns={[
{ key: "call_set_name", label: "名称" },
{ key: "sample_name", label: "Sample" },
{
key: "variant_set_names",
label: "VariantSet",
render: (value) => (
<Badge variant="outline" className="max-w-xs truncate">
{String(value ?? "—")}
</Badge>
),
},
]}
fields={fields}
data={[]}
stats={[{
label: "/brapi/v2/callsets",
value: "BrAPI",
className: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200",
}]}
loadData={loadRows}
fetchRecord={fetchRecord}
createRecord={(payload) => createCallSetRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateCallSetRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteCallSetRow}
renderQueryForm={renderQueryForm}
renderFormExtra={renderFormExtra}
/>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Binary, Dna } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { AlleleCallTab } from "./components/AlleleCallTab";
import { CallSetTab } from "./components/CallSetTab";
function TabFallback() {
return <Skeleton className="h-96 w-full rounded-xl" />;
}
export default function CallSetPage() {
const searchParams = useSearchParams();
const [tab, setTab] = useState("callsets");
useEffect(() => {
const requested = searchParams.get("tab");
if (requested === "allele-calls") {
setTab("allele-calls");
}
}, [searchParams]);
return (
<Tabs value={tab} onValueChange={setTab} className="flex min-h-full flex-col gap-4">
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
<TabsTrigger value="callsets" className="gap-2">
<Binary className="h-4 w-4" />
CallSet
</TabsTrigger>
<TabsTrigger value="allele-calls" className="gap-2">
<Dna className="h-4 w-4" />
allele_call
</TabsTrigger>
</TabsList>
{tab === "callsets" ? (
<TabsContent value="callsets" className="mt-0 min-h-0 flex-1">
<Suspense fallback={<TabFallback />}>
<CallSetTab />
</Suspense>
</TabsContent>
) : null}
{tab === "allele-calls" ? (
<TabsContent value="allele-calls" className="mt-0 min-h-0 flex-1">
<Suspense fallback={<TabFallback />}>
<AlleleCallTab />
</Suspense>
</TabsContent>
) : null}
</Tabs>
);
}

View File

@@ -0,0 +1,57 @@
export const NONE_SELECT_VALUE = "__none__";
export interface SelectOption {
value: string;
label: string;
}
export interface CallSetQuery {
call_set_name?: string;
sample_id?: string;
variant_set_id?: string;
}
export interface CallSetRecord {
id?: string;
callSetDbId?: string;
call_set_name?: string | null;
callSetName?: string | null;
sample_id?: string | null;
sampleDbId?: string | null;
sample_name?: string | null;
sampleName?: string | null;
variant_set_ids?: string[];
variantSetDbIds?: string[] | null;
variant_set_names?: string;
created?: string | null;
updated?: string | null;
}
export interface CallSetDetail extends CallSetRecord {
variant_set_ids: string[];
}
export interface AlleleCallQuery {
call_set_id?: string;
variant_id?: string;
variant_set_id?: string;
}
export interface AlleleCallRecord {
id?: string;
callDbId?: string;
call_set_id?: string | null;
callSetDbId?: string | null;
call_set_name?: string | null;
callSetName?: string | null;
variant_id?: string | null;
variantDbId?: string | null;
variant_name?: string | null;
variantName?: string | null;
variant_set_id?: string | null;
variantSetDbId?: string | null;
genotype?: string | null;
genotypeValue?: string | null;
read_depth?: number | null;
phase_set?: string | null;
}

View File

@@ -0,0 +1,568 @@
import { DEFAULT_LIST_PAGE_SIZE, DEFAULT_PAGE_QUERY } from "@/constants/api";
import { createCachedLoader, loadCommonCropNameOptions, type SelectOption as CachedSelectOption } from "@/services/dropdownCache";
import { getAuthToken } from "@/utils/token";
import { readAdditionalInfoString } from "./genomeMapUtils";
import {
NONE_SELECT_VALUE,
type GenomeMapDetail,
type GenomeMapQuery,
type GenomeMapRecord,
type LinkageGroupQuery,
type LinkageGroupRecord,
type MarkerPositionQuery,
type MarkerPositionRecord,
type SelectOption,
} from "./types";
interface BrapiPagination {
currentPage: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
interface BrapiListResponse<T> {
metadata: {
pagination: BrapiPagination;
status: Array<Record<string, unknown>>;
datafiles: Array<Record<string, unknown>>;
};
result: {
data: T[];
};
}
interface BrapiSingleResponse<T> {
metadata: {
pagination: BrapiPagination;
status: Array<Record<string, unknown>>;
datafiles: Array<Record<string, unknown>>;
};
result: T;
}
type GenomeMapPayload = Partial<Record<
| "id"
| "map_name"
| "map_pui"
| "common_crop_name"
| "scientific_name"
| "type"
| "unit"
| "comments"
| "documentation_url"
| "published_date",
unknown
>>;
type LinkageGroupPayload = Partial<Record<
"id" | "linkage_group_name" | "max_position",
unknown
>>;
type MarkerPositionPayload = Partial<Record<
"id" | "linkage_group_id" | "variant_id" | "position",
unknown
>>;
interface VariantLookup {
variantDbId?: string;
variantId?: string;
id?: string;
variantNames?: string[] | null;
variantName?: string | null;
}
const readVariantLabel = (item: VariantLookup) =>
item.variantNames?.[0] || item.variantName || item.variantDbId || item.variantId || item.id || "";
const toValidSelectOptions = (options: SelectOption[]) =>
options.filter((option) => Boolean(String(option.value ?? "").trim()));
const mapVariantOptions = (rows: VariantLookup[]): SelectOption[] =>
toValidSelectOptions(rows.map((item) => {
const value = String(item.variantDbId ?? item.variantId ?? item.id ?? "").trim();
return { value, label: readVariantLabel(item) || value };
}).filter((item) => item.value));
const apiBase = () => {
if (typeof window !== "undefined") return "";
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken();
const response = await fetch(`${apiBase()}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers || {}),
},
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `Request failed: ${response.status}`);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
const optionalText = (value: unknown) => {
const normalized = String(value ?? "").trim();
if (!normalized || normalized === NONE_SELECT_VALUE) return null;
return normalized;
};
const requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
return normalized;
};
const optionalNumber = (value: unknown) => {
if (value === null || value === undefined || value === "") return null;
const parsed = Number(value);
if (Number.isNaN(parsed)) throw new Error("请输入有效数字");
return parsed;
};
const requiredNumber = (value: unknown, message: string) => {
const parsed = optionalNumber(value);
if (parsed === null) throw new Error(message);
return parsed;
};
const mapGenomeMap = (item: GenomeMapRecord): GenomeMapRecord => ({
...item,
id: item.mapDbId || item.id,
map_name: item.map_name ?? item.mapName ?? null,
map_pui: item.map_pui ?? item.mapPUI ?? null,
common_crop_name: item.common_crop_name ?? item.commonCropName ?? null,
scientific_name: item.scientific_name ?? item.scientificName ?? null,
documentation_url: item.documentation_url ?? item.documentationURL ?? null,
published_date: item.published_date ?? (item.publishedDate ? String(item.publishedDate).slice(0, 10) : null),
linkage_group_count: item.linkage_group_count ?? item.linkageGroupCount ?? null,
marker_count: item.marker_count ?? item.markerCount ?? null,
});
const mapLinkageGroup = (item: Record<string, unknown>): LinkageGroupRecord => {
const id = (
readAdditionalInfoString(item, "linkageGroupDbId")
|| String(item.linkageGroupDbId ?? item.id ?? "")
).trim();
const mapDbId = readAdditionalInfoString(item, "mapDbId") || String(item.mapDbId ?? item.map_db_id ?? "").trim();
return {
...item,
id,
linkage_group_db_id: id,
linkage_group_name: String(item.linkageGroupName ?? item.linkage_group_name ?? ""),
max_position: (item.maxPosition ?? item.max_position) as number | null,
marker_count: (item.markerCount ?? item.marker_count) as number | null,
map_db_id: mapDbId || undefined,
};
};
const mapMarkerPosition = (item: Record<string, unknown>): MarkerPositionRecord => {
const id = readAdditionalInfoString(item, "markerPositionDbId")
|| String(item.markerPositionDbId ?? item.id ?? "");
return {
...item,
id,
marker_position_db_id: id,
linkage_group_db_id: readAdditionalInfoString(item, "linkageGroupDbId") ?? undefined,
linkage_group_name: String(item.linkageGroupName ?? item.linkage_group_name ?? ""),
variant_db_id: String(item.variantDbId ?? item.variant_db_id ?? ""),
variant_name: String(item.variantName ?? item.variant_name ?? ""),
position: (item.position ?? null) as number | null,
map_db_id: String(item.mapDbId ?? item.map_db_id ?? ""),
map_name: String(item.mapName ?? item.map_name ?? ""),
};
};
const genomeMapBody = (payload: GenomeMapPayload) => ({
mapDbId: optionalText(payload.id),
mapName: requiredText(payload.map_name, "图谱名称不能为空"),
commonCropName: optionalText(payload.common_crop_name),
scientificName: optionalText(payload.scientific_name),
type: optionalText(payload.type),
unit: optionalText(payload.unit),
comments: optionalText(payload.comments),
documentationURL: optionalText(payload.documentation_url),
publishedDate: optionalText(payload.published_date),
});
const linkageGroupBody = (payload: LinkageGroupPayload) => ({
linkageGroupName: requiredText(payload.linkage_group_name, "连锁群名称不能为空"),
maxPosition: optionalNumber(payload.max_position),
});
const markerPositionBody = (payload: MarkerPositionPayload) => ({
markerPositionDbId: optionalText(payload.id),
linkageGroupDbId: requiredText(payload.linkage_group_id, "请选择连锁群"),
variantDbId: requiredText(payload.variant_id, "请选择 Variant"),
position: requiredNumber(payload.position, "位置不能为空"),
});
export const normalizeGenomeMapFormData = (row: GenomeMapRecord) => ({
id: row.id,
map_name: row.map_name || "",
map_pui: row.map_pui || "",
common_crop_name: row.common_crop_name && row.common_crop_name !== NONE_SELECT_VALUE
? row.common_crop_name
: NONE_SELECT_VALUE,
scientific_name: row.scientific_name || "",
type: row.type || "",
unit: row.unit || "",
comments: row.comments || "",
documentation_url: row.documentation_url || "",
published_date: row.published_date || "",
});
export const normalizeLinkageGroupFormData = (row: LinkageGroupRecord, includeMap = false) => ({
id: row.id,
linkage_group_name: row.linkage_group_name || "",
max_position: row.max_position ?? "",
...(includeMap ? {
map_db_id: row.map_db_id && row.map_db_id !== NONE_SELECT_VALUE
? row.map_db_id
: NONE_SELECT_VALUE,
} : {}),
});
export const normalizeMarkerPositionFormData = (row: MarkerPositionRecord) => ({
id: row.id,
linkage_group_id: row.linkage_group_db_id && row.linkage_group_db_id !== NONE_SELECT_VALUE
? row.linkage_group_db_id
: NONE_SELECT_VALUE,
variant_id: row.variant_db_id && row.variant_db_id !== NONE_SELECT_VALUE
? row.variant_db_id
: NONE_SELECT_VALUE,
position: row.position ?? "",
});
const mapListLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<GenomeMapRecord>>(`/brapi/v2/maps?${DEFAULT_PAGE_QUERY}`);
return response.result.data.map(mapGenomeMap);
});
const allLinkageGroupsLoader = createCachedLoader(async () => {
const maps = await mapListLoader.load();
if (maps.length === 0) return [];
const batches = await Promise.all(
maps.map(async (map) => {
try {
const rows = await fetchLinkageGroupRows(map.id);
return rows
.filter((row) => row.id)
.map((row) => ({
...row,
map_db_id: row.map_db_id || map.id,
map_name: map.map_name || map.id,
}));
} catch {
return [];
}
}),
);
return batches.flat();
});
const variantListLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<VariantLookup>>(
`/brapi/v2/variants?pageToken=0&pageSize=${DEFAULT_LIST_PAGE_SIZE}`,
);
return mapVariantOptions(response.result?.data ?? []);
});
export function invalidateGenomeMapPageCache() {
mapListLoader.invalidate();
allLinkageGroupsLoader.invalidate();
variantListLoader.invalidate();
}
const filterLinkageGroupRows = (rows: LinkageGroupRecord[], query?: LinkageGroupQuery) => {
const nameFilter = String(query?.linkage_group_name ?? "").trim().toLowerCase();
const mapFilter = optionalText(query?.map_db_id);
return rows.filter((row) => {
if (nameFilter && !String(row.linkage_group_name ?? "").toLowerCase().includes(nameFilter)) return false;
if (mapFilter && row.map_db_id !== mapFilter) return false;
return true;
});
};
export async function loadLinkageGroupPageData(params: {
query?: LinkageGroupQuery;
force?: boolean;
} = {}) {
const [maps, linkageGroups] = await Promise.all([
mapListLoader.load(params.force),
allLinkageGroupsLoader.load(params.force),
]);
const mapOptions: SelectOption[] = maps.map((map) => ({
value: map.id,
label: map.map_name || map.id,
}));
return {
options: { maps: mapOptions },
rows: filterLinkageGroupRows(linkageGroups, params.query),
};
}
const filterMapRows = (rows: GenomeMapRecord[], query?: GenomeMapQuery) => {
const nameFilter = String(query?.map_name ?? "").trim().toLowerCase();
const cropFilter = optionalText(query?.common_crop_name);
const typeFilter = optionalText(query?.type);
return rows.filter((row) => {
if (nameFilter && !String(row.map_name ?? "").toLowerCase().includes(nameFilter)) return false;
if (cropFilter && row.common_crop_name !== cropFilter) return false;
if (typeFilter && row.type !== typeFilter) return false;
return true;
});
};
export async function loadGenomeMapPageData(params: {
query?: GenomeMapQuery;
force?: boolean;
} = {}) {
const [maps, cropOptions] = await Promise.all([
mapListLoader.load(params.force),
loadCommonCropNameOptions(params.force),
]);
const cropSelectOptions: SelectOption[] = cropOptions.map((item: CachedSelectOption) => ({
value: item.value,
label: item.label,
}));
return {
options: { crops: cropSelectOptions },
rows: filterMapRows(maps, params.query),
};
}
export async function fetchGenomeMapDetail(mapDbId: string): Promise<GenomeMapDetail> {
const [mapResponse, linkageResponse, markerResponse] = await Promise.all([
request<BrapiSingleResponse<GenomeMapRecord>>(`/brapi/v2/maps/${encodeURIComponent(mapDbId)}`),
request<BrapiListResponse<Record<string, unknown>>>(
`/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups?${DEFAULT_PAGE_QUERY}`,
),
request<BrapiListResponse<Record<string, unknown>>>(
`/brapi/v2/markerpositions?mapDbId=${encodeURIComponent(mapDbId)}&${DEFAULT_PAGE_QUERY}`,
),
]);
const map = mapGenomeMap(mapResponse.result);
return {
...map,
linkageGroups: linkageResponse.result.data.map(mapLinkageGroup),
markerPositions: markerResponse.result.data.map(mapMarkerPosition),
};
}
export async function fetchLinkageGroupRows(mapDbId: string): Promise<LinkageGroupRecord[]> {
const response = await request<BrapiListResponse<Record<string, unknown>>>(
`/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups?${DEFAULT_PAGE_QUERY}`,
);
return response.result.data.map(mapLinkageGroup);
}
const buildMarkerPositionQueryString = (query?: MarkerPositionQuery) => {
const params = new URLSearchParams(DEFAULT_PAGE_QUERY);
const mapDbId = optionalText(query?.map_db_id);
const linkageGroupName = optionalText(query?.linkage_group_name);
const variantDbId = optionalText(query?.variant_db_id);
if (mapDbId) params.set("mapDbId", mapDbId);
if (linkageGroupName) params.set("linkageGroupName", linkageGroupName);
if (variantDbId) params.set("variantDbId", variantDbId);
return params.toString();
};
const filterMarkerPositionRows = (
rows: MarkerPositionRecord[],
query?: MarkerPositionQuery,
linkageGroupDbId?: string,
) => {
const linkageGroupFilter = optionalText(linkageGroupDbId);
return rows.filter((row) => {
if (linkageGroupFilter && row.linkage_group_db_id !== linkageGroupFilter) return false;
return true;
});
};
export async function fetchMarkerPositionRowsByQuery(
query?: MarkerPositionQuery,
linkageGroupDbId?: string,
): Promise<MarkerPositionRecord[]> {
const response = await request<BrapiListResponse<Record<string, unknown>>>(
`/brapi/v2/markerpositions?${buildMarkerPositionQueryString(query)}`,
);
return filterMarkerPositionRows(
response.result.data.map(mapMarkerPosition),
query,
linkageGroupDbId,
);
}
export async function loadMarkerPositionFilterOptions(params: {
mapDbId?: string;
} = {}) {
const maps = await mapListLoader.load();
const mapOptions: SelectOption[] = maps.map((map) => ({
value: map.id,
label: map.map_name || map.id,
}));
const mapFilter = optionalText(params.mapDbId);
let linkageGroupOptions: SelectOption[] = [];
if (mapFilter) {
const rows = await fetchLinkageGroupRows(mapFilter);
linkageGroupOptions = toValidSelectOptions(rows.map((row) => ({
value: row.linkage_group_name || row.id,
label: row.linkage_group_name || row.id,
})));
} else {
const linkageGroups = await allLinkageGroupsLoader.load();
linkageGroupOptions = toValidSelectOptions(linkageGroups.map((row) => ({
value: row.linkage_group_name || row.id,
label: row.map_name
? `${row.linkage_group_name || row.id} (${row.map_name})`
: (row.linkage_group_name || row.id),
})));
}
const variants = await variantListLoader.load();
return {
maps: mapOptions,
linkageGroups: linkageGroupOptions,
variants,
};
}
export async function loadMarkerPositionPageData(params: {
query?: MarkerPositionQuery;
} = {}) {
const mapDbId = optionalText(params.query?.map_db_id) ?? undefined;
const [options, rows] = await Promise.all([
loadMarkerPositionFilterOptions({ mapDbId }),
fetchMarkerPositionRowsByQuery(params.query),
]);
return { options, rows };
}
export async function fetchLinkageGroupDetail(
mapDbId: string,
linkageGroupDbId: string,
): Promise<LinkageGroupRecord | null> {
const rows = await fetchLinkageGroupRows(mapDbId);
return rows.find((row) => row.id === linkageGroupDbId) ?? null;
}
export async function fetchMarkerPositionRows(mapDbId: string): Promise<MarkerPositionRecord[]> {
return fetchMarkerPositionRowsByQuery({ map_db_id: mapDbId });
}
export async function fetchMarkerPositionsByVariantId(variantDbId: string): Promise<MarkerPositionRecord[]> {
return fetchMarkerPositionRowsByQuery({ variant_db_id: variantDbId });
}
export async function fetchVariantOptions(force = false): Promise<SelectOption[]> {
return variantListLoader.load(force);
}
export async function fetchAllLinkageGroupFormOptions(): Promise<SelectOption[]> {
const linkageGroups = await allLinkageGroupsLoader.load();
return toValidSelectOptions(linkageGroups.map((row) => ({
value: row.id,
label: row.map_name
? `${row.linkage_group_name || row.id} (${row.map_name})`
: (row.linkage_group_name || row.id),
})));
}
export async function fetchLinkageGroupOptions(mapDbId: string): Promise<SelectOption[]> {
const rows = await fetchLinkageGroupRows(mapDbId);
return toValidSelectOptions(rows.map((row) => ({
value: row.id,
label: row.linkage_group_name || row.id,
})));
}
export async function createGenomeMapRow(payload: GenomeMapPayload) {
invalidateGenomeMapPageCache();
const response = await request<BrapiListResponse<GenomeMapRecord>>("/brapi/v2/maps", {
method: "POST",
body: JSON.stringify(genomeMapBody(payload)),
});
return mapGenomeMap(response.result.data[0]);
}
export async function updateGenomeMapRow(id: string, payload: GenomeMapPayload) {
invalidateGenomeMapPageCache();
const response = await request<BrapiSingleResponse<GenomeMapRecord>>(
`/brapi/v2/maps/${encodeURIComponent(id)}`,
{ method: "PUT", body: JSON.stringify(genomeMapBody(payload)) },
);
return mapGenomeMap(response.result);
}
export async function deleteGenomeMapRow(id: string) {
invalidateGenomeMapPageCache();
await request(`/brapi/v2/maps/${encodeURIComponent(id)}`, { method: "DELETE" });
}
export async function createLinkageGroupRow(mapDbId: string, payload: LinkageGroupPayload) {
invalidateGenomeMapPageCache();
const response = await request<BrapiListResponse<Record<string, unknown>>>(
`/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups`,
{ method: "POST", body: JSON.stringify(linkageGroupBody(payload)) },
);
return mapLinkageGroup(response.result.data[0]);
}
export async function createLinkageGroupRowFromPayload(payload: LinkageGroupPayload & { map_db_id?: unknown }) {
const mapDbId = requiredText(payload.map_db_id, "请选择所属图谱");
return createLinkageGroupRow(mapDbId, payload);
}
export async function updateLinkageGroupRow(
mapDbId: string,
linkageGroupDbId: string,
payload: LinkageGroupPayload,
) {
invalidateGenomeMapPageCache();
const response = await request<BrapiListResponse<Record<string, unknown>>>(
`/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups/${encodeURIComponent(linkageGroupDbId)}`,
{ method: "PUT", body: JSON.stringify(linkageGroupBody(payload)) },
);
return mapLinkageGroup(response.result.data[0]);
}
export async function deleteLinkageGroupRow(mapDbId: string, linkageGroupDbId: string) {
invalidateGenomeMapPageCache();
await request(
`/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups/${encodeURIComponent(linkageGroupDbId)}`,
{ method: "DELETE" },
);
}
export async function createMarkerPositionRow(payload: MarkerPositionPayload) {
invalidateGenomeMapPageCache();
const response = await request<BrapiListResponse<Record<string, unknown>>>("/brapi/v2/markerpositions", {
method: "POST",
body: JSON.stringify(markerPositionBody(payload)),
});
return mapMarkerPosition(response.result.data[0]);
}
export async function updateMarkerPositionRow(id: string, payload: MarkerPositionPayload) {
invalidateGenomeMapPageCache();
const response = await request<BrapiListResponse<Record<string, unknown>>>(
`/brapi/v2/markerpositions/${encodeURIComponent(id)}`,
{ method: "PUT", body: JSON.stringify(markerPositionBody(payload)) },
);
return mapMarkerPosition(response.result.data[0]);
}
export async function deleteMarkerPositionRow(id: string) {
invalidateGenomeMapPageCache();
await request(`/brapi/v2/markerpositions/${encodeURIComponent(id)}`, { method: "DELETE" });
}

View File

@@ -0,0 +1,196 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { Map, RotateCcw, Search } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
createGenomeMapRow,
deleteGenomeMapRow,
loadGenomeMapPageData,
normalizeGenomeMapFormData,
updateGenomeMapRow,
} from "../api";
import { NONE_SELECT_VALUE, type GenomeMapQuery, type SelectOption } from "../types";
const mapTypeOptions: SelectOption[] = [
{ value: "physical", label: "physical物理图" },
{ value: "genomic", label: "genomic基因组图" },
];
const emptyQuery = (): GenomeMapQuery => ({
map_name: "",
common_crop_name: NONE_SELECT_VALUE,
type: NONE_SELECT_VALUE,
});
const optionOrNone = (label: string, options: SelectOption[]) => [
{ value: NONE_SELECT_VALUE, label },
...options,
];
export function GenomeMapTab() {
const [cropOptions, setCropOptions] = useState<SelectOption[]>([]);
const [draftQuery, setDraftQuery] = useState<GenomeMapQuery>(emptyQuery);
const [appliedQuery, setAppliedQuery] = useState<GenomeMapQuery>(emptyQuery);
const loadRows = useCallback(async () => {
const { options, rows } = await loadGenomeMapPageData({ query: appliedQuery });
setCropOptions(options.crops);
return rows as unknown as Record<string, unknown>[];
}, [appliedQuery]);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "Map ID", type: "text", placeholder: "留空则系统自动生成" },
{ key: "map_name", label: "图谱名称", type: "text", required: true, placeholder: "如 Maize IBM2" },
{
key: "map_pui",
label: "Map PUI",
type: "text",
readOnly: true,
placeholder: "保存后由系统自动生成",
},
{
key: "common_crop_name",
label: "作物",
type: "select",
options: optionOrNone("不关联作物", cropOptions),
},
{ key: "scientific_name", label: "学名", type: "text", placeholder: "Zea mays" },
{
key: "type",
label: "图谱类型",
type: "select",
options: optionOrNone("不指定", mapTypeOptions),
},
{ key: "unit", label: "单位", type: "text", placeholder: "如 cM" },
{ key: "published_date", label: "发表日期", type: "date" },
{ key: "documentation_url", label: "文档 URL", type: "text", placeholder: "https://..." },
{ key: "comments", label: "备注", type: "textarea", colSpan: 2 },
], [cropOptions]);
const renderQueryForm = useCallback(() => (
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.map_name ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, map_name: event.target.value }))}
placeholder="mapName 模糊匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select
value={draftQuery.common_crop_name ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, common_crop_name: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{cropOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select
value={draftQuery.type ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, type: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{mapTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" variant="outline" className="gap-2" onClick={() => {
const reset = emptyQuery();
setDraftQuery(reset);
setAppliedQuery(reset);
}}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
), [draftQuery, cropOptions]);
return (
<BrapiEntityPage
useEnhancedDialog
icon={Map}
iconBg="bg-gradient-to-br from-teal-500 to-cyan-600"
title="GenomeMap 遗传图谱"
description="维护遗传图谱主数据;可切换 Linkage Group Tab 跨图谱维护,或进入详情维护 Marker Position。"
addLabel="新增图谱"
columns={[
{
key: "map_name",
label: "名称",
render: (value, row) => {
const id = String(row.id ?? row.mapDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link
href={`/genotyping/genome-map/maps/${encodeURIComponent(id)}`}
className="font-medium text-teal-600 hover:underline dark:text-teal-400"
>
{name}
</Link>
);
},
},
{ key: "common_crop_name", label: "作物" },
{ key: "type", label: "类型" },
{ key: "unit", label: "单位" },
{
key: "linkage_group_count",
label: "连锁群",
render: (value) => <Badge variant="outline">{Number(value ?? 0)}</Badge>,
},
{
key: "marker_count",
label: "Marker 数",
render: (value) => <Badge variant="outline">{Number(value ?? 0)}</Badge>,
},
]}
fields={fields}
data={[]}
stats={[{
label: "/brapi/v2/maps",
value: "BrAPI",
className: "bg-teal-50 text-teal-700 dark:bg-teal-400/10 dark:text-teal-200",
}]}
loadData={loadRows}
fetchRecord={async (id) => {
const { rows } = await loadGenomeMapPageData({ force: true });
const row = rows.find((item) => item.id === id);
if (!row) throw new Error("图谱不存在");
return normalizeGenomeMapFormData(row);
}}
createRecord={(payload) => createGenomeMapRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateGenomeMapRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteGenomeMapRow}
renderQueryForm={renderQueryForm}
/>
);
}

View File

@@ -0,0 +1,221 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, type ReactNode } from "react";
import { GitBranch } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
createLinkageGroupRow,
createLinkageGroupRowFromPayload,
deleteLinkageGroupRow,
fetchLinkageGroupRows,
loadLinkageGroupPageData,
normalizeLinkageGroupFormData,
updateLinkageGroupRow,
} from "../api";
import { NONE_SELECT_VALUE, type LinkageGroupQuery, type SelectOption } from "../types";
const optionOrNone = (label: string, options: SelectOption[]) => [
{ value: NONE_SELECT_VALUE, label },
...options,
];
interface LinkageGroupEntityPageProps {
/** 详情页固定所属图谱;入口 Tab 不传则跨图谱列表维护 */
mapDbId?: string;
mapOptions?: SelectOption[];
linkageGroupQuery?: LinkageGroupQuery;
onChanged?: () => void;
defaultFormValues?: Record<string, unknown>;
renderQueryForm?: () => ReactNode;
}
export function LinkageGroupEntityPage({
mapDbId,
mapOptions = [],
linkageGroupQuery,
onChanged,
defaultFormValues,
renderQueryForm,
}: LinkageGroupEntityPageProps) {
const scopedToMap = Boolean(mapDbId);
const includeMapInForm = !scopedToMap;
const loadRows = useCallback(async () => {
if (scopedToMap && mapDbId) {
const rows = await fetchLinkageGroupRows(mapDbId);
return rows as unknown as Record<string, unknown>[];
}
const { rows } = await loadLinkageGroupPageData({ query: linkageGroupQuery });
return rows as unknown as Record<string, unknown>[];
}, [scopedToMap, mapDbId, linkageGroupQuery]);
const resolveMapDbId = useCallback((payload: Record<string, unknown>, row?: Record<string, unknown>) => {
if (mapDbId) return mapDbId;
const fromPayload = String(payload.map_db_id ?? "").trim();
if (fromPayload && fromPayload !== NONE_SELECT_VALUE) return fromPayload;
return String(row?.map_db_id ?? row?.mapDbId ?? "").trim();
}, [mapDbId]);
const fields = useMemo<BrapiFormField[]>(() => {
const base: BrapiFormField[] = [];
if (includeMapInForm) {
base.push({
key: "map_db_id",
label: "所属图谱",
type: "select",
required: true,
options: optionOrNone("请选择图谱", mapOptions),
});
}
base.push(
{ key: "linkage_group_name", label: "连锁群名称", type: "text", required: true, placeholder: "如 Chr01" },
{ key: "max_position", label: "最大位置", type: "number", placeholder: "可选,非负整数" },
);
return base;
}, [includeMapInForm, mapOptions]);
const columns = useMemo(() => {
const cols = [
{
key: "linkage_group_name",
label: "名称",
render: (value: unknown, row: Record<string, unknown>) => {
const name = String(value ?? "—");
const targetMapId = resolveMapDbId({}, row);
if (!targetMapId) return name;
return (
<Link
href={`/genotyping/genome-map/maps/${encodeURIComponent(targetMapId)}/linkage-groups/${encodeURIComponent(String(row.id ?? ""))}`}
className="font-medium text-teal-600 hover:underline dark:text-teal-400"
>
{name}
</Link>
);
},
},
];
if (!scopedToMap) {
cols.push({
key: "map_name",
label: "所属图谱",
render: (value: unknown, row: Record<string, unknown>) => {
const targetMapId = resolveMapDbId({}, row);
const label = String(value ?? row.map_name ?? "—");
if (!targetMapId) return label;
return (
<Link
href={`/genotyping/genome-map/maps/${encodeURIComponent(targetMapId)}`}
className="text-teal-600 hover:underline dark:text-teal-400"
>
{label}
</Link>
);
},
});
}
cols.push(
{ key: "max_position", label: "最大位置", render: (value: unknown) => String(value ?? "—") },
{
key: "marker_count",
label: "Marker 数",
render: (value: unknown) => <Badge variant="outline">{Number(value ?? 0)}</Badge>,
},
);
return cols;
}, [scopedToMap, resolveMapDbId]);
const wrapMutation = useCallback(<T, >(action: () => Promise<T>) => async () => {
const result = await action();
onChanged?.();
return result;
}, [onChanged]);
return (
<BrapiEntityPage
useEnhancedDialog
icon={GitBranch}
iconBg="bg-gradient-to-br from-teal-500 to-emerald-600"
title={scopedToMap ? "连锁群 Linkage Group" : "Linkage Group 连锁群"}
description={
scopedToMap
? "该图谱下的连锁群;删除前请确认无 Marker Position 引用。"
: "跨图谱维护连锁群;新增时需选择所属 GenomeMap也可在图谱详情中维护。"
}
addLabel="新增连锁群"
defaultFormValues={defaultFormValues}
columns={columns}
fields={fields}
data={[]}
stats={[{
label: scopedToMap ? `/maps/${mapDbId}/linkagegroups` : "聚合 /maps/*/linkagegroups",
value: "BrAPI",
className: "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-200",
}]}
loadData={loadRows}
fetchRecord={async (id) => {
if (scopedToMap && mapDbId) {
const rows = await fetchLinkageGroupRows(mapDbId);
const row = rows.find((item) => item.id === id);
if (!row) throw new Error("连锁群不存在");
return normalizeLinkageGroupFormData(row);
}
const { rows } = await loadLinkageGroupPageData({ force: true });
const row = rows.find((item) => item.id === id);
if (!row) throw new Error("连锁群不存在");
return normalizeLinkageGroupFormData(row, true);
}}
createRecord={(payload) => {
if (scopedToMap && mapDbId) {
return wrapMutation(() => createLinkageGroupRow(mapDbId, payload))() as Promise<Record<string, unknown>>;
}
return wrapMutation(() => createLinkageGroupRowFromPayload(payload))() as Promise<Record<string, unknown>>;
}}
updateRecord={async (id, payload) => {
const targetMapId = resolveMapDbId(payload);
if (!targetMapId) throw new Error("无法确定所属图谱");
return wrapMutation(() => updateLinkageGroupRow(targetMapId, id, payload))() as Promise<Record<string, unknown>>;
}}
deleteRecord={async (id) => {
let targetMapId = mapDbId;
if (!targetMapId) {
const { rows } = await loadLinkageGroupPageData({ force: true });
const row = rows.find((item) => item.id === id);
targetMapId = row?.map_db_id || undefined;
}
if (!targetMapId) throw new Error("无法确定所属图谱");
await wrapMutation(() => deleteLinkageGroupRow(targetMapId, id))();
}}
renderQueryForm={renderQueryForm}
renderFormExtra={({ editingRow }) => (
<>
<div className="md:col-span-2 space-y-1.5">
<Label className="text-sm text-slate-700 dark:text-slate-200">LinkageGroup ID</Label>
{editingRow ? (
<Input
value={String(editingRow.id ?? "")}
readOnly
disabled
className="bg-slate-50 dark:bg-slate-900"
/>
) : (
<p className="rounded-md border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
</p>
)}
<p className="text-xs text-slate-500"></p>
</div>
<div className="col-span-2 rounded-lg border border-emerald-100 bg-emerald-50/60 p-3 text-xs text-emerald-900 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-100">
<p className="font-medium"></p>
<p className="mt-1 text-emerald-800/80 dark:text-emerald-200/80">
Marker Position
</p>
</div>
</>
)}
/>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import { LinkageGroupEntityPage } from "./LinkageGroupEntityPage";
interface LinkageGroupPanelProps {
mapDbId: string;
onChanged?: () => void;
}
export function LinkageGroupPanel({ mapDbId, onChanged }: LinkageGroupPanelProps) {
return <LinkageGroupEntityPage mapDbId={mapDbId} onChanged={onChanged} />;
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { RotateCcw, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { loadLinkageGroupPageData } from "../api";
import { LinkageGroupEntityPage } from "./LinkageGroupEntityPage";
import { NONE_SELECT_VALUE, type LinkageGroupQuery, type SelectOption } from "../types";
const emptyQuery = (): LinkageGroupQuery => ({
linkage_group_name: "",
map_db_id: NONE_SELECT_VALUE,
});
function toSelectValue(value: string | null | undefined) {
return value && value !== NONE_SELECT_VALUE ? value : NONE_SELECT_VALUE;
}
export function LinkageGroupTab() {
const searchParams = useSearchParams();
const [mapOptions, setMapOptions] = useState<SelectOption[]>([]);
const [draftQuery, setDraftQuery] = useState<LinkageGroupQuery>(() => ({
...emptyQuery(),
map_db_id: searchParams.get("map_db_id") ?? NONE_SELECT_VALUE,
}));
const [appliedQuery, setAppliedQuery] = useState<LinkageGroupQuery>(() => ({
...emptyQuery(),
map_db_id: searchParams.get("map_db_id") ?? NONE_SELECT_VALUE,
}));
const urlDefaultFormValues = useMemo(() => {
const mapDbId = searchParams.get("map_db_id");
return mapDbId ? { map_db_id: mapDbId } : undefined;
}, [searchParams]);
useEffect(() => {
let mounted = true;
loadLinkageGroupPageData().then(({ options }) => {
if (!mounted) return;
setMapOptions(options.maps);
});
return () => { mounted = false; };
}, []);
const renderQueryForm = useCallback(() => (
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.linkage_group_name ?? ""}
onChange={(event) => setDraftQuery((current) => ({
...current,
linkage_group_name: event.target.value,
}))}
placeholder="名称模糊匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select
value={toSelectValue(draftQuery.map_db_id)}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, map_db_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部图谱" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{mapOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex justify-end gap-2">
<Button type="button" variant="outline" className="gap-2" onClick={() => {
const reset = emptyQuery();
setDraftQuery(reset);
setAppliedQuery(reset);
}}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
), [draftQuery, mapOptions]);
return (
<LinkageGroupEntityPage
mapOptions={mapOptions}
linkageGroupQuery={appliedQuery}
defaultFormValues={urlDefaultFormValues}
renderQueryForm={renderQueryForm}
/>
);
}

View File

@@ -0,0 +1,222 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { MapPin } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import {
createMarkerPositionRow,
deleteMarkerPositionRow,
fetchAllLinkageGroupFormOptions,
fetchLinkageGroupOptions,
fetchMarkerPositionRowsByQuery,
fetchVariantOptions,
normalizeMarkerPositionFormData,
updateMarkerPositionRow,
} from "../api";
import { NONE_SELECT_VALUE, type MarkerPositionQuery, type SelectOption } from "../types";
interface MarkerPositionEntityPageProps {
/** 详情页固定所属图谱 */
mapDbId?: string;
/** 连锁群详情页固定所属连锁群 */
linkageGroupDbId?: string;
linkageGroupName?: string | null;
markerPositionQuery?: MarkerPositionQuery;
onChanged?: () => void;
defaultFormValues?: Record<string, unknown>;
renderQueryForm?: () => ReactNode;
}
export function MarkerPositionEntityPage({
mapDbId,
linkageGroupDbId,
linkageGroupName,
markerPositionQuery,
onChanged,
defaultFormValues,
renderQueryForm,
}: MarkerPositionEntityPageProps) {
const scopedToMap = Boolean(mapDbId);
const scopedToLinkageGroup = Boolean(linkageGroupDbId);
const [linkageGroupOptions, setLinkageGroupOptions] = useState<SelectOption[]>([]);
const [variantOptions, setVariantOptions] = useState<SelectOption[]>([]);
useEffect(() => {
let mounted = true;
const linkagePromise = mapDbId
? fetchLinkageGroupOptions(mapDbId)
: fetchAllLinkageGroupFormOptions();
Promise.all([linkagePromise, fetchVariantOptions()])
.then(([linkageGroups, variants]) => {
if (!mounted) return;
setLinkageGroupOptions(linkageGroups);
setVariantOptions(variants);
})
.catch(() => {
if (!mounted) return;
setLinkageGroupOptions([]);
setVariantOptions([]);
});
return () => { mounted = false; };
}, [mapDbId]);
const loadRows = useCallback(async () => {
const query = scopedToMap && mapDbId
? { map_db_id: mapDbId }
: markerPositionQuery;
const rows = await fetchMarkerPositionRowsByQuery(query, linkageGroupDbId);
return rows as unknown as Record<string, unknown>[];
}, [scopedToMap, mapDbId, linkageGroupDbId, markerPositionQuery]);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "MarkerPosition ID", type: "text", placeholder: "留空则系统自动生成" },
{
key: "linkage_group_id",
label: "连锁群",
type: "select",
required: true,
options: [
{ value: NONE_SELECT_VALUE, label: "请选择连锁群" },
...linkageGroupOptions,
],
},
{
key: "variant_id",
label: "Variant",
type: "select",
required: true,
options: [
{ value: NONE_SELECT_VALUE, label: "请选择 Variant" },
...variantOptions,
],
},
{ key: "position", label: "图谱位置", type: "number", required: true, placeholder: "非负整数" },
], [linkageGroupOptions, variantOptions]);
const columns = useMemo(() => {
const cols = [];
if (!scopedToMap) {
cols.push({
key: "map_name",
label: "所属图谱",
render: (value: unknown, row: Record<string, unknown>) => {
const id = String(row.map_db_id ?? row.mapDbId ?? "");
const label = String(value ?? row.map_name ?? "—");
if (!id) return label;
return (
<Link
href={`/genotyping/genome-map/maps/${encodeURIComponent(id)}`}
className="text-teal-600 hover:underline dark:text-teal-400"
>
{label}
</Link>
);
},
});
}
cols.push(
{
key: "linkage_group_name",
label: "连锁群",
render: (value: unknown, row: Record<string, unknown>) => {
const name = String(value ?? "—");
const lgId = String(row.linkage_group_db_id ?? row.linkageGroupDbId ?? "");
const targetMapId = mapDbId || String(row.map_db_id ?? row.mapDbId ?? "");
if (!lgId || !targetMapId) return name;
return (
<Link
href={`/genotyping/genome-map/maps/${encodeURIComponent(targetMapId)}/linkage-groups/${encodeURIComponent(lgId)}`}
className="font-medium text-teal-600 hover:underline dark:text-teal-400"
>
{name}
</Link>
);
},
},
{
key: "variant_name",
label: "Variant",
render: (value: unknown, row: Record<string, unknown>) => {
const id = String(row.variant_db_id ?? row.variantDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link
href={`/genotyping/variant/variants/${encodeURIComponent(id)}`}
className="font-medium text-rose-600 hover:underline dark:text-rose-400"
>
{name}
</Link>
);
},
},
{ key: "position", label: "位置" },
);
return cols;
}, [scopedToMap, mapDbId]);
const resolvedDefaultFormValues = useMemo(() => ({
...defaultFormValues,
...(linkageGroupDbId ? { linkage_group_id: linkageGroupDbId } : {}),
}), [defaultFormValues, linkageGroupDbId]);
const wrapMutation = useCallback(<T, >(action: () => Promise<T>) => async () => {
const result = await action();
onChanged?.();
return result;
}, [onChanged]);
const findRowById = useCallback(async (id: string) => {
const query = scopedToMap && mapDbId
? { map_db_id: mapDbId }
: markerPositionQuery;
const rows = await fetchMarkerPositionRowsByQuery(query, linkageGroupDbId);
return rows.find((item) => item.id === id);
}, [scopedToMap, mapDbId, linkageGroupDbId, markerPositionQuery]);
const title = scopedToLinkageGroup && linkageGroupName
? `Marker Position · ${linkageGroupName}`
: scopedToMap
? "Marker Position"
: "Marker Position 列表";
const description = scopedToLinkageGroup
? "在该连锁群下维护 Variant 图谱位置;同一 Variant 不可重复。"
: scopedToMap
? "将 Variant 定位到连锁群坐标;同一连锁群下同一 Variant 不可重复。"
: "按图谱、连锁群、Variant 查询并维护 marker_position。";
return (
<BrapiEntityPage
useEnhancedDialog
icon={MapPin}
iconBg="bg-gradient-to-br from-cyan-500 to-blue-600"
title={title}
description={description}
addLabel="新增 Marker Position"
defaultFormValues={resolvedDefaultFormValues}
columns={columns}
fields={fields}
data={[]}
loadData={loadRows}
fetchRecord={async (id) => {
const row = await findRowById(id);
if (!row) throw new Error("Marker Position 不存在");
return normalizeMarkerPositionFormData(row);
}}
createRecord={(payload) => wrapMutation(() => createMarkerPositionRow(payload))() as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => wrapMutation(() => updateMarkerPositionRow(id, payload))() as Promise<Record<string, unknown>>}
deleteRecord={(id) => wrapMutation(() => deleteMarkerPositionRow(id))().then(() => undefined)}
renderQueryForm={renderQueryForm}
renderFormExtra={() => (
<div className="col-span-2 rounded-lg border border-cyan-100 bg-cyan-50/60 p-3 text-xs text-cyan-900 dark:border-cyan-900/40 dark:bg-cyan-950/30 dark:text-cyan-100">
<p className="font-medium"></p>
<p className="mt-1 text-cyan-800/80 dark:text-cyan-200/80">
position max_position unit
</p>
</div>
)}
/>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import { MarkerPositionEntityPage } from "./MarkerPositionEntityPage";
interface MarkerPositionPanelProps {
mapDbId: string;
onChanged?: () => void;
}
export function MarkerPositionPanel({ mapDbId, onChanged }: MarkerPositionPanelProps) {
return <MarkerPositionEntityPage mapDbId={mapDbId} onChanged={onChanged} />;
}

View File

@@ -0,0 +1,153 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { RotateCcw, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { loadMarkerPositionFilterOptions } from "../api";
import { MarkerPositionEntityPage } from "./MarkerPositionEntityPage";
import { NONE_SELECT_VALUE, type MarkerPositionQuery, type SelectOption } from "../types";
const emptyQuery = (): MarkerPositionQuery => ({
map_db_id: NONE_SELECT_VALUE,
linkage_group_name: NONE_SELECT_VALUE,
variant_db_id: NONE_SELECT_VALUE,
});
function toSelectValue(value: string | null | undefined) {
return value && value !== NONE_SELECT_VALUE ? value : NONE_SELECT_VALUE;
}
export function MarkerPositionTab() {
const searchParams = useSearchParams();
const [mapOptions, setMapOptions] = useState<SelectOption[]>([]);
const [linkageGroupOptions, setLinkageGroupOptions] = useState<SelectOption[]>([]);
const [variantOptions, setVariantOptions] = useState<SelectOption[]>([]);
const [draftQuery, setDraftQuery] = useState<MarkerPositionQuery>(() => ({
...emptyQuery(),
map_db_id: searchParams.get("map_db_id") ?? NONE_SELECT_VALUE,
linkage_group_name: searchParams.get("linkage_group_name") ?? NONE_SELECT_VALUE,
variant_db_id: searchParams.get("variant_db_id") ?? NONE_SELECT_VALUE,
}));
const [appliedQuery, setAppliedQuery] = useState<MarkerPositionQuery>(() => ({
...emptyQuery(),
map_db_id: searchParams.get("map_db_id") ?? NONE_SELECT_VALUE,
linkage_group_name: searchParams.get("linkage_group_name") ?? NONE_SELECT_VALUE,
variant_db_id: searchParams.get("variant_db_id") ?? NONE_SELECT_VALUE,
}));
useEffect(() => {
let mounted = true;
loadMarkerPositionFilterOptions()
.then((options) => {
if (!mounted) return;
setMapOptions(options.maps);
setLinkageGroupOptions(options.linkageGroups);
setVariantOptions(options.variants);
})
.catch(() => {
if (!mounted) return;
setMapOptions([]);
setLinkageGroupOptions([]);
setVariantOptions([]);
});
return () => { mounted = false; };
}, []);
useEffect(() => {
let mounted = true;
const mapDbId = toSelectValue(draftQuery.map_db_id);
loadMarkerPositionFilterOptions({
mapDbId: mapDbId !== NONE_SELECT_VALUE ? mapDbId : undefined,
}).then((options) => {
if (!mounted) return;
setLinkageGroupOptions(options.linkageGroups);
});
return () => { mounted = false; };
}, [draftQuery.map_db_id]);
const renderQueryForm = useCallback(() => (
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 md:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select
value={toSelectValue(draftQuery.map_db_id)}
onValueChange={(value) => setDraftQuery((current) => ({
...current,
map_db_id: value,
linkage_group_name: NONE_SELECT_VALUE,
}))}
>
<SelectTrigger><SelectValue placeholder="全部图谱" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{mapOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select
value={toSelectValue(draftQuery.linkage_group_name)}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, linkage_group_name: value }))}
>
<SelectTrigger><SelectValue placeholder="全部连锁群" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{linkageGroupOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Variant</Label>
<Select
value={toSelectValue(draftQuery.variant_db_id)}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, variant_db_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部 Variant" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}> Variant</SelectItem>
{variantOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex justify-end gap-2">
<Button type="button" variant="outline" className="gap-2" onClick={() => {
const reset = emptyQuery();
setDraftQuery(reset);
setAppliedQuery(reset);
}}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
), [draftQuery, mapOptions, linkageGroupOptions, variantOptions]);
const urlDefaultFormValues = useMemo(() => {
const variantId = searchParams.get("variant_db_id");
return variantId ? { variant_id: variantId } : undefined;
}, [searchParams]);
return (
<MarkerPositionEntityPage
markerPositionQuery={appliedQuery}
defaultFormValues={urlDefaultFormValues}
renderQueryForm={renderQueryForm}
/>
);
}

View File

@@ -0,0 +1,15 @@
export function readAdditionalInfoString(
item: Record<string, unknown>,
key: string,
): string | null {
const info = item.additionalInfo;
if (!info || typeof info !== "object") return null;
const value = (info as Record<string, unknown>)[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function formatPublishedDate(value: unknown): string {
if (!value) return "—";
const text = String(value);
return text.length >= 10 ? text.slice(0, 10) : text;
}

View File

@@ -0,0 +1,129 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { ArrowLeft, GitBranch, Map } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { MarkerPositionEntityPage } from "../../../../components/MarkerPositionEntityPage";
import { fetchGenomeMapDetail, fetchLinkageGroupDetail } from "../../../../api";
import type { GenomeMapRecord, LinkageGroupRecord } from "../../../../types";
export default function LinkageGroupDetailPage() {
const params = useParams<{ mapDbId: string; linkageGroupDbId: string }>();
const mapDbId = decodeURIComponent(params.mapDbId);
const linkageGroupDbId = decodeURIComponent(params.linkageGroupDbId);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [mapDetail, setMapDetail] = useState<GenomeMapRecord | null>(null);
const [linkageGroup, setLinkageGroup] = useState<LinkageGroupRecord | null>(null);
const loadDetail = useCallback(async () => {
const [map, group] = await Promise.all([
fetchGenomeMapDetail(mapDbId),
fetchLinkageGroupDetail(mapDbId, linkageGroupDbId),
]);
if (!group) throw new Error("连锁群不存在");
setMapDetail(map);
setLinkageGroup(group);
}, [mapDbId, linkageGroupDbId]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadDetail()
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载连锁群详情失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadDetail]);
if (loading) {
return (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !linkageGroup || !mapDetail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "连锁群不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/genotyping/genome-map?tab=linkage-groups">
<ArrowLeft className="mr-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-full flex-col gap-4">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href={`/genotyping/genome-map/maps/${encodeURIComponent(mapDbId)}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Link>
</Button>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<GitBranch className="h-5 w-5 text-emerald-500" />
{linkageGroup.linkage_group_name || linkageGroup.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500">LinkageGroup ID</span>{linkageGroup.id}</div>
<div>
<span className="text-slate-500"></span>
<Link
href={`/genotyping/genome-map/maps/${encodeURIComponent(mapDbId)}`}
className="text-teal-600 hover:underline dark:text-teal-400"
>
{mapDetail.map_name || mapDbId}
</Link>
</div>
<div><span className="text-slate-500"></span>{linkageGroup.max_position ?? "—"}</div>
<div className="flex items-center gap-2">
<span className="text-slate-500">Marker </span>
<Badge variant="outline">{linkageGroup.marker_count ?? 0}</Badge>
</div>
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/genotyping/genome-map?tab=marker-positions&map_db_id=${encodeURIComponent(mapDbId)}&linkage_group_name=${encodeURIComponent(linkageGroup.linkage_group_name || "")}`}>
<Map className="mr-2 h-4 w-4" />
Marker Position
</Link>
</Button>
</div>
<MarkerPositionEntityPage
mapDbId={mapDbId}
linkageGroupDbId={linkageGroupDbId}
linkageGroupName={linkageGroup.linkage_group_name}
onChanged={loadDetail}
defaultFormValues={{ linkage_group_id: linkageGroupDbId }}
/>
</div>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { ArrowLeft, GitBranch, Map, Sigma } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { LinkageGroupPanel } from "../../components/LinkageGroupPanel";
import { MarkerPositionPanel } from "../../components/MarkerPositionPanel";
import { fetchGenomeMapDetail } from "../../api";
import { formatPublishedDate } from "../../genomeMapUtils";
import type { GenomeMapDetail } from "../../types";
export default function GenomeMapDetailPage() {
const params = useParams<{ mapDbId: string }>();
const mapDbId = decodeURIComponent(params.mapDbId);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<GenomeMapDetail | null>(null);
const loadDetail = useCallback(async () => {
const record = await fetchGenomeMapDetail(mapDbId);
setDetail(record);
}, [mapDbId]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadDetail()
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载图谱详情失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadDetail]);
if (loading) {
return (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !detail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "图谱不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/genotyping/genome-map"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
const hasDependencies = (detail.linkage_group_count ?? detail.linkageGroups.length) > 0
|| (detail.marker_count ?? detail.markerPositions.length) > 0;
return (
<div className="flex min-h-full flex-col gap-4">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href="/genotyping/genome-map"><ArrowLeft className="mr-2 h-4 w-4" /> GenomeMap </Link>
</Button>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Map className="h-5 w-5 text-teal-500" />
{detail.map_name || detail.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500">Map ID</span>{detail.id}</div>
<div><span className="text-slate-500"></span>{detail.common_crop_name || "—"}</div>
<div><span className="text-slate-500"></span>{detail.type || "—"}</div>
<div><span className="text-slate-500"></span>{detail.unit || "—"}</div>
<div><span className="text-slate-500"></span>{detail.scientific_name || "—"}</div>
<div><span className="text-slate-500">Map PUI</span>{detail.map_pui || "—"}</div>
<div><span className="text-slate-500"></span>{formatPublishedDate(detail.published_date)}</div>
<div><span className="text-slate-500"></span>{detail.linkage_group_count ?? detail.linkageGroups.length}</div>
<div><span className="text-slate-500">Marker </span>{detail.marker_count ?? detail.markerPositions.length}</div>
<div className="sm:col-span-2"><span className="text-slate-500"></span>{detail.documentation_url || "—"}</div>
<div className="sm:col-span-2"><span className="text-slate-500"></span>{detail.comments || "—"}</div>
</CardContent>
</Card>
{hasDependencies ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
Marker Position
</p>
) : null}
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/genotyping/genome-map?tab=linkage-groups&map_db_id=${encodeURIComponent(detail.id)}`}>
<GitBranch className="mr-2 h-4 w-4" />
Linkage Group
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/genotyping/variant">
<Sigma className="mr-2 h-4 w-4" />
Variant
</Link>
</Button>
</div>
<LinkageGroupPanel mapDbId={mapDbId} onChanged={loadDetail} />
<MarkerPositionPanel mapDbId={mapDbId} onChanged={loadDetail} />
</div>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { GitBranch, Map, MapPin } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { GenomeMapTab } from "./components/GenomeMapTab";
import { LinkageGroupTab } from "./components/LinkageGroupTab";
import { MarkerPositionTab } from "./components/MarkerPositionTab";
function GenomeMapPageContent() {
const searchParams = useSearchParams();
const [tab, setTab] = useState("genome-maps");
useEffect(() => {
const nextTab = searchParams.get("tab");
if (nextTab === "linkage-groups" || nextTab === "genome-maps" || nextTab === "marker-positions") {
setTab(nextTab);
}
}, [searchParams]);
return (
<Tabs value={tab} onValueChange={setTab} className="flex min-h-full flex-col gap-4">
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
<TabsTrigger value="genome-maps" className="gap-2">
<Map className="h-4 w-4" />
GenomeMap
</TabsTrigger>
<TabsTrigger value="linkage-groups" className="gap-2">
<GitBranch className="h-4 w-4" />
Linkage Group
</TabsTrigger>
<TabsTrigger value="marker-positions" className="gap-2">
<MapPin className="h-4 w-4" />
Marker Position
</TabsTrigger>
</TabsList>
{tab === "genome-maps" ? (
<TabsContent value="genome-maps" className="mt-0 min-h-0 flex-1">
<GenomeMapTab />
</TabsContent>
) : null}
{tab === "linkage-groups" ? (
<TabsContent value="linkage-groups" className="mt-0 min-h-0 flex-1">
<LinkageGroupTab />
</TabsContent>
) : null}
{tab === "marker-positions" ? (
<TabsContent value="marker-positions" className="mt-0 min-h-0 flex-1">
<MarkerPositionTab />
</TabsContent>
) : null}
</Tabs>
);
}
function PageFallback() {
return <Skeleton className="h-96 w-full rounded-xl" />;
}
export default function GenomeMapPage() {
return (
<Suspense fallback={<PageFallback />}>
<GenomeMapPageContent />
</Suspense>
);
}

View File

@@ -0,0 +1,84 @@
export const NONE_SELECT_VALUE = "__none__";
export type SelectOption = { value: string; label: string };
export type GenomeMapQuery = {
map_name?: string;
common_crop_name?: string;
type?: string;
};
export type LinkageGroupQuery = {
linkage_group_name?: string;
map_db_id?: string;
};
export type MarkerPositionQuery = {
map_db_id?: string;
linkage_group_name?: string;
variant_db_id?: string;
};
export type GenomeMapRecord = {
id: string;
mapDbId?: string;
map_name?: string | null;
mapName?: string | null;
map_pui?: string | null;
mapPUI?: string | null;
common_crop_name?: string | null;
commonCropName?: string | null;
scientific_name?: string | null;
scientificName?: string | null;
type?: string | null;
unit?: string | null;
comments?: string | null;
documentation_url?: string | null;
documentationURL?: string | null;
published_date?: string | null;
publishedDate?: string | null;
linkage_group_count?: number | null;
linkageGroupCount?: number | null;
marker_count?: number | null;
markerCount?: number | null;
};
export type LinkageGroupRecord = {
id: string;
linkage_group_db_id?: string;
linkageGroupDbId?: string;
linkage_group_name?: string | null;
linkageGroupName?: string | null;
max_position?: number | null;
maxPosition?: number | null;
marker_count?: number | null;
markerCount?: number | null;
map_db_id?: string | null;
mapDbId?: string | null;
map_name?: string | null;
mapName?: string | null;
};
export type MarkerPositionRecord = {
id: string;
marker_position_db_id?: string;
markerPositionDbId?: string;
linkage_group_db_id?: string;
linkageGroupDbId?: string;
linkage_group_name?: string | null;
linkageGroupName?: string | null;
variant_db_id?: string | null;
variantDbId?: string | null;
variant_name?: string | null;
variantName?: string | null;
position?: number | null;
map_db_id?: string | null;
mapDbId?: string | null;
map_name?: string | null;
mapName?: string | null;
};
export type GenomeMapDetail = GenomeMapRecord & {
linkageGroups: LinkageGroupRecord[];
markerPositions: MarkerPositionRecord[];
};

View File

@@ -3,7 +3,9 @@ import { getAuthToken } from "@/utils/token";
import {
NONE_SELECT_VALUE,
type SelectOption,
type VariantSetAnalysisItem,
type VariantSetDetail,
type VariantSetFormatItem,
type VariantSetQuery,
type VariantSetRecord,
} from "./types";
@@ -273,6 +275,184 @@ export async function deleteVariantSetRow(id: string): Promise<void> {
invalidateVariantSetPageCache();
}
const mapAnalysisRow = (item: VariantSetAnalysisItem): VariantSetAnalysisItem => ({
...item,
id: item.analysisDbId || item.id,
analysisName: item.analysisName ?? null,
type: item.type ?? null,
description: item.description ?? null,
software: Array.isArray(item.software)
? item.software
: (item.software ? [String(item.software)] : []),
created: item.created ?? null,
updated: item.updated ?? null,
});
const mapFormatRow = (item: VariantSetFormatItem): VariantSetFormatItem => ({
...item,
id: item.formatDbId || item.id,
dataFormat: item.dataFormat ?? item.data_format ?? null,
fileFormat: item.fileFormat ?? item.file_format ?? null,
fileURL: item.fileURL ?? item.fileurl ?? null,
expandHomozygotes: item.expandHomozygotes ?? item.expand_homozygotes ?? null,
sepPhased: item.sepPhased ?? item.sep_phased ?? null,
sepUnphased: item.sepUnphased ?? item.sep_unphased ?? null,
unknownString: item.unknownString ?? item.unknown_string ?? null,
});
const parseSoftwareLines = (value: unknown) => String(value ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const isValidUrl = (value: string) => {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
};
const analysisBody = (payload: Partial<Record<string, unknown>>) => {
const analysisName = requiredText(payload.analysis_name, "分析名称不能为空");
const software = parseSoftwareLines(payload.software_text);
for (const item of software) {
if (item.startsWith("http") && !isValidUrl(item)) {
throw new Error(`Software URL 格式无效:${item}`);
}
}
return {
analysisDbId: optionalText(payload.id),
analysisName,
description: optionalText(payload.description),
type: optionalText(payload.type === NONE_SELECT_VALUE ? null : payload.type),
created: optionalText(payload.created),
updated: optionalText(payload.updated),
software,
};
};
const formatBody = (payload: Partial<Record<string, unknown>>) => {
const dataFormat = requiredText(payload.data_format, "请选择 data_format");
const fileFormat = requiredText(payload.file_format, "请选择 file_format");
const fileURL = optionalText(payload.fileurl);
if (fileURL && !isValidUrl(fileURL)) {
throw new Error("fileurl 格式无效,请输入 http(s) URL");
}
return {
formatDbId: optionalText(payload.id),
dataFormat,
fileFormat,
fileURL,
expandHomozygotes: payload.expand_homozygotes === true || payload.expand_homozygotes === "true",
sepPhased: optionalText(payload.sep_phased),
sepUnphased: optionalText(payload.sep_unphased),
unknownString: optionalText(payload.unknown_string),
};
};
export const normalizeVariantSetAnalysisFormData = (row: VariantSetAnalysisItem) => ({
id: row.id || row.analysisDbId || "",
analysis_name: row.analysisName || "",
type: row.type ? row.type : NONE_SELECT_VALUE,
description: row.description || "",
software_text: (Array.isArray(row.software) ? row.software : []).join("\n"),
created: row.created ? String(row.created).slice(0, 19) : "",
updated: row.updated ? String(row.updated).slice(0, 19) : "",
});
export const normalizeVariantSetFormatFormData = (row: VariantSetFormatItem) => ({
id: row.id || row.formatDbId || "",
data_format: row.dataFormat || NONE_SELECT_VALUE,
file_format: row.fileFormat || NONE_SELECT_VALUE,
fileurl: row.fileURL || "",
expand_homozygotes: row.expandHomozygotes === true,
sep_phased: row.sepPhased || "",
sep_unphased: row.sepUnphased || "",
unknown_string: row.unknownString || "",
});
export async function fetchVariantSetAnalysisRows(variantSetDbId: string): Promise<VariantSetAnalysisItem[]> {
const response = await request<BrapiListResponse<VariantSetAnalysisItem>>(
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/analysis`,
);
return response.result.data.map(mapAnalysisRow);
}
export async function fetchVariantSetFormatRows(variantSetDbId: string): Promise<VariantSetFormatItem[]> {
const response = await request<BrapiListResponse<VariantSetFormatItem>>(
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/availableformats`,
);
return response.result.data.map(mapFormatRow);
}
export async function createVariantSetAnalysisRow(
variantSetDbId: string,
payload: Partial<Record<string, unknown>>,
): Promise<VariantSetAnalysisItem> {
const response = await request<BrapiListResponse<VariantSetAnalysisItem>>(
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/analysis`,
{ method: "POST", body: JSON.stringify(analysisBody(payload)) },
);
invalidateVariantSetPageCache();
return mapAnalysisRow(response.result.data[0]);
}
export async function updateVariantSetAnalysisRow(
variantSetDbId: string,
id: string,
payload: Partial<Record<string, unknown>>,
): Promise<VariantSetAnalysisItem> {
const response = await request<BrapiListResponse<VariantSetAnalysisItem>>(
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/analysis/${encodeURIComponent(id)}`,
{ method: "PUT", body: JSON.stringify(analysisBody(payload)) },
);
invalidateVariantSetPageCache();
return mapAnalysisRow(response.result.data[0]);
}
export async function deleteVariantSetAnalysisRow(variantSetDbId: string, id: string): Promise<void> {
await request(
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/analysis/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
invalidateVariantSetPageCache();
}
export async function createVariantSetFormatRow(
variantSetDbId: string,
payload: Partial<Record<string, unknown>>,
): Promise<VariantSetFormatItem> {
const response = await request<BrapiListResponse<VariantSetFormatItem>>(
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/availableformats`,
{ method: "POST", body: JSON.stringify(formatBody(payload)) },
);
invalidateVariantSetPageCache();
return mapFormatRow(response.result.data[0]);
}
export async function updateVariantSetFormatRow(
variantSetDbId: string,
id: string,
payload: Partial<Record<string, unknown>>,
): Promise<VariantSetFormatItem> {
const response = await request<BrapiListResponse<VariantSetFormatItem>>(
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/availableformats/${encodeURIComponent(id)}`,
{ method: "PUT", body: JSON.stringify(formatBody(payload)) },
);
invalidateVariantSetPageCache();
return mapFormatRow(response.result.data[0]);
}
export async function deleteVariantSetFormatRow(variantSetDbId: string, id: string): Promise<void> {
await request(
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/availableformats/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
invalidateVariantSetPageCache();
}
export async function loadVariantSetPageData(options?: { query?: VariantSetQuery; force?: boolean }) {
const force = options?.force ?? false;
const [referenceSets, studies, variantSets] = await Promise.all([

View File

@@ -0,0 +1,115 @@
"use client";
import { useCallback, useMemo } from "react";
import { FlaskConical } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
createVariantSetAnalysisRow,
deleteVariantSetAnalysisRow,
fetchVariantSetAnalysisRows,
normalizeVariantSetAnalysisFormData,
updateVariantSetAnalysisRow,
} from "../api";
import { ANALYSIS_TYPE_OPTIONS, NONE_SELECT_VALUE } from "../types";
interface VariantSetAnalysisPanelProps {
variantSetDbId: string;
onChanged?: () => void;
}
const optionOrNone = (label: string, options: readonly { value: string; label: string }[]) => [
{ value: NONE_SELECT_VALUE, label },
...options,
];
export function VariantSetAnalysisPanel({ variantSetDbId, onChanged }: VariantSetAnalysisPanelProps) {
const loadRows = useCallback(async () => {
const rows = await fetchVariantSetAnalysisRows(variantSetDbId);
return rows as unknown as Record<string, unknown>[];
}, [variantSetDbId]);
const findRowById = useCallback(async (id: string) => {
const rows = await fetchVariantSetAnalysisRows(variantSetDbId);
const row = rows.find((item) => item.id === id);
if (!row) throw new Error("Analysis 不存在");
return normalizeVariantSetAnalysisFormData(row);
}, [variantSetDbId]);
const wrapMutation = useCallback(<T,>(action: () => Promise<T>) => async () => {
const result = await action();
onChanged?.();
return result;
}, [onChanged]);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "Analysis ID", type: "text", placeholder: "留空则系统自动生成" },
{ key: "analysis_name", label: "分析名称", type: "text", required: true, placeholder: "如 Standard QC" },
{
key: "type",
label: "分析类型",
type: "select",
options: optionOrNone("不指定类型", ANALYSIS_TYPE_OPTIONS),
},
{ key: "description", label: "分析说明", type: "textarea", placeholder: "描述分析流程或 QC 标准" },
{ key: "created", label: "创建时间", type: "text", placeholder: "2024-01-01T12:00:00+08:00" },
{ key: "updated", label: "更新时间", type: "text", placeholder: "2024-01-01T12:00:00+08:00" },
], []);
const columns = useMemo(() => [
{ key: "analysisName", label: "分析名称" },
{ key: "type", label: "类型" },
{
key: "software",
label: "Software",
render: (value: unknown) => {
if (Array.isArray(value)) return value.length ? value.join(", ") : "—";
return String(value ?? "—");
},
},
{
key: "description",
label: "说明",
render: (value: unknown) => {
const text = String(value ?? "");
if (!text) return "—";
return text.length > 40 ? `${text.slice(0, 40)}` : text;
},
},
], []);
return (
<BrapiEntityPage
useEnhancedDialog
icon={FlaskConical}
iconBg="bg-gradient-to-br from-amber-500 to-orange-600"
title="Analysis"
description="维护 VariantSet 的分析或 QC 信息,每条记录可关联多个 software名称、版本或 URL。"
addLabel="新增 Analysis"
columns={columns}
fields={fields}
data={[]}
loadData={loadRows}
fetchRecord={findRowById}
createRecord={(payload) => wrapMutation(() => createVariantSetAnalysisRow(variantSetDbId, payload))() as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => wrapMutation(() => updateVariantSetAnalysisRow(variantSetDbId, id, payload))() as Promise<Record<string, unknown>>}
deleteRecord={(id) => wrapMutation(() => deleteVariantSetAnalysisRow(variantSetDbId, id))().then(() => undefined)}
renderFormExtra={({ formData, updateForm }) => (
<div className="col-span-2 space-y-1.5">
<Label htmlFor="software_text" className="text-xs text-slate-500">
Software URL
</Label>
<Textarea
id="software_text"
value={String(formData.software_text ?? "")}
onChange={(event) => updateForm("software_text", event.target.value)}
placeholder={"GATK 4.5\nhttps://github.com/genotyping/QC"}
rows={4}
/>
<p className="text-xs text-slate-500"> URL使 http(s) </p>
</div>
)}
/>
);
}

View File

@@ -0,0 +1,132 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo } from "react";
import { FileDown } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
createVariantSetFormatRow,
deleteVariantSetFormatRow,
fetchVariantSetFormatRows,
normalizeVariantSetFormatFormData,
updateVariantSetFormatRow,
} from "../api";
import { DATA_FORMAT_OPTIONS, FILE_FORMAT_OPTIONS, NONE_SELECT_VALUE } from "../types";
interface VariantSetFormatPanelProps {
variantSetDbId: string;
onChanged?: () => void;
}
const optionOrNone = (label: string, options: readonly { value: string; label: string }[]) => [
{ value: NONE_SELECT_VALUE, label },
...options,
];
const boolLabel = (value: unknown) => (value === true ? "是" : value === false ? "否" : "—");
export function VariantSetFormatPanel({ variantSetDbId, onChanged }: VariantSetFormatPanelProps) {
const loadRows = useCallback(async () => {
const rows = await fetchVariantSetFormatRows(variantSetDbId);
return rows as unknown as Record<string, unknown>[];
}, [variantSetDbId]);
const findRowById = useCallback(async (id: string) => {
const rows = await fetchVariantSetFormatRows(variantSetDbId);
const row = rows.find((item) => item.id === id);
if (!row) throw new Error("Available Format 不存在");
return normalizeVariantSetFormatFormData(row);
}, [variantSetDbId]);
const wrapMutation = useCallback(<T,>(action: () => Promise<T>) => async () => {
const result = await action();
onChanged?.();
return result;
}, [onChanged]);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "Format ID", type: "text", placeholder: "留空则系统自动生成" },
{
key: "data_format",
label: "数据格式 (data_format)",
type: "select",
required: true,
options: optionOrNone("请选择数据格式", DATA_FORMAT_OPTIONS),
},
{
key: "file_format",
label: "文件格式 (file_format)",
type: "select",
required: true,
options: optionOrNone("请选择 MIME 类型", FILE_FORMAT_OPTIONS),
},
{ key: "fileurl", label: "文件 URL (fileurl)", type: "text", placeholder: "https://example.com/data.vcf" },
{ key: "sep_phased", label: "Phased 分隔符", type: "text", placeholder: "如 |" },
{ key: "sep_unphased", label: "Unphased 分隔符", type: "text", placeholder: "如 /" },
{ key: "unknown_string", label: "缺失值字符串", type: "text", placeholder: "如 NA 或 ." },
], []);
const columns = useMemo(() => [
{ key: "dataFormat", label: "数据格式" },
{ key: "fileFormat", label: "MIME" },
{
key: "fileURL",
label: "文件 URL",
render: (value: unknown) => {
const url = String(value ?? "").trim();
if (!url) return "—";
return (
<Link href={url} target="_blank" rel="noopener noreferrer" className="break-all text-violet-600 hover:underline dark:text-violet-400">
{url}
</Link>
);
},
},
{
key: "expandHomozygotes",
label: "展开纯合",
render: (value: unknown) => boolLabel(value),
},
{ key: "sepPhased", label: "Phased 分隔符" },
{ key: "sepUnphased", label: "Unphased 分隔符" },
{ key: "unknownString", label: "缺失值" },
], []);
return (
<BrapiEntityPage
useEnhancedDialog
icon={FileDown}
iconBg="bg-gradient-to-br from-sky-500 to-blue-600"
title="Available Formats"
description="配置 VariantSet 可下载的数据格式与文件地址;矩阵格式可设置分隔符与缺失值字符串。"
addLabel="新增 Format"
columns={columns}
fields={fields}
data={[]}
loadData={loadRows}
fetchRecord={findRowById}
createRecord={(payload) => wrapMutation(() => createVariantSetFormatRow(variantSetDbId, payload))() as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => wrapMutation(() => updateVariantSetFormatRow(variantSetDbId, id, payload))() as Promise<Record<string, unknown>>}
deleteRecord={(id) => wrapMutation(() => deleteVariantSetFormatRow(variantSetDbId, id))().then(() => undefined)}
renderFormExtra={({ formData, updateFormBatch }) => (
<div className="col-span-2 flex items-center justify-between rounded-lg border border-sky-100 bg-sky-50/60 p-3 dark:border-sky-900/40 dark:bg-sky-950/30">
<div>
<Label htmlFor="expand_homozygotes" className="text-sm font-medium text-sky-900 dark:text-sky-100">
(expand_homozygotes)
</Label>
<p className="mt-1 text-xs text-sky-800/80 dark:text-sky-200/80">
phased / unphased
</p>
</div>
<Switch
id="expand_homozygotes"
checked={formData.expand_homozygotes === true}
onCheckedChange={(checked) => updateFormBatch({ expand_homozygotes: checked })}
/>
</div>
)}
/>
);
}

View File

@@ -11,17 +11,60 @@ export interface VariantSetQuery {
study_id?: string;
}
export const DATA_FORMAT_OPTIONS = [
{ value: "DartSeq", label: "DartSeq" },
{ value: "VCF", label: "VCF" },
{ value: "Hapmap", label: "Hapmap" },
{ value: "tabular", label: "tabular" },
{ value: "JSON", label: "JSON" },
] as const;
export const FILE_FORMAT_OPTIONS = [
{ value: "application/json", label: "application/json" },
{ value: "text/csv", label: "text/csv" },
{ value: "text/tsv", label: "text/tsv" },
{ value: "application/flapjack", label: "application/flapjack" },
{ value: "application/excel", label: "application/excel" },
{ value: "application/zip", label: "application/zip" },
] as const;
export const ANALYSIS_TYPE_OPTIONS = [
{ value: "QC", label: "QC" },
{ value: "Annotation", label: "Annotation" },
{ value: "Filtering", label: "Filtering" },
{ value: "Imputation", label: "Imputation" },
] as const;
export interface VariantSetAnalysisItem {
id?: string;
analysisDbId?: string;
analysisName?: string | null;
type?: string | null;
software?: string | null;
description?: string | null;
software?: string[] | string | null;
created?: string | null;
updated?: string | null;
}
export interface VariantSetFormatItem {
id?: string;
formatDbId?: string;
variant_set_id?: string;
variantSetDbId?: string;
dataFormat?: string | null;
data_format?: string | null;
fileFormat?: string | null;
file_format?: string | null;
fileURL?: string | null;
fileurl?: string | null;
expandHomozygotes?: boolean | null;
expand_homozygotes?: boolean | null;
sepPhased?: string | null;
sep_phased?: string | null;
sepUnphased?: string | null;
sep_unphased?: string | null;
unknownString?: string | null;
unknown_string?: string | null;
}
export interface VariantSetRecord {

View File

@@ -3,12 +3,15 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { ArrowLeft, Binary, Layers, Sigma } from "lucide-react";
import { ArrowLeft, Binary, FileDown, FlaskConical, Layers, Sigma } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { VariantSetAnalysisPanel } from "../../components/VariantSetAnalysisPanel";
import { VariantSetFormatPanel } from "../../components/VariantSetFormatPanel";
import {
fetchVariantSetCallsets,
fetchVariantSetDetail,
@@ -105,129 +108,120 @@ export default function VariantSetDetailPage() {
<div><span className="text-slate-500">Study</span>{detail.study_name || "N/A"}</div>
<div><span className="text-slate-500">Variant </span>{detail.variant_count ?? 0}</div>
<div><span className="text-slate-500">CallSet </span>{detail.callset_count ?? 0}</div>
<div><span className="text-slate-500">Analysis </span>{detail.analysis_count ?? 0}</div>
<div><span className="text-slate-500">Formats </span>{detail.format_count ?? 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Sigma className="h-4 w-4 text-rose-500" />
Variants
</CardTitle>
</CardHeader>
<CardContent>
{variants.length === 0 ? (
<p className="text-sm text-slate-500"> Variant </p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Variant ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{variants.slice(0, 20).map((row) => {
const id = String(row.variantDbId ?? row.id ?? "");
const names = Array.isArray(row.variantNames) ? row.variantNames.join(", ") : String(row.variantName ?? "—");
return (
<TableRow key={id}>
<TableCell>
{id ? (
<Link href={`/genotyping/variant/variants/${encodeURIComponent(id)}`} className="text-rose-600 hover:underline dark:text-rose-400">
{id}
</Link>
) : "—"}
</TableCell>
<TableCell>{names}</TableCell>
<TableCell>{String(row.variantType ?? "—")}</TableCell>
<TableCell>{String(row.start ?? "—")}</TableCell>
<TableCell>{String(row.end ?? "—")}</TableCell>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
<TabsTrigger value="overview" className="gap-2">
<Sigma className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="analysis" className="gap-2">
<FlaskConical className="h-4 w-4" />
Analysis
</TabsTrigger>
<TabsTrigger value="formats" className="gap-2">
<FileDown className="h-4 w-4" />
Formats
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Sigma className="h-4 w-4 text-rose-500" />
Variants
</CardTitle>
</CardHeader>
<CardContent>
{variants.length === 0 ? (
<p className="text-sm text-slate-500"> Variant </p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Variant ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
);
})}
</TableBody>
</Table>
)}
{variants.length > 20 ? (
<p className="mt-2 text-xs text-slate-500"> 20 Variant </p>
) : null}
</CardContent>
</Card>
</TableHeader>
<TableBody>
{variants.slice(0, 20).map((row) => {
const id = String(row.variantDbId ?? row.id ?? "");
const names = Array.isArray(row.variantNames) ? row.variantNames.join(", ") : String(row.variantName ?? "—");
return (
<TableRow key={id}>
<TableCell>
{id ? (
<Link href={`/genotyping/variant/variants/${encodeURIComponent(id)}`} className="text-rose-600 hover:underline dark:text-rose-400">
{id}
</Link>
) : "—"}
</TableCell>
<TableCell>{names}</TableCell>
<TableCell>{String(row.variantType ?? "—")}</TableCell>
<TableCell>{String(row.start ?? "—")}</TableCell>
<TableCell>{String(row.end ?? "—")}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
{variants.length > 20 ? (
<p className="mt-2 text-xs text-slate-500"> 20 Variant </p>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Binary className="h-4 w-4 text-sky-500" />
CallSets
</CardTitle>
</CardHeader>
<CardContent>
{callsets.length === 0 ? (
<p className="text-sm text-slate-500"> CallSet</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>CallSet ID</TableHead>
<TableHead></TableHead>
<TableHead>Sample</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{callsets.map((row) => (
<TableRow key={String(row.callSetDbId ?? row.id ?? Math.random())}>
<TableCell>{String(row.callSetDbId ?? "—")}</TableCell>
<TableCell>{String(row.callSetName ?? "—")}</TableCell>
<TableCell>{String(row.sampleName ?? row.sampleDbId ?? "—")}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Binary className="h-4 w-4 text-sky-500" />
CallSets
</CardTitle>
</CardHeader>
<CardContent>
{callsets.length === 0 ? (
<p className="text-sm text-slate-500"> CallSet</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>CallSet ID</TableHead>
<TableHead></TableHead>
<TableHead>Sample</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{callsets.map((row) => (
<TableRow key={String(row.callSetDbId ?? row.id ?? Math.random())}>
<TableCell>{String(row.callSetDbId ?? "—")}</TableCell>
<TableCell>{String(row.callSetName ?? "—")}</TableCell>
<TableCell>{String(row.sampleName ?? row.sampleDbId ?? "—")}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Analysis</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{(detail.analysis || []).length === 0 ? (
<p className="text-slate-500"> Analysis</p>
) : (
detail.analysis.map((item) => (
<div key={item.analysisDbId || item.analysisName || Math.random()} className="rounded-lg border p-3 dark:border-slate-800">
<div className="font-medium">{item.analysisName || item.analysisDbId}</div>
<div className="mt-1 text-slate-500">{item.type || "—"} · {item.software || "—"}</div>
</div>
))
)}
</CardContent>
</Card>
<TabsContent value="analysis">
<VariantSetAnalysisPanel variantSetDbId={variantSetDbId} onChanged={loadDetail} />
</TabsContent>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Available Formats</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{(detail.availableFormats || []).length === 0 ? (
<p className="text-slate-500"></p>
) : (
detail.availableFormats.map((item) => (
<div key={`${item.dataFormat}-${item.fileFormat}-${item.fileURL}`} className="rounded-lg border p-3 dark:border-slate-800">
<div className="font-medium">{item.fileFormat || item.dataFormat || "Format"}</div>
<div className="mt-1 break-all text-slate-500">{item.fileURL || "—"}</div>
</div>
))
)}
</CardContent>
</Card>
</div>
<TabsContent value="formats">
<VariantSetFormatPanel variantSetDbId={variantSetDbId} onChanged={loadDetail} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { createCachedLoader } from "@/services/dropdownCache";
import { DEFAULT_LIST_PAGE, DEFAULT_LIST_PAGE_SIZE } from "@/constants/api";
import { getAuthToken } from "@/utils/token";
import {
NONE_SELECT_VALUE,
type CallRecord,
type SelectOption,
type VariantQuery,
type VariantRecord,
@@ -46,13 +46,6 @@ interface VariantSetResponse {
referenceSetDbId: string | null;
}
interface CallSetResponse {
callSetDbId: string;
callSetName: string | null;
sampleDbId: string | null;
sampleName: string | null;
}
type VariantPayload = Partial<Record<
| "id"
| "variant_name"
@@ -68,17 +61,6 @@ type VariantPayload = Partial<Record<
unknown
>>;
type CallPayload = Partial<Record<
| "id"
| "call_set_id"
| "variant_id"
| "genotype_text"
| "genotype_likelihood"
| "read_depth"
| "phase_set",
unknown
>>;
const apiBase = () => {
if (typeof window !== "undefined") return "";
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
@@ -127,16 +109,6 @@ const optionalBoolean = (value: unknown) => {
return ["true", "1", "pass", "passed"].includes(normalized.toLowerCase());
};
const genotypeToText = (value: unknown) => {
if (!value) return null;
if (typeof value === "string") return value;
if (typeof value === "object" && "values" in value) {
const values = (value as { values?: unknown[] }).values;
return Array.isArray(values) ? values.map((item) => String(item)).join("/") : null;
}
return null;
};
export const mapVariant = (variant: VariantRecord): VariantRecord => ({
...variant,
id: variant.variantDbId || variant.variantId || variant.id,
@@ -161,19 +133,6 @@ export const mapVariant = (variant: VariantRecord): VariantRecord => ({
filters_passed: variant.filters_passed ?? variant.filtersPassed ?? null,
});
const mapCall = (call: CallRecord): CallRecord => ({
...call,
id: call.callDbId || call.id,
call_set_id: call.call_set_id || call.callSetDbId || null,
call_set_name: call.call_set_name || call.callSetName || null,
variant_id: call.variant_id || call.variantDbId || null,
variant_name: call.variant_name || call.variantName || null,
genotype_text: call.genotype_text || call.genotypeText || genotypeToText(call.genotype),
genotype_likelihood: call.genotype_likelihood ?? call.genotypeLikelihood ?? null,
read_depth: call.read_depth ?? call.readDepth ?? null,
phase_set: call.phase_set || call.phaseSet || null,
});
const variantBody = (payload: VariantPayload) => ({
variantName: requiredText(payload.variant_name, "Variant name is required"),
variantType: optionalText(payload.variant_type),
@@ -187,15 +146,6 @@ const variantBody = (payload: VariantPayload) => ({
filtersPassed: optionalBoolean(payload.filters_passed),
});
const callBody = (payload: CallPayload) => ({
callSetDbId: requiredText(payload.call_set_id, "CallSet is required"),
variantDbId: requiredText(payload.variant_id, "Variant is required"),
genotype: requiredText(payload.genotype_text, "Genotype is required"),
genotypeLikelihood: optionalNumber(payload.genotype_likelihood),
readDepth: optionalNumber(payload.read_depth),
phaseSet: optionalText(payload.phase_set),
});
export const normalizeVariantFormData = (row: VariantRecord) => ({
id: row.id,
variant_name: row.variant_name || "",
@@ -220,13 +170,11 @@ const variantSetLoader = createCachedLoader(async () => {
return response.result.data;
});
const callSetLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<CallSetResponse>>("/brapi/v2/callsets?page=0&pageSize=10");
return response.result.data;
});
const variantRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<VariantRecord>>("/brapi/v2/variants?page=0&pageSize=10");
const response = await request<BrapiListResponse<VariantRecord>>("/brapi/v2/search/variants", {
method: "POST",
body: JSON.stringify({ page: DEFAULT_LIST_PAGE, pageSize: DEFAULT_LIST_PAGE_SIZE }),
});
return response.result.data.map(mapVariant);
});
@@ -264,8 +212,6 @@ function filterVariantRows(rows: VariantRecord[], query?: VariantQuery): Variant
function buildVariantOptions(
referenceSets: ReferenceSetResponse[],
variantSets: VariantSetResponse[],
callSets: CallSetResponse[],
variants: VariantRecord[],
) {
return {
referenceSets: referenceSets.map((item) => ({
@@ -276,38 +222,25 @@ function buildVariantOptions(
value: item.variantSetDbId,
label: `${item.variantSetName || item.variantSetDbId}${item.referenceSetDbId ? ` / ${item.referenceSetDbId}` : ""}`,
})),
callSets: callSets.map((item) => ({
value: item.callSetDbId,
label: `${item.callSetName || item.callSetDbId}${item.sampleName || item.sampleDbId ? ` / ${item.sampleName || item.sampleDbId}` : ""}`,
})),
variants: variants.map((item) => ({
value: item.id,
label: `${item.variant_name || item.id}${item.variant_type ? ` / ${item.variant_type}` : ""}`,
})),
};
}
export function invalidateVariantPageCache() {
referenceSetLoader.invalidate();
variantSetLoader.invalidate();
callSetLoader.invalidate();
variantRowsLoader.invalidate();
}
export async function fetchVariantOptions(force = false): Promise<{
referenceSets: SelectOption[];
variantSets: SelectOption[];
callSets: SelectOption[];
variants: SelectOption[];
}> {
const [referenceSets, variantSets, callSets, variants] = await Promise.all([
const [referenceSets, variantSets] = await Promise.all([
referenceSetLoader.load(force),
variantSetLoader.load(force),
callSetLoader.load(force),
variantRowsLoader.load(force),
]);
return buildVariantOptions(referenceSets, variantSets, callSets, variants);
return buildVariantOptions(referenceSets, variantSets);
}
export async function fetchVariantRows(query?: VariantQuery): Promise<VariantRecord[]> {
@@ -324,7 +257,7 @@ export async function fetchVariantDetail(variantDbId: string): Promise<VariantRe
const [detail, options, callsResponse] = await Promise.all([
request<BrapiSingleResponse<VariantRecord>>(`/brapi/v2/variants/${encodeURIComponent(variantDbId)}`),
fetchVariantOptions(),
request<BrapiListResponse<CallRecord>>(`/brapi/v2/variants/${encodeURIComponent(variantDbId)}/calls?pageSize=10`),
request<BrapiListResponse<Record<string, never>>>(`/brapi/v2/variants/${encodeURIComponent(variantDbId)}/calls?pageSize=10`),
]);
const mapped = mapVariant(detail.result);
@@ -339,11 +272,6 @@ export async function fetchVariantDetail(variantDbId: string): Promise<VariantRe
};
}
export async function fetchCallRows(): Promise<CallRecord[]> {
const response = await request<BrapiListResponse<CallRecord>>("/brapi/v2/calls?page=0&pageSize=10");
return response.result.data.map(mapCall);
}
export async function createVariantRow(payload: VariantPayload): Promise<VariantRecord> {
const response = await request<BrapiListResponse<VariantRecord>>("/brapi/v2/variants", {
method: "POST",
@@ -374,45 +302,17 @@ export async function deleteVariantRow(id: string): Promise<void> {
invalidateVariantPageCache();
}
export async function createCallRow(payload: CallPayload): Promise<CallRecord> {
const response = await request<BrapiListResponse<CallRecord>>("/brapi/v2/calls", {
method: "POST",
body: JSON.stringify({
callDbId: requiredText(payload.id, "Call ID is required"),
...callBody(payload),
}),
});
return mapCall(response.result.data[0]);
}
export async function updateCallRow(id: string, payload: CallPayload): Promise<CallRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) throw new Error("Call ID is immutable. Create a new record instead.");
const response = await request<BrapiSingleResponse<CallRecord>>(`/brapi/v2/calls/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(callBody(payload)),
});
return mapCall(response.result);
}
export async function deleteCallRow(id: string): Promise<void> {
await request<BrapiSingleResponse<CallRecord>>(`/brapi/v2/calls/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
export async function loadVariantPageData(options?: { query?: VariantQuery; force?: boolean }) {
const force = options?.force ?? false;
const [referenceSets, variantSets, callSets, variants] = await Promise.all([
const [referenceSets, variantSets, variants] = await Promise.all([
referenceSetLoader.load(force),
variantSetLoader.load(force),
callSetLoader.load(force),
variantRowsLoader.load(force),
]);
const enrichedRows = enrichVariantRows(variants, referenceSets, variantSets);
return {
options: buildVariantOptions(referenceSets, variantSets, callSets, variants),
options: buildVariantOptions(referenceSets, variantSets),
rows: filterVariantRows(enrichedRows, options?.query),
};
}

View File

@@ -0,0 +1,116 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { MapPin } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { fetchMarkerPositionsByVariantId } from "../../genome-map/api";
import type { MarkerPositionRecord } from "../../genome-map/types";
interface VariantMarkerPositionCardProps {
variantDbId: string;
}
export function VariantMarkerPositionCard({ variantDbId }: VariantMarkerPositionCardProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [rows, setRows] = useState<MarkerPositionRecord[]>([]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
fetchMarkerPositionsByVariantId(variantDbId)
.then((result) => {
if (!mounted) return;
setRows(result);
})
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载 Marker Position 失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [variantDbId]);
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="h-4 w-4 text-emerald-500" />
Marker Position
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{loading ? (
<Skeleton className="h-24 w-full" />
) : error ? (
<p className="text-destructive">{error}</p>
) : rows.length === 0 ? (
<p className="text-slate-600 dark:text-slate-300">
Variant
</p>
) : (
<div className="overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-800">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
{row.map_db_id ? (
<Link
href={`/genotyping/genome-map/maps/${encodeURIComponent(row.map_db_id)}`}
className="text-teal-600 hover:underline dark:text-teal-400"
>
{row.map_name || row.map_db_id}
</Link>
) : "—"}
</TableCell>
<TableCell>
{row.linkage_group_db_id && row.map_db_id ? (
<Link
href={`/genotyping/genome-map/maps/${encodeURIComponent(row.map_db_id)}/linkage-groups/${encodeURIComponent(row.linkage_group_db_id)}`}
className="text-teal-600 hover:underline dark:text-teal-400"
>
{row.linkage_group_name || row.linkage_group_db_id}
</Link>
) : (row.linkage_group_name || "—")}
</TableCell>
<TableCell>{row.position ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button asChild variant="link" className="h-auto px-0 text-cyan-600 dark:text-cyan-400">
<Link href={`/genotyping/genome-map?tab=marker-positions&variant_db_id=${encodeURIComponent(variantDbId)}`}>
GenomeMap Marker Position
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,106 +1,18 @@
"use client";
import { Suspense, useCallback, useMemo, useState } from "react";
import { Binary, Sigma } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Suspense } from "react";
import { Sigma } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { VariantTab } from "./components/VariantTab";
import {
createCallRow,
deleteCallRow,
fetchCallRows,
fetchVariantOptions,
updateCallRow,
} from "./api";
import { NONE_SELECT_VALUE, type SelectOption } from "./types";
function VariantTabFallback() {
return <Skeleton className="h-96 w-full rounded-xl" />;
}
export default function VariantPage() {
const [tab, setTab] = useState("variants");
const [callSetOptions, setCallSetOptions] = useState<SelectOption[]>([]);
const [variantOptions, setVariantOptions] = useState<SelectOption[]>([]);
const loadOptions = useCallback(async () => {
const options = await fetchVariantOptions();
setCallSetOptions(options.callSets);
setVariantOptions(options.variants);
return options;
}, []);
const loadCalls = useCallback(async () => {
const [, rows] = await Promise.all([loadOptions(), fetchCallRows()]);
return rows as unknown as Record<string, unknown>[];
}, [loadOptions]);
const callFields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "Call ID", type: "text", required: true, placeholder: "call-001" },
{
key: "call_set_id",
label: "CallSet",
type: "select",
required: true,
options: callSetOptions,
},
{
key: "variant_id",
label: "Variant",
type: "select",
required: true,
options: variantOptions,
},
{ key: "genotype_text", label: "Genotype", type: "text", required: true, placeholder: "A/G" },
{ key: "genotype_likelihood", label: "似然值", type: "number", placeholder: "0.98" },
{ key: "read_depth", label: "测序深度", type: "number", placeholder: "42" },
{ key: "phase_set", label: "Phase Set", type: "text", placeholder: "PS001" },
], [callSetOptions, variantOptions]);
return (
<Tabs value={tab} onValueChange={setTab} className="flex min-h-full flex-col gap-4">
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
<TabsTrigger value="variants" className="gap-2"><Sigma className="h-4 w-4" />Variants</TabsTrigger>
<TabsTrigger value="calls" className="gap-2"><Binary className="h-4 w-4" />Calls</TabsTrigger>
</TabsList>
{tab === "variants" ? (
<TabsContent value="variants" className="mt-0 min-h-0 flex-1">
<Suspense fallback={<VariantTabFallback />}>
<VariantTab />
</Suspense>
</TabsContent>
) : null}
{tab === "calls" ? (
<TabsContent value="calls" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage
useEnhancedDialog
icon={Binary}
iconBg="bg-gradient-to-br from-sky-500 to-cyan-600"
title="Call 基因型判读"
description="维护样品 CallSet 在指定 Variant 上的 genotype、深度和相位信息。"
addLabel="新增 Call"
columns={[
{ key: "callDbId", label: "Call ID" },
{ key: "call_set_name", label: "CallSet" },
{ key: "variant_name", label: "Variant" },
{ key: "genotype_text", label: "Genotype" },
{ key: "genotype_likelihood", label: "似然值" },
{ key: "read_depth", label: "深度" },
{ key: "phase_set", label: "Phase Set" },
]}
fields={callFields}
data={[]}
stats={[{ label: "/brapi/v2/calls", value: "BrAPI", className: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200" }]}
loadData={loadCalls}
createRecord={(payload) => createCallRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateCallRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteCallRow}
/>
</TabsContent>
) : null}
</Tabs>
<Suspense fallback={<VariantTabFallback />}>
<VariantTab />
</Suspense>
);
}

View File

@@ -3,12 +3,13 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { ArrowLeft, MapPin, Sigma } from "lucide-react";
import { ArrowLeft, Sigma } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { fetchVariantDetail } from "../../api";
import { VariantMarkerPositionCard } from "../../components/VariantMarkerPositionCard";
const boolLabel = (value: boolean | null | undefined) => {
if (value === true) return "是";
@@ -97,30 +98,19 @@ export default function VariantDetailPage() {
<div><span className="text-slate-500"></span>{boolLabel(detail.filters_applied)}</div>
<div><span className="text-slate-500"></span>{boolLabel(detail.filters_passed)}</div>
<div><span className="text-slate-500">allele_call </span>{detail.allele_call_count ?? 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="h-4 w-4 text-emerald-500" />
Marker Position
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-slate-600 dark:text-slate-300">
<p>
Marker Position linkage group Marker / GenomeMap
Variant allele_call 便
</p>
{detail.variant_set_id ? (
<Button asChild variant="link" className="mt-2 h-auto px-0 text-violet-600 dark:text-violet-400">
<Link href={`/genotyping/variant-set/variant-sets/${encodeURIComponent(detail.variant_set_id)}`}>
VariantSet
</Link>
</Button>
{(detail.allele_call_count ?? 0) > 0 ? (
<div className="sm:col-span-2">
<Button asChild variant="link" className="h-auto px-0 text-cyan-600 dark:text-cyan-400">
<Link href={`/genotyping/call-set?tab=allele-calls&variant_id=${encodeURIComponent(detail.id)}`}>
allele_call
</Link>
</Button>
</div>
) : null}
</CardContent>
</Card>
<VariantMarkerPositionCard variantDbId={detail.id} />
</div>
);
}

View File

@@ -158,6 +158,8 @@ export interface BrapiFormField {
label: string;
type: "text" | "select" | "date" | "number" | "textarea" | "year";
required?: boolean;
/** 只读展示,用户不可编辑(如后端自动生成的字段) */
readOnly?: boolean;
placeholder?: string;
options?: Array<{ value: string; label: string }>;
colSpan?: 1 | 2;
@@ -256,8 +258,14 @@ export function BrapiEntityPage({
?? row.referenceDbId
?? row.referenceBasesDbId
?? row.variantSetDbId
?? row.callSetDbId
?? row.variantDbId
?? row.callDbId
?? row.mapDbId
?? row.linkageGroupDbId
?? row.markerPositionDbId
?? row.analysisDbId
?? row.formatDbId
?? "",
), []);
@@ -464,7 +472,11 @@ export function BrapiEntityPage({
required={field.required}
/>
) : field.type === "select" ? (
<Select value={String(formData[field.key] ?? "")} onValueChange={(value) => updateForm(field.key, value)}>
<Select
value={String(formData[field.key] ?? "")}
onValueChange={(value) => updateForm(field.key, value)}
disabled={field.readOnly}
>
<SelectTrigger id={field.key}><SelectValue placeholder={field.placeholder ?? `请选择${field.label}`} /></SelectTrigger>
<SelectContent
position="popper"
@@ -474,9 +486,27 @@ export function BrapiEntityPage({
</SelectContent>
</Select>
) : field.type === "textarea" ? (
<Textarea id={field.key} value={String(formData[field.key] ?? "")} onChange={(event) => updateForm(field.key, event.target.value)} placeholder={field.placeholder} rows={3} className="resize-none" />
<Textarea
id={field.key}
value={String(formData[field.key] ?? "")}
onChange={(event) => updateForm(field.key, event.target.value)}
placeholder={field.placeholder}
rows={3}
className="resize-none"
readOnly={field.readOnly}
disabled={field.readOnly}
/>
) : (
<Input id={field.key} type={field.type === "number" ? "number" : "text"} value={String(formData[field.key] ?? "")} onChange={(event) => updateForm(field.key, event.target.value)} placeholder={field.placeholder} />
<Input
id={field.key}
type={field.type === "number" ? "number" : "text"}
value={String(formData[field.key] ?? "")}
onChange={(event) => updateForm(field.key, event.target.value)}
placeholder={field.placeholder}
readOnly={field.readOnly}
disabled={field.readOnly}
className={field.readOnly ? "bg-slate-50 text-slate-500 dark:bg-slate-900 dark:text-slate-400" : undefined}
/>
)}
</div>
))}

View File

@@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import {
BarChart3,
Binary,
BookCheck,
BookOpen,
Briefcase,
@@ -31,6 +32,7 @@ import {
List as ListIcon,
PanelTop,
CalendarClock,
Map,
} from "lucide-react";
export interface BrapiNavItem {
@@ -143,7 +145,14 @@ export const brapiNavSections: BrapiNavSection[] = [
title: "变异数据",
items: [
{ title: "VariantSet", href: "/genotyping/variant-set", icon: Layers },
{ title: "Variant / Call", href: "/genotyping/variant", icon: Sigma },
{ title: "Variant", href: "/genotyping/variant", icon: Sigma },
{ title: "CallSet / allele_call", href: "/genotyping/call-set", icon: Binary },
],
},
{
title: "遗传图谱",
items: [
{ title: "GenomeMap / LinkageGroup", href: "/genotyping/genome-map", icon: Map },
],
},
],

View File

@@ -6,6 +6,12 @@ export const DEFAULT_LIST_PAGE = 0;
/** 标准分页查询串page=0&pageSize=10 */
export const DEFAULT_PAGE_QUERY = `page=${DEFAULT_LIST_PAGE}&pageSize=${DEFAULT_LIST_PAGE_SIZE}`;
/** BrAPI token 分页接口(如 GET /variants */
export const DEFAULT_TOKEN_PAGE_QUERY = "pageToken=0&pageSize=1000";
/** BrAPI search 分页请求体默认值 */
export const DEFAULT_SEARCH_PAGE_BODY = { page: DEFAULT_LIST_PAGE, pageSize: 1000 } as const;
export function buildPageQuery(page = DEFAULT_LIST_PAGE, pageSize = DEFAULT_LIST_PAGE_SIZE): string {
return `page=${page}&pageSize=${pageSize}`;
}

View File

@@ -86,7 +86,11 @@ export const mockBackendMenus: BackendMenuResponse[] = [
{ title: "参考基因组", path: "/genotyping/reference", menu_type: "folder", icon: "book-open", order_index: 2, children: [{ title: "ReferenceSet / Reference / Bases", path: "/genotyping/reference-set", menu_type: "menu", icon: "book-open", order_index: 1, children: [] }] },
{ title: "变异数据", path: "/genotyping/variant-group", menu_type: "folder", icon: "sigma", order_index: 3, children: [
{ title: "VariantSet", path: "/genotyping/variant-set", menu_type: "menu", icon: "layers", order_index: 1, children: [] },
{ title: "Variant / Call", path: "/genotyping/variant", menu_type: "menu", icon: "sigma", order_index: 2, children: [] },
{ title: "Variant", path: "/genotyping/variant", menu_type: "menu", icon: "sigma", order_index: 2, children: [] },
{ title: "CallSet / allele_call", path: "/genotyping/call-set", menu_type: "menu", icon: "binary", order_index: 3, children: [] },
] },
{ title: "遗传图谱", path: "/genotyping/genome-map-group", menu_type: "folder", icon: "map", order_index: 4, children: [
{ title: "GenomeMap / LinkageGroup", path: "/genotyping/genome-map", menu_type: "menu", icon: "map", order_index: 1, children: [] },
] }
]
},

View File

@@ -0,0 +1,80 @@
package org.brapi.test.BrAPITestServer.controller.geno;
import java.util.List;
import org.brapi.test.BrAPITestServer.controller.core.BrAPIController;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException;
import org.brapi.test.BrAPITestServer.model.dto.geno.CallSetWriteRequest;
import org.brapi.test.BrAPITestServer.service.geno.CallSetService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.model.geno.CallSet;
import io.swagger.model.geno.CallSetResponse;
import io.swagger.model.geno.CallSetsListResponse;
import io.swagger.model.geno.CallSetsListResponseResult;
import jakarta.servlet.http.HttpServletRequest;
@RestController
public class GenotypingCallSetWriteController extends BrAPIController {
private static final Logger log = LoggerFactory.getLogger(GenotypingCallSetWriteController.class);
private final CallSetService callSetService;
private final HttpServletRequest request;
@Autowired
public GenotypingCallSetWriteController(CallSetService callSetService, HttpServletRequest request) {
this.callSetService = callSetService;
this.request = request;
}
@CrossOrigin
@RequestMapping(value = "/callsets", produces = { "application/json" }, consumes = {
"application/json" }, method = RequestMethod.POST)
public ResponseEntity<CallSetsListResponse> callSetsPost(@RequestBody CallSetWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
CallSet data = callSetService.saveCallSet(body);
return responseOK(new CallSetsListResponse(), new CallSetsListResponseResult(), List.of(data));
}
@CrossOrigin
@RequestMapping(value = "/callsets/{callSetDbId}", produces = { "application/json" }, consumes = {
"application/json" }, method = RequestMethod.PUT)
public ResponseEntity<CallSetResponse> callSetsCallSetDbIdPut(@PathVariable("callSetDbId") String callSetDbId,
@RequestBody CallSetWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
CallSet data = callSetService.updateCallSet(callSetDbId, body);
return responseOK(new CallSetResponse(), data);
}
@CrossOrigin
@RequestMapping(value = "/callsets/{callSetDbId}", produces = {
"application/json" }, method = RequestMethod.DELETE)
public ResponseEntity<CallSetResponse> callSetsCallSetDbIdDelete(@PathVariable("callSetDbId") String callSetDbId,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
CallSet data = callSetService.deleteCallSet(callSetDbId);
return responseOK(new CallSetResponse(), data);
}
}

View File

@@ -0,0 +1,105 @@
package org.brapi.test.BrAPITestServer.controller.geno;
import java.util.List;
import org.brapi.test.BrAPITestServer.controller.core.BrAPIController;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException;
import org.brapi.test.BrAPITestServer.model.dto.geno.CallWriteRequest;
import org.brapi.test.BrAPITestServer.service.geno.CallService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.model.Metadata;
import io.swagger.model.geno.Call;
import io.swagger.model.geno.CallsListResponse;
import io.swagger.model.geno.CallsListResponseResult;
import jakarta.servlet.http.HttpServletRequest;
@RestController
public class GenotypingCallWriteController extends BrAPIController {
private static final Logger log = LoggerFactory.getLogger(GenotypingCallWriteController.class);
private final CallService callService;
private final HttpServletRequest request;
@Autowired
public GenotypingCallWriteController(CallService callService, HttpServletRequest request) {
this.callService = callService;
this.request = request;
}
@CrossOrigin
@RequestMapping(value = "/calls", produces = { "application/json" }, consumes = {
"application/json" }, method = RequestMethod.POST)
public ResponseEntity<CallsListResponse> callsPost(@RequestBody CallWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
Call data = callService.saveCall(body);
Metadata metadata = generateEmptyMetadata();
CallsListResponseResult result = new CallsListResponseResult();
result.setData(List.of(data));
return responseOK(new CallsListResponse(), result, metadata);
}
@CrossOrigin
@RequestMapping(value = "/calls/import", produces = { "application/json" }, consumes = {
"application/json" }, method = RequestMethod.POST)
public ResponseEntity<CallsListResponse> callsImportPost(@RequestBody List<CallWriteRequest> body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
List<Call> data = callService.importCalls(body);
Metadata metadata = generateEmptyMetadata();
CallsListResponseResult result = new CallsListResponseResult();
result.setData(data);
return responseOK(new CallsListResponse(), result, metadata);
}
@CrossOrigin
@RequestMapping(value = "/calls/{callDbId}", produces = { "application/json" }, consumes = {
"application/json" }, method = RequestMethod.PUT)
public ResponseEntity<CallsListResponse> callsCallDbIdPut(@PathVariable("callDbId") String callDbId,
@RequestBody CallWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
Call data = callService.updateCall(callDbId, body);
Metadata metadata = generateEmptyMetadata();
CallsListResponseResult result = new CallsListResponseResult();
result.setData(List.of(data));
return responseOK(new CallsListResponse(), result, metadata);
}
@CrossOrigin
@RequestMapping(value = "/calls/{callDbId}", produces = {
"application/json" }, method = RequestMethod.DELETE)
public ResponseEntity<CallsListResponse> callsCallDbIdDelete(@PathVariable("callDbId") String callDbId,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
Call data = callService.deleteCall(callDbId);
Metadata metadata = generateEmptyMetadata();
CallsListResponseResult result = new CallsListResponseResult();
result.setData(List.of(data));
return responseOK(new CallsListResponse(), result, metadata);
}
}

View File

@@ -0,0 +1,176 @@
package org.brapi.test.BrAPITestServer.controller.geno;
import java.util.List;
import org.brapi.test.BrAPITestServer.controller.core.BrAPIController;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException;
import org.brapi.test.BrAPITestServer.model.dto.geno.GenomeMapWriteRequest;
import org.brapi.test.BrAPITestServer.model.dto.geno.LinkageGroupWriteRequest;
import org.brapi.test.BrAPITestServer.model.dto.geno.MarkerPositionWriteRequest;
import org.brapi.test.BrAPITestServer.service.geno.GenomeMapService;
import org.brapi.test.BrAPITestServer.service.geno.MarkerPositionService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.model.geno.GenomeMap;
import io.swagger.model.geno.GenomeMapListResponse;
import io.swagger.model.geno.GenomeMapListResponseResult;
import io.swagger.model.geno.GenomeMapSingleResponse;
import io.swagger.model.geno.LinkageGroup;
import io.swagger.model.geno.LinkageGroupListResponse;
import io.swagger.model.geno.LinkageGroupListResponseResult;
import io.swagger.model.geno.MarkerPosition;
import io.swagger.model.geno.MarkerPositionsListResponse;
import io.swagger.model.geno.MarkerPositionsListResponseResult;
import jakarta.servlet.http.HttpServletRequest;
@RestController
public class GenotypingGenomeMapWriteController extends BrAPIController {
private static final Logger log = LoggerFactory.getLogger(GenotypingGenomeMapWriteController.class);
private final GenomeMapService genomeMapService;
private final MarkerPositionService markerPositionService;
private final HttpServletRequest request;
@Autowired
public GenotypingGenomeMapWriteController(GenomeMapService genomeMapService,
MarkerPositionService markerPositionService, HttpServletRequest request) {
this.genomeMapService = genomeMapService;
this.markerPositionService = markerPositionService;
this.request = request;
}
@CrossOrigin
@RequestMapping(value = "/maps", produces = { "application/json" }, consumes = {
"application/json" }, method = RequestMethod.POST)
public ResponseEntity<GenomeMapListResponse> mapsPost(@RequestBody GenomeMapWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
GenomeMap data = genomeMapService.saveMap(body);
return responseOK(new GenomeMapListResponse(), new GenomeMapListResponseResult(), List.of(data));
}
@CrossOrigin
@RequestMapping(value = "/maps/{mapDbId}", produces = { "application/json" }, consumes = {
"application/json" }, method = RequestMethod.PUT)
public ResponseEntity<GenomeMapSingleResponse> mapsMapDbIdPut(@PathVariable("mapDbId") String mapDbId,
@RequestBody GenomeMapWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
GenomeMap data = genomeMapService.updateMap(mapDbId, body);
return responseOK(new GenomeMapSingleResponse(), data);
}
@CrossOrigin
@RequestMapping(value = "/maps/{mapDbId}", produces = { "application/json" }, method = RequestMethod.DELETE)
public ResponseEntity<GenomeMapSingleResponse> mapsMapDbIdDelete(@PathVariable("mapDbId") String mapDbId,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
GenomeMap data = genomeMapService.deleteMap(mapDbId);
return responseOK(new GenomeMapSingleResponse(), data);
}
@CrossOrigin
@RequestMapping(value = "/maps/{mapDbId}/linkagegroups", produces = { "application/json" }, consumes = {
"application/json" }, method = RequestMethod.POST)
public ResponseEntity<LinkageGroupListResponse> mapsMapDbIdLinkagegroupsPost(
@PathVariable("mapDbId") String mapDbId, @RequestBody LinkageGroupWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
LinkageGroup data = genomeMapService.saveLinkageGroup(mapDbId, body);
return responseOK(new LinkageGroupListResponse(), new LinkageGroupListResponseResult(), List.of(data));
}
@CrossOrigin
@RequestMapping(value = "/maps/{mapDbId}/linkagegroups/{linkageGroupDbId}", produces = {
"application/json" }, consumes = { "application/json" }, method = RequestMethod.PUT)
public ResponseEntity<LinkageGroupListResponse> mapsMapDbIdLinkagegroupsLinkageGroupDbIdPut(
@PathVariable("mapDbId") String mapDbId, @PathVariable("linkageGroupDbId") String linkageGroupDbId,
@RequestBody LinkageGroupWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
LinkageGroup data = genomeMapService.updateLinkageGroup(mapDbId, linkageGroupDbId, body);
return responseOK(new LinkageGroupListResponse(), new LinkageGroupListResponseResult(), List.of(data));
}
@CrossOrigin
@RequestMapping(value = "/maps/{mapDbId}/linkagegroups/{linkageGroupDbId}", produces = {
"application/json" }, method = RequestMethod.DELETE)
public ResponseEntity<LinkageGroupListResponse> mapsMapDbIdLinkagegroupsLinkageGroupDbIdDelete(
@PathVariable("mapDbId") String mapDbId, @PathVariable("linkageGroupDbId") String linkageGroupDbId,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
LinkageGroup data = genomeMapService.deleteLinkageGroup(mapDbId, linkageGroupDbId);
return responseOK(new LinkageGroupListResponse(), new LinkageGroupListResponseResult(), List.of(data));
}
@CrossOrigin
@RequestMapping(value = "/markerpositions", produces = { "application/json" }, consumes = {
"application/json" }, method = RequestMethod.POST)
public ResponseEntity<MarkerPositionsListResponse> markerpositionsPost(@RequestBody MarkerPositionWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
MarkerPosition data = markerPositionService.saveMarkerPosition(body);
return responseOK(new MarkerPositionsListResponse(), new MarkerPositionsListResponseResult(), List.of(data));
}
@CrossOrigin
@RequestMapping(value = "/markerpositions/{markerPositionDbId}", produces = { "application/json" }, consumes = {
"application/json" }, method = RequestMethod.PUT)
public ResponseEntity<MarkerPositionsListResponse> markerpositionsMarkerPositionDbIdPut(
@PathVariable("markerPositionDbId") String markerPositionDbId,
@RequestBody MarkerPositionWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
MarkerPosition data = markerPositionService.updateMarkerPosition(markerPositionDbId, body);
return responseOK(new MarkerPositionsListResponse(), new MarkerPositionsListResponseResult(), List.of(data));
}
@CrossOrigin
@RequestMapping(value = "/markerpositions/{markerPositionDbId}", produces = {
"application/json" }, method = RequestMethod.DELETE)
public ResponseEntity<MarkerPositionsListResponse> markerpositionsMarkerPositionDbIdDelete(
@PathVariable("markerPositionDbId") String markerPositionDbId,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
MarkerPosition data = markerPositionService.deleteMarkerPosition(markerPositionDbId);
return responseOK(new MarkerPositionsListResponse(), new MarkerPositionsListResponseResult(), List.of(data));
}
}

View File

@@ -4,9 +4,16 @@ import java.util.List;
import org.brapi.test.BrAPITestServer.controller.core.BrAPIController;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException;
import org.brapi.test.BrAPITestServer.model.dto.geno.VariantSetAnalysisListResponse;
import org.brapi.test.BrAPITestServer.model.dto.geno.VariantSetAnalysisWriteRequest;
import org.brapi.test.BrAPITestServer.model.dto.geno.VariantSetAvailableFormatListResponse;
import org.brapi.test.BrAPITestServer.model.dto.geno.VariantSetAvailableFormatRecord;
import org.brapi.test.BrAPITestServer.model.dto.geno.VariantSetAvailableFormatWriteRequest;
import org.brapi.test.BrAPITestServer.model.dto.geno.VariantSetWriteRequest;
import org.brapi.test.BrAPITestServer.model.dto.geno.VariantWriteRequest;
import org.brapi.test.BrAPITestServer.service.geno.VariantService;
import org.brapi.test.BrAPITestServer.service.geno.VariantSetAnalysisService;
import org.brapi.test.BrAPITestServer.service.geno.VariantSetAvailableFormatService;
import org.brapi.test.BrAPITestServer.service.geno.VariantSetService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -20,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.model.geno.Analysis;
import io.swagger.model.geno.Variant;
import io.swagger.model.geno.VariantSet;
import io.swagger.model.geno.VariantSetResponse;
@@ -36,13 +44,19 @@ public class GenotypingVariantWriteController extends BrAPIController {
private static final Logger log = LoggerFactory.getLogger(GenotypingVariantWriteController.class);
private final VariantSetService variantSetService;
private final VariantSetAnalysisService variantSetAnalysisService;
private final VariantSetAvailableFormatService variantSetAvailableFormatService;
private final VariantService variantService;
private final HttpServletRequest request;
@Autowired
public GenotypingVariantWriteController(VariantSetService variantSetService, VariantService variantService,
public GenotypingVariantWriteController(VariantSetService variantSetService,
VariantSetAnalysisService variantSetAnalysisService,
VariantSetAvailableFormatService variantSetAvailableFormatService, VariantService variantService,
HttpServletRequest request) {
this.variantSetService = variantSetService;
this.variantSetAnalysisService = variantSetAnalysisService;
this.variantSetAvailableFormatService = variantSetAvailableFormatService;
this.variantService = variantService;
this.request = request;
}
@@ -88,6 +102,131 @@ public class GenotypingVariantWriteController extends BrAPIController {
return responseOK(new VariantSetResponse(), data);
}
@CrossOrigin
@RequestMapping(value = "/variantsets/{variantSetDbId}/analysis", produces = {
"application/json" }, method = RequestMethod.GET)
public ResponseEntity<VariantSetAnalysisListResponse> variantSetsVariantSetDbIdAnalysisGet(
@PathVariable("variantSetDbId") String variantSetDbId,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
List<Analysis> data = variantSetAnalysisService.findAnalysisByVariantSet(variantSetDbId);
return responseOK(new VariantSetAnalysisListResponse(), new VariantSetAnalysisListResponse.Result(), data);
}
@CrossOrigin
@RequestMapping(value = "/variantsets/{variantSetDbId}/analysis", produces = { "application/json" }, consumes = {
"application/json" }, method = RequestMethod.POST)
public ResponseEntity<VariantSetAnalysisListResponse> variantSetsVariantSetDbIdAnalysisPost(
@PathVariable("variantSetDbId") String variantSetDbId, @RequestBody VariantSetAnalysisWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
Analysis data = variantSetAnalysisService.saveAnalysis(variantSetDbId, body);
return responseOK(new VariantSetAnalysisListResponse(), new VariantSetAnalysisListResponse.Result(),
List.of(data));
}
@CrossOrigin
@RequestMapping(value = "/variantsets/{variantSetDbId}/analysis/{analysisDbId}", produces = {
"application/json" }, consumes = { "application/json" }, method = RequestMethod.PUT)
public ResponseEntity<VariantSetAnalysisListResponse> variantSetsVariantSetDbIdAnalysisAnalysisDbIdPut(
@PathVariable("variantSetDbId") String variantSetDbId,
@PathVariable("analysisDbId") String analysisDbId, @RequestBody VariantSetAnalysisWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
Analysis data = variantSetAnalysisService.updateAnalysis(variantSetDbId, analysisDbId, body);
return responseOK(new VariantSetAnalysisListResponse(), new VariantSetAnalysisListResponse.Result(),
List.of(data));
}
@CrossOrigin
@RequestMapping(value = "/variantsets/{variantSetDbId}/analysis/{analysisDbId}", produces = {
"application/json" }, method = RequestMethod.DELETE)
public ResponseEntity<VariantSetAnalysisListResponse> variantSetsVariantSetDbIdAnalysisAnalysisDbIdDelete(
@PathVariable("variantSetDbId") String variantSetDbId,
@PathVariable("analysisDbId") String analysisDbId,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
Analysis data = variantSetAnalysisService.deleteAnalysis(variantSetDbId, analysisDbId);
return responseOK(new VariantSetAnalysisListResponse(), new VariantSetAnalysisListResponse.Result(),
List.of(data));
}
@CrossOrigin
@RequestMapping(value = "/variantsets/{variantSetDbId}/availableformats", produces = {
"application/json" }, method = RequestMethod.GET)
public ResponseEntity<VariantSetAvailableFormatListResponse> variantSetsVariantSetDbIdAvailableformatsGet(
@PathVariable("variantSetDbId") String variantSetDbId,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
List<VariantSetAvailableFormatRecord> data = variantSetAvailableFormatService
.findFormatsByVariantSet(variantSetDbId);
return responseOK(new VariantSetAvailableFormatListResponse(),
new VariantSetAvailableFormatListResponse.Result(), data);
}
@CrossOrigin
@RequestMapping(value = "/variantsets/{variantSetDbId}/availableformats", produces = { "application/json" },
consumes = { "application/json" }, method = RequestMethod.POST)
public ResponseEntity<VariantSetAvailableFormatListResponse> variantSetsVariantSetDbIdAvailableformatsPost(
@PathVariable("variantSetDbId") String variantSetDbId,
@RequestBody VariantSetAvailableFormatWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
VariantSetAvailableFormatRecord data = variantSetAvailableFormatService.saveFormat(variantSetDbId, body);
return responseOK(new VariantSetAvailableFormatListResponse(),
new VariantSetAvailableFormatListResponse.Result(), List.of(data));
}
@CrossOrigin
@RequestMapping(value = "/variantsets/{variantSetDbId}/availableformats/{formatDbId}", produces = {
"application/json" }, consumes = { "application/json" }, method = RequestMethod.PUT)
public ResponseEntity<VariantSetAvailableFormatListResponse> variantSetsVariantSetDbIdAvailableformatsFormatDbIdPut(
@PathVariable("variantSetDbId") String variantSetDbId, @PathVariable("formatDbId") String formatDbId,
@RequestBody VariantSetAvailableFormatWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
VariantSetAvailableFormatRecord data = variantSetAvailableFormatService.updateFormat(variantSetDbId, formatDbId,
body);
return responseOK(new VariantSetAvailableFormatListResponse(),
new VariantSetAvailableFormatListResponse.Result(), List.of(data));
}
@CrossOrigin
@RequestMapping(value = "/variantsets/{variantSetDbId}/availableformats/{formatDbId}", produces = {
"application/json" }, method = RequestMethod.DELETE)
public ResponseEntity<VariantSetAvailableFormatListResponse> variantSetsVariantSetDbIdAvailableformatsFormatDbIdDelete(
@PathVariable("variantSetDbId") String variantSetDbId, @PathVariable("formatDbId") String formatDbId,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
VariantSetAvailableFormatRecord data = variantSetAvailableFormatService.deleteFormat(variantSetDbId, formatDbId);
return responseOK(new VariantSetAvailableFormatListResponse(),
new VariantSetAvailableFormatListResponse.Result(), List.of(data));
}
@CrossOrigin
@RequestMapping(value = "/variants", produces = { "application/json" }, consumes = {
"application/json" }, method = RequestMethod.POST)

View File

@@ -0,0 +1,42 @@
package org.brapi.test.BrAPITestServer.model.dto.geno;
import java.util.List;
public class CallSetWriteRequest {
private String callSetDbId;
private String callSetName;
private String sampleDbId;
private List<String> variantSetDbIds;
public String getCallSetDbId() {
return callSetDbId;
}
public void setCallSetDbId(String callSetDbId) {
this.callSetDbId = callSetDbId;
}
public String getCallSetName() {
return callSetName;
}
public void setCallSetName(String callSetName) {
this.callSetName = callSetName;
}
public String getSampleDbId() {
return sampleDbId;
}
public void setSampleDbId(String sampleDbId) {
this.sampleDbId = sampleDbId;
}
public List<String> getVariantSetDbIds() {
return variantSetDbIds;
}
public void setVariantSetDbIds(List<String> variantSetDbIds) {
this.variantSetDbIds = variantSetDbIds;
}
}

View File

@@ -0,0 +1,67 @@
package org.brapi.test.BrAPITestServer.model.dto.geno;
public class CallWriteRequest {
private String callDbId;
private String callSetDbId;
private String variantDbId;
private String genotype;
private Integer readDepth;
private Double genotypeLikelihood;
private String phaseSet;
public String getCallDbId() {
return callDbId;
}
public void setCallDbId(String callDbId) {
this.callDbId = callDbId;
}
public String getCallSetDbId() {
return callSetDbId;
}
public void setCallSetDbId(String callSetDbId) {
this.callSetDbId = callSetDbId;
}
public String getVariantDbId() {
return variantDbId;
}
public void setVariantDbId(String variantDbId) {
this.variantDbId = variantDbId;
}
public String getGenotype() {
return genotype;
}
public void setGenotype(String genotype) {
this.genotype = genotype;
}
public Integer getReadDepth() {
return readDepth;
}
public void setReadDepth(Integer readDepth) {
this.readDepth = readDepth;
}
public Double getGenotypeLikelihood() {
return genotypeLikelihood;
}
public void setGenotypeLikelihood(Double genotypeLikelihood) {
this.genotypeLikelihood = genotypeLikelihood;
}
public String getPhaseSet() {
return phaseSet;
}
public void setPhaseSet(String phaseSet) {
this.phaseSet = phaseSet;
}
}

View File

@@ -0,0 +1,94 @@
package org.brapi.test.BrAPITestServer.model.dto.geno;
public class GenomeMapWriteRequest {
private String mapDbId;
private String mapName;
private String mapPUI;
private String commonCropName;
private String scientificName;
private String type;
private String unit;
private String comments;
private String documentationURL;
private String publishedDate;
public String getMapDbId() {
return mapDbId;
}
public void setMapDbId(String mapDbId) {
this.mapDbId = mapDbId;
}
public String getMapName() {
return mapName;
}
public void setMapName(String mapName) {
this.mapName = mapName;
}
public String getMapPUI() {
return mapPUI;
}
public void setMapPUI(String mapPUI) {
this.mapPUI = mapPUI;
}
public String getCommonCropName() {
return commonCropName;
}
public void setCommonCropName(String commonCropName) {
this.commonCropName = commonCropName;
}
public String getScientificName() {
return scientificName;
}
public void setScientificName(String scientificName) {
this.scientificName = scientificName;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getUnit() {
return unit;
}
public void setUnit(String unit) {
this.unit = unit;
}
public String getComments() {
return comments;
}
public void setComments(String comments) {
this.comments = comments;
}
public String getDocumentationURL() {
return documentationURL;
}
public void setDocumentationURL(String documentationURL) {
this.documentationURL = documentationURL;
}
public String getPublishedDate() {
return publishedDate;
}
public void setPublishedDate(String publishedDate) {
this.publishedDate = publishedDate;
}
}

View File

@@ -0,0 +1,31 @@
package org.brapi.test.BrAPITestServer.model.dto.geno;
public class LinkageGroupWriteRequest {
private String linkageGroupDbId;
private String linkageGroupName;
private Integer maxPosition;
public String getLinkageGroupDbId() {
return linkageGroupDbId;
}
public void setLinkageGroupDbId(String linkageGroupDbId) {
this.linkageGroupDbId = linkageGroupDbId;
}
public String getLinkageGroupName() {
return linkageGroupName;
}
public void setLinkageGroupName(String linkageGroupName) {
this.linkageGroupName = linkageGroupName;
}
public Integer getMaxPosition() {
return maxPosition;
}
public void setMaxPosition(Integer maxPosition) {
this.maxPosition = maxPosition;
}
}

View File

@@ -0,0 +1,40 @@
package org.brapi.test.BrAPITestServer.model.dto.geno;
public class MarkerPositionWriteRequest {
private String markerPositionDbId;
private String linkageGroupDbId;
private String variantDbId;
private Integer position;
public String getMarkerPositionDbId() {
return markerPositionDbId;
}
public void setMarkerPositionDbId(String markerPositionDbId) {
this.markerPositionDbId = markerPositionDbId;
}
public String getLinkageGroupDbId() {
return linkageGroupDbId;
}
public void setLinkageGroupDbId(String linkageGroupDbId) {
this.linkageGroupDbId = linkageGroupDbId;
}
public String getVariantDbId() {
return variantDbId;
}
public void setVariantDbId(String variantDbId) {
this.variantDbId = variantDbId;
}
public Integer getPosition() {
return position;
}
public void setPosition(Integer position) {
this.position = position;
}
}

View File

@@ -0,0 +1,54 @@
package org.brapi.test.BrAPITestServer.model.dto.geno;
import java.util.ArrayList;
import java.util.List;
import io.swagger.model.BrAPIResponse;
import io.swagger.model.BrAPIResponseResult;
import io.swagger.model.Context;
import io.swagger.model.Metadata;
import io.swagger.model.geno.Analysis;
public class VariantSetAnalysisListResponse implements BrAPIResponse<VariantSetAnalysisListResponse.Result> {
private Context _atContext;
private Metadata metadata;
private Result result = new Result();
public static class Result implements BrAPIResponseResult<Analysis> {
private List<Analysis> data = new ArrayList<>();
@Override
public List<Analysis> getData() {
return data;
}
@Override
public void setData(List<Analysis> data) {
this.data = data;
}
}
public void set_atContext(Context _atContext) {
this._atContext = _atContext;
}
@Override
public Metadata getMetadata() {
return metadata;
}
@Override
public void setMetadata(Metadata metadata) {
this.metadata = metadata;
}
@Override
public Result getResult() {
return result;
}
@Override
public void setResult(Result result) {
this.result = result;
}
}

View File

@@ -0,0 +1,78 @@
package org.brapi.test.BrAPITestServer.model.dto.geno;
import java.util.List;
public class VariantSetAnalysisWriteRequest {
private String analysisDbId;
private String variantSetDbId;
private String analysisName;
private String description;
private String type;
private String created;
private String updated;
private List<String> software;
public String getAnalysisDbId() {
return analysisDbId;
}
public void setAnalysisDbId(String analysisDbId) {
this.analysisDbId = analysisDbId;
}
public String getVariantSetDbId() {
return variantSetDbId;
}
public void setVariantSetDbId(String variantSetDbId) {
this.variantSetDbId = variantSetDbId;
}
public String getAnalysisName() {
return analysisName;
}
public void setAnalysisName(String analysisName) {
this.analysisName = analysisName;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getCreated() {
return created;
}
public void setCreated(String created) {
this.created = created;
}
public String getUpdated() {
return updated;
}
public void setUpdated(String updated) {
this.updated = updated;
}
public List<String> getSoftware() {
return software;
}
public void setSoftware(List<String> software) {
this.software = software;
}
}

View File

@@ -0,0 +1,55 @@
package org.brapi.test.BrAPITestServer.model.dto.geno;
import java.util.ArrayList;
import java.util.List;
import io.swagger.model.BrAPIResponse;
import io.swagger.model.BrAPIResponseResult;
import io.swagger.model.Context;
import io.swagger.model.Metadata;
public class VariantSetAvailableFormatListResponse
implements BrAPIResponse<VariantSetAvailableFormatListResponse.Result> {
private Context _atContext;
private Metadata metadata;
private Result result = new Result();
public static class Result implements BrAPIResponseResult<VariantSetAvailableFormatRecord> {
private List<VariantSetAvailableFormatRecord> data = new ArrayList<>();
@Override
public List<VariantSetAvailableFormatRecord> getData() {
return data;
}
@Override
public void setData(List<VariantSetAvailableFormatRecord> data) {
this.data = data;
}
}
@Override
public void set_atContext(Context _atContext) {
this._atContext = _atContext;
}
@Override
public Metadata getMetadata() {
return metadata;
}
@Override
public void setMetadata(Metadata metadata) {
this.metadata = metadata;
}
@Override
public Result getResult() {
return result;
}
@Override
public void setResult(Result result) {
this.result = result;
}
}

View File

@@ -0,0 +1,85 @@
package org.brapi.test.BrAPITestServer.model.dto.geno;
public class VariantSetAvailableFormatRecord {
private String formatDbId;
private String variantSetDbId;
private String dataFormat;
private String fileFormat;
private String fileURL;
private Boolean expandHomozygotes;
private String sepPhased;
private String sepUnphased;
private String unknownString;
public String getFormatDbId() {
return formatDbId;
}
public void setFormatDbId(String formatDbId) {
this.formatDbId = formatDbId;
}
public String getVariantSetDbId() {
return variantSetDbId;
}
public void setVariantSetDbId(String variantSetDbId) {
this.variantSetDbId = variantSetDbId;
}
public String getDataFormat() {
return dataFormat;
}
public void setDataFormat(String dataFormat) {
this.dataFormat = dataFormat;
}
public String getFileFormat() {
return fileFormat;
}
public void setFileFormat(String fileFormat) {
this.fileFormat = fileFormat;
}
public String getFileURL() {
return fileURL;
}
public void setFileURL(String fileURL) {
this.fileURL = fileURL;
}
public Boolean getExpandHomozygotes() {
return expandHomozygotes;
}
public void setExpandHomozygotes(Boolean expandHomozygotes) {
this.expandHomozygotes = expandHomozygotes;
}
public String getSepPhased() {
return sepPhased;
}
public void setSepPhased(String sepPhased) {
this.sepPhased = sepPhased;
}
public String getSepUnphased() {
return sepUnphased;
}
public void setSepUnphased(String sepUnphased) {
this.sepUnphased = sepUnphased;
}
public String getUnknownString() {
return unknownString;
}
public void setUnknownString(String unknownString) {
this.unknownString = unknownString;
}
}

View File

@@ -0,0 +1,85 @@
package org.brapi.test.BrAPITestServer.model.dto.geno;
public class VariantSetAvailableFormatWriteRequest {
private String formatDbId;
private String variantSetDbId;
private String dataFormat;
private String fileFormat;
private String fileURL;
private Boolean expandHomozygotes;
private String sepPhased;
private String sepUnphased;
private String unknownString;
public String getFormatDbId() {
return formatDbId;
}
public void setFormatDbId(String formatDbId) {
this.formatDbId = formatDbId;
}
public String getVariantSetDbId() {
return variantSetDbId;
}
public void setVariantSetDbId(String variantSetDbId) {
this.variantSetDbId = variantSetDbId;
}
public String getDataFormat() {
return dataFormat;
}
public void setDataFormat(String dataFormat) {
this.dataFormat = dataFormat;
}
public String getFileFormat() {
return fileFormat;
}
public void setFileFormat(String fileFormat) {
this.fileFormat = fileFormat;
}
public String getFileURL() {
return fileURL;
}
public void setFileURL(String fileURL) {
this.fileURL = fileURL;
}
public Boolean getExpandHomozygotes() {
return expandHomozygotes;
}
public void setExpandHomozygotes(Boolean expandHomozygotes) {
this.expandHomozygotes = expandHomozygotes;
}
public String getSepPhased() {
return sepPhased;
}
public void setSepPhased(String sepPhased) {
this.sepPhased = sepPhased;
}
public String getSepUnphased() {
return sepUnphased;
}
public void setSepUnphased(String sepUnphased) {
this.sepUnphased = sepUnphased;
}
public String getUnknownString() {
return unknownString;
}
public void setUnknownString(String unknownString) {
this.unknownString = unknownString;
}
}

View File

@@ -2,6 +2,12 @@ package org.brapi.test.BrAPITestServer.repository.geno;
import org.brapi.test.BrAPITestServer.model.entity.geno.CallEntity;
import org.brapi.test.BrAPITestServer.repository.BrAPIRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface CallRepository extends BrAPIRepository<CallEntity, String> {
@Query("SELECT COUNT(c) FROM CallEntity c WHERE c.callSet.id = :callSetDbId")
long countByCallSetDbId(@Param("callSetDbId") String callSetDbId);
public interface CallRepository extends BrAPIRepository<CallEntity, String>{
}

View File

@@ -0,0 +1,10 @@
package org.brapi.test.BrAPITestServer.repository.geno;
import java.util.List;
import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetAnalysisEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface VariantSetAnalysisRepository extends JpaRepository<VariantSetAnalysisEntity, String> {
List<VariantSetAnalysisEntity> findByVariantSet_Id(String variantSetId);
}

View File

@@ -0,0 +1,10 @@
package org.brapi.test.BrAPITestServer.repository.geno;
import java.util.List;
import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetAvailableFormatEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface VariantSetAvailableFormatRepository extends JpaRepository<VariantSetAvailableFormatEntity, String> {
List<VariantSetAvailableFormatEntity> findByVariantSet_Id(String variantSetId);
}

View File

@@ -3,18 +3,27 @@ package org.brapi.test.BrAPITestServer.service.geno;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.lang3.math.NumberUtils;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException;
import org.brapi.test.BrAPITestServer.model.dto.geno.CallWriteRequest;
import org.brapi.test.BrAPITestServer.model.entity.geno.CallEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.CallSetEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.VariantEntity;
import org.brapi.test.BrAPITestServer.repository.geno.CallRepository;
import org.brapi.test.BrAPITestServer.service.PagingUtility;
import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder;
import org.brapi.test.BrAPITestServer.service.UpdateUtility;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -37,9 +46,13 @@ public class CallService {
public static final String UNKNOWN_STRING_DEFAULT = ".";
private final CallRepository callRepository;
private final CallSetService callSetService;
private final VariantService variantService;
public CallService(CallRepository callRepository) {
public CallService(CallRepository callRepository, CallSetService callSetService, VariantService variantService) {
this.callRepository = callRepository;
this.callSetService = callSetService;
this.variantService = variantService;
}
public CallsListResponseResult findCalls(String callSetDbId, String variantDbId, String variantSetDbId,
@@ -103,7 +116,14 @@ public class CallService {
private Call convertFromEntityWithFormatting(CallEntity entity, CallsSearchRequest request) {
Call call = new Call();
call.setAdditionalInfo(entity.getAdditionalInfoMap());
Map<String, Object> additionalInfo = entity.getAdditionalInfoMap();
if (additionalInfo == null) {
additionalInfo = new HashMap<>();
} else {
additionalInfo = new HashMap<>(additionalInfo);
}
additionalInfo.put("callDbId", entity.getId());
call.setAdditionalInfo(additionalInfo);
if (entity.getCallSet() != null) {
call.setCallSetDbId(entity.getCallSet().getId());
call.setCallSetName(entity.getCallSet().getCallSetName());
@@ -154,6 +174,7 @@ public class CallService {
}
public CallsListResponseResult updateCalls(List<Call> body) throws BrAPIServerException {
ensureVariantSetBindings(body);
CallsSearchRequest searchReq = new CallsSearchRequest();
Map<String, Call> callsMap = new HashMap<>();
for (Call call : body) {
@@ -214,4 +235,126 @@ public class CallService {
}
public Call getCall(String callDbId) throws BrAPIServerException {
return convertFromEntity(getCallEntity(callDbId, HttpStatus.NOT_FOUND));
}
public CallEntity getCallEntity(String callDbId) throws BrAPIServerException {
return getCallEntity(callDbId, HttpStatus.BAD_REQUEST);
}
public CallEntity getCallEntity(String callDbId, HttpStatus errorStatus) throws BrAPIServerException {
Optional<CallEntity> entityOpt = callRepository.findById(callDbId);
if (entityOpt.isPresent()) {
return entityOpt.get();
}
throw new BrAPIServerDbIdNotFoundException("call", callDbId, errorStatus);
}
public Call saveCall(CallWriteRequest request) throws BrAPIServerException {
if (request.getCallSetDbId() == null || request.getCallSetDbId().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "callSetDbId is required");
}
if (request.getVariantDbId() == null || request.getVariantDbId().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "variantDbId is required");
}
if (request.getGenotype() == null || request.getGenotype().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "genotype is required");
}
if (request.getCallDbId() != null && callRepository.findById(request.getCallDbId()).isPresent()) {
throw new BrAPIServerException(HttpStatus.CONFLICT, "Call already exists: " + request.getCallDbId());
}
CallSetEntity callSet = callSetService.getCallSetEntity(request.getCallSetDbId());
VariantEntity variant = variantService.getVariantEntity(request.getVariantDbId());
assertNoDuplicateCall(callSet.getId(), variant.getId(), null);
if (request.getReadDepth() != null && request.getReadDepth() < 0) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "readDepth cannot be negative");
}
CallEntity entity = new CallEntity();
if (request.getCallDbId() != null && !request.getCallDbId().isBlank()) {
entity.setId(request.getCallDbId().trim());
}
updateEntity(entity, request, callSet, variant);
Call saved = convertFromEntity(callRepository.save(entity));
if (variant.getVariantSet() != null) {
callSetService.ensureVariantSetBinding(callSet.getId(), variant.getVariantSet().getId());
}
return saved;
}
public List<Call> importCalls(List<CallWriteRequest> requests) throws BrAPIServerException {
List<Call> saved = new ArrayList<>();
for (CallWriteRequest request : requests) {
saved.add(saveCall(request));
}
return saved;
}
public Call updateCall(String callDbId, CallWriteRequest request) throws BrAPIServerException {
CallEntity entity = getCallEntity(callDbId, HttpStatus.NOT_FOUND);
CallSetEntity callSet = callSetService.getCallSetEntity(request.getCallSetDbId());
VariantEntity variant = variantService.getVariantEntity(request.getVariantDbId());
assertNoDuplicateCall(callSet.getId(), variant.getId(), callDbId);
if (request.getReadDepth() != null && request.getReadDepth() < 0) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "readDepth cannot be negative");
}
updateEntity(entity, request, callSet, variant);
Call saved = convertFromEntity(callRepository.save(entity));
if (variant.getVariantSet() != null) {
callSetService.ensureVariantSetBinding(callSet.getId(), variant.getVariantSet().getId());
}
return saved;
}
public Call deleteCall(String callDbId) throws BrAPIServerException {
CallEntity entity = getCallEntity(callDbId, HttpStatus.NOT_FOUND);
Call deleted = convertFromEntity(entity);
try {
callRepository.delete(entity);
callRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new BrAPIServerException(HttpStatus.CONFLICT, "Call cannot be deleted");
}
return deleted;
}
private void assertNoDuplicateCall(String callSetDbId, String variantDbId, String excludeCallDbId)
throws BrAPIServerException {
Pageable pageReq = PageRequest.of(0, 1);
SearchQueryBuilder<CallEntity> searchQuery = new SearchQueryBuilder<CallEntity>(CallEntity.class)
.appendSingle(callSetDbId, "callSet.id")
.appendSingle(variantDbId, "variant.id");
Page<CallEntity> page = callRepository.findAllBySearch(searchQuery, pageReq);
if (page.getTotalElements() > 0) {
CallEntity existing = page.getContent().get(0);
if (excludeCallDbId == null || !excludeCallDbId.equals(existing.getId())) {
throw new BrAPIServerException(HttpStatus.CONFLICT,
"Call already exists for this callSet and variant");
}
}
}
private void updateEntity(CallEntity entity, CallWriteRequest request, CallSetEntity callSet,
VariantEntity variant) {
entity.setCallSet(callSet);
entity.setVariant(variant);
entity.setGenotype(request.getGenotype().trim());
entity.setReadDepth(request.getReadDepth());
entity.setGenotypeLikelihood(request.getGenotypeLikelihood());
entity.setPhaseSet(request.getPhaseSet());
}
private void ensureVariantSetBindings(List<Call> calls) throws BrAPIServerException {
Set<String> processed = new HashSet<>();
for (Call call : calls) {
if (call.getCallSetDbId() == null || call.getVariantSetDbId() == null) {
continue;
}
String key = call.getCallSetDbId() + "|" + call.getVariantSetDbId();
if (processed.add(key)) {
callSetService.ensureVariantSetBinding(call.getCallSetDbId(), call.getVariantSetDbId());
}
}
}
}

View File

@@ -1,16 +1,25 @@
package org.brapi.test.BrAPITestServer.service.geno;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException;
import org.brapi.test.BrAPITestServer.model.dto.geno.CallSetWriteRequest;
import org.brapi.test.BrAPITestServer.model.entity.geno.CallSetEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.SampleEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetEntity;
import org.brapi.test.BrAPITestServer.repository.geno.CallRepository;
import org.brapi.test.BrAPITestServer.repository.geno.CallSetRepository;
import org.brapi.test.BrAPITestServer.repository.geno.VariantSetRepository;
import org.brapi.test.BrAPITestServer.service.DateUtility;
import org.brapi.test.BrAPITestServer.service.PagingUtility;
import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
@@ -24,9 +33,16 @@ import io.swagger.model.geno.CallSetsSearchRequest;
public class CallSetService {
private final CallSetRepository callSetRepository;
private final CallRepository callRepository;
private final SampleService sampleService;
private final VariantSetRepository variantSetRepository;
public CallSetService(CallSetRepository callSetRepository) {
public CallSetService(CallSetRepository callSetRepository, CallRepository callRepository,
SampleService sampleService, VariantSetRepository variantSetRepository) {
this.callSetRepository = callSetRepository;
this.callRepository = callRepository;
this.sampleService = sampleService;
this.variantSetRepository = variantSetRepository;
}
public List<CallSet> findCallSets(String callSetDbId, String callSetName, String variantSetDbId, String sampleDbId,
@@ -111,4 +127,101 @@ public class CallSetService {
public CallSetEntity save(CallSetEntity entity) {
return callSetRepository.save(entity);
}
public void ensureVariantSetBinding(String callSetDbId, String variantSetDbId) throws BrAPIServerException {
if (callSetDbId == null || callSetDbId.isBlank() || variantSetDbId == null || variantSetDbId.isBlank()) {
return;
}
CallSetEntity callSet = getCallSetEntity(callSetDbId.trim());
VariantSetEntity variantSet = variantSetRepository.findById(variantSetDbId.trim())
.orElseThrow(() -> new BrAPIServerDbIdNotFoundException("variantSet", variantSetDbId,
HttpStatus.BAD_REQUEST));
if (callSet.getVariantSets() == null) {
callSet.setVariantSets(new ArrayList<>());
}
boolean exists = callSet.getVariantSets().stream().anyMatch(item -> variantSetDbId.trim().equals(item.getId()));
if (!exists) {
callSet.getVariantSets().add(variantSet);
callSet.setUpdated(new Date());
callSetRepository.save(callSet);
}
}
public CallSet saveCallSet(CallSetWriteRequest request) throws BrAPIServerException {
if (request.getCallSetName() == null || request.getCallSetName().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "callSetName is required");
}
if (request.getSampleDbId() == null || request.getSampleDbId().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "sampleDbId is required");
}
if (request.getCallSetDbId() != null && callSetRepository.findById(request.getCallSetDbId()).isPresent()) {
throw new BrAPIServerException(HttpStatus.CONFLICT, "CallSet already exists: " + request.getCallSetDbId());
}
CallSetEntity entity = new CallSetEntity();
if (request.getCallSetDbId() != null && !request.getCallSetDbId().isBlank()) {
entity.setId(request.getCallSetDbId().trim());
}
entity.setCreated(new Date());
entity.setUpdated(new Date());
updateEntity(entity, request);
return convertFromEntity(callSetRepository.save(entity));
}
public CallSet updateCallSet(String callSetDbId, CallSetWriteRequest request) throws BrAPIServerException {
CallSetEntity entity = getCallSetEntity(callSetDbId, HttpStatus.NOT_FOUND);
entity.setUpdated(new Date());
updateEntity(entity, request);
return convertFromEntity(callSetRepository.save(entity));
}
public CallSet deleteCallSet(String callSetDbId) throws BrAPIServerException {
CallSetEntity entity = getCallSetEntity(callSetDbId, HttpStatus.NOT_FOUND);
assertNoCallSetDependencies(callSetDbId);
CallSet deleted = convertFromEntity(entity);
try {
callSetRepository.delete(entity);
callSetRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new BrAPIServerException(HttpStatus.CONFLICT, "CallSet is in use and cannot be deleted");
}
return deleted;
}
private void assertNoCallSetDependencies(String callSetDbId) throws BrAPIServerException {
if (callRepository.countByCallSetDbId(callSetDbId) > 0) {
throw new BrAPIServerException(HttpStatus.CONFLICT, "CallSet is referenced by allele_call records");
}
}
private void updateEntity(CallSetEntity entity, CallSetWriteRequest request) throws BrAPIServerException {
if (request.getCallSetName() != null && !request.getCallSetName().isBlank()) {
entity.setCallSetName(request.getCallSetName().trim());
} else if (entity.getCallSetName() == null || entity.getCallSetName().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "callSetName is required");
}
if (request.getSampleDbId() != null && !request.getSampleDbId().isBlank()) {
SampleEntity sample = sampleService.getSampleEntity(request.getSampleDbId());
entity.setSample(sample);
} else if (entity.getSample() == null) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "sampleDbId is required");
}
if (request.getVariantSetDbIds() != null) {
List<VariantSetEntity> variantSets = new ArrayList<>();
LinkedHashSet<String> seen = new LinkedHashSet<>();
for (String variantSetDbId : request.getVariantSetDbIds()) {
if (variantSetDbId == null || variantSetDbId.isBlank()) {
continue;
}
String normalizedId = variantSetDbId.trim();
if (!seen.add(normalizedId)) {
continue;
}
VariantSetEntity variantSet = variantSetRepository.findById(normalizedId)
.orElseThrow(() -> new BrAPIServerDbIdNotFoundException("variantSet", variantSetDbId,
HttpStatus.BAD_REQUEST));
variantSets.add(variantSet);
}
entity.setVariantSets(variantSets);
}
}
}

View File

@@ -5,15 +5,22 @@ import java.util.Optional;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException;
import org.brapi.test.BrAPITestServer.model.dto.geno.GenomeMapWriteRequest;
import org.brapi.test.BrAPITestServer.model.dto.geno.LinkageGroupWriteRequest;
import org.brapi.test.BrAPITestServer.model.entity.geno.GenomeMapEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.LinkageGroupEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.MarkerPositionEntity;
import org.brapi.test.BrAPITestServer.repository.geno.GenomeMapRepository;
import org.brapi.test.BrAPITestServer.repository.geno.LinkageGroupRepository;
import org.brapi.test.BrAPITestServer.repository.geno.MarkerPositionRepository;
import org.brapi.test.BrAPITestServer.service.DateUtility;
import org.brapi.test.BrAPITestServer.service.PagingUtility;
import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder;
import org.brapi.test.BrAPITestServer.service.core.CropService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -25,13 +32,18 @@ import io.swagger.model.geno.LinkageGroup;
@Service
public class GenomeMapService {
private GenomeMapRepository genomeMapRepository;
private LinkageGroupRepository linkageGroupRepository;
private final GenomeMapRepository genomeMapRepository;
private final LinkageGroupRepository linkageGroupRepository;
private final MarkerPositionRepository markerPositionRepository;
private final CropService cropService;
@Autowired
public GenomeMapService(GenomeMapRepository genomeMapRepository, LinkageGroupRepository linkageGroupRepository) {
public GenomeMapService(GenomeMapRepository genomeMapRepository, LinkageGroupRepository linkageGroupRepository,
MarkerPositionRepository markerPositionRepository, CropService cropService) {
this.genomeMapRepository = genomeMapRepository;
this.linkageGroupRepository = linkageGroupRepository;
this.markerPositionRepository = markerPositionRepository;
this.cropService = cropService;
}
public List<GenomeMap> findMaps(String commonCropName, String mapPUI, String scientificName, String type,
@@ -74,11 +86,183 @@ public class GenomeMapService {
Pageable pageReq = PagingUtility.getPageRequest(metadata);
Page<LinkageGroupEntity> page = linkageGroupRepository.findAllBySearch(searchQuery, pageReq);
List<LinkageGroup> linkageGroups = page.map(this::convertFromEntity).getContent();
List<LinkageGroup> linkageGroups = page.map(this::convertLinkageGroupFromEntity).getContent();
PagingUtility.calculateMetaData(metadata, page);
return linkageGroups;
}
public GenomeMap saveMap(GenomeMapWriteRequest request) throws BrAPIServerException {
if (request.getMapName() == null || request.getMapName().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "mapName is required");
}
if (request.getMapDbId() != null && genomeMapRepository.findById(request.getMapDbId()).isPresent()) {
throw new BrAPIServerException(HttpStatus.CONFLICT, "GenomeMap already exists: " + request.getMapDbId());
}
GenomeMapEntity entity = new GenomeMapEntity();
if (request.getMapDbId() != null && !request.getMapDbId().isBlank()) {
entity.setId(request.getMapDbId().trim());
}
updateMapEntity(entity, request);
entity = genomeMapRepository.save(entity);
ensureMapPUI(entity);
return convertFromEntity(genomeMapRepository.save(entity));
}
public GenomeMap updateMap(String mapDbId, GenomeMapWriteRequest request) throws BrAPIServerException {
GenomeMapEntity entity = getMapEntity(mapDbId);
updateMapEntity(entity, request);
return convertFromEntity(genomeMapRepository.save(entity));
}
public GenomeMap deleteMap(String mapDbId) throws BrAPIServerException {
GenomeMapEntity entity = getMapEntity(mapDbId);
assertNoMapDependencies(mapDbId);
GenomeMap deleted = convertFromEntity(entity);
try {
genomeMapRepository.delete(entity);
genomeMapRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new BrAPIServerException(HttpStatus.CONFLICT, "GenomeMap is in use and cannot be deleted");
}
return deleted;
}
public LinkageGroup saveLinkageGroup(String mapDbId, LinkageGroupWriteRequest request) throws BrAPIServerException {
if (request.getLinkageGroupName() == null || request.getLinkageGroupName().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "linkageGroupName is required");
}
if (request.getLinkageGroupDbId() != null
&& linkageGroupRepository.findById(request.getLinkageGroupDbId()).isPresent()) {
throw new BrAPIServerException(HttpStatus.CONFLICT,
"LinkageGroup already exists: " + request.getLinkageGroupDbId());
}
GenomeMapEntity map = getMapEntity(mapDbId);
assertLinkageGroupNameUnique(map.getId(), request.getLinkageGroupName().trim(), null);
LinkageGroupEntity entity = new LinkageGroupEntity();
if (request.getLinkageGroupDbId() != null && !request.getLinkageGroupDbId().isBlank()) {
entity.setId(request.getLinkageGroupDbId().trim());
}
entity.setGenomeMap(map);
updateLinkageGroupEntity(entity, request);
return convertLinkageGroupFromEntity(linkageGroupRepository.save(entity));
}
public LinkageGroup updateLinkageGroup(String mapDbId, String linkageGroupDbId, LinkageGroupWriteRequest request)
throws BrAPIServerException {
LinkageGroupEntity entity = getLinkageGroupEntity(linkageGroupDbId);
if (entity.getGenomeMap() == null || !mapDbId.equals(entity.getGenomeMap().getId())) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "LinkageGroup does not belong to map: " + mapDbId);
}
if (request.getLinkageGroupName() != null && !request.getLinkageGroupName().isBlank()) {
assertLinkageGroupNameUnique(mapDbId, request.getLinkageGroupName().trim(), linkageGroupDbId);
}
updateLinkageGroupEntity(entity, request);
return convertLinkageGroupFromEntity(linkageGroupRepository.save(entity));
}
public LinkageGroup deleteLinkageGroup(String mapDbId, String linkageGroupDbId) throws BrAPIServerException {
LinkageGroupEntity entity = getLinkageGroupEntity(linkageGroupDbId);
if (entity.getGenomeMap() == null || !mapDbId.equals(entity.getGenomeMap().getId())) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "LinkageGroup does not belong to map: " + mapDbId);
}
assertNoLinkageGroupDependencies(linkageGroupDbId);
LinkageGroup deleted = convertLinkageGroupFromEntity(entity);
try {
linkageGroupRepository.delete(entity);
linkageGroupRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new BrAPIServerException(HttpStatus.CONFLICT, "LinkageGroup is in use and cannot be deleted");
}
return deleted;
}
public GenomeMapEntity getMapEntity(String mapDbId) throws BrAPIServerException {
return genomeMapRepository.findById(mapDbId)
.orElseThrow(() -> new BrAPIServerDbIdNotFoundException("map", mapDbId, HttpStatus.NOT_FOUND));
}
public LinkageGroupEntity getLinkageGroupEntity(String linkageGroupDbId) throws BrAPIServerException {
return linkageGroupRepository.findById(linkageGroupDbId).orElseThrow(
() -> new BrAPIServerDbIdNotFoundException("linkageGroup", linkageGroupDbId, HttpStatus.NOT_FOUND));
}
private void assertNoMapDependencies(String mapDbId) throws BrAPIServerException {
Pageable pageReq = PageRequest.of(0, 1);
SearchQueryBuilder<LinkageGroupEntity> query = new SearchQueryBuilder<LinkageGroupEntity>(LinkageGroupEntity.class)
.appendSingle(mapDbId, "genomeMap.id");
if (linkageGroupRepository.findAllBySearch(query, pageReq).getTotalElements() > 0) {
throw new BrAPIServerException(HttpStatus.CONFLICT, "GenomeMap is referenced by linkage group records");
}
}
private void assertNoLinkageGroupDependencies(String linkageGroupDbId) throws BrAPIServerException {
Pageable pageReq = PageRequest.of(0, 1);
SearchQueryBuilder<MarkerPositionEntity> query = new SearchQueryBuilder<MarkerPositionEntity>(
MarkerPositionEntity.class).appendSingle(linkageGroupDbId, "linkageGroup.id");
if (markerPositionRepository.findAllBySearch(query, pageReq).getTotalElements() > 0) {
throw new BrAPIServerException(HttpStatus.CONFLICT,
"LinkageGroup is referenced by marker_position records");
}
}
private void assertLinkageGroupNameUnique(String mapDbId, String linkageGroupName, String excludeId)
throws BrAPIServerException {
SearchQueryBuilder<LinkageGroupEntity> query = new SearchQueryBuilder<LinkageGroupEntity>(LinkageGroupEntity.class)
.appendSingle(mapDbId, "genomeMap.id")
.appendSingle(linkageGroupName, "linkageGroupName");
Page<LinkageGroupEntity> page = linkageGroupRepository.findAllBySearch(query, PageRequest.of(0, 2));
for (LinkageGroupEntity existing : page.getContent()) {
if (excludeId == null || !excludeId.equals(existing.getId())) {
throw new BrAPIServerException(HttpStatus.CONFLICT,
"linkageGroupName already exists on this map: " + linkageGroupName);
}
}
}
private void ensureMapPUI(GenomeMapEntity entity) {
if (entity.getMapPUI() == null || entity.getMapPUI().isBlank()) {
entity.setMapPUI("urn:uuid:" + entity.getId());
}
}
private void updateMapEntity(GenomeMapEntity entity, GenomeMapWriteRequest request) throws BrAPIServerException {
entity.setMapName(request.getMapName().trim());
entity.setScientificName(trimOrNull(request.getScientificName()));
entity.setType(trimOrNull(request.getType()));
entity.setUnit(trimOrNull(request.getUnit()));
entity.setComments(trimOrNull(request.getComments()));
entity.setDocumentationURL(trimOrNull(request.getDocumentationURL()));
if (request.getPublishedDate() != null) {
entity.setPublishedDate(DateUtility.toDate(request.getPublishedDate()));
}
if (request.getCommonCropName() != null && !request.getCommonCropName().isBlank()) {
entity.setCrop(cropService.getCropEntity(request.getCommonCropName().trim()));
} else if (request.getCommonCropName() != null) {
entity.setCrop(null);
}
}
private void updateLinkageGroupEntity(LinkageGroupEntity entity, LinkageGroupWriteRequest request)
throws BrAPIServerException {
if (request.getLinkageGroupName() != null && !request.getLinkageGroupName().isBlank()) {
entity.setLinkageGroupName(request.getLinkageGroupName().trim());
}
if (request.getMaxPosition() != null) {
if (request.getMaxPosition() < 0) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "maxPosition cannot be negative");
}
entity.setMaxMarkerPosition(request.getMaxPosition());
}
}
private String trimOrNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private GenomeMap convertFromEntity(GenomeMapEntity entity) {
GenomeMap map = new GenomeMap();
map.setAdditionalInfo(entity.getAdditionalInfoMap());
@@ -102,12 +286,18 @@ public class GenomeMapService {
return map;
}
private LinkageGroup convertFromEntity(LinkageGroupEntity entity) {
private LinkageGroup convertLinkageGroupFromEntity(LinkageGroupEntity entity) {
LinkageGroup group = new LinkageGroup();
group.setAdditionalInfo(entity.getAdditionalInfoMap());
group.setLinkageGroupName(entity.getLinkageGroupName());
group.setMarkerCount(entity.getMarkers().size());
int markerCount = entity.getMarkers() != null ? entity.getMarkers().size() : 0;
group.setMarkerCount(markerCount);
group.setMaxPosition(entity.getMaxMarkerPosition());
group.putAdditionalInfoItem("linkageGroupDbId", entity.getId());
if (entity.getGenomeMap() != null) {
group.putAdditionalInfoItem("mapDbId", entity.getGenomeMap().getId());
group.putAdditionalInfoItem("mapName", entity.getGenomeMap().getMapName());
}
return group;
}

View File

@@ -2,12 +2,20 @@ package org.brapi.test.BrAPITestServer.service.geno;
import java.util.List;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException;
import org.brapi.test.BrAPITestServer.model.dto.geno.MarkerPositionWriteRequest;
import org.brapi.test.BrAPITestServer.model.entity.geno.LinkageGroupEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.MarkerPositionEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.VariantEntity;
import org.brapi.test.BrAPITestServer.repository.geno.MarkerPositionRepository;
import org.brapi.test.BrAPITestServer.service.PagingUtility;
import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import io.swagger.model.Metadata;
@@ -18,9 +26,14 @@ import io.swagger.model.geno.MarkerPosition;
public class MarkerPositionService {
private final MarkerPositionRepository markerPositionRepository;
private final GenomeMapService genomeMapService;
private final VariantService variantService;
public MarkerPositionService(MarkerPositionRepository markerPositionRepository) {
public MarkerPositionService(MarkerPositionRepository markerPositionRepository, GenomeMapService genomeMapService,
VariantService variantService) {
this.markerPositionRepository = markerPositionRepository;
this.genomeMapService = genomeMapService;
this.variantService = variantService;
}
public List<MarkerPosition> findMarkerPositions(String mapDbId, String linkageGroupName, String variantDbId,
@@ -52,11 +65,91 @@ public class MarkerPositionService {
return markerPositions;
}
public MarkerPosition saveMarkerPosition(MarkerPositionWriteRequest request) throws BrAPIServerException {
if (request.getLinkageGroupDbId() == null || request.getLinkageGroupDbId().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "linkageGroupDbId is required");
}
if (request.getVariantDbId() == null || request.getVariantDbId().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "variantDbId is required");
}
if (request.getPosition() == null) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "position is required");
}
if (request.getMarkerPositionDbId() != null
&& markerPositionRepository.findById(request.getMarkerPositionDbId()).isPresent()) {
throw new BrAPIServerException(HttpStatus.CONFLICT,
"MarkerPosition already exists: " + request.getMarkerPositionDbId());
}
MarkerPositionEntity entity = new MarkerPositionEntity();
if (request.getMarkerPositionDbId() != null && !request.getMarkerPositionDbId().isBlank()) {
entity.setId(request.getMarkerPositionDbId().trim());
}
updateMarkerPositionEntity(entity, request);
return convertFromEntity(markerPositionRepository.save(entity));
}
public MarkerPosition updateMarkerPosition(String markerPositionDbId, MarkerPositionWriteRequest request)
throws BrAPIServerException {
MarkerPositionEntity entity = getMarkerPositionEntity(markerPositionDbId);
updateMarkerPositionEntity(entity, request);
return convertFromEntity(markerPositionRepository.save(entity));
}
public MarkerPosition deleteMarkerPosition(String markerPositionDbId) throws BrAPIServerException {
MarkerPositionEntity entity = getMarkerPositionEntity(markerPositionDbId);
MarkerPosition deleted = convertFromEntity(entity);
try {
markerPositionRepository.delete(entity);
markerPositionRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new BrAPIServerException(HttpStatus.CONFLICT, "MarkerPosition cannot be deleted");
}
return deleted;
}
public MarkerPositionEntity getMarkerPositionEntity(String markerPositionDbId) throws BrAPIServerException {
return markerPositionRepository.findById(markerPositionDbId).orElseThrow(
() -> new BrAPIServerDbIdNotFoundException("markerPosition", markerPositionDbId, HttpStatus.NOT_FOUND));
}
private void updateMarkerPositionEntity(MarkerPositionEntity entity, MarkerPositionWriteRequest request)
throws BrAPIServerException {
LinkageGroupEntity linkageGroup = genomeMapService.getLinkageGroupEntity(request.getLinkageGroupDbId());
VariantEntity variant = variantService.getVariantEntity(request.getVariantDbId());
assertMarkerVariantUnique(linkageGroup.getId(), variant.getId(), entity.getId());
if (request.getPosition() < 0) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "position cannot be negative");
}
if (linkageGroup.getMaxMarkerPosition() != null
&& request.getPosition() > linkageGroup.getMaxMarkerPosition()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST,
"position cannot exceed linkage group maxPosition");
}
entity.setLinkageGroup(linkageGroup);
entity.setVariant(variant);
entity.setPosition(request.getPosition());
}
private void assertMarkerVariantUnique(String linkageGroupDbId, String variantDbId, String excludeId)
throws BrAPIServerException {
SearchQueryBuilder<MarkerPositionEntity> query = new SearchQueryBuilder<MarkerPositionEntity>(
MarkerPositionEntity.class).appendSingle(linkageGroupDbId, "linkageGroup.id")
.appendSingle(variantDbId, "variant.id");
Page<MarkerPositionEntity> page = markerPositionRepository.findAllBySearch(query, PageRequest.of(0, 2));
for (MarkerPositionEntity existing : page.getContent()) {
if (excludeId == null || !excludeId.equals(existing.getId())) {
throw new BrAPIServerException(HttpStatus.CONFLICT,
"variant already has a marker position on this linkage group");
}
}
}
private MarkerPosition convertFromEntity(MarkerPositionEntity entity) {
MarkerPosition position = new MarkerPosition();
position.setAdditionalInfo(entity.getAdditionalInfoMap());
if (entity.getLinkageGroup() != null) {
position.setLinkageGroupName(entity.getLinkageGroup().getLinkageGroupName());
position.putAdditionalInfoItem("linkageGroupDbId", entity.getLinkageGroup().getId());
if (entity.getLinkageGroup().getGenomeMap() != null) {
position.setMapDbId(entity.getLinkageGroup().getGenomeMap().getId());
position.setMapName(entity.getLinkageGroup().getGenomeMap().getMapName());
@@ -67,6 +160,7 @@ public class MarkerPositionService {
position.setVariantDbId(entity.getVariant().getId());
position.setVariantName(entity.getVariant().getVariantName());
}
position.putAdditionalInfoItem("markerPositionDbId", entity.getId());
return position;
}

View File

@@ -0,0 +1,134 @@
package org.brapi.test.BrAPITestServer.service.geno;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException;
import org.brapi.test.BrAPITestServer.model.dto.geno.VariantSetAnalysisWriteRequest;
import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetAnalysisEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetEntity;
import org.brapi.test.BrAPITestServer.repository.geno.VariantSetAnalysisRepository;
import org.brapi.test.BrAPITestServer.service.DateUtility;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import io.swagger.model.geno.Analysis;
@Service
public class VariantSetAnalysisService {
private final VariantSetAnalysisRepository variantSetAnalysisRepository;
private final VariantSetService variantSetService;
public VariantSetAnalysisService(VariantSetAnalysisRepository variantSetAnalysisRepository,
VariantSetService variantSetService) {
this.variantSetAnalysisRepository = variantSetAnalysisRepository;
this.variantSetService = variantSetService;
}
public List<Analysis> findAnalysisByVariantSet(String variantSetDbId) throws BrAPIServerException {
assertVariantSetExists(variantSetDbId);
return variantSetAnalysisRepository.findByVariantSet_Id(variantSetDbId).stream()
.map(this::convertFromEntity)
.collect(Collectors.toList());
}
public Analysis saveAnalysis(String variantSetDbId, VariantSetAnalysisWriteRequest request)
throws BrAPIServerException {
VariantSetEntity variantSet = variantSetService.getVariantSetEntity(variantSetDbId);
if (request.getAnalysisName() == null || request.getAnalysisName().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "analysisName is required");
}
if (request.getAnalysisDbId() != null
&& variantSetAnalysisRepository.findById(request.getAnalysisDbId()).isPresent()) {
throw new BrAPIServerException(HttpStatus.CONFLICT,
"Analysis already exists: " + request.getAnalysisDbId());
}
VariantSetAnalysisEntity entity = new VariantSetAnalysisEntity();
if (request.getAnalysisDbId() != null && !request.getAnalysisDbId().isBlank()) {
entity.setId(request.getAnalysisDbId().trim());
}
updateEntity(entity, variantSet, request);
return convertFromEntity(variantSetAnalysisRepository.save(entity));
}
public Analysis updateAnalysis(String variantSetDbId, String analysisDbId, VariantSetAnalysisWriteRequest request)
throws BrAPIServerException {
VariantSetAnalysisEntity entity = getAnalysisEntity(variantSetDbId, analysisDbId);
if (request.getAnalysisName() == null || request.getAnalysisName().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "analysisName is required");
}
updateEntity(entity, entity.getVariantSet(), request);
return convertFromEntity(variantSetAnalysisRepository.save(entity));
}
public Analysis deleteAnalysis(String variantSetDbId, String analysisDbId) throws BrAPIServerException {
VariantSetAnalysisEntity entity = getAnalysisEntity(variantSetDbId, analysisDbId);
Analysis deleted = convertFromEntity(entity);
try {
variantSetAnalysisRepository.delete(entity);
variantSetAnalysisRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new BrAPIServerException(HttpStatus.CONFLICT, "Analysis cannot be deleted");
}
return deleted;
}
private void assertVariantSetExists(String variantSetDbId) throws BrAPIServerException {
variantSetService.getVariantSetEntity(variantSetDbId);
}
private VariantSetAnalysisEntity getAnalysisEntity(String variantSetDbId, String analysisDbId)
throws BrAPIServerException {
VariantSetAnalysisEntity entity = variantSetAnalysisRepository.findById(analysisDbId).orElseThrow(
() -> new BrAPIServerDbIdNotFoundException("analysis", analysisDbId, HttpStatus.NOT_FOUND));
if (entity.getVariantSet() == null || !variantSetDbId.equals(entity.getVariantSet().getId())) {
throw new BrAPIServerDbIdNotFoundException("analysis", analysisDbId, HttpStatus.NOT_FOUND);
}
return entity;
}
private void updateEntity(VariantSetAnalysisEntity entity, VariantSetEntity variantSet,
VariantSetAnalysisWriteRequest request) throws BrAPIServerException {
entity.setVariantSet(variantSet);
entity.setAnalysisName(request.getAnalysisName().trim());
entity.setDescription(trimToNull(request.getDescription()));
entity.setType(trimToNull(request.getType()));
entity.setCreated(DateUtility.toDate(request.getCreated()));
entity.setUpdated(DateUtility.toDate(request.getUpdated()));
entity.setSoftware(normalizeSoftware(request.getSoftware()));
}
private List<String> normalizeSoftware(List<String> software) {
if (software == null) {
return new ArrayList<>();
}
return software.stream()
.map(this::trimToNull)
.filter(item -> item != null && !item.isBlank())
.collect(Collectors.toList());
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private Analysis convertFromEntity(VariantSetAnalysisEntity entity) {
Analysis analysis = new Analysis();
analysis.setAnalysisDbId(entity.getId());
analysis.setAnalysisName(entity.getAnalysisName());
analysis.setCreated(DateUtility.toOffsetDateTime(entity.getCreated()));
analysis.setDescription(entity.getDescription());
analysis.setSoftware(entity.getSoftware());
analysis.setType(entity.getType());
analysis.setUpdated(DateUtility.toOffsetDateTime(entity.getUpdated()));
return analysis;
}
}

View File

@@ -0,0 +1,148 @@
package org.brapi.test.BrAPITestServer.service.geno;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.List;
import java.util.stream.Collectors;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException;
import org.brapi.test.BrAPITestServer.model.dto.geno.VariantSetAvailableFormatRecord;
import org.brapi.test.BrAPITestServer.model.dto.geno.VariantSetAvailableFormatWriteRequest;
import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetAvailableFormatEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetEntity;
import org.brapi.test.BrAPITestServer.repository.geno.VariantSetAvailableFormatRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import io.swagger.model.geno.GenoFileDataFormatEnum;
@Service
public class VariantSetAvailableFormatService {
private final VariantSetAvailableFormatRepository variantSetAvailableFormatRepository;
private final VariantSetService variantSetService;
public VariantSetAvailableFormatService(
VariantSetAvailableFormatRepository variantSetAvailableFormatRepository,
VariantSetService variantSetService) {
this.variantSetAvailableFormatRepository = variantSetAvailableFormatRepository;
this.variantSetService = variantSetService;
}
public List<VariantSetAvailableFormatRecord> findFormatsByVariantSet(String variantSetDbId)
throws BrAPIServerException {
variantSetService.getVariantSetEntity(variantSetDbId);
return variantSetAvailableFormatRepository.findByVariantSet_Id(variantSetDbId).stream()
.map(this::convertFromEntity)
.collect(Collectors.toList());
}
public VariantSetAvailableFormatRecord saveFormat(String variantSetDbId,
VariantSetAvailableFormatWriteRequest request) throws BrAPIServerException {
VariantSetEntity variantSet = variantSetService.getVariantSetEntity(variantSetDbId);
validateFormatRequest(request);
if (request.getFormatDbId() != null
&& variantSetAvailableFormatRepository.findById(request.getFormatDbId()).isPresent()) {
throw new BrAPIServerException(HttpStatus.CONFLICT,
"Available format already exists: " + request.getFormatDbId());
}
VariantSetAvailableFormatEntity entity = new VariantSetAvailableFormatEntity();
if (request.getFormatDbId() != null && !request.getFormatDbId().isBlank()) {
entity.setId(request.getFormatDbId().trim());
}
updateEntity(entity, variantSet, request);
return convertFromEntity(variantSetAvailableFormatRepository.save(entity));
}
public VariantSetAvailableFormatRecord updateFormat(String variantSetDbId, String formatDbId,
VariantSetAvailableFormatWriteRequest request) throws BrAPIServerException {
VariantSetAvailableFormatEntity entity = getFormatEntity(variantSetDbId, formatDbId);
validateFormatRequest(request);
updateEntity(entity, entity.getVariantSet(), request);
return convertFromEntity(variantSetAvailableFormatRepository.save(entity));
}
public VariantSetAvailableFormatRecord deleteFormat(String variantSetDbId, String formatDbId)
throws BrAPIServerException {
VariantSetAvailableFormatEntity entity = getFormatEntity(variantSetDbId, formatDbId);
VariantSetAvailableFormatRecord deleted = convertFromEntity(entity);
try {
variantSetAvailableFormatRepository.delete(entity);
variantSetAvailableFormatRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new BrAPIServerException(HttpStatus.CONFLICT, "Available format cannot be deleted");
}
return deleted;
}
private VariantSetAvailableFormatEntity getFormatEntity(String variantSetDbId, String formatDbId)
throws BrAPIServerException {
VariantSetAvailableFormatEntity entity = variantSetAvailableFormatRepository.findById(formatDbId).orElseThrow(
() -> new BrAPIServerDbIdNotFoundException("availableFormat", formatDbId, HttpStatus.NOT_FOUND));
if (entity.getVariantSet() == null || !variantSetDbId.equals(entity.getVariantSet().getId())) {
throw new BrAPIServerDbIdNotFoundException("availableFormat", formatDbId, HttpStatus.NOT_FOUND);
}
return entity;
}
private void validateFormatRequest(VariantSetAvailableFormatWriteRequest request) throws BrAPIServerException {
if (request.getDataFormat() == null || request.getDataFormat().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "dataFormat is required");
}
if (request.getFileFormat() == null || request.getFileFormat().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "fileFormat is required");
}
if (GenoFileDataFormatEnum.fromValue(request.getDataFormat()) == null) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "Invalid dataFormat: " + request.getDataFormat());
}
if (request.getFileURL() != null && !request.getFileURL().isBlank()) {
try {
new URL(request.getFileURL().trim());
} catch (MalformedURLException e) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "Invalid fileURL: " + request.getFileURL());
}
}
}
private void updateEntity(VariantSetAvailableFormatEntity entity, VariantSetEntity variantSet,
VariantSetAvailableFormatWriteRequest request) {
entity.setVariantSet(variantSet);
entity.setDataFormat(GenoFileDataFormatEnum.fromValue(request.getDataFormat()));
entity.setFileFormat(io.swagger.model.WSMIMEDataTypes.fromValue(request.getFileFormat()));
entity.setFileURL(trimToNull(request.getFileURL()));
entity.setExpandHomozygotes(request.getExpandHomozygotes());
entity.setSepPhased(trimToNull(request.getSepPhased()));
entity.setSepUnphased(trimToNull(request.getSepUnphased()));
entity.setUnknownString(trimToNull(request.getUnknownString()));
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private VariantSetAvailableFormatRecord convertFromEntity(VariantSetAvailableFormatEntity entity) {
VariantSetAvailableFormatRecord record = new VariantSetAvailableFormatRecord();
record.setFormatDbId(entity.getId());
if (entity.getVariantSet() != null) {
record.setVariantSetDbId(entity.getVariantSet().getId());
}
if (entity.getDataFormat() != null) {
record.setDataFormat(entity.getDataFormat().toString());
}
if (entity.getFileFormat() != null) {
record.setFileFormat(entity.getFileFormat().toString());
}
record.setFileURL(entity.getFileURL());
record.setExpandHomozygotes(entity.getExpandHomozygotes());
record.setSepPhased(entity.getSepPhased());
record.setSepUnphased(entity.getSepUnphased());
record.setUnknownString(entity.getUnknownString());
return record;
}
}