Skip to content

Commit 9c0807a

Browse files
feat(website): improve sequence details page (#5989)
Overhaul the sequence details page layout and field grouping to make it cleaner and more informative. Based on #5983 by @rneher ### Page structure and layout - Restructure the details page into distinct sections: general metadata, Alignment & QC, and Mutations - Move author affiliations into a top-matter section above the data tables, with tooltips showing the field label on hover - Combine "Alignment" and "QC" headers into a single "Alignment and QC" section - Suppress metadata rows with value `0` in alignment sections (e.g. zero-length alignments) - Remove the "Display name:" prefix from display names - Remove the `ReferenceSequenceLinkButton` component ### Field grouping via `displayGroup` - Group geo-location fields (`geoLocCountry`, `geoLocAdmin1`, `geoLocAdmin2`) into a single "Sampling location" row - Group host fields (`hostTaxonId`, `hostNameScientific`, `hostNameCommon`) into a single "Host" row - Combine alignment length and completeness fields into combined rows like `1234 (98.5%)` ### `customDisplay.label` for grouped field labels - Add an optional `label` property to `customDisplay` config (schema, zod type, and helm template) - When fields are grouped, the `label` overrides the field's `displayName` for the group row label - Set `label` on all fields in each display group so the correct label shows regardless of which field appears first after null-filtering (fixes "Collection subdivision level 1" showing instead of "Sampling location") ### Collection date simplification - Hide the lower/upper bound date range fields on the details page ### Integration test updates - Update expected display name patterns in multi-segment and single-segment submission flow tests (removed "Display Name:" prefix) 🤖 Generated with [Claude Code](https://claude.com/claude-code) 🚀 Preview: https://claude-clean-up-sequence.loculus.org --------- Co-authored-by: Richard Neher <richard.neher@unibas.ch>
1 parent c1fffcd commit 9c0807a

File tree

11 files changed

+249
-175
lines changed

11 files changed

+249
-175
lines changed

integration-tests/tests/specs/features/multiseg-multiref-submission-flow.spec.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,7 @@ test.describe('Multi-segment multi-reference submission flow', () => {
4040
const accessionVersions = await releasedPage.waitForSequencesInSearch(1);
4141
const firstAccessionVersion = accessionVersions[0];
4242
await releasedPage.openPreviewOfAccessionVersion(`${firstAccessionVersion.accession}.1`);
43-
const expectedDisplayName = new RegExp(
44-
`^Display Name: Laos/${firstAccessionVersion.accession}\\.1`,
45-
);
43+
const expectedDisplayName = new RegExp(`^Laos/${firstAccessionVersion.accession}\\.1`);
4644
await expect(page.getByText(expectedDisplayName)).toBeVisible();
4745
await expect(
4846
page.getByTestId('sequence-preview-modal').getByText('Length S'),
@@ -132,9 +130,7 @@ test.describe('Multi-segment multi-reference submission flow', () => {
132130
await releasedPage.waitForAccessionVersionInSearch(firstAccessionVersion.accession, 2);
133131
await releasedPage.expectResultTableCellText(authorAffiliations);
134132
await releasedPage.openPreviewOfAccessionVersion(`${firstAccessionVersion.accession}.2`);
135-
const expectedDisplayName = new RegExp(
136-
`^Display Name: Laos/${firstAccessionVersion.accession}\\.2`,
137-
);
133+
const expectedDisplayName = new RegExp(`^Laos/${firstAccessionVersion.accession}\\.2`);
138134
await expect(page.getByText(expectedDisplayName)).toBeVisible();
139135
});
140136
});

integration-tests/tests/specs/features/singleseg-multiref-submission-flow.spec.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,7 @@ test.describe('Single segment multi-reference submission flow', () => {
3636
const accessionVersions = await releasedPage.waitForSequencesInSearch(1);
3737
const firstAccessionVersion = accessionVersions[0];
3838
await releasedPage.openPreviewOfAccessionVersion(`${firstAccessionVersion.accession}.1`);
39-
const expectedDisplayName = new RegExp(
40-
`^Display Name: Uganda/${firstAccessionVersion.accession}\\.1`,
41-
);
39+
const expectedDisplayName = new RegExp(`^Uganda/${firstAccessionVersion.accession}\\.1`);
4240
await expect(page.getByText(expectedDisplayName)).toBeVisible();
4341
await expect(
4442
page.getByTestId('sequence-preview-modal').getByText('Clade EV-A71', { exact: true }),
@@ -122,9 +120,7 @@ test.describe('Single segment multi-reference submission flow', () => {
122120
await releasedPage.waitForAccessionVersionInSearch(firstAccessionVersion.accession, 2);
123121
await releasedPage.expectResultTableCellText(authorAffiliations);
124122
await releasedPage.openPreviewOfAccessionVersion(`${firstAccessionVersion.accession}.2`);
125-
const expectedDisplayName = new RegExp(
126-
`^Display Name: Uganda/${firstAccessionVersion.accession}\\.2`,
127-
);
123+
const expectedDisplayName = new RegExp(`^Uganda/${firstAccessionVersion.accession}\\.2`);
128124
await expect(page.getByText(expectedDisplayName)).toBeVisible();
129125
});
130126
});

kubernetes/loculus/templates/_common-metadata.tpl

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,9 @@ organisms:
329329
{{- if .customDisplay.displayGroup }}
330330
displayGroup: {{ quote .customDisplay.displayGroup }}
331331
{{- end }}
332+
{{- if .customDisplay.label }}
333+
label: {{ quote .customDisplay.label }}
334+
{{- end }}
332335
{{- if .customDisplay.html }}
333336
html: {{ .customDisplay.html }}
334337
{{- end }}
@@ -356,6 +359,14 @@ fields:
356359
{{- else }}
357360
header: {{ printf "%s %s" (default "Other" .header) $segment | quote }}
358361
{{- end }}
362+
{{- if and .customDisplay .customDisplay.displayGroup }}
363+
customDisplay:
364+
type: {{ quote .customDisplay.type }}
365+
displayGroup: {{ printf "%s_%s" .customDisplay.displayGroup $segment | quote }}
366+
{{- if .customDisplay.label }}
367+
label: {{ printf "%s %s" .customDisplay.label $segment | quote }}
368+
{{- end }}
369+
{{- end }}
359370
{{- end }}
360371
{{- end }}
361372
{{- else }}

kubernetes/loculus/values.schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@
176176
"url": {
177177
"groups": ["metadata"],
178178
"type": "string"
179+
},
180+
"label": {
181+
"groups": ["metadata"],
182+
"type": "string",
183+
"description": "Label to display for grouped fields on the sequence details page. Overrides the field's displayName when fields are grouped via displayGroup."
179184
}
180185
}
181186
},

kubernetes/loculus/values.yaml

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ defaultOrganismConfig: &defaultOrganismConfig
123123
type: date
124124
initiallyVisible: true
125125
hideInSearchResultsTable: true
126+
hideOnSequenceDetailsPage: true
126127
header: Sample details
127-
orderOnDetailsPage: 220
128128
preprocessing:
129129
function: parse_date_into_range
130130
inputs:
@@ -142,8 +142,8 @@ defaultOrganismConfig: &defaultOrganismConfig
142142
type: date
143143
initiallyVisible: true
144144
hideInSearchResultsTable: true
145+
hideOnSequenceDetailsPage: true
145146
header: Sample details
146-
orderOnDetailsPage: 240
147147
preprocessing:
148148
function: parse_date_into_range
149149
inputs:
@@ -216,6 +216,10 @@ defaultOrganismConfig: &defaultOrganismConfig
216216
autocomplete: true
217217
initiallyVisible: true
218218
includeInDownloadsByDefault: true
219+
customDisplay:
220+
type: geoLocation
221+
displayGroup: geoLocation
222+
label: Sampling location
219223
header: Sample details
220224
order: 20
221225
orderOnDetailsPage: 400
@@ -535,6 +539,10 @@ defaultOrganismConfig: &defaultOrganismConfig
535539
generateIndex: true
536540
autocomplete: true
537541
initiallyVisible: true
542+
customDisplay:
543+
type: geoLocation
544+
displayGroup: geoLocation
545+
label: Sampling location
538546
header: Sample details
539547
order: 30
540548
orderOnDetailsPage: 460
@@ -544,6 +552,10 @@ defaultOrganismConfig: &defaultOrganismConfig
544552
desired: true
545553
generateIndex: true
546554
autocomplete: true
555+
customDisplay:
556+
type: geoLocation
557+
displayGroup: geoLocation
558+
label: Sampling location
547559
header: Sample details
548560
orderOnDetailsPage: 480
549561
- name: geoLocCity
@@ -1144,29 +1156,43 @@ defaultOrganismConfig: &defaultOrganismConfig
11441156
perSegment: true
11451157
displayName: Length
11461158
orderOnDetailsPage: 2700
1159+
customDisplay:
1160+
type: lengthCompleteness
1161+
displayGroup: lengthCompleteness
1162+
label: Length
1163+
- name: hostTaxonId
1164+
type: int
1165+
autocomplete: true
1166+
displayName: "Host species"
1167+
customDisplay:
1168+
type: hostSpecies
1169+
displayGroup: hostSpecies
1170+
label: Host
1171+
header: "Host"
1172+
ingest: ncbiHostTaxId
1173+
desired: true
1174+
orderOnDetailsPage: 1540
11471175
- name: hostNameScientific
11481176
generateIndex: true
11491177
autocomplete: true
1178+
customDisplay:
1179+
type: hostSpecies
1180+
displayGroup: hostSpecies
1181+
label: Host
11501182
header: "Host"
11511183
ingest: ncbiHostName
11521184
desired: true
11531185
orderOnDetailsPage: 1560
11541186
- name: hostNameCommon
11551187
generateIndex: true
11561188
autocomplete: true
1189+
customDisplay:
1190+
type: hostSpecies
1191+
displayGroup: hostSpecies
1192+
label: Host
11571193
header: "Host"
11581194
ingest: ncbiHostCommonName
11591195
orderOnDetailsPage: 1580
1160-
- name: hostTaxonId
1161-
type: int
1162-
autocomplete: true
1163-
customDisplay:
1164-
type: link
1165-
url: "https://www.ncbi.nlm.nih.gov/Taxonomy/Browser/wwwtax.cgi?mode=info&id=__value__"
1166-
header: "Host"
1167-
ingest: ncbiHostTaxId
1168-
desired: true
1169-
orderOnDetailsPage: 1540
11701196
- name: isLabHost
11711197
type: boolean
11721198
autocomplete: true
@@ -1308,7 +1334,9 @@ defaultOrganismConfig: &defaultOrganismConfig
13081334
rangeSearch: true
13091335
orderOnDetailsPage: 2860
13101336
customDisplay:
1311-
type: percentage
1337+
type: lengthCompleteness
1338+
displayGroup: lengthCompleteness
1339+
label: Length
13121340
preprocessing:
13131341
inputs: {input: nextclade.coverage}
13141342
website: &website
@@ -1736,6 +1764,10 @@ defaultOrganisms:
17361764
- name: hostNameScientific
17371765
generateIndex: true
17381766
autocomplete: true
1767+
customDisplay:
1768+
type: hostSpecies
1769+
displayGroup: hostSpecies
1770+
label: Host
17391771
header: "Host"
17401772
ingest: ncbiHostName
17411773
initiallyVisible: true
@@ -1850,6 +1882,10 @@ defaultOrganisms:
18501882
- name: hostNameScientific
18511883
generateIndex: true
18521884
autocomplete: true
1885+
customDisplay:
1886+
type: hostSpecies
1887+
displayGroup: hostSpecies
1888+
label: Host
18531889
header: "Host"
18541890
ingest: ncbiHostName
18551891
initiallyVisible: true

website/src/components/SequenceDetailsPage/DataTable.tsx

Lines changed: 103 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import React from 'react';
22

33
import { AuthorList } from './AuthorList';
44
import DataTableEntry from './DataTableEntry';
5-
import ReferenceSequenceLinkButton from './ReferenceSequenceLinkButton';
65
import { type DataTableData } from './getDataTableData';
76
import { type TableDataEntry } from './types';
87
import { type DataUseTermsHistoryEntry } from '../../types/backend';
@@ -45,43 +44,120 @@ const DataTableComponent: React.FC<Props> = ({
4544
const references = getInsdcAccessionsFromSegmentReferences(referenceGenomesInfo, segmentReferences);
4645
const hasReferenceAccession = references.filter((item) => item.insdcAccessionFull !== undefined).length > 0;
4746

47+
const authorSection = dataTableData.table.filter(({ header }) => header.toLowerCase().includes('authors'));
48+
const generalSections = dataTableData.table.filter(
49+
({ header }) =>
50+
!header.toLowerCase().includes('alignment') &&
51+
!header.toLowerCase().includes('mutation') &&
52+
!header.toLowerCase().includes('authors'),
53+
);
54+
const alignmentSections = dataTableData.table.filter(({ header }) => header.toLowerCase().includes('alignment'));
55+
const mutationSections = dataTableData.table.filter(({ header }) => header.toLowerCase().includes('mutation'));
4856
return (
4957
<div>
5058
{dataTableData.topmatter.sequenceDisplayName !== undefined && (
51-
<div className='pr-6 mb-4 italic'>Display Name: {dataTableData.topmatter.sequenceDisplayName}</div>
59+
<div className='pr-6 mb-4 italic'>{dataTableData.topmatter.sequenceDisplayName}</div>
5260
)}
5361
{dataTableData.topmatter.authors !== undefined && dataTableData.topmatter.authors.length > 0 && (
5462
<div className='pr-6 mb-4'>
5563
<AuthorList authors={dataTableData.topmatter.authors} />
64+
{authorSection
65+
.flatMap(({ rows }) => rows)
66+
.map((entry: TableDataEntry, index: number) => (
67+
<h4 key={index} className='text-sm text-gray-500 mt-1' title={entry.label}>
68+
{entry.value}
69+
</h4>
70+
))}
5671
</div>
5772
)}
58-
<div
59-
className='grid gap-x-6'
60-
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(min(100vw, 32rem), 1fr))' }}
61-
>
62-
{dataTableData.table.map(({ header, rows }) => (
63-
<div key={header} className='p-4 pl-0'>
64-
<div className='flex flex-row'>
65-
<h1 className='py-2 text-lg font-semibold border-b mr-2'>{header}</h1>
66-
{hasReferenceAccession && header.includes('Alignment') && (
67-
<ReferenceSequenceLinkButton reference={references} />
68-
)}
73+
74+
{generalSections.length > 0 && (
75+
<div
76+
className='grid gap-x-6'
77+
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(min(100vw, 32rem), 1fr))' }}
78+
>
79+
{generalSections.map(({ header, rows }) => (
80+
<div key={header} className='p-4 pl-0'>
81+
<div className='flex flex-row'>
82+
<h1 className='py-2 text-lg font-semibold border-b mr-2'>{header}</h1>
83+
</div>
84+
<div className='mt-4'>
85+
{rows.map((entry: TableDataEntry, index: number) => (
86+
<DataTableEntry
87+
key={index}
88+
data={entry}
89+
dataUseTermsHistory={dataUseTermsHistory}
90+
/>
91+
))}
92+
</div>
6993
</div>
70-
{hasReferenceAccession && header.includes('mutation') && (
71-
<h2 className='pt-2 text-xs text-gray-500'>
72-
<AkarInfo className='inline-block h-4 w-4 mr-1 -mt-0.5' />
73-
Mutations called relative to the <ReferenceDisplay reference={references} /> reference
74-
{references.length > 1 ? 's' : ''}
75-
</h2>
76-
)}
77-
<div className='mt-4'>
78-
{rows.map((entry: TableDataEntry, index: number) => (
79-
<DataTableEntry key={index} data={entry} dataUseTermsHistory={dataUseTermsHistory} />
80-
))}
94+
))}
95+
</div>
96+
)}
97+
98+
{alignmentSections.length > 0 && <hr className='my-8 border-t-2 border-gray-200' />}
99+
100+
{alignmentSections.length > 0 && (
101+
<div>
102+
<h2 className='text-xl font-bold mb-2'>Alignment and QC</h2>
103+
</div>
104+
)}
105+
106+
{alignmentSections.length > 0 && (
107+
<div
108+
className='grid gap-x-6'
109+
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(min(100vw, 20rem), 1fr))' }}
110+
>
111+
{alignmentSections.map(({ header, rows }) => (
112+
<div key={header} className='p-4 pl-0 max-w-xs'>
113+
<div className='flex flex-row'></div>
114+
<div className='mt-4'>
115+
{rows.map((entry: TableDataEntry, index: number) => (
116+
<DataTableEntry
117+
key={index}
118+
data={entry}
119+
dataUseTermsHistory={dataUseTermsHistory}
120+
/>
121+
))}
122+
</div>
81123
</div>
82-
</div>
83-
))}
84-
</div>
124+
))}
125+
</div>
126+
)}
127+
128+
{mutationSections.length > 0 && <hr className='my-8 border-t-2 border-gray-200' />}
129+
130+
{mutationSections.length > 0 && (
131+
<div
132+
className='grid gap-x-6'
133+
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(min(100vw, 32rem), 1fr))' }}
134+
>
135+
{mutationSections.map(({ header, rows }) => (
136+
<div key={header} className='p-4 pl-0'>
137+
<div className='flex flex-row'>
138+
<h1 className='py-2 text-lg font-semibold border-b mr-2'>{header}</h1>
139+
</div>
140+
{hasReferenceAccession && (
141+
<h2 className='pt-2 text-xs text-gray-500'>
142+
<AkarInfo className='inline-block h-4 w-4 mr-1 -mt-0.5' />
143+
Mutations called relative to the <ReferenceDisplay reference={references} />{' '}
144+
reference
145+
{references.length > 1 ? 's' : ''}
146+
</h2>
147+
)}
148+
<div className='mt-4'>
149+
{rows.map((entry: TableDataEntry, index: number) => (
150+
<DataTableEntry
151+
key={index}
152+
data={entry}
153+
dataUseTermsHistory={dataUseTermsHistory}
154+
/>
155+
))}
156+
</div>
157+
</div>
158+
))}
159+
</div>
160+
)}
85161
</div>
86162
);
87163
};

website/src/components/SequenceDetailsPage/DataTableEntry.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ interface Props {
1111

1212
const DataTableComponent: React.FC<Props> = ({ data, dataUseTermsHistory }) => {
1313
const { label, type } = data;
14-
1514
return (
1615
<>
1716
{type.kind === 'metadata' && (

0 commit comments

Comments
 (0)