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
160 changes: 160 additions & 0 deletions app/src/__tests__/pipes-lint-show.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { TestBed } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it } from 'vitest';
import { PipesInspector } from '../pages/pipes-inspector';

const PIPES = [
{
name: 'currencyFormat',
className: 'CurrencyFormatPipe',
file: 'src/app/currency-format.pipe.ts',
line: 4,
isStandalone: true,
isPure: true,
},
{
name: 'filterBy',
className: 'FilterByPipe',
file: 'src/app/filter-by.pipe.ts',
line: 3,
isStandalone: true,
isPure: false,
},
];

const LINT = [
{
rule: 'impure-pipe-in-for',
severity: 'warning',
pipe: 'filterBy',
file: 'src/app/list.html',
line: 12,
message: 'Impure pipe inside @for.',
fix: 'Use a pure pipe.',
},
{
rule: 'signal-read-in-pure-pipe',
severity: 'warning',
pipe: 'currencyFormat',
file: 'src/app/currency-format.pipe.ts',
line: 9,
message: 'Reads a signal.',
fix: 'Pass the value in.',
},
{
rule: 'impure-pipe-in-for',
severity: 'warning',
pipe: 'libraryPipe',
file: 'src/app/list.html',
line: 20,
message: 'Impure pipe inside @for.',
fix: 'Use a pure pipe.',
},
];

function client() {
const rpc = {
call: (name: string) => {
if (name === 'get-pipes') return Promise.resolve(PIPES);
if (name === 'pipe-lint') return Promise.resolve(LINT);
return Promise.resolve(null);
},
callEvent: () => Promise.resolve(),
sharedState: () =>
Promise.resolve({
value: () => ({ pipes: [], async: [], instrumented: [] }),
on: () => () => {},
}),
};
return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
}

