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

@@ -3,3 +3,14 @@
3.下拉框的数据要缓存下来,同一选项源不要重复请求接口。 3.下拉框的数据要缓存下来,同一选项源不要重复请求接口。
4.前端首页(及同类入口页)加载首页数据时,副作用只触发一次查询,避免 Strict Mode 或重复 mount 导致多次请求。 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` 必须存在。 1. `sample_id` 必须存在。
2. 删除 callset 前检查 `allele_call``callset_variant_sets` 2. 删除 callset 前检查 `allele_call``callset_variant_sets`
3. 如果 callset 绑定多个 variantset查询和导出时要明确当前 variantset 范围。 3. 如果 callset 绑定多个 variantset查询和导出时要明确当前 variantset 范围。
---
**状态:已完成**

View File

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

View File

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

View File

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

View File

@@ -40,3 +40,7 @@
1. `variant_set_id` 必须存在。 1. `variant_set_id` 必须存在。
2. `fileurl` 如填写需通过 URL 格式校验。 2. `fileurl` 如填写需通过 URL 格式校验。
3. 对矩阵格式,`sep_phased/sep_unphased/unknown_string` 会影响解析,应在导入预览时展示。 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 { import {
NONE_SELECT_VALUE, NONE_SELECT_VALUE,
type SelectOption, type SelectOption,
type VariantSetAnalysisItem,
type VariantSetDetail, type VariantSetDetail,
type VariantSetFormatItem,
type VariantSetQuery, type VariantSetQuery,
type VariantSetRecord, type VariantSetRecord,
} from "./types"; } from "./types";
@@ -273,6 +275,184 @@ export async function deleteVariantSetRow(id: string): Promise<void> {
invalidateVariantSetPageCache(); 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 }) { export async function loadVariantSetPageData(options?: { query?: VariantSetQuery; force?: boolean }) {
const force = options?.force ?? false; const force = options?.force ?? false;
const [referenceSets, studies, variantSets] = await Promise.all([ 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; 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 { export interface VariantSetAnalysisItem {
id?: string;
analysisDbId?: string; analysisDbId?: string;
analysisName?: string | null; analysisName?: string | null;
type?: string | null; type?: string | null;
software?: string | null; description?: string | null;
software?: string[] | string | null;
created?: string | null;
updated?: string | null;
} }
export interface VariantSetFormatItem { export interface VariantSetFormatItem {
id?: string;
formatDbId?: string;
variant_set_id?: string;
variantSetDbId?: string;
dataFormat?: string | null; dataFormat?: string | null;
data_format?: string | null;
fileFormat?: string | null; fileFormat?: string | null;
file_format?: string | null;
fileURL?: 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 { export interface VariantSetRecord {

View File

@@ -3,12 +3,15 @@
import Link from "next/link"; import Link from "next/link";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation"; 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; 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 { import {
fetchVariantSetCallsets, fetchVariantSetCallsets,
fetchVariantSetDetail, 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">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">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">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> </CardContent>
</Card> </Card>
<Card> <Tabs defaultValue="overview" className="space-y-4">
<CardHeader className="pb-3"> <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">
<CardTitle className="flex items-center gap-2 text-base"> <TabsTrigger value="overview" className="gap-2">
<Sigma className="h-4 w-4 text-rose-500" /> <Sigma className="h-4 w-4" />
Variants
</CardTitle> </TabsTrigger>
</CardHeader> <TabsTrigger value="analysis" className="gap-2">
<CardContent> <FlaskConical className="h-4 w-4" />
{variants.length === 0 ? ( Analysis
<p className="text-sm text-slate-500"> Variant </p> </TabsTrigger>
) : ( <TabsTrigger value="formats" className="gap-2">
<Table> <FileDown className="h-4 w-4" />
<TableHeader> Formats
<TableRow> </TabsTrigger>
<TableHead>Variant ID</TableHead> </TabsList>
<TableHead></TableHead>
<TableHead></TableHead> <TabsContent value="overview" className="space-y-4">
<TableHead></TableHead> <Card>
<TableHead></TableHead> <CardHeader className="pb-3">
</TableRow> <CardTitle className="flex items-center gap-2 text-base">
</TableHeader> <Sigma className="h-4 w-4 text-rose-500" />
<TableBody> Variants
{variants.slice(0, 20).map((row) => { </CardTitle>
const id = String(row.variantDbId ?? row.id ?? ""); </CardHeader>
const names = Array.isArray(row.variantNames) ? row.variantNames.join(", ") : String(row.variantName ?? "—"); <CardContent>
return ( {variants.length === 0 ? (
<TableRow key={id}> <p className="text-sm text-slate-500"> Variant </p>
<TableCell> ) : (
{id ? ( <Table>
<Link href={`/genotyping/variant/variants/${encodeURIComponent(id)}`} className="text-rose-600 hover:underline dark:text-rose-400"> <TableHeader>
{id} <TableRow>
</Link> <TableHead>Variant ID</TableHead>
) : "—"} <TableHead></TableHead>
</TableCell> <TableHead></TableHead>
<TableCell>{names}</TableCell> <TableHead></TableHead>
<TableCell>{String(row.variantType ?? "—")}</TableCell> <TableHead></TableHead>
<TableCell>{String(row.start ?? "—")}</TableCell>
<TableCell>{String(row.end ?? "—")}</TableCell>
</TableRow> </TableRow>
); </TableHeader>
})} <TableBody>
</TableBody> {variants.slice(0, 20).map((row) => {
</Table> const id = String(row.variantDbId ?? row.id ?? "");
)} const names = Array.isArray(row.variantNames) ? row.variantNames.join(", ") : String(row.variantName ?? "—");
{variants.length > 20 ? ( return (
<p className="mt-2 text-xs text-slate-500"> 20 Variant </p> <TableRow key={id}>
) : null} <TableCell>
</CardContent> {id ? (
</Card> <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> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Binary className="h-4 w-4 text-sky-500" /> <Binary className="h-4 w-4 text-sky-500" />
CallSets CallSets
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{callsets.length === 0 ? ( {callsets.length === 0 ? (
<p className="text-sm text-slate-500"> CallSet</p> <p className="text-sm text-slate-500"> CallSet</p>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>CallSet ID</TableHead> <TableHead>CallSet ID</TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead>Sample</TableHead> <TableHead>Sample</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{callsets.map((row) => ( {callsets.map((row) => (
<TableRow key={String(row.callSetDbId ?? row.id ?? Math.random())}> <TableRow key={String(row.callSetDbId ?? row.id ?? Math.random())}>
<TableCell>{String(row.callSetDbId ?? "—")}</TableCell> <TableCell>{String(row.callSetDbId ?? "—")}</TableCell>
<TableCell>{String(row.callSetName ?? "—")}</TableCell> <TableCell>{String(row.callSetName ?? "—")}</TableCell>
<TableCell>{String(row.sampleName ?? row.sampleDbId ?? "—")}</TableCell> <TableCell>{String(row.sampleName ?? row.sampleDbId ?? "—")}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
<div className="grid gap-4 lg:grid-cols-2"> <TabsContent value="analysis">
<Card> <VariantSetAnalysisPanel variantSetDbId={variantSetDbId} onChanged={loadDetail} />
<CardHeader className="pb-3"> </TabsContent>
<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>
<Card> <TabsContent value="formats">
<CardHeader className="pb-3"> <VariantSetFormatPanel variantSetDbId={variantSetDbId} onChanged={loadDetail} />
<CardTitle className="text-base">Available Formats</CardTitle> </TabsContent>
</CardHeader> </Tabs>
<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>
</div> </div>
); );
} }

View File

@@ -1,8 +1,8 @@
import { createCachedLoader } from "@/services/dropdownCache"; import { createCachedLoader } from "@/services/dropdownCache";
import { DEFAULT_LIST_PAGE, DEFAULT_LIST_PAGE_SIZE } from "@/constants/api";
import { getAuthToken } from "@/utils/token"; import { getAuthToken } from "@/utils/token";
import { import {
NONE_SELECT_VALUE, NONE_SELECT_VALUE,
type CallRecord,
type SelectOption, type SelectOption,
type VariantQuery, type VariantQuery,
type VariantRecord, type VariantRecord,
@@ -46,13 +46,6 @@ interface VariantSetResponse {
referenceSetDbId: string | null; referenceSetDbId: string | null;
} }
interface CallSetResponse {
callSetDbId: string;
callSetName: string | null;
sampleDbId: string | null;
sampleName: string | null;
}
type VariantPayload = Partial<Record< type VariantPayload = Partial<Record<
| "id" | "id"
| "variant_name" | "variant_name"
@@ -68,17 +61,6 @@ type VariantPayload = Partial<Record<
unknown unknown
>>; >>;
type CallPayload = Partial<Record<
| "id"
| "call_set_id"
| "variant_id"
| "genotype_text"
| "genotype_likelihood"
| "read_depth"
| "phase_set",
unknown
>>;
const apiBase = () => { const apiBase = () => {
if (typeof window !== "undefined") return ""; if (typeof window !== "undefined") return "";
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"; 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()); 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 => ({ export const mapVariant = (variant: VariantRecord): VariantRecord => ({
...variant, ...variant,
id: variant.variantDbId || variant.variantId || variant.id, 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, 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) => ({ const variantBody = (payload: VariantPayload) => ({
variantName: requiredText(payload.variant_name, "Variant name is required"), variantName: requiredText(payload.variant_name, "Variant name is required"),
variantType: optionalText(payload.variant_type), variantType: optionalText(payload.variant_type),
@@ -187,15 +146,6 @@ const variantBody = (payload: VariantPayload) => ({
filtersPassed: optionalBoolean(payload.filters_passed), 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) => ({ export const normalizeVariantFormData = (row: VariantRecord) => ({
id: row.id, id: row.id,
variant_name: row.variant_name || "", variant_name: row.variant_name || "",
@@ -220,13 +170,11 @@ const variantSetLoader = createCachedLoader(async () => {
return response.result.data; 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 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); return response.result.data.map(mapVariant);
}); });
@@ -264,8 +212,6 @@ function filterVariantRows(rows: VariantRecord[], query?: VariantQuery): Variant
function buildVariantOptions( function buildVariantOptions(
referenceSets: ReferenceSetResponse[], referenceSets: ReferenceSetResponse[],
variantSets: VariantSetResponse[], variantSets: VariantSetResponse[],
callSets: CallSetResponse[],
variants: VariantRecord[],
) { ) {
return { return {
referenceSets: referenceSets.map((item) => ({ referenceSets: referenceSets.map((item) => ({
@@ -276,38 +222,25 @@ function buildVariantOptions(
value: item.variantSetDbId, value: item.variantSetDbId,
label: `${item.variantSetName || item.variantSetDbId}${item.referenceSetDbId ? ` / ${item.referenceSetDbId}` : ""}`, 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() { export function invalidateVariantPageCache() {
referenceSetLoader.invalidate(); referenceSetLoader.invalidate();
variantSetLoader.invalidate(); variantSetLoader.invalidate();
callSetLoader.invalidate();
variantRowsLoader.invalidate(); variantRowsLoader.invalidate();
} }
export async function fetchVariantOptions(force = false): Promise<{ export async function fetchVariantOptions(force = false): Promise<{
referenceSets: SelectOption[]; referenceSets: SelectOption[];
variantSets: SelectOption[]; variantSets: SelectOption[];
callSets: SelectOption[];
variants: SelectOption[];
}> { }> {
const [referenceSets, variantSets, callSets, variants] = await Promise.all([ const [referenceSets, variantSets] = await Promise.all([
referenceSetLoader.load(force), referenceSetLoader.load(force),
variantSetLoader.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[]> { 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([ const [detail, options, callsResponse] = await Promise.all([
request<BrapiSingleResponse<VariantRecord>>(`/brapi/v2/variants/${encodeURIComponent(variantDbId)}`), request<BrapiSingleResponse<VariantRecord>>(`/brapi/v2/variants/${encodeURIComponent(variantDbId)}`),
fetchVariantOptions(), 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); 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> { export async function createVariantRow(payload: VariantPayload): Promise<VariantRecord> {
const response = await request<BrapiListResponse<VariantRecord>>("/brapi/v2/variants", { const response = await request<BrapiListResponse<VariantRecord>>("/brapi/v2/variants", {
method: "POST", method: "POST",
@@ -374,45 +302,17 @@ export async function deleteVariantRow(id: string): Promise<void> {
invalidateVariantPageCache(); 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 }) { export async function loadVariantPageData(options?: { query?: VariantQuery; force?: boolean }) {
const force = options?.force ?? false; const force = options?.force ?? false;
const [referenceSets, variantSets, callSets, variants] = await Promise.all([ const [referenceSets, variantSets, variants] = await Promise.all([
referenceSetLoader.load(force), referenceSetLoader.load(force),
variantSetLoader.load(force), variantSetLoader.load(force),
callSetLoader.load(force),
variantRowsLoader.load(force), variantRowsLoader.load(force),
]); ]);
const enrichedRows = enrichVariantRows(variants, referenceSets, variantSets); const enrichedRows = enrichVariantRows(variants, referenceSets, variantSets);
return { return {
options: buildVariantOptions(referenceSets, variantSets, callSets, variants), options: buildVariantOptions(referenceSets, variantSets),
rows: filterVariantRows(enrichedRows, options?.query), 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"; "use client";
import { Suspense, useCallback, useMemo, useState } from "react"; import { Suspense } from "react";
import { Binary, Sigma } from "lucide-react"; import { Sigma } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { VariantTab } from "./components/VariantTab"; import { VariantTab } from "./components/VariantTab";
import {
createCallRow,
deleteCallRow,
fetchCallRows,
fetchVariantOptions,
updateCallRow,
} from "./api";
import { NONE_SELECT_VALUE, type SelectOption } from "./types";
function VariantTabFallback() { function VariantTabFallback() {
return <Skeleton className="h-96 w-full rounded-xl" />; return <Skeleton className="h-96 w-full rounded-xl" />;
} }
export default function VariantPage() { 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 ( return (
<Tabs value={tab} onValueChange={setTab} className="flex min-h-full flex-col gap-4"> <Suspense fallback={<VariantTabFallback />}>
<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"> <VariantTab />
<TabsTrigger value="variants" className="gap-2"><Sigma className="h-4 w-4" />Variants</TabsTrigger> </Suspense>
<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>
); );
} }

View File

@@ -3,12 +3,13 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "next/navigation"; 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { fetchVariantDetail } from "../../api"; import { fetchVariantDetail } from "../../api";
import { VariantMarkerPositionCard } from "../../components/VariantMarkerPositionCard";
const boolLabel = (value: boolean | null | undefined) => { const boolLabel = (value: boolean | null | undefined) => {
if (value === true) return "是"; 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_applied)}</div>
<div><span className="text-slate-500"></span>{boolLabel(detail.filters_passed)}</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> <div><span className="text-slate-500">allele_call </span>{detail.allele_call_count ?? 0}</div>
</CardContent> {(detail.allele_call_count ?? 0) > 0 ? (
</Card> <div className="sm:col-span-2">
<Button asChild variant="link" className="h-auto px-0 text-cyan-600 dark:text-cyan-400">
<Card> <Link href={`/genotyping/call-set?tab=allele-calls&variant_id=${encodeURIComponent(detail.id)}`}>
<CardHeader className="pb-3"> allele_call
<CardTitle className="flex items-center gap-2 text-base"> </Link>
<MapPin className="h-4 w-4 text-emerald-500" /> </Button>
Marker Position </div>
</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>
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
<VariantMarkerPositionCard variantDbId={detail.id} />
</div> </div>
); );
} }

View File

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

View File

@@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { import {
BarChart3, BarChart3,
Binary,
BookCheck, BookCheck,
BookOpen, BookOpen,
Briefcase, Briefcase,
@@ -31,6 +32,7 @@ import {
List as ListIcon, List as ListIcon,
PanelTop, PanelTop,
CalendarClock, CalendarClock,
Map,
} from "lucide-react"; } from "lucide-react";
export interface BrapiNavItem { export interface BrapiNavItem {
@@ -143,7 +145,14 @@ export const brapiNavSections: BrapiNavSection[] = [
title: "变异数据", title: "变异数据",
items: [ items: [
{ title: "VariantSet", href: "/genotyping/variant-set", icon: Layers }, { 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 */ /** 标准分页查询串page=0&pageSize=10 */
export const DEFAULT_PAGE_QUERY = `page=${DEFAULT_LIST_PAGE}&pageSize=${DEFAULT_LIST_PAGE_SIZE}`; 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 { export function buildPageQuery(page = DEFAULT_LIST_PAGE, pageSize = DEFAULT_LIST_PAGE_SIZE): string {
return `page=${page}&pageSize=${pageSize}`; 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/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: "变异数据", 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: "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.controller.core.BrAPIController;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException; 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.VariantSetWriteRequest;
import org.brapi.test.BrAPITestServer.model.dto.geno.VariantWriteRequest; import org.brapi.test.BrAPITestServer.model.dto.geno.VariantWriteRequest;
import org.brapi.test.BrAPITestServer.service.geno.VariantService; 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.brapi.test.BrAPITestServer.service.geno.VariantSetService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; 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.RequestMethod;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import io.swagger.model.geno.Analysis;
import io.swagger.model.geno.Variant; import io.swagger.model.geno.Variant;
import io.swagger.model.geno.VariantSet; import io.swagger.model.geno.VariantSet;
import io.swagger.model.geno.VariantSetResponse; 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 static final Logger log = LoggerFactory.getLogger(GenotypingVariantWriteController.class);
private final VariantSetService variantSetService; private final VariantSetService variantSetService;
private final VariantSetAnalysisService variantSetAnalysisService;
private final VariantSetAvailableFormatService variantSetAvailableFormatService;
private final VariantService variantService; private final VariantService variantService;
private final HttpServletRequest request; private final HttpServletRequest request;
@Autowired @Autowired
public GenotypingVariantWriteController(VariantSetService variantSetService, VariantService variantService, public GenotypingVariantWriteController(VariantSetService variantSetService,
VariantSetAnalysisService variantSetAnalysisService,
VariantSetAvailableFormatService variantSetAvailableFormatService, VariantService variantService,
HttpServletRequest request) { HttpServletRequest request) {
this.variantSetService = variantSetService; this.variantSetService = variantSetService;
this.variantSetAnalysisService = variantSetAnalysisService;
this.variantSetAvailableFormatService = variantSetAvailableFormatService;
this.variantService = variantService; this.variantService = variantService;
this.request = request; this.request = request;
} }
@@ -88,6 +102,131 @@ public class GenotypingVariantWriteController extends BrAPIController {
return responseOK(new VariantSetResponse(), data); 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 @CrossOrigin
@RequestMapping(value = "/variants", produces = { "application/json" }, consumes = { @RequestMapping(value = "/variants", produces = { "application/json" }, consumes = {
"application/json" }, method = RequestMethod.POST) "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.model.entity.geno.CallEntity;
import org.brapi.test.BrAPITestServer.repository.BrAPIRepository; 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.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.apache.commons.lang3.math.NumberUtils; 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.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.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.repository.geno.CallRepository;
import org.brapi.test.BrAPITestServer.service.PagingUtility; import org.brapi.test.BrAPITestServer.service.PagingUtility;
import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder; import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder;
import org.brapi.test.BrAPITestServer.service.UpdateUtility; import org.brapi.test.BrAPITestServer.service.UpdateUtility;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -37,9 +46,13 @@ public class CallService {
public static final String UNKNOWN_STRING_DEFAULT = "."; public static final String UNKNOWN_STRING_DEFAULT = ".";
private final CallRepository callRepository; 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.callRepository = callRepository;
this.callSetService = callSetService;
this.variantService = variantService;
} }
public CallsListResponseResult findCalls(String callSetDbId, String variantDbId, String variantSetDbId, public CallsListResponseResult findCalls(String callSetDbId, String variantDbId, String variantSetDbId,
@@ -103,7 +116,14 @@ public class CallService {
private Call convertFromEntityWithFormatting(CallEntity entity, CallsSearchRequest request) { private Call convertFromEntityWithFormatting(CallEntity entity, CallsSearchRequest request) {
Call call = new Call(); 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) { if (entity.getCallSet() != null) {
call.setCallSetDbId(entity.getCallSet().getId()); call.setCallSetDbId(entity.getCallSet().getId());
call.setCallSetName(entity.getCallSet().getCallSetName()); call.setCallSetName(entity.getCallSet().getCallSetName());
@@ -154,6 +174,7 @@ public class CallService {
} }
public CallsListResponseResult updateCalls(List<Call> body) throws BrAPIServerException { public CallsListResponseResult updateCalls(List<Call> body) throws BrAPIServerException {
ensureVariantSetBindings(body);
CallsSearchRequest searchReq = new CallsSearchRequest(); CallsSearchRequest searchReq = new CallsSearchRequest();
Map<String, Call> callsMap = new HashMap<>(); Map<String, Call> callsMap = new HashMap<>();
for (Call call : body) { 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; 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.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException; import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException; 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.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.CallSetRepository;
import org.brapi.test.BrAPITestServer.repository.geno.VariantSetRepository;
import org.brapi.test.BrAPITestServer.service.DateUtility; import org.brapi.test.BrAPITestServer.service.DateUtility;
import org.brapi.test.BrAPITestServer.service.PagingUtility; import org.brapi.test.BrAPITestServer.service.PagingUtility;
import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder; import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -24,9 +33,16 @@ import io.swagger.model.geno.CallSetsSearchRequest;
public class CallSetService { public class CallSetService {
private final CallSetRepository callSetRepository; 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.callSetRepository = callSetRepository;
this.callRepository = callRepository;
this.sampleService = sampleService;
this.variantSetRepository = variantSetRepository;
} }
public List<CallSet> findCallSets(String callSetDbId, String callSetName, String variantSetDbId, String sampleDbId, public List<CallSet> findCallSets(String callSetDbId, String callSetName, String variantSetDbId, String sampleDbId,
@@ -111,4 +127,101 @@ public class CallSetService {
public CallSetEntity save(CallSetEntity entity) { public CallSetEntity save(CallSetEntity entity) {
return callSetRepository.save(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.BrAPIServerDbIdNotFoundException;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException; 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.GenomeMapEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.LinkageGroupEntity; 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.GenomeMapRepository;
import org.brapi.test.BrAPITestServer.repository.geno.LinkageGroupRepository; 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.DateUtility;
import org.brapi.test.BrAPITestServer.service.PagingUtility; import org.brapi.test.BrAPITestServer.service.PagingUtility;
import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder; 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.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -25,13 +32,18 @@ import io.swagger.model.geno.LinkageGroup;
@Service @Service
public class GenomeMapService { public class GenomeMapService {
private GenomeMapRepository genomeMapRepository; private final GenomeMapRepository genomeMapRepository;
private LinkageGroupRepository linkageGroupRepository; private final LinkageGroupRepository linkageGroupRepository;
private final MarkerPositionRepository markerPositionRepository;
private final CropService cropService;
@Autowired @Autowired
public GenomeMapService(GenomeMapRepository genomeMapRepository, LinkageGroupRepository linkageGroupRepository) { public GenomeMapService(GenomeMapRepository genomeMapRepository, LinkageGroupRepository linkageGroupRepository,
MarkerPositionRepository markerPositionRepository, CropService cropService) {
this.genomeMapRepository = genomeMapRepository; this.genomeMapRepository = genomeMapRepository;
this.linkageGroupRepository = linkageGroupRepository; this.linkageGroupRepository = linkageGroupRepository;
this.markerPositionRepository = markerPositionRepository;
this.cropService = cropService;
} }
public List<GenomeMap> findMaps(String commonCropName, String mapPUI, String scientificName, String type, public List<GenomeMap> findMaps(String commonCropName, String mapPUI, String scientificName, String type,
@@ -74,11 +86,183 @@ public class GenomeMapService {
Pageable pageReq = PagingUtility.getPageRequest(metadata); Pageable pageReq = PagingUtility.getPageRequest(metadata);
Page<LinkageGroupEntity> page = linkageGroupRepository.findAllBySearch(searchQuery, pageReq); 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); PagingUtility.calculateMetaData(metadata, page);
return linkageGroups; 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) { private GenomeMap convertFromEntity(GenomeMapEntity entity) {
GenomeMap map = new GenomeMap(); GenomeMap map = new GenomeMap();
map.setAdditionalInfo(entity.getAdditionalInfoMap()); map.setAdditionalInfo(entity.getAdditionalInfoMap());
@@ -102,12 +286,18 @@ public class GenomeMapService {
return map; return map;
} }
private LinkageGroup convertFromEntity(LinkageGroupEntity entity) { private LinkageGroup convertLinkageGroupFromEntity(LinkageGroupEntity entity) {
LinkageGroup group = new LinkageGroup(); LinkageGroup group = new LinkageGroup();
group.setAdditionalInfo(entity.getAdditionalInfoMap()); group.setAdditionalInfo(entity.getAdditionalInfoMap());
group.setLinkageGroupName(entity.getLinkageGroupName()); 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.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; return group;
} }

View File

@@ -2,12 +2,20 @@ package org.brapi.test.BrAPITestServer.service.geno;
import java.util.List; 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.MarkerPositionEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.VariantEntity;
import org.brapi.test.BrAPITestServer.repository.geno.MarkerPositionRepository; import org.brapi.test.BrAPITestServer.repository.geno.MarkerPositionRepository;
import org.brapi.test.BrAPITestServer.service.PagingUtility; import org.brapi.test.BrAPITestServer.service.PagingUtility;
import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder; import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import io.swagger.model.Metadata; import io.swagger.model.Metadata;
@@ -18,9 +26,14 @@ import io.swagger.model.geno.MarkerPosition;
public class MarkerPositionService { public class MarkerPositionService {
private final MarkerPositionRepository markerPositionRepository; 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.markerPositionRepository = markerPositionRepository;
this.genomeMapService = genomeMapService;
this.variantService = variantService;
} }
public List<MarkerPosition> findMarkerPositions(String mapDbId, String linkageGroupName, String variantDbId, public List<MarkerPosition> findMarkerPositions(String mapDbId, String linkageGroupName, String variantDbId,
@@ -52,11 +65,91 @@ public class MarkerPositionService {
return markerPositions; 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) { private MarkerPosition convertFromEntity(MarkerPositionEntity entity) {
MarkerPosition position = new MarkerPosition(); MarkerPosition position = new MarkerPosition();
position.setAdditionalInfo(entity.getAdditionalInfoMap()); position.setAdditionalInfo(entity.getAdditionalInfoMap());
if (entity.getLinkageGroup() != null) { if (entity.getLinkageGroup() != null) {
position.setLinkageGroupName(entity.getLinkageGroup().getLinkageGroupName()); position.setLinkageGroupName(entity.getLinkageGroup().getLinkageGroupName());
position.putAdditionalInfoItem("linkageGroupDbId", entity.getLinkageGroup().getId());
if (entity.getLinkageGroup().getGenomeMap() != null) { if (entity.getLinkageGroup().getGenomeMap() != null) {
position.setMapDbId(entity.getLinkageGroup().getGenomeMap().getId()); position.setMapDbId(entity.getLinkageGroup().getGenomeMap().getId());
position.setMapName(entity.getLinkageGroup().getGenomeMap().getMapName()); position.setMapName(entity.getLinkageGroup().getGenomeMap().getMapName());
@@ -67,6 +160,7 @@ public class MarkerPositionService {
position.setVariantDbId(entity.getVariant().getId()); position.setVariantDbId(entity.getVariant().getId());
position.setVariantName(entity.getVariant().getVariantName()); position.setVariantName(entity.getVariant().getVariantName());
} }
position.putAdditionalInfoItem("markerPositionDbId", entity.getId());
return position; 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;
}
}