Skip to content

Commit 44c39eb

Browse files
feat: Add Data Schema browser page
Adds /content/schema route with: - Collapsible class hierarchy tree sidebar with search/filter - Properties tab showing own attributes, inherited attributes, and referrals - Entries tab with paginated instance list - Instructions section explaining the data model - Mobile-responsive layout - Routes: /content/schema and /content/schema/:className Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 29cde84 commit 44c39eb

5 files changed

Lines changed: 1420 additions & 1 deletion

File tree

projects/website-angular/src/app/app.routes.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ export const routes: Routes = [
3030
{ path: 'content/reactome-research-spotlight', loadComponent: () => import('./article/article-page/article-page.component').then(m => m.ArticlePageComponent), pathMatch: 'full' },
3131
{ path: 'content/reactome-research-spotlight/:slug', loadComponent: () => import('./article/article/article.component').then(m => m.ArticleComponent), pathMatch: 'full' },
3232

33-
//Content Pages (TOC, DOI)
33+
//Content Pages (TOC, DOI, Schema)
3434
{ path: 'content/toc', loadComponent: () => import('./content/toc/toc.component').then(m => m.TocComponent), pathMatch: 'full' },
3535
{ path: 'content/doi', loadComponent: () => import('./content/doi/doi.component').then(m => m.DoiComponent), pathMatch: 'full' },
36+
{ path: 'content/schema', loadComponent: () => import('./content/schema/schema.component').then(m => m.SchemaComponent), pathMatch: 'full' },
37+
{ path: 'content/schema/:className', loadComponent: () => import('./content/schema/schema.component').then(m => m.SchemaComponent), pathMatch: 'full' },
3638

3739
//Search Pages
3840
{ path: 'content/query', loadComponent: () => import('./search/search.component').then(m => m.SearchComponent) },
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
<app-page-layout [showSidebar]="false" [showBreadcrumb]="true">
2+
<div class="schema-browser">
3+
@if (loading) {
4+
<div class="loading-state">
5+
<div class="spinner"></div>
6+
<p>Loading data model...</p>
7+
</div>
8+
} @else if (error) {
9+
<div class="error-state">
10+
<span class="material-symbols-rounded">error</span>
11+
<p>Failed to load the data model.</p>
12+
</div>
13+
} @else {
14+
<div class="schema-layout">
15+
<!-- Mobile sidebar toggle -->
16+
<button class="sidebar-toggle" (click)="toggleSidebar()">
17+
<span class="material-symbols-rounded">{{ sidebarOpen ? 'close' : 'account_tree' }}</span>
18+
Class Hierarchy
19+
</button>
20+
21+
<!-- Tree sidebar -->
22+
<aside class="schema-tree" [class.open]="sidebarOpen">
23+
<div class="tree-header">
24+
<h3>Data Model</h3>
25+
</div>
26+
<div class="tree-search">
27+
<span class="material-symbols-rounded search-icon">search</span>
28+
<input
29+
type="text"
30+
placeholder="Filter classes..."
31+
[value]="treeSearchQuery"
32+
(input)="onTreeSearch($event)"
33+
/>
34+
@if (treeSearchQuery) {
35+
<button class="clear-btn" (click)="clearTreeSearch()">
36+
<span class="material-symbols-rounded">close</span>
37+
</button>
38+
}
39+
</div>
40+
<div class="tree-content">
41+
@for (node of flatTree; track node.className) {
42+
<div
43+
class="tree-node"
44+
[style.padding-left.px]="12 + node.depth * 16"
45+
[class.selected]="node.className === selectedClass"
46+
>
47+
@if (node.hasChildren) {
48+
<button class="expand-btn" (click)="toggleTreeNode(node.className); $event.stopPropagation()">
49+
<span class="material-symbols-rounded">
50+
{{ node.expanded ? 'expand_more' : 'chevron_right' }}
51+
</span>
52+
</button>
53+
} @else {
54+
<span class="expand-placeholder"></span>
55+
}
56+
<button class="node-label" (click)="onTreeNodeClick(node.className)">
57+
<span class="node-name">{{ node.className }}</span>
58+
<span class="node-count">[{{ node.count }}]</span>
59+
</button>
60+
</div>
61+
}
62+
</div>
63+
</aside>
64+
65+
<!-- Main content -->
66+
<main class="schema-content">
67+
@if (selectedClass) {
68+
<div class="class-header">
69+
<h1>{{ selectedClass }}</h1>
70+
<span class="instance-count">{{ getNodeCount(selectedClass) }} instances</span>
71+
</div>
72+
73+
<div class="tab-bar">
74+
<button
75+
class="tab"
76+
[class.active]="activeTab === 'properties'"
77+
(click)="activeTab = 'properties'"
78+
>
79+
Properties
80+
</button>
81+
<button
82+
class="tab"
83+
[class.active]="activeTab === 'entries'"
84+
(click)="switchToEntries()"
85+
>
86+
Entries
87+
</button>
88+
</div>
89+
90+
@if (activeTab === 'properties') {
91+
@if (loadingAttributes) {
92+
<div class="loading-state small">
93+
<div class="spinner"></div>
94+
</div>
95+
} @else {
96+
<!-- Own attributes -->
97+
@if (ownAttributes.length > 0) {
98+
<div class="attr-section">
99+
<h2>Own Attributes</h2>
100+
<div class="attr-table-wrapper">
101+
<table class="attr-table">
102+
<thead>
103+
<tr>
104+
<th>Name</th>
105+
<th>Cardinality</th>
106+
<th>Value Type</th>
107+
</tr>
108+
</thead>
109+
<tbody>
110+
@for (attr of ownAttributes; track attr.name) {
111+
<tr>
112+
<td class="attr-name">{{ attr.name }}</td>
113+
<td class="attr-card">
114+
<span class="cardinality-badge">{{ attr.cardinality }}</span>
115+
</td>
116+
<td class="attr-types">
117+
@for (vt of attr.valueTypes; track vt.name; let last = $last) {
118+
@if (vt.databaseObject && isClassInTree(vt.name)) {
119+
<a class="type-link db-object" (click)="navigateToClass(vt.name)">{{ vt.name }}</a>
120+
} @else if (vt.databaseObject) {
121+
<span class="type-name db-object">{{ vt.name }}</span>
122+
} @else {
123+
<span class="type-name primitive">{{ vt.name }}</span>
124+
}
125+
@if (!last) { <span class="type-sep">, </span> }
126+
}
127+
</td>
128+
</tr>
129+
}
130+
</tbody>
131+
</table>
132+
</div>
133+
</div>
134+
}
135+
136+
<!-- Inherited attributes -->
137+
@if (inheritedAttributes.length > 0) {
138+
<div class="attr-section">
139+
<h2>
140+
Inherited Attributes
141+
<button class="toggle-inherited" (click)="showInherited = !showInherited">
142+
{{ showInherited ? 'Hide' : 'Show' }} ({{ inheritedAttributes.length }})
143+
</button>
144+
</h2>
145+
@if (showInherited) {
146+
<div class="attr-table-wrapper">
147+
<table class="attr-table inherited">
148+
<thead>
149+
<tr>
150+
<th>Name</th>
151+
<th>Cardinality</th>
152+
<th>Value Type</th>
153+
<th>Origin</th>
154+
</tr>
155+
</thead>
156+
<tbody>
157+
@for (attr of inheritedAttributes; track attr.name + attr.origin) {
158+
<tr>
159+
<td class="attr-name">{{ attr.name }}</td>
160+
<td class="attr-card">
161+
<span class="cardinality-badge">{{ attr.cardinality }}</span>
162+
</td>
163+
<td class="attr-types">
164+
@for (vt of attr.valueTypes; track vt.name; let last = $last) {
165+
@if (vt.databaseObject && isClassInTree(vt.name)) {
166+
<a class="type-link db-object" (click)="navigateToClass(vt.name)">{{ vt.name }}</a>
167+
} @else if (vt.databaseObject) {
168+
<span class="type-name db-object">{{ vt.name }}</span>
169+
} @else {
170+
<span class="type-name primitive">{{ vt.name }}</span>
171+
}
172+
@if (!last) { <span class="type-sep">, </span> }
173+
}
174+
</td>
175+
<td class="attr-origin">
176+
@if (isClassInTree(attr.origin)) {
177+
<a class="type-link" (click)="navigateToClass(attr.origin)">{{ attr.origin }}</a>
178+
} @else {
179+
{{ attr.origin }}
180+
}
181+
</td>
182+
</tr>
183+
}
184+
</tbody>
185+
</table>
186+
</div>
187+
}
188+
</div>
189+
}
190+
191+
<!-- Referrals -->
192+
@if (referrals.length > 0) {
193+
<div class="attr-section">
194+
<h2>Referrals</h2>
195+
<p class="section-desc">Other classes that reference {{ selectedClass }}:</p>
196+
<div class="attr-table-wrapper">
197+
<table class="attr-table referrals">
198+
<thead>
199+
<tr>
200+
<th>Attribute</th>
201+
<th>Cardinality</th>
202+
<th>In Class</th>
203+
</tr>
204+
</thead>
205+
<tbody>
206+
@for (ref of referrals; track ref.name + ref.origin) {
207+
<tr>
208+
<td class="attr-name">{{ ref.name }}</td>
209+
<td class="attr-card">
210+
<span class="cardinality-badge">{{ ref.cardinality }}</span>
211+
</td>
212+
<td class="attr-origin">
213+
@if (isClassInTree(ref.origin)) {
214+
<a class="type-link" (click)="navigateToClass(ref.origin)">{{ ref.origin }}</a>
215+
} @else {
216+
{{ ref.origin }}
217+
}
218+
</td>
219+
</tr>
220+
}
221+
</tbody>
222+
</table>
223+
</div>
224+
</div>
225+
}
226+
227+
@if (attributesError) {
228+
<div class="empty-section">
229+
<p>Could not load attributes. The attributes endpoint may need to be deployed.</p>
230+
</div>
231+
} @else if (ownAttributes.length === 0 && inheritedAttributes.length === 0) {
232+
<div class="empty-section">
233+
<p>No attributes found for this class.</p>
234+
</div>
235+
}
236+
237+
<div class="instructions">
238+
<hr />
239+
<p>
240+
You can find documentation for the Reactome data model
241+
<a routerLink="/documentation/data-model">here</a>.
242+
</p>
243+
<p>
244+
The sidebar on the left shows the hierarchy of Reactome classes. The number
245+
of instances of each class is shown in square brackets and links to a listing
246+
of all instances of that class.
247+
</p>
248+
<p>
249+
The main panel shows attributes of the selected class.
250+
<span class="own-highlight">Own attributes</span>, i.e. the ones which are
251+
not inherited from a parent class, are shown separately from inherited ones.
252+
</p>
253+
<p>
254+
<span class="cardinality-badge">+</span> in the Cardinality column indicates
255+
a multi-value attribute.
256+
</p>
257+
</div>
258+
}
259+
} @else if (activeTab === 'entries') {
260+
@if (loadingEntries) {
261+
<div class="loading-state small">
262+
<div class="spinner"></div>
263+
</div>
264+
} @else {
265+
<div class="entries-header">
266+
<span class="entries-count">
267+
{{ entryCount }} entries
268+
@if (totalPages > 1) {
269+
&mdash; Page {{ entriesPage }} of {{ totalPages }}
270+
}
271+
</span>
272+
</div>
273+
274+
@if (entries.length > 0) {
275+
<div class="entries-table-wrapper">
276+
<table class="entries-table">
277+
<thead>
278+
<tr>
279+
<th>Identifier</th>
280+
<th>Name</th>
281+
</tr>
282+
</thead>
283+
<tbody>
284+
@for (entry of entries; track entry.dbId) {
285+
<tr>
286+
<td class="entry-id">
287+
<a [href]="entryUrl(entry)" target="_blank">
288+
{{ entry.stId || entry.dbId }}
289+
</a>
290+
</td>
291+
<td class="entry-name">{{ entry.displayName }}</td>
292+
</tr>
293+
}
294+
</tbody>
295+
</table>
296+
</div>
297+
298+
@if (totalPages > 1) {
299+
<div class="pagination">
300+
<button
301+
class="page-btn"
302+
[disabled]="entriesPage === 1"
303+
(click)="goToPage(1)"
304+
>
305+
<span class="material-symbols-rounded">first_page</span>
306+
</button>
307+
<button
308+
class="page-btn"
309+
[disabled]="entriesPage === 1"
310+
(click)="goToPage(entriesPage - 1)"
311+
>
312+
<span class="material-symbols-rounded">chevron_left</span>
313+
</button>
314+
315+
@for (p of visiblePages; track p) {
316+
<button
317+
class="page-btn"
318+
[class.active]="p === entriesPage"
319+
(click)="goToPage(p)"
320+
>
321+
{{ p }}
322+
</button>
323+
}
324+
325+
<button
326+
class="page-btn"
327+
[disabled]="entriesPage === totalPages"
328+
(click)="goToPage(entriesPage + 1)"
329+
>
330+
<span class="material-symbols-rounded">chevron_right</span>
331+
</button>
332+
<button
333+
class="page-btn"
334+
[disabled]="entriesPage === totalPages"
335+
(click)="goToPage(totalPages)"
336+
>
337+
<span class="material-symbols-rounded">last_page</span>
338+
</button>
339+
</div>
340+
}
341+
} @else {
342+
<div class="empty-section">
343+
<p>No entries found for this class.</p>
344+
</div>
345+
}
346+
}
347+
}
348+
} @else {
349+
<div class="welcome-state">
350+
<span class="material-symbols-rounded">schema</span>
351+
<h2>Reactome Data Model Browser</h2>
352+
<p>Select a class from the hierarchy to view its properties and entries.</p>
353+
</div>
354+
}
355+
</main>
356+
</div>
357+
}
358+
</div>
359+
</app-page-layout>

0 commit comments

Comments
 (0)