diff --git a/examples/ckan/.env b/examples/ckan/.env new file mode 100644 index 00000000..262c6bbd --- /dev/null +++ b/examples/ckan/.env @@ -0,0 +1 @@ +DMS=https://demo.dev.datopian.com/ diff --git a/examples/data-literate/.eslintrc.json b/examples/ckan/.eslintrc.json similarity index 81% rename from examples/data-literate/.eslintrc.json rename to examples/ckan/.eslintrc.json index 82b9aa29..51f18cc1 100644 --- a/examples/data-literate/.eslintrc.json +++ b/examples/ckan/.eslintrc.json @@ -10,10 +10,7 @@ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": { - "@next/next/no-html-link-for-pages": [ - "error", - "apps/data-literate/pages" - ] + "@next/next/no-html-link-for-pages": ["error", "packages/ckan/pages"] } }, { diff --git a/examples/ckan/README.md b/examples/ckan/README.md new file mode 100644 index 00000000..74592e54 --- /dev/null +++ b/examples/ckan/README.md @@ -0,0 +1,306 @@ +

+ +🌀 Portal.JS
+The javascript framework for
+data portals + +

+ +🌀 `Portal` is a framework for rapidly building rich data portal frontends using a modern frontend approach (javascript, React, SSR). + +`Portal` assumes a "decoupled" approach where the frontend is a separate service from the backend and interacts with backend(s) via an API. It can be used with any backend and has out of the box support for [CKAN][]. `portal` is built in Javascript and React on top of the popular [Next.js][] framework. + +[ckan]: https://ckan.org/ +[next.js]: https://nextjs.com/ + +Live DEMO: https://catalog-portal-js.vercel.app + +## Features + +- 🗺️ Unified sites: present data and content in one seamless site, pulling datasets from a DMS (e.g. CKAN) and content from a CMS (e.g. wordpress) with a common internal API. +- 👩‍💻 Developer friendly: built with familiar frontend tech Javascript, React etc +- 🔋 Batteries included: Full set of portal components out of the box e.g. catalog search, dataset showcase, blog etc. +- 🎨 Easy to theme and customize: installable themes, use standard CSS and React+CSS tooling. Add new routes quickly. +- 🧱 Extensible: quickly extend and develop/import your own React components +- 📝 Well documented: full set of documentation plus the documentation of NextJS and Apollo. + +### For developers + +- 🏗 Build with modern, familiar frontend tech such as Javascript and React. +- 🚀 NextJS framework: so everything in NextJS for free React, SSR, static site generation, huge number of examples and integrations etc. + - SSR => unlimited number of pages, SEO etc whilst still using React. + - Static Site Generation (SSG) (good for small sites) => ultra-simple deployment, great performance and lighthouse scores etc +- 📋 Typescript support + +## Getting Started + +### Setup + +Install a recent version of Node. You'll need Node 10.13 or later. + +### Create a Portal app + +To create a Portal app, open your terminal, cd into the directory you'd like to create the app in, and run the following command: + +```console +npm init portal-app my-data-portal +``` + +> NB: Under the hood, this uses the tool called create-next-app, which bootstraps a Next.js app for you. It uses this template through the --example flag. +> +> If it doesn’t work, please open an issue. + +## Guide + +### Styling 🎨 + +We use Tailwind as a CSS framework. Take a look at `/styles/index.css` to see what we're importing from Tailwind bundle. You can also configure Tailwind using `tailwind.config.js` file. + +Have a look at Next.js support of CSS and ways of writing CSS: + +https://nextjs.org/docs/basic-features/built-in-css-support + +### Backend + +So far the app is running with mocked data behind. You can connect CMS and DMS backends easily via environment variables: + +```console +$ export DMS=http://ckan:5000 +$ export CMS=http://myblog.wordpress.com +``` + +> Note that we don't yet have implementations for the following CKAN features: +> +> - Activities +> - Auth +> - Groups +> - Facets + +### Routes + +These are the default routes set up in the "starter" app. + +- Home `/` +- Search `/search` +- Dataset `/@org/dataset` +- Resource `/@org/dataset/r/resource` +- Organization `/@org` +- Collection (aka group in CKAN) (?) - suggest to merge into org +- Static pages, eg, `/about` etc. from CMS or can do it without external CMS, e.g., in Next.js + +### New Routes + +TODO + +### Data fetching + +We use Apollo client which allows us to query data with GraphQL. We have setup CKAN API for the demo (it uses demo.ckan.org as DMS): + +http://portal.datopian1.now.sh/ + +Note that we don't have Apollo Server but we connect CKAN API using [`apollo-link-rest`](https://www.apollographql.com/docs/link/links/rest/) module. You can see how it works in [lib/apolloClient.ts](https://github.com/datopian/portal/blob/master/lib/apolloClient.ts) and then have a look at [pages/\_app.tsx](https://github.com/datopian/portal/blob/master/pages/_app.tsx). + +For development/debugging purposes, we suggest installing the Chrome extension - https://chrome.google.com/webstore/detail/apollo-client-developer-t/jdkknkkbebbapilgoeccciglkfbmbnfm. + +#### i18n configuration + +Portal.js is configured by default to support both `English` and `French` subpath for language translation. But for subsequent users, this following steps can be used to configure i18n for other languages; + +1. Update `next.config.js`, to add more languages to the i18n locales + +```js +i18n: { + locales: ['en', 'fr', 'nl-NL'], // add more language to the list + defaultLocale: 'en', // set the default language to use +}, +``` + +2. Create a folder for the language in `locales` --> `locales/en-Us` + +3. In the language folder, different namespace files (json) can be created for each translation. For the `index.js` use-case, I named it `common.json` + +```json +// locales/en/common.json +{ + "title" : "Portal js in English", +} + +// locales/fr/common.json +{ + "title" : "Portal js in French", +} +``` + +4. To use on pages using Server-side Props. + +```js +import { loadNamespaces } from './_app'; +import useTranslation from 'next-translate/useTranslation'; + +const Home: React.FC = ()=> { + const { t } = useTranslation(); + return ( +
{t(`common:title`)}
// we use common and title base on the common.json data + ); +}; + +export const getServerSideProps: GetServerSideProps = async ({ locale }) => { + ........ ........ + return { + props : { + _ns: await loadNamespaces(['common'], locale), + } + }; +}; + +``` + +5. Go to the browser and view the changes using language subpath like this `http://localhost:3000` and `http://localhost:3000/fr`. **Note** The subpath also activate chrome language Translator + +#### Pre-fetch data in the server-side + +When visiting a dataset page, you may want to fetch the dataset metadata in the server-side. To do so, you can use `getServerSideProps` function from NextJS: + +```javascript +import { GetServerSideProps } from 'next'; +import { initializeApollo } from '../lib/apolloClient'; +import gql from 'graphql-tag'; + +const QUERY = gql` + query dataset($id: String) { + dataset(id: $id) @rest(type: "Response", path: "package_show?{args}") { + result + } + } +`; + +... + +export const getServerSideProps: GetServerSideProps = async (context) => { + const apolloClient = initializeApollo(); + + await apolloClient.query({ + query: QUERY, + variables: { + id: 'my-dataset' + }, + }); + + return { + props: { + initialApolloState: apolloClient.cache.extract(), + }, + }; +}; +``` + +This would fetch the data from DMS and save it in the Apollo cache so that we can query it again from the components. + +#### Access data from a component + +Consider situation when rendering a component for org info on the dataset page. We already have pre-fetched dataset metadata that includes `organization` property with attributes such as `name`, `title` etc. We can now query only organization part for our `Org` component: + +```javascript +import { useQuery } from '@apollo/react-hooks'; +import gql from 'graphql-tag'; + +export const GET_ORG_QUERY = gql` + query dataset($id: String) { + dataset(id: $id) @rest(type: "Response", path: "package_show?{args}") { + result { + organization { + name + title + image_url + } + } + } + } +`; + +export default function Org({ variables }) { + const { loading, error, data } = useQuery( + GET_ORG_QUERY, + { + variables: { id: 'my-dataset' } + } + ); + + ... + + const { organization } = data.dataset.result; + + return ( + <> + {organization ? ( + <> + + + + {organization.title || organization.name} + + + + ) : ( + '' + )} + + ); +} +``` + +#### Add a new data source + +TODO + +## Developers + +### Boot the local instance + +Install the dependencies: + +```bash +yarn # or npm i +``` + +Boot the demo portal: + +```console +$ yarn dev # or npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) to see the home page 🎉 + +You can start editing the page by modifying `/pages/index.tsx`. The page auto-updates as you edit the file. + +### Tests + +We use Jest for running tests: + +```bash +yarn test # or npm run test + +# turn on watching +yarn test --watch +``` + +We use Cypress tests as well + +``` +yarn run e2e +``` + +### Architecture + +- Language: Javascript +- Framework: NextJS - https://nextjs.org/ +- Data layer API: GraphQL using Apollo. So controllers access data using GraphQL “gatsby like” + +### Key Pages + +See https://tech.datopian.com/frontend/ diff --git a/examples/ckan/__tests__/components/search/Form.test.tsx b/examples/ckan/__tests__/components/search/Form.test.tsx new file mode 100644 index 00000000..24b0160d --- /dev/null +++ b/examples/ckan/__tests__/components/search/Form.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Form from '../../../components/search/Form'; + +const useRouter = jest.spyOn(require('next/router'), 'useRouter'); + +test('📸 of Form component with empty', () => { + useRouter.mockImplementationOnce(() => ({ + query: { search: '', sort: '' }, + })); + + const { container } = render(
); + expect(container).toMatchSnapshot(); +}); + +test('📸 of Form component with query', () => { + useRouter.mockImplementationOnce(() => ({ + query: { search: 'gdp', sort: '' }, + })); + + const { container } = render(); + expect(container).toMatchSnapshot(); +}); diff --git a/examples/ckan/__tests__/components/search/__snapshots__/Form.test.tsx.snap b/examples/ckan/__tests__/components/search/__snapshots__/Form.test.tsx.snap new file mode 100644 index 00000000..d8dfe846 --- /dev/null +++ b/examples/ckan/__tests__/components/search/__snapshots__/Form.test.tsx.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`📸 of Form component with empty 1`] = ` +
+ + + + +
+`; + +exports[`📸 of Form component with query 1`] = ` +
+
+ + +
+
+`; diff --git a/examples/ckan/components/_shared/CustomLink.tsx b/examples/ckan/components/_shared/CustomLink.tsx new file mode 100644 index 00000000..2b50236e --- /dev/null +++ b/examples/ckan/components/_shared/CustomLink.tsx @@ -0,0 +1,15 @@ +type LinkProps = { + url: string; + format: any; +}; + +const CustomLink: React.FC = ({ url, format }: LinkProps) => ( + + {format} + +); + +export default CustomLink; diff --git a/examples/ckan/components/_shared/Error.tsx b/examples/ckan/components/_shared/Error.tsx new file mode 100644 index 00000000..afdb4cec --- /dev/null +++ b/examples/ckan/components/_shared/Error.tsx @@ -0,0 +1,17 @@ +const ErrorMessage: React.FC<{ message: any }> = ({ message }) => { + return ( + + ); +}; + +export default ErrorMessage; diff --git a/examples/ckan/components/_shared/Table.tsx b/examples/ckan/components/_shared/Table.tsx new file mode 100644 index 00000000..deccbf49 --- /dev/null +++ b/examples/ckan/components/_shared/Table.tsx @@ -0,0 +1,53 @@ +interface TableProps { + columns: Array; + data: Array; + className?: string; +} + +const Table: React.FC = ({ columns, data, className }) => { + return ( +
+
+
+ + + + {columns.map(({ key, name }) => ( + + ))} + + + + {data.map((item) => ( + + {columns.map(({ key, render }) => ( + + ))} + + ))} + +
+ {name} +
+ {(render && + typeof render === 'function' && + render(item)) || + item[key] || + ''} +
+
+
+
+ ); +}; + +export default Table; diff --git a/examples/ckan/components/_shared/index.ts b/examples/ckan/components/_shared/index.ts new file mode 100644 index 00000000..f0643faa --- /dev/null +++ b/examples/ckan/components/_shared/index.ts @@ -0,0 +1,5 @@ +import Table from './Table'; +import ErrorMessage from './Error'; +import CustomLink from './CustomLink'; + +export { Table, ErrorMessage, CustomLink }; diff --git a/examples/ckan/components/dataset/About.tsx b/examples/ckan/components/dataset/About.tsx new file mode 100644 index 00000000..ea6910a7 --- /dev/null +++ b/examples/ckan/components/dataset/About.tsx @@ -0,0 +1,83 @@ +import { useQuery } from '@apollo/react-hooks'; +import * as timeago from 'timeago.js'; +import { ErrorMessage } from '../_shared'; +import { GET_DATASET_QUERY } from '../../graphql/queries'; + +const About: React.FC<{ variables: any }> = ({ variables }) => { + const { loading, error, data } = useQuery(GET_DATASET_QUERY, { + variables, + // Setting this value to true will make the component rerender when + // the "networkStatus" changes, so we are able to know if it is fetching + // more data + notifyOnNetworkStatusChange: true, + }); + + if (error) return ; + if (loading) return
Loading
; + + const { result } = data.dataset; + + const stats = [ + { name: 'Files', stat: result.resources.length }, + { name: 'Size', stat: result.size || 'N/A' }, + { + name: 'Formats', + stat: result.resources.map((item) => item.format).join(', '), + }, + { + name: 'Created', + stat: result.created && timeago.format(result.created), + }, + { + name: 'Updated', + stat: result.updated && timeago.format(result.updated), + }, + { + name: 'Licenses', + stat: result.licenses?.length + ? result.licenses.map((item, index) => ( + + {item.name} + + )) + : 'N/A', + }, + ]; + + return ( + <> +
+

+ {result.title || result.name} +

+

+ {result.description || 'This dataset does not have a description.'} +

+
+
+
+ {stats.map((item) => ( +
+
+ {item.name} +
+
+ {item.stat} +
+
+ ))} +
+
+ + ); +}; + +export default About; diff --git a/examples/ckan/components/dataset/Org.tsx b/examples/ckan/components/dataset/Org.tsx new file mode 100644 index 00000000..09272c36 --- /dev/null +++ b/examples/ckan/components/dataset/Org.tsx @@ -0,0 +1,23 @@ +import { useQuery } from '@apollo/react-hooks'; +import { ErrorMessage } from '../_shared'; +import { GET_DATASET_QUERY } from '../../graphql/queries'; +import { Org } from '@portaljs/portaljs-components'; + +const OrgInfo: React.FC<{ variables: any }> = ({ variables }) => { + const { loading, error, data } = useQuery(GET_DATASET_QUERY, { + variables, + // Setting this value to true will make the component rerender when + // the "networkStatus" changes, so we are able to know if it is fetching + // more data + notifyOnNetworkStatusChange: true, + }); + + if (error) return ; + if (loading) return
Loading
; + + const { organization } = data.dataset.result; + + return ; +}; + +export default OrgInfo; diff --git a/examples/ckan/components/dataset/Resources.tsx b/examples/ckan/components/dataset/Resources.tsx new file mode 100644 index 00000000..f2702f60 --- /dev/null +++ b/examples/ckan/components/dataset/Resources.tsx @@ -0,0 +1,76 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ +/* eslint-disable react/display-name */ +import Link from 'next/link'; +import { useQuery } from '@apollo/react-hooks'; +import * as timeago from 'timeago.js'; +import { Table, ErrorMessage } from '../_shared'; +import { GET_DATASET_QUERY } from '../../graphql/queries'; + +const columns = [ + { + name: 'File', + key: 'file', + render: ({ name: resName, title, parentName }) => ( + + {title || resName} + + ), + }, + { + name: 'Format', + key: 'format', + }, + { + name: 'Created', + key: 'created', + render: ({ created }) => timeago.format(created), + }, + { + name: 'Updated', + key: 'updated', + render: ({ updated }) => timeago.format(updated), + }, + { + name: 'Link', + key: 'link', + render: ({ name: resName, parentName }) => ( + + Preview + + ), + }, +]; + +const Resources: React.FC<{ variables: any }> = ({ variables }) => { + const { loading, error, data } = useQuery(GET_DATASET_QUERY, { + variables, + // Setting this value to true will make the component rerender when + // the "networkStatus" changes, so we are able to know if it is fetching + // more data + notifyOnNetworkStatusChange: true, + }); + + if (error) return ; + if (loading) return
Loading
; + + const { result } = data.dataset; + + return ( +
+
+

+ Data files +

+
+ ({ + ...resource, + parentName: result.name, + }))} + /> + + ); +}; + +export default Resources; diff --git a/examples/ckan/components/home/Footer.tsx b/examples/ckan/components/home/Footer.tsx new file mode 100644 index 00000000..4f871814 --- /dev/null +++ b/examples/ckan/components/home/Footer.tsx @@ -0,0 +1,61 @@ +const Footer: React.FC = () => { + const navigation = { + main: [ + { name: 'Blog', href: '/blog' }, + { name: 'Search', href: '/search' }, + { name: 'Docs', href: '/docs' }, + ], + social: [ + { + name: 'GitHub', + href: 'https://github.com/datopian/portaljs', + // eslint-disable-next-line + icon: (props) => ( + + + + ), + }, + ], + }; + + return ( + + ); +}; + +export default Footer; diff --git a/examples/ckan/components/home/Hero.tsx b/examples/ckan/components/home/Hero.tsx new file mode 100644 index 00000000..07479ae0 --- /dev/null +++ b/examples/ckan/components/home/Hero.tsx @@ -0,0 +1,7 @@ +import Template from './HeroTemplate'; + +const Hero: React.FC = () => { + return