Skip to content

Commit f79d39f

Browse files
constantantclaude
andcommitted
feat(devtools-panel): create unregistered (local) mocks from the panel
Developers can now create mock entries directly in the DevTools panel for endpoints that have not yet been wired up with provideMockResource() in the app. The flow: pick a spec, select an operation, confirm the auto-generated key, enable catch mode — then add the Angular code. On the next page load the request is caught immediately. Key changes: - New "+ New mock" button in the mock table opens a CreateMockDialog: spec dropdown, searchable operation list, auto-filled/editable key field with conflict detection. - Local entries (status: 'local') persist across panel sessions in chrome.storage.local (oarm_local_mocks) including catch mode state. A delete (🗑) button removes them; all other controls work identically to live entries. - When the app registers the key, the local entry is promoted in-place (status → 'idle'), catch mode preserved, and setCatchMode re-sent. - SW now tracks catch mode per tab in memory + chrome.storage.session. On tabs.onUpdated (status: 'loading') it injects window.__oarmPendingCatch__ via executeScript with injectImmediately:true before Angular's module scripts execute. - MockResourceBus constructor reads window.__oarmPendingCatch__ and pre-populates catchModeKeys so the very first _notifyRequest call is intercepted without any async round-trip. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5b869c1 commit f79d39f

10 files changed

