diff --git a/templates/next/components/common/Pagination.tsx b/templates/next/components/common/Pagination.tsx new file mode 100644 index 00000000..17dac045 --- /dev/null +++ b/templates/next/components/common/Pagination.tsx @@ -0,0 +1,45 @@ +import Link from "next/link"; +import { PagedCollection } from "../../types/Collection"; + +interface Props { + collection: PagedCollection; +} + +const Pagination = ({ collection }: Props) => { + const view = collection && collection['{{{hydraPrefix}}}view']; + if (!view) return; + + const { + '{{{hydraPrefix}}}first': first, + '{{{hydraPrefix}}}previous': previous, + '{{{hydraPrefix}}}next': next, + '{{{hydraPrefix}}}last': last + } = view; + + return ( + + ); +}; + +export default Pagination; diff --git a/templates/next/pages/foos/[id]/edit.tsx b/templates/next/pages/foos/[id]/edit.tsx index 77b1cc7d..f052a1a0 100644 --- a/templates/next/pages/foos/[id]/edit.tsx +++ b/templates/next/pages/foos/[id]/edit.tsx @@ -1,30 +1,74 @@ -import { NextComponentType, NextPageContext } from 'next'; -import { Form } from '../../../components/{{{lc}}}/Form'; -import { {{{ucf}}} } from '../../../types/{{{ucf}}}'; -import { fetch } from '../../../utils/dataAccess'; +import { GetStaticPaths, GetStaticProps, NextComponentType, NextPageContext } from "next"; +import { Form } from "../../../components/{{{lc}}}/Form"; +import { {{{ucf}}} } from "../../../types/{{{ucf}}}"; +import { fetch } from "../../../utils/dataAccess"; import Head from "next/head"; +import DefaultErrorPage from "next/error"; interface Props { {{{lc}}}: {{{ucf}}}; }; const Page: NextComponentType = ({ {{{lc}}} }) => { + if (!{{{lc}}}) { + return ; + } + return (
- { {{{lc}}} && `Edit {{{ucf}}} ${ {{~lc}}['@id']}`} + { {{{lc}}} && `Edit {{{ucf}}} ${ {{~lc}}['@id'] }` }
-
+
); }; -Page.getInitialProps = async ({ asPath }: NextPageContext) => { - const {{{lc}}} = await fetch(asPath.replace( '/edit', '')); +export const getStaticProps: GetStaticProps = async ({ params }) => { + return { + props: { + {{{lc}}}: await fetch(`/{{{name}}}/${params.id}`), + }, + revalidate: 1, + }; +} - return { {{{lc}}} }; -}; +export const getStaticPaths: GetStaticPaths = async () => { + try { + const response = await fetch("/{{{name}}}"); + } catch (e) { + console.error(e); + + return { + paths: [], + fallback: true, + }; + } + + const view = response.data['{{{hydraPrefix}}}view']; + const paths = response.data["{{{hydraPrefix}}}member"].map(({{{lc}}}) => `${ {{~lc}}['@id'] }/edit`); + + if (view) { + try { + const { + '{{{hydraPrefix}}}last': last + } = view; + for (let page = 2; page <= parseInt(last.replace(/^\/{{{name}}}\?page=(\d+)/, '$1')); page++) { + paths.concat( + await fetch(`/{{{name}}}?page=${page}`).data["{{{hydraPrefix}}}member"].map(({{{lc}}}) => `${ {{~lc}}['@id'] }/edit`) + ); + } + } catch (e) { + console.error(e); + } + } + + return { + paths, + fallback: true, + }; +} export default Page; diff --git a/templates/next/pages/foos/[id]/index.tsx b/templates/next/pages/foos/[id]/index.tsx index 9bee5f4e..0656f688 100644 --- a/templates/next/pages/foos/[id]/index.tsx +++ b/templates/next/pages/foos/[id]/index.tsx @@ -1,30 +1,81 @@ -import { NextComponentType, NextPageContext } from 'next'; -import { Show } from '../../../components/{{{lc}}}/Show'; -import { {{{ucf}}} } from '../../../types/{{{ucf}}}'; -import { fetch } from '../../../utils/dataAccess'; +import { GetStaticPaths, GetStaticProps, NextComponentType, NextPageContext } from "next"; +import { Show } from "../../../components/{{{lc}}}/Show"; +import { {{{ucf}}} } from "../../../types/{{{ucf}}}"; +import { fetch } from "../../../utils/dataAccess"; import Head from "next/head"; +import DefaultErrorPage from "next/error"; +import { useMercure } from "../../../utils/mercure"; interface Props { {{{lc}}}: {{{ucf}}}; + hubURL: null | string; }; -const Page: NextComponentType = ({ {{{lc}}} }) => { +const Page: NextComponentType = (props) => { + const {{{lc}}} = props.hubURL === null ? props.{{{lc}}} : useMercure(props.{{{lc}}}, props.hubURL); + + if (!{{{lc}}}) { + return ; + } + return (
- - {`Show {{{ucf}}} ${ {{~lc}}['@id']}`} - -
- + + {`Show {{{ucf}}} ${ {{~lc}}['@id'] }`} + +
+ ); }; -Page.getInitialProps = async ({ asPath }: NextPageContext) => { - const {{{lc}}} = await fetch(asPath); +export const getStaticProps: GetStaticProps = async ({ params }) => { + const response = await fetch(`/{{{name}}}/${params.id}`); - return { {{{lc}}} }; -}; + return { + props: { + {{{lc}}}: response.data, + hubURL: response.hubURL, + }, + revalidate: 1, + }; +} + +export const getStaticPaths: GetStaticPaths = async () => { + try { + const response = await fetch("/{{{name}}}"); + } catch (e) { + console.error(e); + + return { + paths: [], + fallback: true, + }; + } + + const view = response.data['{{{hydraPrefix}}}view']; + const paths = response.data["{{{hydraPrefix}}}member"].map(({{{lc}}}) => `${ {{~lc}}['@id'] }`); + + if (view) { + try { + const { + '{{{hydraPrefix}}}last': last + } = view; + for (let page = 2; page <= parseInt(last.replace(/^\/{{{name}}}\?page=(\d+)/, '$1')); page++) { + paths.concat( + await fetch(`/{{{name}}}?page=${page}`).data["{{{hydraPrefix}}}member"].map(({{{lc}}}) => `${ {{~lc}}['@id'] }`) + ); + } + } catch (e) { + console.error(e); + } + } + + return { + paths, + fallback: true, + }; +} export default Page; diff --git a/templates/next/pages/foos/create.tsx b/templates/next/pages/foos/create.tsx index 625fe6e3..7402cf38 100644 --- a/templates/next/pages/foos/create.tsx +++ b/templates/next/pages/foos/create.tsx @@ -11,7 +11,6 @@ const Page: NextComponentType = () => ( -) - +); export default Page; diff --git a/templates/next/pages/foos/index.tsx b/templates/next/pages/foos/index.tsx index 9d29b91a..4fe26b57 100644 --- a/templates/next/pages/foos/index.tsx +++ b/templates/next/pages/foos/index.tsx @@ -1,29 +1,42 @@ -import { NextComponentType, NextPageContext } from 'next'; -import { List } from '../../components/{{{lc}}}/List'; -import { PagedCollection } from '../../types/Collection'; -import { {{{ucf}}} } from '../../types/{{{ucf}}}'; -import { fetch } from '../../utils/dataAccess'; +import { GetServerSideProps, NextComponentType, NextPageContext } from "next"; +import { List } from "../../components/{{{lc}}}/List"; +import { PagedCollection } from "../../types/Collection"; +import { {{{ucf}}} } from "../../types/{{{ucf}}}"; +import { fetch } from "../../utils/dataAccess"; import Head from "next/head"; +import Pagination from "../../components/common/Pagination"; +import { useMercure } from "../../utils/mercure"; interface Props { collection: PagedCollection<{{{ucf}}}>; + hubURL: string; } -const Page: NextComponentType = ({collection}) => ( -
+const Page: NextComponentType = (props) => { + const collection = useMercure(props.collection, props.hubURL); + + return ( +
{{{ucf}}} List
- + +
-); + ); +} -Page.getInitialProps = async () => { +export const getServerSideProps: GetServerSideProps = async () => { const collection = await fetch('/{{{name}}}'); - return {collection}; -}; + return { + props: { + collection: response.data, + hubURL: response.hubURL, + }, + } +} export default Page; diff --git a/templates/next/utils/dataAccess.ts b/templates/next/utils/dataAccess.ts index 07b3cebc..26ac50e4 100644 --- a/templates/next/utils/dataAccess.ts +++ b/templates/next/utils/dataAccess.ts @@ -11,6 +11,17 @@ interface Violation { propertyPath: string; } +const extractHubURL = (response: Response): null | URL => { + const linkHeader = response.headers.get('Link'); + if (!linkHeader) return null; + + const matches = linkHeader.match( + /<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/ + ); + + return matches && matches[1] ? new URL(matches[1], ENTRYPOINT) : null; +}; + export const fetch = async (id: string, init: RequestInit = {}) => { if (typeof init.headers === "undefined") init.headers = {}; if (!init.headers.hasOwnProperty("Accept")) @@ -26,10 +37,15 @@ export const fetch = async (id: string, init: RequestInit = {}) => { if (resp.status === 204) return; const json = await resp.json(); - if (resp.ok) return normalize(json); + if (resp.ok) { + return { + hubURL: extractHubURL(resp)?.toString(), // URL cannot be serialized as JSON, must be sent as string + data: normalize(json), + }; + } - const defaultErrorMsg = json["hydra:title"]; - const status = json["hydra:description"] || resp.statusText; + const defaultErrorMsg = json["{{{hydraPrefix}}}title"]; + const status = json["{{{hydraPrefix}}}description"] || resp.statusText; if (!json.violations) throw Error(defaultErrorMsg); const fields = {}; json.violations.map( @@ -40,12 +56,12 @@ export const fetch = async (id: string, init: RequestInit = {}) => { throw { defaultErrorMsg, status, fields }; }; -export const normalize = (data: any) => { +export const normalize = (data: unknown) => { if (has(data, "{{{hydraPrefix}}}member")) { // Normalize items in collections data["{{{hydraPrefix}}}member"] = data[ "{{{hydraPrefix}}}member" - ].map((item: any) => normalize(item)); + ].map((item: unknown) => normalize(item)); return data; } diff --git a/templates/next/utils/mercure.ts b/templates/next/utils/mercure.ts new file mode 100644 index 00000000..78db99bc --- /dev/null +++ b/templates/next/utils/mercure.ts @@ -0,0 +1,54 @@ +import has from "lodash/has"; +import { useEffect, useState } from "react"; +import { PagedCollection } from "../types/Collection"; +import { normalize } from "./dataAccess"; + +const mercureSubscribe = (hubURL: string, data: unknown | PagedCollection, setData: (data: unknown) => void) => { + const url = new URL(hubURL, window.origin); + url.searchParams.append("topic", (new URL(data["@id"], window.origin)).toString()); + const eventSource = new EventSource(url.toString()); + eventSource.addEventListener("message", (event) => setData(normalize(JSON.parse(event.data)))); + + return eventSource; +} + +export const useMercure = (deps: unknown | PagedCollection, hubURL: string) => { + const [data, setData] = useState(deps); + + useEffect(() => { + setData(deps); + }, [deps]); + + if (!data) { + return data; + } + + if (!has(data, "{{{hydraPrefix}}}member") && !has(data, "@id")) { + console.error("Object sent is not in JSON-LD format."); + + return data; + } + + useEffect(() => { + if (has(data, "{{{hydraPrefix}}}member") && Array.isArray(data["{{{hydraPrefix}}}member"]) && data["{{{hydraPrefix}}}member"].length !== 0) { + // It's a PagedCollection + data["{{{hydraPrefix}}}member"].forEach((obj, pos) => mercureSubscribe(hubURL, obj, (datum) => { + data["{{{hydraPrefix}}}member"][pos] = datum; + setData(data); + })); + + return () => data; + } + + // It's a single object + const eventSource = mercureSubscribe(hubURL, data, setData); + + return () => { + eventSource.removeEventListener("message", (event) => setData(normalize(JSON.parse(event.data)))); + + return data; + }; + }, [data]); + + return data; +}