Added a new library for solid-react

main
Jackson Morgan 6 months ago
parent 3b4efda881
commit c69fef3070
  1. 0
      packages/react/test/Solid-Integration.test.tsx
  2. 3
      packages/solid-react/.eslintrc
  3. 1
      packages/solid-react/.gitignore
  4. 21
      packages/solid-react/LICENSE.txt
  5. 138
      packages/solid-react/README.md
  6. 6
      packages/solid-react/jest.config.js
  7. 2
      packages/solid-react/jest.setup.ts
  8. 56
      packages/solid-react/package.json
  9. 97
      packages/solid-react/src/BrowserSolidLdoProvider.tsx
  10. 26
      packages/solid-react/src/SolidAuthContext.ts
  11. 55
      packages/solid-react/src/SolidLdoProvider.tsx
  12. 62
      packages/solid-react/src/UnauthenticatedSolidLdoProvider.tsx
  13. 12
      packages/solid-react/src/index.ts
  14. 84
      packages/solid-react/src/useLdoMethods.ts
  15. 21
      packages/solid-react/src/useMatchObject.ts
  16. 21
      packages/solid-react/src/useMatchSubject.ts
  17. 114
      packages/solid-react/src/useResource.ts
  18. 31
      packages/solid-react/src/useRootContainer.ts
  19. 30
      packages/solid-react/src/useSubject.ts
  20. 52
      packages/solid-react/src/useSubscribeToResource.ts
  21. 61
      packages/solid-react/src/util/TrackingProxyContext.ts
  22. 56
      packages/solid-react/src/util/TrackingSetProxy.ts
  23. 43
      packages/solid-react/src/util/TrackingSubjectProxy.ts
  24. 55
      packages/solid-react/src/util/useTrackingProxy.ts
  25. 32
      packages/solid-react/test/.ldo/post.context.ts
  26. 155
      packages/solid-react/test/.ldo/post.schema.ts
  27. 19
      packages/solid-react/test/.ldo/post.shapeTypes.ts
  28. 45
      packages/solid-react/test/.ldo/post.typings.ts
  29. 645
      packages/solid-react/test/Solid-Integration.test.tsx
  30. 113
      packages/solid-react/test/setUpServer.ts
  31. 52
      packages/solid-react/test/test-server/configs/components-config/unauthenticatedServer.json
  32. 9
      packages/solid-react/test/test-server/configs/solid-css-seed.json
  33. 13
      packages/solid-react/test/test-server/configs/template/wac/.acl.hbs
  34. 19
      packages/solid-react/test/test-server/configs/template/wac/profile/card.acl.hbs
  35. 7
      packages/solid-react/test/test-server/runServer.ts
  36. 39
      packages/solid-react/test/test-server/solidServer.helper.ts
  37. 8
      packages/solid-react/tsconfig.build.json

@ -0,0 +1,3 @@
{
"extends": ["../../.eslintrc"]
}

