diff --git a/README.md b/README.md index d1c115f..e1b466e 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`` @@ -161,6 +167,11 @@ The following media types are supported for integrated preview: - Configuration: `text/toml`, `text/yaml` - Binary/Generic: `application/octet-stream` (can be manually rendered as text with syntax highlighting options). +## Limitations + +- **Nullable objects in action parameters:** a nullable object can not be left unset. + It is always sent as an object, never as `null`, and its required properties must be filled before the action can be submitted. + ## 💚 Many thanks to our dear sponsors
diff --git a/e2e/hypermedia-ui.spec.ts b/e2e/hypermedia-ui.spec.ts index d83a989..943b72a 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'], @@ -299,6 +307,114 @@ 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 }); + 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('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(); @@ -323,7 +439,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(); @@ -331,6 +446,125 @@ 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 Page down and Page up', 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'); + await expect(page.getByRole('switch', { name: 'Link relations' })).toBeHidden(); + + const search = page.getByPlaceholder('Search ...'); + await search.fill('plain'); + await expect(page.locator('.match-counter')).toHaveText('1/2'); + await search.press('PageDown'); + await expect(page.locator('.match-counter')).toHaveText('2/2'); + await expect(search).not.toBeFocused(); + // outside the search box too + await page.keyboard.press('PageUp'); + await expect(page.locator('.match-counter')).toHaveText('1/2'); + await expect(page.locator('.link-group.search-current')).toContainText('plain-json'); +}); + +test('continues the page navigation from the embedded entity of a search hit', async ({ page }) => { + await openEntryPoint(page); + const summary = page.locator('mat-expansion-panel', { hasText: 'Embedded summary' }); + const linkedCustomer = page.locator('mat-card', { hasText: 'Linked customer' }); + const search = page.getByPlaceholder('Search ...'); + + await page.keyboard.press('/'); + await expect(search).toBeFocused(); + await page.keyboard.type('ready'); + // keys edit the query while in the search box + await page.keyboard.press('Home'); + await page.keyboard.type('is '); + await expect(search).toHaveValue('is ready'); + await page.keyboard.press('ArrowDown'); + await expect(search).toBeFocused(); + + await page.keyboard.press('Control+a'); + await page.keyboard.type('ready'); + await page.keyboard.press('Enter'); + await expect(search).not.toBeFocused(); + await expect(summary).toHaveClass(/embedded-nav-current/); + await page.keyboard.press('ArrowDown'); + await expect(linkedCustomer).toHaveClass(/embedded-nav-current/); + + await search.focus(); + await page.keyboard.press('Escape'); + await expect(search).not.toBeFocused(); + await expect(search).toHaveValue('ready'); + + await page.getByRole('button', { name: 'Next hit' }).hover(); + await expect(page.locator('.mat-mdc-tooltip')).toHaveText('Next hit (Page down)'); +}); + +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('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(); + 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(); @@ -442,3 +676,166 @@ 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 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'); + 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); +}); + +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(); +}); + +test('keeps using headers stored before values could be hidden', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('appSettings', JSON.stringify({ + SiteSettings: { + GlobalSiteSettings: { SiteUrl: 'Global', Headers: [{ Key: 'X-Global', Value: 'g1' }] }, + SiteSpecificSettings: [{ SiteUrl: 'api.test', Headers: [{ Key: 'X-Site', Value: 's1' }] }], + }, + }))); + + 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'); + + await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByRole('tab', { name: 'Sites' }).click(); + const site = page.locator('app-site-settings').nth(1); + await site.locator('mat-expansion-panel-header').click(); + await expect(site.getByRole('button', { name: 'Hide value' })).toHaveAttribute('aria-pressed', 'false'); + await expect(site.getByRole('textbox', { name: 'Value' })).not.toHaveClass(/maskedValue/); +}); + +test('hides a header value, also after renaming the header and reopening the settings', 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-Secret', 'token'); + const value = global.getByRole('textbox', { name: 'Value' }); + await expect(value).not.toHaveClass(/maskedValue/); + + await global.getByRole('button', { name: 'Hide value' }).click(); + await expect(value).toHaveClass(/maskedValue/); + await global.getByRole('textbox', { name: 'Key' }).fill('X-Renamed'); + await global.locator('#headersTitle').click(); + await page.keyboard.press('Escape'); + await expect(page.getByText('Settings saved.')).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem('appSettings'))) + .toContain('{"Key":"X-Renamed","Value":"token","Hidden":true}'); + + await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByRole('tab', { name: 'Sites' }).click(); + await global.locator('mat-expansion-panel-header').click(); + await expect(global.getByRole('button', { name: 'Hide value' })).toHaveAttribute('aria-pressed', 'true'); + await expect(value).toHaveClass(/maskedValue/); + await expect(value).toHaveValue('token'); +}); 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 aff2c60..db37779 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 @@
+ + } {{embeddedEntities?.length + embeddedLinkEntities?.length}} items
@for (embeddedEntity of embeddedEntities; track embeddedEntity; let idx = $index) { - - + + {{idx + 1}} @@ -15,28 +32,35 @@ @if (getRelationIcon(rel)) { {{getRelationIcon(rel)}} } - {{getRelationDisplayText(rel)}} + } @if(embeddedEntity.title){ - {{embeddedEntity.title}} + } @if (generalSettings.showClasses) { - Classes: {{embeddedEntity.classes.join(",")}} + Classes: } - + } @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 c56ce6f..82689e6 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,28 +7,76 @@ .itemsHeader { display: flex; - justify-content: space-between; + justify-content: flex-start; + 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; } +$header-background: #eceef1; +$header-hover-background: #e1e4e8; + +// the header is tinted and divided from the body, so it reads as the clickable part .entityExpander { margin: defaults.$spacing 0; + // Material's own hover and focus colours; they compete with the rules below, so all use the same colour + --mat-expansion-header-hover-state-layer-color: #{$header-hover-background}; + --mat-expansion-header-focus-state-layer-color: #{$header-hover-background}; - .mat-expansion-panel-header { + > .mat-expansion-panel-header { height: auto; padding-top: 8px; padding-bottom: 8px; + background: $header-background; + + // Material resets open headers on hover and focus + &:hover, + &.cdk-keyboard-focused, + &.cdk-program-focused { + background: $header-hover-background; + } + } + + &.mat-expanded > .mat-expansion-panel-header { + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + } +} + +// larger chevron left of the header content, pointing right when closed and down when open, as in a tree +:host ::ng-deep .entityExpander > .mat-expansion-panel-header.mat-expansion-toggle-indicator-before .mat-expansion-indicator { + margin: 0 12px 0 0; + transform: rotate(-90deg); + + svg { + width: 36px; + height: 36px; + margin: -6px; } } +:host ::ng-deep .entityExpander > .mat-expansion-panel-header.mat-expanded .mat-expansion-indicator { + transform: none; +} + .entity-index-badge { display: inline-flex; align-items: center; @@ -97,3 +145,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 cfef133..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 @@ -1,4 +1,6 @@ -import { Component, Input, inject } from '@angular/core'; +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'; import { getDisplayTextForRelation } from '../display-text-mapping'; @@ -9,6 +11,14 @@ import { Store } from '@ngrx/store'; 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, + embeddedLinkEntityTarget, + rootEntityKey, +} from '../search/entity-search'; @Component({ selector: 'app-embedded-entity-view', @@ -22,8 +32,17 @@ export class EmbeddedEntityViewComponent { @Input() embeddedLinkEntities: EmbeddedLinkEntity[] = []; @Input() embeddedEntities: EmbeddedEntity[] = []; + @Input() entityKey: string = rootEntityKey; generalSettings: GeneralSettings = new GeneralSettings(); + protected search = inject(EntitySearchService); + protected navigation = inject(EmbeddedNavigationService); + protected readonly embeddedEntityKey = embeddedEntityKey; + protected readonly embeddedHeaderTarget = embeddedHeaderTarget; + protected readonly embeddedLinkEntityTarget = embeddedLinkEntityTarget; + + private readonly panels = viewChildren(MatExpansionPanel); + constructor() { const store = inject this.generalSettings = generalSettings, }); + + this.navigation.toggleRequests + .pipe(takeUntilDestroyed()) + .subscribe(index => { + 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) => { + if (this.search.containsHit(embeddedEntityKey(this.entityKey, index))) { + panel.open(); + } + }); + }); } + /** 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()); + } + + collapseAll() { + this.panels().forEach(panel => panel.close()); + } + navigateHref(href: string) { this.hypermediaClient.Navigate(href); } diff --git a/src/app/hypermedia-view/entity-view/entity-view.component.html b/src/app/hypermedia-view/entity-view/entity-view.component.html index 511cea7..4c52fa1 100644 --- a/src/app/hypermedia-view/entity-view/entity-view.component.html +++ b/src/app/hypermedia-view/entity-view/entity-view.component.html @@ -1,26 +1,26 @@
@if (showTitle) { -
- {{title}} +
+
} @if (GeneralSettings.showClasses) { -
Classes: {{classes}}
+
Classes:
} - + @if (GeneralSettings.showEmptyLinks || links.length > 0) { - + } @if (GeneralSettings.showEmptyActions || actions.length > 0) { - + } @if (GeneralSettings.showEmptyEntities || embeddedEntities.length > 0 || embeddedLinkEntities.length > 0) { - + }
diff --git a/src/app/hypermedia-view/entity-view/entity-view.component.ts b/src/app/hypermedia-view/entity-view/entity-view.component.ts index a953f4a..1a96055 100644 --- a/src/app/hypermedia-view/entity-view/entity-view.component.ts +++ b/src/app/hypermedia-view/entity-view/entity-view.component.ts @@ -8,6 +8,7 @@ import { AppSettings, GeneralSettings } from 'src/app/settings/app-settings'; import { Store } from '@ngrx/store'; import { selectEffectiveGeneralSettings } from 'src/app/store/selectors'; import { AppConfig } from 'src/app.config.service'; +import { entityClassesTarget, entityTitleTarget, rootEntityKey } from '../search/entity-search'; @Component({ selector: 'app-entity-view', @@ -24,6 +25,7 @@ export class EntityViewComponent implements OnInit, OnChanges { @Input() entity: SirenClientObject = new SirenClientObject(); @Input() showTitle: boolean = true; + @Input() entityKey: string = rootEntityKey; public title: string = ""; public embeddedLinkEntities: IEmbeddedLinkEntity[] = []; @@ -33,6 +35,8 @@ export class EntityViewComponent implements OnInit, OnChanges { public properties: PropertyInfo[] = new Array(); public actions: HypermediaAction[] = new Array(); GeneralSettings: GeneralSettings = new GeneralSettings(); + protected readonly entityTitleTarget = entityTitleTarget; + protected readonly entityClassesTarget = entityClassesTarget; constructor() { const store = this.store; 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 629e951..a10186f 100644 --- a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.html +++ b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.html @@ -52,19 +52,77 @@ - @if (GeneralSettings.showRawTab && !showRaw) { - - tune - + @if (isSearchAvailable) { + + + +
+ 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 @@ -84,3 +142,5 @@ } @else { } + + 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..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 { @@ -123,22 +125,76 @@ 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; + + &.invalid { + border-color: #c62828; + } + + input { + border: none; + outline: none; + font-size: 13px; + flex: 1; + min-width: 30px; + background: transparent; + } - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - margin-left: 8px; + .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..6a91039 100644 --- a/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts +++ b/src/app/hypermedia-view/hypermedia-control/hypermedia-control.component.ts @@ -1,4 +1,4 @@ -import {Component, OnInit, inject, OnDestroy} from '@angular/core'; +import {Component, ElementRef, HostListener, OnInit, inject, OnDestroy, viewChild} from '@angular/core'; import {HypermediaClientService} from '../hypermedia-client.service'; import {SirenClientObject} from '../siren-parser/siren-client-object'; import {ActivatedRoute, Router} from '@angular/router'; @@ -13,6 +13,18 @@ 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"; +import {EmbeddedNavigationService} from "../page-navigation/embedded-navigation.service"; +import {keyHandlingElements} from "../page-navigation/page-navigation.component"; + +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 +37,10 @@ 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 embeddedNavigation = inject(EmbeddedNavigationService); + private searchInput = viewChild>('searchInput'); private store = inject { this.GeneralSettings = generalSettings; - this.showPropertyTreeControls = generalSettings.showPropertyTreeControls; + this.search.showClasses.set(generalSettings.showClasses); + this.search.setOptions(generalSettings.searchOptions); + this.updateSearchMode(); + this.updateEmbeddedNavigation(); }, }); + store + .select(state => state.appSettings.generalSettings) + .subscribe({ + next: generalSettings => this.userGeneralSettings = generalSettings, + }); store .select(state => state.appConfig) .subscribe({ @@ -108,14 +146,19 @@ export class HypermediaControlComponent implements OnInit, OnDestroy { ngOnInit() { this.hypermediaClient.getHypermediaObjectStream().subscribe((hto) => { this.hto = hto; + this.search.setEntity(hto); + this.embeddedNavigation.reset(); + this.updateEmbeddedNavigation(); }); this.hypermediaClient.getHypermediaObjectRawStream().subscribe((rawResponse) => { this.rawResponse = rawResponse; + this.search.setRawObject(rawResponse); }); this.hypermediaClient.getContentTypeStream().subscribe((contentType) => { this.contentType = contentType; + this.updateEmbeddedNavigation(); }); this.hypermediaClient.getNavPathsStream().subscribe((navPaths) => { @@ -186,10 +229,100 @@ export class HypermediaControlComponent implements OnInit, OnDestroy { this.hypermediaClient.Navigate(url); } - public togglePropertyTreeControls(checked: boolean) { + /** + * 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(); + } + + /** + * Jumping to a hit leaves the search box, so the page navigation keys continue from the hit. + * All other keys, e.g. Home and End, keep editing the query. + */ + public onSearchKeydown(event: KeyboardEvent) { + if (event.ctrlKey || event.altKey || event.metaKey) return; + switch (event.key) { + case 'Enter': + if (event.shiftKey) this.search.previous(); else this.search.next(); + break; + case 'PageDown': + this.search.next(); + break; + case 'PageUp': + this.search.previous(); + break; + case 'Escape': + this.searchInput()?.nativeElement.blur(); + event.preventDefault(); + return; + default: + return; + } + event.preventDefault(); + // stay in the box while there is nothing to jump to, e.g. to fix the query + if (this.search.hits().length > 0) this.searchInput()?.nativeElement.blur(); + } + + @HostListener('document:keydown', ['$event']) + public onDocumentKeydown(event: KeyboardEvent) { + if (!this.isSearchAvailable) 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. German + if (event.shiftKey && event.key !== '/') return; + switch (event.key) { + case '/': + this.searchInput()?.nativeElement.focus(); + this.searchInput()?.nativeElement.select(); + break; + // without an active search the browser scrolls by a page as usual + case 'PageDown': + if (!this.search.isActive()) return; + this.search.next(); + break; + case 'PageUp': + if (!this.search.isActive()) return; + this.search.previous(); + break; + default: + return; + } + event.preventDefault(); + } + + 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() { + 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..f9f7897 100644 --- a/src/app/hypermedia-view/hypermedia-view.module.ts +++ b/src/app/hypermedia-view/hypermedia-view.module.ts @@ -61,6 +61,8 @@ 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 {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'; @@ -70,6 +72,8 @@ import {MatSlideToggleModule} from '@angular/material/slide-toggle'; declarations: [ HypermediaControlComponent, PropertyTreeComponent, + SearchHighlightComponent, + PageNavigationComponent, 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/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..f8ab7d1 --- /dev/null +++ b/src/app/hypermedia-view/page-navigation/embedded-navigation.service.ts @@ -0,0 +1,76 @@ +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(); + /** 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. */ + 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()); + } + + expandAll() { + this.expandAllRequests.next(true); + } + + collapseAll() { + this.expandAllRequests.next(false); + } + + 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..1fd0fb5 --- /dev/null +++ b/src/app/hypermedia-view/page-navigation/page-navigation.component.html @@ -0,0 +1,32 @@ +@if (scrollOnly || 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..0beefd5 --- /dev/null +++ b/src/app/hypermedia-view/page-navigation/page-navigation.component.ts @@ -0,0 +1,82 @@ +import { Component, HostListener, Input, 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 +export 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); + + /** Shows only "Back to top" and "To bottom", e.g. for the raw view, which has no embedded items to step through. */ + @Input() scrollOnly = false; + + /** + * 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.scrollOnly && this.navigation.itemCount() === 0) 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; + // the browser scrolls with the other keys itself + if (this.scrollOnly && event.key !== '+' && event.key !== '-') return; + + switch (event.key) { + case '+': + this.navigation.expandAll(); + break; + case '-': + this.navigation.collapseAll(); + break; + 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(); + } +} 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..4d9f83d 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 fdcef4b..99a65c3 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 { @@ -49,48 +30,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 +124,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..4cc253d 100644 --- a/src/app/hypermedia-view/raw-view/raw-view.component.html +++ b/src/app/hypermedia-view/raw-view/raw-view.component.html @@ -1,10 +1,20 @@
+ + @@ -12,7 +22,8 @@ @@ -27,5 +38,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..4ab52fb 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,11 @@ import { Component, Input, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ClipboardService } from 'ngx-clipboard'; +import { EntitySearchService } from '../search/entity-search.service'; +import { EmbeddedNavigationService } from '../page-navigation/embedded-navigation.service'; + +// opens the root keys and their content, e.g. class and properties, but not deeper levels +const defaultDepth = 2; @Component({ selector: 'app-raw-view', @@ -9,16 +15,32 @@ import { ClipboardService } from 'ngx-clipboard'; }) export class RawViewComponent { private clipboardService = inject(ClipboardService); + protected search = inject(EntitySearchService); @Input() rawObject: any; - expand:boolean = true; + expand: boolean = true; + // -1 opens all levels; the full tree renders slowly for large entities + depth: number = defaultDepth; + + constructor() { + inject(EmbeddedNavigationService).expandAllRequests + .pipe(takeUntilDestroyed()) + .subscribe(expand => expand ? this.onExpand() : this.onCollapse()); + } + copyToClipBoard() { this.clipboardService.copyFromContent(JSON.stringify(this.rawObject, null, 2)); } onExpand() { this.expand = true; + this.depth = -1; + } + + onDefaultDepth() { + this.expand = true; + this.depth = defaultDepth; } onCollapse() { 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..c32b186 --- /dev/null +++ b/src/app/hypermedia-view/search/entity-search.service.ts @@ -0,0 +1,204 @@ +import { computed, inject, Injectable, linkedSignal, signal } from '@angular/core'; +import { EmbeddedNavigationService } from '../page-navigation/embedded-navigation.service'; +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; + + private readonly navigation = inject(EmbeddedNavigationService); + + /** 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) { + // a jump 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 and makes its top level embedded item the current one, so + * the page navigation keys continue from there. 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' }); + this.selectNavigationItem(element); + } else if (remainingFrames > 0) { + this.revealCurrent(remainingFrames - 1); + } + }); + } + + private selectNavigationItem(element: Element) { + const item = element.closest(`[${EmbeddedNavigationService.itemAttribute}]`); + if (item) { + this.navigation.select(Number(item.getAttribute(EmbeddedNavigationService.itemAttribute))); + } else { + this.navigation.reset(); + } + } +} + +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..5da95fc 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,12 +16,20 @@ export class GeneralSettings extends Record({ useEmbeddingPropertyForActionParameters: true, showHostInformation: true, showPropertyTreeControls: true, + showSearch: true, + searchOptions: defaultSearchOptions as SearchOptions, actionExecutionTimeoutMs: 60000 }) {} +/** hidden masks the value in the settings, e.g. while screen sharing; it is no protection of the stored value */ +export class HeaderSetting extends Record({ + value: "", + hidden: false, +}) {} + export class SiteSetting extends Record({ siteUrl: "", - headers: Map(), + headers: Map(), }) {} export class SiteSettings extends Record({ 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..32ed279 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; } @@ -56,6 +67,7 @@ export class SiteSettingStorageModel { } export class HeaderSettingStorageModel { - constructor(public Key: string = "", public Value: string = "") { + // Hidden is missing in settings stored by earlier versions + constructor(public Key: string = "", public Value: string = "", public Hidden?: boolean) { } } diff --git a/src/app/settings/services/settings.service.ts b/src/app/settings/services/settings.service.ts index ad2efa8..d5bc9d1 100644 --- a/src/app/settings/services/settings.service.ts +++ b/src/app/settings/services/settings.service.ts @@ -10,7 +10,7 @@ import {ProblemDetailsError} from 'src/app/error-dialog/problem-details-error'; import {Store} from '@ngrx/store'; import {Map} from 'immutable'; import {updateAppSettings} from 'src/app/store/appsettings.actions'; -import {AppSettings, GeneralSettings, SiteSetting, SiteSettings} from '../app-settings'; +import {AppSettings, GeneralSettings, HeaderSetting, SiteSetting, SiteSettings} from '../app-settings'; @Injectable() export class SettingsService { @@ -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, }); } @@ -82,7 +84,7 @@ export class SettingsService { private mapSiteSettingFromStorageModel(storageModel: SiteSettingStorageModel): SiteSetting { return new SiteSetting({ siteUrl: storageModel.SiteUrl, - headers: Map(storageModel.Headers.map(h => [h.Key, h.Value] as const)), + headers: Map(storageModel.Headers.map(h => [h.Key, new HeaderSetting({ value: h.Value, hidden: h.Hidden ?? false })] as const)), }); } @@ -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, }; } @@ -121,7 +125,7 @@ export class SettingsService { private mapSiteSettingToStorageModel(appModel: SiteSetting): SiteSettingStorageModel { return new SiteSettingStorageModel( appModel.siteUrl, - Array.from(appModel.headers.entries()).map(e => new HeaderSettingStorageModel(e[0], e[1])), + Array.from(appModel.headers.entries()).map(([key, header]) => new HeaderSettingStorageModel(key, header.value, header.hidden)), ); } @@ -134,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/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..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,11 @@ - -@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..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,6 +1,7 @@ -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, 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 +19,17 @@ 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 }[] = []; + siteUrls: string[] = []; + private siteComponents = viewChildren(SiteSettingsComponent); + private siteIds = new Map(); + private nextSiteId = 0; + constructor() { const store = this.store; @@ -26,16 +38,54 @@ export class SiteSettingsPageComponent { .subscribe({ next: siteSettings => { this.siteSettings = siteSettings; + this.updateSites(); } }) } + // 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 { + 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()); + 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); + } + this.sites = settings + .map(setting => ({ id: this.siteId(setting.siteUrl), setting })) + .sort((a, b) => a.id - b.id); } - removeSite(index: number): void { - const site = Array.from(this.siteSettings.siteSpecificSettings.entries())[index]; - this.store.dispatch(removeSite({ siteUrl: site[0] })); + 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-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..ce706ba 100644 --- a/src/app/settings/site-settings/site-settings.component.html +++ b/src/app/settings/site-settings/site-settings.component.html @@ -6,7 +6,7 @@ {{headline}} - Applies to all http calls. Overrriden by site specific settings. + Applies to all http calls. Overridden by site specific settings. } @@ -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')}} + } } @@ -53,8 +57,15 @@ Value - + + + delete
diff --git a/src/app/settings/site-settings/site-settings.component.scss b/src/app/settings/site-settings/site-settings.component.scss index 28ccaae..39f5169 100644 --- a/src/app/settings/site-settings/site-settings.component.scss +++ b/src/app/settings/site-settings/site-settings.component.scss @@ -64,3 +64,19 @@ flex-direction: row; align-items: center; } + +.maskedValue { + -webkit-text-security: disc; +} + +// lifted like the delete icon, to the middle of the input above the form field's hint space +.hideHeaderToggle { + display: inline-flex; + margin-left: defaults.$spacing; + margin-bottom: 2*defaults.$spacing; + padding: 0; + border: none; + background: none; + color: inherit; + cursor: pointer; +} diff --git a/src/app/settings/site-settings/site-settings.component.ts b/src/app/settings/site-settings/site-settings.component.ts index 06a662c..01c0c17 100644 --- a/src/app/settings/site-settings/site-settings.component.ts +++ b/src/app/settings/site-settings/site-settings.component.ts @@ -1,9 +1,21 @@ -import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; -import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; -import { AppSettings, SiteSetting } from '../app-settings'; +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, HeaderSetting, SiteSetting } from '../app-settings'; import { Store } from '@ngrx/store'; -import { addHeader, removeHeader, updateHeader, updateSiteUrl } from 'src/app/store/appsettings.actions'; +import { + addHeader, + globalHeaderTarget, + HeaderTarget, + removeHeader, + setHeaderHidden, + siteHeaderTarget, + 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,39 +33,52 @@ 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.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 .from(headers.entries()) - .map(h => this.AddHeaderFormControl(h)); + .map(([key, header]) => this.AddHeaderFormControl(key, header)); } - private AddHeaderFormControl(headerSetting: [string, string]): FormGroup { - let key = headerSetting[0]; - let value = headerSetting[1]; + private AddHeaderFormControl(initialKey: string, header: HeaderSetting): FormGroup { + let key = initialKey; + let value = header.value; + let hidden = header.hidden; const keyControl = new FormControl(key, {updateOn: 'blur'}); 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({ target: this.headerTarget, key: v, value: value, hidden: hidden })); } else { - this.store.dispatch(updateHeader({ siteUrl: this.siteSetting!.siteUrl, previousKey: key, newKey: v, newValue: value })); + this.store.dispatch(updateHeader({ target: this.headerTarget, previousKey: key, newKey: v, newValue: value })); } key = v; }); @@ -62,29 +87,62 @@ 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({ target: this.headerTarget, previousKey: key, newKey: key, newValue: v })); } value = v; }); + const hiddenControl = new FormControl(hidden); + hiddenControl.valueChanges.subscribe(v => { + hidden = !!v; + if (key !== "") { + this.store.dispatch(setHeaderHidden({ target: this.headerTarget, key, hidden })); + } + }); + const result = this.formBuilder.group({ key: keyControl, - value: valueControl + value: valueControl, + hidden: hiddenControl, }); 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 headerTarget(): HeaderTarget { + return this.isGlobal ? globalHeaderTarget : siteHeaderTarget(this.siteSetting!.siteUrl); + } + + toggleHidden(headerFormGroup: FormGroup) { + const hidden = headerFormGroup.controls['hidden']; + hidden.setValue(!hidden.value); + } + addHeader() { - this.headerFormGroups.push(this.AddHeaderFormControl(["", ""])); + this.headerFormGroups.push(this.AddHeaderFormControl("", new HeaderSetting())); } + // 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({ target: this.headerTarget, key })); } + this.headerFormGroups.splice(index, 1); } removeSite() { diff --git a/src/app/store/appsettings.actions.ts b/src/app/store/appsettings.actions.ts index 9a3d42d..2f3a1e9 100644 --- a/src/app/store/appsettings.actions.ts +++ b/src/app/store/appsettings.actions.ts @@ -17,17 +17,31 @@ export const updateSiteUrl = createAction( '[AppSettings]->[SiteSettings] UpdateSiteUrl', props<{ previousSiteUrl: string, newSiteUrl: string }>()); +/** + * Which settings a header action changes. Global headers go to every outgoing request, + * so they must be addressed explicitly rather than by a missing site url. + */ +export type HeaderTarget = { kind: 'global' } | { kind: 'site', siteUrl: string }; + +export const globalHeaderTarget: HeaderTarget = { kind: 'global' }; + +export const siteHeaderTarget = (siteUrl: string): HeaderTarget => ({ kind: 'site', siteUrl }); + export const updateHeader = createAction( '[AppSettings]->[SiteSettings] UpdateHeader', - props<{ siteUrl: string, previousKey: string, newKey: string, newValue: string}>()); + props<{ target: HeaderTarget, previousKey: string, newKey: string, newValue: string}>()); export const addHeader = createAction( '[AppSettings]->[SiteSettings] AddHeader', - props<{ siteUrl: string, key: string, value: string }>()); + props<{ target: HeaderTarget, key: string, value: string, hidden: boolean }>()); + +export const setHeaderHidden = createAction( + '[AppSettings]->[SiteSettings] SetHeaderHidden', + props<{ target: HeaderTarget, key: string, hidden: boolean }>()); export const removeHeader = createAction( '[AppSettings]->[SiteSettings] RemoveHeader', - props<{ siteUrl: string, key: string }>()); + props<{ target: HeaderTarget, key: string }>()); export const addSite = createAction( '[AppSettings]->[SiteSettings] AddSite', diff --git a/src/app/store/appsettings.reducer.spec.ts b/src/app/store/appsettings.reducer.spec.ts new file mode 100644 index 0000000..d1dc2a6 --- /dev/null +++ b/src/app/store/appsettings.reducer.spec.ts @@ -0,0 +1,40 @@ +import { addHeader, addSite, globalHeaderTarget, removeHeader, setHeaderHidden, siteHeaderTarget, updateHeader } from './appsettings.actions'; +import { appSettingsReducer, initialState } from './appsettings.reducer'; + +describe('appSettingsReducer headers', () => { + const site = 'api.example.com'; + const withSite = appSettingsReducer(initialState, addSite({ siteUrl: site })); + + it('adds, updates and removes global headers without touching sites', () => { + let state = appSettingsReducer(withSite, addHeader({ target: globalHeaderTarget, key: 'X-Key', value: '1', hidden: true })); + state = appSettingsReducer(state, updateHeader({ target: globalHeaderTarget, previousKey: 'X-Key', newKey: 'X-Other', newValue: '2' })); + + // renaming keeps the hidden state + expect(state.siteSettings.globalSiteSettings.headers.toJS()).toEqual({ 'X-Other': { value: '2', hidden: true } }); + expect(state.siteSettings.siteSpecificSettings.get(site)!.headers.size).toBe(0); + + state = appSettingsReducer(state, removeHeader({ target: globalHeaderTarget, key: 'X-Other' })); + expect(state.siteSettings.globalSiteSettings.headers.size).toBe(0); + }); + + it('adds site headers only to that site', () => { + const state = appSettingsReducer(withSite, addHeader({ target: siteHeaderTarget(site), key: 'X-Key', value: '1', hidden: false })); + + expect(state.siteSettings.siteSpecificSettings.get(site)!.headers.toJS()).toEqual({ 'X-Key': { value: '1', hidden: false } }); + expect(state.siteSettings.globalSiteSettings.headers.size).toBe(0); + }); + + it('hides and shows a header value', () => { + let state = appSettingsReducer(withSite, addHeader({ target: siteHeaderTarget(site), key: 'X-Key', value: '1', hidden: false })); + state = appSettingsReducer(state, setHeaderHidden({ target: siteHeaderTarget(site), key: 'X-Key', hidden: true })); + expect(state.siteSettings.siteSpecificSettings.get(site)!.headers.get('X-Key')!.hidden).toBeTrue(); + + state = appSettingsReducer(state, setHeaderHidden({ target: siteHeaderTarget(site), key: 'X-Key', hidden: false })); + expect(state.siteSettings.siteSpecificSettings.get(site)!.headers.get('X-Key')!.hidden).toBeFalse(); + }); + + it('rejects headers for an unknown site', () => { + expect(() => appSettingsReducer(withSite, addHeader({ target: siteHeaderTarget('unknown.example.com'), key: 'X-Key', value: '1', hidden: false }))) + .toThrowError('site url not present'); + }); +}); diff --git a/src/app/store/appsettings.reducer.ts b/src/app/store/appsettings.reducer.ts index 9992e12..c5de530 100644 --- a/src/app/store/appsettings.reducer.ts +++ b/src/app/store/appsettings.reducer.ts @@ -2,15 +2,17 @@ import { createReducer, on } from "@ngrx/store"; import { addHeader, addSite, + HeaderTarget, removeHeader, removeSite, + setHeaderHidden, updateAppSettings, updateGeneralAppSettings, updateHeader, updateSiteSettings, updateSiteUrl } from "./appsettings.actions"; -import { AppSettings, SiteSetting } from "../settings/app-settings"; +import { AppSettings, HeaderSetting, SiteSetting } from "../settings/app-settings"; import { Map } from 'immutable'; export const initialState: AppSettings = new AppSettings(); @@ -29,7 +31,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.target); if (existingSiteSpecificEntry === undefined) { throw new Error("site url not present"); } @@ -37,11 +39,14 @@ export const appSettingsReducer = createReducer( if (existingHeader === undefined) { throw new Error("header not present"); } - const updatedMap = existingSiteSpecificEntry.headers.remove(props.previousKey).set(props.newKey, props.newValue); - return setHeaders(state, props.siteUrl, updatedMap); + // the header keeps its hidden state when renamed + const updatedMap = existingSiteSpecificEntry.headers + .remove(props.previousKey) + .set(props.newKey, existingHeader.set("value", props.newValue)); + return setHeaders(state, props.target, updatedMap); }), on(addHeader, (state, props) => { - const existingSiteSpecificEntry = state.siteSettings.siteSpecificSettings.get(props.siteUrl); + const existingSiteSpecificEntry = getSite(state, props.target); if (existingSiteSpecificEntry === undefined) { throw new Error("site url not present"); } @@ -49,16 +54,28 @@ export const appSettingsReducer = createReducer( if (existingHeader !== undefined) { throw new Error("header already present"); } - const updatedMap = existingSiteSpecificEntry.headers.set(props.key, props.value); - return setHeaders(state, props.siteUrl, updatedMap); + const updatedMap = existingSiteSpecificEntry.headers.set(props.key, new HeaderSetting({ value: props.value, hidden: props.hidden })); + return setHeaders(state, props.target, updatedMap); + }), + on(setHeaderHidden, (state, props) => { + const existingSiteSpecificEntry = getSite(state, props.target); + if (existingSiteSpecificEntry === undefined) { + throw new Error("site url not present"); + } + const existingHeader = existingSiteSpecificEntry.headers.get(props.key); + if (existingHeader === undefined) { + throw new Error("header not present"); + } + const updatedMap = existingSiteSpecificEntry.headers.set(props.key, existingHeader.set("hidden", props.hidden)); + return setHeaders(state, props.target, updatedMap); }), on(removeHeader, (state, props) => { - const existingSiteSpecificEntry = state.siteSettings.siteSpecificSettings.get(props.siteUrl); + const existingSiteSpecificEntry = getSite(state, props.target); if (existingSiteSpecificEntry === undefined) { throw new Error("site url not present"); } const updatedMap = existingSiteSpecificEntry.headers.remove(props.key); - return setHeaders(state, props.siteUrl, updatedMap); + return setHeaders(state, props.target, updatedMap); }), on(addSite, (state, props) => { const existingSite = state.siteSettings.siteSpecificSettings.get(props.siteUrl); @@ -76,9 +93,17 @@ export const appSettingsReducer = createReducer( }) ); -const setHeaders = (state: AppSettings, siteUrl: string, headers: Map): AppSettings => { - const updatedEntry = state.siteSettings.siteSpecificSettings.get(siteUrl)!.set("headers", headers); - return setSite(state, siteUrl, updatedEntry); +const getSite = (state: AppSettings, target: HeaderTarget): SiteSetting | undefined => + target.kind === 'global' + ? state.siteSettings.globalSiteSettings + : state.siteSettings.siteSpecificSettings.get(target.siteUrl); + +const setHeaders = (state: AppSettings, target: HeaderTarget, headers: Map): AppSettings => { + const updatedEntry = getSite(state, target)!.set("headers", headers); + if (target.kind === 'global') { + return state.set("siteSettings", state.siteSettings.set("globalSiteSettings", updatedEntry)); + } + return setSite(state, target.siteUrl, updatedEntry); } const setSite = (state: AppSettings, siteUrl: string, siteSpecificSettings: SiteSetting): AppSettings => { 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; +}