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
83 changes: 82 additions & 1 deletion app/src/__tests__/store-inspector.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { StoreInspector } from '../pages/store-inspector';
import type {
NgrxLogEntry,
Expand Down Expand Up @@ -196,6 +196,87 @@ describe('StoreInspector restore', () => {
});
});

describe('StoreInspector copy', () => {
const original = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
let copied: string[] = [];

function stubClipboard(writeText?: (text: string) => Promise<void>) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: writeText ? { writeText } : undefined,
});
}

beforeEach(() => {
copied = [];
stubClipboard((text) => {
copied.push(text);
return Promise.resolve();
});
});

afterEach(() => {
if (original) Object.defineProperty(navigator, 'clipboard', original);
else delete (navigator as { clipboard?: unknown }).clipboard;
});

function copyButton(fixture: ComponentFixture<unknown>, label: string) {
return root(fixture).querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
}

function message(fixture: ComponentFixture<unknown>) {
const el = root(fixture).querySelector('.message[role="status"]');
return el?.textContent?.trim();
}

it('copies the formatted state shown in the panel', async () => {
const { fixture } = await mount([]);
const shown = root(fixture).querySelector('pre.tree')!.textContent;
copyButton(fixture, 'Copy state')!.click();
await settle(fixture);
expect(copied).toEqual([shown]);
expect(copied[0]).toBe('{\n items: []\n}');
expect(message(fixture)).toBe('Copied.');
});

it('copies the state diff of the selected entry', async () => {
const diff = [{ path: 'items.0', op: 'add' as const, after: { id: 1 } }];
const { fixture } = await mount([entry(1, { diff })]);
expect(copyButton(fixture, 'Copy the state diff of #1')).toBeNull();
root(fixture).querySelector<HTMLButtonElement>('.log-item')!.click();
await settle(fixture);
copyButton(fixture, 'Copy the state diff of #1')!.click();
await settle(fixture);
expect(copied).toEqual([
'[\n {\n path: "items.0",\n op: "add",\n after: {\n id: 1\n }\n }\n]',
]);
expect(message(fixture)).toBe('Copied.');
});

it('offers no diff copy when the entry changed nothing', async () => {
const { fixture } = await mount([entry(1)]);
root(fixture).querySelector<HTMLButtonElement>('.log-item')!.click();
await settle(fixture);
expect(copyButton(fixture, 'Copy the state diff of #1')).toBeNull();
});

it('says so when the clipboard is missing', async () => {
stubClipboard();
const { fixture } = await mount([]);
copyButton(fixture, 'Copy state')!.click();
await settle(fixture);
expect(message(fixture)).toBe('The clipboard is not available here.');
});

it('says so when the clipboard rejects the write', async () => {
stubClipboard(() => Promise.reject(new Error('denied')));
const { fixture } = await mount([]);
copyButton(fixture, 'Copy state')!.click();
await settle(fixture);
expect(message(fixture)).toBe('The clipboard is not available here.');
});
});

