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 { +
+

+ {{ schemaClass }} + {{ dbId }} +

+
+ + @if (rows.length > 0) { +
+ + + + + + + + + @for (row of rows; track row.name) { + + + + + } + +
AttributeValue
{{ 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 { -
- - {{ entryCount }} entries - @if (totalPages > 1) { - — Page {{ entriesPage }} of {{ totalPages }} - } - -
+ @if (loadingEntries) { +
+
+
+ } @else { +
+ + {{ entryCount }} entries + @if (totalPages > 1) { + — Page {{ entriesPage }} of {{ totalPages }} + } + +
- @if (entries.length > 0) { -
- - - - - - - - - @for (entry of entries; track entry.dbId) { + @if (entries.length > 0) { +
+
IdentifierName
+ - - + + - } - -
- - {{ entry.stId || entry.dbId }} - - {{ entry.displayName }}IdentifierName
-
- - @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}` + ); + } }