@ -0,0 +1 @@
test/test-server/data

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Jackson Morgan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -0,0 +1,138 @@
# @ldo/solid-react
`@ldo/solid-react` provides tool and hooks for easily building Solid applications using react.
## Guide
A full walkthrough for using the `@ldo/solid` library can be found in the [For Solid + React Guide](https://ldo.js.org/latest/guides/solid_react/)
## Installation
Navigate into your project's root folder and run the following command:
```
cd my_project/
npx run @ldo/cli init
```
Now install the @ldo/solid library
```
npm i @ldo/solid @ldo/solid-react
```
<details>
<summary>
Manual Installation
</summary>
If you already have generated ShapeTypes, you may install the `@ldo/ldo` and `@ldo/solid` libraries independently.
```
npm i @ldo/ldo @ldo/solid @ldo/solid-react
```
</details>
## Simple Example
Below is a simple example of @ldo/solid-react in a real use-case. Assume that a ShapeType was previously generated and placed at `./.ldo/solidProfile.shapeTypess`.
```typescript
import type { FunctionComponent } from "react";
import React, { useCallback } from "react";
import {
BrowserSolidLdoProvider,
useResource,
useSolidAuth,
useSubject,
} from "@ldo/solid-react";
import { SolidProfileShapeShapeType } from "./.ldo/solidProfile.shapeTypes";
import { changeData, commitData } from "@ldo/solid";
// The base component for the app
const App: FunctionComponent = () => {
return (
/* The application should be surrounded with the BrowserSolidLdoProvider
this will set up all the underlying infrastructure for the application */
<BrowserSolidLdoProvider>
<Login />
</BrowserSolidLdoProvider>
);
};
// A component that handles login
const Login: FunctionComponent = () => {
// Get login information using the "useSolidAuth" hook
const { login, logout, session } = useSolidAuth();
const onLogin = useCallback(() => {
const issuer = prompt("What is your Solid IDP?");
// Call the "login" function to initiate login
if (issuer) login(issuer);
}, []);
// You can use session.isLoggedIn to check if the user is logged in
if (session.isLoggedIn) {
return (
<div>
{/* Get the user's webId from session.webId */}
<p>Logged in as {session.webId}</p>
{/* Use the logout function to log out */}
<button onClick={logout}>Log Out</button>
<Profile />
</div>
);
}
return <button onClick={onLogin}>Log In</button>;
};
// Renders the name on the profile
const Profile: FunctionComponent = () => {
const { session } = useSolidAuth();
// With useResource, you can automatically fetch a resource
const resource = useResource(session.webId);
// With useSubject, you can extract data from that resource
const profile = useSubject(SolidProfileShapeShapeType, session.webId);
const onNameChange = useCallback(async (e) => {
// Ensure that the
if (!profile || !resource) return;
// Change data lets you create a new object to make changes to
const cProfile = changeData(profile, resource);
// Change the name
cProfile.name = e.target.value;
// Commit the data back to the Pod
await commitData(cProfile);
}, []);
return <input type="text" value={profile?.name} onChange={onNameChange} />;
};
export default App;
```
## API Details
Providers
- [BrowserSolidLdoProvider](https://ldo.js.org/latest/api/react/BrowserSolidLdoProvider/)
- [SolidLdoProvider](https://ldo.js.org/latest/api/react/SolidLdoProvider/)
Hooks
- [useLdo](https://ldo.js.org/latest/api/react/useLdo/)
- [useResource](https://ldo.js.org/latest/api/react/useResource/)
- [useRootContainer](https://ldo.js.org/latest/api/react/useRootContainer/)
- [useSolidAuth](https://ldo.js.org/latest/api/react/useSolidAuth/)
- [useSubject](https://ldo.js.org/latest/api/react/useSubject/)
- [useMatchSubject](https://ldo.js.org/latest/api/react/useMatchSubject/)
- [useMatchObject](https://ldo.js.org/latest/api/react/useMatchSubject/)
- [useSubscribeToResource](https://ldo.js.org/latest/api/react/useMatchSubject/)
## Sponsorship
This project was made possible by a grant from NGI Zero Entrust via nlnet. Learn more on the [NLnet project page](https://nlnet.nl/project/SolidUsableApps/).
[<img src="https://nlnet.nl/logo/banner.png" alt="nlnet foundation logo" width="300" />](https://nlnet.nl/)
[<img src="https://nlnet.nl/image/logos/NGI0Entrust_tag.svg" alt="NGI Zero Entrust Logo" width="300" />](https://nlnet.nl/)
## Liscense
MIT

@ -0,0 +1,6 @@
const sharedConfig = require("../../jest.config.js");
module.exports = {
...sharedConfig,
rootDir: "./",
testEnvironment: "jsdom",
};

@ -0,0 +1,2 @@
import "@inrupt/jest-jsdom-polyfills";
globalThis.fetch = async () => new Response();

@ -0,0 +1,56 @@
{
"name": "@ldo/solid-react",
"version": "1.0.0-alpha.1",
"description": "A React library for LDO and Solid",
"main": "dist/index.js",
"scripts": {
"build": "tsc --project tsconfig.build.json",
"watch": "tsc --watch",
"test": "npm run test:integration",
"test:watch": "jest --watch",
"prepublishOnly": "npm run test && npm run build",
"build:ldo": "ldo build --input src/shapes --output src/ldo",
"lint": "eslint src/** --fix --no-error-on-unmatched-pattern",
"test:integration": "start-server-and-test start-test-server http://localhost:3002 start-integration-test",
"start-test-server": "ts-node ./test/test-server/runServer.ts",
"start-integration-test": "jest --coverage"
},
"repository": {
"type": "git",
"url": "git+https://github.com/o-development/ldobjects.git"
},
"author": "Jackson Morgan",
"license": "MIT",
"bugs": {
"url": "https://github.com/o-development/ldobjects/issues"
},
"homepage": "https://github.com/o-development/ldobjects/tree/main/packages/solid-react#readme",
"devDependencies": {
"@ldo/rdf-utils": "^1.0.0-alpha.1",
"@rdfjs/types": "^1.0.1",
"@testing-library/react": "^14.1.2",
"@types/jest": "^27.0.3",
"jest-environment-jsdom": "^27.0.0",
"start-server-and-test": "^2.0.3",
"ts-jest": "^27.1.2",
"ts-node": "^10.9.2"
},
"dependencies": {
"@inrupt/solid-client-authn-browser": "^2.0.0",
"@ldo/dataset": "^1.0.0-alpha.1",
"@ldo/jsonld-dataset-proxy": "^1.0.0-alpha.1",
"@ldo/ldo": "^1.0.0-alpha.1",
"@ldo/connected": "^1.0.0-alpha.1",
"@ldo/subscribable-dataset": "^1.0.0-alpha.1",
"@rdfjs/data-model": "^1.2.0",
"cross-fetch": "^3.1.6"
},
"files": [
"dist",
"src"
],
"publishConfig": {
"access": "public"
},
"gitHead": "0287cd6371f06630763568dec5e41653f7b8583e"
}

@ -0,0 +1,97 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import type { FunctionComponent, PropsWithChildren } from "react";
import type { LoginOptions, SessionInfo } from "./SolidAuthContext";
import { SolidAuthContext } from "./SolidAuthContext";
import {
getDefaultSession,
handleIncomingRedirect,
login as libraryLogin,
logout as libraryLogout,
fetch as libraryFetch,
} from "@inrupt/solid-client-authn-browser";
import { SolidLdoProvider } from "./SolidLdoProvider";
const PRE_REDIRECT_URI = "PRE_REDIRECT_URI";
export const BrowserSolidLdoProvider: FunctionComponent<PropsWithChildren> = ({
children,
}) => {
const [session, setSession] = useState<SessionInfo>(getDefaultSession().info);
const [ranInitialAuthCheck, setRanInitialAuthCheck] = useState(false);
const runInitialAuthCheck = useCallback(async () => {
if (!window.localStorage.getItem(PRE_REDIRECT_URI)) {
window.localStorage.setItem(PRE_REDIRECT_URI, window.location.href);
}
await handleIncomingRedirect({
restorePreviousSession: true,
});
// Set timout to ensure this happens after the redirect
setTimeout(() => {
setSession({ ...getDefaultSession().info });
window.history.replaceState(
{},
"",
window.localStorage.getItem(PRE_REDIRECT_URI),
);
window.localStorage.removeItem(PRE_REDIRECT_URI);
setRanInitialAuthCheck(true);
}, 0);
}, []);
const login = useCallback(async (issuer: string, options?: LoginOptions) => {
const cleanUrl = new URL(window.location.href);
cleanUrl.hash = "";
cleanUrl.search = "";
const fullOptions = {
redirectUrl: cleanUrl.toString(),
clientName: "Solid App",
oidcIssuer: issuer,
...options,
};
window.localStorage.setItem(PRE_REDIRECT_URI, window.location.href);
await libraryLogin(fullOptions);
setSession({ ...getDefaultSession().info });
}, []);
const logout = useCallback(async () => {
await libraryLogout();
setSession({ ...getDefaultSession().info });
}, []);
const signUp = useCallback(
async (issuer: string, options?: LoginOptions) => {
// The typings on @inrupt/solid-client-authn-core have not yet been updated
// TODO: remove this ts-ignore when they are updated.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return login(issuer, { ...options, prompt: "create" });
},
[login],
);
useEffect(() => {
runInitialAuthCheck();
}, []);
const solidAuthFunctions = useMemo(
() => ({
runInitialAuthCheck,
login,
logout,
signUp,
session,
ranInitialAuthCheck,
fetch: libraryFetch,
}),
[login, logout, ranInitialAuthCheck, runInitialAuthCheck, session, signUp],
);
return (
<SolidAuthContext.Provider value={solidAuthFunctions}>
<SolidLdoProvider>{children}</SolidLdoProvider>
</SolidAuthContext.Provider>
);
};

@ -0,0 +1,26 @@
import type {
ISessionInfo,
ILoginInputOptions,
} from "@inrupt/solid-client-authn-core";
import { createContext, useContext } from "react";
export type SessionInfo = ISessionInfo;
export type LoginOptions = ILoginInputOptions;
export interface SolidAuthFunctions {
login: (issuer: string, loginOptions?: LoginOptions) => Promise<void>;
logout: () => Promise<void>;
signUp: (issuer: string, loginOptions?: LoginOptions) => Promise<void>;
fetch: typeof fetch;
session: SessionInfo;
ranInitialAuthCheck: boolean;
}
// There is no initial value for this context. It will be given in the provider
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
export const SolidAuthContext = createContext<SolidAuthFunctions>(undefined);
export function useSolidAuth(): SolidAuthFunctions {
return useContext(SolidAuthContext);
}

@ -0,0 +1,55 @@
import React, { createContext, useContext } from "react";
import {
useMemo,
type FunctionComponent,
type PropsWithChildren,
useEffect,
} from "react";
import { useSolidAuth } from "./SolidAuthContext";
import type { SolidLdoDataset } from "@ldo/solid";
import { createSolidLdoDataset } from "@ldo/solid";
import type { UseLdoMethods } from "./useLdoMethods";
import { createUseLdoMethods } from "./useLdoMethods";
export const SolidLdoReactContext =
// This will be set in the provider
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
createContext<UseLdoMethods>(undefined);
export function useLdo(): UseLdoMethods {
return useContext(SolidLdoReactContext);
}
export interface SolidLdoProviderProps extends PropsWithChildren {}
export const SolidLdoProvider: FunctionComponent<SolidLdoProviderProps> = ({
children,
}) => {
const { fetch } = useSolidAuth();
// Initialize storeDependencies before render
const solidLdoDataset: SolidLdoDataset = useMemo(() => {
const ldoDataset = createSolidLdoDataset({
fetch,
});
ldoDataset.setMaxListeners(1000);
return ldoDataset;
}, []);
// Keep context in sync with props
useEffect(() => {
solidLdoDataset.context.fetch = fetch;
}, [fetch]);
const value: UseLdoMethods = useMemo(
() => createUseLdoMethods(solidLdoDataset),
[solidLdoDataset],
);
return (
<SolidLdoReactContext.Provider value={value}>
{children}
</SolidLdoReactContext.Provider>
);
};

@ -0,0 +1,62 @@
/* istanbul ignore file */
import React, { useCallback, useMemo } from "react";
import type { FunctionComponent, PropsWithChildren } from "react";
import type { LoginOptions, SessionInfo } from "./SolidAuthContext";
import { SolidAuthContext } from "./SolidAuthContext";
import libraryFetch from "cross-fetch";
import { SolidLdoProvider } from "./SolidLdoProvider";
const DUMMY_SESSION: SessionInfo = {
isLoggedIn: false,
webId: undefined,
clientAppId: undefined,
sessionId: "no_session",
expirationDate: undefined,
};
export const UnauthenticatedSolidLdoProvider: FunctionComponent<
PropsWithChildren
> = ({ children }) => {
const login = useCallback(
async (_issuer: string, _options?: LoginOptions) => {
throw new Error(
"login is not available for a UnauthenticatedSolidLdoProvider",
);
},
[],
);
const logout = useCallback(async () => {
throw new Error(
"logout is not available for a UnauthenticatedSolidLdoProvider",
);
}, []);
const signUp = useCallback(
async (_issuer: string, _options?: LoginOptions) => {
throw new Error(
"signUp is not available for a UnauthenticatedSolidLdoProvider",
);
},
[],
);
const solidAuthFunctions = useMemo(
() => ({
runInitialAuthCheck: () => {},
login,
logout,
signUp,
session: DUMMY_SESSION,
ranInitialAuthCheck: true,
fetch: libraryFetch,
}),
[login, logout, signUp],
);
return (
<SolidAuthContext.Provider value={solidAuthFunctions}>
<SolidLdoProvider>{children}</SolidLdoProvider>
</SolidAuthContext.Provider>
);
};

@ -0,0 +1,12 @@
export * from "./BrowserSolidLdoProvider";
export * from "./UnauthenticatedSolidLdoProvider";
export * from "./SolidAuthContext";
export { useLdo } from "./SolidLdoProvider";
// hooks
export * from "./useResource";
export * from "./useSubject";
export * from "./useMatchSubject";
export * from "./useMatchObject";
export * from "./useRootContainer";

@ -0,0 +1,84 @@
import type { LdoBase, ShapeType } from "@ldo/ldo";
import type { SubjectNode } from "@ldo/rdf-utils";
import type {
Resource,
SolidLdoDataset,
SolidLdoTransactionDataset,
} from "@ldo/solid";
import { changeData, commitData } from "@ldo/solid";
export interface UseLdoMethods {
dataset: SolidLdoDataset;
getResource: SolidLdoDataset["getResource"];
getSubject<Type extends LdoBase>(
shapeType: ShapeType<Type>,
subject: string | SubjectNode,
): Type;
createData<Type extends LdoBase>(
shapeType: ShapeType<Type>,
subject: string | SubjectNode,
resource: Resource,
...additionalResources: Resource[]
): Type;
changeData<Type extends LdoBase>(
input: Type,
resource: Resource,
...additionalResources: Resource[]
): Type;
commitData(
input: LdoBase,
): ReturnType<SolidLdoTransactionDataset["commitToPod"]>;
}
export function createUseLdoMethods(dataset: SolidLdoDataset): UseLdoMethods {
return {
dataset: dataset,
/**
* Gets a resource
*/
getResource: dataset.getResource.bind(dataset),
/**
* Returns a Linked Data Object for a subject
* @param shapeType The shape type for the data
* @param subject Subject Node
* @returns A Linked Data Object
*/
getSubject<Type extends LdoBase>(
shapeType: ShapeType<Type>,
subject: string | SubjectNode,
): Type {
return dataset.usingType(shapeType).fromSubject(subject);
},
/**
* Begins tracking changes to eventually commit for a new subject
* @param shapeType The shape type that defines the created data
* @param subject The RDF subject for a Linked Data Object
* @param resources Any number of resources to which this data should be written
* @returns A Linked Data Object to modify and commit
*/
createData<Type extends LdoBase>(
shapeType: ShapeType<Type>,
subject: string | SubjectNode,
resource: Resource,
...additionalResources: Resource[]
): Type {
return dataset.createData(
shapeType,
subject,
resource,
...additionalResources,
);
},
/**
* Begins tracking changes to eventually commit
* @param input A linked data object to track changes on
* @param resources
*/
changeData: changeData,
/**
* Commits the transaction to the global dataset, syncing all subscribing
* components and Solid Pods
*/
commitData: commitData,
};
}

@ -0,0 +1,21 @@
import type { LdoBase, LdSet, ShapeType } from "@ldo/ldo";
import type { QuadMatch } from "@ldo/rdf-utils";
import type { LdoBuilder } from "@ldo/ldo";
import { useCallback } from "react";
import { useTrackingProxy } from "./util/useTrackingProxy";
export function useMatchObject<Type extends LdoBase>(
shapeType: ShapeType<Type>,
subject?: QuadMatch[0] | string,
predicate?: QuadMatch[1] | string,
graph?: QuadMatch[3] | string,
): LdSet<Type> {
const matchObject = useCallback(
(builder: LdoBuilder<Type>) => {
return builder.matchObject(subject, predicate, graph);
},
[subject, predicate, graph],
);
return useTrackingProxy(shapeType, matchObject);
}

@ -0,0 +1,21 @@
import type { LdoBase, LdSet, ShapeType } from "@ldo/ldo";
import type { QuadMatch } from "@ldo/rdf-utils";
import type { LdoBuilder } from "@ldo/ldo";
import { useCallback } from "react";
import { useTrackingProxy } from "./util/useTrackingProxy";
export function useMatchSubject<Type extends LdoBase>(
shapeType: ShapeType<Type>,
predicate?: QuadMatch[1] | string,
object?: QuadMatch[2] | string,
graph?: QuadMatch[3] | string,
): LdSet<Type> {
const matchSubject = useCallback(
(builder: LdoBuilder<Type>) => {
return builder.matchSubject(predicate, object, graph);
},
[predicate, object, graph],
);
return useTrackingProxy(shapeType, matchSubject);
}

@ -0,0 +1,114 @@
import { useMemo, useEffect, useRef, useState, useCallback } from "react";
import type {
Container,
ContainerUri,
LeafUri,
Resource,
Leaf,
} from "@ldo/solid";
import { useLdo } from "./SolidLdoProvider";
export interface UseResourceOptions {
suppressInitialRead?: boolean;
reloadOnMount?: boolean;
subscribe?: boolean;
}
export function useResource(
uri: ContainerUri,
options?: UseResourceOptions,
): Container;
export function useResource(uri: LeafUri, options?: UseResourceOptions): Leaf;
export function useResource(
uri: string,
options?: UseResourceOptions,
): Leaf | Container;
export function useResource(
uri?: ContainerUri,
options?: UseResourceOptions,
): Container | undefined;
export function useResource(
uri?: LeafUri,
options?: UseResourceOptions,
): Leaf | undefined;
export function useResource(
uri?: string,
options?: UseResourceOptions,
): Leaf | Container | undefined;
export function useResource(
uri?: string,
options?: UseResourceOptions,
): Leaf | Container | undefined {
const { getResource } = useLdo();
const subscriptionIdRef = useRef<string | undefined>();
// Get the resource
const resource = useMemo(() => {
if (uri) {
const resource = getResource(uri);
// Run read operations if necissary
if (!options?.suppressInitialRead) {
if (options?.reloadOnMount) {
resource.read();
} else {
resource.readIfUnfetched();
}
}
return resource;
}
return undefined;
}, [getResource, uri]);
const [resourceRepresentation, setResourceRepresentation] =
useState(resource);
const pastResource = useRef<
{ resource?: Resource; callback: () => void } | undefined
>();
useEffect(() => {
if (options?.subscribe) {
resource
?.subscribeToNotifications()
.then((subscriptionId) => (subscriptionIdRef.current = subscriptionId));
} else if (subscriptionIdRef.current) {
resource?.unsubscribeFromNotifications(subscriptionIdRef.current);
}
return () => {
if (subscriptionIdRef.current)
resource?.unsubscribeFromNotifications(subscriptionIdRef.current);
};
}, [resource, options?.subscribe]);
// Callback function to force the react dom to reload.
const forceReload = useCallback(
// Wrap the resource in a proxy so it's techically a different object
() => {
if (resource) setResourceRepresentation(new Proxy(resource, {}));
},
[resource],
);
useEffect(() => {
// Remove listeners for the previous resource
if (pastResource.current?.resource) {
pastResource.current.resource.off(
"update",
pastResource.current.callback,
);
}
// Set a new past resource to the current resource
pastResource.current = { resource, callback: forceReload };
if (resource) {
// Add listener
resource.on("update", forceReload);
setResourceRepresentation(new Proxy(resource, {}));
// Unsubscribe on unmount
return () => {
resource.off("update", forceReload);
};
} else {
setResourceRepresentation(undefined);
}
}, [resource]);
return resourceRepresentation;
}

@ -0,0 +1,31 @@
import type { Container, ContainerUri } from "@ldo/solid";
import { useEffect, useState } from "react";
import type { UseResourceOptions } from "./useResource";
import { useResource } from "./useResource";
import { useLdo } from "./SolidLdoProvider";
export function useRootContainerFor(
uri?: string,
options?: UseResourceOptions,
): Container | undefined {
const { getResource } = useLdo();
const [rootContainerUri, setRootContainerUri] = useState<
ContainerUri | undefined
>(undefined);
useEffect(() => {
if (uri) {
const givenResource = getResource(uri);
givenResource.getRootContainer().then((result) => {
if (!result.isError) {
setRootContainerUri(result.uri);
}
});
} else {
setRootContainerUri(undefined);
}
}, [uri]);
return useResource(rootContainerUri, options);
}

@ -0,0 +1,30 @@
import type { SubjectNode } from "@ldo/rdf-utils";
import type { ShapeType } from "@ldo/ldo";
import type { LdoBuilder } from "@ldo/ldo";
import type { LdoBase } from "@ldo/ldo";
import { useCallback } from "react";
import { useTrackingProxy } from "./util/useTrackingProxy";
export function useSubject<Type extends LdoBase>(
shapeType: ShapeType<Type>,
subject: string | SubjectNode,
): Type;
export function useSubject<Type extends LdoBase>(
shapeType: ShapeType<Type>,
subject?: string | SubjectNode,
): Type | undefined;
export function useSubject<Type extends LdoBase>(
shapeType: ShapeType<Type>,
subject?: string | SubjectNode,
): Type | undefined {
const fromSubject = useCallback(
(builder: LdoBuilder<Type>) => {
if (!subject) return;
return builder.fromSubject(subject);
},
[subject],
);
return useTrackingProxy(shapeType, fromSubject);
}

@ -0,0 +1,52 @@
import { useLdo } from "./SolidLdoProvider";
import { useEffect, useRef } from "react";
export function useSubscribeToResource(...uris: string[]): void {
const { dataset } = useLdo();
const currentlySubscribed = useRef<Record<string, string>>({});
useEffect(() => {
const resources = uris.map((uri) => dataset.getResource(uri));
const previousSubscriptions = { ...currentlySubscribed.current };
Promise.all<void>(
resources.map(async (resource) => {
if (!previousSubscriptions[resource.uri]) {
// Prevent multiple triggers from created subscriptions while waiting
// for connection
currentlySubscribed.current[resource.uri] = "AWAITING";
// Read and subscribe
await resource.readIfUnfetched();
currentlySubscribed.current[resource.uri] =
await resource.subscribeToNotifications();
} else {
delete previousSubscriptions[resource.uri];
}
}),
).then(async () => {
// Unsubscribe from all remaining previous subscriptions
await Promise.all(
Object.entries(previousSubscriptions).map(
async ([resourceUri, subscriptionId]) => {
// Unsubscribe
delete currentlySubscribed.current[resourceUri];
const resource = dataset.getResource(resourceUri);
await resource.unsubscribeFromNotifications(subscriptionId);
},
),
);
});
}, [uris]);
// Cleanup Subscriptions
useEffect(() => {
return () => {
Promise.all(
Object.entries(currentlySubscribed.current).map(
async ([resourceUri, subscriptionId]) => {
const resource = dataset.getResource(resourceUri);
await resource.unsubscribeFromNotifications(subscriptionId);
},
),
);
};
}, []);
}

@ -0,0 +1,61 @@
import type {
ProxyContextOptions,
SubjectProxy,
SetProxy,
} from "@ldo/jsonld-dataset-proxy";
import { ProxyContext } from "@ldo/jsonld-dataset-proxy";
import type { QuadMatch } from "@ldo/rdf-utils";
import type { SubscribableDataset } from "@ldo/subscribable-dataset";
import type { BlankNode, NamedNode, Quad } from "@rdfjs/types";
import { createTrackingSubjectProxy } from "./TrackingSubjectProxy";
import { createTrackingSetProxy } from "./TrackingSetProxy";
/**
* @internal
* Options to be passed to the tracking proxy
*/
export interface TrackingProxyContextOptions extends ProxyContextOptions {
dataset: SubscribableDataset<Quad>;
}
/**
* @internal
* This proxy exists to ensure react components rerender at the right time. It
* keeps track of every key accessed in a Linked Data Object and only when the
* dataset is updated with that key does it rerender the react component.
*/
export class TrackingProxyContext extends ProxyContext {
private listener: () => void;
private subscribableDataset: SubscribableDataset<Quad>;
constructor(options: TrackingProxyContextOptions, listener: () => void) {
super(options);
this.subscribableDataset = options.dataset;
this.listener = listener;
}
// Adds the listener to the subscribable dataset while ensuring deduping of the listener
public addListener(eventName: QuadMatch) {
const listeners = this.subscribableDataset.listeners(eventName);
if (!listeners.includes(this.listener)) {
this.subscribableDataset.on(eventName, this.listener);
}
}
protected createNewSubjectProxy(node: NamedNode | BlankNode): SubjectProxy {
return createTrackingSubjectProxy(this, node);
}
protected createNewSetProxy(
quadMatch: QuadMatch,
isSubjectOriented?: boolean,
isLangStringSet?: boolean,
): SetProxy {
return createTrackingSetProxy(
this,
quadMatch,
isSubjectOriented,
isLangStringSet,
);
}
}

@ -0,0 +1,56 @@
import { createNewSetProxy, type SetProxy } from "@ldo/jsonld-dataset-proxy";
import type { TrackingProxyContext } from "./TrackingProxyContext";
import type { QuadMatch } from "@ldo/rdf-utils";
export function createTrackingSetProxy(
proxyContext: TrackingProxyContext,
quadMatch: QuadMatch,
isSubjectOriented?: boolean,
isLangStringSet?: boolean,
): SetProxy {
const baseSetProxy = createNewSetProxy(
quadMatch,
isSubjectOriented ?? false,
proxyContext,
isLangStringSet,
);
return new Proxy(baseSetProxy, {
get: (target: SetProxy, key: string | symbol, receiver) => {
if (trackingMethods.has(key)) {
proxyContext.addListener(quadMatch);
} else if (disallowedMethods.has(key)) {
console.warn(
"You've attempted to modify a value on a Linked Data Object from the useSubject, useMatchingSubject, or useMatchingObject hooks. These linked data objects should only be used to render data, not modify it. To modify data, use the `changeData` function.",
);
}
return Reflect.get(target, key, receiver);
},
});
}
const trackingMethods = new Set([
"has",
"size",
"entries",
"keys",
"values",
Symbol.iterator,
"every",
"every",
"some",
"forEach",
"map",
"reduce",
"toArray",
"toJSON",
"difference",
"intersection",
"isDisjointFrom",
"isSubsetOf",
"isSupersetOf",
"symmetricDifference",
"union",
]);
const disallowedMethods = new Set<string | symbol>(["add", "clear", "delete"]);

@ -0,0 +1,43 @@
import type { SubjectProxyTarget } from "@ldo/jsonld-dataset-proxy";
import {
createSubjectHandler,
type SubjectProxy,
} from "@ldo/jsonld-dataset-proxy";
import type { BlankNode, NamedNode } from "@rdfjs/types";
import type { TrackingProxyContext } from "./TrackingProxyContext";
import { namedNode } from "@rdfjs/data-model";
export function createTrackingSubjectProxy(
proxyContext: TrackingProxyContext,
node: NamedNode | BlankNode,
): SubjectProxy {
const baseHandler = createSubjectHandler(proxyContext);
const oldGetFunction = baseHandler.get;
const newGetFunction: ProxyHandler<SubjectProxyTarget>["get"] = (
target: SubjectProxyTarget,
key: string | symbol,
receiver,
) => {
const subject = target["@id"];
const rdfTypes = proxyContext.getRdfType(subject);
if (typeof key === "symbol") {
// Do Nothing
} else if (key === "@id") {
proxyContext.addListener([subject, null, null, null]);
} else if (!proxyContext.contextUtil.isSet(key, rdfTypes)) {
const predicate = namedNode(
proxyContext.contextUtil.keyToIri(key, rdfTypes),
);
proxyContext.addListener([subject, predicate, null, null]);
}
return oldGetFunction && oldGetFunction(target, key, receiver);
};
baseHandler.get = newGetFunction;
baseHandler.set = () => {
console.warn(
"You've attempted to set a value on a Linked Data Object from the useSubject, useMatchingSubject, or useMatchingObject hooks. These linked data objects should only be used to render data, not modify it. To modify data, use the `changeData` function.",
);
return true;
};
return new Proxy({ "@id": node }, baseHandler) as unknown as SubjectProxy;
}

@ -0,0 +1,55 @@
import {
ContextUtil,
JsonldDatasetProxyBuilder,
} from "@ldo/jsonld-dataset-proxy";
import { LdoBuilder } from "@ldo/ldo";
import type { LdoBase, ShapeType } from "@ldo/ldo";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TrackingProxyContext } from "./TrackingProxyContext";
import { defaultGraph } from "@rdfjs/data-model";
import { useLdo } from "../SolidLdoProvider";
export function useTrackingProxy<Type extends LdoBase, ReturnType>(
shapeType: ShapeType<Type>,
createLdo: (builder: LdoBuilder<Type>) => ReturnType,
): ReturnType {
const { dataset } = useLdo();
const [forceUpdateCounter, setForceUpdateCounter] = useState(0);
const forceUpdate = useCallback(
() => setForceUpdateCounter((val) => val + 1),
[],
);
// The main linked data object
const linkedDataObject = useMemo(() => {
// Remove all current subscriptions
dataset.removeListenerFromAllEvents(forceUpdate);
// Rebuild the LdoBuilder from scratch to inject TrackingProxyContext
const contextUtil = new ContextUtil(shapeType.context);
const proxyContext = new TrackingProxyContext(
{
dataset,
contextUtil,
writeGraphs: [defaultGraph()],
languageOrdering: ["none", "en", "other"],
},
forceUpdate,
);
const builder = new LdoBuilder(
new JsonldDatasetProxyBuilder(proxyContext),
shapeType,
);
return createLdo(builder);
}, [shapeType, dataset, forceUpdateCounter, forceUpdate, createLdo]);
useEffect(() => {
// Unregister force update listener upon unmount
return () => {
dataset.removeListenerFromAllEvents(forceUpdate);
};
}, [shapeType]);
return linkedDataObject;
}

@ -0,0 +1,32 @@
import { ContextDefinition } from "jsonld";
/**
* =============================================================================
* postContext: JSONLD Context for post
* =============================================================================
*/
export const postContext: ContextDefinition = {
type: {
"@id": "@type",
},
SocialMediaPosting: "http://schema.org/SocialMediaPosting",
CreativeWork: "http://schema.org/CreativeWork",
Thing: "http://schema.org/Thing",
articleBody: {
"@id": "http://schema.org/articleBody",
"@type": "http://www.w3.org/2001/XMLSchema#string",
},
uploadDate: {
"@id": "http://schema.org/uploadDate",
"@type": "http://www.w3.org/2001/XMLSchema#date",
},
image: {
"@id": "http://schema.org/image",
"@type": "@id",
},
publisher: {
"@id": "http://schema.org/publisher",
"@type": "@id",
"@container": "@set",
},
};

@ -0,0 +1,155 @@
import { Schema } from "shexj";
/**
* =============================================================================
* postSchema: ShexJ Schema for post
* =============================================================================
*/
export const postSchema: Schema = {
type: "Schema",
shapes: [
{
id: "https://example.com/PostSh",
type: "ShapeDecl",
shapeExpr: {
type: "Shape",
expression: {
type: "EachOf",
expressions: [
{
type: "TripleConstraint",
predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
valueExpr: {
type: "NodeConstraint",
values: [
"http://schema.org/SocialMediaPosting",
"http://schema.org/CreativeWork",
"http://schema.org/Thing",
],
},
},
{
type: "TripleConstraint",
predicate: "http://schema.org/articleBody",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#string",
},
min: 0,
max: 1,
annotations: [
{
type: "Annotation",
predicate: "http://www.w3.org/2000/01/rdf-schema#label",
object: {
value: "articleBody",
},
},
{
type: "Annotation",
predicate: "http://www.w3.org/2000/01/rdf-schema#comment",
object: {
value: "The actual body of the article. ",
},
},
],
},
{
type: "TripleConstraint",
predicate: "http://schema.org/uploadDate",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#date",
},
annotations: [
{
type: "Annotation",
predicate: "http://www.w3.org/2000/01/rdf-schema#label",
object: {
value: "uploadDate",
},
},
{
type: "Annotation",
predicate: "http://www.w3.org/2000/01/rdf-schema#comment",
object: {
value:
"Date when this media object was uploaded to this site.",
},
},
],
},
{
type: "TripleConstraint",
predicate: "http://schema.org/image",
valueExpr: {
type: "NodeConstraint",
nodeKind: "iri",
},
min: 0,
max: 1,
annotations: [
{
type: "Annotation",
predicate: "http://www.w3.org/2000/01/rdf-schema#label",
object: {
value: "image",
},
},
{
type: "Annotation",
predicate: "http://www.w3.org/2000/01/rdf-schema#comment",
object: {
value:
"A media object that encodes this CreativeWork. This property is a synonym for encoding.",
},
},
],
},
{
type: "TripleConstraint",
predicate: "http://schema.org/publisher",
valueExpr: {
type: "NodeConstraint",
nodeKind: "iri",
},
annotations: [
{
type: "Annotation",
predicate: "http://www.w3.org/2000/01/rdf-schema#label",
object: {
value: "publisher",
},
},
{
type: "Annotation",
predicate: "http://www.w3.org/2000/01/rdf-schema#comment",
object: {
value: "The publisher of the creative work.",
},
},
],
},
],
},
annotations: [
{
type: "Annotation",
predicate: "http://www.w3.org/2000/01/rdf-schema#label",
object: {
value: "SocialMediaPost",
},
},
{
type: "Annotation",
predicate: "http://www.w3.org/2000/01/rdf-schema#comment",
object: {
value:
"A post to a social media platform, including blog posts, tweets, Facebook posts, etc.",
},
},
],
},
},
],
};

@ -0,0 +1,19 @@
import { ShapeType } from "@ldo/ldo";
import { postSchema } from "./post.schema";
import { postContext } from "./post.context";
import { PostSh } from "./post.typings";
/**
* =============================================================================
* LDO ShapeTypes post
* =============================================================================
*/
/**
* PostSh ShapeType
*/
export const PostShShapeType: ShapeType<PostSh> = {
schema: postSchema,
shape: "https://example.com/PostSh",
context: postContext,
};

@ -0,0 +1,45 @@
import { LdSet, LdoJsonldContext } from "@ldo/ldo";
/**
* =============================================================================
* Typescript Typings for post
* =============================================================================
*/
/**
* PostSh Type
*/
export interface PostSh {
"@id"?: string;
"@context"?: LdoJsonldContext;
type:
| {
"@id": "SocialMediaPosting";
}
| {
"@id": "CreativeWork";
}
| {
"@id": "Thing";
};
/**
* The actual body of the article.
*/
articleBody?: string;
/**
* Date when this media object was uploaded to this site.
*/
uploadDate: string;
/**
* A media object that encodes this CreativeWork. This property is a synonym for encoding.
*/
image?: {
"@id": string;
};
/**
* The publisher of the creative work.
*/
publisher: LdSet<{
"@id": string;
}>;
}

@ -0,0 +1,645 @@
import React, { useCallback, useEffect, useState } from "react";
import type { FunctionComponent } from "react";
import { render, screen, fireEvent, act } from "@testing-library/react";
import {
SAMPLE_BINARY_URI,
SAMPLE_DATA_URI,
SERVER_DOMAIN,
setUpServer,
} from "./setUpServer";
import { UnauthenticatedSolidLdoProvider } from "../src/UnauthenticatedSolidLdoProvider";
import { useResource } from "../src/useResource";
import { useRootContainerFor } from "../src/useRootContainer";
import { useLdo } from "../src/SolidLdoProvider";
import { PostShShapeType } from "./.ldo/post.shapeTypes";
import type { PostSh } from "./.ldo/post.typings";
import { useSubject } from "../src/useSubject";
import { useMatchSubject } from "../src/useMatchSubject";
import { useMatchObject } from "../src/useMatchObject";
import { useSubscribeToResource } from "../src/useSubscribeToResource";
// Use an increased timeout, since the CSS server takes too much setup time.
jest.setTimeout(40_000);
describe("Integration Tests", () => {
setUpServer();
/**
* ===========================================================================
* useResource
* ===========================================================================
*/
describe("useResource", () => {
it("Fetches a resource and indicates it is loading while doing so", async () => {
const UseResourceTest: FunctionComponent = () => {
const resource = useResource(SAMPLE_DATA_URI);
if (resource?.isLoading()) return <p>Loading</p>;
return <p role="status">{resource.status.type}</p>;
};
render(
<UnauthenticatedSolidLdoProvider>
<UseResourceTest />
</UnauthenticatedSolidLdoProvider>,
);
await screen.findByText("Loading");
const resourceStatus = await screen.findByRole("status");
expect(resourceStatus.innerHTML).toBe("dataReadSuccess");
});
it("returns undefined when no uri is provided, then rerenders when one is", async () => {
const UseResourceUndefinedTest: FunctionComponent = () => {
const [uri, setUri] = useState<string | undefined>(undefined);
const resource = useResource(uri, { suppressInitialRead: true });
if (!resource)
return (
<div>
<p>Undefined</p>
<button onClick={() => setUri(SAMPLE_DATA_URI)}>Next</button>
</div>
);
return <p role="status">{resource.status.type}</p>;
};
render(
<UnauthenticatedSolidLdoProvider>
<UseResourceUndefinedTest />
</UnauthenticatedSolidLdoProvider>,
);
await screen.findByText("Undefined");
fireEvent.click(screen.getByText("Next"));
const resourceStatus = await screen.findByRole("status");
expect(resourceStatus.innerHTML).toBe("unfetched");
});
it("Reloads the data on mount", async () => {
const ReloadTest: FunctionComponent = () => {
const resource = useResource(SAMPLE_DATA_URI, { reloadOnMount: true });
if (resource?.isLoading()) return <p>Loading</p>;
return <p role="status">{resource.status.type}</p>;
};
const ReloadParent: FunctionComponent = () => {
const [showComponent, setShowComponent] = useState(true);
return (
<div>
<button onClick={() => setShowComponent(!showComponent)}>
Show Component
</button>
{showComponent ? <ReloadTest /> : <p>Hidden</p>}
</div>
);
};
render(
<UnauthenticatedSolidLdoProvider>
<ReloadParent />
</UnauthenticatedSolidLdoProvider>,
);
await screen.findByText("Loading");
const resourceStatus1 = await screen.findByRole("status");
expect(resourceStatus1.innerHTML).toBe("dataReadSuccess");
fireEvent.click(screen.getByText("Show Component"));
await screen.findByText("Hidden");
fireEvent.click(screen.getByText("Show Component"));
await screen.findByText("Loading");
const resourceStatus2 = await screen.findByRole("status", undefined, {
timeout: 5000,
});
expect(resourceStatus2.innerHTML).toBe("dataReadSuccess");
});
it("handles swapping to a new resource", async () => {
const SwapResourceTest: FunctionComponent = () => {
const [uri, setUri] = useState(SAMPLE_DATA_URI);
const resource = useResource(uri);
if (resource?.isLoading()) return <p>Loading</p>;
return (
<div>
<p role="status">{resource.status.type}</p>
<button onClick={() => setUri(SAMPLE_BINARY_URI)}>
Update URI
</button>
</div>
);
};
render(
<UnauthenticatedSolidLdoProvider>
<SwapResourceTest />
</UnauthenticatedSolidLdoProvider>,
);
await screen.findByText("Loading");
const resourceStatus1 = await screen.findByRole("status");
expect(resourceStatus1.innerHTML).toBe("dataReadSuccess");
fireEvent.click(screen.getByText("Update URI"));
await screen.findByText("Loading");
const resourceStatus2 = await screen.findByRole("status");
expect(resourceStatus2.innerHTML).toBe("binaryReadSuccess");
});
});
/**
* ===========================================================================
* useRootContainer
* ===========================================================================
*/
describe("useRootContainer", () => {
it("gets the root container for a sub-resource", async () => {
const RootContainerTest: FunctionComponent = () => {
const rootContainer = useRootContainerFor(SAMPLE_DATA_URI, {
suppressInitialRead: true,
});
return rootContainer ? <p role="root">{rootContainer?.uri}</p> : <></>;
};
render(
<UnauthenticatedSolidLdoProvider>
<RootContainerTest />
</UnauthenticatedSolidLdoProvider>,
);
const container = await screen.findByRole("root");
expect(container.innerHTML).toBe(SERVER_DOMAIN);
});
it("returns undefined when a URI is not provided", async () => {
const RootContainerTest: FunctionComponent = () => {
const rootContainer = useRootContainerFor(undefined, {
suppressInitialRead: true,
});
return rootContainer ? (
<p role="root">{rootContainer?.uri}</p>
) : (
<p role="undefined">Undefined</p>
);
};
render(
<UnauthenticatedSolidLdoProvider>
<RootContainerTest />
</UnauthenticatedSolidLdoProvider>,
);
const container = await screen.findByRole("undefined");
expect(container.innerHTML).toBe("Undefined");
});
});
/**
* ===========================================================================
* useLdoMethods
* ===========================================================================
*/
describe("useLdoMethods", () => {
it("uses get subject to get a linked data object", async () => {
const GetSubjectTest: FunctionComponent = () => {
const [subject, setSubject] = useState<PostSh | undefined>();
const { getSubject } = useLdo();
useEffect(() => {
const someSubject = getSubject(
PostShShapeType,
"https://example.com/subject",
);
setSubject(someSubject);
}, []);
return subject ? <p role="subject">{subject["@id"]}</p> : <></>;
};
render(
<UnauthenticatedSolidLdoProvider>
<GetSubjectTest />
</UnauthenticatedSolidLdoProvider>,
);
const container = await screen.findByRole("subject");
expect(container.innerHTML).toBe("https://example.com/subject");
});
it("uses createData to create a new data object", async () => {
const GetSubjectTest: FunctionComponent = () => {
const [subject, setSubject] = useState<PostSh | undefined>();
const { createData, getResource } = useLdo();
useEffect(() => {
const someSubject = createData(
PostShShapeType,
"https://example.com/subject",
getResource("https://example.com/"),
);
someSubject.articleBody = "Cool Article";
setSubject(someSubject);
}, []);
return subject ? <p role="subject">{subject.articleBody}</p> : <></>;
};
render(
<UnauthenticatedSolidLdoProvider>
<GetSubjectTest />
</UnauthenticatedSolidLdoProvider>,
);
const container = await screen.findByRole("subject");
expect(container.innerHTML).toBe("Cool Article");
});
});
/**
* ===========================================================================
* useSubject
* ===========================================================================
*/
describe("useSubject", () => {
it("renders the article body from the useSubject value", async () => {
const UseSubjectTest: FunctionComponent = () => {
useResource(SAMPLE_DATA_URI);
const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`);
return <p role="article">{post.articleBody}</p>;
};
render(
<UnauthenticatedSolidLdoProvider>
<UseSubjectTest />
</UnauthenticatedSolidLdoProvider>,
);
await screen.findByText("test");
});
it("renders the set value from the useSubject value", async () => {
const UseSubjectTest: FunctionComponent = () => {
const resource = useResource(SAMPLE_DATA_URI);
const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`);
if (resource.isLoading() || !post) return <p>loading</p>;
return (
<div>
<p role="single">{post.publisher.toArray()[0]["@id"]}</p>
<ul role="list">
{post.publisher.map((publisher) => {
return <li key={publisher["@id"]}>{publisher["@id"]}</li>;
})}
</ul>
</div>
);
};
render(
<UnauthenticatedSolidLdoProvider>
<UseSubjectTest />
</UnauthenticatedSolidLdoProvider>,
);
const single = await screen.findByRole("single");
expect(single.innerHTML).toBe("https://example.com/Publisher1");
const list = await screen.findByRole("list");
expect(list.children[0].innerHTML).toBe("https://example.com/Publisher1");
expect(list.children[1].innerHTML).toBe("https://example.com/Publisher2");
});
it("returns undefined in the subject URI is undefined", async () => {
const UseSubjectTest: FunctionComponent = () => {
useResource(SAMPLE_DATA_URI, { suppressInitialRead: true });
const post = useSubject(PostShShapeType, undefined);
return (
<p role="article">
{post === undefined ? "Undefined" : "Not Undefined"}
</p>
);
};
render(
<UnauthenticatedSolidLdoProvider>
<UseSubjectTest />
</UnauthenticatedSolidLdoProvider>,
);
const article = await screen.findByRole("article");
expect(article.innerHTML).toBe("Undefined");
});
it("returns nothing if a symbol key is provided", async () => {
const UseSubjectTest: FunctionComponent = () => {
const resource = useResource(SAMPLE_DATA_URI);
const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`);
if (resource.isLoading() || !post) return <p>loading</p>;
return <p role="value">{typeof post[Symbol.hasInstance]}</p>;
};
render(
<UnauthenticatedSolidLdoProvider>
<UseSubjectTest />
</UnauthenticatedSolidLdoProvider>,
);
const article = await screen.findByRole("value");
expect(article.innerHTML).toBe("undefined");
});
it("returns an id if an id key is provided", async () => {
const UseSubjectTest: FunctionComponent = () => {
const resource = useResource(SAMPLE_DATA_URI);
const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`);
if (resource.isLoading() || !post) return <p>loading</p>;
return <p role="value">{post["@id"]}</p>;
};
render(
<UnauthenticatedSolidLdoProvider>
<UseSubjectTest />
</UnauthenticatedSolidLdoProvider>,
);
const article = await screen.findByRole("value");
expect(article.innerHTML).toBe(`${SAMPLE_DATA_URI}#Post1`);
});
it("does not set a value if a value is attempted to be set", async () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
const UseSubjectTest: FunctionComponent = () => {
const resource = useResource(SAMPLE_DATA_URI);
const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`);
if (resource.isLoading() || !post) return <p>loading</p>;
return (
<div>
<p role="value">{post.articleBody}</p>
<button
onClick={() => {
post.articleBody = "bad";
post.publisher.add({ "@id": "example" });
}}
>
Attempt Change
</button>
</div>
);
};
render(
<UnauthenticatedSolidLdoProvider>
<UseSubjectTest />
</UnauthenticatedSolidLdoProvider>,
);
const article = await screen.findByRole("value");
expect(article.innerHTML).toBe(`test`);
fireEvent.click(screen.getByText("Attempt Change"));
expect(article.innerHTML).not.toBe("bad");
expect(warn).toHaveBeenCalledWith(
"You've attempted to set a value on a Linked Data Object from the useSubject, useMatchingSubject, or useMatchingObject hooks. These linked data objects should only be used to render data, not modify it. To modify data, use the `changeData` function.",
);
expect(warn).toHaveBeenCalledWith(
"You've attempted to modify a value on a Linked Data Object from the useSubject, useMatchingSubject, or useMatchingObject hooks. These linked data objects should only be used to render data, not modify it. To modify data, use the `changeData` function.",
);
warn.mockReset();
});
it("rerenders when asked to subscribe to a resource", async () => {
const NotificationTest: FunctionComponent = () => {
const [isSubscribed, setIsSubscribed] = useState(true);
const resource = useResource(SAMPLE_DATA_URI, {
subscribe: isSubscribed,
});
const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`);
const addPublisher = useCallback(async () => {
await fetch(SAMPLE_DATA_URI, {
method: "PATCH",
body: `INSERT DATA { <${SAMPLE_DATA_URI}#Post1> <http://schema.org/publisher> <https://example.com/Publisher3> . }`,
headers: {
"Content-Type": "application/sparql-update",
},
});
}, []);
if (resource.isLoading() || !post) return <p>loading</p>;
return (
<div>
<p role="resource">
{resource.isSubscribedToNotifications().toString()}
</p>
<ul role="list">
{post.publisher.map((publisher) => {
return <li key={publisher["@id"]}>{publisher["@id"]}</li>;
})}
</ul>
<button onClick={addPublisher}>Add Publisher</button>
<button onClick={() => setIsSubscribed(false)}>Unsubscribe</button>
</div>
);
};
const { unmount } = render(
<UnauthenticatedSolidLdoProvider>
<NotificationTest />
</UnauthenticatedSolidLdoProvider>,
);
// Wait for subscription to connect
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
});
const list = await screen.findByRole("list");
expect(list.children[0].innerHTML).toBe("https://example.com/Publisher1");
expect(list.children[1].innerHTML).toBe("https://example.com/Publisher2");
const resourceP = await screen.findByRole("resource");
expect(resourceP.innerHTML).toBe("true");
// Click button to add a publisher
await fireEvent.click(screen.getByText("Add Publisher"));
await screen.findByText("https://example.com/Publisher3");
// Verify the new publisher is in the list
const updatedList = await screen.findByRole("list");
expect(updatedList.children[2].innerHTML).toBe(
"https://example.com/Publisher3",
);
await fireEvent.click(screen.getByText("Unsubscribe"));
const resourcePUpdated = await screen.findByRole("resource");
expect(resourcePUpdated.innerHTML).toBe("false");
unmount();
});
});
/**
* ===========================================================================
* useMatchSubject
* ===========================================================================
*/
describe("useMatchSubject", () => {
it("returns an array of matched subjects", async () => {
const UseMatchSubjectTest: FunctionComponent = () => {
const resource = useResource(SAMPLE_DATA_URI);
const posts = useMatchSubject(
PostShShapeType,
"http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
"http://schema.org/CreativeWork",
);
if (resource.isLoading()) return <p>loading</p>;
return (
<div>
<ul role="list">
{posts.map((post) => {
return <li key={post["@id"]}>{post["@id"]}</li>;
})}
</ul>
</div>
);
};
render(
<UnauthenticatedSolidLdoProvider>
<UseMatchSubjectTest />
</UnauthenticatedSolidLdoProvider>,
);
const list = await screen.findByRole("list");
expect(list.children[0].innerHTML).toBe(
"http://localhost:3002/example/test_ldo/sample.ttl#Post1",
);
expect(list.children[1].innerHTML).toBe(
"http://localhost:3002/example/test_ldo/sample.ttl#Post2",
);
});
});
/**
* ===========================================================================
* useMatchObject
* ===========================================================================
*/
describe("useMatchObject", () => {
it("returns an array of matched objects", async () => {
const UseMatchObjectTest: FunctionComponent = () => {
const resource = useResource(SAMPLE_DATA_URI);
const publishers = useMatchObject(
PostShShapeType,
"http://localhost:3002/example/test_ldo/sample.ttl#Post1",
"http://schema.org/publisher",
);
if (resource.isLoading()) return <p>loading</p>;
return (
<div>
<ul role="list">
{publishers.map((publisher) => {
return <li key={publisher["@id"]}>{publisher["@id"]}</li>;
})}
</ul>
</div>
);
};
render(
<UnauthenticatedSolidLdoProvider>
<UseMatchObjectTest />
</UnauthenticatedSolidLdoProvider>,
);
const list = await screen.findByRole("list");
expect(list.children[0].innerHTML).toBe("https://example.com/Publisher1");
expect(list.children[1].innerHTML).toBe("https://example.com/Publisher2");
});
});
/**
* ===========================================================================
* useSubscribeToResource
* ===========================================================================
*/
describe("useSubscribeToResource", () => {
it("handles useSubscribeToResource", async () => {
const NotificationTest: FunctionComponent = () => {
const [subscribedUris, setSubScribedUris] = useState<string[]>([
SAMPLE_DATA_URI,
]);
useSubscribeToResource(...subscribedUris);
const resource1 = useResource(SAMPLE_DATA_URI);
const resource2 = useResource(SAMPLE_BINARY_URI);
const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`);
const addPublisher = useCallback(async () => {
await fetch(SAMPLE_DATA_URI, {
method: "PATCH",
body: `INSERT DATA { <${SAMPLE_DATA_URI}#Post1> <http://schema.org/publisher> <https://example.com/Publisher3> . }`,
headers: {
"Content-Type": "application/sparql-update",
},
});
}, []);
if (resource1.isLoading() || resource2.isLoading())
return <p>Loading</p>;
return (
<div>
<p role="resource1">
{resource1.isSubscribedToNotifications().toString()}
</p>
<p role="resource2">
{resource2.isSubscribedToNotifications().toString()}
</p>
<ul role="list">
{post.publisher.map((publisher) => {
return <li key={publisher["@id"]}>{publisher["@id"]}</li>;
})}
</ul>
<button onClick={addPublisher}>Add Publisher</button>
<button
onClick={() =>
setSubScribedUris([SAMPLE_DATA_URI, SAMPLE_BINARY_URI])
}
>
Subscribe More
</button>
<button onClick={() => setSubScribedUris([SAMPLE_BINARY_URI])}>
Subscribe Less
</button>
</div>
);
};
const { unmount } = render(
<UnauthenticatedSolidLdoProvider>
<NotificationTest />
</UnauthenticatedSolidLdoProvider>,
);
const preResource1P = await screen.findByRole("resource1");
expect(preResource1P.innerHTML).toBe("false");
// Wait for subscription to connect
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
});
const list = await screen.findByRole("list");
expect(list.children[0].innerHTML).toBe("https://example.com/Publisher1");
expect(list.children[1].innerHTML).toBe("https://example.com/Publisher2");
const resource1P = await screen.findByRole("resource1");
expect(resource1P.innerHTML).toBe("true");
const resource2P = await screen.findByRole("resource2");
expect(resource2P.innerHTML).toBe("false");
// Click button to add a publisher
await fireEvent.click(screen.getByText("Add Publisher"));
await screen.findByText("https://example.com/Publisher3");
// Verify the new publisher is in the list
const updatedList = await screen.findByRole("list");
expect(updatedList.children[2].innerHTML).toBe(
"https://example.com/Publisher3",
);
await fireEvent.click(screen.getByText("Subscribe More"));
// Wait for subscription to connect
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
});
const resource1PUpdated = await screen.findByRole("resource1");
expect(resource1PUpdated.innerHTML).toBe("true");
const resource2PUpdated = await screen.findByRole("resource2");
expect(resource2PUpdated.innerHTML).toBe("true");
await fireEvent.click(screen.getByText("Subscribe Less"));
// Wait for subscription to connect
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
});
const resource1PUpdatedAgain = await screen.findByRole("resource1");
expect(resource1PUpdatedAgain.innerHTML).toBe("false");
const resource2PUpdatedAgain = await screen.findByRole("resource2");
expect(resource2PUpdatedAgain.innerHTML).toBe("true");
unmount();
});
});
});

