From 32be2e9625366aba99160ec361ea93ce38eec9ed Mon Sep 17 00:00:00 2001 From: Jozef Harag <32jojo32@gmail.com> Date: Tue, 6 Oct 2020 16:05:23 +0200 Subject: [PATCH 1/6] Add data provider details page layout --- modules/authors-box/index.jsx | 63 +++++++++ modules/authors-box/styles.module.css | 36 ++++++ modules/{repositories-map => map}/index.jsx | 2 - modules/{repositories-map => map}/map.jsx | 43 +++--- .../styles.module.css | 0 modules/search-layout/index.js | 8 +- modules/search-layout/main.jsx | 12 ++ modules/search-layout/results.jsx | 15 --- .../{content.jsx => sidebar.jsx} | 6 +- modules/search-layout/styles.module.css | 14 +- pages/data-providers/[data-provider-id].jsx | 40 ++++++ store/data-provider.js | 36 ++++++ store/root.js | 6 +- templates/data-provider/claim-card.jsx | 28 ++++ .../hooks/use-state-to-url-effect.js | 32 +++++ templates/data-provider/index.jsx | 34 +++++ templates/data-provider/map-card.jsx | 25 ++++ templates/data-provider/result.jsx | 37 ++++++ templates/data-provider/search.jsx | 55 ++++++++ templates/data-provider/styles.module.css | 122 ++++++++++++++++++ templates/data-providers/index.jsx | 35 +++-- templates/data-providers/result-card.jsx | 15 ++- templates/data-providers/styles.module.css | 1 + 23 files changed, 589 insertions(+), 76 deletions(-) create mode 100644 modules/authors-box/index.jsx create mode 100644 modules/authors-box/styles.module.css rename modules/{repositories-map => map}/index.jsx (80%) rename modules/{repositories-map => map}/map.jsx (69%) rename modules/{repositories-map => map}/styles.module.css (100%) create mode 100644 modules/search-layout/main.jsx delete mode 100644 modules/search-layout/results.jsx rename modules/search-layout/{content.jsx => sidebar.jsx} (62%) create mode 100644 pages/data-providers/[data-provider-id].jsx create mode 100644 store/data-provider.js create mode 100644 templates/data-provider/claim-card.jsx create mode 100644 templates/data-provider/hooks/use-state-to-url-effect.js create mode 100644 templates/data-provider/index.jsx create mode 100644 templates/data-provider/map-card.jsx create mode 100644 templates/data-provider/result.jsx create mode 100644 templates/data-provider/search.jsx create mode 100644 templates/data-provider/styles.module.css diff --git a/modules/authors-box/index.jsx b/modules/authors-box/index.jsx new file mode 100644 index 00000000..03f4ac5e --- /dev/null +++ b/modules/authors-box/index.jsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react' +import { Button, Link } from '@oacore/design' +import { classNames } from '@oacore/design/lib/utils' + +import styles from './styles.module.css' + +const AuthorLink = ({ name }) => ( + + {name.replace(',', ' ')} + +) + +const Authors = ({ authors }) => { + const [isExpanded, setIsExpanded] = useState(false) + if (authors.length <= 3) { + return authors.map((author, index) => ( + <> + + {index < authors.length - 1 ? ', ' : ''} + + )) + } + + return ( + <> + ,{' '} + ,{' '} + + + + {authors.slice(2, -1).map((author) => ( + <> + ,{' '} + + ))} + + + + + ) +} + +export default Authors diff --git a/modules/authors-box/styles.module.css b/modules/authors-box/styles.module.css new file mode 100644 index 00000000..dfab0f71 --- /dev/null +++ b/modules/authors-box/styles.module.css @@ -0,0 +1,36 @@ +.author-link { + color: var(--gray-800); + text-decoration: underline; +} + +.show-more { + position: relative; + padding: 0 0.25rem; + margin: 0 0.25rem; + color: var(--gray-700); + text-transform: uppercase; + white-space: nowrap; + background: var(--gray-100); +} + +.show-more::after { + position: absolute; + right: -0.05rem; + content: ','; +} + +.more-authors { + display: none; +} + +.more-authors-box { + display: contents; +} + +.more-authors-expanded .show-more { + display: none; +} + +.more-authors-expanded .more-authors { + display: inline-block; +} diff --git a/modules/repositories-map/index.jsx b/modules/map/index.jsx similarity index 80% rename from modules/repositories-map/index.jsx rename to modules/map/index.jsx index 2b7e75d4..88089baf 100644 --- a/modules/repositories-map/index.jsx +++ b/modules/map/index.jsx @@ -1,13 +1,11 @@ import React from 'react' import dynamic from 'next/dynamic' -import { ProgressSpinner } from '@oacore/design' import { classNames } from '@oacore/design/lib/utils' import styles from './styles.module.css' const Map = dynamic(() => import('./map'), { ssr: false, - loading: () => , }) const RepositoriesMap = React.memo( diff --git a/modules/repositories-map/map.jsx b/modules/map/map.jsx similarity index 69% rename from modules/repositories-map/map.jsx rename to modules/map/map.jsx index 9f0ff839..6cad1d6c 100644 --- a/modules/repositories-map/map.jsx +++ b/modules/map/map.jsx @@ -16,7 +16,7 @@ const markerIcon = L.icon({ popupAnchor: [0, -32], }) -const CustomMap = ({ dataProviders }) => { +const CustomMap = ({ locations }) => { const mapContainerRef = useRef(null) const map = useRef(null) @@ -34,7 +34,10 @@ const CustomMap = ({ dataProviders }) => { ) map.current = L.map(mapContainerRef.current, { - center: centerPosition, + center: + locations.length > 1 + ? centerPosition + : new L.LatLng(locations[0].latitude, locations[0].longitude), zoom: 2, maxBounds: [ [-90, -180], @@ -52,40 +55,30 @@ const CustomMap = ({ dataProviders }) => { icon: markerIcon, }) - dataProviders - .filter( - ({ name, dataProviderLocation }) => - dataProviderLocation != null && - dataProviderLocation.latitude != null && - dataProviderLocation.longitude != null && - name - ) - .forEach(({ id, name, dataProviderLocation }) => { - const marker = L.marker( - new L.LatLng( - dataProviderLocation.latitude, - dataProviderLocation.longitude - ), - { - title: name, - icon: markerIcon, - } - ) + locations.forEach(({ name, href, latitude, longitude }) => { + const marker = L.marker(new L.LatLng(latitude, longitude), { + title: name, + icon: markerIcon, + }) + + if (href) { marker.bindPopup( ` ${name} ` ) - markers.addLayer(marker) - }) + } else marker.bindPopup(name) + + markers.addLayer(marker) + }) map.current.addLayer(markers) return () => map.current.removeLayer(markers) - }, [dataProviders]) + }, [locations]) return
} diff --git a/modules/repositories-map/styles.module.css b/modules/map/styles.module.css similarity index 100% rename from modules/repositories-map/styles.module.css rename to modules/map/styles.module.css diff --git a/modules/search-layout/index.js b/modules/search-layout/index.js index 9a63c20c..2b357f49 100644 --- a/modules/search-layout/index.js +++ b/modules/search-layout/index.js @@ -1,12 +1,12 @@ -import Content from './content' +import Sidebar from './sidebar' import Result from './result' -import Results from './results' +import Main from './main' import Search from './search' import ResultStats from './result-stats' -Search.Content = Content +Search.Sidebar = Sidebar Search.Result = Result -Search.Results = Results +Search.Main = Main Search.ResultStats = ResultStats export default Search diff --git a/modules/search-layout/main.jsx b/modules/search-layout/main.jsx new file mode 100644 index 00000000..6ff8475d --- /dev/null +++ b/modules/search-layout/main.jsx @@ -0,0 +1,12 @@ +import React from 'react' +import { classNames } from '@oacore/design/lib/utils' + +import styles from './styles.module.css' + +const Main = ({ children, className, tag: Tag = 'div', ...restProps }) => ( + + {children} + +) + +export default Main diff --git a/modules/search-layout/results.jsx b/modules/search-layout/results.jsx deleted file mode 100644 index c7a2be11..00000000 --- a/modules/search-layout/results.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react' -import { classNames } from '@oacore/design/lib/utils' - -import styles from './styles.module.css' - -const Results = ({ children, className, tag: Tag = 'div', ...restProps }) => ( - - {children} - -) - -export default Results diff --git a/modules/search-layout/content.jsx b/modules/search-layout/sidebar.jsx similarity index 62% rename from modules/search-layout/content.jsx rename to modules/search-layout/sidebar.jsx index dbfb9196..ec6317cc 100644 --- a/modules/search-layout/content.jsx +++ b/modules/search-layout/sidebar.jsx @@ -4,14 +4,14 @@ import { Card } from '@oacore/design' import styles from './styles.module.css' -const Content = ({ children, className, tag = 'div', ...restProps }) => ( +const Sidebar = ({ children, className, tag = 'div', ...restProps }) => ( {children} ) -export default Content +export default Sidebar diff --git a/modules/search-layout/styles.module.css b/modules/search-layout/styles.module.css index 828e56c7..e5b15b74 100644 --- a/modules/search-layout/styles.module.css +++ b/modules/search-layout/styles.module.css @@ -5,14 +5,14 @@ grid-auto-rows: auto; } -.search-results { +.main { display: flex; flex-direction: column; grid-column: 1; align-items: stretch; } -.content { +.sidebar { padding-top: 0; border: none; box-shadow: none; @@ -50,20 +50,16 @@ grid-auto-rows: auto; } - .search-results { + .main { display: contents; } - .content { + .sidebar { + display: contents; grid-column: 1; - grid-row: 2; padding-top: 1rem; } - .result-stats ~ .content { - grid-row: 3; - } - .result { margin: 0; } diff --git a/pages/data-providers/[data-provider-id].jsx b/pages/data-providers/[data-provider-id].jsx new file mode 100644 index 00000000..8ad3b780 --- /dev/null +++ b/pages/data-providers/[data-provider-id].jsx @@ -0,0 +1,40 @@ +import React from 'react' + +import DataProvider from 'store/data-provider' +import { useStore, observe } from 'store' +import DataProviderTemplate from 'templates/data-provider' + +export async function getServerSideProps({ query }) { + const id = query['data-provider-id'] + + const outputs = await DataProvider.loadOutputs({ + id, + }) + const metadata = await DataProvider.loadMetadata({ + id, + }) + + return { + props: { + initialState: { + dataProvider: { + outputs, + id: query['data-provider-id'], + metadata, + }, + }, + }, + } +} + +const DataProviderPage = observe(() => { + const { dataProvider } = useStore() + return ( + + ) +}) + +export default DataProviderPage diff --git a/store/data-provider.js b/store/data-provider.js new file mode 100644 index 00000000..dc8dc69c --- /dev/null +++ b/store/data-provider.js @@ -0,0 +1,36 @@ +import { observable } from 'mobx' + +import apiRequest from 'api' + +class DataProvider { + @observable outputs + + @observable page + + @observable metadata + + constructor({ outputs, metadata, page }) { + this.outputs = outputs ?? [] + this.metadata = metadata ?? [] + this.page = page + } + + static async loadOutputs({ id, page = 1 }) { + const params = { + from: (page - 1) * 20, + size: 20, + } + + const { data } = await apiRequest(`/data-providers/${id}/works`, { + searchParams: params, + }) + return data + } + + static async loadMetadata({ id }) { + const { data } = await apiRequest(`/data-providers/${id}`) + return data + } +} + +export default DataProvider diff --git a/store/root.js b/store/root.js index 1eca2f8b..0b3028ac 100644 --- a/store/root.js +++ b/store/root.js @@ -2,16 +2,20 @@ import { observable } from 'mobx' import Claim from './claim' import DataProviders from './data-providers' +import DataProvider from './data-provider' class Root { claim = new Claim() + @observable dataProvider = null + @observable dataProviders = null @observable statistics - init({ dataProviders = {}, statistics = {} } = {}) { + init({ dataProviders = {}, statistics = {}, dataProvider = {} } = {}) { this.dataProviders = new DataProviders(dataProviders) + this.dataProvider = new DataProvider(dataProvider) this.statistics = statistics } } diff --git a/templates/data-provider/claim-card.jsx b/templates/data-provider/claim-card.jsx new file mode 100644 index 00000000..54f8f0c4 --- /dev/null +++ b/templates/data-provider/claim-card.jsx @@ -0,0 +1,28 @@ +import React from 'react' +import { Card, Button } from '@oacore/design' +import { classNames } from '@oacore/design/lib/utils' + +import styles from './styles.module.css' + +const ClaimCard = ({ name }) => ( + +
+ Do you manage {name}? Access insider analytics, issue reports and + manage access to outputs from your repository in + the CORE Dashboard!  + +
+
+ + +
+
+) + +export default ClaimCard diff --git a/templates/data-provider/hooks/use-state-to-url-effect.js b/templates/data-provider/hooks/use-state-to-url-effect.js new file mode 100644 index 00000000..0632da41 --- /dev/null +++ b/templates/data-provider/hooks/use-state-to-url-effect.js @@ -0,0 +1,32 @@ +import { useCallback, useEffect } from 'react' +import { useRouter } from 'next/router' + +const useStateToUrlEffect = ({ query, size, showForm }) => { + const router = useRouter() + const setUrlParams = useCallback( + (params) => { + const url = { + pathname: '/data-providers', + query: { + ...params, + }, + } + router.push(url, url, { shallow: true }) + }, + [router] + ) + + // reflect state to URL + useEffect(() => { + const params = { + query, + size, + } + + if (showForm) params.action = 'add' + + setUrlParams(params) + }, [query, size, showForm]) +} + +export default useStateToUrlEffect diff --git a/templates/data-provider/index.jsx b/templates/data-provider/index.jsx new file mode 100644 index 00000000..d5ff0ee5 --- /dev/null +++ b/templates/data-provider/index.jsx @@ -0,0 +1,34 @@ +import React from 'react' + +import Result from './result' +import DataProviderOutputsSearch from './search' +import ClaimCard from './claim-card' +import MapCard from './map-card' +import styles from './styles.module.css' + +import Search from 'modules/search-layout' + +const DataProviderTemplate = ({ outputs, metadata }) => ( + + + + {metadata.institution} +

{metadata.name}

+
+ + {}} + className={styles.search} + /> + {outputs.map((o) => ( + + ))} +
+ + + + +
+) + +export default DataProviderTemplate diff --git a/templates/data-provider/map-card.jsx b/templates/data-provider/map-card.jsx new file mode 100644 index 00000000..1beb3ad6 --- /dev/null +++ b/templates/data-provider/map-card.jsx @@ -0,0 +1,25 @@ +import React from 'react' +import { Card } from '@oacore/design' +import { classNames } from '@oacore/design/lib/utils' + +import styles from './styles.module.css' + +import Map from 'modules/map' + +const MapCard = ({ metadata }) => ( + + +
{metadata.name}
+
+) + +export default MapCard diff --git a/templates/data-provider/result.jsx b/templates/data-provider/result.jsx new file mode 100644 index 00000000..049819ac --- /dev/null +++ b/templates/data-provider/result.jsx @@ -0,0 +1,37 @@ +import React from 'react' +import { Link } from '@oacore/design' +import { classNames } from '@oacore/design/lib/utils' + +import styles from './styles.module.css' + +import Authors from 'modules/authors-box' +import Search from 'modules/search-layout' + +const Result = ({ id, title, author, publisher, year, abstract }) => ( + +
+ thumb + Free PDF +
+ +
+ + {title} + + + + + + {Boolean(publisher) && ( + {publisher} + )} + {Boolean(year) && ( + {year} + )} + +

{abstract}

+
+
+) + +export default Result diff --git a/templates/data-provider/search.jsx b/templates/data-provider/search.jsx new file mode 100644 index 00000000..2bd44e70 --- /dev/null +++ b/templates/data-provider/search.jsx @@ -0,0 +1,55 @@ +import React, { useCallback } from 'react' +import { throttle } from 'throttle-debounce' +import { Select } from '@oacore/design' + +const DataProviderOutputsSearch = ({ + initQuery, + searchOutputs, + onQueryChanged, + className, +}) => { + const [suggestions, setSuggestions] = React.useState([]) + const [value, setValue] = React.useState(initQuery || '') + + const search = useCallback( + throttle(500, false, (searchTerm) => { + if (!searchOutputs) return + setSuggestions(searchOutputs(searchTerm).slice(0, 10)) + }), + [] + ) + + const handleOnChange = (data) => { + onQueryChanged(data.value) + search(data.value) + } + + const handleOnInput = (data) => { + setValue(data.value) + + // if id doesn't exists it means user type own text + // and didn't use suggestion + if (!data.id) search(data.value) + } + + return ( + + ) +} + +export default DataProviderOutputsSearch diff --git a/templates/data-provider/styles.module.css b/templates/data-provider/styles.module.css new file mode 100644 index 00000000..9bcfa27e --- /dev/null +++ b/templates/data-provider/styles.module.css @@ -0,0 +1,122 @@ +.layout { + z-index: 5; +} + +.title-box { + grid-row: 1; + margin-bottom: 1rem; +} + +.search { + grid-row: 2; +} + +.title { + margin-top: 0; +} + +.card:not(:last-child) { + margin-bottom: 1rem; +} + +.claim-card { + box-shadow: none; +} + +.claim-card-actions { + display: flex; + flex-wrap: wrap; +} + +.claim-card-action { + margin-top: 1rem; + grid-row: 6; +} + +.map-card { + grid-row: 5; +} + +.claim-card-action:not(:last-child) { + margin-right: 0.5rem; +} + +.card .map { + width: auto; + margin: calc(-1 * var(--card-padding-y)) calc(-1 * var(--card-padding-x)) + var(--card-padding-x) calc(-1 * var(--card-padding-x)); +} + +.result { + --card-padding-x: 0.5rem; + --card-padding-y: 0.5rem; + + display: flex; +} + +.result-image { + position: relative; + display: flex; + flex: 0 0 100px; + flex-direction: column; + justify-content: center; + margin-right: 1rem; +} + +.result-image > img { + width: 100%; + height: auto; +} + +.result-content { + display: flex; + flex-direction: column; +} + +/* stylelint-disable property-no-vendor-prefix, value-no-vendor-prefix */ +.result-title { + display: -webkit-box; + overflow: hidden; + font-weight: 500; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.result-abstract { + display: -webkit-box; + -webkit-line-clamp: 7; + -webkit-box-orient: vertical; + overflow: hidden; +} +/* stylelint-enable property-no-vendor-prefix, value-no-vendor-prefix */ +.result-metadata { + display: inline-block; + padding-left: 0; + margin: 0; +} + +.result-metadata-item:not(:first-child) { + position: relative; + padding-left: 1rem; + overflow: hidden; +} + +.result-metadata-item:not(:first-child)::before { + position: absolute; + top: 0.75rem; + left: 0.5rem; + display: inline-block; + width: 0.25rem; + height: 0.25rem; + content: ''; + background: var(--gray-500); + border-radius: 50%; + transform: translate(-50%, -50%); +} + +.free-pdf { + font-size: 0.75rem; + color: var(--white); + text-align: center; + background: var(--primary); +} diff --git a/templates/data-providers/index.jsx b/templates/data-providers/index.jsx index b5ff0c45..937efc56 100644 --- a/templates/data-providers/index.jsx +++ b/templates/data-providers/index.jsx @@ -8,7 +8,22 @@ import AddDataProviderForm from './form' import { formatNumber } from 'utils/format-number' import Search from 'modules/search-layout' -import RepositoriesMap from 'modules/repositories-map' +import Map from 'modules/map' + +const filterAndMapDataProviders = (dataProviders) => + dataProviders + .filter( + ({ name, dataProviderLocation }) => + dataProviderLocation?.latitude != null && + dataProviderLocation?.longitude != null && + name + ) + .map(({ id, name, dataProviderLocation }) => ({ + name, + href: `https://core.ac.uk/search?q=repositories.id:(${id})`, + latitude: dataProviderLocation?.latitude, + longitude: dataProviderLocation?.longitude, + })) const getCountryName = (code) => { const countryName = countries[String(code).toUpperCase()] @@ -31,17 +46,17 @@ const SearchResults = ({ if (!results.length) { return ( - + No results found {children} - + ) } return ( - + {results.slice(0, dataProvidersSize).map((el) => ( )} {children} - + ) } @@ -140,12 +155,12 @@ const DataProvidersSearchTemplate = React.memo(
- - +

We aggregate research papers from data providers all over the world @@ -170,7 +185,7 @@ const DataProvidersSearchTemplate = React.memo( > Become data provider - + ) } diff --git a/templates/data-providers/result-card.jsx b/templates/data-providers/result-card.jsx index 1faf6e92..96723a39 100644 --- a/templates/data-providers/result-card.jsx +++ b/templates/data-providers/result-card.jsx @@ -1,5 +1,6 @@ import React from 'react' -import { Icon, Link } from '@oacore/design' +import { Icon, Link as UILink } from '@oacore/design' +import Link from 'next/link' import styles from './styles.module.css' @@ -17,19 +18,19 @@ const ResultCard = ({ - {title} + {title}

  • - + {homePage} - +
  • {country &&
  • {country}
  • }
diff --git a/templates/data-providers/styles.module.css b/templates/data-providers/styles.module.css index b59c0647..b72b9aab 100644 --- a/templates/data-providers/styles.module.css +++ b/templates/data-providers/styles.module.css @@ -65,6 +65,7 @@ .map { height: 30rem; + grid-row: 3; } @media (--screen-until-desktop) { From fb90d7e6980af3fcd8dbd8441c303f7891572dcd Mon Sep 17 00:00:00 2001 From: Jozef Harag <32jojo32@gmail.com> Date: Tue, 6 Oct 2020 16:05:23 +0200 Subject: [PATCH 2/6] Connect search to API --- pages/data-providers/[data-provider-id].jsx | 30 +++++- store/data-provider.js | 50 ++++++++-- templates/data-provider/hooks/index.js | 1 + .../hooks/use-state-to-url-effect.js | 29 ++++-- templates/data-provider/index.jsx | 92 ++++++++++++++----- templates/data-provider/result.jsx | 37 -------- templates/data-provider/search.jsx | 23 +++-- templates/data-provider/styles.module.css | 74 +-------------- 8 files changed, 179 insertions(+), 157 deletions(-) create mode 100644 templates/data-provider/hooks/index.js delete mode 100644 templates/data-provider/result.jsx diff --git a/pages/data-providers/[data-provider-id].jsx b/pages/data-providers/[data-provider-id].jsx index 8ad3b780..6369af2f 100644 --- a/pages/data-providers/[data-provider-id].jsx +++ b/pages/data-providers/[data-provider-id].jsx @@ -1,14 +1,19 @@ import React from 'react' +import { useStateToUrlEffect } from 'templates/data-provider/hooks' import DataProvider from 'store/data-provider' import { useStore, observe } from 'store' import DataProviderTemplate from 'templates/data-provider' export async function getServerSideProps({ query }) { const id = query['data-provider-id'] + const { q = '', from = 0, size = 10 } = query - const outputs = await DataProvider.loadOutputs({ + const outputs = await DataProvider.fetchOutputs({ id, + q, + from, + size, }) const metadata = await DataProvider.loadMetadata({ id, @@ -19,8 +24,11 @@ export async function getServerSideProps({ query }) { initialState: { dataProvider: { outputs, - id: query['data-provider-id'], metadata, + id, + q, + from, + size, }, }, }, @@ -29,10 +37,28 @@ export async function getServerSideProps({ query }) { const DataProviderPage = observe(() => { const { dataProvider } = useStore() + + useStateToUrlEffect({ + id: dataProvider.id, + q: dataProvider.query, + from: dataProvider.from, + size: dataProvider.size, + }) + return ( { + dataProvider.query = q + }} + loadOutputs={dataProvider.loadOutputs} + from={dataProvider.from} + size={dataProvider.size} + loading={dataProvider.loading} /> ) }) diff --git a/store/data-provider.js b/store/data-provider.js index dc8dc69c..bc301a4d 100644 --- a/store/data-provider.js +++ b/store/data-provider.js @@ -1,24 +1,37 @@ -import { observable } from 'mobx' +import { observable, action } from 'mobx' import apiRequest from 'api' class DataProvider { @observable outputs - @observable page - @observable metadata - constructor({ outputs, metadata, page }) { + @observable id + + @observable query + + @observable from + + @observable size + + @observable loading = false + + constructor({ id, q, from, size, outputs, metadata }) { + this.id = id + this.from = from ?? 0 + this.size = size ?? 10 + this.query = q this.outputs = outputs ?? [] this.metadata = metadata ?? [] - this.page = page } - static async loadOutputs({ id, page = 1 }) { + static async fetchOutputs({ id, from, size, q, ...restParams }) { const params = { - from: (page - 1) * 20, - size: 20, + from, + size, + q, + ...restParams, } const { data } = await apiRequest(`/data-providers/${id}/works`, { @@ -31,6 +44,27 @@ class DataProvider { const { data } = await apiRequest(`/data-providers/${id}`) return data } + + @action + loadOutputs = async () => { + // show loading placeholders only when search takes more than 500ms + const timeout = setTimeout(() => { + this.loading = true + }, 500) + this.loading = true + this.outputs = await DataProvider.fetchOutputs({ + id: this.id, + from: this.from, + size: this.size, + q: this.query, + }) + clearTimeout(timeout) + this.loading = false + } + + @action + loadSuggestions = async (searchTerm) => + DataProvider.fetchOutputs({ id: this.id, q: searchTerm, from: 0, size: 10 }) } export default DataProvider diff --git a/templates/data-provider/hooks/index.js b/templates/data-provider/hooks/index.js new file mode 100644 index 00000000..4e47465f --- /dev/null +++ b/templates/data-provider/hooks/index.js @@ -0,0 +1 @@ +export useStateToUrlEffect from './use-state-to-url-effect' diff --git a/templates/data-provider/hooks/use-state-to-url-effect.js b/templates/data-provider/hooks/use-state-to-url-effect.js index 0632da41..a4eef4e8 100644 --- a/templates/data-provider/hooks/use-state-to-url-effect.js +++ b/templates/data-provider/hooks/use-state-to-url-effect.js @@ -1,17 +1,29 @@ import { useCallback, useEffect } from 'react' import { useRouter } from 'next/router' -const useStateToUrlEffect = ({ query, size, showForm }) => { +const useStateToUrlEffect = ({ id, q, from, size }) => { const router = useRouter() const setUrlParams = useCallback( (params) => { const url = { - pathname: '/data-providers', - query: { - ...params, + href: { + pathname: '/data-providers/[data-provider-id]', + query: { + ...params, + }, + }, + + as: { + pathname: `/data-providers/${id}`, + query: { + ...params, + }, + }, + options: { + shallow: true, }, } - router.push(url, url, { shallow: true }) + router.push(url.href, url.as, url.options) }, [router] ) @@ -19,14 +31,13 @@ const useStateToUrlEffect = ({ query, size, showForm }) => { // reflect state to URL useEffect(() => { const params = { - query, + q, + from, size, } - if (showForm) params.action = 'add' - setUrlParams(params) - }, [query, size, showForm]) + }, [q, from, size]) } export default useStateToUrlEffect diff --git a/templates/data-provider/index.jsx b/templates/data-provider/index.jsx index d5ff0ee5..3c8d3093 100644 --- a/templates/data-provider/index.jsx +++ b/templates/data-provider/index.jsx @@ -1,6 +1,6 @@ import React from 'react' +import { SearchResult } from '@oacore/design' -import Result from './result' import DataProviderOutputsSearch from './search' import ClaimCard from './claim-card' import MapCard from './map-card' @@ -8,27 +8,75 @@ import styles from './styles.module.css' import Search from 'modules/search-layout' -const DataProviderTemplate = ({ outputs, metadata }) => ( - - - - {metadata.institution} -

{metadata.name}

-
+const DataProviderTemplate = ({ + outputs, + metadata, + query, + setQuery, + loadSuggestions, + loadOutputs, + loading, +}) => { + const placeholders = Array.from(Array(10).keys()) - {}} - className={styles.search} - /> - {outputs.map((o) => ( - - ))} -
- - - - -
-) + return ( + + + + {metadata.institution} +

{metadata.name}

+
+ + { + setQuery(q) + loadOutputs() + }} + className={styles.search} + loadSuggestions={loadSuggestions} + /> + {loading + ? placeholders.map((key) => ( + +

+ + )) + : outputs.map(({ id, abstract, title, author, year, link }) => { + const fullTextLink = link.find((l) => l.type === 'download')?.url + const metadataLink = link.find((l) => l.type === 'display')?.url + return ( + + {abstract} + + ) + })} + {outputs.length === 0 && ( +

No results found
+ )} +
+ + + + +
+ ) +} export default DataProviderTemplate diff --git a/templates/data-provider/result.jsx b/templates/data-provider/result.jsx deleted file mode 100644 index 049819ac..00000000 --- a/templates/data-provider/result.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react' -import { Link } from '@oacore/design' -import { classNames } from '@oacore/design/lib/utils' - -import styles from './styles.module.css' - -import Authors from 'modules/authors-box' -import Search from 'modules/search-layout' - -const Result = ({ id, title, author, publisher, year, abstract }) => ( - -
- thumb - Free PDF -
- -
- - {title} - - - - - - {Boolean(publisher) && ( - {publisher} - )} - {Boolean(year) && ( - {year} - )} - -

{abstract}

-
-
-) - -export default Result diff --git a/templates/data-provider/search.jsx b/templates/data-provider/search.jsx index 2bd44e70..3dde355a 100644 --- a/templates/data-provider/search.jsx +++ b/templates/data-provider/search.jsx @@ -1,25 +1,34 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useState } from 'react' import { throttle } from 'throttle-debounce' import { Select } from '@oacore/design' const DataProviderOutputsSearch = ({ initQuery, - searchOutputs, + loadSuggestions, onQueryChanged, className, }) => { const [suggestions, setSuggestions] = React.useState([]) const [value, setValue] = React.useState(initQuery || '') + const [isInitialised, setIsInitialised] = useState(false) const search = useCallback( - throttle(500, false, (searchTerm) => { - if (!searchOutputs) return - setSuggestions(searchOutputs(searchTerm).slice(0, 10)) + throttle(500, false, async (searchTerm) => { + const newSuggestions = await loadSuggestions(searchTerm) + setSuggestions(newSuggestions) }), [] ) const handleOnChange = (data) => { + // TODO: Consider this having in @oacore/design + // prevent firing callback in initialisation (we have the data prefetched + // on the server side already + if (!isInitialised) { + setIsInitialised(true) + return + } + onQueryChanged(data.value) search(data.value) } @@ -44,8 +53,8 @@ const DataProviderOutputsSearch = ({ className={className} > {suggestions.map((el) => ( - - {el.name} + + {el.title} ))} diff --git a/templates/data-provider/styles.module.css b/templates/data-provider/styles.module.css index 9bcfa27e..af91a8d0 100644 --- a/templates/data-provider/styles.module.css +++ b/templates/data-provider/styles.module.css @@ -47,76 +47,6 @@ var(--card-padding-x) calc(-1 * var(--card-padding-x)); } -.result { - --card-padding-x: 0.5rem; - --card-padding-y: 0.5rem; - - display: flex; -} - -.result-image { - position: relative; - display: flex; - flex: 0 0 100px; - flex-direction: column; - justify-content: center; - margin-right: 1rem; -} - -.result-image > img { - width: 100%; - height: auto; -} - -.result-content { - display: flex; - flex-direction: column; -} - -/* stylelint-disable property-no-vendor-prefix, value-no-vendor-prefix */ -.result-title { - display: -webkit-box; - overflow: hidden; - font-weight: 500; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; -} - -.result-abstract { - display: -webkit-box; - -webkit-line-clamp: 7; - -webkit-box-orient: vertical; - overflow: hidden; -} -/* stylelint-enable property-no-vendor-prefix, value-no-vendor-prefix */ -.result-metadata { - display: inline-block; - padding-left: 0; - margin: 0; -} - -.result-metadata-item:not(:first-child) { - position: relative; - padding-left: 1rem; - overflow: hidden; -} - -.result-metadata-item:not(:first-child)::before { - position: absolute; - top: 0.75rem; - left: 0.5rem; - display: inline-block; - width: 0.25rem; - height: 0.25rem; - content: ''; - background: var(--gray-500); - border-radius: 50%; - transform: translate(-50%, -50%); -} - -.free-pdf { - font-size: 0.75rem; - color: var(--white); - text-align: center; - background: var(--primary); +.no-results-find { + margin-top: 1rem; } From 9824666d33c0adb77b1e4d7db57e4b7e9b62e89e Mon Sep 17 00:00:00 2001 From: Jozef Harag <32jojo32@gmail.com> Date: Tue, 3 Nov 2020 09:24:58 +0100 Subject: [PATCH 3/6] Add pagination --- design.config.js | 8 +- hooks/use-sync-url-params-with-store/index.js | 82 +++++++---- main/hooks.js | 26 ---- pages/_app.jsx | 5 +- pages/data-providers/[data-provider-id].jsx | 102 +++++++++---- pages/data-providers/index.jsx | 4 +- store/data-provider.js | 87 +++++++---- store/data-providers.js | 15 +- store/index.jsx | 23 +-- store/params.js | 65 ++------- store/root.js | 12 +- templates/data-provider/hooks/index.js | 1 - .../hooks/use-state-to-url-effect.js | 43 ------ templates/data-provider/index.jsx | 33 ++++- templates/data-provider/pagination.jsx | 136 ++++++++++++++++++ templates/data-provider/search.jsx | 26 +--- templates/data-provider/styles.module.css | 35 +++++ templates/data-providers/result-card.jsx | 6 +- utils/validation.js | 54 +++++++ 19 files changed, 503 insertions(+), 260 deletions(-) delete mode 100644 main/hooks.js delete mode 100644 templates/data-provider/hooks/index.js delete mode 100644 templates/data-provider/hooks/use-state-to-url-effect.js create mode 100644 templates/data-provider/pagination.jsx create mode 100644 utils/validation.js diff --git a/design.config.js b/design.config.js index 6831227c..1b06facd 100644 --- a/design.config.js +++ b/design.config.js @@ -1,6 +1,12 @@ const path = require('path') -const icons = ['office-building'] +const icons = [ + 'office-building', + 'chevron-left', + 'chevron-right', + 'chevron-double-left', + 'chevron-double-right', +] const iconsRoot = path.join( path.dirname(require.resolve('@mdi/svg/package.json')), diff --git a/hooks/use-sync-url-params-with-store/index.js b/hooks/use-sync-url-params-with-store/index.js index d05919b8..fd815b42 100644 --- a/hooks/use-sync-url-params-with-store/index.js +++ b/hooks/use-sync-url-params-with-store/index.js @@ -1,38 +1,64 @@ -import { useEffect, useCallback } from 'react' +import { useEffect } from 'react' import { useRouter } from 'next/router' -export const useSyncUrlParamsWithStore = (params) => { +import useDebouncedEffect from 'hooks/use-debounced-effect' + +export const useSyncUrlParamsWithStore = (paramsInstance) => { const router = useRouter() const setUrlParams = () => { - const newParams = Array.from(params.entries()).filter( + const { schema, ...params } = paramsInstance + + const newParams = Object.entries(params).filter( ([, value]) => value != null ) - const query = Object.fromEntries(newParams) - router.push( - { pathname: router.pathname, query }, - { - // asPath contains url params so we need to get rid of them - // and replace them with the new ones. - pathname: new URL(router.route, window.location.origin).pathname, - query, - }, - { shallow: true } + const { pathname: pathnameUrl } = new URL( + router.asPath, + window.location.origin + ) + const { pathname: pathnameAs } = new URL( + router.pathname, + window.location.origin ) + + const urlParams = new URLSearchParams(newParams) + + urlParams.sort() + + if ( + `${window.location.pathname}${window.location.search}` !== + `${pathnameUrl}?${urlParams.toString()}` + ) { + const updateParams = + window.location.search === '' ? router.replace : router.push + + updateParams( + { + pathname: `${pathnameAs}`, + query: { ...router.query, ...urlParams }, + }, + `${pathnameUrl}?${urlParams.toString()}`, + + { shallow: true } + ) + } } - const handleRouteChange = useCallback( - (nextUrl) => { - const { searchParams } = new URL(nextUrl, window.location.origin) - Array.from(searchParams.entries()).forEach(([key, value]) => { + const handleRouteChange = (nextUrl) => { + const { schema, ...params } = paramsInstance + const { searchParams } = new URL(nextUrl, window.location.origin) + + const newParams = Array.from(searchParams.entries()).filter( + ([key, value]) => // We want to compare like it because URLSearch params doesn't // automatically convert param (e.g. size) to number. // eslint-disable-next-line eqeqeq - if (params.has(key) && params[key] != value) params[key] = value - }) - }, - [params] - ) + params[key] != value + ) + + if (newParams.length) + paramsInstance.changeParams(Object.fromEntries(newParams)) + } useEffect(() => { router.events.on('routeChangeComplete', handleRouteChange) @@ -42,9 +68,15 @@ export const useSyncUrlParamsWithStore = (params) => { }, []) // whenever any param changes in store reflect it to the URL - useEffect(() => { - setUrlParams() - }, Array.from(params.values())) + useDebouncedEffect( + () => { + setUrlParams() + }, + Object.keys(paramsInstance) + .sort() + .filter((key) => key !== 'schema') + .map((key) => paramsInstance[key]) + ) } export default useSyncUrlParamsWithStore diff --git a/main/hooks.js b/main/hooks.js deleted file mode 100644 index 11df8d62..00000000 --- a/main/hooks.js +++ /dev/null @@ -1,26 +0,0 @@ -import { join } from 'path' - -import { useEffect } from 'react' -import { useRouter } from 'next/router' - -const useRedirect = ( - routePath, - actualPath = routePath, - { replace = true } = {} -) => { - const router = useRouter() - - useEffect(() => { - // Do not need to redirect - if (!routePath) return - - const pathname = join(router.pathname, routePath) - const asPath = join(router.asPath, actualPath) - if (replace) router.replace(pathname, asPath) - else router.push(pathname, asPath) - }, []) -} - -// Keeping default way to import hooks -// eslint-disable-next-line import/prefer-default-export -export { useRedirect } diff --git a/pages/_app.jsx b/pages/_app.jsx index 8660de65..c5173b7d 100644 --- a/pages/_app.jsx +++ b/pages/_app.jsx @@ -36,10 +36,9 @@ class App extends NextApp { render() { const { Component, pageProps, statistics } = this.props - const { initialState, ...restPageProps } = pageProps || {} return ( -
- +
+
) } diff --git a/pages/data-providers/[data-provider-id].jsx b/pages/data-providers/[data-provider-id].jsx index 6369af2f..584dc075 100644 --- a/pages/data-providers/[data-provider-id].jsx +++ b/pages/data-providers/[data-provider-id].jsx @@ -1,64 +1,116 @@ -import React from 'react' +import React, { useEffect, useRef } from 'react' +import { useRouter } from 'next/router' -import { useStateToUrlEffect } from 'templates/data-provider/hooks' -import DataProvider from 'store/data-provider' +import { useSyncUrlParamsWithStore } from 'hooks/use-sync-url-params-with-store' +import { convertAndValidate } from 'utils/validation' +import DataProvider, { schema } from 'store/data-provider' import { useStore, observe } from 'store' import DataProviderTemplate from 'templates/data-provider' export async function getServerSideProps({ query }) { const id = query['data-provider-id'] - const { q = '', from = 0, size = 10 } = query + const params = convertAndValidate({ + params: Object.fromEntries( + Object.entries(query).filter(([, v]) => v != null) + ), + schema, + }) + + const { q, from, size } = params - const outputs = await DataProvider.fetchOutputs({ + const outputsPromise = DataProvider.fetchOutputs({ id, q, from, size, }) - const metadata = await DataProvider.loadMetadata({ + const metadataPromise = DataProvider.fetchMetadata({ id, }) + const [outputs, metadata] = await Promise.allSettled([ + outputsPromise, + metadataPromise, + ]) + return { props: { initialState: { dataProvider: { - outputs, - metadata, + outputs: outputs.value, + metadata: metadata.value, + params, id, - q, - from, - size, }, }, }, } } -const DataProviderPage = observe(() => { - const { dataProvider } = useStore() +const DataProviderPage = observe(({ initialState }) => { + const router = useRouter() + const store = useStore( + initialState ?? { + initialState: { + dataProvider: { + id: router.query['data-provider-id'], + }, + }, + } + ) + const { dataProvider } = store + const previousQuery = useRef(dataProvider.params.q) + const previousFrom = useRef(dataProvider.params.from) + const previousSize = useRef(dataProvider.params.size) + + useSyncUrlParamsWithStore(dataProvider.params) - useStateToUrlEffect({ - id: dataProvider.id, - q: dataProvider.query, - from: dataProvider.from, - size: dataProvider.size, - }) + useEffect(() => { + // whenever query changes go back to first page + if (previousQuery.current !== dataProvider.params.q) { + dataProvider.params.changeParams({ + size: 10, + from: 0, + }) + } + + const isLoadMore = + previousQuery.current === dataProvider.params.q && + previousFrom.current === dataProvider.params.from && + previousSize.current !== dataProvider.params.size + + const hasChanged = + previousQuery.current !== dataProvider.params.q || + previousFrom.current !== dataProvider.params.from || + previousSize.current !== dataProvider.params.size + + previousQuery.current = dataProvider.params.q + previousFrom.current = dataProvider.params.from + previousSize.current = dataProvider.params.size + + if (hasChanged) dataProvider.loadOutputs({ loadMore: isLoadMore }) + }, [ + dataProvider.params.size, + dataProvider.params.from, + dataProvider.params.q, + ]) return ( { - dataProvider.query = q + dataProvider.params.q = q }} - loadOutputs={dataProvider.loadOutputs} - from={dataProvider.from} - size={dataProvider.size} + from={dataProvider.params.from} + size={dataProvider.params.size} + // TODO: Get proper value from API + total={50000} loading={dataProvider.loading} + basePath={`/data-providers/${dataProvider.id}`} + isLoadingMore={dataProvider.isLoadingMore} /> ) }) diff --git a/pages/data-providers/index.jsx b/pages/data-providers/index.jsx index f6e75b14..9598b912 100644 --- a/pages/data-providers/index.jsx +++ b/pages/data-providers/index.jsx @@ -35,8 +35,8 @@ export async function getServerSideProps({ query }) { } } -const SearchPage = observe(() => { - const { dataProviders, statistics } = useStore() +const SearchPage = observe(({ initialState }) => { + const { dataProviders, statistics } = useStore(initialState) const { params: { action, query, size }, results, diff --git a/store/data-provider.js b/store/data-provider.js index bc301a4d..c2d88227 100644 --- a/store/data-provider.js +++ b/store/data-provider.js @@ -1,29 +1,52 @@ import { observable, action } from 'mobx' +import Params from './params' + import apiRequest from 'api' -class DataProvider { - @observable outputs +export const schema = { + q: { + default: '', + convert: 'string', + validation: 'string', + }, + + from: { + default: 0, + validation: 'number', + convert: 'number', + }, + size: { + default: 10, + validation: 'number', + convert: 'number', + }, +} +class DataProvider { @observable metadata @observable id - @observable query + @observable loading = false - @observable from + @observable isLoadingMore = false - @observable size + @observable params - @observable loading = false + @observable outputsData + + @observable outputs - constructor({ id, q, from, size, outputs, metadata }) { + constructor({ id, outputs, metadata, params = {} }) { this.id = id - this.from = from ?? 0 - this.size = size ?? 10 - this.query = q - this.outputs = outputs ?? [] - this.metadata = metadata ?? [] + this.params = new Params(params, schema) + + if (!outputs) this.loadOutputs() + else this.outputs = outputs ?? [] + + if (!metadata) this.loadMetadata() + else this.metadata = metadata } static async fetchOutputs({ id, from, size, q, ...restParams }) { @@ -40,31 +63,37 @@ class DataProvider { return data } - static async loadMetadata({ id }) { + static async fetchMetadata({ id }) { const { data } = await apiRequest(`/data-providers/${id}`) return data } @action - loadOutputs = async () => { - // show loading placeholders only when search takes more than 500ms - const timeout = setTimeout(() => { - this.loading = true - }, 500) - this.loading = true - this.outputs = await DataProvider.fetchOutputs({ - id: this.id, - from: this.from, - size: this.size, - q: this.query, - }) - clearTimeout(timeout) - this.loading = false + loadMetadata = async () => { + this.metadata = await DataProvider.fetchMetadata({ id: this.id }) } @action - loadSuggestions = async (searchTerm) => - DataProvider.fetchOutputs({ id: this.id, q: searchTerm, from: 0, size: 10 }) + loadOutputs = async ({ loadMore = false } = {}) => { + this.loading = true + this.isLoadingMore = loadMore + + this.outputs = (loadMore ? this.outputs : []).concat( + await DataProvider.fetchOutputs({ + id: this.id, + from: loadMore + ? this.params.from + this.outputs.length + : this.params.from, + size: loadMore + ? this.params.size - this.outputs.length + : this.params.size, + q: this.params.q, + }) + ) + + this.loading = false + this.isLoadingMore = false + } } export default DataProvider diff --git a/store/data-providers.js b/store/data-providers.js index d3ebb54a..b67aaffc 100644 --- a/store/data-providers.js +++ b/store/data-providers.js @@ -2,30 +2,31 @@ import { observable, computed } from 'mobx' import Params from './params' -const schema = { - query: { +export const schema = { + q: { default: '', - convert: (value) => value.toString(), + convert: 'string', + validation: 'string', }, size: { default: 10, validation: 'number', - convert: (value) => Number(value), + convert: 'number', }, action: { default: null, - validation: (value) => ['ADD', null].includes(value), + validation: ['ADD', null], }, } class DataProviders { @observable data - @observable params + params @computed get results() { - return this.search(this.params.query) + return this.search(this.params.q) } constructor({ data, params = {} }) { diff --git a/store/index.jsx b/store/index.jsx index abd4b9a5..2041a3a2 100644 --- a/store/index.jsx +++ b/store/index.jsx @@ -1,4 +1,4 @@ -import React, { createContext, useMemo, useContext } from 'react' +import React, { createContext, useMemo, useContext, useRef } from 'react' import { useStaticRendering, observer } from 'mobx-react-lite' import RootStore from './root' @@ -10,17 +10,14 @@ let store = null useStaticRendering(isServer) export const initStore = (initialData) => { - const storeInner = store ?? new RootStore() - storeInner.init(initialData) - // on the server-side a new instance is created for each page request // as we don't want to mix between users/requests, etc. - if (isServer) return storeInner + if (isServer) return new RootStore(initialData) // Create the store once in the client - if (!store) store = storeInner + if (!store) store = new RootStore(initialData) - return storeInner + return store } const StoreContext = createContext({}) @@ -28,7 +25,17 @@ export const StoreProvider = ({ children, store: storeInner }) => ( {children} ) -export const useStore = () => useContext(StoreContext) +export const useStore = (initialState) => { + const isInitialized = useRef(false) + const storeContext = useContext(StoreContext) + + if (isInitialized.current === false && initialState) { + storeContext.extend(initialState) + isInitialized.current = true + } + + return storeContext +} export const useInitStore = (initialState) => useMemo(() => initStore(initialState), []) diff --git a/store/params.js b/store/params.js index ba0b9ac6..3ec47c9c 100644 --- a/store/params.js +++ b/store/params.js @@ -1,66 +1,27 @@ -import { observable } from 'mobx' +import { action, extendObservable } from 'mobx' -const validate = (keySchema, value) => { - if (typeof keySchema.validation === 'function') - return keySchema.validation(value) - - if (keySchema.validation === 'number') return Number.isInteger(value) - - return true -} - -const convert = (keySchema, value) => { - if (typeof keySchema.convert === 'function') return keySchema.convert(value) - - return value -} +import { convertAndValidate } from 'utils/validation' class Params { - @observable paramsMap = new Map() - schema constructor(params, schema) { - this.paramsSchema = params this.schema = schema - const proxyParams = new Proxy(this.paramsMap, { - set: (object, key, value) => { - if (key in this.schema) { - const keySchema = this.schema[key] - let keyValue = value - - try { - keyValue = convert(keySchema, keyValue) - if (!validate(keySchema, keyValue)) keyValue = keySchema.default - } catch (error) { - keyValue = keySchema.default - } - - object.set(key, keyValue) - return true - } + extendObservable(this, convertAndValidate({ schema, params })) + } - if (process.env.NODE_ENV === 'development') { - console.error( - `Trying to access non-existing property ${key} on Params Proxy` - ) - } - return false - }, - get: (object, key) => { - if (typeof object[key] === 'function') - return (...args) => Reflect.apply(object[key], object, args) + @action + changeParams({ ...params }) { + const { schema, ...assignedParams } = this - return object.get(key) + const newParams = convertAndValidate({ + schema: this.schema, + params: { + ...assignedParams, + ...params, }, }) - - Object.entries(schema).forEach(([key, value]) => { - if (key in params) proxyParams[key] = params[key] - else proxyParams[key] = value.default - }) - - return proxyParams + Object.assign(this, newParams) } } diff --git a/store/root.js b/store/root.js index 0b3028ac..396837d9 100644 --- a/store/root.js +++ b/store/root.js @@ -13,11 +13,17 @@ class Root { @observable statistics - init({ dataProviders = {}, statistics = {}, dataProvider = {} } = {}) { - this.dataProviders = new DataProviders(dataProviders) - this.dataProvider = new DataProvider(dataProvider) + constructor({ statistics }) { this.statistics = statistics } + + extend({ dataProviders, dataProvider }) { + if (!this.dataProviders && dataProviders != null) + this.dataProviders = new DataProviders(dataProviders) + + if (!this.dataProvider && dataProvider != null) + this.dataProvider = new DataProvider(dataProvider) + } } export default Root diff --git a/templates/data-provider/hooks/index.js b/templates/data-provider/hooks/index.js deleted file mode 100644 index 4e47465f..00000000 --- a/templates/data-provider/hooks/index.js +++ /dev/null @@ -1 +0,0 @@ -export useStateToUrlEffect from './use-state-to-url-effect' diff --git a/templates/data-provider/hooks/use-state-to-url-effect.js b/templates/data-provider/hooks/use-state-to-url-effect.js deleted file mode 100644 index a4eef4e8..00000000 --- a/templates/data-provider/hooks/use-state-to-url-effect.js +++ /dev/null @@ -1,43 +0,0 @@ -import { useCallback, useEffect } from 'react' -import { useRouter } from 'next/router' - -const useStateToUrlEffect = ({ id, q, from, size }) => { - const router = useRouter() - const setUrlParams = useCallback( - (params) => { - const url = { - href: { - pathname: '/data-providers/[data-provider-id]', - query: { - ...params, - }, - }, - - as: { - pathname: `/data-providers/${id}`, - query: { - ...params, - }, - }, - options: { - shallow: true, - }, - } - router.push(url.href, url.as, url.options) - }, - [router] - ) - - // reflect state to URL - useEffect(() => { - const params = { - q, - from, - size, - } - - setUrlParams(params) - }, [q, from, size]) -} - -export default useStateToUrlEffect diff --git a/templates/data-provider/index.jsx b/templates/data-provider/index.jsx index 3c8d3093..98d7ee64 100644 --- a/templates/data-provider/index.jsx +++ b/templates/data-provider/index.jsx @@ -5,17 +5,21 @@ import DataProviderOutputsSearch from './search' import ClaimCard from './claim-card' import MapCard from './map-card' import styles from './styles.module.css' +import Pagination from './pagination' import Search from 'modules/search-layout' const DataProviderTemplate = ({ + basePath, outputs, metadata, query, setQuery, - loadSuggestions, - loadOutputs, loading, + isLoadingMore, + from, + size, + total, }) => { const placeholders = Array.from(Array(10).keys()) @@ -31,15 +35,14 @@ const DataProviderTemplate = ({ initQuery={query} onQueryChanged={(q) => { setQuery(q) - loadOutputs() }} className={styles.search} - loadSuggestions={loadSuggestions} /> - {loading + {loading && !isLoadingMore ? placeholders.map((key) => ( @@ -57,7 +60,7 @@ const DataProviderTemplate = ({ data={{ title, author, - publicationDate: year, + publicationDate: String(year), thumbnailUrl: `//core.ac.uk/image/${id}/medium`, metadataLink, fullTextLink, @@ -67,9 +70,27 @@ const DataProviderTemplate = ({ ) })} + {isLoadingMore && + placeholders.map((key) => ( + +

+ + ))} {outputs.length === 0 && (

No results found
)} + diff --git a/templates/data-provider/pagination.jsx b/templates/data-provider/pagination.jsx new file mode 100644 index 00000000..0979634a --- /dev/null +++ b/templates/data-provider/pagination.jsx @@ -0,0 +1,136 @@ +import React from 'react' +import { Button, Icon } from '@oacore/design' +import Link from 'next/link' +import { classNames } from '@oacore/design/lib/utils' + +import styles from './styles.module.css' + +import Search from 'modules/search-layout' + +const getPaginationState = ({ from, size, total }) => ({ + loadMore: size < total, + previous: from > 0, + next: from + size < total, + first: from > 0, + last: from + size < total, +}) + +const Pagination = ({ basePath, from, size, total, ...restParams }) => { + const hrefBuilder = ({ next, previous, first, last, loadMore }) => { + const params = { + from, + size, + ...restParams, + } + + if (!loadMore) params.size = 10 + if (next) params.from = from + size + if (previous) params.from = Math.max(from - 10, 0) + if (first) params.from = 0 + if (last) params.from = total - 10 + if (loadMore) params.size = Math.min(size + 10, total) + + const urlParams = new URLSearchParams(params) + urlParams.sort() + + return `${basePath}?${urlParams.toString()}` + } + + const paginationState = getPaginationState({ from, size, total }) + + return ( + + ) +} + +export default Pagination diff --git a/templates/data-provider/search.jsx b/templates/data-provider/search.jsx index 3dde355a..3c3b56e0 100644 --- a/templates/data-provider/search.jsx +++ b/templates/data-provider/search.jsx @@ -1,25 +1,14 @@ -import React, { useCallback, useState } from 'react' -import { throttle } from 'throttle-debounce' +import React, { useState } from 'react' import { Select } from '@oacore/design' const DataProviderOutputsSearch = ({ initQuery, - loadSuggestions, onQueryChanged, className, }) => { - const [suggestions, setSuggestions] = React.useState([]) const [value, setValue] = React.useState(initQuery || '') const [isInitialised, setIsInitialised] = useState(false) - const search = useCallback( - throttle(500, false, async (searchTerm) => { - const newSuggestions = await loadSuggestions(searchTerm) - setSuggestions(newSuggestions) - }), - [] - ) - const handleOnChange = (data) => { // TODO: Consider this having in @oacore/design // prevent firing callback in initialisation (we have the data prefetched @@ -30,15 +19,10 @@ const DataProviderOutputsSearch = ({ } onQueryChanged(data.value) - search(data.value) } const handleOnInput = (data) => { setValue(data.value) - - // if id doesn't exists it means user type own text - // and didn't use suggestion - if (!data.id) search(data.value) } return ( @@ -51,13 +35,7 @@ const DataProviderOutputsSearch = ({ placeholder="Search over 11K research outputs in Open Research Online" prependIcon="#magnify" className={className} - > - {suggestions.map((el) => ( - - {el.title} - - ))} - + /> ) } diff --git a/templates/data-provider/styles.module.css b/templates/data-provider/styles.module.css index af91a8d0..86533922 100644 --- a/templates/data-provider/styles.module.css +++ b/templates/data-provider/styles.module.css @@ -50,3 +50,38 @@ .no-results-find { margin-top: 1rem; } + +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 2rem; +} + +.pagination .arrow-pagination { + display: flex; + align-items: center; +} + +.pagination .arrow { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + padding: 0; + color: var(--gray-700); + border: none; + border-radius: 2rem; +} + +.result-stats { + margin-right: 1rem; +} + +.pagination .disabled { + text-decoration: none; + pointer-events: none; + cursor: not-allowed; + opacity: 0.5; +} diff --git a/templates/data-providers/result-card.jsx b/templates/data-providers/result-card.jsx index 96723a39..072ddff8 100644 --- a/templates/data-providers/result-card.jsx +++ b/templates/data-providers/result-card.jsx @@ -17,11 +17,7 @@ const ResultCard = ({ - + {title} diff --git a/utils/validation.js b/utils/validation.js new file mode 100644 index 00000000..918cc277 --- /dev/null +++ b/utils/validation.js @@ -0,0 +1,54 @@ +const validate = (keySchema, keyValue) => { + if (!keySchema) return true + + if (Array.isArray(keySchema.validation)) + return keySchema.validation.includes(keyValue) + + if (keySchema.validation === 'number') return Number.isInteger(keyValue) + if (keySchema.validation === 'string') return typeof keyValue === 'string' + + return true +} + +const convert = (keySchema, keyValue) => { + if (!keySchema || keyValue === undefined) return keyValue + + if (keySchema.convert === 'number') return Number(keyValue) + + if (keySchema.convert === 'string') return keyValue.toString() + + return keyValue +} + +const processKey = (keySchema, keyValue) => { + const convertedValue = convert(keySchema, keyValue) + if (validate(keySchema, convertedValue)) return convertedValue + + const defaultValue = keySchema.default + if (defaultValue === undefined) { + throw Error( + `Value for schema ${keySchema} is invalid. Default value is not specified` + ) + } else return defaultValue +} + +export const convertAndValidate = ({ schema, params, key = null }) => { + // request for validate specific key + if (key) return processKey(schema[key], params[key]) + + const schemaKeys = Object.keys(schema) + + // for the simplicity and our use-case we take all keys in schema + // and process them, i.e. if params object contains any key + // which is not specified in schema it gets removed. + // At the same time if key we apply default values + // if key is in schema but not in params + return Object.fromEntries( + schemaKeys.map((schemaKey) => [ + schemaKey, + processKey(schema[schemaKey], params[schemaKey]), + ]) + ) +} + +export default convertAndValidate From 660169101d5a4db9aa9d5e0ee41c5c372f7fedfb Mon Sep 17 00:00:00 2001 From: Jozef Harag <32jojo32@gmail.com> Date: Wed, 11 Nov 2020 08:47:56 +0100 Subject: [PATCH 4/6] Temporarily hide claim card --- templates/data-provider/index.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/data-provider/index.jsx b/templates/data-provider/index.jsx index 98d7ee64..1ead5233 100644 --- a/templates/data-provider/index.jsx +++ b/templates/data-provider/index.jsx @@ -2,7 +2,7 @@ import React from 'react' import { SearchResult } from '@oacore/design' import DataProviderOutputsSearch from './search' -import ClaimCard from './claim-card' +// import ClaimCard from './claim-card' import MapCard from './map-card' import styles from './styles.module.css' import Pagination from './pagination' @@ -94,7 +94,7 @@ const DataProviderTemplate = ({ - + {/* */} ) From 5d7500c6250cb378b4e947e195da87be878d1a0c Mon Sep 17 00:00:00 2001 From: Jozef Harag <32jojo32@gmail.com> Date: Mon, 16 Nov 2020 12:18:35 +0100 Subject: [PATCH 5/6] Fix params --- pages/data-providers/index.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pages/data-providers/index.jsx b/pages/data-providers/index.jsx index 9598b912..84f53c18 100644 --- a/pages/data-providers/index.jsx +++ b/pages/data-providers/index.jsx @@ -65,9 +65,9 @@ const SearchPage = observe(({ initialState }) => { ) const setQuery = useCallback( (q) => { - dataProviders.params.query = q + dataProviders.params.q = q }, - [dataProviders.params.query] + [dataProviders.params.q] ) const handleUrlChange = useCallback( (event) => { From 2ea320748bbd06a22442bb1cbb9958f0e929a7b8 Mon Sep 17 00:00:00 2001 From: Jozef Harag <32jojo32@gmail.com> Date: Mon, 16 Nov 2020 15:27:47 +0100 Subject: [PATCH 6/6] Add meta tags --- pages/data-providers/[data-provider-id].jsx | 47 ++++++++++++++------- pages/data-providers/index.jsx | 2 +- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/pages/data-providers/[data-provider-id].jsx b/pages/data-providers/[data-provider-id].jsx index 584dc075..f931ab61 100644 --- a/pages/data-providers/[data-provider-id].jsx +++ b/pages/data-providers/[data-provider-id].jsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef } from 'react' import { useRouter } from 'next/router' +import Head from 'next/head' import { useSyncUrlParamsWithStore } from 'hooks/use-sync-url-params-with-store' import { convertAndValidate } from 'utils/validation' @@ -96,22 +97,36 @@ const DataProviderPage = observe(({ initialState }) => { ]) return ( - { - dataProvider.params.q = q - }} - from={dataProvider.params.from} - size={dataProvider.params.size} - // TODO: Get proper value from API - total={50000} - loading={dataProvider.loading} - basePath={`/data-providers/${dataProvider.id}`} - isLoadingMore={dataProvider.isLoadingMore} - /> + <> + + + {dataProvider.params.q + ? `Search "${dataProvider.params.q}" in ${dataProvider.metadata.name}` + : `${dataProvider.metadata.name}`} + Data providers search + + + + { + dataProvider.params.q = q + }} + from={dataProvider.params.from} + size={dataProvider.params.size} + // TODO: Get proper value from API + total={50000} + loading={dataProvider.loading} + basePath={`/data-providers/${dataProvider.id}`} + isLoadingMore={dataProvider.isLoadingMore} + /> + ) }) diff --git a/pages/data-providers/index.jsx b/pages/data-providers/index.jsx index 84f53c18..13d24d14 100644 --- a/pages/data-providers/index.jsx +++ b/pages/data-providers/index.jsx @@ -82,7 +82,7 @@ const SearchPage = observe(({ initialState }) => { {query ? `${query} - ` : ''}Data providers search {/* eslint-disable react/no-danger */}