Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
@if (loading) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading instance...</p>
</div>
} @else if (error) {
<div class="error-state">
<span class="material-symbols-rounded">error</span>
<p>Failed to load instance data.</p>
</div>
} @else {
<div class="instance-header">
<h2>
<a class="schema-link" [routerLink]="'/content/schema/' + schemaClass">{{ schemaClass }}</a>
<span class="dbid">{{ dbId }}</span>
</h2>
</div>

@if (rows.length > 0) {
<div class="attr-table-wrapper">
<table class="attr-table">
<thead>
<tr>
<th>Attribute</th>
<th>Value</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.name) {
<tr>
<td class="attr-name">{{ row.name }}</td>
<td class="attr-value">
@for (val of row.values; track $index) {
@if (val.type === 'link') {
<a class="instance-link" href="#" (click)="onLinkClick(val.dbId!, $event)">{{ val.text }}</a>
} @else {
<span class="text-value">{{ val.text }}</span>
}
@if (!$last) {
<br />
}
}
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="empty-section">
<p>No attributes found for this instance.</p>
</div>
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<void>();

@Input() instanceId!: number | string;
@Output() instanceLinkClick = new EventEmitter<number>();

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);
}
}
Loading