Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
59 changes: 57 additions & 2 deletions examples/app/contact-book-manager/src/cb-app/cb-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// Licensed under the MIT license.

import { WebUIElement, observable } from '@microsoft/webui-framework';
import { Router } from '@microsoft/webui-router';
import { api } from '#api';
import { Router, isStateful } from '@microsoft/webui-router';
import { api, type Contact } from '#api';

// Child components used in cb-app.html
import '#organisms/cb-header/cb-header.js';
Expand All @@ -21,8 +21,63 @@ export class CbApp extends WebUIElement {
@observable totalFavorites = '0';
@observable groups: string[] = [];

private searchGen = 0;

private readonly onNavigated = (): void => {
if (this.searchQuery) void this.applySearch(this.searchQuery);
};

override connectedCallback(): void {
super.connectedCallback();
window.addEventListener('webui:route:navigated', this.onNavigated);
}

override disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener('webui:route:navigated', this.onNavigated);
}

onSearch(e: SearchChangeEvent): void {
this.searchQuery = e.detail.value;
void this.applySearch(e.detail.value);
}

private async applySearch(query: string): Promise<void> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main architectural concern: cb-app now has to know about concrete route page tags, the router's active DOM shape, API filters, and manual setState() calls. That bypasses the router/state model we already have.

Can we model search as route query state instead and let the router apply route state to the active page? Sketching the direction:

onSearch(e: SearchChangeEvent): void {
  this.searchQuery = e.detail.value;

  const next = new URL(window.location.href);
  const query = e.detail.value.trim();
  if (query) {
    next.searchParams.set('q', query);
  } else {
    next.searchParams.delete('q');
  }

  Router.navigate(`${next.pathname}${next.search}`);
}

Then the list routes can declare query="q" and their route state/loader can return filtered contacts. That keeps search refresh/back/forward/direct-load behavior in the same path as normal WebUI routing.

const gen = ++this.searchGen;
const root = this.shadowRoot;
if (!root) return;

const activeRoute = root.querySelector('webui-route[active]');
if (!activeRoute) return;

const contactsPage = activeRoute.querySelector('cb-page-contacts');
const favoritesPage = activeRoute.querySelector('cb-page-favorites');
const groupPage = activeRoute.querySelector('cb-page-group');

let pageEl: Element | null = null;
let favorites = false;
let group = '';

if (contactsPage) {
pageEl = contactsPage;
} else if (favoritesPage) {
pageEl = favoritesPage;
favorites = true;
} else if (groupPage) {
pageEl = groupPage;
group = this.activeGroup;
}

if (!pageEl) return;

const contacts: Contact[] = await api.contacts.list({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generation counter prevents stale writes, but it does not cancel in-flight requests, so search-as-you-type can still create one request per keystroke. Route loaders already receive an AbortSignal, which is a better fit for this.

For example, after threading signal through api.contacts.list, the page can own the data load:

import type { RouteLoaderContext } from '@microsoft/webui-router';

export class CbPageGroup extends WebUIElement {
  @observable contacts: Contact[] = [];
  @observable groupName = '';

  static async loader({ params, query, signal }: RouteLoaderContext) {
    const contacts = await api.contacts.list(
      { q: query.q || undefined, group: params.group },
      { signal },
    );

    return { contacts, groupName: params.group };
  }
}

Same pattern can cover contacts and favorites without app-shell DOM probing.

q: query || undefined,
favorites: favorites || undefined,
group: group || undefined,
});

if (gen !== this.searchGen) return;
if (isStateful(pageEl)) pageEl.setState({ contacts });
}

