diff --git a/CHANGELOG.md b/CHANGELOG.md index a98a4af2..7756aa60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.11.0 ( September 14, 2020 ) + +### Added + +- Add useThing and useDataset hooks + ## 0.9.8 ( September 9, 2020 ) ### Added diff --git a/package-lock.json b/package-lock.json index 2ec59491..03b783b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@inrupt/solid-ui-react", - "version": "0.10.1", + "version": "0.11.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -10868,8 +10868,7 @@ "fast-deep-equal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", - "dev": true + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" }, "fast-diff": { "version": "1.2.0", @@ -22775,6 +22774,14 @@ } } }, + "swr": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/swr/-/swr-0.3.2.tgz", + "integrity": "sha512-Bs5Bihq1hQ66O5bdKaL47iZ2nlAaBsd8tTLRLkw9stZeuBEfH7zSuQI95S2TpchL0ybsMq3isWwuso2uPvCfHA==", + "requires": { + "fast-deep-equal": "2.0.1" + } + }, "symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/package.json b/package.json index 0aa9f2ff..7a65df31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@inrupt/solid-ui-react", - "version": "0.10.1", + "version": "0.11.0", "description": "Set of UI libraries using @solid/core", "main": "dist/index.js", "types": "dist/src/index.d.ts", @@ -88,6 +88,7 @@ "@inrupt/solid-client": "^0.2.0", "@inrupt/solid-client-authn-browser": "^0.1.1", "@types/react-table": "^7.0.22", - "react-table": "^7.5.0" + "react-table": "^7.5.0", + "swr": "^0.3.2" } } diff --git a/src/context/datasetContext/index.test.tsx b/src/context/datasetContext/index.test.tsx index 9918266e..ea0a7094 100644 --- a/src/context/datasetContext/index.test.tsx +++ b/src/context/datasetContext/index.test.tsx @@ -23,8 +23,11 @@ import * as React from "react"; import { RenderResult, render, waitFor } from "@testing-library/react"; import * as SolidFns from "@inrupt/solid-client"; +import useDataset from "../../hooks/useDataset"; import DatasetContext, { DatasetProvider } from "./index"; +jest.mock("../../hooks/useDataset"); + let documentBody: RenderResult; const mockUrl = "https://some-interesting-value.com"; @@ -119,6 +122,10 @@ function ExampleComponentWithDatasetUrl(): React.ReactElement { describe("Testing DatasetContext", () => { it("matches snapshot with Dataset provided", () => { + (useDataset as jest.Mock).mockReturnValue({ + dataset: undefined, + error: undefined, + }); documentBody = render( @@ -129,7 +136,10 @@ describe("Testing DatasetContext", () => { }); it("matches snapshot when fetching fails", async () => { - jest.spyOn(SolidFns, "fetchLitDataset").mockRejectedValue(null); + (useDataset as jest.Mock).mockReturnValue({ + dataset: undefined, + error: "Error", + }); documentBody = render( @@ -141,6 +151,10 @@ describe("Testing DatasetContext", () => { }); it("matches snapshot when fetching", async () => { + (useDataset as jest.Mock).mockReturnValue({ + dataset: undefined, + error: undefined, + }); documentBody = render( { }); describe("Functional testing", () => { - it("Should call fetchLitDataset", async () => { - jest - .spyOn(SolidFns, "fetchLitDataset") - .mockResolvedValue(mockDataSetWithResourceInfo); + it("Should call useDataset", async () => { + (useDataset as jest.Mock).mockReturnValue({ + dataset: mockDataSetWithResourceInfo, + error: undefined, + }); render( ); - expect(SolidFns.fetchLitDataset).toHaveBeenCalled(); + expect(useDataset).toHaveBeenCalled(); }); - it("When fetchLitDataset fails, should call onError if passed", async () => { + it("When useDataset return an error, should call onError if passed", async () => { + (useDataset as jest.Mock).mockReturnValue({ + dataset: undefined, + error: "Error", + }); const onError = jest.fn(); - (SolidFns.fetchLitDataset as jest.Mock).mockRejectedValue(null); render( { ); - await waitFor(() => expect(onError).toHaveBeenCalled()); + await waitFor(() => expect(onError).toHaveBeenCalledWith("Error")); }); }); diff --git a/src/context/datasetContext/index.tsx b/src/context/datasetContext/index.tsx index 848cc66d..9d52a913 100644 --- a/src/context/datasetContext/index.tsx +++ b/src/context/datasetContext/index.tsx @@ -19,22 +19,10 @@ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import React, { - createContext, - ReactElement, - useState, - useEffect, - useCallback, - useContext, -} from "react"; -import { - fetchLitDataset, - LitDataset, - WithResourceInfo, - UrlString, -} from "@inrupt/solid-client"; +import React, { createContext, ReactElement } from "react"; +import { LitDataset, WithResourceInfo, UrlString } from "@inrupt/solid-client"; -import SessionContext from "../sessionContext"; +import useDataset from "../../hooks/useDataset"; export interface IDatasetContext { dataset: LitDataset | (LitDataset & WithResourceInfo) | undefined; @@ -68,35 +56,16 @@ export const DatasetProvider = ({ datasetUrl, loading, }: RequireDatasetOrDatasetUrl): ReactElement => { - const { fetch } = useContext(SessionContext); - const [dataset, setDataset] = useState< - LitDataset | (LitDataset & WithResourceInfo) | undefined - >(propDataset); + const { dataset, error } = useDataset(datasetUrl); - const fetchDataset = useCallback( - async (url: string) => { - try { - const resource = await fetchLitDataset(url, { fetch }); - setDataset(resource); - } catch (error) { - if (onError) { - onError(error); - } - } - }, - [onError, fetch] - ); - - useEffect(() => { - if (!dataset && datasetUrl) { - // eslint-disable-next-line no-void - void fetchDataset(datasetUrl); - } - }, [dataset, datasetUrl, fetchDataset]); + if (error && onError) { + onError(error); + } + const datasetToUse = propDataset ?? dataset; return ( - - {dataset ? children : loading || Fetching data...} + + {datasetToUse ? children : loading || Fetching data...} ); }; diff --git a/src/hooks/useDataset/index.test.tsx b/src/hooks/useDataset/index.test.tsx new file mode 100644 index 00000000..6e991cb1 --- /dev/null +++ b/src/hooks/useDataset/index.test.tsx @@ -0,0 +1,120 @@ +/** + * Copyright 2020 Inrupt Inc. + * + * 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. + */ + +import * as React from "react"; +import { renderHook } from "@testing-library/react-hooks"; +import { SWRConfig, cache } from "swr"; +import * as SolidFns from "@inrupt/solid-client"; +import { Session } from "@inrupt/solid-client-authn-browser"; +import DatasetContext from "../../context/datasetContext"; +import SessionContext from "../../context/sessionContext"; +import useDataset from "."; + +describe("useDataset() hook", () => { + const mockDatasetIri = "https://mock.url"; + const mockDataset = SolidFns.mockSolidDatasetFrom(mockDatasetIri); + const mockContextDataset = SolidFns.mockSolidDatasetFrom(mockDatasetIri); + const mockGetSolidDataset = jest + .spyOn(SolidFns, "getSolidDataset") + .mockResolvedValue(mockDataset); + const mockFetch = jest.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + + afterEach(() => { + jest.clearAllMocks(); + cache.clear(); + }); + + it("should call getSolidDataset with given Iri", async () => { + const { result, waitFor } = renderHook(() => useDataset(mockDatasetIri), { + wrapper, + }); + + expect(mockGetSolidDataset).toHaveBeenCalledTimes(1); + expect(mockGetSolidDataset).toHaveBeenCalledWith(mockDatasetIri, { + fetch: mockFetch, + }); + await waitFor(() => expect(result.current.dataset).toBe(mockDataset)); + }); + + it("should call getSolidDataset with given options", async () => { + const newMockFetch = jest.fn(); + const mockAdditionalOption = "additional option"; + const mockOptions = { + fetch: newMockFetch, + additionalOption: mockAdditionalOption, + }; + + const { result, waitFor } = renderHook( + () => useDataset(mockDatasetIri, mockOptions), + { + wrapper, + } + ); + + expect(mockGetSolidDataset).toHaveBeenCalledTimes(1); + expect(mockGetSolidDataset).toHaveBeenCalledWith(mockDatasetIri, { + fetch: newMockFetch, + additionalOption: mockAdditionalOption, + }); + await waitFor(() => expect(result.current.dataset).toBe(mockDataset)); + }); + + it("should return error if getSolidDataset call fails", async () => { + mockGetSolidDataset.mockRejectedValue(new Error("async error")); + + const { result, waitFor } = renderHook(() => useDataset(mockDatasetIri), { + wrapper, + }); + + expect(mockGetSolidDataset).toHaveBeenCalledTimes(1); + expect(mockGetSolidDataset).toHaveBeenCalledWith(mockDatasetIri, { + fetch: mockFetch, + }); + await waitFor(() => + expect(result.current.error.message).toBe("async error") + ); + }); + + it("should attempt to return dataset from context if uri is not defined", async () => { + const { result, waitFor } = renderHook(() => useDataset(), { + wrapper, + }); + + expect(mockGetSolidDataset).toHaveBeenCalledTimes(0); + + await waitFor(() => + expect(result.current.dataset).toBe(mockContextDataset) + ); + }); +}); diff --git a/src/hooks/useDataset/index.tsx b/src/hooks/useDataset/index.tsx new file mode 100644 index 00000000..bb159a1c --- /dev/null +++ b/src/hooks/useDataset/index.tsx @@ -0,0 +1,57 @@ +/** + * Copyright 2020 Inrupt Inc. + * + * 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. + */ + +import { useContext } from "react"; +import useSWR from "swr"; +import { + getSolidDataset, + SolidDataset, + WithResourceInfo, +} from "@inrupt/solid-client"; +import SessionContext from "../../context/sessionContext"; +import DatasetContext from "../../context/datasetContext"; + +export default function useDataset( + datasetIri?: string | null | undefined, + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + options?: any +): { + dataset: SolidDataset | (SolidDataset & WithResourceInfo) | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: any; +} { + const { fetch } = useContext(SessionContext); + const { dataset: datasetFromContext } = useContext(DatasetContext); + const { data, error } = useSWR( + datasetIri ? [datasetIri, options, fetch] : null, + () => { + const requestOptions = { + fetch, + ...options, + }; + // useSWR will only call this fetcher if datasetUri is defined + return getSolidDataset(datasetIri as string, requestOptions); + } + ); + + const dataset = datasetIri ? data : datasetFromContext; + return { dataset, error }; +} diff --git a/src/hooks/useThing/index.test.tsx b/src/hooks/useThing/index.test.tsx new file mode 100644 index 00000000..ee24c208 --- /dev/null +++ b/src/hooks/useThing/index.test.tsx @@ -0,0 +1,117 @@ +/** + * Copyright 2020 Inrupt Inc. + * + * 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. + */ + +import React from "react"; +import { renderHook } from "@testing-library/react-hooks"; +import * as SolidFns from "@inrupt/solid-client"; +import useDataset from "../useDataset"; +import ThingContext from "../../context/thingContext"; +import useThing from "."; + +const mockDatasetIri = "https://mock.url"; +const mockThingIri = "https://mock.url#thing"; +const mockDataset = SolidFns.mockSolidDatasetFrom(mockDatasetIri); +const mockThing = SolidFns.mockThingFrom(mockThingIri); +const mockContextThing = SolidFns.mockThingFrom(mockThingIri); +const mockGetThing = jest + .spyOn(SolidFns, "getThing") + .mockReturnValue(mockThing); + +jest.mock("../useDataset"); + +describe("useThing() hook", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should call useDataset with given dataset iri and options", async () => { + (useDataset as jest.Mock).mockReturnValue({ + dataset: mockDataset, + error: undefined, + }); + const mockOptions = { + additionalOption: "additional option", + }; + renderHook(() => useThing(mockDatasetIri, mockThingIri, mockOptions)); + + expect(useDataset).toHaveBeenCalledTimes(1); + expect(useDataset).toHaveBeenCalledWith(mockDatasetIri, mockOptions); + }); + + it("should call getThing with given thing iri, and return retrieved Thing", async () => { + (useDataset as jest.Mock).mockReturnValue({ + dataset: mockDataset, + error: undefined, + }); + const { result, waitFor } = renderHook(() => + useThing(mockDatasetIri, mockThingIri) + ); + + expect(mockGetThing).toHaveBeenCalledTimes(1); + await waitFor(() => expect(result.current.thing).toBe(mockThing)); + }); + + it("when dataset is undefined, should not call getThing, and return thing: undefined", async () => { + (useDataset as jest.Mock).mockReturnValue({ + dataset: undefined, + error: undefined, + }); + const { result, waitFor } = renderHook(() => + useThing(mockDatasetIri, mockThingIri) + ); + + expect(mockGetThing).toHaveBeenCalledTimes(0); + await waitFor(() => expect(result.current.thing).toBeUndefined()); + }); + + it("should return any error returned by useDataset", async () => { + (useDataset as jest.Mock).mockReturnValue({ + dataset: mockDataset, + error: new Error("useDataset error"), + }); + const { result, waitFor } = renderHook(() => + useThing(mockDatasetIri, mockThingIri) + ); + + await waitFor(() => + expect(result.current.error.message).toBe("useDataset error") + ); + }); + + it("should attempt to return thing from context if thing uri is not defined", async () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + (useDataset as jest.Mock).mockReturnValue({ + dataset: mockDataset, + error: undefined, + }); + const { result, waitFor } = renderHook( + () => useThing(mockDatasetIri, undefined), + { wrapper } + ); + + expect(mockGetThing).not.toHaveBeenCalled(); + await waitFor(() => expect(result.current.thing).toBe(mockContextThing)); + }); +}); diff --git a/src/hooks/useThing/index.tsx b/src/hooks/useThing/index.tsx new file mode 100644 index 00000000..60770e2b --- /dev/null +++ b/src/hooks/useThing/index.tsx @@ -0,0 +1,45 @@ +/** + * Copyright 2020 Inrupt Inc. + * + * 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. + */ + +import { getThing, Thing } from "@inrupt/solid-client"; +import { useContext } from "react"; +import ThingContext from "../../context/thingContext"; +import useDataset from "../useDataset"; + +export default function useThing( + datasetIri?: string | null | undefined, + thingIri?: string | null | undefined, + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + options?: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): { thing: Thing | undefined; error: any } { + const { dataset, error } = useDataset(datasetIri, options); + const { thing: thingFromContext } = useContext(ThingContext); + if (!thingIri) { + return { thing: thingFromContext, error }; + } + if (!dataset) { + return { thing: undefined, error }; + } + + const thing = getThing(dataset, thingIri); + return { thing, error }; +} diff --git a/src/index.ts b/src/index.ts index e5518463..e8ab7842 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,3 +38,5 @@ export { DatasetProvider, } from "./context/datasetContext"; export { default as useSession } from "./hooks/useSession"; +export { default as useDataset } from "./hooks/useDataset"; +export { default as useThing } from "./hooks/useThing";