@ -0,0 +1,113 @@
import type { ContainerUri, LeafUri } from "@ldo/solid";
import fetch from "cross-fetch";
export const SERVER_DOMAIN = process.env.SERVER || "http://localhost:3002/";
export const ROOT_ROUTE = process.env.ROOT_CONTAINER || "example/";
export const ROOT_CONTAINER = `${SERVER_DOMAIN}${ROOT_ROUTE}`;
export const WEB_ID = `${SERVER_DOMAIN}${ROOT_ROUTE}profile/card#me`;
export const TEST_CONTAINER_SLUG = "test_ldo/";
export const TEST_CONTAINER_URI =
`${ROOT_CONTAINER}${TEST_CONTAINER_SLUG}` as ContainerUri;
export const SAMPLE_DATA_URI = `${TEST_CONTAINER_URI}sample.ttl` as LeafUri;
export const SAMPLE2_DATA_SLUG = "sample2.ttl";
export const SAMPLE2_DATA_URI =
`${TEST_CONTAINER_URI}${SAMPLE2_DATA_SLUG}` as LeafUri;
export const SAMPLE_BINARY_URI = `${TEST_CONTAINER_URI}sample.txt` as LeafUri;
export const SAMPLE2_BINARY_SLUG = `sample2.txt`;
export const SAMPLE2_BINARY_URI =
`${TEST_CONTAINER_URI}${SAMPLE2_BINARY_SLUG}` as LeafUri;
export const SAMPLE_CONTAINER_URI =
`${TEST_CONTAINER_URI}sample_container/` as ContainerUri;
export const EXAMPLE_POST_TTL = `@prefix schema: <http://schema.org/> .
<#Post1>
a schema:CreativeWork, schema:Thing, schema:SocialMediaPosting ;
schema:image <https://example.com/postImage.jpg> ;
schema:articleBody "test" ;
schema:publisher <https://example.com/Publisher1>, <https://example.com/Publisher2> .
<#Post2>
a schema:CreativeWork, schema:Thing, schema:SocialMediaPosting .`;
export const TEST_CONTAINER_TTL = `@prefix dc: <http://purl.org/dc/terms/>.
@prefix ldp: <http://www.w3.org/ns/ldp#>.
@prefix posix: <http://www.w3.org/ns/posix/stat#>.
@prefix xsd: <http://www.w3.org/2001/XMLSchema#>.
<> <urn:npm:solid:community-server:http:slug> "sample.txt";
a ldp:Container, ldp:BasicContainer, ldp:Resource;
dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime.
<sample.ttl> a ldp:Resource, <http://www.w3.org/ns/iana/media-types/text/turtle#Resource>;
dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime.
<sample.txt> a ldp:Resource, <http://www.w3.org/ns/iana/media-types/text/plain#Resource>;
dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime.
<> posix:mtime 1697810234;
ldp:contains <sample.ttl>, <sample.txt>.
<sample.ttl> posix:mtime 1697810234;
posix:size 522.
<sample.txt> posix:mtime 1697810234;
posix:size 10.`;
export interface SetUpServerReturn {
authFetch: typeof fetch;
fetchMock: jest.Mock<
Promise<Response>,
[input: RequestInfo | URL, init?: RequestInit | undefined]
>;
}
export function setUpServer(): SetUpServerReturn {
// Ignore to build s
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const s: SetUpServerReturn = {};
beforeAll(async () => {
// s.authFetch = await getAuthenticatedFetch();
s.authFetch = fetch;
});
beforeEach(async () => {
s.fetchMock = jest.fn(s.authFetch);
// Create a new document called sample.ttl
await s.authFetch(ROOT_CONTAINER, {
method: "POST",
headers: {
link: '<http://www.w3.org/ns/ldp#Container>; rel="type"',
slug: TEST_CONTAINER_SLUG,
},
});
await Promise.all([
s.authFetch(TEST_CONTAINER_URI, {
method: "POST",
headers: { "content-type": "text/turtle", slug: "sample.ttl" },
body: EXAMPLE_POST_TTL,
}),
s.authFetch(TEST_CONTAINER_URI, {
method: "POST",
headers: { "content-type": "text/plain", slug: "sample.txt" },
body: "some text.",
}),
]);
});
afterEach(async () => {
await Promise.all([
s.authFetch(SAMPLE_DATA_URI, {
method: "DELETE",
}),
s.authFetch(SAMPLE2_DATA_URI, {
method: "DELETE",
}),
s.authFetch(SAMPLE_BINARY_URI, {
method: "DELETE",
}),
s.authFetch(SAMPLE2_BINARY_URI, {
method: "DELETE",
}),
s.authFetch(SAMPLE_CONTAINER_URI, {
method: "DELETE",
}),
]);
});
return s;
}

