diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index a56d9bbe..e96f4d09 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.relations()`: Now takes optional predicate to filter by + ## 0.16.1 ### Fixed diff --git a/core/src/thing/Thing.relations.spec.ts b/core/src/thing/Thing.relations.spec.ts index 37ee9d65..34ad361e 100644 --- a/core/src/thing/Thing.relations.spec.ts +++ b/core/src/thing/Thing.relations.spec.ts @@ -188,4 +188,31 @@ describe("Thing", function () { expect(result).toEqual([]); }); }); + + it("only follows the given predicate if provided", () => { + const store = graph(); + const uri = "https://jane.doe.example/container/file.ttl#fragment"; + store.add( + sym(uri), + sym("http://vocab.test/first"), + sym("https://pod.example/first"), + ); + store.add( + sym(uri), + sym("http://vocab.test/second"), + sym("https://pod.example/second"), + ); + const it = new Thing( + "https://jane.doe.example/container/file.ttl#fragment", + store, + ); + const result = it.relations("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 565288d4..3cb8d64f 100644 --- a/core/src/thing/Thing.ts +++ b/core/src/thing/Thing.ts @@ -72,8 +72,11 @@ export class Thing { })); } - relations(): Relation[] { - const statements = this.store.statementsMatching(sym(this.uri)); + relations(predicate?: string): Relation[] { + const statements = this.store.statementsMatching( + sym(this.uri), + predicate ? sym(predicate) : null, + ); const values = statements .filter((it) => isNamedNode(it.object) && !isRdfType(it.predicate)) diff --git a/docs/elements/components/pos-list/readme.md b/docs/elements/components/pos-list/readme.md new file mode 100644 index 00000000..f376bfa4 --- /dev/null +++ b/docs/elements/components/pos-list/readme.md @@ -0,0 +1,40 @@ + + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| -------- | --------- | ----------------------------------------------------------------- | --------- | ----------- | +| `fetch` | `fetch` | Whether listed resources should be fetched before being displayed | `boolean` | `false` | +| `rel` | `rel` | URI of the predicate to follow | `string` | `undefined` | + + +## Events + +| Event | Description | Type | +| ----------------- | ----------- | ------------------ | +| `pod-os:resource` | | `CustomEvent` | + + +## Dependencies + +### Depends on + +- [pos-resource](../pos-resource) + +### Graph +```mermaid +graph TD; + pos-list --> pos-resource + pos-resource --> ion-progress-bar + pos-resource --> ion-card + pos-resource --> ion-card-header + pos-resource --> ion-card-content + ion-card --> ion-ripple-effect + style pos-list fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/docs/elements/components/pos-resource/readme.md b/docs/elements/components/pos-resource/readme.md index 21536d2f..3a2ce014 100644 --- a/docs/elements/components/pos-resource/readme.md +++ b/docs/elements/components/pos-resource/readme.md @@ -40,6 +40,7 @@ Type: `Promise` - [pos-app-browser](../../apps/pos-app-browser) - [pos-container-contents](../pos-container-contents) + - [pos-list](../pos-list) - [pos-login](../pos-login) - [pos-make-findable](../pos-make-findable) - [pos-rich-link](../pos-rich-link) @@ -61,6 +62,7 @@ graph TD; ion-card --> ion-ripple-effect pos-app-browser --> pos-resource pos-container-contents --> pos-resource + pos-list --> pos-resource pos-login --> pos-resource pos-make-findable --> pos-resource pos-rich-link --> pos-resource diff --git a/elements/CHANGELOG.md b/elements/CHANGELOG.md index d053bb59..d40635f5 100644 --- a/elements/CHANGELOG.md +++ b/elements/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. 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 + +### Added + +- [pos-list](../docs/elements/components/pos-list): + - Basic implementation of element to list things related to a resource with a custom display + ## 0.25.2 ### Fixed diff --git a/elements/src/components.d.ts b/elements/src/components.d.ts index 71377171..ce09b3a1 100644 --- a/elements/src/components.d.ts +++ b/elements/src/components.d.ts @@ -76,6 +76,16 @@ export namespace Components { } interface PosLabel { } + interface PosList { + /** + * Whether listed resources should be fetched before being displayed + */ + "fetch": boolean; + /** + * URI of the predicate to follow + */ + "rel": string; + } interface PosLiterals { } interface PosLogin { @@ -183,6 +193,10 @@ export interface PosLabelCustomEvent extends CustomEvent { detail: T; target: HTMLPosLabelElement; } +export interface PosListCustomEvent extends CustomEvent { + detail: T; + target: HTMLPosListElement; +} export interface PosLiteralsCustomEvent extends CustomEvent { detail: T; target: HTMLPosLiteralsElement; @@ -508,6 +522,23 @@ declare global { prototype: HTMLPosLabelElement; new (): HTMLPosLabelElement; }; + interface HTMLPosListElementEventMap { + "pod-os:resource": any; + } + interface HTMLPosListElement extends Components.PosList, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLPosListElement, ev: PosListCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLPosListElement, ev: PosListCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLPosListElement: { + prototype: HTMLPosListElement; + new (): HTMLPosListElement; + }; interface HTMLPosLiteralsElementEventMap { "pod-os:resource": any; } @@ -845,6 +876,7 @@ declare global { "pos-image": HTMLPosImageElement; "pos-internal-router": HTMLPosInternalRouterElement; "pos-label": HTMLPosLabelElement; + "pos-list": HTMLPosListElement; "pos-literals": HTMLPosLiteralsElement; "pos-login": HTMLPosLoginElement; "pos-login-form": HTMLPosLoginFormElement; @@ -966,6 +998,17 @@ declare namespace LocalJSX { interface PosLabel { "onPod-os:resource"?: (event: PosLabelCustomEvent) => void; } + interface PosList { + /** + * Whether listed resources should be fetched before being displayed + */ + "fetch"?: boolean; + "onPod-os:resource"?: (event: PosListCustomEvent) => void; + /** + * URI of the predicate to follow + */ + "rel"?: string; + } interface PosLiterals { "onPod-os:resource"?: (event: PosLiteralsCustomEvent) => void; } @@ -1091,6 +1134,7 @@ declare namespace LocalJSX { "pos-image": PosImage; "pos-internal-router": PosInternalRouter; "pos-label": PosLabel; + "pos-list": PosList; "pos-literals": PosLiterals; "pos-login": PosLogin; "pos-login-form": PosLoginForm; @@ -1146,6 +1190,7 @@ declare module "@stencil/core" { "pos-image": LocalJSX.PosImage & JSXBase.HTMLAttributes; "pos-internal-router": LocalJSX.PosInternalRouter & JSXBase.HTMLAttributes; "pos-label": LocalJSX.PosLabel & JSXBase.HTMLAttributes; + "pos-list": LocalJSX.PosList & JSXBase.HTMLAttributes; "pos-literals": LocalJSX.PosLiterals & JSXBase.HTMLAttributes; "pos-login": LocalJSX.PosLogin & JSXBase.HTMLAttributes; "pos-login-form": LocalJSX.PosLoginForm & JSXBase.HTMLAttributes; diff --git a/elements/src/components/pos-list/pos-list-fetch.integration.spec.tsx b/elements/src/components/pos-list/pos-list-fetch.integration.spec.tsx new file mode 100644 index 00000000..2e37e115 --- /dev/null +++ b/elements/src/components/pos-list/pos-list-fetch.integration.spec.tsx @@ -0,0 +1,40 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { mockPodOS } from '../../test/mockPodOS'; +import { PosApp } from '../pos-app/pos-app'; +import { PosLabel } from '../pos-label/pos-label'; +import { PosList } from './pos-list'; +import { PosResource } from '../pos-resource/pos-resource'; +import { when } from 'jest-when'; + +describe('pos-list', () => { + it('fetches resources if fetch attribute is present', async () => { + const os = mockPodOS(); + when(os.store.get) + .calledWith('https://resource.test') + .mockReturnValue({ + relations: () => [ + { + predicate: 'http://schema.org/video', + uris: ['https://video.test/video-1', 'https://video.test/video-2'], + }, + ], + }); + await newSpecPage({ + components: [PosApp, PosLabel, PosList, PosResource], + supportsShadowDom: false, + html: ` + + + + + + + `, + }); + + expect(os.fetch.mock.calls).toHaveLength(2); + expect(os.fetch.mock.calls).toEqual([['https://video.test/video-1'], ['https://video.test/video-2']]); + }); +}); diff --git a/elements/src/components/pos-list/pos-list.integration.spec.tsx b/elements/src/components/pos-list/pos-list.integration.spec.tsx new file mode 100644 index 00000000..5331751b --- /dev/null +++ b/elements/src/components/pos-list/pos-list.integration.spec.tsx @@ -0,0 +1,68 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { mockPodOS } from '../../test/mockPodOS'; +import { PosApp } from '../pos-app/pos-app'; +import { PosLabel } from '../pos-label/pos-label'; +import { PosList } from './pos-list'; +import { PosResource } from '../pos-resource/pos-resource'; +import { when } from 'jest-when'; + +describe('pos-list', () => { + it('children render label for loaded resources (without fetching)', async () => { + const os = mockPodOS(); + when(os.store.get) + .calledWith('https://resource.test') + .mockReturnValue({ + relations: () => [ + { + predicate: 'http://schema.org/video', + uris: ['https://video.test/video-1', 'https://video.test/video-2'], + }, + ], + }); + when(os.store.get) + .calledWith('https://video.test/video-1') + .mockReturnValue({ uri: 'https://video.test/video-1', label: () => 'Video 1' }); + when(os.store.get) + .calledWith('https://video.test/video-2') + .mockReturnValue({ uri: 'https://video.test/video-2', label: () => 'Video 2' }); + const page = await newSpecPage({ + components: [PosApp, PosLabel, PosList, PosResource], + supportsShadowDom: false, + html: ` + + + + + + + `, + }); + expect(os.fetch.mock.calls).toHaveLength(0); + + const resources = page.root ? page.root.querySelectorAll('pos-list pos-resource') : []; + expect(resources).toHaveLength(2); + + const label1 = resources[0] as unknown as PosResource; + expect(label1).toEqualHtml(` + + + Video 1 + + +`); + //Tested separately because pos-resource does not reflect the uri property as an attribute + expect(label1?.uri).toEqual('https://video.test/video-1'); + + const label2 = resources[1] as unknown as PosResource; + expect(label2).toEqualHtml(` + + + Video 2 + + +`); + expect(label2?.uri).toEqual('https://video.test/video-2'); + }); +}); diff --git a/elements/src/components/pos-list/pos-list.spec.tsx b/elements/src/components/pos-list/pos-list.spec.tsx new file mode 100644 index 00000000..d16b4341 --- /dev/null +++ b/elements/src/components/pos-list/pos-list.spec.tsx @@ -0,0 +1,174 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { PosList } from './pos-list'; + +describe('pos-list', () => { + it('contains only template initially', async () => { + const page = await newSpecPage({ + components: [PosList], + html: ` + + + `, + }); + expect(page.root).toEqualHtml(` + + + + `); + }); + + it('renders single rel object', async () => { + const page = await newSpecPage({ + components: [PosList], + html: ` + + + `, + }); + await page.rootInstance.receiveResource({ + relations: () => [ + { + predicate: 'http://schema.org/video', + label: 'url', + uris: ['https://video.test/video-1'], + }, + ], + }); + await page.waitForChanges(); + + const el: HTMLElement = page.root as unknown as HTMLElement; + + expect(el.querySelectorAll('pos-resource')).toHaveLength(1); + expect(el.querySelector('pos-resource')?.innerHTML).toEqualHtml('Test'); + }); + + it('renders multiple rel objects', async () => { + const page = await newSpecPage({ + components: [PosList], + html: ` + + + `, + }); + await page.rootInstance.receiveResource({ + relations: () => [ + { + predicate: 'http://schema.org/video', + label: 'url', + uris: ['https://video.test/video-1', 'https://video.test/video-2'], + }, + ], + }); + await page.waitForChanges(); + + const el: HTMLElement = page.root as unknown as HTMLElement; + + expect(el.querySelectorAll('pos-resource')).toHaveLength(2); + }); + + it('displays error on missing template', async () => { + const page = await newSpecPage({ + components: [PosList], + html: ``, + }); + await page.rootInstance.receiveResource({ + relations: () => [ + { + predicate: 'http://schema.org/video', + label: 'url', + uris: ['https://video.test/video-1'], + }, + ], + }); + await page.waitForChanges(); + + const el: HTMLElement = page.root as unknown as HTMLElement; + + expect(el.textContent).toEqual('No template element found'); + }); + + it('sets about and uri attributes on children', async () => { + const page = await newSpecPage({ + components: [PosList], + html: ` + + + `, + }); + await page.rootInstance.receiveResource({ + relations: () => [ + { + predicate: 'http://schema.org/video', + label: 'url', + uris: ['https://video.test/video-1', 'https://video.test/video-2'], + }, + ], + }); + await page.waitForChanges(); + + const el: HTMLElement = page.root as unknown as HTMLElement; + const resources = el.querySelectorAll('pos-resource'); + + expect(resources[0]?.getAttribute('about')).toEqual('https://video.test/video-1'); + expect(resources[1]?.getAttribute('about')).toEqual('https://video.test/video-2'); + expect(resources[0]?.getAttribute('uri')).toEqual('https://video.test/video-1'); + expect(resources[1]?.getAttribute('uri')).toEqual('https://video.test/video-2'); + }); + + it('sets lazy attribute on children if fetch is not present', async () => { + const page = await newSpecPage({ + components: [PosList], + html: ` + + + `, + }); + await page.rootInstance.receiveResource({ + relations: () => [ + { + predicate: 'http://schema.org/video', + label: 'url', + uris: ['https://video.test/video-1'], + }, + ], + }); + await page.waitForChanges(); + + const el: HTMLElement = page.root as unknown as HTMLElement; + + expect(el.querySelector('pos-resource')?.getAttribute('lazy')).toEqual(''); + }); + + it('does not set lazy attribute on children if fetch is present', async () => { + const page = await newSpecPage({ + components: [PosList], + html: ` + + + `, + }); + await page.rootInstance.receiveResource({ + relations: () => [ + { + predicate: 'http://schema.org/video', + label: 'url', + uris: ['https://video.test/video-1'], + }, + ], + }); + await page.waitForChanges(); + + const el: HTMLElement = page.root as unknown as HTMLElement; + expect(el.querySelector('pos-resource')?.getAttribute('lazy')).toEqual(null); + }); +}); diff --git a/elements/src/components/pos-list/pos-list.tsx b/elements/src/components/pos-list/pos-list.tsx new file mode 100644 index 00000000..0d32971f --- /dev/null +++ b/elements/src/components/pos-list/pos-list.tsx @@ -0,0 +1,50 @@ +import { Thing } from '@pod-os/core'; +import { Component, Element, Event, h, Prop, State } from '@stencil/core'; +import { ResourceAware, ResourceEventEmitter, subscribeResource } from '../events/ResourceAware'; + +@Component({ + tag: 'pos-list', + shadow: false, +}) +export class PosList implements ResourceAware { + /** + * URI of the predicate to follow + */ + @Prop() rel: string; + /** + * Whether listed resources should be fetched before being displayed + */ + @Prop() fetch: boolean = false; + + @Element() host: HTMLElement; + @State() error: string = null; + @State() resource: Thing; + @State() items: string[] = []; + @State() templateString: string; + + @Event({ eventName: 'pod-os:resource' }) + subscribeResource: ResourceEventEmitter; + + componentWillLoad() { + subscribeResource(this); + const templateElement = this.host.querySelector('template'); + if (templateElement == null) { + this.error = 'No template element found'; + } else { + this.templateString = templateElement.innerHTML; + } + } + + receiveResource = (resource: Thing) => { + this.items = []; + if (this.rel) this.items = resource.relations(this.rel).flatMap(relation => relation.uris); + }; + + render() { + if (this.error) return this.error; + const elems = this.items.map(it => ( + + )); + return this.items.length > 0 ? elems : null; + } +} diff --git a/storybook/stories/6a_pos-list.stories.mdx b/storybook/stories/6a_pos-list.stories.mdx new file mode 100644 index 00000000..27c00d02 --- /dev/null +++ b/storybook/stories/6a_pos-list.stories.mdx @@ -0,0 +1,30 @@ +import { html } from "lit-html"; + +import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; + + + +## pos-list + +Renders things related to a resource with a custom template. The related +resources can optionally be fetched before rendering. + + + + {({ uri, rel, fetch }) => html` + The template is repeated for each item: + + + + + + `} + + diff --git a/storybook/stories/composition/2_list-composition.stories.mdx b/storybook/stories/composition/2_list-composition.stories.mdx new file mode 100644 index 00000000..edcf3164 --- /dev/null +++ b/storybook/stories/composition/2_list-composition.stories.mdx @@ -0,0 +1,37 @@ +import { html } from "lit-html"; + +import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; + + + +## pos-list composition + +This is an example of how you can use `` element to list things +related to a resource with a custom display. + + + + {({ uri }) => html` +Skills: + +
    + + + +
+
+ `} +
+