Skip to content
Merged
1 change: 1 addition & 0 deletions core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ and this project adheres to
- [`Store.anyValue`]((https://pod-os.org/reference/core/classes/Store/#anyValue)) returns the value of any one RDF/JS term matching the first wildcard in the provided quad pattern
- [`Store.profileQuery`]((https://pod-os.org/reference/core/classes/Store/#profileQuery)) creates a [query](https://solid-contrib.github.io/data-modules/rdflib-utils/classes/index.ProfileQuery.html) to fetch information from a user's profile document
- [`Store.preferencesQuery`]((https://pod-os.org/reference/core/classes/Store/#preferencesQuery)) creates a [query](https://solid-contrib.github.io/data-modules/rdflib-utils/classes/index.PreferencesQuery.html) to fetch information from a user's preferences file
- [`LdpContainer.observeContains`](https://pod-os.org/reference/core/classes/ldpcontainer/#observeContains) pushes new array when `LdpContainer.contains` changes.

## 0.25.0

Expand Down
132 changes: 130 additions & 2 deletions core/src/ldp-container/LdpContainer.contains.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { graph, IndexedFormula, sym } from "rdflib";
import { graph, IndexedFormula, quad, sym } from "rdflib";
import { PodOsSession } from "../authentication";
import { LdpContainer } from "./LdpContainer";
import { ContainerContent, LdpContainer } from "./LdpContainer";
import { Store } from "../Store";
import { Observable, Subscription } from "rxjs";

describe("LDP container", () => {
describe("contains", () => {
Expand Down Expand Up @@ -86,4 +87,131 @@ describe("LDP container", () => {
]);
});
});

describe("observeContains", () => {
jest.useFakeTimers();
let internalStore: IndexedFormula,
store: Store,
subscriber: jest.Mock,
subscription: Subscription,
container: LdpContainer,
observable: Observable<ContainerContent[]>;

beforeEach(() => {
internalStore = graph();
internalStore.add(
sym("https://pod.test/container/"),
sym("http://www.w3.org/ns/ldp#contains"),
sym("https://pod.test/container/file"),
sym("https://pod.test/container/"),
);
store = new Store(
{} as PodOsSession,
undefined,
undefined,
internalStore,
);
subscriber = jest.fn();
container = new LdpContainer("https://pod.test/container/", store);
observable = container.observeContains();
subscription = observable.subscribe(subscriber);
});

//To avoid memory leak
afterEach(() => {
subscription.unsubscribe();
});

it("pushes existing values immediately", () => {
expect(subscriber).toHaveBeenCalledTimes(1);
expect(subscriber.mock.calls).toEqual([
[
[
{
uri: "https://pod.test/container/file",
name: "file",
},
],
],
]);
});

it("pushes new values once per group of changes until unsubcribe, ignoring irrelevant changes", () => {
const containsSpy = jest.spyOn(container, "contains");
internalStore.removeDocument(sym("https://pod.test/container/"));
internalStore.addAll([
quad(
sym("https://pod.test/container/"),
sym("http://www.w3.org/ns/ldp#contains"),
sym("https://pod.test/container/file-1"),
sym("https://pod.test/container/"),
),
quad(
sym("https://pod.test/container/"),
sym("http://www.w3.org/ns/ldp#contains"),
sym("https://pod.test/container/file-2"),
sym("https://pod.test/container/"),
),
quad(
sym("http://recipe.test/2"),
sym("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
sym("http://recipe.test/RecipeClass"),
),
]);
jest.advanceTimersByTime(255);
// Note: subscriber counted initial push but containsSpy didn't
expect(subscriber).toHaveBeenCalledTimes(2);
expect(containsSpy).toHaveBeenCalledTimes(1);
// Irrelevant as in another document
internalStore.add(
sym("https://pod.test/container/"),
sym("http://www.w3.org/ns/ldp#contains"),
sym("https://pod.test/container/injected-file"),
sym("https://pod.test/other-container/"),
);
// Irrelevant with different predicate
internalStore.add(
sym("https://pod.test/container/"),
sym("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
sym("http://www.w3.org/ns/ldp#Container"),
sym("https://pod.test/container/"),
);
jest.advanceTimersByTime(255);
expect(subscriber).toHaveBeenCalledTimes(2);
expect(containsSpy).toHaveBeenCalledTimes(1);
expect(subscriber.mock.calls).toEqual([
[
[
{
uri: "https://pod.test/container/file",
name: "file",
},
],
],
[
[
{
uri: "https://pod.test/container/file-1",
name: "file-1",
},
{
uri: "https://pod.test/container/file-2",
name: "file-2",
},
],
],
]);

// Stop listening to ignore future changes
subscription.unsubscribe();
internalStore.add(
sym("https://pod.test/container/"),
sym("http://www.w3.org/ns/ldp#contains"),
sym("https://pod.test/container/file-ignored"),
sym("https://pod.test/container/"),
);
expect(subscriber).toHaveBeenCalledTimes(2);
expect(containsSpy).toHaveBeenCalledTimes(1);
});
});
});
24 changes: 24 additions & 0 deletions core/src/ldp-container/LdpContainer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { sym } from "rdflib";
import { labelFromUri, Thing } from "../thing";
import { Store } from "../Store";
import { debounceTime, filter, map, merge, Observable, startWith } from "rxjs";

export interface ContainerContent {
uri: string;
Expand All @@ -15,6 +16,11 @@ export class LdpContainer extends Thing {
super(uri, store, editable);
}

/**
* Resources that the LDP Container contains
*
* @returns Array of objects with uri and name
*/
contains(): ContainerContent[] {
const contains = this.store.statementsMatching(
sym(this.uri),
Expand All @@ -27,4 +33,22 @@ export class LdpContainer extends Thing {
name: labelFromUri(content.object.value),
}));
}

/**
* Observe changes to the resources that the LDP Container contains
*
* @returns RxJS Observable that pushes a new contains() array when it changes
*/
observeContains(): Observable<ContainerContent[]> {
return merge(this.store.additions$, this.store.removals$).pipe(
filter(
(quad) =>
quad.graph.value == this.uri &&
Copy link
Contributor

Choose a reason for hiding this comment

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

question: Why graph and not subject?

Copy link
Contributor

Choose a reason for hiding this comment

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

thought: i wonder if the merge, pipe, filter pattern should be a convenience function on store. I guess we will need it a lot

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The graph limits changes to this container, i.e. ldp:contains triples in other documents won't trigger. In principle this minimises some triple injection issues, though in practice I was simply trying to find an efficient filter.
I'll add a test for it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm still expecting that we might want to reuse specific filters - at the moment a new observable is created for every call.
I don't want to prematurely optimise for that though - I think we can refactor with some performance oriented tests later.
If we end up doing that, it'll be more than just a convenience function.

quad.predicate.value == "http://www.w3.org/ns/ldp#contains",
),
debounceTime(250),
map(() => this.contains()),
startWith(this.contains()),
);
}
}
6 changes: 5 additions & 1 deletion elements/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- [pos-switch](https://pod-os.org/reference/elements/components/pos-switch/) and [pos-case](https://pod-os.org/reference/elements/components/pos-case/) : New components for conditional rendering
- [pos-share](https://pod-os.org/reference/elements/components/pos-share/)
- provides the means to share a resource with other apps or by copying its URI
^

### Changed

- [pos-container-contents](https://pod-os.org/reference/elements/components/pos-container-contents/): Now reactively updates when changes are pushed by [LdpContainer.observeContains](https://pod-os.org/reference/core/classes/ldpcontainer/#observeContains)

## 0.36.0

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,24 @@ import { PosContainerContents } from './pos-container-contents';
import { Components, LdpContainer } from '../../components';
import PosCreateNewContainerItem = Components.PosCreateNewContainerItem;
import { pressKey } from '../../test/pressKey';
import { Subject } from 'rxjs';
import { ContainerContent, Thing } from '@pod-os/core';

describe('pos-container-contents', () => {
let container: LdpContainer, observed$: Subject<ContainerContent[]>, resource: Thing;
beforeEach(() => {
// Given a container
observed$ = new Subject<ContainerContent[]>();
container = {
observeContains: () => observed$,
} as unknown as LdpContainer;

// available as a resource
resource = {
assume: () => container,
} as unknown as Thing;
});

it('are empty initially', async () => {
const page = await newSpecPage({
components: [PosContainerContents],
Expand All @@ -28,16 +44,13 @@ describe('pos-container-contents', () => {
html: `<pos-container-contents />`,
supportsShadowDom: false,
});
await page.rootInstance.receiveResource({
assume: () => ({
contains: () => [
{
uri: 'https://pod.test/container/file',
name: 'file',
},
],
}),
});
await page.rootInstance.receiveResource(resource);
observed$.next([
{
uri: 'https://pod.test/container/file',
name: 'file',
},
]);
await page.waitForChanges();

expect(page.root).toEqualHtml(`
Expand All @@ -60,11 +73,8 @@ describe('pos-container-contents', () => {
components: [PosContainerContents],
html: `<pos-container-contents />`,
});
await page.rootInstance.receiveResource({
assume: () => ({
contains: () => [],
}),
});
await page.rootInstance.receiveResource(resource);
observed$.next([]);
await page.waitForChanges();

expect(page.root).toEqualHtml(`<pos-container-contents>
Expand All @@ -84,24 +94,21 @@ describe('pos-container-contents', () => {
html: `<pos-container-contents />`,
supportsShadowDom: false,
});
await page.rootInstance.receiveResource({
assume: () => ({
contains: () => [
{
uri: 'https://pod.test/container/file',
name: 'file',
},
{
uri: 'https://pod.test/container/subdir/',
name: 'subdir',
},
{
uri: 'https://pod.test/container/a-file-on-top-of-the-list',
name: 'a-file-on-top-of-the-list',
},
],
}),
});
await page.rootInstance.receiveResource(resource);
observed$.next([
{
uri: 'https://pod.test/container/file',
name: 'file',
},
{
uri: 'https://pod.test/container/subdir/',
name: 'subdir',
},
{
uri: 'https://pod.test/container/a-file-on-top-of-the-list',
name: 'a-file-on-top-of-the-list',
},
]);
await page.waitForChanges();

expect(page.root).toEqualHtml(`
Expand Down Expand Up @@ -134,24 +141,75 @@ describe('pos-container-contents', () => {
`);
});

it('re-renders when container contents in store change', async () => {
const page = await newSpecPage({
components: [PosContainerContents],
html: `<pos-container-contents />`,
supportsShadowDom: false,
});

await page.rootInstance.receiveResource(resource);

observed$.next([
{
uri: 'https://pod.test/container/file',
name: 'file',
},
]);
await page.waitForChanges();

expect(page.root).toEqualHtml(`
<pos-container-contents>
<pos-container-toolbar></pos-container-toolbar>
<ul aria-label="Container contents">
<li>
<pos-resource lazy="" uri="https://pod.test/container/file">
<pos-container-item>
file
</pos-container-item>
</pos-resource>
</li>
</ul>
</pos-container-contents>`);

observed$.next([
{
uri: 'https://pod.test/container/file-1',
name: 'file-1',
},
]);
await page.waitForChanges();

expect(page.root).toEqualHtml(`
<pos-container-contents>
<pos-container-toolbar></pos-container-toolbar>
<ul aria-label="Container contents">
<li>
<pos-resource lazy="" uri="https://pod.test/container/file-1">
<pos-container-item>
file-1
</pos-container-item>
</pos-resource>
</li>
</ul>
</pos-container-contents>`);
});

describe('new files and folders', () => {
let page;
let container: LdpContainer;
let page: any;
beforeEach(async () => {
// Given a page with container contents
// and given a page with container contents
page = await newSpecPage({
components: [PosContainerContents],
html: `<pos-container-contents />`,
supportsShadowDom: false,
});

// and a container resource is available
container = {
contains: () => [],
} as LdpContainer;
await page.rootInstance.receiveResource({
assume: () => container,
});
await page.rootInstance.receiveResource(resource);

// as well as (empty) container contents
observed$.next([]);
await page.waitForChanges();
});

Expand Down
Loading