fix:第三章开发结束
This commit is contained in:
11
AGENTS.md
11
AGENTS.md
@@ -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` 等索引/说明类文档无需标注。
|
||||||
@@ -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 范围。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**状态:已完成**
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**状态:已完成**
|
||||||
|
|||||||
@@ -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 主数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**状态:已完成**
|
||||||
|
|||||||
@@ -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 格式提示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**状态:已完成**
|
||||||
|
|||||||
@@ -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` 会影响解析,应在导入预览时展示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**状态:已完成**
|
||||||
|
|||||||
318
frontend/src/app/(app)/genotyping/call-set/api.ts
Normal file
318
frontend/src/app/(app)/genotyping/call-set/api.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/src/app/(app)/genotyping/call-set/page.tsx
Normal file
56
frontend/src/app/(app)/genotyping/call-set/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
frontend/src/app/(app)/genotyping/call-set/types.ts
Normal file
57
frontend/src/app/(app)/genotyping/call-set/types.ts
Normal 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;
|
||||||
|
}
|
||||||
568
frontend/src/app/(app)/genotyping/genome-map/api.ts
Normal file
568
frontend/src/app/(app)/genotyping/genome-map/api.ts
Normal 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" });
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
frontend/src/app/(app)/genotyping/genome-map/page.tsx
Normal file
71
frontend/src/app/(app)/genotyping/genome-map/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
frontend/src/app/(app)/genotyping/genome-map/types.ts
Normal file
84
frontend/src/app/(app)/genotyping/genome-map/types.ts
Normal 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[];
|
||||||
|
};
|
||||||
@@ -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([
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,9 +108,28 @@ 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>
|
||||||
|
|
||||||
|
<Tabs defaultValue="overview" className="space-y-4">
|
||||||
|
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
|
||||||
|
<TabsTrigger value="overview" className="gap-2">
|
||||||
|
<Sigma className="h-4 w-4" />
|
||||||
|
概览
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="analysis" className="gap-2">
|
||||||
|
<FlaskConical className="h-4 w-4" />
|
||||||
|
Analysis
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="formats" className="gap-2">
|
||||||
|
<FileDown className="h-4 w-4" />
|
||||||
|
Formats
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="space-y-4">
|
||||||
<Card>
|
<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">
|
||||||
@@ -190,44 +212,16 @@ export default function VariantSetDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
|
||||||
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
|
|
||||||
<TabsTrigger value="variants" className="gap-2"><Sigma className="h-4 w-4" />Variants</TabsTrigger>
|
|
||||||
<TabsTrigger value="calls" className="gap-2"><Binary className="h-4 w-4" />Calls</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{tab === "variants" ? (
|
|
||||||
<TabsContent value="variants" className="mt-0 min-h-0 flex-1">
|
|
||||||
<Suspense fallback={<VariantTabFallback />}>
|
<Suspense fallback={<VariantTabFallback />}>
|
||||||
<VariantTab />
|
<VariantTab />
|
||||||
</Suspense>
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
|
||||||
<MapPin className="h-4 w-4 text-emerald-500" />
|
|
||||||
Marker Position
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-sm text-slate-600 dark:text-slate-300">
|
|
||||||
<p>
|
|
||||||
Marker Position 与遗传图谱 linkage group 关联,可在后续 Marker / GenomeMap 模块中维护。
|
|
||||||
当前 Variant 详情已展示 allele_call 引用数量,便于删除前校验。
|
|
||||||
</p>
|
|
||||||
{detail.variant_set_id ? (
|
|
||||||
<Button asChild variant="link" className="mt-2 h-auto px-0 text-violet-600 dark:text-violet-400">
|
|
||||||
<Link href={`/genotyping/variant-set/variant-sets/${encodeURIComponent(detail.variant_set_id)}`}>
|
|
||||||
查看所属 VariantSet
|
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<VariantMarkerPositionCard variantDbId={detail.id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [] },
|
||||||
] }
|
] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {
|
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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user