Lines changed: 402 additions & 11 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
mat-dialog-content {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 8px;
5+
padding-top: 4px;
6+
min-width: 400px;
7+
max-height: 60vh;
8+
}
9+
10+
.spec-row mat-form-field,
11+
.filter-row .filter-field {
12+
width: 100%;
13+
}
14+
15+
.ops-list {
16+
flex: 1;
17+
overflow-y: auto;
18+
border: 1px solid color-mix(in srgb, var(--mat-sys-on-surface) 15%, transparent);
19+
border-radius: 4px;
20+
min-height: 120px;
21+
max-height: 260px;
22+
}
23+
24+
.op-row {
25+
display: grid;
26+
grid-template-columns: 52px 1fr auto;
27+
align-items: center;
28+
gap: 6px;
29+
width: 100%;
30+
padding: 5px 8px;
31+
border: none;
32+
background: transparent;
33+
color: inherit;
34+
font-family: var(--app-font-mono), monospace;
35+
font-size: 11px;
36+
text-align: left;
37+
cursor: pointer;
38+
border-bottom: 1px solid color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent);
39+
40+
&:last-child { border-bottom: none; }
41+
&:hover { background: color-mix(in srgb, var(--mat-sys-on-surface) 6%, transparent); }
42+
&.selected { background: color-mix(in srgb, var(--mat-sys-primary) 20%, transparent); }
43+
}
44+
45+
.op-method {
46+
font-weight: 700;
47+
font-size: 10px;
48+
text-align: center;
49+
}
50+
51+
.op-path {
52+
overflow: hidden;
53+
text-overflow: ellipsis;
54+
white-space: nowrap;
55+
color: var(--mat-sys-on-surface);
56+
}
57+
58+
.op-id {
59+
font-size: 10px;
60+
color: var(--mat-sys-on-surface-variant);
61+
overflow: hidden;
62+
text-overflow: ellipsis;
63+
white-space: nowrap;
64+
max-width: 130px;
65+
}
66+
67+
.ops-empty {
68+
padding: 16px;
69+
text-align: center;
70+
color: var(--mat-sys-on-surface-variant);
71+
font-size: 12px;
72+
}
73+
74+
.key-row .key-field { width: 100%; }
75+
76+
.key-conflict { color: var(--mat-sys-error); }
77+
78+
.empty-state {
79+
text-align: center;
80+
color: var(--mat-sys-on-surface-variant);
81+
font-size: 12px;
82+
line-height: 1.8;
83+
padding: 16px 0;
84+
}
85+
86+
/* Method colours — same as mock-table.css */
87+
.method-get { color: #4fc3f7; }
88+
.method-post { color: #81c784; }
89+
.method-put { color: #ffb74d; }
90+
.method-patch { color: #ffd54f; }
91+
.method-delete { color: #e57373; }
92+
.method-head,
93+
.method-options { color: #ba68c8; }
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
@if (specList().length === 0) {
2+
<h2 mat-dialog-title>New mock</h2>
3+
<mat-dialog-content class="empty-state">
4+
<p>No specs imported yet.</p>
5+
<p>Go to the <strong>Specs</strong> tab and import an OpenAPI spec first.</p>
6+
</mat-dialog-content>
7+
<mat-dialog-actions align="end">
8+
<button mat-button mat-dialog-close>Close</button>
9+
</mat-dialog-actions>
10+
} @else {
11+
<h2 mat-dialog-title>New mock</h2>
12+
13+
<mat-dialog-content>
14+
<div class="spec-row">
15+
<mat-form-field appearance="outline" subscriptSizing="dynamic">
16+
<mat-label>Spec</mat-label>
17+
<mat-select [value]="effectiveSpecId()" (selectionChange)="onSpecChange($event.value)">
18+
@for (spec of specList(); track spec.specId) {
19+
<mat-option [value]="spec.specId">
20+
{{ spec.specId }} ({{ spec.mocks.length }})
21+
</mat-option>
22+
}
23+
</mat-select>
24+
</mat-form-field>
25+
</div>
26+
27+
<div class="filter-row">
28+
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="filter-field">
29+
<mat-label>Filter operations</mat-label>
30+
<input matInput placeholder="path, operationId, method…"
31+
[value]="operationFilter()"
32+
(input)="operationFilter.set($any($event.target).value)" />
33+
</mat-form-field>
34+
</div>
35+
36+
<div class="ops-list">
37+
@for (mock of filteredMocks(); track mock.operationId) {
38+
<button class="op-row"
39+
[class.selected]="selectedMock()?.operationId === mock.operationId"
40+
(click)="selectOperation(mock)">
41+
<span class="op-method method-{{ mock.method }}">{{ mock.method.toUpperCase() }}</span>
42+
<span class="op-path" [title]="mock.path">{{ mock.path }}</span>
43+
<span class="op-id" [title]="mock.operationId">{{ mock.operationId }}</span>
44+
</button>
45+
}
46+
@if (filteredMocks().length === 0) {
47+
<div class="ops-empty">No operations match.</div>
48+
}
49+
</div>
50+
51+
<div class="key-row">
52+
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="key-field">
53+
<mat-label>Key</mat-label>
54+
<input matInput placeholder="TOKEN_NAME"
55+
[value]="keyInput()"
56+
(input)="keyInput.set($any($event.target).value)" />
57+
@if (keyConflict()) {
58+
<mat-hint class="key-conflict">Key already exists in the mock table</mat-hint>
59+
}
60+
</mat-form-field>
61+
</div>
62+
</mat-dialog-content>
63+
64+
<mat-dialog-actions align="end">
65+
<button mat-button mat-dialog-close>Cancel</button>
66+
<button mat-flat-button
67+
[disabled]="!canCreate()"
68+
[matTooltip]="keyConflict() ? 'Key already exists' : ''"
69+
(click)="create()">
70+
Create
71+
</button>
72+
</mat-dialog-actions>
73+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Component, computed, inject, signal } from '@angular/core';
2+
import { MatButtonModule } from '@angular/material/button';
3+
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
4+
import { MatSelectModule } from '@angular/material/select';
5+
import { MatFormFieldModule } from '@angular/material/form-field';
6+
import { MatInputModule } from '@angular/material/input';
7+
import { MatTooltipModule } from '@angular/material/tooltip';
8+
import { MOCK_BRIDGE } from '../../mock-bridge.token';
9+
import { SPEC_STORE, toScreamingSnake, ManifestMock, SpecEntry } from '../../spec-store.token';
10+
11+
@Component({
12+
selector: 'app-create-mock-dialog',
13+
imports: [MatButtonModule, MatDialogModule, MatSelectModule, MatFormFieldModule, MatInputModule, MatTooltipModule],
14+
templateUrl: './create-mock-dialog.html',
15+
styleUrl: './create-mock-dialog.css',
16+
})
17+
export class CreateMockDialog {
18+
protected readonly specStore = inject(SPEC_STORE);
19+
protected readonly bridge = inject(MOCK_BRIDGE);
20+
protected readonly dialogRef = inject(MatDialogRef<CreateMockDialog>);
21+
22+
protected readonly specList = computed<SpecEntry[]>(() => [...this.specStore.specs().values()]);
23+
24+
protected readonly selectedSpecId = signal<string | null>(null);
25+
protected readonly operationFilter = signal('');
26+
protected readonly selectedMock = signal<ManifestMock | null>(null);
27+
protected readonly keyInput = signal('');
28+
29+
protected readonly effectiveSpecId = computed<string | null>(() => {
30+
const explicit = this.selectedSpecId();
31+
if (explicit) return explicit;
32+
const list = this.specList();
33+
return list.length > 0 ? list[0].specId : null;
34+
});
35+
36+
protected readonly filteredMocks = computed<ManifestMock[]>(() => {
37+
const specId = this.effectiveSpecId();
38+
if (!specId) return [];
39+
const entry = this.specStore.specs().get(specId);
40+
if (!entry) return [];
41+
const f = this.operationFilter().toLowerCase();
42+
if (!f) return entry.mocks;
43+
return entry.mocks.filter(
44+
(m) => m.operationId.toLowerCase().includes(f) || m.path.toLowerCase().includes(f) || m.method.toLowerCase().includes(f),
45+
);
46+
});
47+
48+
protected readonly keyConflict = computed(() => {
49+
const k = this.keyInput().trim();
50+
return k !== '' && this.bridge.mocks().has(k);
51+
});
52+
53+
protected readonly canCreate = computed(() => {
54+
const mock = this.selectedMock();
55+
const key = this.keyInput().trim();
56+
return mock !== null && key !== '' && !this.keyConflict();
57+
});
58+
59+
protected selectOperation(mock: ManifestMock): void {
60+
this.selectedMock.set(mock);
61+
this.keyInput.set(toScreamingSnake(mock.operationId));
62+
}
63+
64+
protected onSpecChange(specId: string): void {
65+
this.selectedSpecId.set(specId);
66+
this.selectedMock.set(null);
67+
this.keyInput.set('');
68+
this.operationFilter.set('');
69+
}
70+
71+
protected create(): void {
72+
const mock = this.selectedMock();
73+
const key = this.keyInput().trim();
74+
const specId = this.effectiveSpecId();
75+
if (!mock || !key || !specId) return;
76+
this.bridge.createLocalMock(key, {
77+
specId,
78+
operationId: mock.operationId,
79+
path: mock.path,
80+
method: mock.method,
81+
...(mock.tag ? { tag: mock.tag } : {}),
82+
});
83+
this.dialogRef.close();
84+
}
85+
}

apps/devtools-panel/src/app/components/mock-table/mock-table.css

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
11
:host {
22
flex: 1;
33
min-width: 0;
4-
overflow: auto;
5-
display: block;
4+
overflow: hidden;
5+
display: flex;
6+
flex-direction: column;
7+
}
8+
9+
.new-mock-bar {
10+
flex-shrink: 0;
11+
display: flex;
12+
align-items: center;
13+
padding: 2px 4px;
14+
border-bottom: 1px solid color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent);
15+
16+
button { font-size: 12px; }
617
}
718

819
.table-wrap {
9-
height: 100%;
20+
flex: 1;
21+
height: 0;
1022
overflow: auto;
1123
}
1224

@@ -22,10 +34,10 @@ table {
2234
.mat-mdc-row.selected { background: color-mix(in srgb, var(--mat-sys-primary) 20%, transparent); }
2335
.mat-mdc-row.catching { border-left: 2px solid var(--app-color-catch); }
2436

25-
.mat-column-key { width: 32%; }
37+
.mat-column-key { width: 30%; }
2638
.mat-column-status { width: 13%; }
27-
.mat-column-lastEvent { width: 28%; }
28-
.mat-column-actions { width: 27%; }
39+
.mat-column-lastEvent { width: 22%; }
40+
.mat-column-actions { width: 35%; }
2941

3042
.key-cell {
3143
font-family: var(--app-font-mono), monospace;

apps/devtools-panel/src/app/components/mock-table/mock-table.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
<div class="new-mock-bar">
2+
<button mat-button (click)="openCreateDialog()">+ New mock</button>
3+
</div>
4+
15
@if (entries().length === 0) {
26
<div class="empty-state">
37
<p>No mocks registered yet.</p>
@@ -48,6 +52,10 @@
4852
<th mat-header-cell *matHeaderCellDef></th>
4953
<td mat-cell *matCellDef="let row" class="actions-cell"
5054
(click)="$event.stopPropagation()">
55+
@if (row.state.status === 'local') {
56+
<button mat-icon-button matTooltip="Remove local mock"
57+
(click)="bridge.deleteLocalMock(row.key)">🗑</button>
58+
}
5159
<button mat-icon-button
5260
[class.catch-active]="row.catchMode"
5361
[matTooltip]="row.catchMode ? 'Catch mode on — click to disable' : 'Enable catch mode'"

apps/devtools-panel/src/app/components/mock-table/mock-table.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { Component, computed, inject, input } from '@angular/core';
22
import { MatButtonModule } from '@angular/material/button';
3+
import { MatDialog } from '@angular/material/dialog';
34
import { MatTableModule } from '@angular/material/table';
45
import { MatTooltipModule } from '@angular/material/tooltip';
56
import { MOCK_BRIDGE } from '../../mock-bridge.token';
67
import type { MockEntry } from '../../mock-entry';
8+
import { CreateMockDialog } from '../create-mock-dialog/create-mock-dialog';
79

810
@Component({
911
selector: 'app-mock-table',
@@ -13,6 +15,7 @@ import type { MockEntry } from '../../mock-entry';
1315
})
1416
export class MockTable {
1517
protected readonly bridge = inject(MOCK_BRIDGE);
18+
private readonly dialog = inject(MatDialog);
1619
readonly filter = input<string>('');
1720

1821
protected readonly entries = computed<MockEntry[]>(() => {
@@ -24,6 +27,10 @@ export class MockTable {
2427

2528
protected readonly cols = ['key', 'status', 'lastEvent', 'actions'];
2629

30+
protected openCreateDialog(): void {
31+
this.dialog.open(CreateMockDialog, { autoFocus: false });
32+
}
33+
2734
protected ago(ts: number): string {
2835
const ms = Date.now() - ts;
2936
if (ms < 1000) return `${ms}ms ago`;

0 commit comments

Comments
 (0)