onSelectContact(e: ContactEvent): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
<h2 class="page-title">All Contacts</h2>
<div class="contact-list-container">
<for each="contact in contacts">
<cb-contact-card id="{{contact.id}}" first-name="{{contact.firstName}}" last-name="{{contact.lastName}}"
email="{{contact.email}}" phone="{{contact.phone}}" company="{{contact.company}}" group="{{contact.group}}"
favorite="{{contact.favorite}}" initials="{{contact.initials}}" avatar-color="{{contact.avatarColor}}"
notes="{{contact.notes}}" address="{{contact.address}}"></cb-contact-card>
</for>
</div>
<if condition="contacts.length">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repeats the contact-card loop and empty-state behavior that already exists in cb-contact-list, and the same duplication is added to favorites/group. Can we reuse that component instead?

<h2 class="page-title">All Contacts</h2>
<cb-contact-list :contacts="{{contacts}}"></cb-contact-list>

Then the page TS only needs to import the list component:

import '#organisms/cb-contact-list/cb-contact-list.js';

If the page-specific container class is needed for styling, let's move/parameterize that in the shared list component rather than duplicating the loop in three pages.

<div class="contact-list-container">
<for each="contact in contacts">
<cb-contact-card id="{{contact.id}}" first-name="{{contact.firstName}}" last-name="{{contact.lastName}}"
email="{{contact.email}}" phone="{{contact.phone}}" company="{{contact.company}}" group="{{contact.group}}"
favorite="{{contact.favorite}}" initials="{{contact.initials}}" avatar-color="{{contact.avatarColor}}"
notes="{{contact.notes}}" address="{{contact.address}}"></cb-contact-card>
</for>
</div>
</if>
<if condition="!contacts.length">
<cb-empty-state title="No contacts found" message="Try adjusting your search or add a new contact."></cb-empty-state>
</if>
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license.

import { WebUIElement, observable } from '@microsoft/webui-framework';
import '#atoms/cb-empty-state/cb-empty-state.js';
import '#organisms/cb-contact-card/cb-contact-card.js';
import { Contact } from '#api';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
<h2 class="page-title">Favorites</h2>
<div class="contact-list-container">
<for each="contact in contacts">
<cb-contact-card id="{{contact.id}}" first-name="{{contact.firstName}}" last-name="{{contact.lastName}}"
email="{{contact.email}}" phone="{{contact.phone}}" company="{{contact.company}}" group="{{contact.group}}"
favorite="{{contact.favorite}}" initials="{{contact.initials}}" avatar-color="{{contact.avatarColor}}"
notes="{{contact.notes}}" address="{{contact.address}}"></cb-contact-card>
</for>
</div>
<if condition="contacts.length">
<div class="contact-list-container">
<for each="contact in contacts">
<cb-contact-card id="{{contact.id}}" first-name="{{contact.firstName}}" last-name="{{contact.lastName}}"
email="{{contact.email}}" phone="{{contact.phone}}" company="{{contact.company}}" group="{{contact.group}}"
favorite="{{contact.favorite}}" initials="{{contact.initials}}" avatar-color="{{contact.avatarColor}}"
notes="{{contact.notes}}" address="{{contact.address}}"></cb-contact-card>
</for>
</div>
</if>
<if condition="!contacts.length">
<cb-empty-state title="No contacts found" message="Try adjusting your search."></cb-empty-state>
</if>
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license.

import { WebUIElement, observable } from '@microsoft/webui-framework';
import '#atoms/cb-empty-state/cb-empty-state.js';
import '#organisms/cb-contact-card/cb-contact-card.js';
import { Contact } from '#api';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
<h2 class="page-title">{{groupName}}</h2>
<div class="contact-list-container">
<for each="contact in contacts">
<cb-contact-card id="{{contact.id}}" first-name="{{contact.firstName}}" last-name="{{contact.lastName}}"
email="{{contact.email}}" phone="{{contact.phone}}" company="{{contact.company}}" group="{{contact.group}}"
favorite="{{contact.favorite}}" initials="{{contact.initials}}" avatar-color="{{contact.avatarColor}}"
notes="{{contact.notes}}" address="{{contact.address}}"></cb-contact-card>
</for>
</div>
<if condition="contacts.length">
<div class="contact-list-container">
<for each="contact in contacts">
<cb-contact-card id="{{contact.id}}" first-name="{{contact.firstName}}" last-name="{{contact.lastName}}"
email="{{contact.email}}" phone="{{contact.phone}}" company="{{contact.company}}" group="{{contact.group}}"
favorite="{{contact.favorite}}" initials="{{contact.initials}}" avatar-color="{{contact.avatarColor}}"
notes="{{contact.notes}}" address="{{contact.address}}"></cb-contact-card>
</for>
</div>
</if>
<if condition="!contacts.length">
<cb-empty-state title="No contacts found" message="Try adjusting your search."></cb-empty-state>
</if>
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license.