describe('StoreInspector signal store', () => {
function signalStore(overrides: Partial<NgrxSignalStoreInfo> = {}): NgrxSignalStoreInfo {
return {
Expand Down
69 changes: 67 additions & 2 deletions app/src/pages/store-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,17 @@ const CLASSIC_KINDS = new Set([

<div class="facts">
<section class="fact" aria-labelledby="ngrx-state-heading">
<h4 id="ngrx-state-heading">State</h4>
<div class="fact-head">
<h4 id="ngrx-state-heading">State</h4>
<button
type="button"
class="btn copy"
aria-label="Copy state"
(click)="copyState()"
>
Copy
</button>
</div>
<pre #stateTree class="tree" tabindex="0" aria-labelledby="ngrx-state-heading">{{
stateText()
}}</pre>
Expand Down Expand Up @@ -588,7 +598,19 @@ const CLASSIC_KINDS = new Set([
}
</dl>
@if (selected.source !== 'event') {
<div class="sub" role="heading" [attr.aria-level]="headingLevel + 1">State diff</div>
<div class="sub-head">
<div class="sub" role="heading" [attr.aria-level]="headingLevel + 1">State diff</div>
@if (selected.diff.length) {
<button
type="button"
class="btn copy"
[attr.aria-label]="'Copy the state diff of #' + selected.seq"
(click)="copyDiff(selected)"
>
Copy
</button>
}
</div>
@if (selected.diff.length) {
<ul class="diff">
@for (change of selected.diff; track change.path) {
Expand Down Expand Up @@ -900,6 +922,32 @@ const CLASSIC_KINDS = new Set([
.sub {
margin: 14px 0 8px;
}
.fact-head,
.sub-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.fact-head {
margin-bottom: 10px;
}
.fact-head h4 {
margin: 0;
}
.sub-head {
margin: 14px 0 8px;
}
.sub-head .sub {
margin: 0;
}
.btn.copy {
flex: none;
height: 26px;
padding: 0 10px;
font-size: 12px;
}
.pill {
min-width: 20px;
padding: 0 7px;
Expand Down Expand Up @@ -1912,6 +1960,23 @@ export class StoreInspector {
this.message.set('');
}

copyState() {
return this.copyText(this.stateText());
}

copyDiff(entry: NgrxLogEntry) {
return this.copyText(pretty(entry.diff));
}

private async copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
this.message.set('Copied.');
} catch {
this.message.set('The clipboard is not available here.');
}
}

askRestore(seq: number) {
this.confirmSeq.set(seq);
this.focusConfirm.set('cancel');
Expand Down
8 changes: 6 additions & 2 deletions apps/docs/src/content/inspectors/ngrx-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Select a store to see:
- Its kind, scope and declaring file. The file appears when the store's state keys match a `signalStore` or `signalState` in your source.
- **Store DevTools on** or **Store DevTools off**, for the classic Store.
- **Referenced by**: the component fields that hold it.
- **State**, **Computed** and **Methods**, with a call count per method. The tab tags `signalMethod` and `rxMethod` members. Once a method has been called, its chip also shows the average and last call duration, in milliseconds.
- **State**, **Computed** and **Methods**, with a call count per method. **Copy** next to **State** copies the state as shown in the panel, with secrets already redacted. The copy is the panel's formatted text, not strict JSON. The tab tags `signalMethod` and `rxMethod` members. Once a method has been called, its chip also shows the average and last call duration, in milliseconds.
- **Entities**, for a `signalStore` that calls `withEntities()`. One group per collection, with the entity count and the ids as chips. A group past 30 ids shows the first 30 and a count of the rest.

### Change log
Expand All @@ -45,7 +45,7 @@ Signal stores get a **Change log**. The classic Store gets an **Action log**. Ea
| `effect` | An NgRx effect. Effects send their actions through `Store.next`. |
| `reactive` | `store.dispatch(() => action)`, which dispatches again when a signal changes. |

Open an entry to see its arguments, or the action and its **Origin** for the classic Store, and a **State diff** with the value before and after each change. The diff lists up to 50 changes. A method-call entry also shows its **Duration**, in milliseconds. An entry caused by a dispatched event shows the event under **Caused by event**. An action entry also has **Dispatch again**.
Open an entry to see its arguments, or the action and its **Origin** for the classic Store, and a **State diff** with the value before and after each change. The diff lists up to 50 changes. A method-call entry also shows its **Duration**, in milliseconds. An entry caused by a dispatched event shows the event under **Caused by event**. An action entry also has **Dispatch again**. **Copy** next to **State diff** copies that entry's diff in the same format as **State**, one object per change with its `path`, `op`, `before` and `after`. An entry that changed nothing has no diff to copy.

Entry numbers start at 1 again when the app reloads. So when the app reloads, or the selected page closes and the tab shows another one, the open entry closes and a pending **Restore** confirmation is cancelled.

Expand Down Expand Up @@ -190,6 +190,10 @@ The devtools replace state keys with secret-looking names with `[redacted]`, at

The origin comes from the Store calls made while the page is connected. A `store.dispatch(() => action)` that started before the overlay connected shows as `dispatch`. An action sent straight to `ActionsSubject` has no origin.

### Copy needs clipboard access

The copy buttons use the browser clipboard. If the panel cannot write to it, for example in a page without clipboard permission, the tab says **The clipboard is not available here.** instead of **Copied.**

### Write actions

Restore, **Back to latest**, **Dispatch** and **Dispatch again** need [`actions.ngrx`](../getting-started/configuration.md#actions). With it off, the panel disables them and the server refuses them.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading