diff --git a/packages/react/test/Integration.test.tsx b/packages/react/test/Solid-Integration.test.tsx similarity index 100% rename from packages/react/test/Integration.test.tsx rename to packages/react/test/Solid-Integration.test.tsx diff --git a/packages/solid-react/.eslintrc b/packages/solid-react/.eslintrc new file mode 100644 index 0000000..83c51a9 --- /dev/null +++ b/packages/solid-react/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": ["../../.eslintrc"] +} \ No newline at end of file diff --git a/packages/solid-react/.gitignore b/packages/solid-react/.gitignore new file mode 100644 index 0000000..0c32b1f --- /dev/null +++ b/packages/solid-react/.gitignore @@ -0,0 +1 @@ +test/test-server/data \ No newline at end of file diff --git a/packages/solid-react/LICENSE.txt b/packages/solid-react/LICENSE.txt new file mode 100644 index 0000000..b87e67e --- /dev/null +++ b/packages/solid-react/LICENSE.txt @@ -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. \ No newline at end of file diff --git a/packages/solid-react/README.md b/packages/solid-react/README.md new file mode 100644 index 0000000..1458af6 --- /dev/null +++ b/packages/solid-react/README.md @@ -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 +``` + +
+ +Manual Installation + + +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 +``` +
+ +## 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 */ + + + + ); +}; + +// 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 ( +
+ {/* Get the user's webId from session.webId */} +

Logged in as {session.webId}

+ {/* Use the logout function to log out */} + + +
+ ); + } + return ; +}; + +// 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 ; +}; + +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/). + +[nlnet foundation logo](https://nlnet.nl/) +[NGI Zero Entrust Logo](https://nlnet.nl/) + +## Liscense +MIT \ No newline at end of file diff --git a/packages/solid-react/jest.config.js b/packages/solid-react/jest.config.js new file mode 100644 index 0000000..4275a3f --- /dev/null +++ b/packages/solid-react/jest.config.js @@ -0,0 +1,6 @@ +const sharedConfig = require("../../jest.config.js"); +module.exports = { + ...sharedConfig, + rootDir: "./", + testEnvironment: "jsdom", +}; diff --git a/packages/solid-react/jest.setup.ts b/packages/solid-react/jest.setup.ts new file mode 100644 index 0000000..22eed5f --- /dev/null +++ b/packages/solid-react/jest.setup.ts @@ -0,0 +1,2 @@ +import "@inrupt/jest-jsdom-polyfills"; +globalThis.fetch = async () => new Response(); diff --git a/packages/solid-react/package.json b/packages/solid-react/package.json new file mode 100644 index 0000000..b2b13a1 --- /dev/null +++ b/packages/solid-react/package.json @@ -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" +} diff --git a/packages/solid-react/src/BrowserSolidLdoProvider.tsx b/packages/solid-react/src/BrowserSolidLdoProvider.tsx new file mode 100644 index 0000000..6213890 --- /dev/null +++ b/packages/solid-react/src/BrowserSolidLdoProvider.tsx @@ -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 = ({ + children, +}) => { + const [session, setSession] = useState(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 ( + + {children} + + ); +}; diff --git a/packages/solid-react/src/SolidAuthContext.ts b/packages/solid-react/src/SolidAuthContext.ts new file mode 100644 index 0000000..4620b21 --- /dev/null +++ b/packages/solid-react/src/SolidAuthContext.ts @@ -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; + logout: () => Promise; + signUp: (issuer: string, loginOptions?: LoginOptions) => Promise; + 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(undefined); + +export function useSolidAuth(): SolidAuthFunctions { + return useContext(SolidAuthContext); +} diff --git a/packages/solid-react/src/SolidLdoProvider.tsx b/packages/solid-react/src/SolidLdoProvider.tsx new file mode 100644 index 0000000..a0c9664 --- /dev/null +++ b/packages/solid-react/src/SolidLdoProvider.tsx @@ -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(undefined); + +export function useLdo(): UseLdoMethods { + return useContext(SolidLdoReactContext); +} + +export interface SolidLdoProviderProps extends PropsWithChildren {} + +export const SolidLdoProvider: FunctionComponent = ({ + 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 ( + + {children} + + ); +}; diff --git a/packages/solid-react/src/UnauthenticatedSolidLdoProvider.tsx b/packages/solid-react/src/UnauthenticatedSolidLdoProvider.tsx new file mode 100644 index 0000000..8f25de1 --- /dev/null +++ b/packages/solid-react/src/UnauthenticatedSolidLdoProvider.tsx @@ -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 ( + + {children} + + ); +}; diff --git a/packages/solid-react/src/index.ts b/packages/solid-react/src/index.ts new file mode 100644 index 0000000..edbcc74 --- /dev/null +++ b/packages/solid-react/src/index.ts @@ -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"; diff --git a/packages/solid-react/src/useLdoMethods.ts b/packages/solid-react/src/useLdoMethods.ts new file mode 100644 index 0000000..fc95869 --- /dev/null +++ b/packages/solid-react/src/useLdoMethods.ts @@ -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( + shapeType: ShapeType, + subject: string | SubjectNode, + ): Type; + createData( + shapeType: ShapeType, + subject: string | SubjectNode, + resource: Resource, + ...additionalResources: Resource[] + ): Type; + changeData( + input: Type, + resource: Resource, + ...additionalResources: Resource[] + ): Type; + commitData( + input: LdoBase, + ): ReturnType; +} + +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( + shapeType: ShapeType, + 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( + shapeType: ShapeType, + 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, + }; +} diff --git a/packages/solid-react/src/useMatchObject.ts b/packages/solid-react/src/useMatchObject.ts new file mode 100644 index 0000000..c338646 --- /dev/null +++ b/packages/solid-react/src/useMatchObject.ts @@ -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( + shapeType: ShapeType, + subject?: QuadMatch[0] | string, + predicate?: QuadMatch[1] | string, + graph?: QuadMatch[3] | string, +): LdSet { + const matchObject = useCallback( + (builder: LdoBuilder) => { + return builder.matchObject(subject, predicate, graph); + }, + [subject, predicate, graph], + ); + + return useTrackingProxy(shapeType, matchObject); +} diff --git a/packages/solid-react/src/useMatchSubject.ts b/packages/solid-react/src/useMatchSubject.ts new file mode 100644 index 0000000..494afc8 --- /dev/null +++ b/packages/solid-react/src/useMatchSubject.ts @@ -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( + shapeType: ShapeType, + predicate?: QuadMatch[1] | string, + object?: QuadMatch[2] | string, + graph?: QuadMatch[3] | string, +): LdSet { + const matchSubject = useCallback( + (builder: LdoBuilder) => { + return builder.matchSubject(predicate, object, graph); + }, + [predicate, object, graph], + ); + + return useTrackingProxy(shapeType, matchSubject); +} diff --git a/packages/solid-react/src/useResource.ts b/packages/solid-react/src/useResource.ts new file mode 100644 index 0000000..fb3fffb --- /dev/null +++ b/packages/solid-react/src/useResource.ts @@ -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(); + + // 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; +} diff --git a/packages/solid-react/src/useRootContainer.ts b/packages/solid-react/src/useRootContainer.ts new file mode 100644 index 0000000..e32369a --- /dev/null +++ b/packages/solid-react/src/useRootContainer.ts @@ -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); +} diff --git a/packages/solid-react/src/useSubject.ts b/packages/solid-react/src/useSubject.ts new file mode 100644 index 0000000..c728c6f --- /dev/null +++ b/packages/solid-react/src/useSubject.ts @@ -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( + shapeType: ShapeType, + subject: string | SubjectNode, +): Type; +export function useSubject( + shapeType: ShapeType, + subject?: string | SubjectNode, +): Type | undefined; +export function useSubject( + shapeType: ShapeType, + subject?: string | SubjectNode, +): Type | undefined { + const fromSubject = useCallback( + (builder: LdoBuilder) => { + if (!subject) return; + return builder.fromSubject(subject); + }, + [subject], + ); + + return useTrackingProxy(shapeType, fromSubject); +} diff --git a/packages/solid-react/src/useSubscribeToResource.ts b/packages/solid-react/src/useSubscribeToResource.ts new file mode 100644 index 0000000..d6dc8b0 --- /dev/null +++ b/packages/solid-react/src/useSubscribeToResource.ts @@ -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>({}); + useEffect(() => { + const resources = uris.map((uri) => dataset.getResource(uri)); + const previousSubscriptions = { ...currentlySubscribed.current }; + Promise.all( + 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); + }, + ), + ); + }; + }, []); +} diff --git a/packages/solid-react/src/util/TrackingProxyContext.ts b/packages/solid-react/src/util/TrackingProxyContext.ts new file mode 100644 index 0000000..3bbd84a --- /dev/null +++ b/packages/solid-react/src/util/TrackingProxyContext.ts @@ -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; +} + +/** + * @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; + + 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, + ); + } +} diff --git a/packages/solid-react/src/util/TrackingSetProxy.ts b/packages/solid-react/src/util/TrackingSetProxy.ts new file mode 100644 index 0000000..9c34347 --- /dev/null +++ b/packages/solid-react/src/util/TrackingSetProxy.ts @@ -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(["add", "clear", "delete"]); diff --git a/packages/solid-react/src/util/TrackingSubjectProxy.ts b/packages/solid-react/src/util/TrackingSubjectProxy.ts new file mode 100644 index 0000000..54fa5fe --- /dev/null +++ b/packages/solid-react/src/util/TrackingSubjectProxy.ts @@ -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["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; +} diff --git a/packages/solid-react/src/util/useTrackingProxy.ts b/packages/solid-react/src/util/useTrackingProxy.ts new file mode 100644 index 0000000..05fa452 --- /dev/null +++ b/packages/solid-react/src/util/useTrackingProxy.ts @@ -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( + shapeType: ShapeType, + createLdo: (builder: LdoBuilder) => 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; +} diff --git a/packages/solid-react/test/.ldo/post.context.ts b/packages/solid-react/test/.ldo/post.context.ts new file mode 100644 index 0000000..5cb3a91 --- /dev/null +++ b/packages/solid-react/test/.ldo/post.context.ts @@ -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", + }, +}; diff --git a/packages/solid-react/test/.ldo/post.schema.ts b/packages/solid-react/test/.ldo/post.schema.ts new file mode 100644 index 0000000..39e8b63 --- /dev/null +++ b/packages/solid-react/test/.ldo/post.schema.ts @@ -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.", + }, + }, + ], + }, + }, + ], +}; diff --git a/packages/solid-react/test/.ldo/post.shapeTypes.ts b/packages/solid-react/test/.ldo/post.shapeTypes.ts new file mode 100644 index 0000000..4c50683 --- /dev/null +++ b/packages/solid-react/test/.ldo/post.shapeTypes.ts @@ -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 = { + schema: postSchema, + shape: "https://example.com/PostSh", + context: postContext, +}; diff --git a/packages/solid-react/test/.ldo/post.typings.ts b/packages/solid-react/test/.ldo/post.typings.ts new file mode 100644 index 0000000..bf9ac17 --- /dev/null +++ b/packages/solid-react/test/.ldo/post.typings.ts @@ -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; + }>; +} diff --git a/packages/solid-react/test/Solid-Integration.test.tsx b/packages/solid-react/test/Solid-Integration.test.tsx new file mode 100644 index 0000000..5dd3f1c --- /dev/null +++ b/packages/solid-react/test/Solid-Integration.test.tsx @@ -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

Loading

; + return

{resource.status.type}

; + }; + render( + + + , + ); + 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(undefined); + const resource = useResource(uri, { suppressInitialRead: true }); + if (!resource) + return ( +
+

Undefined

+ +
+ ); + return

{resource.status.type}

; + }; + render( + + + , + ); + 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

Loading

; + return

{resource.status.type}

; + }; + const ReloadParent: FunctionComponent = () => { + const [showComponent, setShowComponent] = useState(true); + return ( +
+ + {showComponent ? :

Hidden

} +
+ ); + }; + render( + + + , + ); + 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

Loading

; + return ( +
+

{resource.status.type}

+ +
+ ); + }; + render( + + + , + ); + 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 ?

{rootContainer?.uri}

: <>; + }; + render( + + + , + ); + 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 ? ( +

{rootContainer?.uri}

+ ) : ( +

Undefined

+ ); + }; + render( + + + , + ); + 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(); + const { getSubject } = useLdo(); + useEffect(() => { + const someSubject = getSubject( + PostShShapeType, + "https://example.com/subject", + ); + setSubject(someSubject); + }, []); + return subject ?

{subject["@id"]}

: <>; + }; + render( + + + , + ); + 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(); + 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 ?

{subject.articleBody}

: <>; + }; + render( + + + , + ); + 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

{post.articleBody}

; + }; + render( + + + , + ); + + 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

loading

; + + return ( +
+

{post.publisher.toArray()[0]["@id"]}

+
    + {post.publisher.map((publisher) => { + return
  • {publisher["@id"]}
  • ; + })} +
+
+ ); + }; + render( + + + , + ); + + 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 ( +

+ {post === undefined ? "Undefined" : "Not Undefined"} +

+ ); + }; + render( + + + , + ); + + 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

loading

; + + return

{typeof post[Symbol.hasInstance]}

; + }; + render( + + + , + ); + + 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

loading

; + + return

{post["@id"]}

; + }; + render( + + + , + ); + + 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

loading

; + + return ( +
+

{post.articleBody}

+ +
+ ); + }; + render( + + + , + ); + + 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> . }`, + headers: { + "Content-Type": "application/sparql-update", + }, + }); + }, []); + + if (resource.isLoading() || !post) return