import { WebUIElement, observable } from '@microsoft/webui-framework';
import '#atoms/cb-empty-state/cb-empty-state.js';
import '#organisms/cb-contact-card/cb-contact-card.js';
import type { Contact } from '#api';

Expand Down
64 changes: 64 additions & 0 deletions examples/app/contact-book-manager/tests/contact-book.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,70 @@ test.describe('contact edit does not corrupt sidebar groups', () => {
});
});

// ── Search tests ────────────────────────────────────────────────

test.describe('search', () => {
test('filters contacts on the contacts page as the user types', async ({ page }) => {
await page.goto('/contacts');
await expect(page.locator('cb-page-contacts cb-contact-card')).toHaveCount(15);

const searchInput = page.locator('cb-header .search-input');
await searchInput.fill('Sarah');

// Only Sarah Chen should remain
await expect(page.locator('cb-page-contacts cb-contact-card')).toHaveCount(1);
await expect(page.locator('cb-page-contacts cb-contact-card')).toContainText('Sarah');
Comment on lines +251 to +261
});

test('shows empty state when search has no matches', async ({ page }) => {
await page.goto('/contacts');
const searchInput = page.locator('cb-header .search-input');
await searchInput.fill('zzz_no_match_zzz');

await expect(page.locator('cb-page-contacts cb-contact-card')).toHaveCount(0);
await expect(page.locator('cb-page-contacts cb-empty-state')).toBeVisible();
});

test('restores full list when search is cleared', async ({ page }) => {
await page.goto('/contacts');
const searchInput = page.locator('cb-header .search-input');
await searchInput.fill('Sarah');
await expect(page.locator('cb-page-contacts cb-contact-card')).toHaveCount(1);

await searchInput.clear();
await expect(page.locator('cb-page-contacts cb-contact-card')).toHaveCount(15);
});

test('filters favorites page as the user types', async ({ page }) => {
await page.goto('/favorites');
const initialCount = await page.locator('cb-page-favorites cb-contact-card').count();
expect(initialCount).toBeGreaterThan(0);

const searchInput = page.locator('cb-header .search-input');
await searchInput.fill('Sarah');

await expect(page.locator('cb-page-favorites cb-contact-card')).toHaveCount(1);
await expect(page.locator('cb-page-favorites cb-contact-card')).toContainText('Sarah');
});

test('search persists when navigating back to contacts page', async ({ page }) => {
await page.goto('/contacts');
const searchInput = page.locator('cb-header .search-input');
await searchInput.fill('Sarah');
await expect(page.locator('cb-page-contacts cb-contact-card')).toHaveCount(1);

// Navigate to a contact detail
await page.locator('cb-page-contacts cb-contact-card').click();
await expect(page.locator('cb-contact-detail')).toBeVisible();

// Navigate back — search box still shows query, contacts page should re-filter
await page.locator('cb-sidebar').getByRole('link', { name: /All Contacts/ }).click();
await expect(page).toHaveURL('/contacts');
await expect(page.locator('cb-header .search-input')).toHaveValue('Sarah');
await expect(page.locator('cb-page-contacts cb-contact-card')).toHaveCount(1);
});
});

// ── Visual regression tests ──────────────────────────────────────

test.describe('visual regression', () => {
Expand Down
Loading