Skip to content

Commit e0a1ce0

Browse files
feat: use angular component for attributes panel
1 parent 3bbe2bc commit e0a1ce0

15 files changed

Lines changed: 1142 additions & 38 deletions

File tree

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,57 @@ The editor is built on [ProseMirror](https://prosemirror.net/) and leverages QTI
2222

2323
For more details, see the [Angular integration guide](https://qti-editor.citolab.nl/docs/frameworks/angular/).
2424

25+
## Attribute Panel Overrides
26+
27+
This app includes an app-level override layer for the attributes panel. It lets you
28+
change which fields are shown, how they are labeled, and where they appear in the
29+
panel without changing `@qti-editor/core`.
30+
31+
The override configuration lives in:
32+
33+
- `src/app/overrides/attribute-panel-overrides.ts`
34+
35+
The override type is exposed from:
36+
37+
- `src/app/shared/attribute-panel-overrides.ts`
38+
39+
Overrides are applied by the custom `qti-attributes-panel` implementation in:
40+
41+
- `src/components/blocks/attributes-panel/index.ts`
42+
43+
Supported override options per node type:
44+
45+
- `editableAttributes`: replace the editable attribute list
46+
- `hiddenAttributes`: hide specific attributes from the panel
47+
- `removeFields`: remove specific fields entirely
48+
- `fieldOrder`: control the order of editable and read-only fields
49+
- `fields`: override field labels or input definitions
50+
- `friendlyEditors`: append custom friendly editors
51+
- `replaceFriendlyEditors`: replace the core friendly editor list instead of appending
52+
- `friendlyEditorsPlacement`: place friendly editors at the top or bottom of the editable section
53+
54+
Example:
55+
56+
```ts
57+
import type { AttributePanelOverrides } from '../shared/attribute-panel-overrides';
58+
59+
export const ATTRIBUTE_PANEL_OVERRIDES: AttributePanelOverrides = {
60+
qtitextentryinteraction: {
61+
fieldOrder: ['responseIdentifier', 'expectedLength', 'placeholderText', 'class'],
62+
hiddenAttributes: ['format'],
63+
fields: {
64+
responseIdentifier: { label: 'Response identifier' },
65+
expectedLength: { label: 'Expected length', input: 'number' },
66+
},
67+
friendlyEditorsPlacement: 'bottom',
68+
},
69+
};
70+
```
71+
72+
Use this when the host app wants a different authoring experience than the shared
73+
QTI core metadata exposes by default. The core schema and attribute semantics stay
74+
the same; only the panel presentation changes.
75+
2576
## Development
2677

2778
To start a local development server:

src/app/app.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
<app-editor-host
2323
[identifier]="identifier()"
2424
[itemTitle]="itemTitle()"
25+
[attributePanelOverrides]="attributePanelOverrides"
2526
(contentChange)="onContentChange($event)"
2627
(metadataChange)="onMetadataChange($event)"
2728
></app-editor-host>

src/app/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99

1010
import { EditorHostComponent } from './components/editor-host/editor-host';
1111
import { MenuBarComponent } from './components/menu-bar/menu-bar';
12+
import { ATTRIBUTE_PANEL_OVERRIDES } from './overrides/attribute-panel-overrides';
1213
import { FileStorageService } from './services/file-storage.service';
1314
import type { SavedFileRecord } from './shared/file-record';
1415
import type { QtiContentChangeEventDetail } from '../lib/qti-prosekit-integration/events';
@@ -22,6 +23,7 @@ import type { QtiContentChangeEventDetail } from '../lib/qti-prosekit-integratio
2223
})
2324
export class App {
2425
protected readonly appTitle = 'QTI Editor';
26+
protected readonly attributePanelOverrides = ATTRIBUTE_PANEL_OVERRIDES;
2527

2628
protected readonly fileName = signal('angular-qti-item');
2729
protected readonly identifier = signal('ANGULAR_QTI_ITEM');
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { AttributePanelOverrides } from '../components/attributes-panel/attributes-panel.component';
2+
3+
/**
4+
* App-level attribute panel overrides.
5+
*
6+
* This lets the host application control which fields are visible and how they
7+
* are ordered without changing @qti-editor/core metadata.
8+
*/
9+
export const ATTRIBUTE_PANEL_OVERRIDES: AttributePanelOverrides = {
10+
// Example:
11+
// qtitextentryinteraction: {
12+
// fieldOrder: ['responseIdentifier', 'expectedLength', 'placeholderText', 'class'],
13+
// hiddenAttributes: ['format'],
14+
// fields: {
15+
// responseIdentifier: { label: 'Response identifier' },
16+
// expectedLength: { label: 'Expected length', input: 'number' },
17+
// },
18+
// },
19+
};
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<section class="card border border-base-300/50 bg-base-100">
2+
<div class="card-body gap-3 p-4">
3+
4+
<!-- Header -->
5+
<header class="flex items-start justify-between gap-3">
6+
<div>
7+
<h3 class="text-base font-semibold">Attributes</h3>
8+
<p class="text-xs text-base-content/70">
9+
{{ activeNode()?.type ?? 'No selection' }}
10+
</p>
11+
</div>
12+
</header>
13+
14+
<!-- Node switcher -->
15+
@if (nodes().length > 1) {
16+
<div class="flex flex-wrap gap-2">
17+
@for (node of nodes(); track node.pos) {
18+
<button
19+
type="button"
20+
class="btn btn-xs"
21+
[class.btn-primary]="selectedIndex() === $index"
22+
[class.btn-ghost]="selectedIndex() !== $index"
23+
(click)="setSelectedNode($index)"
24+
>
25+
{{ node.type }}
26+
</button>
27+
}
28+
</div>
29+
}
30+
31+
<!-- Panel body -->
32+
<div class="flex flex-col gap-3">
33+
@if (activeNode(); as node) {
34+
35+
<!-- Friendly editors: top placement -->
36+
@if (friendlyEditorsPlacement() === 'top') {
37+
@for (editor of friendlyEditors(); track editor.kind) {
38+
@if (isChoiceEditor(editor)) {
39+
<app-choice-attributes-editor
40+
[activeNode]="node"
41+
[presentation]="choiceInteractionPresentation()"
42+
(patch)="handlePatch($event)"
43+
/>
44+
}
45+
@if (isTextEntryEditor(editor)) {
46+
<app-text-entry-attributes-editor
47+
[activeNode]="node"
48+
(patch)="handlePatch($event)"
49+
/>
50+
}
51+
}
52+
}
53+
54+
<!-- Editable fields -->
55+
@if (sortedEditableEntries().length > 0) {
56+
@for (entry of sortedEditableEntries(); track entry[0]) {
57+
@let key = entry[0];
58+
@let value = entry[1];
59+
@let fieldMeta = getFieldMetadata(key, value);
60+
@if (fieldMeta.input === 'checkbox') {
61+
<label class="flex items-center justify-between gap-3">
62+
<span class="text-sm font-medium">{{ fieldMeta.label ?? key }}</span>
63+
<input
64+
class="checkbox checkbox-sm"
65+
type="checkbox"
66+
[checked]="!!value"
67+
(change)="handleFieldChange(key, value, $event)"
68+
/>
69+
</label>
70+
} @else if (fieldMeta.input === 'select') {
71+
<label class="form-control w-full">
72+
<span class="mb-1 text-sm font-medium">{{ fieldMeta.label ?? key }}</span>
73+
<select
74+
class="select select-sm select-bordered w-full"
75+
[value]="value ?? ''"
76+
(change)="handleFieldChange(key, value, $event)"
77+
>
78+
<option value="">Select…</option>
79+
@for (option of fieldMeta.options ?? []; track option.value) {
80+
<option [value]="option.value">{{ option.label }}</option>
81+
}
82+
</select>
83+
</label>
84+
} @else {
85+
<label class="form-control w-full">
86+
<span class="mb-1 text-sm font-medium">{{ fieldMeta.label ?? key }}</span>
87+
<input
88+
class="input input-sm input-bordered w-full"
89+
[type]="fieldMeta.input === 'number' ? 'number' : 'text'"
90+
[value]="value ?? ''"
91+
(input)="handleFieldChange(key, value, $event)"
92+
/>
93+
</label>
94+
}
95+
}
96+
} @else if (friendlyEditors().length === 0) {
97+
<p class="text-sm text-base-content/70">No editable attributes.</p>
98+
}
99+
100+
<!-- Friendly editors: bottom placement -->
101+
@if (friendlyEditorsPlacement() === 'bottom') {
102+
@for (editor of friendlyEditors(); track editor.kind) {
103+
@if (isChoiceEditor(editor)) {
104+
<app-choice-attributes-editor
105+
[activeNode]="node"
106+
[presentation]="choiceInteractionPresentation()"
107+
(patch)="handlePatch($event)"
108+
/>
109+
}
110+
@if (isTextEntryEditor(editor)) {
111+
<app-text-entry-attributes-editor
112+
[activeNode]="node"
113+
(patch)="handlePatch($event)"
114+
/>
115+
}
116+
}
117+
}
118+
119+
<!-- Read-only fields -->
120+
@if (sortedReadOnlyEntries().length > 0) {
121+
<details class="rounded-lg border border-base-300/50 bg-base-50 p-2">
122+
<summary class="cursor-pointer text-sm font-medium">
123+
Read-only attributes ({{ sortedReadOnlyEntries().length }})
124+
</summary>
125+
<div class="mt-3 flex flex-col gap-3 opacity-80">
126+
@for (entry of sortedReadOnlyEntries(); track entry[0]) {
127+
@let key = entry[0];
128+
@let value = entry[1];
129+
@let fieldMeta = getFieldMetadata(key, value);
130+
@if (fieldMeta.input === 'checkbox') {
131+
<label class="flex items-center justify-between gap-3">
132+
<span class="text-sm font-medium">{{ fieldMeta.label ?? key }}</span>
133+
<input class="checkbox checkbox-sm" type="checkbox" [checked]="!!value" disabled />
134+
</label>
135+
} @else if (fieldMeta.input === 'select') {
136+
<label class="form-control w-full">
137+
<span class="mb-1 text-sm font-medium">{{ fieldMeta.label ?? key }}</span>
138+
<select class="select select-sm select-bordered w-full" [value]="value ?? ''" disabled>
139+
@for (option of fieldMeta.options ?? []; track option.value) {
140+
<option [value]="option.value">{{ option.label }}</option>
141+
}
142+
</select>
143+
</label>
144+
} @else {
145+
<label class="form-control w-full">
146+
<span class="mb-1 text-sm font-medium">{{ fieldMeta.label ?? key }}</span>
147+
<input
148+
class="input input-sm input-bordered w-full"
149+
[type]="fieldMeta.input === 'number' ? 'number' : 'text'"
150+
[value]="value ?? ''"
151+
disabled
152+
/>
153+
</label>
154+
}
155+
}
156+
</div>
157+
</details>
158+
}
159+
160+
} @else {
161+
<p class="text-sm text-base-content/70">Place the cursor inside an interaction to inspect its attributes.</p>
162+
}
163+
</div>
164+
165+
</div>
166+
</section>

0 commit comments

Comments
 (0)