From 40b0b6a0b2f909cb253fd7998c1b7f372318d523 Mon Sep 17 00:00:00 2001 From: Mathias Reichardt Date: Sat, 3 Oct 2026 00:22:39 +0200 Subject: [PATCH 01/12] feat(search): add global entity search to the toolbar - Move the search from each property tree into one search box in the toolbar - Searches the entity data, so collapsed embedded entities are searched too - Opens nested properties and embedded entity panels that contain a hit - Hits are listed in render order; previous/next buttons, Enter / Shift+Enter - Add search options, saved with the user settings - Property values, property names, titles & classes, link relations, embedded entity relations, actions - Case-sensitive and regex modes; invalid regexes are flagged - Relations match by raw name and by mapped display text - Mark matching substrings and the current hit; debounce typing - Keep the query on navigation - Search the raw view as plain text over the formatted JSON - Add showSearch user setting and app.config.json key; config can only disable it - Remove the property-controls switch from the toolbar; it stays in the settings - Fix jumping to the wrong row when properties share a name - Hide the property tree header when no top-level property can be expanded - Add unit and e2e tests for the search --- README.md | 6 + e2e/hypermedia-ui.spec.ts | 55 ++++- src/app.config.d.ts | 1 + src/app.config.json | 1 + src/app.config.service.ts | 3 + .../actions-view/actions-view.component.html | 13 +- .../actions-view/actions-view.component.ts | 8 +- .../file-upload-action.component.html | 2 +- .../file-upload-action.component.ts | 1 + .../parameter-action.component.html | 2 +- .../parameter-action.component.ts | 1 + .../parameterless-action-view.component.html | 2 +- .../parameterless-action-view.component.ts | 1 + .../embedded-entity-view.component.html | 24 ++- .../embedded-entity-view.component.ts | 27 ++- .../entity-view/entity-view.component.html | 14 +- .../entity-view/entity-view.component.ts | 4 + .../hypermedia-control.component.html | 78 ++++++- .../hypermedia-control.component.scss | 73 ++++++- .../hypermedia-control.component.spec.ts | 6 + .../hypermedia-control.component.ts | 60 +++++- .../hypermedia-view/hypermedia-view.module.ts | 2 + .../link-view/link-view.component.html | 9 +- .../link-view/link-view.component.ts | 6 + .../property-tree.component.html | 52 ++--- .../property-tree.component.scss | 56 +----- .../property-tree.component.spec.ts | 56 ++++-- .../property-tree/property-tree.component.ts | 153 ++++---------- .../raw-view/raw-view.component.html | 7 +- .../raw-view/raw-view.component.scss | 18 ++ .../raw-view/raw-view.component.ts | 2 + .../search/entity-search.service.spec.ts | 82 ++++++++ .../search/entity-search.service.ts | 190 ++++++++++++++++++ .../search/entity-search.spec.ts | 126 ++++++++++++ .../hypermedia-view/search/entity-search.ts | 163 +++++++++++++++ .../search/search-highlight.component.ts | 33 +++ .../hypermedia-view/search/search-matcher.ts | 67 ++++++ src/app/settings/app-settings.ts | 3 + .../general-settings-page.component.html | 1 + .../general-settings-page.component.ts | 4 + src/app/settings/services/AppSettings.ts | 11 + src/app/settings/services/settings.service.ts | 4 + src/app/store/selectors.ts | 2 + src/styles/styles.scss | 11 + 44 files changed, 1169 insertions(+), 271 deletions(-) create mode 100644 src/app/hypermedia-view/search/entity-search.service.spec.ts create mode 100644 src/app/hypermedia-view/search/entity-search.service.ts create mode 100644 src/app/hypermedia-view/search/entity-search.spec.ts create mode 100644 src/app/hypermedia-view/search/entity-search.ts create mode 100644 src/app/hypermedia-view/search/search-highlight.component.ts create mode 100644 src/app/hypermedia-view/search/search-matcher.ts diff --git a/README.md b/README.md index d1c115f..7a8104a 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,12 @@ when set to ``true`` removes the following UI elements for a leaner experience: - Titles on embedded entities (since they are already shown in the entity list header) - Action names (since the title already defines which action is which) +### ``showSearch`` + +default: ``true`` + +when set to ``false`` removes the search box from the toolbar above the entity view, regardless of the user's general settings. + ### ``autoFollowActionLocationOnSuccess`` default: ``false`` diff --git a/e2e/hypermedia-ui.spec.ts b/e2e/hypermedia-ui.spec.ts index e6ff55d..f342139 100644 --- a/e2e/hypermedia-ui.spec.ts +++ b/e2e/hypermedia-ui.spec.ts @@ -279,7 +279,6 @@ test('downloads non-Siren content', async ({ page }) => { test('searches nested properties in the tree', async ({ page }) => { await openEntryPoint(page); - await page.getByRole('switch').check(); const search = page.getByPlaceholder('Search ...'); await search.fill('needle-value'); await expect(page.getByText('searchableProperty:', { exact: true })).toBeVisible(); @@ -287,6 +286,60 @@ test('searches nested properties in the tree', async ({ page }) => { await expect(page.locator('.match-counter')).toHaveText('1/1'); }); +test('expands an embedded entity that contains a search hit', async ({ page }) => { + await openEntryPoint(page); + await page.getByPlaceholder('Search ...').fill('ready'); + await expect(page.locator('mark', { hasText: 'ready' })).toBeVisible(); + await expect(page.locator('.match-counter')).toHaveText('1/1'); +}); + +test('steps through link relation hits with Enter and Shift+Enter', async ({ page }) => { + await openEntryPoint(page); + await page.getByRole('button', { name: 'Search options' }).click(); + await page.getByRole('switch', { name: 'Link relations' }).check(); + await page.keyboard.press('Escape'); + + const search = page.getByPlaceholder('Search ...'); + await search.fill('plain'); + await expect(page.locator('.match-counter')).toHaveText('1/2'); + await search.press('Enter'); + await expect(page.locator('.match-counter')).toHaveText('2/2'); + await search.press('Shift+Enter'); + await expect(page.locator('.match-counter')).toHaveText('1/2'); + await expect(page.locator('.link-group.search-current')).toContainText('plain-json'); +}); + +test('remembers search options after a reload', async ({ page }) => { + await openEntryPoint(page); + await page.getByRole('button', { name: 'Search options' }).click(); + await page.getByRole('switch', { name: 'Actions' }).check(); + await page.keyboard.press('Escape'); + + await page.reload(); + await expect(page.getByText('Demo API', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Search options' }).click(); + await expect(page.getByRole('switch', { name: 'Actions' })).toBeChecked(); +}); + +test('searches the raw view as plain text', async ({ page }) => { + await openEntryPoint(page); + await page.getByRole('radio', { name: 'Raw' }).check(); + await page.getByPlaceholder('Search ...').fill('"class"'); + await expect(page.locator('app-raw-view mark').first()).toHaveText('"class"'); +}); + +test('hides the search when configured', async ({ page }) => { + await page.route('**/app.config.json', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + showSearch: false, + relationIconMapping: {}, httpMethodIconMapping: {}, actionPopupWarningConfigurations: [], + }), + })); + await openEntryPoint(page); + await expect(page.getByPlaceholder('Search ...')).toBeHidden(); +}); + test('navigates with breadcrumbs and recovers from an API error', async ({ page }) => { await openEntryPoint(page); await page.getByRole('link', { name: 'customer' }).click(); diff --git a/src/app.config.d.ts b/src/app.config.d.ts index c906ece..49f15d3 100644 --- a/src/app.config.d.ts +++ b/src/app.config.d.ts @@ -2,6 +2,7 @@ declare namespace HypermediaUI { export interface IAppConfig { disableDeveloperControls: boolean; reduceUiElements: boolean; + showSearch?: boolean; configuredEntryPoints: IConfiguredEntryPointsItem[]; onlyAllowConfiguredEntryPoints: boolean; relationIconMapping?: Record; diff --git a/src/app.config.json b/src/app.config.json index 2d4849e..9840699 100644 --- a/src/app.config.json +++ b/src/app.config.json @@ -1,6 +1,7 @@ { "disableDeveloperControls": false, "reduceUiElements": false, + "showSearch": true, "autoFollowActionLocationOnSuccess": true, "configuredEntryPoints": [], "onlyAllowConfiguredEntryPoints": false, diff --git a/src/app.config.service.ts b/src/app.config.service.ts index 0c1a8e7..b677ce9 100644 --- a/src/app.config.service.ts +++ b/src/app.config.service.ts @@ -19,6 +19,7 @@ export class AppConfigService implements HypermediaUI.IAppConfig { public disableDeveloperControls: boolean = false; public reduceUiElements: boolean = false; public showActionsBeforeProperties: boolean = false; + public showSearch: boolean = true; public autoFollowActionLocationOnSuccess: boolean = false; public configuredEntryPoints: ConfiguredEntryPoint[] = []; public onlyAllowConfiguredEntryPoints: boolean = false; @@ -41,6 +42,7 @@ export class AppConfigService implements HypermediaUI.IAppConfig { disableDeveloperControls: this.disableDeveloperControls, reduceUiElements: this.reduceUiElements, showActionsBeforeProperties: this.showActionsBeforeProperties, + showSearch: this.showSearch, autoFollowActionLocationOnSuccess: this.autoFollowActionLocationOnSuccess, configuredEntryPoints: this.configuredEntryPoints, onlyAllowConfiguredEntryPoints: this.onlyAllowConfiguredEntryPoints, @@ -61,6 +63,7 @@ export class AppConfig extends ImmutableJsRecord({ disableDeveloperControls: true, reduceUiElements: true, showActionsBeforeProperties: false, + showSearch: true, autoFollowActionLocationOnSuccess: false, configuredEntryPoints: [] as ConfiguredEntryPoint[], onlyAllowConfiguredEntryPoints : false, diff --git a/src/app/hypermedia-view/actions-view/actions-view.component.html b/src/app/hypermedia-view/actions-view/actions-view.component.html index 0129a1a..2f2e890 100644 --- a/src/app/hypermedia-view/actions-view/actions-view.component.html +++ b/src/app/hypermedia-view/actions-view/actions-view.component.html @@ -1,15 +1,18 @@
- @for (action of actions; track action) { -
+ @for (action of actions; track action; let idx = $index) { +
@switch (action.actionType) { @case (ActionType.NoParameters) { - + } @case ( ActionType.JsonObjectParameters) { - + } @case (ActionType.FileUpload) { - + } }
diff --git a/src/app/hypermedia-view/actions-view/actions-view.component.ts b/src/app/hypermedia-view/actions-view/actions-view.component.ts index 3557b98..1b62a56 100644 --- a/src/app/hypermedia-view/actions-view/actions-view.component.ts +++ b/src/app/hypermedia-view/actions-view/actions-view.component.ts @@ -1,5 +1,7 @@ -import { Component, Input } from '@angular/core'; +import { Component, inject, Input } from '@angular/core'; import {ActionType, HypermediaAction} from '../siren-parser/hypermedia-action'; +import {actionTarget, rootEntityKey} from '../search/entity-search'; +import {EntitySearchService} from '../search/entity-search.service'; @Component({ selector: 'app-actions-view', @@ -8,7 +10,11 @@ import {ActionType, HypermediaAction} from '../siren-parser/hypermedia-action'; standalone: false }) export class ActionsViewComponent { + protected search = inject(EntitySearchService); + @Input() actions: HypermediaAction[] = []; + @Input() entityKey: string = rootEntityKey; + protected readonly actionTarget = actionTarget; protected readonly ActionType = ActionType; } diff --git a/src/app/hypermedia-view/actions-view/file-upload-action/file-upload-action.component.html b/src/app/hypermedia-view/actions-view/file-upload-action/file-upload-action.component.html index a6fd835..c6a0419 100644 --- a/src/app/hypermedia-view/actions-view/file-upload-action/file-upload-action.component.html +++ b/src/app/hypermedia-view/actions-view/file-upload-action/file-upload-action.component.html @@ -4,7 +4,7 @@
+ } + + + + +
+ + +
+ Search in + @for (option of searchScopeOptions; track option.key) { + + {{option.label}} + + } + + @for (option of searchModifierOptions; track option.key) { + + {{option.label}} + + } +
+
} @if (GeneralSettings.showRawTab) { - + grid_view UI diff --git a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.scss b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.scss index dcf5e6a..4f21db6 100644 --- a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.scss +++ b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.scss @@ -123,22 +123,75 @@ rawContent{ } } -.property-toggle { - margin-right: 24px; +.search-box { display: flex; align-items: center; + background: #fff; + border: 1px solid #ccc; + border-radius: 4px; + padding: 0 0 0 8px; + height: 32px; + width: 360px; + min-width: 0; + margin-right: 16px; - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - margin-left: 8px; + &.invalid { + border-color: #c62828; + } + + input { + border: none; + outline: none; + font-size: 13px; + flex: 1; + min-width: 30px; + background: transparent; + } + + .match-counter { + font-size: 10px; + font-weight: 700; color: #666; + background: #eee; + padding: 2px 6px; + border-radius: 10px; + margin: 0 4px; + white-space: nowrap; + + &.no-matches { + background: #ffebee; + color: #c62828; + } } - ::ng-deep .mdc-label { - display: flex; + .search-icon { font-size: 16px; width: 16px; height: 16px; color: #999; margin-right: 4px; } + + .search-button { + width: 30px; + height: 30px; + padding: 3px; + display: inline-flex; align-items: center; - padding-left: 0; + justify-content: center; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } +} + +.search-options { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 16px; + + .search-options-title { + font-size: 12px; + font-weight: 500; + color: #666; + text-transform: uppercase; } } diff --git a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.spec.ts b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.spec.ts index ae6fd36..1c60c14 100644 --- a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.spec.ts +++ b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.spec.ts @@ -13,7 +13,10 @@ import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatDividerModule } from '@angular/material/divider'; import {GlobalNavigationEvents} from "../../global-navigation.events"; +import { SettingsService } from 'src/app/settings/services/settings.service'; describe('HypermediaControlComponent', () => { let component: HypermediaControlComponent; @@ -32,9 +35,12 @@ describe('HypermediaControlComponent', () => { MatButtonToggleModule, MatSlideToggleModule, MatTooltipModule, + MatMenuModule, + MatDividerModule, ], providers: [ provideHypermediaClientServiceMock(), + SettingsService, { provide: ActivatedRoute, useValue: { diff --git a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts index 0f115ed..c012631 100644 --- a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts +++ b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts @@ -13,6 +13,16 @@ import {AuthService} from "../auth.service"; import {updateGeneralAppSettings} from 'src/app/store/appsettings.actions'; import {MediaTypes} from "../MediaTypes"; import {GlobalNavigationEvents} from "../../global-navigation.events"; +import {EntitySearchService} from "../search/entity-search.service"; +import {SearchOptions} from "../search/entity-search"; +import {SettingsService} from "../../settings/services/settings.service"; + +interface SearchOptionDefinition { + key: keyof SearchOptions; + label: string; + // has no effect on the plain text search of the raw view + entityOnly: boolean; +} @Component({ selector: 'app-hypermedia-control', @@ -25,6 +35,8 @@ export class HypermediaControlComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); private router = inject(Router); private authService = inject(AuthService); + protected search = inject(EntitySearchService); + private settingsService = inject(SettingsService); private store = inject { this.GeneralSettings = generalSettings; - this.showPropertyTreeControls = generalSettings.showPropertyTreeControls; + this.search.showClasses.set(generalSettings.showClasses); + this.search.setOptions(generalSettings.searchOptions); + this.updateSearchMode(); }, }); + store + .select(state => state.appSettings.generalSettings) + .subscribe({ + next: generalSettings => this.userGeneralSettings = generalSettings, + }); store .select(state => state.appConfig) .subscribe({ @@ -108,10 +141,12 @@ export class HypermediaControlComponent implements OnInit, OnDestroy { ngOnInit() { this.hypermediaClient.getHypermediaObjectStream().subscribe((hto) => { this.hto = hto; + this.search.setEntity(hto); }); this.hypermediaClient.getHypermediaObjectRawStream().subscribe((rawResponse) => { this.rawResponse = rawResponse; + this.search.setRawObject(rawResponse); }); this.hypermediaClient.getContentTypeStream().subscribe((contentType) => { @@ -186,10 +221,27 @@ export class HypermediaControlComponent implements OnInit, OnDestroy { this.hypermediaClient.Navigate(url); } - public togglePropertyTreeControls(checked: boolean) { + public setShowRaw(showRaw: boolean) { + this.showRaw = showRaw; + this.updateSearchMode(); + } + + private updateSearchMode() { + this.search.rawMode.set(this.GeneralSettings.showRawTab && this.showRaw); + } + + public get isSearchAvailable(): boolean { + if (!this.GeneralSettings.showSearch) return false; + if (this.GeneralSettings.showRawTab && this.showRaw) return true; + return !this.contentType || this.contentType.toLowerCase() === MediaTypes.Siren.toLowerCase(); + } + + public setSearchOption(key: keyof SearchOptions, value: boolean) { + const searchOptions = { ...this.userGeneralSettings.searchOptions, [key]: value }; this.store.dispatch(updateGeneralAppSettings({ - newGeneralSettings: this.GeneralSettings.set("showPropertyTreeControls", checked) + newGeneralSettings: this.userGeneralSettings.set("searchOptions", searchOptions) })); + this.settingsService.SaveCurrentSettings(); } public async exitApi() { diff --git a/src/app/hypermedia-view/hypermedia-view.module.ts b/src/app/hypermedia-view/hypermedia-view.module.ts index 7e7dc47..4e2f75b 100644 --- a/src/app/hypermedia-view/hypermedia-view.module.ts +++ b/src/app/hypermedia-view/hypermedia-view.module.ts @@ -61,6 +61,7 @@ import {MatNativeDateModule} from '@angular/material/core'; import {FileSizePipe} from '../common/pipes/file-size.pipe'; import {allowEmptyArrayExtension} from "./formly-extensions"; import {PropertyTreeComponent} from "./property-tree/property-tree.component"; +import {SearchHighlightComponent} from "./search/search-highlight.component"; import {MatTree, MatTreeNode, MatTreeNodeDef, MatTreeNodePadding, MatTreeNodeToggle} from "@angular/material/tree"; import {MatChipsModule} from '@angular/material/chips'; import {MatSlideToggleModule} from '@angular/material/slide-toggle'; @@ -70,6 +71,7 @@ import {MatSlideToggleModule} from '@angular/material/slide-toggle'; declarations: [ HypermediaControlComponent, PropertyTreeComponent, + SearchHighlightComponent, LinkViewComponent, EmbeddedEntityViewComponent, EntityViewComponent, diff --git a/src/app/hypermedia-view/link-view/link-view.component.html b/src/app/hypermedia-view/link-view/link-view.component.html index 96d6687..9d15679 100644 --- a/src/app/hypermedia-view/link-view/link-view.component.html +++ b/src/app/hypermedia-view/link-view/link-view.component.html @@ -1,6 +1,9 @@
- @for (link of links; track link) { - + @for (link of links; track link; let idx = $index) { + link } - {{getRelationDisplayText(link.relations).join(', ')}} + @if (this.generalSettings.showLinkDropdown) { diff --git a/src/app/hypermedia-view/link-view/link-view.component.ts b/src/app/hypermedia-view/link-view/link-view.component.ts index 19c5078..b9ac47e 100644 --- a/src/app/hypermedia-view/link-view/link-view.component.ts +++ b/src/app/hypermedia-view/link-view/link-view.component.ts @@ -11,6 +11,8 @@ import {Store} from "@ngrx/store"; import {AppSettings, GeneralSettings} from "../../settings/app-settings"; import {AppConfig} from "../../../app.config.service"; import {selectEffectiveGeneralSettings} from "../../store/selectors"; +import {linkTarget, rootEntityKey} from "../search/entity-search"; +import {EntitySearchService} from "../search/entity-search.service"; @Component({ selector: 'app-link-view', @@ -26,7 +28,11 @@ export class LinkViewComponent { appConfig: AppConfig; }>>(Store); + protected search = inject(EntitySearchService); + @Input() links: HypermediaLink[] = []; + @Input() entityKey: string = rootEntityKey; + protected readonly linkTarget = linkTarget; protected readonly getIconForMimeType = getIconForMimeType; protected readonly MediaTypes = MediaTypes; diff --git a/src/app/hypermedia-view/property-tree/property-tree.component.html b/src/app/hypermedia-view/property-tree/property-tree.component.html index 271c5b5..2ff8bae 100644 --- a/src/app/hypermedia-view/property-tree/property-tree.component.html +++ b/src/app/hypermedia-view/property-tree/property-tree.component.html @@ -1,31 +1,13 @@ -@if (showHeader && propertyContainer && propertyContainer.length > 0) { +@if (showHeader && hasExpandableItems) {
- @if (hasExpandableItems) { - - - } - - + +
} @@ -34,18 +16,20 @@ + [attr.data-search-target]="node.searchTarget" + [class.is-direct-match]="search.isHitTarget(node.searchTarget)" + [class.is-current-match]="search.isCurrentTarget(node.searchTarget)" + [class.is-ancestor-match]="ancestorMatches().has(node)">
- {{node.name}}: + :
@switch (node.type) { - @case (propertyTypes.string) { {{node.value}} } - @case (propertyTypes.number) { {{node.value}} } + @case (propertyTypes.string) { } + @case (propertyTypes.number) { } @case (propertyTypes.boolean) { } @case (propertyTypes.nullvalue) { null } } @@ -54,8 +38,10 @@ + [attr.data-search-target]="node.searchTarget" + [class.is-direct-match]="search.isHitTarget(node.searchTarget)" + [class.is-current-match]="search.isCurrentTarget(node.searchTarget)" + [class.is-ancestor-match]="ancestorMatches().has(node)">
- {{node.name}}: + :
diff --git a/src/app/hypermedia-view/property-tree/property-tree.component.scss b/src/app/hypermedia-view/property-tree/property-tree.component.scss index 07e3b36..e5d1ae9 100644 --- a/src/app/hypermedia-view/property-tree/property-tree.component.scss +++ b/src/app/hypermedia-view/property-tree/property-tree.component.scss @@ -49,48 +49,6 @@ $highlight-ancestor: #fffde7; } } -// search box -.search-box { - display: flex; - align-items: center; - background: #fff; - border: 1px solid #ccc; - border-radius: 4px; - padding: 0 8px; - height: 32px; - flex: 1; - min-width: 0; - margin-left: 4px; - - input { - border: none; - outline: none; - font-size: 12px; - flex: 1; - min-width: 30px; - background: transparent; - } - - .match-counter { - font-size: 10px; - font-weight: 700; - color: #666; - background: #eee; - padding: 2px 6px; - border-radius: 10px; - margin: 0 4px; - white-space: nowrap; - - &.no-matches { - background: #ffebee; - color: #c62828; - } - } - - .search-icon { font-size: 14px; width: 16px; height: 16px; color: #999; } - .clear-icon { font-size: 14px; width: 16px; height: 16px; color: #999; cursor: pointer; &:hover { color: #333; } } -} - // tree styles .mat-tree-node { min-height: 36px; @@ -185,18 +143,12 @@ button[mat-icon-button] { .prop-name { font-weight: bold; color: #000; } } + &.is-current-match { + box-shadow: inset 3px 0 0 #ff9800; + } + &.is-ancestor-match:not(.is-direct-match) { background-color: $highlight-ancestor !important; .prop-name { border-bottom: 1px dashed #ffd600; } } - - &.pulse-highlight { - animation: search-pulse 1s ease-out; - } -} - -@keyframes search-pulse { - 0% { background-color: $highlight-direct; } - 50% { background-color: #ffd600; } - 100% { background-color: $highlight-direct; } } diff --git a/src/app/hypermedia-view/property-tree/property-tree.component.spec.ts b/src/app/hypermedia-view/property-tree/property-tree.component.spec.ts index 808fd4c..d54d8a6 100644 --- a/src/app/hypermedia-view/property-tree/property-tree.component.spec.ts +++ b/src/app/hypermedia-view/property-tree/property-tree.component.spec.ts @@ -1,34 +1,50 @@ +import { TestBed } from '@angular/core/testing'; import { PropertyTreeComponent } from './property-tree.component'; import { PropertyInfo, PropertyTypes } from '../siren-parser/property-info'; +import { EntitySearchService } from '../search/entity-search.service'; +import { SirenClientObject } from '../siren-parser/siren-client-object'; describe('PropertyTreeComponent', () => { + const properties = [ + new PropertyInfo('customer', { name: 'Ada Lovelace', address: { city: 'London' } }, PropertyTypes.object), + ]; + function createComponent() { - const component = new PropertyTreeComponent(); - component.propertyContainer = [ - new PropertyInfo('customer', { name: 'Ada Lovelace', address: { city: 'London' } }, PropertyTypes.object), - ]; + const search = TestBed.inject(EntitySearchService); + const entity = new SirenClientObject(); + entity.properties = properties; + search.setEntity(entity); + + const component = TestBed.runInInjectionContext(() => new PropertyTreeComponent()); + component.propertyContainer = properties; component.ngOnChanges({ propertyContainer: {} as any }); - return component; + return { component, search }; + } + + function nodeNamed(component: PropertyTreeComponent, name: string) { + return component.treeControl.dataNodes.find(node => node.name === name)!; } - it('reveals a matching nested property and marks its ancestor', () => { - const component = createComponent(); - component.applyFilter({ target: { value: 'london' } } as unknown as Event); + it('expands the ancestors of a matching nested property', () => { + const { component, search } = createComponent(); + + search.query.set('london'); + TestBed.tick(); - expect(component.matchCount).toBe(1); - expect(component.currentMatchIndex).toBe(0); - expect(component.treeControl.isExpanded(component.treeControl.dataNodes[0])).toBeTrue(); + expect(search.isHitTarget(nodeNamed(component, 'city').searchTarget)).toBeTrue(); + expect(component.treeControl.isExpanded(nodeNamed(component, 'customer'))).toBeTrue(); + expect(component.treeControl.isExpanded(nodeNamed(component, 'address'))).toBeTrue(); }); - it('clears match state when the search is cleared', () => { - const component = createComponent(); - component.applyFilter({ target: { value: 'ada' } } as unknown as Event); - component.clearSearch(); + it('keeps expanded nodes open when the search is cleared', () => { + const { component, search } = createComponent(); + search.query.set('london'); + TestBed.tick(); + + search.clear(); + TestBed.tick(); - expect(component.searchQuery).toBe(''); - expect(component.matchCount).toBe(0); - expect(component.currentMatchIndex).toBe(-1); - expect(component.directMatches.size).toBe(0); - expect(component.ancestorMatches.size).toBe(0); + expect(component.ancestorMatches().size).toBe(0); + expect(component.treeControl.isExpanded(nodeNamed(component, 'customer'))).toBeTrue(); }); }); diff --git a/src/app/hypermedia-view/property-tree/property-tree.component.ts b/src/app/hypermedia-view/property-tree/property-tree.component.ts index c1435b1..7cd73b9 100644 --- a/src/app/hypermedia-view/property-tree/property-tree.component.ts +++ b/src/app/hypermedia-view/property-tree/property-tree.component.ts @@ -1,7 +1,9 @@ -import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { Component, computed, effect, inject, input, Input, OnChanges, signal, SimpleChanges } from '@angular/core'; import { FlatTreeControl } from '@angular/cdk/tree'; import { MatTreeFlatDataSource, MatTreeFlattener } from '@angular/material/tree'; import { PropertyInfo, PropertyTypes } from '../siren-parser/property-info'; +import { EntitySearchService } from '../search/entity-search.service'; +import { getChildProperties, propertyTarget, rootEntityKey } from '../search/entity-search'; interface PropertyFlatNode { expandable: boolean; @@ -10,6 +12,7 @@ interface PropertyFlatNode { preview?: string; type: PropertyTypes; level: number; + searchTarget: string; } @Component({ @@ -19,20 +22,40 @@ interface PropertyFlatNode { standalone: false }) export class PropertyTreeComponent implements OnChanges { + protected search = inject(EntitySearchService); + @Input() propertyContainer: PropertyInfo[] = []; @Input() showHeader: boolean = true; + readonly entityKey = input(rootEntityKey); public propertyTypes = PropertyTypes; - // search and highlighting states - public searchQuery: string = ''; - public matchCount: number = 0; - public directMatches = new Set(); - public ancestorMatches = new Set(); - - public currentMatchIndex: number = -1; - private matchNodes: PropertyFlatNode[] = []; public hasExpandableItems: boolean = false; + // the flattener only passes the node and its level, so the property path is tracked here + private propertyPaths = new WeakMap(); + private readonly nodes = signal([]); + + /** Nodes that contain a search hit further down; they are expanded to reveal it. */ + readonly ancestorMatches = computed(() => { + const ancestors = new Set(); + const nodes = this.nodes(); + nodes.forEach((node, index) => { + if (!this.search.isHitTarget(node.searchTarget)) return; + let currentLevel = node.level; + for (let i = index - 1; i >= 0 && currentLevel > 0; i--) { + if (nodes[i].level < currentLevel) { + ancestors.add(nodes[i]); + currentLevel = nodes[i].level; + } + } + }); + return ancestors; + }); + + constructor() { + effect(() => this.ancestorMatches().forEach(node => this.treeControl.expand(node))); + } + private _transformer = (node: PropertyInfo, level: number): PropertyFlatNode => { return { expandable: node.type === PropertyTypes.object || node.type === PropertyTypes.array, @@ -41,6 +64,7 @@ export class PropertyTreeComponent implements OnChanges { preview: this.getPreview(node), type: node.type, level: level, + searchTarget: propertyTarget(this.entityKey(), this.propertyPaths.get(node) ?? [node.name]), }; }; @@ -84,7 +108,7 @@ export class PropertyTreeComponent implements OnChanges { this._transformer, node => node.level, node => node.expandable, - (node: PropertyInfo) => this.getChildProperties(node) + (node: PropertyInfo) => this.getChildPropertiesWithPath(node) ); dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); @@ -92,96 +116,11 @@ export class PropertyTreeComponent implements OnChanges { ngOnChanges(changes: SimpleChanges) { if (changes['propertyContainer'] && this.propertyContainer) { this.dataSource.data = this.propertyContainer; + this.nodes.set(this.treeControl.dataNodes); this.hasExpandableItems = this.checkForExpandable(this.propertyContainer); } } - // search logic and filter - applyFilter(event: Event) { - const filterValue = (event.target as HTMLInputElement).value; - this.searchQuery = filterValue.toLowerCase().trim(); - this.calculateMatchCount(); - } - - private calculateMatchCount() { - this.directMatches.clear(); - this.ancestorMatches.clear(); - this.currentMatchIndex = -1; - - if (!this.searchQuery) { - this.matchCount = 0; - this.matchNodes = []; - return; - } - - const nodes = this.treeControl.dataNodes; - const search = this.searchQuery; - - nodes.forEach((node, index) => { - // check if name or value matches - const nameMatch = node.name.toLowerCase().includes(search); - let valueMatch = false; - if (node.value !== null && typeof node.value !== 'object') { - valueMatch = node.value.toString().toLowerCase().includes(search); - } - - if (nameMatch || valueMatch) { - this.directMatches.add(node); - - // propagate to parent - let currentLevel = node.level; - for (let i = index - 1; i >= 0; i--) { - const prevNode = nodes[i]; - if (prevNode.level < currentLevel) { - this.ancestorMatches.add(prevNode); - currentLevel = prevNode.level; - } - if (currentLevel === 0) break; - } - } - }); - - this.matchNodes = Array.from(this.directMatches); - this.matchCount = this.matchNodes.length; - // A matching descendant must be visible for search to be useful. - this.ancestorMatches.forEach(node => this.treeControl.expand(node)); - if (this.matchCount > 0) this.currentMatchIndex = 0; - } - - handleSearchEnter(_event: Event) { - if (this.matchCount > 0) { - this.scrollToMatch(this.currentMatchIndex); - this.currentMatchIndex = (this.currentMatchIndex + 1) % this.matchCount; - } - } - - private scrollToMatch(index: number) { - const targetNode = this.matchNodes[index]; - if (!targetNode) return; - - // search the DOM Element - const elements = document.querySelectorAll('.property-row'); - const targetElement = Array.from(elements).find(el => - el.querySelector('.prop-name')?.textContent?.trim() === targetNode.name + ':' - ); - - if (targetElement) { - targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); - targetElement.classList.add('pulse-highlight'); - setTimeout(() => targetElement.classList.remove('pulse-highlight'), 1000); - } - } - - clearSearch() { - this.searchQuery = ''; - this.matchCount = 0; - this.directMatches.clear(); - this.ancestorMatches.clear(); - this.currentMatchIndex = -1; - } - - - private checkForExpandable(nodes: PropertyInfo[]): boolean { if (!nodes) return false; return nodes.some(node => @@ -189,22 +128,14 @@ export class PropertyTreeComponent implements OnChanges { ); } + private getChildPropertiesWithPath(node: PropertyInfo): PropertyInfo[] | null { + const children = getChildProperties(node); + const path = this.propertyPaths.get(node) ?? [node.name]; + children?.forEach(child => this.propertyPaths.set(child, [...path, child.name])); + return children; + } + expandAll() { this.treeControl.expandAll(); } collapseAll() { this.treeControl.collapseAll(); } hasChild = (_: number, node: PropertyFlatNode) => node.expandable; - - private getChildProperties(node: PropertyInfo): PropertyInfo[] | null { - if (node.type !== PropertyTypes.object && node.type !== PropertyTypes.array) return null; - const val = node.value; - return Object.keys(val).map(key => { - const v = val[key]; - let type = PropertyTypes.object; - if (v === null) type = PropertyTypes.nullvalue; - else if (Array.isArray(v)) type = PropertyTypes.array; - else if (typeof v === 'number') type = PropertyTypes.number; - else if (typeof v === 'boolean') type = PropertyTypes.boolean; - else if (typeof v === 'string') type = PropertyTypes.string; - return new PropertyInfo(key, v, type); - }); - } } diff --git a/src/app/hypermedia-view/raw-view/raw-view.component.html b/src/app/hypermedia-view/raw-view/raw-view.component.html index 37df6dc..dc7e794 100644 --- a/src/app/hypermedia-view/raw-view/raw-view.component.html +++ b/src/app/hypermedia-view/raw-view/raw-view.component.html @@ -27,5 +27,10 @@
- + @if (search.rawSegments(); as segments) { + +
@for (segment of segments; track $index) {@if (segment.target) {{{segment.text}}} @else {{{segment.text}}}}
+ } @else { + + }
diff --git a/src/app/hypermedia-view/raw-view/raw-view.component.scss b/src/app/hypermedia-view/raw-view/raw-view.component.scss index 3290bec..5cdccb7 100644 --- a/src/app/hypermedia-view/raw-view/raw-view.component.scss +++ b/src/app/hypermedia-view/raw-view/raw-view.component.scss @@ -11,3 +11,21 @@ .container { padding: defaults.$containerSpacing; } + +.rawSearchText { + font-family: monospace; + font-size: 13px; + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + +.search-mark { + background-color: #fff176; + color: inherit; + border-radius: 2px; + + &.current { + background-color: #ff9800; + } +} diff --git a/src/app/hypermedia-view/raw-view/raw-view.component.ts b/src/app/hypermedia-view/raw-view/raw-view.component.ts index a6e0acc..2689b41 100644 --- a/src/app/hypermedia-view/raw-view/raw-view.component.ts +++ b/src/app/hypermedia-view/raw-view/raw-view.component.ts @@ -1,5 +1,6 @@ import { Component, Input, inject } from '@angular/core'; import { ClipboardService } from 'ngx-clipboard'; +import { EntitySearchService } from '../search/entity-search.service'; @Component({ selector: 'app-raw-view', @@ -9,6 +10,7 @@ import { ClipboardService } from 'ngx-clipboard'; }) export class RawViewComponent { private clipboardService = inject(ClipboardService); + protected search = inject(EntitySearchService); @Input() rawObject: any; diff --git a/src/app/hypermedia-view/search/entity-search.service.spec.ts b/src/app/hypermedia-view/search/entity-search.service.spec.ts new file mode 100644 index 0000000..b2dfc3e --- /dev/null +++ b/src/app/hypermedia-view/search/entity-search.service.spec.ts @@ -0,0 +1,82 @@ +import { TestBed } from '@angular/core/testing'; +import { EntitySearchService } from './entity-search.service'; +import { SirenClientObject } from '../siren-parser/siren-client-object'; +import { PropertyInfo, PropertyTypes } from '../siren-parser/property-info'; + +describe('EntitySearchService', () => { + let search: EntitySearchService; + + function entityWithValues(...values: string[]): SirenClientObject { + const entity = new SirenClientObject(); + entity.properties = values.map((value, i) => new PropertyInfo(`p${i}`, value, PropertyTypes.string)); + return entity; + } + + beforeEach(() => { + search = TestBed.inject(EntitySearchService); + search.clear(); + }); + + it('applies typed text after the debounce', () => { + jasmine.clock().install(); + try { + search.setEntity(entityWithValues('apple')); + search.setInputText('app'); + + expect(search.hits().length).toBe(0); + jasmine.clock().tick(EntitySearchService.debounceMs); + expect(search.hits().length).toBe(1); + } finally { + jasmine.clock().uninstall(); + } + }); + + it('goes to the first hit when Enter is pressed before the debounce elapsed', () => { + search.setEntity(entityWithValues('apple', 'pineapple')); + search.setInputText('apple'); + + search.next(); + + expect(search.query()).toBe('apple'); + expect(search.currentIndex()).toBe(0); + }); + + it('wraps around when stepping through hits', () => { + search.setEntity(entityWithValues('apple', 'pineapple')); + search.query.set('apple'); + + search.previous(); + expect(search.currentIndex()).toBe(1); + search.next(); + expect(search.currentIndex()).toBe(0); + }); + + it('keeps the query and restarts at the first hit when the entity changes', () => { + search.setEntity(entityWithValues('apple', 'pineapple')); + search.query.set('apple'); + search.next(); + + search.setEntity(entityWithValues('crab apple', 'apple pie', 'apple')); + + expect(search.hits().length).toBe(3); + expect(search.currentIndex()).toBe(0); + }); + + it('searches the raw json as plain text in raw mode', () => { + search.setRawObject({ name: 'apple' }); + search.rawMode.set(true); + search.query.set('"name"'); + + expect(search.hits().length).toBe(1); + expect(search.rawSegments()!.filter(segment => segment.target).map(segment => segment.text)).toEqual(['"name"']); + }); + + it('has no hits for an invalid regular expression', () => { + search.setEntity(entityWithValues('apple')); + search.setOptions({ ...search.options(), regex: true }); + search.query.set('('); + + expect(search.queryError()).toBeDefined(); + expect(search.hits()).toEqual([]); + }); +}); diff --git a/src/app/hypermedia-view/search/entity-search.service.ts b/src/app/hypermedia-view/search/entity-search.service.ts new file mode 100644 index 0000000..4e9390b --- /dev/null +++ b/src/app/hypermedia-view/search/entity-search.service.ts @@ -0,0 +1,190 @@ +import { computed, Injectable, linkedSignal, signal } from '@angular/core'; +import { ISirenClientObject } from '../siren-parser/entity-interfaces'; +import { SirenClientObject } from '../siren-parser/siren-client-object'; +import { + collectEntityHits, + defaultSearchOptions, + entityKeyWithAncestors, + rawTarget, + SearchField, + SearchHit, + SearchOptions, +} from './entity-search'; +import { createMatcher, MatchRange, splitByRanges, TextSegment } from './search-matcher'; + +export interface RawSegment extends TextSegment { + target?: string; +} + +/** + * Search over the currently displayed entity (or its raw JSON). Runs on the model rather than on + * the rendered views, because embedded entity panels render their content lazily. + */ +@Injectable({ providedIn: 'root' }) +export class EntitySearchService { + static readonly debounceMs = 150; + + /** The text as typed; `query` follows it after the debounce. */ + readonly inputText = signal(''); + readonly query = signal(''); + readonly options = signal(defaultSearchOptions); + readonly rawMode = signal(false); + readonly showClasses = signal(false); + + private readonly entity = signal(new SirenClientObject()); + private readonly rawObject = signal(null); + private debounceHandle: ReturnType | undefined; + + private readonly matcher = computed(() => + createMatcher(this.query(), this.options().caseSensitive, this.options().regex)); + + readonly queryError = computed(() => { + const matcher = this.matcher(); + return matcher.kind === 'invalid' ? matcher.error : undefined; + }); + + private readonly rawText = computed(() => { + const raw = this.rawObject(); + return raw === null || raw === undefined ? '' : JSON.stringify(raw, null, 2); + }); + + private readonly rawRanges = computed(() => { + const matcher = this.matcher(); + return matcher.kind === 'valid' && this.rawMode() ? matcher.match(this.rawText()) : []; + }); + + readonly hits = computed(() => { + const matcher = this.matcher(); + if (matcher.kind !== 'valid') return []; + if (this.rawMode()) { + return this.rawRanges().map((_, index) => ({ target: rawTarget(index), field: 'raw', entityKey: '' })); + } + return collectEntityHits(this.entity(), this.options(), matcher.match, { showClasses: this.showClasses() }); + }); + + readonly currentIndex = linkedSignal({ + source: this.hits, + computation: hits => hits.length > 0 ? 0 : -1, + }); + + readonly currentHit = computed(() => this.hits()[this.currentIndex()]); + + readonly isActive = computed(() => this.matcher().kind === 'valid'); + + /** Raw JSON split into plain and matching segments; undefined when there is nothing to highlight. */ + readonly rawSegments = computed(() => { + if (!this.isActive() || !this.rawMode()) return undefined; + let matchIndex = 0; + return splitByRanges(this.rawText(), this.rawRanges()) + .map(segment => segment.isMatch ? { ...segment, target: rawTarget(matchIndex++) } : segment); + }); + + private readonly hitFields = computed(() => new Set(this.hits().map(hit => fieldKey(hit.target, hit.field)))); + private readonly hitTargets = computed(() => new Set(this.hits().map(hit => hit.target))); + private readonly entitiesWithHits = computed(() => + new Set(this.hits().filter(hit => hit.entityKey).flatMap(hit => entityKeyWithAncestors(hit.entityKey)))); + + setEntity(entity: ISirenClientObject) { + this.entity.set(entity); + } + + setRawObject(rawObject: unknown) { + this.rawObject.set(rawObject); + } + + setInputText(text: string) { + this.inputText.set(text); + clearTimeout(this.debounceHandle); + this.debounceHandle = setTimeout(() => this.commitQuery(), EntitySearchService.debounceMs); + } + + setOptions(options: SearchOptions) { + const current = this.options(); + if ((Object.keys(options) as (keyof SearchOptions)[]).every(key => options[key] === current[key])) return; + this.options.set(options); + this.revealCurrent(); + } + + clear() { + clearTimeout(this.debounceHandle); + this.debounceHandle = undefined; + this.inputText.set(''); + this.query.set(''); + } + + next() { + this.step(1); + } + + previous() { + this.step(-1); + } + + isHitTarget(target: string): boolean { + return this.hitTargets().has(target); + } + + isHitField(target: string, field: SearchField): boolean { + return this.hitFields().has(fieldKey(target, field)); + } + + isCurrentTarget(target: string): boolean { + return this.currentHit()?.target === target; + } + + isCurrentField(target: string, field: SearchField): boolean { + const current = this.currentHit(); + return current?.target === target && current.field === field; + } + + /** True if the entity or one of its embedded descendants renders a hit in its content area. */ + containsHit(entityKey: string): boolean { + return this.entitiesWithHits().has(entityKey); + } + + matchRanges(text: string): MatchRange[] { + const matcher = this.matcher(); + return matcher.kind === 'valid' ? matcher.match(text) : []; + } + + private step(direction: 1 | -1) { + // Enter right after typing should go to the first hit of the new query, not past it + if (this.debounceHandle !== undefined) { + this.commitQuery(); + return; + } + const count = this.hits().length; + if (count === 0) return; + this.currentIndex.update(index => (index + direction + count) % count); + this.revealCurrent(); + } + + private commitQuery() { + clearTimeout(this.debounceHandle); + this.debounceHandle = undefined; + this.query.set(this.inputText().trim()); + this.revealCurrent(); + } + + /** + * Scrolls the current hit into view. Expanding its embedded entities renders their content + * asynchronously (lazy panel content, expand animation), so retry for a few frames. + */ + private revealCurrent(remainingFrames = 30) { + const hit = this.currentHit(); + if (!hit) return; + requestAnimationFrame(() => { + if (this.currentHit() !== hit) return; + const element = document.querySelector(`[data-search-target="${CSS.escape(hit.target)}"]`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } else if (remainingFrames > 0) { + this.revealCurrent(remainingFrames - 1); + } + }); + } +} + +function fieldKey(target: string, field: SearchField): string { + return `${target}#${field}`; +} diff --git a/src/app/hypermedia-view/search/entity-search.spec.ts b/src/app/hypermedia-view/search/entity-search.spec.ts new file mode 100644 index 0000000..f44fe04 --- /dev/null +++ b/src/app/hypermedia-view/search/entity-search.spec.ts @@ -0,0 +1,126 @@ +import { collectEntityHits, defaultSearchOptions, SearchOptions } from './entity-search'; +import { createMatcher, TextMatcher } from './search-matcher'; +import { SirenClientObject } from '../siren-parser/siren-client-object'; +import { EmbeddedEntity } from '../siren-parser/embedded-entity'; +import { EmbeddedLinkEntity } from '../siren-parser/embedded-link-entity'; +import { PropertyInfo, PropertyTypes } from '../siren-parser/property-info'; +import { HypermediaLink } from '../siren-parser/hypermedia-link'; +import { HypermediaAction } from '../siren-parser/hypermedia-action'; +import { relationDisplayTextMapping, updateMappingsDisplayTextMappings } from '../display-text-mapping'; + +describe('collectEntityHits', () => { + const allScopes: SearchOptions = { + ...defaultSearchOptions, + linkRelations: true, + embeddedRelations: true, + actions: true, + }; + + function matcher(query: string, caseSensitive = false, regex = false): TextMatcher { + const result = createMatcher(query, caseSensitive, regex); + if (result.kind !== 'valid') throw new Error('expected a valid matcher'); + return result.match; + } + + function createOrder(): SirenClientObject { + const order = new SirenClientObject(); + order.title = 'Order'; + order.properties = [ + new PropertyInfo('customer', { name: 'Ada', address: { city: 'London' } }, PropertyTypes.object), + ]; + order.links = [new HypermediaLink(['self'], 'https://api/order/1', 'application/vnd.siren+json')]; + const cancel = new HypermediaAction(); + cancel.name = 'CancelOrder'; + cancel.title = 'Cancel'; + order.actions = [cancel]; + + const item = new EmbeddedEntity(); + item.relations = ['item']; + item.title = 'Line item'; + item.properties = [new PropertyInfo('product', 'London Fog Tea', PropertyTypes.string)]; + order.embeddedEntities = [item]; + + const invoice = new EmbeddedLinkEntity(); + invoice.relations = ['invoice']; + invoice.title = 'Invoice'; + order.embeddedLinkEntities = [invoice]; + return order; + } + + afterEach(() => { + Object.keys(relationDisplayTextMapping).forEach(key => delete relationDisplayTextMapping[key]); + }); + + it('finds nested property values in render order, including embedded entities', () => { + const hits = collectEntityHits(createOrder(), defaultSearchOptions, matcher('london'), { showClasses: false }); + + expect(hits).toEqual([ + { target: 'e|p|customer/address/city', field: 'value', entityKey: 'e' }, + { target: 'e.0|p|product', field: 'value', entityKey: 'e.0' }, + ]); + }); + + it('finds titles of the entity, embedded entities and embedded links', () => { + const hits = collectEntityHits(createOrder(), defaultSearchOptions, matcher('i'), { showClasses: false }) + .filter(hit => hit.field === 'title'); + + expect(hits.map(hit => hit.target)).toEqual(['e.0|header', 'e|le|0']); + }); + + it('finds link, embedded and action relations only when enabled', () => { + const order = createOrder(); + + expect(collectEntityHits(order, defaultSearchOptions, matcher('self'), { showClasses: false })).toEqual([]); + expect(collectEntityHits(order, allScopes, matcher('self'), { showClasses: false })) + .toEqual([{ target: 'e|l|0', field: 'rel', entityKey: 'e' }]); + expect(collectEntityHits(order, allScopes, matcher('cancelorder'), { showClasses: false })) + .toEqual([{ target: 'e|a|0', field: 'title', entityKey: 'e' }]); + expect(collectEntityHits(order, allScopes, matcher('invoice'), { showClasses: false }).map(hit => hit.field)) + .toEqual(['rel', 'title']); + }); + + it('finds a relation by its mapped display text', () => { + updateMappingsDisplayTextMappings({ item: 'Position' }); + + const hits = collectEntityHits(createOrder(), allScopes, matcher('position'), { showClasses: false }); + + expect(hits).toEqual([{ target: 'e.0|header', field: 'rel', entityKey: 'e' }]); + }); + + it('matches classes only while they are shown', () => { + const order = createOrder(); + order.classes = ['OrderClass']; + + expect(collectEntityHits(order, defaultSearchOptions, matcher('orderclass'), { showClasses: false })).toEqual([]); + expect(collectEntityHits(order, defaultSearchOptions, matcher('orderclass'), { showClasses: true })) + .toEqual([{ target: 'e|classes', field: 'classes', entityKey: 'e' }]); + }); + + it('respects case sensitivity and regular expressions', () => { + const order = createOrder(); + + expect(collectEntityHits(order, defaultSearchOptions, matcher('london', true), { showClasses: false })).toEqual([]); + expect(collectEntityHits(order, defaultSearchOptions, matcher('^Lon.*Tea$', false, true), { showClasses: false })) + .toEqual([{ target: 'e.0|p|product', field: 'value', entityKey: 'e.0' }]); + }); +}); + +describe('createMatcher', () => { + it('reports an invalid regular expression', () => { + expect(createMatcher('(', false, true).kind).toBe('invalid'); + }); + + it('treats regex characters literally when regex is off', () => { + const result = createMatcher('a.b', false, false); + if (result.kind !== 'valid') throw new Error('expected a valid matcher'); + + expect(result.match('axb a.b')).toEqual([{ start: 4, end: 7 }]); + }); + + it('skips zero-length matches', () => { + const result = createMatcher('x*', false, true); + if (result.kind !== 'valid') throw new Error('expected a valid matcher'); + + expect(result.match('axxb')).toEqual([{ start: 1, end: 3 }]); + }); +}); diff --git a/src/app/hypermedia-view/search/entity-search.ts b/src/app/hypermedia-view/search/entity-search.ts new file mode 100644 index 0000000..a4aa8ca --- /dev/null +++ b/src/app/hypermedia-view/search/entity-search.ts @@ -0,0 +1,163 @@ +import { ISirenClientObject } from '../siren-parser/entity-interfaces'; +import { PropertyInfo, PropertyTypes } from '../siren-parser/property-info'; +import { getDisplayTextForRelation } from '../display-text-mapping'; +import { TextMatcher } from './search-matcher'; + +export interface SearchOptions { + propertyValues: boolean; + propertyNames: boolean; + titles: boolean; + linkRelations: boolean; + embeddedRelations: boolean; + actions: boolean; + caseSensitive: boolean; + regex: boolean; +} + +export const defaultSearchOptions: SearchOptions = { + propertyValues: true, + propertyNames: true, + titles: true, + linkRelations: false, + embeddedRelations: false, + actions: false, + caseSensitive: false, + regex: false, +}; + +/** + * One matching text field. `target` identifies the rendered element that holds the field + * (scroll target), `entityKey` the entity whose content area renders it (must be expanded). + */ +export interface SearchHit { + target: string; + field: SearchField; + entityKey: string; +} + +export type SearchField = 'name' | 'value' | 'title' | 'classes' | 'rel' | 'raw'; + +export interface EntitySearchContext { + showClasses: boolean; +} + +// Entity keys mirror the embedding structure: "e", "e.0", "e.0.3", ... +export const rootEntityKey = 'e'; +export const embeddedEntityKey = (parentKey: string, index: number) => `${parentKey}.${index}`; +export const entityTitleTarget = (entityKey: string) => `${entityKey}|title`; +export const entityClassesTarget = (entityKey: string) => `${entityKey}|classes`; +export const propertyTarget = (entityKey: string, path: string[]) => + `${entityKey}|p|${path.map(encodeURIComponent).join('/')}`; +export const linkTarget = (entityKey: string, index: number) => `${entityKey}|l|${index}`; +export const actionTarget = (entityKey: string, index: number) => `${entityKey}|a|${index}`; +export const embeddedHeaderTarget = (entityKey: string) => `${entityKey}|header`; +export const embeddedLinkEntityTarget = (entityKey: string, index: number) => `${entityKey}|le|${index}`; +export const rawTarget = (index: number) => `raw|${index}`; + +/** The entity key itself plus all its ancestors, e.g. "e.0.3" -> ["e", "e.0", "e.0.3"]. */ +export function entityKeyWithAncestors(entityKey: string): string[] { + const parts = entityKey.split('.'); + return parts.map((_, i) => parts.slice(0, i + 1).join('.')); +} + +/** Child properties as rendered by the property tree: object keys in insertion order, array items by index. */ +export function getChildProperties(node: PropertyInfo): PropertyInfo[] | null { + if (node.type !== PropertyTypes.object && node.type !== PropertyTypes.array) return null; + const val = node.value; + return Object.keys(val).map(key => { + const v = val[key]; + let type = PropertyTypes.object; + if (v === null) type = PropertyTypes.nullvalue; + else if (Array.isArray(v)) type = PropertyTypes.array; + else if (typeof v === 'number') type = PropertyTypes.number; + else if (typeof v === 'boolean') type = PropertyTypes.boolean; + else if (typeof v === 'string') type = PropertyTypes.string; + return new PropertyInfo(key, v, type); + }); +} + +export function displayTextOfAction(action: { title: string, name: string | undefined }): string { + return action.title || action.name || ''; +} + +/** Collects hits in render order: title, classes, properties, links, actions, embedded entities. */ +export function collectEntityHits( + entity: ISirenClientObject, + options: SearchOptions, + match: TextMatcher, + context: EntitySearchContext): SearchHit[] { + + const hits: SearchHit[] = []; + const matches = (text: string | undefined | null) => !!text && match(text).length > 0; + const relationsMatch = (relations: string[]) => + relations.some(rel => matches(rel) || matches(getDisplayTextForRelation(rel))); + + const visitProperty = (entityKey: string, property: PropertyInfo, path: string[]) => { + const target = propertyTarget(entityKey, path); + if (options.propertyNames && matches(property.name)) { + hits.push({ target, field: 'name', entityKey }); + } + if (options.propertyValues && property.value !== null && typeof property.value !== 'object' && matches(String(property.value))) { + hits.push({ target, field: 'value', entityKey }); + } + getChildProperties(property)?.forEach(child => visitProperty(entityKey, child, [...path, child.name])); + }; + + const visitEntity = (current: ISirenClientObject, entityKey: string, isRoot: boolean) => { + // An embedded entity's title and classes are rendered in its panel header (see visitEmbedded) + if (isRoot && options.titles) { + if (matches(current.title)) { + hits.push({ target: entityTitleTarget(entityKey), field: 'title', entityKey }); + } + if (context.showClasses && matches(current.classes.join(','))) { + hits.push({ target: entityClassesTarget(entityKey), field: 'classes', entityKey }); + } + } + + current.properties.forEach(property => visitProperty(entityKey, property, [property.name])); + + if (options.linkRelations) { + current.links.forEach((link, index) => { + if (relationsMatch(link.relations)) { + hits.push({ target: linkTarget(entityKey, index), field: 'rel', entityKey }); + } + }); + } + + if (options.actions) { + current.actions.forEach((action, index) => { + if (matches(action.title) || matches(action.name)) { + hits.push({ target: actionTarget(entityKey, index), field: 'title', entityKey }); + } + }); + } + + current.embeddedEntities.forEach((embedded, index) => { + const childKey = embeddedEntityKey(entityKey, index); + visitHeader(embeddedHeaderTarget(childKey), embedded, entityKey); + visitEntity(embedded, childKey, false); + }); + + current.embeddedLinkEntities.forEach((linkEntity, index) => { + visitHeader(embeddedLinkEntityTarget(entityKey, index), linkEntity, entityKey); + }); + }; + + const visitHeader = ( + target: string, + embedded: { relations: string[], title: string, classes: string[] }, + parentKey: string) => { + if (options.embeddedRelations && relationsMatch(embedded.relations)) { + hits.push({ target, field: 'rel', entityKey: parentKey }); + } + if (options.titles && matches(embedded.title)) { + hits.push({ target, field: 'title', entityKey: parentKey }); + } + if (options.titles && context.showClasses && matches(embedded.classes.join(','))) { + hits.push({ target, field: 'classes', entityKey: parentKey }); + } + }; + + visitEntity(entity, rootEntityKey, true); + return hits; +} diff --git a/src/app/hypermedia-view/search/search-highlight.component.ts b/src/app/hypermedia-view/search/search-highlight.component.ts new file mode 100644 index 0000000..edb947c --- /dev/null +++ b/src/app/hypermedia-view/search/search-highlight.component.ts @@ -0,0 +1,33 @@ +import { Component, computed, inject, input } from '@angular/core'; +import { EntitySearchService } from './entity-search.service'; +import { SearchField } from './entity-search'; +import { splitByRanges, TextSegment } from './search-matcher'; + +/** Renders a text and marks the search matches in it, if the field is a search hit. */ +@Component({ + selector: 'app-search-highlight', + template: `@for (segment of segments(); track $index) {@if (segment.isMatch) {{{segment.text}}} @else {{{segment.text}}}}`, + styles: ` + .search-mark { background-color: #fff176; color: inherit; padding: 0; border-radius: 2px; } + .search-mark.current { background-color: #ff9800; } + `, + standalone: false +}) +export class SearchHighlightComponent { + private search = inject(EntitySearchService); + + readonly text = input(''); + readonly target = input.required(); + readonly field = input.required(); + + readonly isCurrent = computed(() => this.search.isCurrentField(this.target(), this.field())); + + readonly segments = computed(() => { + const text = this.text() == null ? '' : String(this.text()); + if (!this.search.isHitField(this.target(), this.field())) { + return [{ text, isMatch: false }]; + } + // A relation can match via its raw name while showing its mapped display text: then nothing is marked here + return splitByRanges(text, this.search.matchRanges(text)); + }); +} diff --git a/src/app/hypermedia-view/search/search-matcher.ts b/src/app/hypermedia-view/search/search-matcher.ts new file mode 100644 index 0000000..338555a --- /dev/null +++ b/src/app/hypermedia-view/search/search-matcher.ts @@ -0,0 +1,67 @@ +export interface MatchRange { + start: number; + end: number; +} + +export type TextMatcher = (text: string) => MatchRange[]; + +export type MatcherResult = + | { kind: 'none' } + | { kind: 'invalid', error: string } + | { kind: 'valid', match: TextMatcher }; + +export function createMatcher(query: string, caseSensitive: boolean, regex: boolean): MatcherResult { + if (!query) { + return { kind: 'none' }; + } + + let pattern: RegExp; + try { + pattern = new RegExp(regex ? query : escapeRegExp(query), caseSensitive ? 'g' : 'gi'); + } catch (e) { + return { kind: 'invalid', error: e instanceof Error ? e.message : String(e) }; + } + + return { + kind: 'valid', + match: (text: string) => { + const ranges: MatchRange[] = []; + pattern.lastIndex = 0; + let result: RegExpExecArray | null; + while ((result = pattern.exec(text)) !== null) { + if (result[0].length === 0) { + // zero-length matches (e.g. "a*") would never advance and carry nothing to highlight + pattern.lastIndex++; + continue; + } + ranges.push({ start: result.index, end: result.index + result[0].length }); + } + return ranges; + }, + }; +} + +export interface TextSegment { + text: string; + isMatch: boolean; +} + +export function splitByRanges(text: string, ranges: MatchRange[]): TextSegment[] { + const segments: TextSegment[] = []; + let position = 0; + for (const range of ranges) { + if (range.start > position) { + segments.push({ text: text.substring(position, range.start), isMatch: false }); + } + segments.push({ text: text.substring(range.start, range.end), isMatch: true }); + position = range.end; + } + if (position < text.length) { + segments.push({ text: text.substring(position), isMatch: false }); + } + return segments; +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/app/settings/app-settings.ts b/src/app/settings/app-settings.ts index cabc3b8..8743018 100644 --- a/src/app/settings/app-settings.ts +++ b/src/app/settings/app-settings.ts @@ -1,4 +1,5 @@ import { Record, Map } from 'immutable' +import { defaultSearchOptions, SearchOptions } from '../hypermedia-view/search/entity-search'; export class Test extends Record({num: 1, other: false}) {} @@ -15,6 +16,8 @@ export class GeneralSettings extends Record({ useEmbeddingPropertyForActionParameters: true, showHostInformation: true, showPropertyTreeControls: true, + showSearch: true, + searchOptions: defaultSearchOptions as SearchOptions, actionExecutionTimeoutMs: 60000 }) {} diff --git a/src/app/settings/general-settings-page/general-settings-page.component.html b/src/app/settings/general-settings-page/general-settings-page.component.html index 8afeaae..8ffe140 100644 --- a/src/app/settings/general-settings-page/general-settings-page.component.html +++ b/src/app/settings/general-settings-page/general-settings-page.component.html @@ -13,6 +13,7 @@ Use embedding property for action parameters
Show host information and security of connection
Show property tree controls
+ Show search
Action execution timeout (ms) diff --git a/src/app/settings/general-settings-page/general-settings-page.component.ts b/src/app/settings/general-settings-page/general-settings-page.component.ts index a2495ff..83e3b7e 100644 --- a/src/app/settings/general-settings-page/general-settings-page.component.ts +++ b/src/app/settings/general-settings-page/general-settings-page.component.ts @@ -37,6 +37,7 @@ export class GeneralSettingsPageComponent implements OnInit { useEmbeddingPropertyForActionParameters: FormControl = new FormControl(false, { nonNullable: true }); showHostInformation: FormControl = new FormControl(false, { nonNullable: true }); showPropertyTreeControls: FormControl = new FormControl(false, { nonNullable: true }); + showSearch: FormControl = new FormControl(false, { nonNullable: true }); actionExecutionTimeoutMs: FormControl = new FormControl(0, { validators: Validators.required, nonNullable: true }); constructor() { @@ -88,6 +89,9 @@ export class GeneralSettingsPageComponent implements OnInit { this.showPropertyTreeControls.setValue(this.generalSettings.showPropertyTreeControls); this.showPropertyTreeControls.valueChanges.subscribe(v => this.store.dispatch(updateGeneralAppSettings({ newGeneralSettings: this.generalSettings.set("showPropertyTreeControls", v)}))); + this.showSearch.setValue(this.generalSettings.showSearch); + this.showSearch.valueChanges.subscribe(v => this.store.dispatch(updateGeneralAppSettings({ newGeneralSettings: this.generalSettings.set("showSearch", v)}))); + this.actionExecutionTimeoutMs.setValue(this.generalSettings.actionExecutionTimeoutMs); this.actionExecutionTimeoutMs.valueChanges.subscribe(v => { const value = v == null ? 60000 : v; diff --git a/src/app/settings/services/AppSettings.ts b/src/app/settings/services/AppSettings.ts index d280ede..d5b3e78 100644 --- a/src/app/settings/services/AppSettings.ts +++ b/src/app/settings/services/AppSettings.ts @@ -1,3 +1,5 @@ +import { defaultSearchOptions, SearchOptions } from '../../hypermedia-view/search/entity-search'; + export class AppSettingsStorageModel { GeneralSettings: GeneralSettingsStorageModel = new GeneralSettingsStorageModel(); SiteSettings: SiteSettingsStorageModel = new SiteSettingsStorageModel(); @@ -14,6 +16,11 @@ export class AppSettingsStorageModel { if (this.GeneralSettings.showPropertyTreeControls === undefined) { this.GeneralSettings.showPropertyTreeControls = true; } + if (this.GeneralSettings.showSearch === undefined) { + this.GeneralSettings.showSearch = true; + } + // options added later keep their default + this.GeneralSettings.searchOptions = { ...defaultSearchOptions, ...this.GeneralSettings.searchOptions }; } } @@ -42,6 +49,10 @@ export class GeneralSettingsStorageModel { showPropertyTreeControls: boolean = true; + showSearch: boolean = true; + + searchOptions: SearchOptions = defaultSearchOptions; + actionExecutionTimeoutMs: number = 60000; } diff --git a/src/app/settings/services/settings.service.ts b/src/app/settings/services/settings.service.ts index ad2efa8..4b518a8 100644 --- a/src/app/settings/services/settings.service.ts +++ b/src/app/settings/services/settings.service.ts @@ -68,6 +68,8 @@ export class SettingsService { useEmbeddingPropertyForActionParameters: storageModel.useEmbeddingPropertyForActionParameters, showHostInformation: storageModel.showHostInformation, showPropertyTreeControls: storageModel.showPropertyTreeControls, + showSearch: storageModel.showSearch, + searchOptions: storageModel.searchOptions, actionExecutionTimeoutMs: storageModel.actionExecutionTimeoutMs, }); } @@ -107,6 +109,8 @@ export class SettingsService { useEmbeddingPropertyForActionParameters: appModel.useEmbeddingPropertyForActionParameters, showHostInformation: appModel.showHostInformation, showPropertyTreeControls: appModel.showPropertyTreeControls, + showSearch: appModel.showSearch, + searchOptions: appModel.searchOptions, actionExecutionTimeoutMs: appModel.actionExecutionTimeoutMs, }; } diff --git a/src/app/store/selectors.ts b/src/app/store/selectors.ts index b1398c1..0e917e4 100644 --- a/src/app/store/selectors.ts +++ b/src/app/store/selectors.ts @@ -19,6 +19,8 @@ export const selectEffectiveGeneralSettings = createSelector( useEmbeddingPropertyForActionParameters: generalSettings.useEmbeddingPropertyForActionParameters && !appConfig.disableDeveloperControls, showHostInformation: generalSettings.showHostInformation && !appConfig.disableDeveloperControls, showPropertyTreeControls: generalSettings.showPropertyTreeControls && !appConfig.disableDeveloperControls, + showSearch: generalSettings.showSearch && appConfig.showSearch, + searchOptions: generalSettings.searchOptions, actionExecutionTimeoutMs: generalSettings.actionExecutionTimeoutMs }); } diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 71b2eed..8f929c1 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -15,3 +15,14 @@ body{ max-width: none !important; width: 800px; } + +// Entity search: elements that contain a hit, and the hit navigated to +.search-hit { + outline: 2px solid #fff176; + outline-offset: 2px; + border-radius: 4px; +} + +.search-current { + outline-color: #ff9800; +} From 8b9e28a20e9c650b22d1745f3f88b3fd2a7cb380 Mon Sep 17 00:00:00 2001 From: Mathias Reichardt Date: Sat, 3 Oct 2026 00:34:48 +0200 Subject: [PATCH 02/12] feat(entity-view): add expand/collapse all for embedded entities - Add icon buttons right of the embedded items, next to the counter; they open or close only that level's panels - Nested embedded levels keep their state and have their own buttons - Property tree expand/collapse buttons now show only the icon; the label moved to a tooltip - Add e2e tests, including nested embedded entities in the test data --- e2e/hypermedia-ui.spec.ts | 37 +++++++++++++++++++ .../embedded-entity-view.component.html | 9 ++++- .../embedded-entity-view.component.scss | 19 ++++++++-- .../embedded-entity-view.component.ts | 8 ++++ .../property-tree.component.html | 8 ++-- .../property-tree.component.scss | 19 ---------- 6 files changed, 73 insertions(+), 27 deletions(-) diff --git a/e2e/hypermedia-ui.spec.ts b/e2e/hypermedia-ui.spec.ts index f342139..4122d5f 100644 --- a/e2e/hypermedia-ui.spec.ts +++ b/e2e/hypermedia-ui.spec.ts @@ -24,6 +24,14 @@ const entryPoint = { class: ['Summary'], title: 'Embedded summary', properties: { status: 'ready' }, + entities: [ + { + rel: ['detail'], + class: ['Detail'], + title: 'Nested detail', + properties: { depth: 'nested-value' }, + }, + ], }, { rel: ['customer-reference'], @@ -255,6 +263,35 @@ test('shows raw Siren data and embedded entities', async ({ page }) => { await expect(page.getByText('Customer 42', { exact: true })).toBeVisible(); }); +test('expands and collapses all embedded entities of a level', async ({ page }) => { + await openEntryPoint(page); + const status = page.getByText('ready', { exact: true }); + await page.getByRole('button', { name: 'Expand all embedded entities' }).click(); + await expect(status).toBeVisible(); + await page.getByRole('button', { name: 'Collapse all embedded entities' }).first().click(); + await expect(status).toBeHidden(); +}); + +test('leaves nested embedded entities alone when expanding or collapsing a level', async ({ page }) => { + await openEntryPoint(page); + const expandAll = page.getByRole('button', { name: 'Expand all embedded entities' }); + const collapseAll = page.getByRole('button', { name: 'Collapse all embedded entities' }); + const summaryHeader = page.locator('mat-expansion-panel-header', { hasText: 'Embedded summary' }); + const nestedHeader = page.locator('mat-expansion-panel-header', { hasText: 'Nested detail' }); + + await expandAll.click(); + await expect(summaryHeader).toHaveAttribute('aria-expanded', 'true'); + await expect(nestedHeader).toHaveAttribute('aria-expanded', 'false'); + + await expandAll.last().click(); + await expect(nestedHeader).toHaveAttribute('aria-expanded', 'true'); + + await collapseAll.first().click(); + await expect(summaryHeader).toHaveAttribute('aria-expanded', 'false'); + await expect(nestedHeader).toHaveAttribute('aria-expanded', 'true'); + await expect(collapseAll).toHaveCount(1); +}); + test('previews JSON, text, and image link content', async ({ page }) => { await openEntryPoint(page); await page.getByRole('link', { name: 'plain-json' }).click(); diff --git a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.html b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.html index ca45699..2a804d9 100644 --- a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.html +++ b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.html @@ -1,6 +1,13 @@
- + @if (embeddedEntities?.length) { + + + } {{embeddedEntities?.length + embeddedLinkEntities?.length}} items
diff --git a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.scss b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.scss index c56ce6f..f24f31d 100644 --- a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.scss +++ b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.scss @@ -7,14 +7,27 @@ .itemsHeader { display: flex; - justify-content: space-between; + justify-content: flex-end; + align-items: center; } -.spacer { - flex: auto auto; +.panelControl { + width: 32px; + height: 32px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } } .itemsLabel { + margin-left: 4px; font-size: 12px; color: grey; } diff --git a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.ts b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.ts index c2b98e6..76eb46c 100644 --- a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.ts +++ b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.ts @@ -62,6 +62,14 @@ export class EmbeddedEntityViewComponent { }); } + expandAll() { + this.panels().forEach(panel => panel.open()); + } + + collapseAll() { + this.panels().forEach(panel => panel.close()); + } + navigateHref(href: string) { this.hypermediaClient.Navigate(href); } diff --git a/src/app/hypermedia-view/property-tree/property-tree.component.html b/src/app/hypermedia-view/property-tree/property-tree.component.html index 2ff8bae..4d9f83d 100644 --- a/src/app/hypermedia-view/property-tree/property-tree.component.html +++ b/src/app/hypermedia-view/property-tree/property-tree.component.html @@ -2,11 +2,11 @@ @if (showHeader && hasExpandableItems) {
- -
diff --git a/src/app/hypermedia-view/property-tree/property-tree.component.scss b/src/app/hypermedia-view/property-tree/property-tree.component.scss index e5d1ae9..2366240 100644 --- a/src/app/hypermedia-view/property-tree/property-tree.component.scss +++ b/src/app/hypermedia-view/property-tree/property-tree.component.scss @@ -19,25 +19,6 @@ $highlight-ancestor: #fffde7; width: 100%; box-sizing: border-box; - button { - flex-shrink: 0; - font-size: 11px; - height: 32px; - padding: 0 8px; - min-width: 40px; - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - margin: 0; - } - - span { - margin-left: 4px; - @media (max-width: 1300px) { display: none; } - } - } } .header-label { From 7f1310693bd0ea8b9da44aa89102d53b27479353 Mon Sep 17 00:00:00 2001 From: Mathias Reichardt Date: Sat, 3 Oct 2026 00:54:46 +0200 Subject: [PATCH 03/12] feat(entity-view): add floating page navigation for embedded entities - Floating footer at the bottom right, shown only when the top-level entity has embedded entities - Collapsed to a small handle; expands on hover or focus - Previous/next entity (Arrow up/down) on the left, top/bottom (Home/End) on the right - Top/bottom make the first/last embedded entity current - Previous/next step through top-level embedded entities and links; nested ones are skipped - Keys are ignored while text fields, toggles, menus, tree or dialogs handle them, and with modifier keys - Clicked footer buttons release focus, so Enter does not repeat them - Outline the current embedded entity - Clicking or tabbing into an entity makes it current; clicking elsewhere clears it - Enter expands/collapses the current entity; top-level panel headers show this in a tooltip - Reset on navigation - Bottom spacer so the footer never covers the last content - Add unit and e2e tests --- e2e/hypermedia-ui.spec.ts | 60 ++++++++++++++++ .../embedded-entity-view.component.html | 12 +++- .../embedded-entity-view.component.scss | 10 +++ .../embedded-entity-view.component.ts | 19 ++++++ .../hypermedia-control.component.html | 2 + .../hypermedia-control.component.ts | 16 +++++ .../hypermedia-view/hypermedia-view.module.ts | 2 + .../embedded-navigation.service.spec.ts | 55 +++++++++++++++ .../embedded-navigation.service.ts | 66 ++++++++++++++++++ .../page-navigation.component.html | 30 ++++++++ .../page-navigation.component.scss | 60 ++++++++++++++++ .../page-navigation.component.ts | 68 +++++++++++++++++++ 12 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 src/app/hypermedia-view/page-navigation/embedded-navigation.service.spec.ts create mode 100644 src/app/hypermedia-view/page-navigation/embedded-navigation.service.ts create mode 100644 src/app/hypermedia-view/page-navigation/page-navigation.component.html create mode 100644 src/app/hypermedia-view/page-navigation/page-navigation.component.scss create mode 100644 src/app/hypermedia-view/page-navigation/page-navigation.component.ts diff --git a/e2e/hypermedia-ui.spec.ts b/e2e/hypermedia-ui.spec.ts index 4122d5f..feadc70 100644 --- a/e2e/hypermedia-ui.spec.ts +++ b/e2e/hypermedia-ui.spec.ts @@ -263,6 +263,66 @@ test('shows raw Siren data and embedded entities', async ({ page }) => { await expect(page.getByText('Customer 42', { exact: true })).toBeVisible(); }); +test('navigates between top level embedded entities with keys, buttons and clicks', async ({ page }) => { + await openEntryPoint(page); + const navigation = page.getByRole('navigation', { name: 'Page navigation' }); + const summary = page.locator('mat-expansion-panel', { hasText: 'Embedded summary' }); + const linkedCustomer = page.locator('mat-card', { hasText: 'Linked customer' }); + const current = page.locator('.embedded-nav-current'); + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + + await expect(current).toHaveCount(0); + // collapsed to a handle until hovered + const buttonsWidth = async () => (await navigation.locator('.buttons').boundingBox())?.width ?? 0; + await expect.poll(buttonsWidth).toBe(0); + await navigation.hover(); + await expect.poll(buttonsWidth).toBe(4 * 40 + 3 * 4); + const buttonsBox = await navigation.locator('.buttons').boundingBox(); + const bottomButtonBox = await navigation.getByRole('button', { name: 'To bottom' }).boundingBox(); + expect(bottomButtonBox!.x + bottomButtonBox!.width).toBeLessThanOrEqual(buttonsBox!.x + buttonsBox!.width + 0.5); + await page.mouse.move(0, 0); + await page.keyboard.press('ArrowDown'); + await expect(summary).toHaveClass(/embedded-nav-current/); + const summaryHeader = summary.locator('mat-expansion-panel-header').first(); + await page.keyboard.press('Enter'); + await expect(summaryHeader).toHaveAttribute('aria-expanded', 'true'); + await page.keyboard.press('Enter'); + await expect(summaryHeader).toHaveAttribute('aria-expanded', 'false'); + await summaryHeader.hover(); + await expect(page.locator('.mat-mdc-tooltip')).toHaveText('Expand / collapse (Enter while selected)'); + await page.mouse.move(0, 0); + await page.keyboard.press('ArrowDown'); + await expect(linkedCustomer).toHaveClass(/embedded-nav-current/); + await expect(navigation.getByRole('button', { name: 'Next embedded entity' })).toBeDisabled(); + + await navigation.hover(); + await navigation.getByRole('button', { name: 'Previous embedded entity' }).click(); + await expect(summary).toHaveClass(/embedded-nav-current/); + // Enter toggles the current item instead of pressing the clicked footer button again + await page.keyboard.press('Enter'); + await expect(summaryHeader).toHaveAttribute('aria-expanded', 'true'); + await expect(summary).toHaveClass(/embedded-nav-current/); + await page.keyboard.press('Enter'); + await expect(summaryHeader).toHaveAttribute('aria-expanded', 'false'); + + await linkedCustomer.getByText('Linked customer', { exact: true }).click(); + await expect(linkedCustomer).toHaveClass(/embedded-nav-current/); + await expect(current).toHaveCount(1); + + await page.getByText('welcome:', { exact: true }).click(); + await expect(current).toHaveCount(0); + + await navigation.hover(); + await navigation.getByRole('button', { name: 'To bottom' }).click(); + await expect(linkedCustomer).toHaveClass(/embedded-nav-current/); + await page.keyboard.press('Home'); + await expect(summary).toHaveClass(/embedded-nav-current/); + + await page.getByRole('link', { name: 'customer', exact: true }).click(); + await expect(page.getByText('Customer 42', { exact: true })).toBeVisible(); + await expect(navigation).toHaveCount(0); +}); + test('expands and collapses all embedded entities of a level', async ({ page }) => { await openEntryPoint(page); const status = page.getByText('ready', { exact: true }); diff --git a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.html b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.html index 2a804d9..dfb22ba 100644 --- a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.html +++ b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.html @@ -12,8 +12,14 @@
@for (embeddedEntity of embeddedEntities; track embeddedEntity; let idx = $index) { - + @@ -47,6 +53,10 @@ @for (embeddedLinkEntity of embeddedLinkEntities; track embeddedLinkEntity; let idx = $index) { diff --git a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.scss b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.scss index f24f31d..1b3ff30 100644 --- a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.scss +++ b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.scss @@ -110,3 +110,13 @@ ::ng-deep div.mat-expansion-panel-body { padding: defaults.$spacing; } + +// the item the page navigation points at +.embedded-nav-current { + outline: 2px solid #90caf9; + outline-offset: 2px; +} + +[data-embedded-nav-index] { + scroll-margin-top: 8px; +} diff --git a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.ts b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.ts index 76eb46c..b70d76c 100644 --- a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.ts +++ b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.ts @@ -1,4 +1,5 @@ import { Component, Input, effect, inject, viewChildren } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatExpansionPanel } from '@angular/material/expansion'; import { EmbeddedLinkEntity } from '../siren-parser/embedded-link-entity'; import { EmbeddedEntity } from '../siren-parser/embedded-entity'; @@ -11,6 +12,7 @@ import { AppSettings, GeneralSettings } from 'src/app/settings/app-settings'; import { AppConfig } from 'src/app.config.service'; import { selectEffectiveGeneralSettings } from 'src/app/store/selectors'; import { EntitySearchService } from '../search/entity-search.service'; +import { EmbeddedNavigationService } from '../page-navigation/embedded-navigation.service'; import { embeddedEntityKey, embeddedHeaderTarget, @@ -34,6 +36,7 @@ export class EmbeddedEntityViewComponent { generalSettings: GeneralSettings = new GeneralSettings(); protected search = inject(EntitySearchService); + protected navigation = inject(EmbeddedNavigationService); protected readonly embeddedEntityKey = embeddedEntityKey; protected readonly embeddedHeaderTarget = embeddedHeaderTarget; protected readonly embeddedLinkEntityTarget = embeddedLinkEntityTarget; @@ -52,6 +55,12 @@ export class EmbeddedEntityViewComponent { next: generalSettings => this.generalSettings = generalSettings, }); + this.navigation.toggleRequests + .pipe(takeUntilDestroyed()) + .subscribe(index => { + if (this.isTopLevel) this.panels()[index]?.toggle(); + }); + // Only ever opens panels: collapsing is left to the user, also when the search is cleared effect(() => { this.panels().forEach((panel, index) => { @@ -62,6 +71,16 @@ export class EmbeddedEntityViewComponent { }); } + /** Only the embedded items of the displayed entity take part in the page navigation. */ + get isTopLevel(): boolean { + return this.entityKey === rootEntityKey; + } + + /** Clicking into or focusing a top level item makes it the one the page navigation continues from. */ + selectForNavigation(index: number) { + if (this.isTopLevel) this.navigation.select(index); + } + expandAll() { this.panels().forEach(panel => panel.open()); } diff --git a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.html b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.html index 73ff350..67fd2c8 100644 --- a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.html +++ b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.html @@ -142,3 +142,5 @@ } @else { } + + diff --git a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts index c012631..9c4dbfa 100644 --- a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts +++ b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts @@ -16,6 +16,7 @@ import {GlobalNavigationEvents} from "../../global-navigation.events"; import {EntitySearchService} from "../search/entity-search.service"; import {SearchOptions} from "../search/entity-search"; import {SettingsService} from "../../settings/services/settings.service"; +import {EmbeddedNavigationService} from "../page-navigation/embedded-navigation.service"; interface SearchOptionDefinition { key: keyof SearchOptions; @@ -37,6 +38,7 @@ export class HypermediaControlComponent implements OnInit, OnDestroy { private authService = inject(AuthService); protected search = inject(EntitySearchService); private settingsService = inject(SettingsService); + private embeddedNavigation = inject(EmbeddedNavigationService); private store = inject { this.hto = hto; this.search.setEntity(hto); + this.embeddedNavigation.reset(); + this.updateEmbeddedNavigation(); }); this.hypermediaClient.getHypermediaObjectRawStream().subscribe((rawResponse) => { @@ -151,6 +156,7 @@ export class HypermediaControlComponent implements OnInit, OnDestroy { this.hypermediaClient.getContentTypeStream().subscribe((contentType) => { this.contentType = contentType; + this.updateEmbeddedNavigation(); }); this.hypermediaClient.getNavPathsStream().subscribe((navPaths) => { @@ -224,6 +230,16 @@ export class HypermediaControlComponent implements OnInit, OnDestroy { public setShowRaw(showRaw: boolean) { this.showRaw = showRaw; this.updateSearchMode(); + this.updateEmbeddedNavigation(); + } + + private updateEmbeddedNavigation() { + const isEntityViewShown = !(this.GeneralSettings.showRawTab && this.showRaw) + && (!this.contentType || this.contentType.toLowerCase() === MediaTypes.Siren.toLowerCase()); + const itemCount = isEntityViewShown + ? this.hto.embeddedEntities.length + this.hto.embeddedLinkEntities.length + : 0; + this.embeddedNavigation.setItemCount(itemCount); } private updateSearchMode() { diff --git a/src/app/hypermedia-view/hypermedia-view.module.ts b/src/app/hypermedia-view/hypermedia-view.module.ts index 4e2f75b..f9f7897 100644 --- a/src/app/hypermedia-view/hypermedia-view.module.ts +++ b/src/app/hypermedia-view/hypermedia-view.module.ts @@ -62,6 +62,7 @@ import {FileSizePipe} from '../common/pipes/file-size.pipe'; import {allowEmptyArrayExtension} from "./formly-extensions"; import {PropertyTreeComponent} from "./property-tree/property-tree.component"; import {SearchHighlightComponent} from "./search/search-highlight.component"; +import {PageNavigationComponent} from "./page-navigation/page-navigation.component"; import {MatTree, MatTreeNode, MatTreeNodeDef, MatTreeNodePadding, MatTreeNodeToggle} from "@angular/material/tree"; import {MatChipsModule} from '@angular/material/chips'; import {MatSlideToggleModule} from '@angular/material/slide-toggle'; @@ -72,6 +73,7 @@ import {MatSlideToggleModule} from '@angular/material/slide-toggle'; HypermediaControlComponent, PropertyTreeComponent, SearchHighlightComponent, + PageNavigationComponent, LinkViewComponent, EmbeddedEntityViewComponent, EntityViewComponent, diff --git a/src/app/hypermedia-view/page-navigation/embedded-navigation.service.spec.ts b/src/app/hypermedia-view/page-navigation/embedded-navigation.service.spec.ts new file mode 100644 index 0000000..94a64d7 --- /dev/null +++ b/src/app/hypermedia-view/page-navigation/embedded-navigation.service.spec.ts @@ -0,0 +1,55 @@ +import { TestBed } from '@angular/core/testing'; +import { EmbeddedNavigationService } from './embedded-navigation.service'; + +describe('EmbeddedNavigationService', () => { + let navigation: EmbeddedNavigationService; + + beforeEach(() => { + navigation = TestBed.inject(EmbeddedNavigationService); + navigation.reset(); + navigation.setItemCount(3); + }); + + it('goes to the first item on the first step down', () => { + expect(navigation.canGoPrevious()).toBeFalse(); + + navigation.next(); + + expect(navigation.currentIndex()).toBe(0); + }); + + it('stops at the first and the last item', () => { + navigation.select(2); + navigation.next(); + expect(navigation.currentIndex()).toBe(2); + + navigation.select(0); + navigation.previous(); + expect(navigation.currentIndex()).toBe(0); + }); + + it('continues from a selected item', () => { + navigation.select(1); + + navigation.next(); + + expect(navigation.currentIndex()).toBe(2); + }); + + it('makes the first item current at the top and the last at the bottom', () => { + navigation.toBottom(); + expect(navigation.currentIndex()).toBe(2); + + navigation.toTop(); + expect(navigation.currentIndex()).toBe(0); + }); + + it('keeps the current item when the count is updated, unless it no longer exists', () => { + navigation.select(1); + navigation.setItemCount(3); + expect(navigation.currentIndex()).toBe(1); + + navigation.setItemCount(1); + expect(navigation.currentIndex()).toBe(-1); + }); +}); diff --git a/src/app/hypermedia-view/page-navigation/embedded-navigation.service.ts b/src/app/hypermedia-view/page-navigation/embedded-navigation.service.ts new file mode 100644 index 0000000..06ced35 --- /dev/null +++ b/src/app/hypermedia-view/page-navigation/embedded-navigation.service.ts @@ -0,0 +1,66 @@ +import { Injectable, signal } from '@angular/core'; +import { Subject } from 'rxjs'; + +/** Which top level embedded entity (or embedded link) the page navigation points at. */ +@Injectable({ providedIn: 'root' }) +export class EmbeddedNavigationService { + static readonly itemAttribute = 'data-embedded-nav-index'; + + /** Number of top level embedded items, 0 when the entity view is not shown. */ + readonly itemCount = signal(0); + /** -1 until the user navigated to an item. */ + readonly currentIndex = signal(-1); + /** Index of the item whose panel should be expanded or collapsed. */ + readonly toggleRequests = new Subject(); + + /** Keeps the current item, unless it no longer exists. */ + setItemCount(itemCount: number) { + this.itemCount.set(itemCount); + if (this.currentIndex() >= itemCount) this.currentIndex.set(-1); + } + + reset() { + this.currentIndex.set(-1); + } + + select(index: number) { + this.currentIndex.set(index); + } + + toggleCurrent() { + if (this.currentIndex() >= 0) this.toggleRequests.next(this.currentIndex()); + } + + canGoPrevious(): boolean { + return this.currentIndex() > 0; + } + + canGoNext(): boolean { + return this.currentIndex() < this.itemCount() - 1; + } + + previous() { + if (this.canGoPrevious()) this.goTo(this.currentIndex() - 1); + } + + next() { + if (this.canGoNext()) this.goTo(this.currentIndex() + 1); + } + + toTop() { + this.currentIndex.set(this.itemCount() > 0 ? 0 : -1); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + + toBottom() { + this.currentIndex.set(this.itemCount() - 1); + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' }); + } + + private goTo(index: number) { + this.currentIndex.set(index); + document + .querySelector(`[${EmbeddedNavigationService.itemAttribute}="${index}"]`) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } +} diff --git a/src/app/hypermedia-view/page-navigation/page-navigation.component.html b/src/app/hypermedia-view/page-navigation/page-navigation.component.html new file mode 100644 index 0000000..bbfd76d --- /dev/null +++ b/src/app/hypermedia-view/page-navigation/page-navigation.component.html @@ -0,0 +1,30 @@ +@if (navigation.itemCount() > 0) { + +
+ +} diff --git a/src/app/hypermedia-view/page-navigation/page-navigation.component.scss b/src/app/hypermedia-view/page-navigation/page-navigation.component.scss new file mode 100644 index 0000000..34f8ab6 --- /dev/null +++ b/src/app/hypermedia-view/page-navigation/page-navigation.component.scss @@ -0,0 +1,60 @@ +$size: 40px; +$gap: 4px; + +.bottom-space { + height: 72px; +} + +.page-navigation { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 100; + display: flex; + align-items: center; + min-width: $size; + min-height: $size; + padding: 4px; + box-sizing: border-box; + border-radius: 24px; + background: #fff; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); + opacity: 0.75; + transition: opacity 0.2s ease; + + .handle { + margin: 0 4px; + color: #666; + } + + .buttons { + display: flex; + gap: $gap; + max-width: 0; + overflow: hidden; + opacity: 0; + transition: max-width 0.2s ease, opacity 0.2s ease; + + // fixed size, so the expanded width below fits exactly (icon buttons default to 48px) + button { + width: $size; + height: $size; + padding: 8px; + flex-shrink: 0; + } + } + + &:hover, + &:focus-within { + opacity: 1; + + .handle { + display: none; + } + + .buttons { + max-width: 4 * $size + 3 * $gap; + opacity: 1; + } + } +} diff --git a/src/app/hypermedia-view/page-navigation/page-navigation.component.ts b/src/app/hypermedia-view/page-navigation/page-navigation.component.ts new file mode 100644 index 0000000..f477afb --- /dev/null +++ b/src/app/hypermedia-view/page-navigation/page-navigation.component.ts @@ -0,0 +1,68 @@ +import { Component, HostListener, inject } from '@angular/core'; +import { EmbeddedNavigationService } from './embedded-navigation.service'; + +// keys are left to elements that use them themselves, e.g. text fields, toggles and the property tree +const keyHandlingElements = [ + 'input', 'textarea', 'select', '[contenteditable]:not([contenteditable="false"])', + '[role="radio"]', '[role="radiogroup"]', '[role="tree"]', '[role="treeitem"]', '[role="menu"]', + '[role="menuitem"]', '[role="listbox"]', '[role="option"]', '[role="slider"]', '[role="tab"]', + '[role="switch"]', '.cdk-overlay-container', +].join(','); + +const enterHandlingElements = 'button, a, [role="button"], mat-expansion-panel-header'; + +@Component({ + selector: 'app-page-navigation', + templateUrl: './page-navigation.component.html', + styleUrls: ['./page-navigation.component.scss'], + standalone: false +}) +export class PageNavigationComponent { + protected navigation = inject(EmbeddedNavigationService); + + /** + * A clicked button would keep the focus, so Enter would press it again instead of toggling the + * current item. Buttons activated by keyboard (detail 0) keep the focus. + */ + releaseFocus(event: MouseEvent) { + if (event.detail > 0) (event.currentTarget as HTMLElement).blur(); + } + + // a click outside every top level embedded item, e.g. on the entity's own properties, clears the current item + @HostListener('document:pointerdown', ['$event']) + onPointerdown(event: PointerEvent) { + if (!(event.target instanceof Element)) return; + if (event.target.closest(`[${EmbeddedNavigationService.itemAttribute}], app-page-navigation, .cdk-overlay-container`)) return; + this.navigation.reset(); + } + + @HostListener('document:keydown', ['$event']) + onKeydown(event: KeyboardEvent) { + if (this.navigation.itemCount() === 0) return; + if (event.defaultPrevented || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return; + if (event.target instanceof Element && event.target.closest(keyHandlingElements)) return; + + switch (event.key) { + case 'Home': + this.navigation.toTop(); + break; + case 'End': + this.navigation.toBottom(); + break; + case 'ArrowUp': + this.navigation.previous(); + break; + case 'ArrowDown': + this.navigation.next(); + break; + case 'Enter': + // buttons, links and panel headers handle Enter themselves + if (event.target instanceof Element && event.target.closest(enterHandlingElements)) return; + this.navigation.toggleCurrent(); + break; + default: + return; + } + event.preventDefault(); + } +} From 8b0cdfc55f76629edef1bece8214b013dccba6f1 Mon Sep 17 00:00:00 2001 From: Mathias Reichardt Date: Sat, 3 Oct 2026 01:02:39 +0200 Subject: [PATCH 04/12] fix(settings): keep site settings panel open while editing - Track sites by an id that survives edits; each edit replaced the site record, which rebuilt and collapsed its panel and dropped focus - Keep a renamed site in place - Remove header rows by key, since the panel is no longer rebuilt - Add accessible name to settings button - Add e2e test --- e2e/hypermedia-ui.spec.ts | 28 ++++++++++++ .../settings-menu.component.html | 2 +- .../site-settings-page.component.html | 6 ++- .../site-settings-page.component.ts | 45 +++++++++++++++++-- .../site-settings/site-settings.component.ts | 12 ++--- 5 files changed, 81 insertions(+), 12 deletions(-) diff --git a/e2e/hypermedia-ui.spec.ts b/e2e/hypermedia-ui.spec.ts index feadc70..1f7c61e 100644 --- a/e2e/hypermedia-ui.spec.ts +++ b/e2e/hypermedia-ui.spec.ts @@ -548,3 +548,31 @@ test('sends credentials to an API backed by a BFF', async ({ page, context }) => await openEntryPoint(page); expect(await (await entryPointRequest).headerValue('cookie')).toContain('bff-session=session-cookie'); }); + +test('keeps a site settings panel open and the focus while editing it', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByRole('tab', { name: 'Sites' }).click(); + await page.getByRole('button', { name: 'Add site' }).click(); + + const site = page.locator('app-site-settings').nth(1); + const header = site.locator('mat-expansion-panel-header'); + await header.click(); + await site.getByRole('textbox', { name: 'Host' }).fill('api.test'); + await site.locator('#headersTitle').click(); + await expect(header).toHaveAttribute('aria-expanded', 'true'); + await expect(header).toContainText('api.test'); + + await site.locator('#addHeaderButton').click(); + await site.getByRole('textbox', { name: 'Key' }).fill('X-Tenant'); + await page.keyboard.press('Tab'); + await expect(site.getByRole('textbox', { name: 'Value' })).toBeFocused(); + await page.keyboard.type('blue'); + await site.locator('#headersTitle').click(); + await expect(header).toHaveAttribute('aria-expanded', 'true'); + await expect(site.getByRole('textbox', { name: 'Key' })).toHaveValue('X-Tenant'); + await expect(site.getByRole('textbox', { name: 'Value' })).toHaveValue('blue'); + + await site.locator('.deleteHeader').click(); + await expect(site.getByRole('textbox', { name: 'Key' })).toHaveCount(0); +}); diff --git a/src/app/settings/settings-menu/settings-menu.component.html b/src/app/settings/settings-menu/settings-menu.component.html index 30c84b0..798f571 100644 --- a/src/app/settings/settings-menu/settings-menu.component.html +++ b/src/app/settings/settings-menu/settings-menu.component.html @@ -1,5 +1,5 @@
-
diff --git a/src/app/settings/site-settings-page/site-settings-page.component.html b/src/app/settings/site-settings-page/site-settings-page.component.html index b9d7616..f10cbc4 100644 --- a/src/app/settings/site-settings-page/site-settings-page.component.html +++ b/src/app/settings/site-settings-page/site-settings-page.component.html @@ -2,8 +2,10 @@ -@for (site of siteSettings.siteSpecificSettings.values(); track site; let i = $index) { - +@for (site of sites; track site.id) { + }
diff --git a/src/app/settings/site-settings-page/site-settings-page.component.ts b/src/app/settings/site-settings-page/site-settings-page.component.ts index 4fc1ed6..e3c8423 100644 --- a/src/app/settings/site-settings-page/site-settings-page.component.ts +++ b/src/app/settings/site-settings-page/site-settings-page.component.ts @@ -1,6 +1,6 @@ import { Component, inject } from '@angular/core'; import {FormControl} from '@angular/forms'; -import { AppSettings, SiteSettings } from '../app-settings'; +import { AppSettings, SiteSetting, SiteSettings } from '../app-settings'; import { Store } from '@ngrx/store'; import { addSite, removeSite } from 'src/app/store/appsettings.actions'; @@ -18,6 +18,15 @@ export class SiteSettingsPageComponent { siteFormControls: FormControl[] = []; siteSettings: SiteSettings = new SiteSettings(); + /** + * Every edit replaces the site's immutable record, so the sites are tracked by an id that survives edits. + * Otherwise each edit recreates the site's panel, which collapses it and drops the focus. + * Sorting by id keeps a renamed site in place. + */ + sites: { id: number, setting: SiteSetting }[] = []; + private siteIds = new Map(); + private nextSiteId = 0; + constructor() { const store = this.store; @@ -26,6 +35,7 @@ export class SiteSettingsPageComponent { .subscribe({ next: siteSettings => { this.siteSettings = siteSettings; + this.updateSites(); } }) } @@ -34,8 +44,35 @@ export class SiteSettingsPageComponent { this.store.dispatch(addSite({ siteUrl: "" })); } - removeSite(index: number): void { - const site = Array.from(this.siteSettings.siteSpecificSettings.entries())[index]; - this.store.dispatch(removeSite({ siteUrl: site[0] })); + removeSite(siteUrl: string): void { + this.store.dispatch(removeSite({ siteUrl })); + } + + /** Called before the rename is dispatched, so the renamed site keeps its id. */ + renameSiteId(change: { previousSiteUrl: string, newSiteUrl: string }) { + const id = this.siteIds.get(change.previousSiteUrl); + if (id === undefined) return; + this.siteIds.delete(change.previousSiteUrl); + this.siteIds.set(change.newSiteUrl, id); + } + + private updateSites() { + const settings = Array.from(this.siteSettings.siteSpecificSettings.values()); + const urls = new Set(settings.map(s => s.siteUrl)); + for (const url of Array.from(this.siteIds.keys())) { + if (!urls.has(url)) this.siteIds.delete(url); + } + this.sites = settings + .map(setting => ({ id: this.siteId(setting.siteUrl), setting })) + .sort((a, b) => a.id - b.id); + } + + private siteId(siteUrl: string): number { + let id = this.siteIds.get(siteUrl); + if (id === undefined) { + id = this.nextSiteId++; + this.siteIds.set(siteUrl, id); + } + return id; } } diff --git a/src/app/settings/site-settings/site-settings.component.ts b/src/app/settings/site-settings/site-settings.component.ts index 06a662c..47a30bc 100644 --- a/src/app/settings/site-settings/site-settings.component.ts +++ b/src/app/settings/site-settings/site-settings.component.ts @@ -23,6 +23,7 @@ export class SiteSettingsComponent implements OnInit { @Input() headline: string = ""; @Output() deleteRequested: EventEmitter = new EventEmitter(); + @Output() siteUrlChanging = new EventEmitter<{ previousSiteUrl: string, newSiteUrl: string }>(); public urlFormControl: FormControl = new FormControl(); headerFormGroups: FormGroup[] = []; @@ -35,6 +36,7 @@ export class SiteSettingsComponent implements OnInit { this.urlFormControl.valueChanges.subscribe(v => { v = v.trim(); + this.siteUrlChanging.emit({ previousSiteUrl: this.siteSetting!.siteUrl, newSiteUrl: v }); this.store.dispatch(updateSiteUrl({ previousSiteUrl: this.siteSetting!.siteUrl, newSiteUrl: v})); }); @@ -78,13 +80,13 @@ export class SiteSettingsComponent implements OnInit { this.headerFormGroups.push(this.AddHeaderFormControl(["", ""])); } + // the panel outlives store updates, so the row is removed here; renamed keys move in the store's map, so look up by key removeHeader(index: number) { - const header = Array.from(this.siteSetting!.headers.entries())[index]; - if (header) { - this.store.dispatch(removeHeader({ siteUrl: this.siteSetting!.siteUrl, key: header[0]})); - } else { - this.headerFormGroups.splice(index, 1); + const key = ((this.headerFormGroups[index]?.value.key as string | null) ?? "").trim(); + if (key !== "" && this.siteSetting!.headers.has(key)) { + this.store.dispatch(removeHeader({ siteUrl: this.siteSetting!.siteUrl, key })); } + this.headerFormGroups.splice(index, 1); } removeSite() { From f7c5fdd9db532a3ae7e342d463bf53f970dab26d Mon Sep 17 00:00:00 2001 From: Mathias Reichardt Date: Sat, 3 Oct 2026 01:19:32 +0200 Subject: [PATCH 05/12] fix(settings): store global headers and validate site hosts - Store global header edits in the global settings; they failed with "site url not present" - Validate site hosts while typing; interceptor matches only host name and non-default port - Reject scheme, path, credentials, invalid ports and duplicates, with a corrected-host suggestion - Store only valid hosts, on leaving the field; panel title shows the stored host - Placeholder "Host or IP, optional port" instead of a hint - Match site hosts case-insensitively - Add site opens and focuses the new site; focus an existing site without host instead of failing - Add unit and e2e tests --- e2e/hypermedia-ui.spec.ts | 90 ++++++++++++++++++- src/app/settings/services/settings.service.ts | 2 +- .../site-settings-page.component.html | 4 +- .../site-settings-page.component.ts | 19 +++- .../site-settings/site-host.validator.spec.ts | 39 ++++++++ .../site-settings/site-host.validator.ts | 40 +++++++++ .../site-settings.component.html | 10 ++- .../site-settings/site-settings.component.ts | 56 +++++++++--- src/app/store/appsettings.actions.ts | 7 +- src/app/store/appsettings.reducer.ts | 16 ++-- 10 files changed, 253 insertions(+), 30 deletions(-) create mode 100644 src/app/settings/site-settings/site-host.validator.spec.ts create mode 100644 src/app/settings/site-settings/site-host.validator.ts diff --git a/e2e/hypermedia-ui.spec.ts b/e2e/hypermedia-ui.spec.ts index 1f7c61e..a70b629 100644 --- a/e2e/hypermedia-ui.spec.ts +++ b/e2e/hypermedia-ui.spec.ts @@ -557,7 +557,7 @@ test('keeps a site settings panel open and the focus while editing it', async ({ const site = page.locator('app-site-settings').nth(1); const header = site.locator('mat-expansion-panel-header'); - await header.click(); + await expect(header).toHaveAttribute('aria-expanded', 'true'); await site.getByRole('textbox', { name: 'Host' }).fill('api.test'); await site.locator('#headersTitle').click(); await expect(header).toHaveAttribute('aria-expanded', 'true'); @@ -576,3 +576,91 @@ test('keeps a site settings panel open and the focus while editing it', async ({ await site.locator('.deleteHeader').click(); await expect(site.getByRole('textbox', { name: 'Key' })).toHaveCount(0); }); + +async function openSiteSettings(page: import('@playwright/test').Page) { + await page.goto('/'); + await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByRole('tab', { name: 'Sites' }).click(); +} + +async function addHeaderSetting(site: import('@playwright/test').Locator, key: string, value: string) { + await site.locator('#addHeaderButton').click(); + await site.getByRole('textbox', { name: 'Key' }).last().fill(key); + await site.getByRole('textbox', { name: 'Value' }).last().fill(value); + await site.locator('#headersTitle').click(); +} + +test('sends global and site specific headers', async ({ page }) => { + await openSiteSettings(page); + const global = page.locator('app-site-settings').first(); + await global.locator('mat-expansion-panel-header').click(); + await addHeaderSetting(global, 'X-Global', 'g1'); + + await page.getByRole('button', { name: 'Add site' }).click(); + const site = page.locator('app-site-settings').nth(1); + await expect(site.locator('mat-expansion-panel-header')).toHaveAttribute('aria-expanded', 'true'); + await site.getByRole('textbox', { name: 'Host' }).fill('API.test'); + await addHeaderSetting(site, 'X-Site', 's1'); + await expect(page.locator('app-error-dialog')).toHaveCount(0); + await page.keyboard.press('Escape'); + + await expect(page.getByText('Settings saved.')).toBeVisible(); + const request = page.waitForRequest(`${api}/entrypoint`); + await openEntryPoint(page); + const headers = (await request).headers(); + expect(headers['x-global']).toBe('g1'); + expect(headers['x-site']).toBe('s1'); +}); + +test('accepts only a host for site specific settings', async ({ page }) => { + await openSiteSettings(page); + await page.getByRole('button', { name: 'Add site' }).click(); + const site = page.locator('app-site-settings').nth(1); + const header = site.locator('mat-expansion-panel-header'); + await expect(header).toHaveAttribute('aria-expanded', 'true'); + const host = site.getByRole('textbox', { name: 'Host' }); + + await expect(host).toHaveAttribute('placeholder', 'Host or IP, optional port'); + await host.pressSequentially('https://api.test/entrypoint'); + // validated while typing + await expect(site.locator('mat-error')).toHaveText('Enter the host without scheme: api.test'); + await site.locator('#headersTitle').click(); + await expect(header).not.toContainText('api.test'); + + await host.fill('api.test/entrypoint'); + await site.locator('#headersTitle').click(); + await expect(site.locator('mat-error')).toHaveText('Enter the host without path, query or fragment: api.test'); + + await host.fill('api.test:8080'); + await site.locator('#headersTitle').click(); + await expect(site.locator('mat-error')).toHaveCount(0); + await expect(header).toContainText('api.test:8080'); + + // an invalid host is never stored, closing the settings discards it + await host.fill('https://other.test'); + await site.locator('#headersTitle').click(); + await page.keyboard.press('Escape'); + await expect(page.getByText('Settings saved.')).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem('appSettings'))).not.toContain('other.test'); + await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByRole('tab', { name: 'Sites' }).click(); + await page.locator('app-site-settings').nth(1).locator('mat-expansion-panel-header').click(); + await expect(page.locator('app-site-settings').nth(1).getByRole('textbox', { name: 'Host' })).toHaveValue('api.test:8080'); +}); + +test('opens a new site for entering its host and does not add a second one without host', async ({ page }) => { + await openSiteSettings(page); + await page.getByRole('button', { name: 'Add site' }).click(); + const newSite = page.locator('app-site-settings').nth(1); + await expect(newSite.locator('mat-expansion-panel-header')).toHaveAttribute('aria-expanded', 'true'); + await expect(newSite.getByRole('textbox', { name: 'Host' })).toBeFocused(); + + await newSite.locator('mat-expansion-panel-header').click(); + await page.getByRole('button', { name: 'Add site' }).click(); + + await expect(page.locator('app-site-settings')).toHaveCount(2); + await expect(page.locator('app-error-dialog')).toHaveCount(0); + const site = page.locator('app-site-settings').nth(1); + await expect(site.locator('mat-expansion-panel-header')).toHaveAttribute('aria-expanded', 'true'); + await expect(site.getByRole('textbox', { name: 'Host' })).toBeFocused(); +}); diff --git a/src/app/settings/services/settings.service.ts b/src/app/settings/services/settings.service.ts index 4b518a8..fbb7176 100644 --- a/src/app/settings/services/settings.service.ts +++ b/src/app/settings/services/settings.service.ts @@ -138,7 +138,7 @@ export class SettingsService { } getHeadersForSite(requestSiteHost: string): HeaderSettingStorageModel[] { - const specificSettings = this.CurrentSettings.SiteSettings.SiteSpecificSettings.filter(site => site.SiteUrl.trim() != '' && site.SiteUrl === requestSiteHost); + const specificSettings = this.CurrentSettings.SiteSettings.SiteSpecificSettings.filter(site => site.SiteUrl.trim() != '' && site.SiteUrl.trim().toLowerCase() === requestSiteHost); // we should only find one site if (specificSettings.length > 1) { throw new ProblemDetailsError({ diff --git a/src/app/settings/site-settings-page/site-settings-page.component.html b/src/app/settings/site-settings-page/site-settings-page.component.html index f10cbc4..a25ccd1 100644 --- a/src/app/settings/site-settings-page/site-settings-page.component.html +++ b/src/app/settings/site-settings-page/site-settings-page.component.html @@ -1,9 +1,9 @@ - @for (site of sites; track site.id) { - } diff --git a/src/app/settings/site-settings-page/site-settings-page.component.ts b/src/app/settings/site-settings-page/site-settings-page.component.ts index e3c8423..ebd896b 100644 --- a/src/app/settings/site-settings-page/site-settings-page.component.ts +++ b/src/app/settings/site-settings-page/site-settings-page.component.ts @@ -1,4 +1,5 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, viewChildren } from '@angular/core'; +import { SiteSettingsComponent } from '../site-settings/site-settings.component'; import {FormControl} from '@angular/forms'; import { AppSettings, SiteSetting, SiteSettings } from '../app-settings'; import { Store } from '@ngrx/store'; @@ -24,6 +25,8 @@ export class SiteSettingsPageComponent { * Sorting by id keeps a renamed site in place. */ sites: { id: number, setting: SiteSetting }[] = []; + siteUrls: string[] = []; + private siteComponents = viewChildren(SiteSettingsComponent); private siteIds = new Map(); private nextSiteId = 0; @@ -40,8 +43,17 @@ export class SiteSettingsPageComponent { }) } + // sites are keyed by host, so there can be only one new site without a host; it is opened for entering the host addSite(): void { - this.store.dispatch(addSite({ siteUrl: "" })); + if (!this.findNewSite()) { + this.store.dispatch(addSite({ siteUrl: "" })); + } + // the panel of a just added site is rendered with the next change detection + setTimeout(() => this.findNewSite()?.focusHost()); + } + + private findNewSite(): SiteSettingsComponent | undefined { + return this.siteComponents().find(c => !c.isGlobal && c.siteSetting?.siteUrl === ""); } removeSite(siteUrl: string): void { @@ -58,7 +70,8 @@ export class SiteSettingsPageComponent { private updateSites() { const settings = Array.from(this.siteSettings.siteSpecificSettings.values()); - const urls = new Set(settings.map(s => s.siteUrl)); + this.siteUrls = settings.map(s => s.siteUrl); + const urls = new Set(this.siteUrls); for (const url of Array.from(this.siteIds.keys())) { if (!urls.has(url)) this.siteIds.delete(url); } diff --git a/src/app/settings/site-settings/site-host.validator.spec.ts b/src/app/settings/site-settings/site-host.validator.spec.ts new file mode 100644 index 0000000..7e9a793 --- /dev/null +++ b/src/app/settings/site-settings/site-host.validator.spec.ts @@ -0,0 +1,39 @@ +import { siteHostError } from './site-host.validator'; + +describe('siteHostError', () => { + it('accepts host names, ports and IP addresses', () => { + for (const host of ['api.example.com', 'localhost:5000', 'API.Example.com', '127.0.0.1:8080', '[::1]:8080']) { + expect(siteHostError(host)).withContext(host).toBeNull(); + } + }); + + it('requires a host', () => { + expect(siteHostError(' ')).toContain('Enter a host'); + }); + + it('rejects a scheme and suggests the host', () => { + expect(siteHostError('https://api.example.com')).toBe('Enter the host without scheme: api.example.com'); + expect(siteHostError('http://localhost:5000/api')).toBe('Enter the host without scheme: localhost:5000'); + }); + + it('rejects a path, query or fragment', () => { + expect(siteHostError('api.example.com/entrypoint')).toBe('Enter the host without path, query or fragment: api.example.com'); + expect(siteHostError('api.example.com?a=1')).toContain('without path'); + expect(siteHostError('api.example.com#top')).toContain('without path'); + }); + + it('rejects invalid hosts', () => { + for (const host of ['user@api.example.com', 'api example.com', 'localhost:99999', 'localhost:abc']) { + expect(siteHostError(host)).withContext(host).toContain('Not a valid host'); + } + }); + + it('rejects hosts that requests address differently', () => { + expect(siteHostError('localhost:80')).toBe('Requests address this host as localhost'); + expect(siteHostError('bücher.de')).toBe('Requests address this host as xn--bcher-kva.de'); + }); + + it('rejects a host that already has settings', () => { + expect(siteHostError('API.example.com', ['other.com', 'api.example.com'])).toBe('Settings for this host already exist'); + }); +}); diff --git a/src/app/settings/site-settings/site-host.validator.ts b/src/app/settings/site-settings/site-host.validator.ts new file mode 100644 index 0000000..9fe7f10 --- /dev/null +++ b/src/app/settings/site-settings/site-host.validator.ts @@ -0,0 +1,40 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +const examples = 'e.g. api.example.com:5000 or 10.0.0.1'; + +/** + * Site specific headers are sent when a request's host (`new URL(url).host`: host name and non default port) + * equals the configured host, so only that form matches. + * @returns an error message, or null when the host is valid + */ +export function siteHostError(host: string, otherHosts: Iterable = []): string | null { + const value = host.trim(); + if (value === '') return `Enter a host, ${examples}`; + + const hasScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(value); + const url = parseUrl(hasScheme ? value : `http://${value}`); + if (!url || !url.hostname || url.username || url.password || /\s/.test(value)) return `Not a valid host, ${examples}`; + if (hasScheme) return `Enter the host without scheme: ${url.host}`; + if (/[/?#]/.test(value)) return `Enter the host without path, query or fragment: ${url.host}`; + if (url.host !== value.toLowerCase()) return `Requests address this host as ${url.host}`; + + const lowerCaseValue = value.toLowerCase(); + if (Array.from(otherHosts).some(h => h.trim().toLowerCase() === lowerCaseValue)) return 'Settings for this host already exist'; + return null; +} + +/** Puts the message of {@link siteHostError} into the `siteHost` error. */ +export function siteHostValidator(otherHosts: () => Iterable): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const error = siteHostError(control.value ?? '', otherHosts()); + return error ? { siteHost: error } : null; + }; +} + +function parseUrl(url: string): URL | null { + try { + return new URL(url); + } catch { + return null; + } +} diff --git a/src/app/settings/site-settings/site-settings.component.html b/src/app/settings/site-settings/site-settings.component.html index 9de9658..e2aedd6 100644 --- a/src/app/settings/site-settings/site-settings.component.html +++ b/src/app/settings/site-settings/site-settings.component.html @@ -14,10 +14,10 @@ @if (urlEditable) { - @if (!this.urlFormControl.value) { + @if (!siteSetting?.siteUrl) { Host } - {{this.urlFormControl.value}} + {{siteSetting?.siteUrl}} @if (canBeDeleted) { delete @@ -32,7 +32,11 @@ Host - + + @if (urlFormControl.hasError('siteHost')) { + {{urlFormControl.getError('siteHost')}} + } } diff --git a/src/app/settings/site-settings/site-settings.component.ts b/src/app/settings/site-settings/site-settings.component.ts index 47a30bc..1367a4b 100644 --- a/src/app/settings/site-settings/site-settings.component.ts +++ b/src/app/settings/site-settings/site-settings.component.ts @@ -1,9 +1,12 @@ -import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; -import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { Component, ElementRef, EventEmitter, Input, OnInit, Output, inject, viewChild } from '@angular/core'; +import { MatExpansionPanel } from '@angular/material/expansion'; +import { AbstractControl, FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { ErrorStateMatcher } from '@angular/material/core'; import { AppSettings, SiteSetting } from '../app-settings'; import { Store } from '@ngrx/store'; import { addHeader, removeHeader, updateHeader, updateSiteUrl } from 'src/app/store/appsettings.actions'; import {Unit} from "../../utils/unit"; +import { siteHostValidator } from './site-host.validator'; @Component({ selector: 'app-site-settings', @@ -21,24 +24,34 @@ export class SiteSettingsComponent implements OnInit { @Input() urlEditable: boolean = true; @Input() canBeDeleted: boolean = true; @Input() headline: string = ""; + @Input() isGlobal: boolean = false; + /** Hosts of all site specific settings, to reject duplicates. */ + @Input() siteUrls: string[] = []; @Output() deleteRequested: EventEmitter = new EventEmitter(); @Output() siteUrlChanging = new EventEmitter<{ previousSiteUrl: string, newSiteUrl: string }>(); public urlFormControl: FormControl = new FormControl(); headerFormGroups: FormGroup[] = []; + private panel = viewChild.required(MatExpansionPanel); + private hostInput = viewChild>('hostInput'); + /** Shows host errors while typing, not only after leaving the field. */ + readonly hostErrorStateMatcher: ErrorStateMatcher = { + isErrorState: (control: AbstractControl | null) => !!control && control.invalid && (control.dirty || control.touched), + }; ngOnInit(): void { if (!this.siteSetting) { this.siteSetting = new SiteSetting(); } - this.urlFormControl = new FormControl(this.siteSetting!.siteUrl, { updateOn: 'blur' }); - - this.urlFormControl.valueChanges.subscribe(v => { - v = v.trim(); - this.siteUrlChanging.emit({ previousSiteUrl: this.siteSetting!.siteUrl, newSiteUrl: v }); - this.store.dispatch(updateSiteUrl({ previousSiteUrl: this.siteSetting!.siteUrl, newSiteUrl: v})); + this.urlFormControl = new FormControl(this.siteSetting!.siteUrl, { + validators: siteHostValidator(() => this.siteUrls.filter(url => url !== this.siteSetting!.siteUrl)), }); + // show problems of stored hosts right away, except for a site that was just added + if (this.siteSetting.siteUrl !== "" && this.urlFormControl.invalid) { + this.urlFormControl.markAsTouched(); + } + const headers = this.siteSetting.headers; this.headerFormGroups = Array @@ -53,9 +66,9 @@ export class SiteSettingsComponent implements OnInit { keyControl.valueChanges.subscribe(v => { v = (v ?? "").trim(); if (key === "") { - this.store.dispatch(addHeader({ siteUrl: this.siteSetting!.siteUrl, key: v, value: value })); + this.store.dispatch(addHeader({ siteUrl: this.storeSiteUrl, key: v, value: value })); } else { - this.store.dispatch(updateHeader({ siteUrl: this.siteSetting!.siteUrl, previousKey: key, newKey: v, newValue: value })); + this.store.dispatch(updateHeader({ siteUrl: this.storeSiteUrl, previousKey: key, newKey: v, newValue: value })); } key = v; }); @@ -64,7 +77,7 @@ export class SiteSettingsComponent implements OnInit { valueControl.valueChanges.subscribe(v => { v = (v ?? "").trim(); if (key !== "") { - this.store.dispatch(updateHeader({ siteUrl: this.siteSetting!.siteUrl, previousKey: key, newKey: key, newValue: v })); + this.store.dispatch(updateHeader({ siteUrl: this.storeSiteUrl, previousKey: key, newKey: key, newValue: v })); } value = v; }); @@ -76,6 +89,25 @@ export class SiteSettingsComponent implements OnInit { return result; } + focusHost() { + this.panel().open(); + // the input can only take the focus once the opened panel shows it + setTimeout(() => this.hostInput()?.nativeElement.focus()); + } + + /** Stores the host when leaving the field. An invalid host is not stored, the previous one stays in effect. */ + commitHost() { + if (this.urlFormControl.invalid) return; + const host = (this.urlFormControl.value as string).trim(); + if (host === this.siteSetting!.siteUrl) return; + this.siteUrlChanging.emit({ previousSiteUrl: this.siteSetting!.siteUrl, newSiteUrl: host }); + this.store.dispatch(updateSiteUrl({ previousSiteUrl: this.siteSetting!.siteUrl, newSiteUrl: host })); + } + + private get storeSiteUrl(): string | null { + return this.isGlobal ? null : this.siteSetting!.siteUrl; + } + addHeader() { this.headerFormGroups.push(this.AddHeaderFormControl(["", ""])); } @@ -84,7 +116,7 @@ export class SiteSettingsComponent implements OnInit { removeHeader(index: number) { const key = ((this.headerFormGroups[index]?.value.key as string | null) ?? "").trim(); if (key !== "" && this.siteSetting!.headers.has(key)) { - this.store.dispatch(removeHeader({ siteUrl: this.siteSetting!.siteUrl, key })); + this.store.dispatch(removeHeader({ siteUrl: this.storeSiteUrl, key })); } this.headerFormGroups.splice(index, 1); } diff --git a/src/app/store/appsettings.actions.ts b/src/app/store/appsettings.actions.ts index 9a3d42d..874c0bb 100644 --- a/src/app/store/appsettings.actions.ts +++ b/src/app/store/appsettings.actions.ts @@ -17,17 +17,18 @@ export const updateSiteUrl = createAction( '[AppSettings]->[SiteSettings] UpdateSiteUrl', props<{ previousSiteUrl: string, newSiteUrl: string }>()); +/** In the header actions, siteUrl null addresses the global site settings. */ export const updateHeader = createAction( '[AppSettings]->[SiteSettings] UpdateHeader', - props<{ siteUrl: string, previousKey: string, newKey: string, newValue: string}>()); + props<{ siteUrl: string | null, previousKey: string, newKey: string, newValue: string}>()); export const addHeader = createAction( '[AppSettings]->[SiteSettings] AddHeader', - props<{ siteUrl: string, key: string, value: string }>()); + props<{ siteUrl: string | null, key: string, value: string }>()); export const removeHeader = createAction( '[AppSettings]->[SiteSettings] RemoveHeader', - props<{ siteUrl: string, key: string }>()); + props<{ siteUrl: string | null, key: string }>()); export const addSite = createAction( '[AppSettings]->[SiteSettings] AddSite', diff --git a/src/app/store/appsettings.reducer.ts b/src/app/store/appsettings.reducer.ts index 9992e12..0f346ce 100644 --- a/src/app/store/appsettings.reducer.ts +++ b/src/app/store/appsettings.reducer.ts @@ -29,7 +29,7 @@ export const appSettingsReducer = createReducer( return setSiteSettings(state, newMap); }), on(updateHeader, (state, props) => { - const existingSiteSpecificEntry = state.siteSettings.siteSpecificSettings.get(props.siteUrl); + const existingSiteSpecificEntry = getSite(state, props.siteUrl); if (existingSiteSpecificEntry === undefined) { throw new Error("site url not present"); } @@ -41,7 +41,7 @@ export const appSettingsReducer = createReducer( return setHeaders(state, props.siteUrl, updatedMap); }), on(addHeader, (state, props) => { - const existingSiteSpecificEntry = state.siteSettings.siteSpecificSettings.get(props.siteUrl); + const existingSiteSpecificEntry = getSite(state, props.siteUrl); if (existingSiteSpecificEntry === undefined) { throw new Error("site url not present"); } @@ -53,7 +53,7 @@ export const appSettingsReducer = createReducer( return setHeaders(state, props.siteUrl, updatedMap); }), on(removeHeader, (state, props) => { - const existingSiteSpecificEntry = state.siteSettings.siteSpecificSettings.get(props.siteUrl); + const existingSiteSpecificEntry = getSite(state, props.siteUrl); if (existingSiteSpecificEntry === undefined) { throw new Error("site url not present"); } @@ -76,8 +76,14 @@ export const appSettingsReducer = createReducer( }) ); -const setHeaders = (state: AppSettings, siteUrl: string, headers: Map): AppSettings => { - const updatedEntry = state.siteSettings.siteSpecificSettings.get(siteUrl)!.set("headers", headers); +const getSite = (state: AppSettings, siteUrl: string | null): SiteSetting | undefined => + siteUrl === null ? state.siteSettings.globalSiteSettings : state.siteSettings.siteSpecificSettings.get(siteUrl); + +const setHeaders = (state: AppSettings, siteUrl: string | null, headers: Map): AppSettings => { + const updatedEntry = getSite(state, siteUrl)!.set("headers", headers); + if (siteUrl === null) { + return state.set("siteSettings", state.siteSettings.set("globalSiteSettings", updatedEntry)); + } return setSite(state, siteUrl, updatedEntry); } From e42b2a01c6616ab9e2b26d62789160266cee24c8 Mon Sep 17 00:00:00 2001 From: Mathias Reichardt Date: Sat, 3 Oct 2026 01:25:24 +0200 Subject: [PATCH 06/12] feat(entity-view): add +/- shortcuts to expand or collapse all embedded entities - Apply to top level embedded entities only, outside inputs, like the arrow keys - Accept Shift with +, which needs it on US layouts - Show the keys in the top level expand/collapse all tooltips --- e2e/hypermedia-ui.spec.ts | 19 +++++++++++++++++++ .../embedded-entity-view.component.html | 4 ++-- .../embedded-entity-view.component.ts | 8 ++++++++ .../embedded-navigation.service.ts | 10 ++++++++++ .../page-navigation.component.ts | 11 ++++++++++- 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/e2e/hypermedia-ui.spec.ts b/e2e/hypermedia-ui.spec.ts index a70b629..03f46bc 100644 --- a/e2e/hypermedia-ui.spec.ts +++ b/e2e/hypermedia-ui.spec.ts @@ -352,6 +352,25 @@ test('leaves nested embedded entities alone when expanding or collapsing a level await expect(collapseAll).toHaveCount(1); }); +test('expands and collapses all top level embedded entities with + and -', async ({ page }) => { + await openEntryPoint(page); + const summaryHeader = page.locator('mat-expansion-panel-header', { hasText: 'Embedded summary' }); + const nestedHeader = page.locator('mat-expansion-panel-header', { hasText: 'Nested detail' }); + + await page.keyboard.press('+'); + await expect(summaryHeader).toHaveAttribute('aria-expanded', 'true'); + await expect(nestedHeader).toHaveAttribute('aria-expanded', 'false'); + + // + needs Shift on a US layout + await page.keyboard.press('-'); + await expect(summaryHeader).toHaveAttribute('aria-expanded', 'false'); + await page.keyboard.press('Shift++'); + await expect(summaryHeader).toHaveAttribute('aria-expanded', 'true'); + + await page.getByRole('button', { name: 'Expand all embedded entities' }).first().hover(); + await expect(page.locator('.mat-mdc-tooltip')).toHaveText('Expand all embedded entities (+)'); +}); + test('previews JSON, text, and image link content', async ({ page }) => { await openEntryPoint(page); await page.getByRole('link', { name: 'plain-json' }).click(); diff --git a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.html b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.html index dfb22ba..a3f1b67 100644 --- a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.html +++ b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.html @@ -1,10 +1,10 @@
@if (embeddedEntities?.length) { - - } diff --git a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.ts b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.ts index b70d76c..951f30a 100644 --- a/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.ts +++ b/src/app/hypermedia-view/embedded-entity-view/embedded-entity-view.component.ts @@ -61,6 +61,14 @@ export class EmbeddedEntityViewComponent { if (this.isTopLevel) this.panels()[index]?.toggle(); }); + this.navigation.expandAllRequests + .pipe(takeUntilDestroyed()) + .subscribe(expand => { + if (!this.isTopLevel) return; + if (expand) this.expandAll(); + else this.collapseAll(); + }); + // Only ever opens panels: collapsing is left to the user, also when the search is cleared effect(() => { this.panels().forEach((panel, index) => { diff --git a/src/app/hypermedia-view/page-navigation/embedded-navigation.service.ts b/src/app/hypermedia-view/page-navigation/embedded-navigation.service.ts index 06ced35..92ee8f5 100644 --- a/src/app/hypermedia-view/page-navigation/embedded-navigation.service.ts +++ b/src/app/hypermedia-view/page-navigation/embedded-navigation.service.ts @@ -12,6 +12,8 @@ export class EmbeddedNavigationService { readonly currentIndex = signal(-1); /** Index of the item whose panel should be expanded or collapsed. */ readonly toggleRequests = new Subject(); + /** true expands, false collapses all top level embedded entities. */ + readonly expandAllRequests = new Subject(); /** Keeps the current item, unless it no longer exists. */ setItemCount(itemCount: number) { @@ -31,6 +33,14 @@ export class EmbeddedNavigationService { if (this.currentIndex() >= 0) this.toggleRequests.next(this.currentIndex()); } + expandAll() { + this.expandAllRequests.next(true); + } + + collapseAll() { + this.expandAllRequests.next(false); + } + canGoPrevious(): boolean { return this.currentIndex() > 0; } diff --git a/src/app/hypermedia-view/page-navigation/page-navigation.component.ts b/src/app/hypermedia-view/page-navigation/page-navigation.component.ts index f477afb..7ef1b3e 100644 --- a/src/app/hypermedia-view/page-navigation/page-navigation.component.ts +++ b/src/app/hypermedia-view/page-navigation/page-navigation.component.ts @@ -39,10 +39,19 @@ export class PageNavigationComponent { @HostListener('document:keydown', ['$event']) onKeydown(event: KeyboardEvent) { if (this.navigation.itemCount() === 0) return; - if (event.defaultPrevented || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return; + if (event.defaultPrevented || event.ctrlKey || event.altKey || event.metaKey) return; if (event.target instanceof Element && event.target.closest(keyHandlingElements)) return; + // + needs Shift on some layouts, e.g. US, so only the other keys require no Shift + if (event.shiftKey && event.key !== '+') return; + switch (event.key) { + case '+': + this.navigation.expandAll(); + break; + case '-': + this.navigation.collapseAll(); + break; case 'Home': this.navigation.toTop(); break; From a6fd1e6843450c9f4500a4d89990efbb05d9636c Mon Sep 17 00:00:00 2001 From: Mathias Reichardt Date: Sat, 3 Oct 2026 01:57:13 +0200 Subject: [PATCH 07/12] feat(raw-view): open 2 levels deep and add keys and page navigation - Open the raw tree 2 levels deep; the fully opened tree rendered slowly for many embedded entities - "Expand 2 levels" button, left of "Expand all" - +/- expand or collapse all, as in the UI view - Fix "Collaps all" tooltip - Show back to top / to bottom navigation in the raw view - UI/Raw toggle: active view shown pressed in instead of a check mark - Release the toggle's focus after a click, so the page shortcuts work right away - Add e2e test --- e2e/hypermedia-ui.spec.ts | 29 +++++++++++++++++ .../hypermedia-control.component.html | 4 +-- .../hypermedia-control.component.scss | 5 ++- .../hypermedia-control.component.ts | 8 +++++ .../embedded-navigation.service.ts | 2 +- .../page-navigation.component.html | 32 ++++++++++--------- .../page-navigation.component.ts | 9 ++++-- .../raw-view/raw-view.component.html | 17 ++++++++-- .../raw-view/raw-view.component.ts | 22 ++++++++++++- 9 files changed, 103 insertions(+), 25 deletions(-) diff --git a/e2e/hypermedia-ui.spec.ts b/e2e/hypermedia-ui.spec.ts index 03f46bc..eab98e1 100644 --- a/e2e/hypermedia-ui.spec.ts +++ b/e2e/hypermedia-ui.spec.ts @@ -437,6 +437,35 @@ test('remembers search options after a reload', async ({ page }) => { await expect(page.getByRole('switch', { name: 'Actions' })).toBeChecked(); }); +test('opens the raw view 2 levels deep and expands or collapses it with buttons and keys', async ({ page }) => { + await openEntryPoint(page); + await page.getByRole('radio', { name: 'Raw' }).check(); + const rawView = page.locator('app-raw-view'); + // an opened node lists "key: value"; a collapsed one only shows a JSON preview of its content + const rootProperty = 'welcome: "Navigate using hypermedia"'; + const embeddedProperty = 'status: "ready"'; + await expect(rawView).toContainText(rootProperty); + await expect(rawView).not.toContainText(embeddedProperty); + + // a clicked view toggle releases the focus, so the shortcuts work right away + await page.keyboard.press('+'); + await expect(rawView).toContainText(embeddedProperty); + await page.keyboard.press('-'); + await expect(rawView).not.toContainText(rootProperty); + await page.getByRole('button', { name: 'Expand 2 levels' }).click(); + await expect(rawView).toContainText(rootProperty); + await expect(rawView).not.toContainText(embeddedProperty); + await page.getByRole('button', { name: 'Expand all' }).click(); + await expect(rawView).toContainText(embeddedProperty); + await page.getByRole('button', { name: 'Collapse all' }).hover(); + await expect(page.locator('.mat-mdc-tooltip')).toHaveText('Collapse all (-)'); + + const navigation = page.getByRole('navigation', { name: 'Page navigation' }); + await expect(navigation.getByRole('button')).toHaveCount(2); + await expect(navigation.getByRole('button', { name: 'Back to top' })).toBeAttached(); + await expect(navigation.getByRole('button', { name: 'To bottom' })).toBeAttached(); +}); + test('searches the raw view as plain text', async ({ page }) => { await openEntryPoint(page); await page.getByRole('radio', { name: 'Raw' }).check(); diff --git a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.html b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.html index 67fd2c8..4151fe7 100644 --- a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.html +++ b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.html @@ -122,7 +122,7 @@ } @if (GeneralSettings.showRawTab) { - + grid_view UI @@ -143,4 +143,4 @@ } - + diff --git a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.scss b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.scss index 4f21db6..e13c8f6 100644 --- a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.scss +++ b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.scss @@ -102,8 +102,10 @@ rawContent{ align-items: center; background-color: transparent !important; + // the active view looks pressed in, instead of the default check mark &.mat-button-toggle-checked { - background-color: rgba(0, 0, 0, 0.05) !important; + background-color: rgba(0, 0, 0, 0.1) !important; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.25); } ::ng-deep .mat-button-toggle-label-content { @@ -195,3 +197,4 @@ rawContent{ text-transform: uppercase; } } + diff --git a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts index 9c4dbfa..d4c02f6 100644 --- a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts +++ b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts @@ -227,6 +227,14 @@ export class HypermediaControlComponent implements OnInit, OnDestroy { this.hypermediaClient.Navigate(url); } + /** + * A clicked toggle would keep the focus and handle the arrow keys itself, so the page shortcuts + * would not work until the user clicks into the page. Toggles used by keyboard (detail 0) keep it. + */ + public releaseToggleFocus(event: MouseEvent) { + if (event.detail > 0 && document.activeElement instanceof HTMLElement) document.activeElement.blur(); + } + public setShowRaw(showRaw: boolean) { this.showRaw = showRaw; this.updateSearchMode(); diff --git a/src/app/hypermedia-view/page-navigation/embedded-navigation.service.ts b/src/app/hypermedia-view/page-navigation/embedded-navigation.service.ts index 92ee8f5..f8ab7d1 100644 --- a/src/app/hypermedia-view/page-navigation/embedded-navigation.service.ts +++ b/src/app/hypermedia-view/page-navigation/embedded-navigation.service.ts @@ -12,7 +12,7 @@ export class EmbeddedNavigationService { readonly currentIndex = signal(-1); /** Index of the item whose panel should be expanded or collapsed. */ readonly toggleRequests = new Subject(); - /** true expands, false collapses all top level embedded entities. */ + /** true expands, false collapses all top level embedded entities, or the whole raw view. */ readonly expandAllRequests = new Subject(); /** Keeps the current item, unless it no longer exists. */ diff --git a/src/app/hypermedia-view/page-navigation/page-navigation.component.html b/src/app/hypermedia-view/page-navigation/page-navigation.component.html index bbfd76d..1fd0fb5 100644 --- a/src/app/hypermedia-view/page-navigation/page-navigation.component.html +++ b/src/app/hypermedia-view/page-navigation/page-navigation.component.html @@ -1,24 +1,26 @@ -@if (navigation.itemCount() > 0) { +@if (scrollOnly || navigation.itemCount() > 0) {