loading

; + + return ( +
+

+ {resource.isSubscribedToNotifications().toString()} +

+
    + {post.publisher.map((publisher) => { + return
  • {publisher["@id"]}
  • ; + })} +
+ + +
+ ); + }; + const { unmount } = render( + + + , + ); + + // 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

loading

; + + return ( +
+
    + {posts.map((post) => { + return
  • {post["@id"]}
  • ; + })} +
+
+ ); + }; + render( + + + , + ); + + 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

loading

; + + return ( +
+
    + {publishers.map((publisher) => { + return
  • {publisher["@id"]}
  • ; + })} +
+
+ ); + }; + render( + + + , + ); + + 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([ + 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> . }`, + headers: { + "Content-Type": "application/sparql-update", + }, + }); + }, []); + + if (resource1.isLoading() || resource2.isLoading()) + return

Loading

; + + return ( +
+

+ {resource1.isSubscribedToNotifications().toString()} +

+

+ {resource2.isSubscribedToNotifications().toString()} +

+
    + {post.publisher.map((publisher) => { + return
  • {publisher["@id"]}
  • ; + })} +
+ + + +
+ ); + }; + const { unmount } = render( + + + , + ); + + 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(); + }); + }); +}); diff --git a/packages/solid-react/test/setUpServer.ts b/packages/solid-react/test/setUpServer.ts new file mode 100644 index 0000000..04a300c --- /dev/null +++ b/packages/solid-react/test/setUpServer.ts @@ -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: . + +<#Post1> + a schema:CreativeWork, schema:Thing, schema:SocialMediaPosting ; + schema:image ; + schema:articleBody "test" ; + schema:publisher , . +<#Post2> + a schema:CreativeWork, schema:Thing, schema:SocialMediaPosting .`; +export const TEST_CONTAINER_TTL = `@prefix dc: . +@prefix ldp: . +@prefix posix: . +@prefix xsd: . + +<> "sample.txt"; + a ldp:Container, ldp:BasicContainer, ldp:Resource; + dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime. + a ldp:Resource, ; + dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime. + a ldp:Resource, ; + dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime. +<> posix:mtime 1697810234; + ldp:contains , . + posix:mtime 1697810234; + posix:size 522. + posix:mtime 1697810234; + posix:size 10.`; + +export interface SetUpServerReturn { + authFetch: typeof fetch; + fetchMock: jest.Mock< + Promise, + [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: '; 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; +} diff --git a/packages/solid-react/test/test-server/configs/components-config/unauthenticatedServer.json b/packages/solid-react/test/test-server/configs/components-config/unauthenticatedServer.json new file mode 100644 index 0000000..ff01914 --- /dev/null +++ b/packages/solid-react/test/test-server/configs/components-config/unauthenticatedServer.json @@ -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" + } + } + ] +} diff --git a/packages/solid-react/test/test-server/configs/solid-css-seed.json b/packages/solid-react/test/test-server/configs/solid-css-seed.json new file mode 100644 index 0000000..5894d0d --- /dev/null +++ b/packages/solid-react/test/test-server/configs/solid-css-seed.json @@ -0,0 +1,9 @@ +[ + { + "email": "hello@example.com", + "password": "abc123", + "pods": [ + { "name": "example" } + ] + } +] \ No newline at end of file diff --git a/packages/solid-react/test/test-server/configs/template/wac/.acl.hbs b/packages/solid-react/test/test-server/configs/template/wac/.acl.hbs new file mode 100644 index 0000000..48fd101 --- /dev/null +++ b/packages/solid-react/test/test-server/configs/template/wac/.acl.hbs @@ -0,0 +1,13 @@ +@prefix : <#>. +@prefix acl: . +@prefix foaf: . +@prefix eve: <./>. +@prefix c: <./profile/card#>. + +:ControlReadWrite + a acl:Authorization; + acl:accessTo eve:; + acl:agent c:me, ; + acl:agentClass foaf:Agent; + acl:default eve:; + acl:mode acl:Control, acl:Read, acl:Write. \ No newline at end of file diff --git a/packages/solid-react/test/test-server/configs/template/wac/profile/card.acl.hbs b/packages/solid-react/test/test-server/configs/template/wac/profile/card.acl.hbs new file mode 100644 index 0000000..ea7c2a8 --- /dev/null +++ b/packages/solid-react/test/test-server/configs/template/wac/profile/card.acl.hbs @@ -0,0 +1,19 @@ +# ACL resource for the WebID profile document +@prefix acl: . +@prefix foaf: . + +# 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. \ No newline at end of file diff --git a/packages/solid-react/test/test-server/runServer.ts b/packages/solid-react/test/test-server/runServer.ts new file mode 100644 index 0000000..9d91222 --- /dev/null +++ b/packages/solid-react/test/test-server/runServer.ts @@ -0,0 +1,7 @@ +import { createApp } from "./solidServer.helper"; + +async function run() { + const app = await createApp(); + await app.start(); +} +run(); diff --git a/packages/solid-react/test/test-server/solidServer.helper.ts b/packages/solid-react/test/test-server/solidServer.helper.ts new file mode 100644 index 0000000..a973b91 --- /dev/null +++ b/packages/solid-react/test/test-server/solidServer.helper.ts @@ -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 { + 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; +} diff --git a/packages/solid-react/tsconfig.build.json b/packages/solid-react/tsconfig.build.json new file mode 100644 index 0000000..e375629 --- /dev/null +++ b/packages/solid-react/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "lib": ["dom"] + }, + "include": ["./src"] +} \ No newline at end of file