diff --git a/projects/website-angular/src/app/content/schema/instance-browser/instance-browser.component.html b/projects/website-angular/src/app/content/schema/instance-browser/instance-browser.component.html
new file mode 100644
index 0000000..6b7f29b
--- /dev/null
+++ b/projects/website-angular/src/app/content/schema/instance-browser/instance-browser.component.html
@@ -0,0 +1,54 @@
+@if (loading) {
+
+
+
Loading instance...
+
+} @else if (error) {
+
+
error
+
Failed to load instance data.
+
+} @else {
+
+
+ @if (rows.length > 0) {
+
+
+
+
+ | Attribute |
+ Value |
+
+
+
+ @for (row of rows; track row.name) {
+
+ | {{ row.name }} |
+
+ @for (val of row.values; track $index) {
+ @if (val.type === 'link') {
+ {{ val.text }}
+ } @else {
+ {{ val.text }}
+ }
+ @if (!$last) {
+
+ }
+ }
+ |
+
+ }
+
+
+
+ } @else {
+
+
No attributes found for this instance.
+
+ }
+}
diff --git a/projects/website-angular/src/app/content/schema/instance-browser/instance-browser.component.scss b/projects/website-angular/src/app/content/schema/instance-browser/instance-browser.component.scss
new file mode 100644
index 0000000..f2eb743
--- /dev/null
+++ b/projects/website-angular/src/app/content/schema/instance-browser/instance-browser.component.scss
@@ -0,0 +1,149 @@
+// --- Header ---
+
+.instance-header {
+ margin-bottom: 24px;
+
+ h2 {
+ margin: 0;
+ font-size: 1.4rem;
+ font-family: "Roboto Mono", monospace;
+ color: var(--on-surface);
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ flex-wrap: wrap;
+ }
+
+ .schema-link {
+ color: var(--primary);
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .dbid {
+ font-size: 1.1rem;
+ color: var(--on-surface-variant);
+ font-weight: 400;
+ }
+}
+
+// --- Attribute table ---
+
+.attr-table-wrapper {
+ overflow-x: auto;
+}
+
+.attr-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+
+ th {
+ text-align: left;
+ padding: 8px 12px;
+ background: #f8f9fa;
+ border-bottom: 2px solid #dee2e6;
+ font-weight: 600;
+ color: #495057;
+ white-space: nowrap;
+
+ :host-context(.dark) & {
+ background: rgba(255, 255, 255, 0.05);
+ border-bottom-color: rgba(255, 255, 255, 0.12);
+ color: var(--on-surface-variant);
+ }
+ }
+
+ td {
+ padding: 7px 12px;
+ border-bottom: 1px solid #eee;
+ vertical-align: top;
+
+ :host-context(.dark) & {
+ border-bottom-color: rgba(255, 255, 255, 0.06);
+ }
+ }
+
+ tbody tr:hover {
+ background: #f8f9fa;
+
+ :host-context(.dark) & {
+ background: rgba(255, 255, 255, 0.03);
+ }
+ }
+}
+
+.attr-name {
+ font-family: "Roboto Mono", monospace;
+ font-size: 0.82rem;
+ color: var(--on-surface);
+ white-space: nowrap;
+ width: 180px;
+ min-width: 140px;
+}
+
+.attr-value {
+ word-break: break-word;
+}
+
+.instance-link {
+ color: var(--primary);
+ text-decoration: none;
+ font-size: 0.82rem;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.text-value {
+ color: var(--on-surface);
+}
+
+// --- Loading / empty / error ---
+
+.loading-state,
+.error-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 24px;
+ color: var(--on-surface-variant);
+ text-align: center;
+
+ .material-symbols-rounded {
+ font-size: 48px;
+ margin-bottom: 12px;
+ opacity: 0.4;
+ }
+
+ p {
+ margin: 0;
+ }
+}
+
+.empty-section {
+ padding: 24px;
+ text-align: center;
+ color: var(--on-surface-variant);
+}
+
+.spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid #e0e0e0;
+ border-top-color: var(--primary);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ margin-bottom: 12px;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/projects/website-angular/src/app/content/schema/instance-browser/instance-browser.component.ts b/projects/website-angular/src/app/content/schema/instance-browser/instance-browser.component.ts
new file mode 100644
index 0000000..eef8f1e
--- /dev/null
+++ b/projects/website-angular/src/app/content/schema/instance-browser/instance-browser.component.ts
@@ -0,0 +1,184 @@
+import {
+ Component,
+ Input,
+ Output,
+ EventEmitter,
+ OnChanges,
+ SimpleChanges,
+ OnDestroy,
+} from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import {
+ ContentDataService,
+ SchemaAttribute,
+} from '../../../../services/content-data.service';
+
+interface AttributeRow {
+ name: string;
+ values: AttributeValue[];
+}
+
+interface AttributeValue {
+ type: 'text' | 'link';
+ text: string;
+ dbId?: number;
+ schemaClass?: string;
+}
+
+@Component({
+ selector: 'app-instance-browser',
+ imports: [RouterLink],
+ templateUrl: './instance-browser.component.html',
+ styleUrl: './instance-browser.component.scss',
+})
+export class InstanceBrowserComponent implements OnChanges, OnDestroy {
+ private destroy$ = new Subject();
+
+ @Input() instanceId!: number | string;
+ @Output() instanceLinkClick = new EventEmitter();
+
+ instance: any = null;
+ schemaClass = '';
+ dbId: number | string = '';
+ rows: AttributeRow[] = [];
+ loading = true;
+ error = false;
+
+ constructor(private contentDataService: ContentDataService) {}
+
+ ngOnChanges(changes: SimpleChanges) {
+ if (changes['instanceId'] && this.instanceId != null) {
+ this.loadInstance();
+ }
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ private loadInstance() {
+ this.loading = true;
+ this.error = false;
+ this.rows = [];
+
+ this.contentDataService
+ .getInstance(this.instanceId)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe({
+ next: (instance) => {
+ this.instance = instance;
+ this.schemaClass = instance.schemaClass || instance.className || '';
+ this.dbId = instance.dbId;
+ this.loadAttributes();
+ },
+ error: () => {
+ this.error = true;
+ this.loading = false;
+ },
+ });
+ }
+
+ private loadAttributes() {
+ this.contentDataService.getSchemaAttributes(this.schemaClass).subscribe({
+ next: (attrs) => {
+ this.rows = this.buildRows(attrs);
+ this.loading = false;
+ },
+ error: () => {
+ // Fall back to rendering instance keys directly
+ this.rows = this.buildRowsFromInstance();
+ this.loading = false;
+ },
+ });
+ }
+
+ private buildRows(attrs: SchemaAttribute[]): AttributeRow[] {
+ const rows: AttributeRow[] = [];
+ for (const attr of attrs) {
+ const raw = this.instance[attr.name];
+ if (raw === undefined || raw === null) continue;
+
+ const hasDatabaseObjectType = attr.valueTypes.some(
+ (vt) => vt.databaseObject
+ );
+ const values = this.resolveValues(raw, hasDatabaseObjectType);
+ if (values.length > 0) {
+ rows.push({ name: attr.name, values });
+ }
+ }
+ return rows;
+ }
+
+ private buildRowsFromInstance(): AttributeRow[] {
+ const rows: AttributeRow[] = [];
+ for (const key of Object.keys(this.instance)) {
+ const raw = this.instance[key];
+ if (raw === undefined || raw === null) continue;
+ const values = this.resolveValues(raw, false);
+ if (values.length > 0) {
+ rows.push({ name: key, values });
+ }
+ }
+ return rows;
+ }
+
+ private resolveValues(
+ raw: any,
+ hasDatabaseObjectType: boolean
+ ): AttributeValue[] {
+ if (Array.isArray(raw)) {
+ const result: AttributeValue[] = [];
+ for (const item of raw) {
+ result.push(...this.resolveSingleValue(item, hasDatabaseObjectType));
+ }
+ return result;
+ }
+ return this.resolveSingleValue(raw, hasDatabaseObjectType);
+ }
+
+ private resolveSingleValue(
+ val: any,
+ hasDatabaseObjectType: boolean
+ ): AttributeValue[] {
+ // Database object with dbId
+ if (val !== null && typeof val === 'object' && val.dbId) {
+ return [
+ {
+ type: 'link',
+ text: `[${val.schemaClass || val.className || 'Object'}:${
+ val.dbId
+ }] ${val.displayName || ''}`,
+ dbId: val.dbId,
+ schemaClass: val.schemaClass || val.className,
+ },
+ ];
+ }
+
+ // Numeric ID reference (e.g. authored: [109913]) when schema says it's a database object
+ if (typeof val === 'number' && hasDatabaseObjectType) {
+ return [
+ {
+ type: 'link',
+ text: `${val}`,
+ dbId: val,
+ },
+ ];
+ }
+
+ // Primitive
+ return [
+ {
+ type: 'text',
+ text: String(val),
+ },
+ ];
+ }
+
+ onLinkClick(dbId: number, event: Event) {
+ event.preventDefault();
+ this.instanceLinkClick.emit(dbId);
+ }
+}
diff --git a/projects/website-angular/src/app/content/schema/schema.component.html b/projects/website-angular/src/app/content/schema/schema.component.html
index 2da2a6b..1dbe994 100644
--- a/projects/website-angular/src/app/content/schema/schema.component.html
+++ b/projects/website-angular/src/app/content/schema/schema.component.html
@@ -257,91 +257,101 @@ Referrals
}
} @else if (activeTab === 'entries') {
- @if (loadingEntries) {
-
+ @if (selectedInstanceId) {
+
+
} @else {
-
+ @if (loadingEntries) {
+
+ } @else {
+
- @if (entries.length > 0) {
-
-
-
-
- | Identifier |
- Name |
-
-
-
- @for (entry of entries; track entry.dbId) {
+ @if (entries.length > 0) {
+
-
- @if (totalPages > 1) {
-
+
- @for (p of visiblePages; track p) {
+ @if (totalPages > 1) {
+
+ }
+ } @else {
+
+
No entries found for this class.
}
- } @else {
-
-
No entries found for this class.
-
}
}
}
diff --git a/projects/website-angular/src/app/content/schema/schema.component.scss b/projects/website-angular/src/app/content/schema/schema.component.scss
index 39d512e..309fb7a 100644
--- a/projects/website-angular/src/app/content/schema/schema.component.scss
+++ b/projects/website-angular/src/app/content/schema/schema.component.scss
@@ -212,7 +212,7 @@ $sidebar-width: 320px;
margin: 0;
font-size: 1.6rem;
color: var(--on-surface);
- font-family: 'Roboto Mono', monospace;
+ font-family: "Roboto Mono", monospace;
}
.instance-count {
@@ -352,7 +352,7 @@ $sidebar-width: 320px;
}
.attr-name {
- font-family: 'Roboto Mono', monospace;
+ font-family: "Roboto Mono", monospace;
font-size: 0.82rem;
color: var(--on-surface);
}
@@ -369,7 +369,7 @@ $sidebar-width: 320px;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 600;
- font-family: 'Roboto Mono', monospace;
+ font-family: "Roboto Mono", monospace;
background: rgba(0, 0, 0, 0.06);
color: var(--on-surface-variant);
@@ -411,6 +411,36 @@ $sidebar-width: 320px;
font-size: 0.82rem;
}
+// --- Back button ---
+
+.back-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 12px;
+ margin-bottom: 16px;
+ border: 1px solid rgba(0, 0, 0, 0.15);
+ border-radius: 6px;
+ background: none;
+ color: var(--on-surface);
+ font-size: 0.85rem;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ :host-context(.dark) & {
+ border-color: rgba(255, 255, 255, 0.15);
+ }
+
+ .material-symbols-rounded {
+ font-size: 18px;
+ }
+
+ &:hover {
+ border-color: var(--primary);
+ color: var(--primary);
+ }
+}
+
// --- Entries table ---
.entries-header {
diff --git a/projects/website-angular/src/app/content/schema/schema.component.ts b/projects/website-angular/src/app/content/schema/schema.component.ts
index 8eabb68..13b5f36 100644
--- a/projects/website-angular/src/app/content/schema/schema.component.ts
+++ b/projects/website-angular/src/app/content/schema/schema.component.ts
@@ -3,6 +3,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PageLayoutComponent } from '../../page-layout/page-layout.component';
+import { InstanceBrowserComponent } from './instance-browser/instance-browser.component';
import {
ContentDataService,
SchemaNode,
@@ -20,7 +21,7 @@ interface FlatTreeNode {
@Component({
selector: 'app-schema',
- imports: [PageLayoutComponent, RouterLink],
+ imports: [PageLayoutComponent, RouterLink, InstanceBrowserComponent],
templateUrl: './schema.component.html',
styleUrl: './schema.component.scss',
})
@@ -54,6 +55,9 @@ export class SchemaComponent implements OnInit, OnDestroy {
entriesPageSize = 50;
loadingEntries = false;
+ // Instance detail state
+ selectedInstanceId: number | null = null;
+
// Overall
loading = true;
error = false;
@@ -201,6 +205,7 @@ export class SchemaComponent implements OnInit, OnDestroy {
this.activeTab = 'properties';
this.entries = [];
this.entriesPage = 1;
+ this.selectedInstanceId = null;
this.loadAttributes(className);
// Expand tree path to this node
@@ -292,7 +297,11 @@ export class SchemaComponent implements OnInit, OnDestroy {
loadEntries() {
this.loadingEntries = true;
this.contentDataService
- .getSchemaEntries(this.selectedClass, this.entriesPage, this.entriesPageSize)
+ .getSchemaEntries(
+ this.selectedClass,
+ this.entriesPage,
+ this.entriesPageSize
+ )
.subscribe({
next: (entries) => {
this.entries = entries;
@@ -337,10 +346,15 @@ export class SchemaComponent implements OnInit, OnDestroy {
this.sidebarOpen = !this.sidebarOpen;
}
- entryUrl(entry: SimpleDatabaseObject): string {
- if (entry.stId) {
- return `https://dev.reactome.org/content/detail/${entry.stId}`;
- }
- return `https://dev.reactome.org/content/detail/${entry.dbId}`;
+ selectInstance(dbId: number) {
+ this.selectedInstanceId = dbId;
+ }
+
+ clearSelectedInstance() {
+ this.selectedInstanceId = null;
+ }
+
+ onInstanceLinkClick(dbId: number) {
+ this.selectedInstanceId = dbId;
}
}
diff --git a/projects/website-angular/src/services/content-data.service.ts b/projects/website-angular/src/services/content-data.service.ts
index e9dc3e2..507840d 100644
--- a/projects/website-angular/src/services/content-data.service.ts
+++ b/projects/website-angular/src/services/content-data.service.ts
@@ -102,14 +102,23 @@ export class ContentDataService {
}
getSchemaAttributes(className: string): Observable {
- return this.http.get(`${this.schemaUrl}/${className}/attributes`);
+ return this.http.get(
+ `${this.schemaUrl}/${className}/attributes`
+ );
}
getSchemaReferrals(className: string): Observable {
- return this.http.get(`${this.schemaUrl}/${className}/referrals`);
+ return this.http.get(
+ `${this.schemaUrl}/${className}/referrals`
+ );
}
- getSchemaEntries(className: string, page: number, offset: number, species?: string): Observable {
+ getSchemaEntries(
+ className: string,
+ page: number,
+ offset: number,
+ species?: string
+ ): Observable {
let url = `${this.schemaUrl}/${className}/min?page=${page}&offset=${offset}`;
if (species) url += `&species=${encodeURIComponent(species)}`;
return this.http.get(url);
@@ -120,4 +129,10 @@ export class ContentDataService {
if (species) url += `?species=${encodeURIComponent(species)}`;
return this.http.get(url);
}
+
+ getInstance(id: string | number): Observable {
+ return this.http.get(
+ `https://dev.reactome.org/ContentService/data/query/enhanced/${id}`
+ );
+ }
}