@ -0,0 +1,52 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld",
"import": [
"css:config/app/init/initialize-intro.json",
"css:config/app/main/default.json",
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/all.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",
"css:config/identity/email/default.json",
"css:config/identity/handler/default.json",
"css:config/identity/oidc/default.json",
"css:config/identity/ownership/token.json",
"css:config/identity/pod/static.json",
"css:config/ldp/authentication/dpop-bearer.json",
"css:config/ldp/authorization/webacl.json",
"css:config/ldp/handler/default.json",
"css:config/ldp/metadata-parser/default.json",
"css:config/ldp/metadata-writer/default.json",
"css:config/ldp/modes/default.json",
"css:config/storage/backend/memory.json",
"css:config/storage/key-value/resource-store.json",
"css:config/storage/location/root.json",
"css:config/storage/middleware/default.json",
"css:config/util/auxiliary/acl.json",
"css:config/util/identifiers/suffix.json",
"css:config/util/index/default.json",
"css:config/util/logging/winston.json",
"css:config/util/representation-conversion/default.json",
"css:config/util/resource-locker/memory.json",
"css:config/util/variables/default.json"
],
"@graph": [
{
"comment": "A Solid server that stores its resources in memory and uses WAC for authorization."
},
{
"comment": "The location of the new pod templates folder.",
"@type": "Override",
"overrideInstance": {
"@id": "urn:solid-server:default:PodResourcesGenerator"
},
"overrideParameters": {
"@type": "StaticFolderGenerator",
"templateFolder": "./test/test-server/configs/template"
}
}
]
}

