From 4e6bbe0fd6c5de6e9b1a4f9542510a42f404c33b Mon Sep 17 00:00:00 2001 From: jg10 <181532694+jg10-mastodon-social@users.noreply.github.com> Date: Sun, 6 Jul 2025 21:05:19 +1000 Subject: [PATCH 01/12] feat(pos-rich-link): receive resource if uri is not present --- .../pos-rich-link/pos-rich-link.spec.tsx | 51 ++++++++++++++- .../pos-rich-link/pos-rich-link.tsx | 64 +++++++++++++------ 2 files changed, 95 insertions(+), 20 deletions(-) diff --git a/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx b/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx index 3f0a0dea..30dc3115 100644 --- a/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx +++ b/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx @@ -2,7 +2,7 @@ import { newSpecPage } from '@stencil/core/testing'; import { PosRichLink } from './pos-rich-link'; import { getByText } from '@testing-library/dom'; -describe('pos-rich-link', () => { +describe('pos-rich-link with uri', () => { let page; beforeEach(async () => { page = await newSpecPage({ @@ -49,3 +49,52 @@ describe('pos-rich-link', () => { }); }); }); + +describe('pos-rich-link without uri', () => { + it('does not emit pod-os:resource event if uri is present', async () => { + const receiveResource = jest.fn(); + const page = await newSpecPage({ + components: [PosRichLink], + }); + page.body.addEventListener('pod-os:resource', receiveResource); + await page.setContent(''); + expect(receiveResource).toHaveBeenCalledTimes(0); + }); + + it('receives resource and sets it as link if uri is not present', async () => { + const receiveResource = jest.fn(); + const page = await newSpecPage({ + components: [PosRichLink], + }); + page.body.addEventListener('pod-os:resource', receiveResource); + await page.setContent(''); + expect(receiveResource).toHaveBeenCalledTimes(1); + + await page.rootInstance.receiveResource({ + uri: 'https://pod.example/resource', + }); + await page.waitForChanges(); + const link = page.root?.shadowRoot?.querySelector('a'); + expect(link).toEqualAttribute('href', 'https://pod.example/resource'); + }); + + it('is empty if neither uri nor resource are received', async () => { + const page = await newSpecPage({ + components: [PosRichLink], + html: ``, + }); + expect(page.root?.innerHTML).toBe(''); + }); + + it('does not use pos-resource if uri is not present', async () => { + const page = await newSpecPage({ + components: [PosRichLink], + html: ``, + }); + await page.rootInstance.receiveResource({ + uri: 'https://pod.example/resource', + }); + await page.waitForChanges(); + expect(page.root?.shadowRoot?.querySelector('pos-resource')).toBeNull(); + }); +}); diff --git a/elements/src/components/pos-rich-link/pos-rich-link.tsx b/elements/src/components/pos-rich-link/pos-rich-link.tsx index fe58f7e4..beb3bf12 100644 --- a/elements/src/components/pos-rich-link/pos-rich-link.tsx +++ b/elements/src/components/pos-rich-link/pos-rich-link.tsx @@ -1,32 +1,58 @@ -import { Component, Event, EventEmitter, h, Prop } from '@stencil/core'; +import { Thing } from '@pod-os/core'; +import { Component, Event, EventEmitter, h, Prop, State } from '@stencil/core'; +import { ResourceAware, ResourceEventEmitter, subscribeResource } from '../events/ResourceAware'; @Component({ tag: 'pos-rich-link', shadow: true, styleUrl: 'pos-rich-link.css', }) -export class PosRichLink { - @Prop() uri: string; +export class PosRichLink implements ResourceAware { + @Prop() uri?: string; @Event({ eventName: 'pod-os:link' }) linkEmitter: EventEmitter; + @Event({ eventName: 'pod-os:resource' }) + subscribeResource: ResourceEventEmitter; + + @State() resource?: Thing; + + componentWillLoad() { + if (!this.uri) subscribeResource(this); + } + + receiveResource = (resource: Thing) => { + this.resource = resource; + }; + render() { - return ( - -

- { - e.preventDefault(); - this.linkEmitter.emit(this.uri); - }} - > - - - {new URL(this.uri).host} - -

-
+ const uri = this.uri || this.resource?.uri; + if (!uri) return null; + + const content = ( +

+ { + e.preventDefault(); + this.linkEmitter.emit(uri); + }} + > + + + {new URL(uri).host} + +

); + + if (this.resource) { + return content; + } else if (this.uri) { + return ( + + {content} + + ); + } } } From 27809566df2a493563caa1dcb4d438dd6e1df89e Mon Sep 17 00:00:00 2001 From: jg10 <181532694+jg10-mastodon-social@users.noreply.github.com> Date: Sun, 6 Jul 2025 21:36:10 +1000 Subject: [PATCH 02/12] feat(pos-rich-link): follow rel attribute if present --- .../pos-rich-link/pos-rich-link.spec.tsx | 42 +++++++++++++++++++ .../pos-rich-link/pos-rich-link.tsx | 23 ++++++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx b/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx index 30dc3115..21eb77cd 100644 --- a/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx +++ b/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx @@ -97,4 +97,46 @@ describe('pos-rich-link without uri', () => { await page.waitForChanges(); expect(page.root?.shadowRoot?.querySelector('pos-resource')).toBeNull(); }); + + it('uses the matching relation if rel prop is defined', async () => { + const page = await newSpecPage({ + components: [PosRichLink], + html: ``, + }); + await page.rootInstance.receiveResource({ + uri: 'https://pod.example/resource', + relations: () => [{ predicate: 'https://schema.org/video', uris: ['https://video.test/video-1'] }], + }); + await page.waitForChanges(); + const link = page.root?.shadowRoot?.querySelector('a'); + expect(link).toEqualAttribute('href', 'https://video.test/video-1'); + }); + + it('displays an error if no link is found', async () => { + const page = await newSpecPage({ + components: [PosRichLink], + html: ``, + }); + await page.rootInstance.receiveResource({ + uri: 'https://pod.example/resource', + relations: () => [], + }); + await page.waitForChanges(); + expect(page.root?.shadowRoot?.textContent).toEqual('No matching link found'); + }); + + it('displays an error if more than one link is found', async () => { + const page = await newSpecPage({ + components: [PosRichLink], + html: ``, + }); + await page.rootInstance.receiveResource({ + uri: 'https://pod.example/resource', + relations: () => [ + { predicate: 'https://schema.org/video', uris: ['https://video.test/video-1', 'https://video.test/video-2'] }, + ], + }); + await page.waitForChanges(); + expect(page.root?.shadowRoot?.textContent).toEqual('More than one matching link found'); + }); }); diff --git a/elements/src/components/pos-rich-link/pos-rich-link.tsx b/elements/src/components/pos-rich-link/pos-rich-link.tsx index beb3bf12..4d958fa9 100644 --- a/elements/src/components/pos-rich-link/pos-rich-link.tsx +++ b/elements/src/components/pos-rich-link/pos-rich-link.tsx @@ -9,24 +9,39 @@ import { ResourceAware, ResourceEventEmitter, subscribeResource } from '../event }) export class PosRichLink implements ResourceAware { @Prop() uri?: string; + @Prop() rel?: string; @Event({ eventName: 'pod-os:link' }) linkEmitter: EventEmitter; @Event({ eventName: 'pod-os:resource' }) subscribeResource: ResourceEventEmitter; - @State() resource?: Thing; + @State() link?: string; + @State() error: string = null; componentWillLoad() { if (!this.uri) subscribeResource(this); } receiveResource = (resource: Thing) => { - this.resource = resource; + if (this.rel) { + const links = resource.relations(this.rel); + if (links.length == 0) { + this.error = 'No matching link found'; + } else if (links[0].uris.length > 1) { + this.error = 'More than one matching link found'; + } else { + this.link = links[0].uris[0]; + } + } else { + this.link = resource.uri; + } }; render() { - const uri = this.uri || this.resource?.uri; + if (this.error) return this.error; + + const uri = this.uri || this.link; if (!uri) return null; const content = ( @@ -45,7 +60,7 @@ export class PosRichLink implements ResourceAware {

); - if (this.resource) { + if (this.link) { return content; } else if (this.uri) { return ( From 9b78cb39e6d15427c5797250ffd3d9c2b6ca5edf Mon Sep 17 00:00:00 2001 From: jg10 <181532694+jg10-mastodon-social@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:02:47 +1000 Subject: [PATCH 03/12] feat(core): thing.reverseRelations takes optional predicate --- core/CHANGELOG.md | 6 +++++ core/src/thing/Thing.reverseRelations.spec.ts | 25 +++++++++++++++++++ core/src/thing/Thing.ts | 4 +-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 5856e1fd..decf80e5 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- `Thing.reverseRelations()`: Now takes optional predicate to filter by + ## 0.17.0 ### Changed diff --git a/core/src/thing/Thing.reverseRelations.spec.ts b/core/src/thing/Thing.reverseRelations.spec.ts index 2109e761..2fa9e639 100644 --- a/core/src/thing/Thing.reverseRelations.spec.ts +++ b/core/src/thing/Thing.reverseRelations.spec.ts @@ -142,5 +142,30 @@ describe("Thing", function () { }, ]); }); + + it("only follows the given predicate if provided", () => { + const store = graph(); + const uri = "https://jane.doe.example/container/file.ttl#fragment"; + store.add( + sym("https://pod.example/first"), + sym("http://vocab.test/first"), + + sym(uri), + ); + store.add( + sym("https://pod.example/second"), + sym("http://vocab.test/second"), + sym(uri), + ); + const it = new Thing(uri, store); + const result = it.reverseRelations("http://vocab.test/first"); + expect(result).toEqual([ + { + predicate: "http://vocab.test/first", + label: "first", + uris: ["https://pod.example/first"], + }, + ]); + }); }); }); diff --git a/core/src/thing/Thing.ts b/core/src/thing/Thing.ts index 3cb8d64f..b53b0fb1 100644 --- a/core/src/thing/Thing.ts +++ b/core/src/thing/Thing.ts @@ -89,10 +89,10 @@ export class Thing { })); } - reverseRelations(): Relation[] { + reverseRelations(predicate?: string): Relation[] { const statements = this.store.statementsMatching( undefined, - undefined, + predicate ? sym(predicate) : null, sym(this.uri), ); From 3db0d4283acc8359f67ca3276c7b2b98a327b93e Mon Sep 17 00:00:00 2001 From: jg10 <181532694+jg10-mastodon-social@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:05:39 +1000 Subject: [PATCH 04/12] fix(pos-rich-link): clarify test mock function name --- .../components/pos-rich-link/pos-rich-link.spec.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx b/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx index 21eb77cd..17ee1aa1 100644 --- a/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx +++ b/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx @@ -52,23 +52,23 @@ describe('pos-rich-link with uri', () => { describe('pos-rich-link without uri', () => { it('does not emit pod-os:resource event if uri is present', async () => { - const receiveResource = jest.fn(); + const onResource = jest.fn(); const page = await newSpecPage({ components: [PosRichLink], }); - page.body.addEventListener('pod-os:resource', receiveResource); + page.body.addEventListener('pod-os:resource', onResource); await page.setContent(''); - expect(receiveResource).toHaveBeenCalledTimes(0); + expect(onResource).toHaveBeenCalledTimes(0); }); it('receives resource and sets it as link if uri is not present', async () => { - const receiveResource = jest.fn(); + const onResource = jest.fn(); const page = await newSpecPage({ components: [PosRichLink], }); - page.body.addEventListener('pod-os:resource', receiveResource); + page.body.addEventListener('pod-os:resource', onResource); await page.setContent(''); - expect(receiveResource).toHaveBeenCalledTimes(1); + expect(onResource).toHaveBeenCalledTimes(1); await page.rootInstance.receiveResource({ uri: 'https://pod.example/resource', From 3e453781bb2b5960038e4da3d2b37205ae0afde0 Mon Sep 17 00:00:00 2001 From: jg10 <181532694+jg10-mastodon-social@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:10:43 +1000 Subject: [PATCH 05/12] refactor(pos-rich-link): use content function --- .../components/pos-rich-link/pos-rich-link.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/elements/src/components/pos-rich-link/pos-rich-link.tsx b/elements/src/components/pos-rich-link/pos-rich-link.tsx index 4d958fa9..bad97971 100644 --- a/elements/src/components/pos-rich-link/pos-rich-link.tsx +++ b/elements/src/components/pos-rich-link/pos-rich-link.tsx @@ -39,12 +39,7 @@ export class PosRichLink implements ResourceAware { }; render() { - if (this.error) return this.error; - - const uri = this.uri || this.link; - if (!uri) return null; - - const content = ( + const content = (uri: string) => (

); - if (this.link) { - return content; + if (this.error) { + return this.error; + } else if (this.link) { + return content(this.link); } else if (this.uri) { return ( - {content} + {content(this.uri)} ); + } else { + return null; } } } From 20ff56212cf9349fb1098cb3f0d706f2f5f36505 Mon Sep 17 00:00:00 2001 From: jg10 <181532694+jg10-mastodon-social@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:21:14 +1000 Subject: [PATCH 06/12] test(pos-rich-link): test Thing.relations is called with correct rel --- .../components/pos-rich-link/pos-rich-link.spec.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx b/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx index 17ee1aa1..9b27e7f1 100644 --- a/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx +++ b/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx @@ -1,6 +1,7 @@ import { newSpecPage } from '@stencil/core/testing'; import { PosRichLink } from './pos-rich-link'; import { getByText } from '@testing-library/dom'; +import { when } from 'jest-when'; describe('pos-rich-link with uri', () => { let page; @@ -103,10 +104,15 @@ describe('pos-rich-link without uri', () => { components: [PosRichLink], html: ``, }); - await page.rootInstance.receiveResource({ + const thing = { uri: 'https://pod.example/resource', - relations: () => [{ predicate: 'https://schema.org/video', uris: ['https://video.test/video-1'] }], - }); + relations: jest.fn(), + }; + when(thing.relations) + .calledWith('https://schema.org/video') + .mockReturnValue([{ predicate: 'https://schema.org/video', uris: ['https://video.test/video-1'] }]); + + await page.rootInstance.receiveResource(thing); await page.waitForChanges(); const link = page.root?.shadowRoot?.querySelector('a'); expect(link).toEqualAttribute('href', 'https://video.test/video-1'); From d8827c10ff3bcc192d6327033388b0ca25760dea Mon Sep 17 00:00:00 2001 From: jg10 <181532694+jg10-mastodon-social@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:43:56 +1000 Subject: [PATCH 07/12] feat(pos-rich-link): follow rev attribute if present --- .../pos-rich-link/pos-rich-link.spec.tsx | 19 ++++++++++++++++++ .../pos-rich-link/pos-rich-link.tsx | 20 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx b/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx index 9b27e7f1..9e1a8566 100644 --- a/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx +++ b/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx @@ -118,6 +118,25 @@ describe('pos-rich-link without uri', () => { expect(link).toEqualAttribute('href', 'https://video.test/video-1'); }); + it('uses the matching relation if rev prop is defined', async () => { + const page = await newSpecPage({ + components: [PosRichLink], + html: ``, + }); + const thing = { + uri: 'https://video.test/video-1', + reverseRelations: jest.fn(), + }; + when(thing.reverseRelations) + .calledWith('https://schema.org/video') + .mockReturnValue([{ predicate: 'https://schema.org/video', uris: ['https://pod.example/resource'] }]); + + await page.rootInstance.receiveResource(thing); + await page.waitForChanges(); + const link = page.root?.shadowRoot?.querySelector('a'); + expect(link).toEqualAttribute('href', 'https://pod.example/resource'); + }); + it('displays an error if no link is found', async () => { const page = await newSpecPage({ components: [PosRichLink], diff --git a/elements/src/components/pos-rich-link/pos-rich-link.tsx b/elements/src/components/pos-rich-link/pos-rich-link.tsx index bad97971..06235491 100644 --- a/elements/src/components/pos-rich-link/pos-rich-link.tsx +++ b/elements/src/components/pos-rich-link/pos-rich-link.tsx @@ -8,8 +8,18 @@ import { ResourceAware, ResourceEventEmitter, subscribeResource } from '../event styleUrl: 'pos-rich-link.css', }) export class PosRichLink implements ResourceAware { + /** + * Link will use this URI + */ @Prop() uri?: string; + /** + * Link will be obtained by following the predicate with this URI forward from a resource + */ @Prop() rel?: string; + /** + * Link will be obtained by following the predicate with this URI in reverse from a resource + */ + @Prop() rev?: string; @Event({ eventName: 'pod-os:link' }) linkEmitter: EventEmitter; @@ -24,8 +34,14 @@ export class PosRichLink implements ResourceAware { } receiveResource = (resource: Thing) => { - if (this.rel) { - const links = resource.relations(this.rel); + if (this.rel || this.rev) { + let links = []; + if (this.rel) { + links = resource.relations(this.rel); + } else if (this.rev) { + links = resource.reverseRelations(this.rev); + } + if (links.length == 0) { this.error = 'No matching link found'; } else if (links[0].uris.length > 1) { From 83ee599a504445762e57ca45307a77627f8009aa Mon Sep 17 00:00:00 2001 From: jg10 <181532694+jg10-mastodon-social@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:49:24 +1000 Subject: [PATCH 08/12] doc(elements): update generated readmes with pos-rich-link --- docs/elements/apps/pos-app-browser/readme.md | 10 +++---- .../pos-example-resources/readme.md | 2 +- .../elements/apps/pos-app-dashboard/readme.md | 2 +- docs/elements/apps/pos-app-generic/readme.md | 2 +- .../apps/pos-app-ldp-container/readme.md | 2 +- .../apps/pos-app-rdf-document/readme.md | 2 +- .../components/pos-internal-router/readme.md | 2 +- .../components/pos-navigation-bar/readme.md | 2 +- .../components/pos-relations/readme.md | 2 +- .../pos-reverse-relations/readme.md | 2 +- .../components/pos-rich-link/readme.md | 19 ++++++++------ .../components/pos-subjects/readme.md | 2 +- elements/src/components.d.ts | 26 ++++++++++++++++++- 13 files changed, 51 insertions(+), 24 deletions(-) diff --git a/docs/elements/apps/pos-app-browser/readme.md b/docs/elements/apps/pos-app-browser/readme.md index 219a2574..3834dc2c 100644 --- a/docs/elements/apps/pos-app-browser/readme.md +++ b/docs/elements/apps/pos-app-browser/readme.md @@ -7,10 +7,10 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| ------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | -------------- | -| `mode` | `mode` | The mode the app is running in: - standalone: use this when you deploy it as a standalone web application - pod: use this when you host this app as a default interface for you pod | `"pod" \| "standalone"` | `'standalone'` | -| `restorePreviousSession` | `restore-previous-session` | | `boolean` | `false` | +| Property | Attribute | Description | Type | Default | +| ------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------- | -------------- | +| `mode` | `mode` | The mode the app is running in: - standalone: use this when you deploy it as a standalone web application - pod: use this when you host this app as a default interface for you pod | `"pod" \| "standalone"` | `'standalone'` | +| `restorePreviousSession` | `restore-previous-session` | | `boolean` | `false` | ## Dependencies @@ -55,9 +55,9 @@ graph TD; pos-make-findable --> pos-label pos-resource --> ion-progress-bar ion-searchbar --> ion-icon - pos-rich-link --> pos-resource pos-rich-link --> pos-label pos-rich-link --> pos-description + pos-rich-link --> pos-resource pos-login --> pos-resource pos-login --> pos-picture pos-login --> pos-label diff --git a/docs/elements/apps/pos-app-dashboard/pos-example-resources/readme.md b/docs/elements/apps/pos-app-dashboard/pos-example-resources/readme.md index be8580d5..e5129690 100644 --- a/docs/elements/apps/pos-app-dashboard/pos-example-resources/readme.md +++ b/docs/elements/apps/pos-app-dashboard/pos-example-resources/readme.md @@ -16,9 +16,9 @@ ```mermaid graph TD; pos-example-resources --> pos-rich-link - pos-rich-link --> pos-resource pos-rich-link --> pos-label pos-rich-link --> pos-description + pos-rich-link --> pos-resource pos-resource --> ion-progress-bar pos-app-dashboard --> pos-example-resources style pos-example-resources fill:#f9f,stroke:#333,stroke-width:4px diff --git a/docs/elements/apps/pos-app-dashboard/readme.md b/docs/elements/apps/pos-app-dashboard/readme.md index bfd7c2ed..6b7ea98b 100644 --- a/docs/elements/apps/pos-app-dashboard/readme.md +++ b/docs/elements/apps/pos-app-dashboard/readme.md @@ -19,9 +19,9 @@ graph TD; pos-app-dashboard --> pos-getting-started pos-app-dashboard --> pos-example-resources pos-example-resources --> pos-rich-link - pos-rich-link --> pos-resource pos-rich-link --> pos-label pos-rich-link --> pos-description + pos-rich-link --> pos-resource pos-resource --> ion-progress-bar pos-internal-router --> pos-app-dashboard style pos-app-dashboard fill:#f9f,stroke:#333,stroke-width:4px diff --git a/docs/elements/apps/pos-app-generic/readme.md b/docs/elements/apps/pos-app-generic/readme.md index bb20fa58..19fbdd54 100644 --- a/docs/elements/apps/pos-app-generic/readme.md +++ b/docs/elements/apps/pos-app-generic/readme.md @@ -39,9 +39,9 @@ graph TD; pos-add-literal-value --> pos-select-term pos-relations --> pos-predicate pos-relations --> pos-rich-link - pos-rich-link --> pos-resource pos-rich-link --> pos-label pos-rich-link --> pos-description + pos-rich-link --> pos-resource pos-resource --> ion-progress-bar pos-reverse-relations --> pos-predicate pos-reverse-relations --> pos-rich-link diff --git a/docs/elements/apps/pos-app-ldp-container/readme.md b/docs/elements/apps/pos-app-ldp-container/readme.md index c292a1ab..71e6610b 100644 --- a/docs/elements/apps/pos-app-ldp-container/readme.md +++ b/docs/elements/apps/pos-app-ldp-container/readme.md @@ -28,9 +28,9 @@ graph TD; pos-resource --> ion-progress-bar pos-container-item --> ion-icon pos-subjects --> pos-rich-link - pos-rich-link --> pos-resource pos-rich-link --> pos-label pos-rich-link --> pos-description + pos-rich-link --> pos-resource pos-type-badges --> ion-badge pos-type-badges --> ion-icon pos-literals --> pos-predicate diff --git a/docs/elements/apps/pos-app-rdf-document/readme.md b/docs/elements/apps/pos-app-rdf-document/readme.md index c144f960..02389038 100644 --- a/docs/elements/apps/pos-app-rdf-document/readme.md +++ b/docs/elements/apps/pos-app-rdf-document/readme.md @@ -22,9 +22,9 @@ graph TD; pos-app-rdf-document --> pos-type-badges pos-app-rdf-document --> pos-literals pos-subjects --> pos-rich-link - pos-rich-link --> pos-resource pos-rich-link --> pos-label pos-rich-link --> pos-description + pos-rich-link --> pos-resource pos-resource --> ion-progress-bar pos-type-badges --> ion-badge pos-type-badges --> ion-icon diff --git a/docs/elements/components/pos-internal-router/readme.md b/docs/elements/components/pos-internal-router/readme.md index 497c0353..9a069e59 100644 --- a/docs/elements/components/pos-internal-router/readme.md +++ b/docs/elements/components/pos-internal-router/readme.md @@ -29,9 +29,9 @@ graph TD; pos-app-dashboard --> pos-getting-started pos-app-dashboard --> pos-example-resources pos-example-resources --> pos-rich-link - pos-rich-link --> pos-resource pos-rich-link --> pos-label pos-rich-link --> pos-description + pos-rich-link --> pos-resource pos-resource --> ion-progress-bar pos-app-browser --> pos-internal-router style pos-internal-router fill:#f9f,stroke:#333,stroke-width:4px diff --git a/docs/elements/components/pos-navigation-bar/readme.md b/docs/elements/components/pos-navigation-bar/readme.md index 48ecfdda..f96a8a6a 100644 --- a/docs/elements/components/pos-navigation-bar/readme.md +++ b/docs/elements/components/pos-navigation-bar/readme.md @@ -42,9 +42,9 @@ graph TD; pos-make-findable --> pos-label pos-resource --> ion-progress-bar ion-searchbar --> ion-icon - pos-rich-link --> pos-resource pos-rich-link --> pos-label pos-rich-link --> pos-description + pos-rich-link --> pos-resource pos-app-browser --> pos-navigation-bar style pos-navigation-bar fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/docs/elements/components/pos-relations/readme.md b/docs/elements/components/pos-relations/readme.md index a7d3807e..3017addb 100644 --- a/docs/elements/components/pos-relations/readme.md +++ b/docs/elements/components/pos-relations/readme.md @@ -29,9 +29,9 @@ graph TD; pos-relations --> pos-predicate pos-relations --> pos-rich-link pos-predicate --> ion-icon - pos-rich-link --> pos-resource pos-rich-link --> pos-label pos-rich-link --> pos-description + pos-rich-link --> pos-resource pos-resource --> ion-progress-bar pos-app-generic --> pos-relations style pos-relations fill:#f9f,stroke:#333,stroke-width:4px diff --git a/docs/elements/components/pos-reverse-relations/readme.md b/docs/elements/components/pos-reverse-relations/readme.md index 2292c79a..b0f9b1eb 100644 --- a/docs/elements/components/pos-reverse-relations/readme.md +++ b/docs/elements/components/pos-reverse-relations/readme.md @@ -29,9 +29,9 @@ graph TD; pos-reverse-relations --> pos-predicate pos-reverse-relations --> pos-rich-link pos-predicate --> ion-icon - pos-rich-link --> pos-resource pos-rich-link --> pos-label pos-rich-link --> pos-description + pos-rich-link --> pos-resource pos-resource --> ion-progress-bar pos-app-generic --> pos-reverse-relations style pos-reverse-relations fill:#f9f,stroke:#333,stroke-width:4px diff --git a/docs/elements/components/pos-rich-link/readme.md b/docs/elements/components/pos-rich-link/readme.md index b9563d04..efc40f69 100644 --- a/docs/elements/components/pos-rich-link/readme.md +++ b/docs/elements/components/pos-rich-link/readme.md @@ -7,16 +7,19 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| -------- | --------- | ----------- | -------- | ----------- | -| `uri` | `uri` | | `string` | `undefined` | +| Property | Attribute | Description | Type | Default | +| -------- | --------- | ----------------------------------------------------------------------------------------- | -------- | ----------- | +| `rel` | `rel` | Link will be obtained by following the predicate with this URI forward from a resource | `string` | `undefined` | +| `rev` | `rev` | Link will be obtained by following the predicate with this URI in reverse from a resource | `string` | `undefined` | +| `uri` | `uri` | Link will use this URI | `string` | `undefined` | ## Events -| Event | Description | Type | -| ------------- | ----------- | ------------------ | -| `pod-os:link` | | `CustomEvent` | +| Event | Description | Type | +| ----------------- | ----------- | ------------------ | +| `pod-os:link` | | `CustomEvent` | +| `pod-os:resource` | | `CustomEvent` | ## Dependencies @@ -31,16 +34,16 @@ ### Depends on -- [pos-resource](../pos-resource) - [pos-label](../pos-label) - [pos-description](../pos-description) +- [pos-resource](../pos-resource) ### Graph ```mermaid graph TD; - pos-rich-link --> pos-resource pos-rich-link --> pos-label pos-rich-link --> pos-description + pos-rich-link --> pos-resource pos-resource --> ion-progress-bar pos-example-resources --> pos-rich-link pos-navigation-bar --> pos-rich-link diff --git a/docs/elements/components/pos-subjects/readme.md b/docs/elements/components/pos-subjects/readme.md index 8e300153..7d1e2101 100644 --- a/docs/elements/components/pos-subjects/readme.md +++ b/docs/elements/components/pos-subjects/readme.md @@ -27,9 +27,9 @@ ```mermaid graph TD; pos-subjects --> pos-rich-link - pos-rich-link --> pos-resource pos-rich-link --> pos-label pos-rich-link --> pos-description + pos-rich-link --> pos-resource pos-resource --> ion-progress-bar pos-app-ldp-container --> pos-subjects pos-app-rdf-document --> pos-subjects diff --git a/elements/src/components.d.ts b/elements/src/components.d.ts index a2885085..033be9b3 100644 --- a/elements/src/components.d.ts +++ b/elements/src/components.d.ts @@ -121,7 +121,18 @@ export namespace Components { interface PosReverseRelations { } interface PosRichLink { - "uri": string; + /** + * Link will be obtained by following the predicate with this URI forward from a resource + */ + "rel"?: string; + /** + * Link will be obtained by following the predicate with this URI in reverse from a resource + */ + "rev"?: string; + /** + * Link will use this URI + */ + "uri"?: string; } /** * The responsibility of pos-router is to handle the `uri` query param, that specifies the URI of the resource that is currently opened. @@ -724,6 +735,7 @@ declare global { }; interface HTMLPosRichLinkElementEventMap { "pod-os:link": any; + "pod-os:resource": any; } interface HTMLPosRichLinkElement extends Components.PosRichLink, HTMLStencilElement { addEventListener(type: K, listener: (this: HTMLPosRichLinkElement, ev: PosRichLinkCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; @@ -1067,6 +1079,18 @@ declare namespace LocalJSX { } interface PosRichLink { "onPod-os:link"?: (event: PosRichLinkCustomEvent) => void; + "onPod-os:resource"?: (event: PosRichLinkCustomEvent) => void; + /** + * Link will be obtained by following the predicate with this URI forward from a resource + */ + "rel"?: string; + /** + * Link will be obtained by following the predicate with this URI in reverse from a resource + */ + "rev"?: string; + /** + * Link will use this URI + */ "uri"?: string; } /** From e6972f723be642f93d17b3e97140758866d19a38 Mon Sep 17 00:00:00 2001 From: jg10 <181532694+jg10-mastodon-social@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:53:47 +1000 Subject: [PATCH 09/12] doc(elements): add pos-rich-link changes to changelog --- elements/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/elements/CHANGELOG.md b/elements/CHANGELOG.md index 1396b84d..6ab9677d 100644 --- a/elements/CHANGELOG.md +++ b/elements/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +### Changed + +- [pos-rich-link](../docs/elements/components/pos-rich-link): + - can now receive a resource to use for the link + - can now follow `rel` and `rev` to discover a resource to use for the link + ### Fixed - [pos-app-browser](../docs/elements/apps/pos-app-browser): prevent error message flashing up while uri is unset on hard refresh From e1abc7dda6e5ab5adf0a5da4afdb97a2bab450d4 Mon Sep 17 00:00:00 2001 From: jg10 <181532694+jg10-mastodon-social@users.noreply.github.com> Date: Sat, 12 Jul 2025 00:23:16 +1000 Subject: [PATCH 10/12] fix(pos-rich-link): rel and rev use nested pos-resource --- .../pos-rich-link.integration.spec.tsx | 158 ++++++++++++++++++ .../pos-rich-link/pos-rich-link.tsx | 10 +- 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 elements/src/components/pos-rich-link/pos-rich-link.integration.spec.tsx diff --git a/elements/src/components/pos-rich-link/pos-rich-link.integration.spec.tsx b/elements/src/components/pos-rich-link/pos-rich-link.integration.spec.tsx new file mode 100644 index 00000000..0784add9 --- /dev/null +++ b/elements/src/components/pos-rich-link/pos-rich-link.integration.spec.tsx @@ -0,0 +1,158 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { mockPodOS } from '../../test/mockPodOS'; +import { PosApp } from '../pos-app/pos-app'; +import { PosDescription } from '../pos-description/pos-description'; +import { PosLabel } from '../pos-label/pos-label'; +import { PosResource } from '../pos-resource/pos-resource'; +import { PosRichLink } from './pos-rich-link'; +import { when } from 'jest-when'; +import { Component, h } from '@stencil/core'; + +describe('pos-rich-link', () => { + let os; + beforeEach(async () => { + os = mockPodOS(); + when(os.store.get) + .calledWith('https://resource.test') + .mockReturnValue({ + uri: 'https://resource.test', + label: () => 'Test label', + description: () => 'Test description', + relations: () => [{ predicate: 'https://schema.org/video', uris: ['https://video.test/video-1'] }], + }); + when(os.store.get) + .calledWith('https://video.test/video-1') + .mockReturnValue({ + uri: 'https://video.test/video-1', + label: () => 'Video 1', + description: () => 'Description of Video 1', + reverseRelations: () => [{ predicate: 'https://schema.org/video', uris: ['https://resource.test'] }], + }); + }); + + it('can be used outside resource', async () => { + const page = await newSpecPage({ + components: [PosApp, PosDescription, PosLabel, PosResource, PosRichLink], + supportsShadowDom: false, + html: ` + + + `, + }); + + const link = page.root?.querySelector('pos-rich-link'); + expect(link).toEqualHtml(` + + +

+ + + Test label + + + resource.test + + Test description + +

+ +
+ `); + }); + + it('receives and renders resource', async () => { + const page = await newSpecPage({ + components: [PosApp, PosDescription, PosLabel, PosResource, PosRichLink], + supportsShadowDom: false, + html: ` + + + + + `, + }); + + expect(os.store.get.mock.calls).toHaveLength(1); + + const link = page.root?.querySelector('pos-rich-link'); + expect(link).toEqualHtml(` + +

+ + + Test label + + + resource.test + + Test description + +

+
+ `); + }); + + it('uses label and description of the matching rel', async () => { + const page = await newSpecPage({ + components: [PosApp, PosDescription, PosLabel, PosResource, PosRichLink], + supportsShadowDom: false, + html: ` + + + + + `, + }); + + const link = page.root?.querySelector('pos-rich-link'); + expect(link).toEqualHtml(` + + +

+ + + Video 1 + + + video.test + + Description of Video 1 + +

+
+
+ `); + }); + + it('uses label and description of the matching rev', async () => { + const page = await newSpecPage({ + components: [PosApp, PosDescription, PosLabel, PosResource, PosRichLink], + supportsShadowDom: false, + html: ` + + + + + `, + }); + + const link = page.root?.querySelector('pos-rich-link'); + expect(link).toEqualHtml(` + + +

+ + + Test label + + + resource.test + + Test description + +

+
+
+ `); + }); +}); diff --git a/elements/src/components/pos-rich-link/pos-rich-link.tsx b/elements/src/components/pos-rich-link/pos-rich-link.tsx index 06235491..9b4aa828 100644 --- a/elements/src/components/pos-rich-link/pos-rich-link.tsx +++ b/elements/src/components/pos-rich-link/pos-rich-link.tsx @@ -27,6 +27,7 @@ export class PosRichLink implements ResourceAware { subscribeResource: ResourceEventEmitter; @State() link?: string; + @State() followPredicate: boolean = false; @State() error: string = null; componentWillLoad() { @@ -34,7 +35,8 @@ export class PosRichLink implements ResourceAware { } receiveResource = (resource: Thing) => { - if (this.rel || this.rev) { + this.followPredicate = typeof this.rel != 'undefined' || typeof this.rev != 'undefined'; + if (this.followPredicate) { let links = []; if (this.rel) { links = resource.relations(this.rel); @@ -73,6 +75,12 @@ export class PosRichLink implements ResourceAware { if (this.error) { return this.error; + } else if (this.followPredicate) { + return ( + + {content(this.link)} + + ); } else if (this.link) { return content(this.link); } else if (this.uri) { From f688cfdb418e79339b4f254abbc1a3340f4c7c8f Mon Sep 17 00:00:00 2001 From: jg10 <181532694+jg10-mastodon-social@users.noreply.github.com> Date: Sat, 12 Jul 2025 00:27:49 +1000 Subject: [PATCH 11/12] doc(pos-rich-link): add storybook examples --- .../navigation/0_pos-rich-link.stories.mdx | 87 +++++++++++++++---- 1 file changed, 68 insertions(+), 19 deletions(-) diff --git a/storybook/stories/navigation/0_pos-rich-link.stories.mdx b/storybook/stories/navigation/0_pos-rich-link.stories.mdx index 201d3acc..d35b3c82 100644 --- a/storybook/stories/navigation/0_pos-rich-link.stories.mdx +++ b/storybook/stories/navigation/0_pos-rich-link.stories.mdx @@ -1,35 +1,84 @@ -import { - html -} from "lit-html"; +import { html } from "lit-html"; -import { - Canvas, - Meta, - Story -} from '@storybook/addon-docs/blocks'; +import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; - ## pos-rich-link -Links to the given URI and shows a label and description of the resource identified by that URI, if available. +Links to the given URI and shows a label and description of the resource +identified by that URI, if available. + + + + {({ uri }) => html` `} + + + +Link can be obtained by receiving a resource from an ancestor element + + + + {({ uri }) => html` + + + + `} + + + +Link can be obtained by following the predicate forward from a resource + + + + {({ uri }) => html` + + + + `} + + + +Link can be obtained by following a predicate in reverse from a resource + + + + {({ uri }) => html` + + + + `} + + + +Error is shown if there is no matching link + + + + {({ uri }) => html` + + + + `} + + + +Error is shown if there is more than one link - - - {({uri}) => html` - + + + {({ uri }) => html` + + + `} From 6f24aa8f39dfde48493a009d715ffd50fcb4f8fc Mon Sep 17 00:00:00 2001 From: jg10 <181532694+jg10-mastodon-social@users.noreply.github.com> Date: Sat, 12 Jul 2025 00:50:58 +1000 Subject: [PATCH 12/12] feat(pos-rich-link): emit errors --- .../components/pos-rich-link/readme.md | 1 + elements/src/components.d.ts | 2 ++ .../pos-rich-link/pos-rich-link.spec.tsx | 23 ++++++++++++++-- .../pos-rich-link/pos-rich-link.tsx | 27 ++++++++++++------- 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/docs/elements/components/pos-rich-link/readme.md b/docs/elements/components/pos-rich-link/readme.md index efc40f69..650b3f32 100644 --- a/docs/elements/components/pos-rich-link/readme.md +++ b/docs/elements/components/pos-rich-link/readme.md @@ -18,6 +18,7 @@ | Event | Description | Type | | ----------------- | ----------- | ------------------ | +| `pod-os:error` | | `CustomEvent` | | `pod-os:link` | | `CustomEvent` | | `pod-os:resource` | | `CustomEvent` | diff --git a/elements/src/components.d.ts b/elements/src/components.d.ts index 033be9b3..ac1acfa3 100644 --- a/elements/src/components.d.ts +++ b/elements/src/components.d.ts @@ -736,6 +736,7 @@ declare global { interface HTMLPosRichLinkElementEventMap { "pod-os:link": any; "pod-os:resource": any; + "pod-os:error": any; } interface HTMLPosRichLinkElement extends Components.PosRichLink, HTMLStencilElement { addEventListener(type: K, listener: (this: HTMLPosRichLinkElement, ev: PosRichLinkCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; @@ -1078,6 +1079,7 @@ declare namespace LocalJSX { "onPod-os:resource"?: (event: PosReverseRelationsCustomEvent) => void; } interface PosRichLink { + "onPod-os:error"?: (event: PosRichLinkCustomEvent) => void; "onPod-os:link"?: (event: PosRichLinkCustomEvent) => void; "onPod-os:resource"?: (event: PosRichLinkCustomEvent) => void; /** diff --git a/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx b/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx index 9e1a8566..92c7f392 100644 --- a/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx +++ b/elements/src/components/pos-rich-link/pos-rich-link.spec.tsx @@ -137,24 +137,36 @@ describe('pos-rich-link without uri', () => { expect(link).toEqualAttribute('href', 'https://pod.example/resource'); }); - it('displays an error if no link is found', async () => { + it('displays and emits an error if no link is found', async () => { const page = await newSpecPage({ components: [PosRichLink], html: ``, }); + const errorListener = jest.fn(); + page.body.addEventListener('pod-os:error', errorListener); await page.rootInstance.receiveResource({ uri: 'https://pod.example/resource', relations: () => [], }); await page.waitForChanges(); expect(page.root?.shadowRoot?.textContent).toEqual('No matching link found'); + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + detail: new Error( + 'pos-rich-link: No matching link found from https://pod.example/resource rel=https://schema.org/video', + ), + }), + ); }); - it('displays an error if more than one link is found', async () => { + it('displays and emits an error if more than one link is found', async () => { const page = await newSpecPage({ components: [PosRichLink], html: ``, }); + const errorListener = jest.fn(); + page.body.addEventListener('pod-os:error', errorListener); + await page.rootInstance.receiveResource({ uri: 'https://pod.example/resource', relations: () => [ @@ -163,5 +175,12 @@ describe('pos-rich-link without uri', () => { }); await page.waitForChanges(); expect(page.root?.shadowRoot?.textContent).toEqual('More than one matching link found'); + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + detail: new Error( + 'pos-rich-link: More than one matching link found from https://pod.example/resource rel=https://schema.org/video', + ), + }), + ); }); }); diff --git a/elements/src/components/pos-rich-link/pos-rich-link.tsx b/elements/src/components/pos-rich-link/pos-rich-link.tsx index 9b4aa828..f66f050a 100644 --- a/elements/src/components/pos-rich-link/pos-rich-link.tsx +++ b/elements/src/components/pos-rich-link/pos-rich-link.tsx @@ -1,4 +1,4 @@ -import { Thing } from '@pod-os/core'; +import { Relation, Thing } from '@pod-os/core'; import { Component, Event, EventEmitter, h, Prop, State } from '@stencil/core'; import { ResourceAware, ResourceEventEmitter, subscribeResource } from '../events/ResourceAware'; @@ -26,6 +26,8 @@ export class PosRichLink implements ResourceAware { @Event({ eventName: 'pod-os:resource' }) subscribeResource: ResourceEventEmitter; + @Event({ eventName: 'pod-os:error' }) errorEmitter: EventEmitter; + @State() link?: string; @State() followPredicate: boolean = false; @State() error: string = null; @@ -35,22 +37,27 @@ export class PosRichLink implements ResourceAware { } receiveResource = (resource: Thing) => { - this.followPredicate = typeof this.rel != 'undefined' || typeof this.rev != 'undefined'; - if (this.followPredicate) { - let links = []; - if (this.rel) { - links = resource.relations(this.rel); - } else if (this.rev) { - links = resource.reverseRelations(this.rev); - } - + const addLink = (links: Relation[], resource: Thing, predicate: string, direction: string) => { if (links.length == 0) { this.error = 'No matching link found'; + this.errorEmitter.emit( + new Error(`pos-rich-link: No matching link found from ${resource.uri} ${direction}=${predicate}`), + ); } else if (links[0].uris.length > 1) { this.error = 'More than one matching link found'; + this.errorEmitter.emit( + new Error(`pos-rich-link: More than one matching link found from ${resource.uri} ${direction}=${predicate}`), + ); } else { this.link = links[0].uris[0]; + this.followPredicate = true; } + }; + + if (this.rel) { + addLink(resource.relations(this.rel), resource, this.rel, 'rel'); + } else if (this.rev) { + addLink(resource.reverseRelations(this.rev), resource, this.rev, 'rev'); } else { this.link = resource.uri; }