Skip to content

Commit 51d3679

Browse files
authored
Merge pull request #63 from reactome/add-instance-edit-pages
Adding Instance Edit Pages
2 parents 31b1b68 + d325057 commit 51d3679

7 files changed

Lines changed: 542 additions & 86 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
@if (loading) {
2+
<div class="loading-state">
3+
<div class="spinner"></div>
4+
<p>Loading instance...</p>
5+
</div>
6+
} @else if (error) {
7+
<div class="error-state">
8+
<span class="material-symbols-rounded">error</span>
9+
<p>Failed to load instance data.</p>
10+
</div>
11+
} @else {
12+
<div class="instance-header">
13+
<h2>
14+
<a class="schema-link" [routerLink]="'/content/schema/' + schemaClass">{{ schemaClass }}</a>
15+
<span class="dbid">{{ dbId }}</span>
16+
</h2>
17+
</div>
18+
19+
@if (rows.length > 0) {
20+
<div class="attr-table-wrapper">
21+
<table class="attr-table">
22+
<thead>
23+
<tr>
24+
<th>Attribute</th>
25+
<th>Value</th>
26+
</tr>
27+
</thead>
28+
<tbody>
29+
@for (row of rows; track row.name) {
30+
<tr>
31+
<td class="attr-name">{{ row.name }}</td>
32+
<td class="attr-value">
33+
@for (val of row.values; track $index) {
34+
@if (val.type === 'link') {
35+
<a class="instance-link" href="#" (click)="onLinkClick(val.dbId!, $event)">{{ val.text }}</a>
36+
} @else {
37+
<span class="text-value">{{ val.text }}</span>
38+
}
39+
@if (!$last) {
40+
<br />
41+
}
42+
}
43+
</td>
44+
</tr>
45+
}
46+
</tbody>
47+
</table>
48+
</div>
49+
} @else {
50+
<div class="empty-section">
51+
<p>No attributes found for this instance.</p>
52+
</div>
53+
}
54+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// --- Header ---
2+
3+
.instance-header {
4+
margin-bottom: 24px;
5+
6+
h2 {
7+
margin: 0;
8+
font-size: 1.4rem;
9+
font-family: "Roboto Mono", monospace;
10+
color: var(--on-surface);
11+
display: flex;
12+
align-items: baseline;
13+
gap: 12px;
14+
flex-wrap: wrap;
15+
}
16+
17+
.schema-link {
18+
color: var(--primary);
19+
text-decoration: none;
20+
21+
&:hover {
22+
text-decoration: underline;
23+
}
24+
}
25+
26+
.dbid {
27+
font-size: 1.1rem;
28+
color: var(--on-surface-variant);
29+
font-weight: 400;
30+
}
31+
}
32+
33+
// --- Attribute table ---
34+
35+
.attr-table-wrapper {
36+
overflow-x: auto;
37+
}
38+
39+
.attr-table {
40+
width: 100%;
41+
border-collapse: collapse;
42+
font-size: 0.875rem;
43+
44+
th {
45+
text-align: left;
46+
padding: 8px 12px;
47+
background: #f8f9fa;
48+
border-bottom: 2px solid #dee2e6;
49+
font-weight: 600;
50+
color: #495057;
51+
white-space: nowrap;
52+
53+
:host-context(.dark) & {
54+
background: rgba(255, 255, 255, 0.05);
55+
border-bottom-color: rgba(255, 255, 255, 0.12);
56+
color: var(--on-surface-variant);
57+
}
58+
}
59+
60+
td {
61+
padding: 7px 12px;
62+
border-bottom: 1px solid #eee;
63+
vertical-align: top;
64+
65+
:host-context(.dark) & {
66+
border-bottom-color: rgba(255, 255, 255, 0.06);
67+
}
68+
}
69+
70+
tbody tr:hover {
71+
background: #f8f9fa;
72+
73+
:host-context(.dark) & {
74+
background: rgba(255, 255, 255, 0.03);
75+
}
76+
}
77+
}
78+
79+
.attr-name {
80+
font-family: "Roboto Mono", monospace;
81+
font-size: 0.82rem;
82+
color: var(--on-surface);
83+
white-space: nowrap;
84+
width: 180px;
85+
min-width: 140px;
86+
}
87+
88+
.attr-value {
89+
word-break: break-word;
90+
}
91+
92+
.instance-link {
93+
color: var(--primary);
94+
text-decoration: none;
95+
font-size: 0.82rem;
96+
97+
&:hover {
98+
text-decoration: underline;
99+
}
100+
}
101+
102+
.text-value {
103+
color: var(--on-surface);
104+
}
105+
106+
// --- Loading / empty / error ---
107+
108+
.loading-state,
109+
.error-state {
110+
display: flex;
111+
flex-direction: column;
112+
align-items: center;
113+
justify-content: center;
114+
padding: 48px 24px;
115+
color: var(--on-surface-variant);
116+
text-align: center;
117+
118+
.material-symbols-rounded {
119+
font-size: 48px;
120+
margin-bottom: 12px;
121+
opacity: 0.4;
122+
}
123+
124+
p {
125+
margin: 0;
126+
}
127+
}
128+
129+
.empty-section {
130+
padding: 24px;
131+
text-align: center;
132+
color: var(--on-surface-variant);
133+
}
134+
135+
.spinner {
136+
width: 32px;
137+
height: 32px;
138+
border: 3px solid #e0e0e0;
139+
border-top-color: var(--primary);
140+
border-radius: 50%;
141+
animation: spin 0.8s linear infinite;
142+
margin-bottom: 12px;
143+
}
144+
145+
@keyframes spin {
146+
to {
147+
transform: rotate(360deg);
148+
}
149+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import {
2+
Component,
3+
Input,
4+
Output,
5+
EventEmitter,
6+
OnChanges,
7+
SimpleChanges,
8+
OnDestroy,
9+
} from '@angular/core';
10+
import { RouterLink } from '@angular/router';
11+
import { Subject } from 'rxjs';
12+
import { takeUntil } from 'rxjs/operators';
13+
import {
14+
ContentDataService,
15+
SchemaAttribute,
16+
} from '../../../../services/content-data.service';
17+
18+
interface AttributeRow {
19+
name: string;
20+
values: AttributeValue[];
21+
}
22+
23+
interface AttributeValue {
24+
type: 'text' | 'link';
25+
text: string;
26+
dbId?: number;
27+
schemaClass?: string;
28+
}
29+
30+
@Component({
31+
selector: 'app-instance-browser',
32+
imports: [RouterLink],
33+
templateUrl: './instance-browser.component.html',
34+
styleUrl: './instance-browser.component.scss',
35+
})
36+
export class InstanceBrowserComponent implements OnChanges, OnDestroy {
37+
private destroy$ = new Subject<void>();
38+
39+
@Input() instanceId!: number | string;
40+
@Output() instanceLinkClick = new EventEmitter<number>();
41+
42+
instance: any = null;
43+
schemaClass = '';
44+
dbId: number | string = '';
45+
rows: AttributeRow[] = [];
46+
loading = true;
47+
error = false;
48+
49+
constructor(private contentDataService: ContentDataService) {}
50+
51+
ngOnChanges(changes: SimpleChanges) {
52+
if (changes['instanceId'] && this.instanceId != null) {
53+
this.loadInstance();
54+
}
55+
}
56+
57+
ngOnDestroy() {
58+
this.destroy$.next();
59+
this.destroy$.complete();
60+
}
61+
62+
private loadInstance() {
63+
this.loading = true;
64+
this.error = false;
65+
this.rows = [];
66+
67+
this.contentDataService
68+
.getInstance(this.instanceId)
69+
.pipe(takeUntil(this.destroy$))
70+
.subscribe({
71+
next: (instance) => {
72+
this.instance = instance;
73+
this.schemaClass = instance.schemaClass || instance.className || '';
74+
this.dbId = instance.dbId;
75+
this.loadAttributes();
76+
},
77+
error: () => {
78+
this.error = true;
79+
this.loading = false;
80+
},
81+
});
82+
}
83+
84+
private loadAttributes() {
85+
this.contentDataService.getSchemaAttributes(this.schemaClass).subscribe({
86+
next: (attrs) => {
87+
this.rows = this.buildRows(attrs);
88+
this.loading = false;
89+
},
90+
error: () => {
91+
// Fall back to rendering instance keys directly
92+
this.rows = this.buildRowsFromInstance();
93+
this.loading = false;
94+
},
95+
});
96+
}
97+
98+
private buildRows(attrs: SchemaAttribute[]): AttributeRow[] {
99+
const rows: AttributeRow[] = [];
100+
for (const attr of attrs) {
101+
const raw = this.instance[attr.name];
102+
if (raw === undefined || raw === null) continue;
103+
104+
const hasDatabaseObjectType = attr.valueTypes.some(
105+
(vt) => vt.databaseObject
106+
);
107+
const values = this.resolveValues(raw, hasDatabaseObjectType);
108+
if (values.length > 0) {
109+
rows.push({ name: attr.name, values });
110+
}
111+
}
112+
return rows;
113+
}
114+
115+
private buildRowsFromInstance(): AttributeRow[] {
116+
const rows: AttributeRow[] = [];
117+
for (const key of Object.keys(this.instance)) {
118+
const raw = this.instance[key];
119+
if (raw === undefined || raw === null) continue;
120+
const values = this.resolveValues(raw, false);
121+
if (values.length > 0) {
122+
rows.push({ name: key, values });
123+
}
124+
}
125+
return rows;
126+
}
127+
128+
private resolveValues(
129+
raw: any,
130+
hasDatabaseObjectType: boolean
131+
): AttributeValue[] {
132+
if (Array.isArray(raw)) {
133+
const result: AttributeValue[] = [];
134+
for (const item of raw) {
135+
result.push(...this.resolveSingleValue(item, hasDatabaseObjectType));
136+
}
137+
return result;
138+
}
139+
return this.resolveSingleValue(raw, hasDatabaseObjectType);
140+
}
141+
142+
private resolveSingleValue(
143+
val: any,
144+
hasDatabaseObjectType: boolean
145+
): AttributeValue[] {
146+
// Database object with dbId
147+
if (val !== null && typeof val === 'object' && val.dbId) {
148+
return [
149+
{
150+
type: 'link',
151+
text: `[${val.schemaClass || val.className || 'Object'}:${
152+
val.dbId
153+
}] ${val.displayName || ''}`,
154+
dbId: val.dbId,
155+
schemaClass: val.schemaClass || val.className,
156+
},
157+
];
158+
}
159+
160+
// Numeric ID reference (e.g. authored: [109913]) when schema says it's a database object
161+
if (typeof val === 'number' && hasDatabaseObjectType) {
162+
return [
163+
{
164+
type: 'link',
165+
text: `${val}`,
166+
dbId: val,
167+
},
168+
];
169+
}
170+
171+
// Primitive
172+
return [
173+
{
174+
type: 'text',
175+
text: String(val),
176+
},
177+
];
178+
}
179+
180+
onLinkClick(dbId: number, event: Event) {
181+
event.preventDefault();
182+
this.instanceLinkClick.emit(dbId);
183+
}
184+
}

0 commit comments

Comments
 (0)