@ -0,0 +1,9 @@
[
{
"email": "hello@example.com",
"password": "abc123",
"pods": [
{ "name": "example" }
]
}
]

@ -0,0 +1,13 @@
@prefix : <#>.
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
@prefix eve: <./>.
@prefix c: <./profile/card#>.
:ControlReadWrite
a acl:Authorization;
acl:accessTo eve:;
acl:agent c:me, <mailto:info@o.team>;
acl:agentClass foaf:Agent;
acl:default eve:;
acl:mode acl:Control, acl:Read, acl:Write.

@ -0,0 +1,19 @@
# ACL resource for the WebID profile document
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
# The WebID profile is readable by the public.
# This is required for discovery and verification,
# e.g. when checking identity providers.
<#public>
a acl:Authorization;
acl:agentClass foaf:Agent;
acl:accessTo <./card>;
acl:mode acl:Read.
# The owner has full access to the profile
<#owner>
a acl:Authorization;
acl:agent <{{webId}}>;
acl:accessTo <./card>;
acl:mode acl:Read, acl:Write, acl:Control.

@ -0,0 +1,7 @@
import { createApp } from "./solidServer.helper";
async function run() {
const app = await createApp();
await app.start();
}
run();

@ -0,0 +1,39 @@
// Taken from https://github.com/comunica/comunica/blob/b237be4265c353a62a876187d9e21e3bc05123a3/engines/query-sparql/test/QuerySparql-solid-test.ts#L9
import * as path from "path";
import type { App } from "@solid/community-server";
import { AppRunner, resolveModulePath } from "@solid/community-server";
export async function createApp(): Promise<App> {
if (process.env.SERVER) {
return {
start: () => {},
stop: () => {},
} as App;
}
const appRunner = new AppRunner();
return appRunner.create({
loaderProperties: {
mainModulePath: resolveModulePath(""),
typeChecking: false,
},
config: path.join(
__dirname,
"configs",
"components-config",
"unauthenticatedServer.json",
),
variableBindings: {},
shorthand: {
port: 3_002,
loggingLevel: "off",
seedConfig: path.join(__dirname, "configs", "solid-css-seed.json"),
},
});
}
export interface ISecretData {
id: string;
secret: string;
}

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"lib": ["dom"]
},
"include": ["./src"]
}
Loading…
Cancel
Save