async function settle(fixture: { whenStable(): Promise<unknown> }) {
for (let i = 0; i < 4; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
}

async function render() {
const fixture = TestBed.createComponent(PipesInspector);
document.body.appendChild(fixture.nativeElement);
fixture.componentRef.setInput('rpc', client());
await settle(fixture);
return fixture;
}

function showButtons(host: HTMLElement) {
return Array.from(host.querySelectorAll<HTMLButtonElement>('.findings button.pipe-link'));
}

function names(host: HTMLElement) {
return Array.from(host.querySelectorAll('[data-pipe-row] .name')).map((n) => n.textContent);
}

describe('pipes lint show pipe', () => {
afterEach(() => {
document.body.innerHTML = '';
});

it('gives findings with a listed pipe a Show pipe button, and none to the rest', async () => {
const fixture = await render();
const host = fixture.nativeElement as HTMLElement;
const buttons = showButtons(host);
expect(buttons.map((b) => b.textContent?.trim())).toEqual([
'Show pipe filterBy',
'Show pipe currencyFormat',
]);
const findings = host.querySelectorAll('.findings .finding');
expect(findings).toHaveLength(3);
expect(findings[2].querySelector('button')).toBeNull();
expect(findings[2].querySelector('.where')?.textContent).toContain('libraryPipe');
});

it('selects the pipe and moves focus to its detail heading', async () => {
const fixture = await render();
const host = fixture.nativeElement as HTMLElement;
showButtons(host)[0].click();
await settle(fixture);
expect(fixture.componentInstance.selected()?.name).toBe('filterBy');
expect(host.querySelector('.row.selected .name')?.textContent).toBe('filterBy');
const heading = host.querySelector('#pipe-detail-title');
expect(heading?.textContent).toBe('filterBy');
expect(document.activeElement).toBe(heading);
});

it('clears a search and a kind filter that hide the pipe', async () => {
const fixture = await render();
const host = fixture.nativeElement as HTMLElement;
const page = fixture.componentInstance;
page.filter.set('currency');
page.kind.set('custom');
await settle(fixture);
expect(names(host)).toEqual(['currencyFormat']);
showButtons(host)[0].click();
await settle(fixture);
expect(page.filter()).toBe('');
expect(page.kind()).toBe('custom');
expect(names(host)).toContain('filterBy');

page.kind.set('builtin');
await settle(fixture);
showButtons(host)[1].click();
await settle(fixture);
expect(page.kind()).toBe('all');
expect(page.selected()?.name).toBe('currencyFormat');
});

it('keeps filters that do not hide the pipe', async () => {
const fixture = await render();
const page = fixture.componentInstance;
page.filter.set('pipe.ts');
page.kind.set('impure');
await settle(fixture);
showButtons(fixture.nativeElement)[0].click();
await settle(fixture);
expect(page.filter()).toBe('pipe.ts');
expect(page.kind()).toBe('impure');
expect(page.selected()?.name).toBe('filterBy');
});
});
109 changes: 93 additions & 16 deletions app/src/pages/pipes-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
Component,
DestroyRef,
ElementRef,
Injector,
afterNextRender,
computed,
effect,
inject,
Expand Down Expand Up @@ -266,7 +268,7 @@ const KIND_OPTIONS: readonly SelectOption<PipeKind>[] = [
<section id="pipe-detail" class="detail" aria-labelledby="pipe-detail-title">
@if (selected(); as p) {
<header class="detail-head">
<h2 id="pipe-detail-title" class="mono">{{ p.name }}</h2>
<h2 id="pipe-detail-title" class="mono" tabindex="-1">{{ p.name }}</h2>
<span class="chip" [class.impure]="!p.isPure">{{
p.isPure ? 'pure' : 'impure'
}}</span>
Expand Down Expand Up @@ -468,7 +470,17 @@ const KIND_OPTIONS: readonly SelectOption<PipeKind>[] = [
<div class="finding-head">
<span class="severity" [attr.data-tone]="f.severity">{{ f.severity }}</span>
<code class="mono rule">{{ f.rule }}</code>
<span class="where mono">{{ f.pipe }} · {{ f.file }}:{{ f.line }}</span>
@let target = pipeFor(f);
<span class="where mono">
@if (target) {
<button type="button" class="pipe-link" (click)="showPipe(target)">
<span class="sr-only">Show pipe </span>{{ f.pipe }}
</button>
} @else {
{{ f.pipe }}
}
· {{ f.file }}:{{ f.line }}
</span>
</div>
<p class="finding-message">{{ f.message }}</p>
<p class="finding-fix"><span class="fix-label">Fix</span> {{ f.fix }}</p>
Expand Down Expand Up @@ -783,6 +795,13 @@ const KIND_OPTIONS: readonly SelectOption<PipeKind>[] = [
font-size: 15px;
font-weight: 600;
overflow-wrap: anywhere;
border-radius: 4px;
}
.detail-head h2:focus {
outline: none;
}
.detail-head h2:focus-visible {
@include m.focus-ring;
}
.block {
padding: 14px 16px;
Expand Down Expand Up @@ -990,6 +1009,27 @@ const KIND_OPTIONS: readonly SelectOption<PipeKind>[] = [
font-size: 11px;
overflow-wrap: anywhere;
}
button.pipe-link {
display: inline;
height: auto;
padding: 0;
border: 0;
border-radius: 4px;
background: none;
color: var(--accent);
font: inherit;
text-decoration: underline;
text-underline-offset: 3px;
white-space: normal;
}
button.pipe-link:hover:not(:disabled) {
border-color: transparent;
background: none;
color: var(--accent-hover);
}
button.pipe-link:focus-visible {
@include m.focus-ring;
}
.finding-message {
margin: 6px 0 0;
color: var(--text);
Expand Down Expand Up @@ -1057,6 +1097,7 @@ const KIND_OPTIONS: readonly SelectOption<PipeKind>[] = [
export class PipesInspector {
private readonly destroyRef = inject(DestroyRef);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
private readonly filterInput = viewChild<ElementRef<HTMLInputElement>>('filterInput');

rpc = input<DevframeRpcClient | null>(null);
Expand Down Expand Up @@ -1088,21 +1129,15 @@ export class PipesInspector {
private readonly liveByName = computed(() => new Map(this.live().map((p) => [p.name, p])));

filtered = computed(() => {
const q = this.filter().trim().toLowerCase();
const q = this.filter();
const kind = this.kind() ?? 'all';
const live = this.liveByName();
return this.pipes().filter((p) => {
if (kind === 'custom' && p.builtin) return false;
if (kind === 'builtin' && !p.builtin) return false;
if (kind === 'impure' && p.isPure) return false;
if (kind === 'live' && !live.has(p.name)) return false;
return (
!q ||
p.name.toLowerCase().includes(q) ||
p.className.toLowerCase().includes(q) ||
p.file.toLowerCase().includes(q)
);
});
return this.pipes().filter((p) => this.fitsKind(p, kind) && this.fitsQuery(p, q));
});

private readonly pipesByName = computed(() => {
const byName = new Map<string, PipeInfo[]>();
for (const p of this.pipes()) byName.set(p.name, [...(byName.get(p.name) ?? []), p]);
return byName;
});

private unsubscribe?: () => void;
Expand Down Expand Up @@ -1239,6 +1274,48 @@ export class PipesInspector {
this.selected.set(this.isSelected(pipe) ? null : pipe);
}

/** The listed pipe a lint finding is about: the one declared in the finding's file, or else
* the first pipe with that name. Null when the pipe is not in the list. */
pipeFor(finding: PipeLintFinding): PipeInfo | null {
const matches = this.pipesByName().get(finding.pipe);
if (!matches?.length) return null;
return matches.find((p) => p.file === finding.file) ?? matches[0];
}

/** Selects a pipe from a lint finding, clearing only the filters that hide it, then scrolls
* its row into view and moves focus to the detail heading. */
showPipe(pipe: PipeInfo) {
if (!this.fitsQuery(pipe, this.filter())) this.filter.set('');
if (!this.fitsKind(pipe, this.kind() ?? 'all')) this.kind.set('all');
this.selected.set(pipe);
afterNextRender(
() => {
const host = this.host.nativeElement;
host.querySelector<HTMLElement>('.row.selected')?.scrollIntoView?.({ block: 'nearest' });
host.querySelector<HTMLElement>('#pipe-detail-title')?.focus();
},
{ injector: this.injector },
);
}

private fitsKind(p: PipeInfo, kind: PipeKind): boolean {
if (kind === 'custom') return !p.builtin;
if (kind === 'builtin') return !!p.builtin;
if (kind === 'impure') return !p.isPure;
if (kind === 'live') return this.liveByName().has(p.name);
return true;
}

private fitsQuery(p: PipeInfo, filter: string): boolean {
const q = filter.trim().toLowerCase();
return (
!q ||
p.name.toLowerCase().includes(q) ||
p.className.toLowerCase().includes(q) ||
p.file.toLowerCase().includes(q)
);
}

clearFilters() {
this.filter.set('');
this.kind.set('all');
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/src/content/inspectors/pipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ A source that changes on three reports in a row, like `getData() | async` return
| `signal-read-in-pure-pipe` | warning, info | A pure pipe whose `transform()` reads a `signal`, `input`, `computed`, `linkedSignal`, `toSignal` or `model` field of the pipe (warning). A call with no arguments on an injected service, like `this.rates.current()`, might be a signal read (info). |
| `async-on-call` | info | A method call piped to `\| async`, like `getUsers() \| async`. If the method builds a new Observable each time, `AsyncPipe` resubscribes on every check. Signal, `input`, `computed` and `toSignal` fields of the component are skipped. |

If the pipe in a finding is in the pipe list, its name is a button. Click it to select that pipe and open its details. A search or **Show pipes** filter that hides the pipe is cleared first; filters that already show it stay. A pipe that is not in the list, like one from a library, stays plain text.

## Where the data comes from

<ngmd-card-grid columns="3">
Expand Down

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

Loading
Loading