From afeeb64230ea55b2330f529d30de5e7c57801e1f Mon Sep 17 00:00:00 2001 From: "Dusan Mijatovic (PC2020)" Date: Mon, 3 Apr 2023 15:28:23 +0200 Subject: [PATCH] refactor: migrating initial highlights markup, crated by Jesus to software components. refactor: new components are moved to software highglight and overview folder while old ones are not removed. --- .vscode/settings.json | 14 +- .vscode/settings.json.license | 3 + database/100-create-api-views.sql | 109 +++------ database/103-software-views.sql | 75 +++++- .../admin/rsd-contributors/config.tsx | 3 +- .../AddSoftwareHighlights.tsx | 3 +- .../apiSoftwareHighlights.tsx | 30 ++- .../admin/software-highlights/index.tsx | 6 +- frontend/components/layout/DefaultLayout.tsx | 6 +- .../filter/ProgrammingLanguageFilter.tsx | 8 +- .../software/filter/softwareFilterApi.ts | 4 +- .../highlights/HighlightsCarousel.tsx | 87 +++++++ .../highlights/SoftwareHighlights.tsx | 26 +++ .../highlights/useSoftwareHighlights.tsx | 37 +++ .../software/overview/PageBackground.tsx | 12 + .../software/overview/SearchInput.tsx | 66 ++++++ .../software/overview/SoftwareCard.tsx | 144 ++++++++++++ .../software/overview/SoftwareFilters.tsx | 213 ++++++++++++++++++ .../overview/SoftwareFiltersPanel.tsx | 13 ++ .../overview/SoftwareOverviewGrid.tsx | 20 ++ .../software/overview/useSoftwareOverview.ts | 210 +++++++++++++++++ .../softwarePage/FeaturedSoftwareCarousel.tsx | 86 +++++++ .../components/softwarePage/SearchInput.tsx | 62 +++++ .../components/softwarePage/SoftwareCard.tsx | 140 ++++++++++++ .../softwarePage/SoftwareFilterPanel.tsx | 197 ++++++++++++++++ .../softwarePage/softwarePagePanel.d.ts | 9 + .../softwarePage/useSoftwarefilterPanel.ts | 205 +++++++++++++++++ frontend/pages/highlights/index.tsx | 203 +++++++++++++++++ frontend/pages/software/index.tsx | 9 +- frontend/styles/custom.css | 4 + frontend/types/SoftwareTypes.ts | 8 +- frontend/utils/extractQueryParam.test.ts | 9 +- frontend/utils/extractQueryParam.ts | 45 +++- frontend/utils/postgrestUrl.ts | 48 +++- 34 files changed, 1964 insertions(+), 150 deletions(-) create mode 100644 frontend/components/software/highlights/HighlightsCarousel.tsx create mode 100644 frontend/components/software/highlights/SoftwareHighlights.tsx create mode 100644 frontend/components/software/highlights/useSoftwareHighlights.tsx create mode 100644 frontend/components/software/overview/PageBackground.tsx create mode 100644 frontend/components/software/overview/SearchInput.tsx create mode 100644 frontend/components/software/overview/SoftwareCard.tsx create mode 100644 frontend/components/software/overview/SoftwareFilters.tsx create mode 100644 frontend/components/software/overview/SoftwareFiltersPanel.tsx create mode 100644 frontend/components/software/overview/SoftwareOverviewGrid.tsx create mode 100644 frontend/components/software/overview/useSoftwareOverview.ts create mode 100644 frontend/components/softwarePage/FeaturedSoftwareCarousel.tsx create mode 100644 frontend/components/softwarePage/SearchInput.tsx create mode 100644 frontend/components/softwarePage/SoftwareCard.tsx create mode 100644 frontend/components/softwarePage/SoftwareFilterPanel.tsx create mode 100644 frontend/components/softwarePage/softwarePagePanel.d.ts create mode 100644 frontend/components/softwarePage/useSoftwarefilterPanel.ts create mode 100644 frontend/pages/highlights/index.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index e6cbbfbaf..faf88f1c1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,7 +23,7 @@ "editor.quickSuggestions": { "strings": true }, - "tailwindCSS.includeLanguages":{ + "tailwindCSS.includeLanguages": { "plaintext": "html" }, "[java]": { @@ -39,5 +39,13 @@ "editor.tabSize": 2, "editor.insertSpaces": true, "editor.detectIndentation": false - } -} + }, + "[typescriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[javascript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, +} \ No newline at end of file diff --git a/.vscode/settings.json.license b/.vscode/settings.json.license index f1cb7ecef..bbe22bd24 100644 --- a/.vscode/settings.json.license +++ b/.vscode/settings.json.license @@ -1,3 +1,6 @@ SPDX-FileCopyrightText: 2022 Netherlands eScience Center +SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +SPDX-FileCopyrightText: 2023 dv4all +SPDX-License-Identifier: Apache-2.0 SPDX-License-Identifier: CC0-1.0 diff --git a/database/100-create-api-views.sql b/database/100-create-api-views.sql index 589082ab0..9627cc999 100644 --- a/database/100-create-api-views.sql +++ b/database/100-create-api-views.sql @@ -177,52 +177,39 @@ BEGIN END $$; --- SOFTWARE OVERVIEW LIST FOR SEARCH --- WITH COUNTS and KEYWORDS for filtering -CREATE FUNCTION software_search() RETURNS TABLE ( - id UUID, - slug VARCHAR, - brand_name VARCHAR, - short_statement VARCHAR, - image_id VARCHAR, - updated_at TIMESTAMPTZ, - contributor_cnt BIGINT, - mention_cnt BIGINT, - is_published BOOLEAN, - keywords CITEXT[], - keywords_text TEXT, - prog_lang TEXT[] -) LANGUAGE plpgsql STABLE AS +-- license counts for software +-- used in software filter - license dropdown +CREATE FUNCTION license_cnt_for_software() RETURNS TABLE ( + license VARCHAR, + cnt BIGINT +) LANGUAGE sql STABLE AS $$ -BEGIN - RETURN QUERY - SELECT - software.id, - software.slug, - software.brand_name, - software.short_statement, - software.image_id, - software.updated_at, - count_software_countributors.contributor_cnt, - count_software_mentions.mention_cnt, - software.is_published, - keyword_filter_for_software.keywords, - keyword_filter_for_software.keywords_text, - prog_lang_filter_for_software.prog_lang - FROM - software - LEFT JOIN - count_software_countributors() ON software.id=count_software_countributors.software - LEFT JOIN - count_software_mentions() ON software.id=count_software_mentions.software - LEFT JOIN - keyword_filter_for_software() ON software.id=keyword_filter_for_software.software - LEFT JOIN - prog_lang_filter_for_software() ON software.id=prog_lang_filter_for_software.software - ; -END +SELECT + license_for_software.license, + COUNT(license_for_software.license) AS cnt +FROM + license_for_software +GROUP BY + license_for_software.license +; $$; +-- license filter for software +-- used by software_search func +CREATE FUNCTION license_filter_for_software() RETURNS TABLE ( + software UUID, + licenses VARCHAR[] +) LANGUAGE sql STABLE AS +$$ +SELECT + license_for_software.software, + ARRAY_AGG(license_for_software.license) +FROM + license_for_software +GROUP BY + license_for_software.software +; +$$; -- RELATED SOFTWARE LIST WITH COUNTS CREATE FUNCTION related_software_for_software(software_id UUID) RETURNS TABLE ( @@ -1444,39 +1431,3 @@ DESC LIMIT 1; ; $$; - --- Get a list of all software highlights with latest highlights first -CREATE FUNCTION software_for_highlight() RETURNS TABLE ( - id UUID, - slug VARCHAR, - brand_name VARCHAR, - short_statement VARCHAR, - image_id VARCHAR, - is_published BOOLEAN, - contributor_cnt BIGINT, - mention_cnt BIGINT, - "position" INTEGER, - updated_at TIMESTAMPTZ -) LANGUAGE sql STABLE AS -$$ -SELECT - software.id, - software.slug, - software.brand_name, - software.short_statement, - software.image_id, - software.is_published, - count_software_countributors.contributor_cnt, - count_software_mentions.mention_cnt, - software_highlight.position, - software_highlight.updated_at -FROM - software -INNER JOIN - software_highlight ON software.id=software_highlight.software -LEFT JOIN - count_software_countributors() ON software.id=count_software_countributors.software -LEFT JOIN - count_software_mentions() ON software.id=count_software_mentions.software -; -$$; diff --git a/database/103-software-views.sql b/database/103-software-views.sql index 3098a4610..f61af1746 100644 --- a/database/103-software-views.sql +++ b/database/103-software-views.sql @@ -10,13 +10,15 @@ CREATE FUNCTION software_overview() RETURNS TABLE ( slug VARCHAR, brand_name VARCHAR, short_statement VARCHAR, + image_id VARCHAR, updated_at TIMESTAMPTZ, contributor_cnt BIGINT, mention_cnt BIGINT, is_published BOOLEAN, keywords CITEXT[], keywords_text TEXT, - prog_lang TEXT[] + prog_lang TEXT[], + licenses VARCHAR[] ) LANGUAGE sql STABLE AS $$ SELECT @@ -24,13 +26,15 @@ SELECT software.slug, software.brand_name, software.short_statement, + software.image_id, software.updated_at, count_software_countributors.contributor_cnt, count_software_mentions.mention_cnt, software.is_published, keyword_filter_for_software.keywords, keyword_filter_for_software.keywords_text, - prog_lang_filter_for_software.prog_lang + prog_lang_filter_for_software.prog_lang, + license_filter_for_software.licenses FROM software LEFT JOIN @@ -41,6 +45,8 @@ LEFT JOIN keyword_filter_for_software() ON software.id=keyword_filter_for_software.software LEFT JOIN prog_lang_filter_for_software() ON software.id=prog_lang_filter_for_software.software +LEFT JOIN + license_filter_for_software() ON software.id=license_filter_for_software.software ; $$; @@ -52,13 +58,15 @@ CREATE FUNCTION software_search(search VARCHAR) RETURNS TABLE ( slug VARCHAR, brand_name VARCHAR, short_statement VARCHAR, + image_id VARCHAR, updated_at TIMESTAMPTZ, + is_published BOOLEAN, contributor_cnt BIGINT, mention_cnt BIGINT, - is_published BOOLEAN, keywords CITEXT[], keywords_text TEXT, - prog_lang TEXT[] + prog_lang TEXT[], + licenses VARCHAR[] ) LANGUAGE sql STABLE AS $$ SELECT @@ -66,13 +74,15 @@ SELECT software.slug, software.brand_name, software.short_statement, + software.image_id, software.updated_at, + software.is_published, count_software_countributors.contributor_cnt, count_software_mentions.mention_cnt, - software.is_published, keyword_filter_for_software.keywords, keyword_filter_for_software.keywords_text, - prog_lang_filter_for_software.prog_lang + prog_lang_filter_for_software.prog_lang, + license_filter_for_software.licenses FROM software LEFT JOIN @@ -83,6 +93,8 @@ LEFT JOIN keyword_filter_for_software() ON software.id=keyword_filter_for_software.software LEFT JOIN prog_lang_filter_for_software() ON software.id=prog_lang_filter_for_software.software +LEFT JOIN + license_filter_for_software() ON software.id=license_filter_for_software.software WHERE software.brand_name ILIKE CONCAT('%', search, '%') OR @@ -112,3 +124,54 @@ ORDER BY END ; $$; + + +-- Get a list of all software highlights +CREATE FUNCTION software_for_highlight() RETURNS TABLE ( + id UUID, + slug VARCHAR, + brand_name VARCHAR, + short_statement VARCHAR, + image_id VARCHAR, + updated_at TIMESTAMPTZ, + is_published BOOLEAN, + contributor_cnt BIGINT, + mention_cnt BIGINT, + keywords CITEXT[], + keywords_text TEXT, + prog_lang TEXT[], + licenses VARCHAR[], + "position" INTEGER +) LANGUAGE sql STABLE AS +$$ +SELECT + software.id, + software.slug, + software.brand_name, + software.short_statement, + software.image_id, + software.updated_at, + software.is_published, + count_software_countributors.contributor_cnt, + count_software_mentions.mention_cnt, + keyword_filter_for_software.keywords, + keyword_filter_for_software.keywords_text, + prog_lang_filter_for_software.prog_lang, + license_filter_for_software.licenses, + software_highlight.position +FROM + software +INNER JOIN + software_highlight ON software.id=software_highlight.software +LEFT JOIN + count_software_countributors() ON software.id=count_software_countributors.software +LEFT JOIN + count_software_mentions() ON software.id=count_software_mentions.software +LEFT JOIN + keyword_filter_for_software() ON software.id=keyword_filter_for_software.software +LEFT JOIN + prog_lang_filter_for_software() ON software.id=prog_lang_filter_for_software.software +LEFT JOIN + license_filter_for_software() ON software.id=license_filter_for_software.software +; +$$; diff --git a/frontend/components/admin/rsd-contributors/config.tsx b/frontend/components/admin/rsd-contributors/config.tsx index 7ebb87902..75a525b73 100644 --- a/frontend/components/admin/rsd-contributors/config.tsx +++ b/frontend/components/admin/rsd-contributors/config.tsx @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) // SPDX-FileCopyrightText: 2023 dv4all // // SPDX-License-Identifier: Apache-2.0 @@ -116,7 +117,7 @@ export function createColumns(token: string) { url=`/projects/${data.slug}/edit/team` } return ( - + {data.origin === 'contributor' ? 'Software' : 'Project'} diff --git a/frontend/components/admin/software-highlights/AddSoftwareHighlights.tsx b/frontend/components/admin/software-highlights/AddSoftwareHighlights.tsx index 2dcf39d92..62dbc390a 100644 --- a/frontend/components/admin/software-highlights/AddSoftwareHighlights.tsx +++ b/frontend/components/admin/software-highlights/AddSoftwareHighlights.tsx @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) // SPDX-FileCopyrightText: 2023 dv4all // // SPDX-License-Identifier: Apache-2.0 @@ -46,7 +47,7 @@ export default function AddSoftwareHighlights({onAddSoftware,highlights}:AddSoft // remove items already in hightlights const software = itemsNotInReferenceList({ list: resp.data ?? [], - referenceList: highlights ?? [], + referenceList: highlights as any as SoftwareListItem[] ?? [], key: 'id' }) diff --git a/frontend/components/admin/software-highlights/apiSoftwareHighlights.tsx b/frontend/components/admin/software-highlights/apiSoftwareHighlights.tsx index 58f8e8de4..5cc198738 100644 --- a/frontend/components/admin/software-highlights/apiSoftwareHighlights.tsx +++ b/frontend/components/admin/software-highlights/apiSoftwareHighlights.tsx @@ -1,26 +1,19 @@ // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) // SPDX-FileCopyrightText: 2023 dv4all // // SPDX-License-Identifier: Apache-2.0 import {useCallback, useEffect, useState} from 'react' import useSnackbar from '~/components/snackbar/useSnackbar' +import {SoftwareListItem} from '~/types/SoftwareTypes' import {extractCountFromHeader} from '~/utils/extractCountFromHeader' import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers' import logger from '~/utils/logger' import {paginationUrlParams} from '~/utils/postgrestUrl' import usePaginationWithSearch from '~/utils/usePaginationWithSearch' -export type SoftwareHighlight = { - id:string, - slug: string, - brand_name: string, - short_statement: string, - image_id: string, - updated_at: string, - contributor_cnt: number, - mention_cnt: number, - is_published: boolean, +export type SoftwareHighlight = SoftwareListItem & { position: number | null } @@ -40,7 +33,7 @@ export function useSoftwareHighlights(token: string) { const loadHighlight = useCallback(async() => { setLoading(true) - const {highlights, count} = await getHighlights({ + const {highlights, count} = await getSoftwareHighlights({ token, searchFor, page, @@ -63,6 +56,7 @@ export function useSoftwareHighlights(token: string) { async function addHighlight(id: string) { const resp = await addSoftwareHighlight({ id, + position: highlights.length + 1, token }) @@ -125,9 +119,10 @@ export function useSoftwareHighlights(token: string) { } } -async function getHighlights({page, rows, token, searchFor,orderBy}:getHighlightsApiParams) { +export async function getSoftwareHighlights({page, rows, token, searchFor,orderBy}:getHighlightsApiParams) { try { - let query = paginationUrlParams({rows, page}) + // let query = paginationUrlParams({ rows, page }) + let query = '' if (searchFor) { query+=`&or=(brand_name.ilike.*${searchFor}*,short_statement.ilike.*${searchFor}*)` } @@ -157,7 +152,7 @@ async function getHighlights({page, rows, token, searchFor,orderBy}:getHighlight highlights } } - logger(`getHighlights: ${resp.status}: ${resp.statusText}`,'warn') + logger(`getSoftwareHighlights: ${resp.status}: ${resp.statusText}`,'warn') return { count: 0, highlights: [] @@ -171,10 +166,13 @@ async function getHighlights({page, rows, token, searchFor,orderBy}:getHighlight } } -async function addSoftwareHighlight({id,token}:{id:string,token:string}) { +async function addSoftwareHighlight({id,position,token}:{id:string,position:number,token:string}) { try { const resp = await fetch('/api/v1/software_highlight', { - body: JSON.stringify({software:id}), + body: JSON.stringify({ + software: id, + position + }), headers: createJsonHeaders(token), method: 'POST' }) diff --git a/frontend/components/admin/software-highlights/index.tsx b/frontend/components/admin/software-highlights/index.tsx index 45b35b252..684759cc9 100644 --- a/frontend/components/admin/software-highlights/index.tsx +++ b/frontend/components/admin/software-highlights/index.tsx @@ -1,19 +1,16 @@ // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) // SPDX-FileCopyrightText: 2023 dv4all // // SPDX-License-Identifier: Apache-2.0 -import {useContext} from 'react' - import {useSession} from '~/auth' -import PaginationContext from '~/components/pagination/PaginationContext' import {useSoftwareHighlights} from './apiSoftwareHighlights' import AddSoftwareHighlights from './AddSoftwareHighlights' import SortableHighlightsList from './SortableHighlightList' export default function AdminSoftwareHighlight() { const {token} = useSession() - const {pagination:{count}} = useContext(PaginationContext) const {highlights, loading, addHighlight, sortHighlights, deleteHighlight} = useSoftwareHighlights(token) // console.group('OrganisationAdminPage') @@ -27,7 +24,6 @@ export default function AdminSoftwareHighlight() { Highlights {highlights.length} - {children} - {/*
- {children} -
*/} ) diff --git a/frontend/components/software/filter/ProgrammingLanguageFilter.tsx b/frontend/components/software/filter/ProgrammingLanguageFilter.tsx index 9ba5209c8..9172be440 100644 --- a/frontend/components/software/filter/ProgrammingLanguageFilter.tsx +++ b/frontend/components/software/filter/ProgrammingLanguageFilter.tsx @@ -5,7 +5,7 @@ import SelectedFilterItems from '~/components/filter/SelectedFilterItems' import FindFilterOptions from '~/components/filter/FindFilterOptions' -import {ProgramminLanguage} from './softwareFilterApi' +import {ProgrammingLanguage} from './softwareFilterApi' type SeachApiProps = { @@ -15,7 +15,7 @@ type SeachApiProps = { type ResearchDomainFilterProps = { items?: string[] onApply: (items: string[]) => void - searchApi: ({searchFor}:SeachApiProps)=> Promise + searchApi: ({searchFor}:SeachApiProps)=> Promise } export default function ProgrammingLanguageFilter({items=[], searchApi, onApply}:ResearchDomainFilterProps) { @@ -29,7 +29,7 @@ export default function ProgrammingLanguageFilter({items=[], searchApi, onApply} onApply(newList) } - function onAdd(item: ProgramminLanguage) { + function onAdd(item: ProgrammingLanguage) { const find = items.find(lang => lang.toLowerCase() === item.prog_lang.toLowerCase()) // new item if (typeof find == 'undefined') { @@ -42,7 +42,7 @@ export default function ProgrammingLanguageFilter({items=[], searchApi, onApply} } } - function itemsToOptions(items: ProgramminLanguage[]) { + function itemsToOptions(items: ProgrammingLanguage[]) { const options = items.map(item => ({ key: item.prog_lang, label: item.prog_lang, diff --git a/frontend/components/software/filter/softwareFilterApi.ts b/frontend/components/software/filter/softwareFilterApi.ts index 5165e48b3..2d2301a0b 100644 --- a/frontend/components/software/filter/softwareFilterApi.ts +++ b/frontend/components/software/filter/softwareFilterApi.ts @@ -35,7 +35,7 @@ export async function searchForKeyword( } } -export type ProgramminLanguage = { +export type ProgrammingLanguage = { prog_lang: string cnt: number } @@ -53,7 +53,7 @@ export async function searchForProgrammingLanguage({searchFor}: method: 'GET' }) if (resp.status === 200) { - const json: ProgramminLanguage[] = await resp.json() + const json: ProgrammingLanguage[] = await resp.json() if (json.length > 0) { return json } diff --git a/frontend/components/software/highlights/HighlightsCarousel.tsx b/frontend/components/software/highlights/HighlightsCarousel.tsx new file mode 100644 index 000000000..f8c6db136 --- /dev/null +++ b/frontend/components/software/highlights/HighlightsCarousel.tsx @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {UIEventHandler, useRef, useState} from 'react' +import {SoftwareHighlight} from '~/components/admin/software-highlights/apiSoftwareHighlights' +import {SoftwareCard} from '~/components/software/overview/SoftwareCard' + +export const HighlightsCarousel = ({items=[]}: {items:SoftwareHighlight[]}) => { + + const canrdMovement: number = 680 // card size + margin + // Keep track of the current scroll position of the carousel. + const [scrollPosition, setScrollPosition] = useState(0) + const carousel = useRef(null) + + // Event handlers for the next and previous buttons. + const handleNextClick = () => { + // move the scroll to the left + if (carousel.current) { + carousel.current.scrollLeft -= canrdMovement + } + } + + const handlePrevClick = () => { + if (carousel.current) { + carousel.current.scrollLeft += canrdMovement + } + } + + const handleScroll:UIEventHandler = (event:any) => { + // update the scroll position state variable whenever the user scrolls + setScrollPosition(event.target.scrollLeft) + } + + return ( +
+ {/* Left Button */} + {scrollPosition > 0 && + + } + + {/* Right Button */} + + + {/* Carousel */} +
+ {/* render software card in the row direction */} + {items.map(highlight => ( +
+ +
+ )) + } +
+
+ ) +} diff --git a/frontend/components/software/highlights/SoftwareHighlights.tsx b/frontend/components/software/highlights/SoftwareHighlights.tsx new file mode 100644 index 000000000..cb1eed3c8 --- /dev/null +++ b/frontend/components/software/highlights/SoftwareHighlights.tsx @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {HighlightsCarousel} from './HighlightsCarousel' +import useSoftwareHighlights from './useSoftwareHighlights' + +export default function SoftwareHighlights() { + const {highlights} = useSoftwareHighlights() + + // console.group('SoftwareHighlights') + // console.log('loading...', loading) + // console.log('highlights...', highlights) + // console.groupEnd() + + // if there are no hightlights we do not show this section + if (highlights.length===0) return null + + return ( +
+ +
+ ) +} diff --git a/frontend/components/software/highlights/useSoftwareHighlights.tsx b/frontend/components/software/highlights/useSoftwareHighlights.tsx new file mode 100644 index 000000000..1b5726ace --- /dev/null +++ b/frontend/components/software/highlights/useSoftwareHighlights.tsx @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {useEffect, useState} from 'react' + +import {useSession} from '~/auth' +import {SoftwareHighlight, getSoftwareHighlights} from '~/components/admin/software-highlights/apiSoftwareHighlights' + +export default function useSoftwareHighlights() { + const {token} = useSession() + const [highlights, setHighlights] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + setLoading(true) + getSoftwareHighlights({ + token, + page: 0, + // get max. 20 items + rows: 20, + orderBy: 'position' + }) + .then(data => { + setHighlights(data.highlights) + }) + .finally(() => setLoading(false)) + + },[token]) + + return { + highlights, + loading + } +} diff --git a/frontend/components/software/overview/PageBackground.tsx b/frontend/components/software/overview/PageBackground.tsx new file mode 100644 index 000000000..7f536dc08 --- /dev/null +++ b/frontend/components/software/overview/PageBackground.tsx @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +export default function OverviewPageBackground({children}:{children:any}) { + return ( +
+ {children} +
+ ) +} diff --git a/frontend/components/software/overview/SearchInput.tsx b/frontend/components/software/overview/SearchInput.tsx new file mode 100644 index 000000000..1ba61e78a --- /dev/null +++ b/frontend/components/software/overview/SearchInput.tsx @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2022 - 2023 dv4all +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// +// SPDX-License-Identifier: Apache-2.0 + +import {useState, useEffect} from 'react' +import {useDebounce} from '~/utils/useDebounce' +import TextField from '@mui/material/TextField' + +type SearchInputProps = { + placeholder: string, + onSearch: Function, + delay?: number, + defaultValue?: string, +} + +export default function SearchInput({ + placeholder, + onSearch, + delay = 400, + defaultValue = '' +}: SearchInputProps) { + const [state, setState] = useState({ + value: defaultValue ?? '', + wait: true + }) + const searchFor = useDebounce(state.value, delay) + + useEffect(() => { + if ((searchFor !== '' && defaultValue === '') || defaultValue !== '') { + setState({value: defaultValue, wait: true}) + } + }, [searchFor, defaultValue]) + + useEffect(() => { + let abort = false + const {wait, value} = state + if (!wait && value === searchFor) { + if (abort) return + setState({ + wait: true, + value + }) + onSearch(searchFor) + } + return () => { + abort = true + } + }, [state, searchFor, onSearch]) + + return ( + setState({value: target.value, wait: false})} + sx={{ + width: '100%', + backgroundColor:'background.paper' + }} + /> + ) +} diff --git a/frontend/components/software/overview/SoftwareCard.tsx b/frontend/components/software/overview/SoftwareCard.tsx new file mode 100644 index 000000000..ca84fb6c0 --- /dev/null +++ b/frontend/components/software/overview/SoftwareCard.tsx @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @next/next/no-img-element */ +import Link from 'next/link' +import {SoftwareListItem} from '~/types/SoftwareTypes' +import {getImageUrl} from '~/utils/editImage' + +type SoftwareCardProps = { + item: SoftwareListItem; + direction?: string +} + +export const SoftwareCard = ({item, direction}:SoftwareCardProps) => { + + const visibleNumberOfKeywords: number = 3 + const visibleNumberOfProgLang: number = 3 + const isHorizontal = !!direction + + return ( + +
+ {/* Cover image */} + {`Cover + + {/* Card content */} +
+

+ {item.brand_name} +

+

+ {item.short_statement} +

+ + {/* keywords */} +
    + {// limits the keywords to 'visibleNumberOfKeywords' per software. + item.keywords?.slice(0, visibleNumberOfKeywords) + .map((keyword:string, index: number) => ( +
  • {keyword}
  • + ))} + + { // Show the number of keywords that are not visible. + (item.keywords?.length > 0) + && (item.keywords?.length > visibleNumberOfKeywords) + && (item.keywords?.length - visibleNumberOfKeywords > 0) + && `+ ${item.keywords?.length - visibleNumberOfKeywords}` + } +
+ + {/*
*/} +
+ + {/* Languages */} +
    + {// limits the keywords to 'visibleNumberOfProgLang' per software. + item.prog_lang?.slice(0, visibleNumberOfProgLang) + .map((lang:string, index: number) => ( +
  • {lang}
  • + ))} + { // Show the number of keywords that are not visible. + (item.prog_lang?.length > 0) + && (item.prog_lang?.length > visibleNumberOfProgLang) + && (item.prog_lang?.length - visibleNumberOfProgLang > 0) + && `+ ${item.prog_lang?.length - visibleNumberOfProgLang}` + } +
+ {/* Metrics */} +
+
+ + + + {item.contributor_cnt || 0} +
+ +
+ + + + {item.mention_cnt || 0} +
+ + {/* TODO Add download counts to the cards */} + {item?.downloads && item?.downloads > 0 && ( +
+ + + + + 34K +
+ )} +
+
+
+
+ + ) +} +// TODO Only show images every 3rd card for testing purposes +// index % 3 === 0 <-- todo diff --git a/frontend/components/software/overview/SoftwareFilters.tsx b/frontend/components/software/overview/SoftwareFilters.tsx new file mode 100644 index 000000000..e2148715a --- /dev/null +++ b/frontend/components/software/overview/SoftwareFilters.tsx @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import Button from '@mui/material/Button' +import FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' +import Select from '@mui/material/Select' +import MenuItem from '@mui/material/MenuItem' +import Autocomplete from '@mui/material/Autocomplete' +import TextField from '@mui/material/TextField' +import {Keyword} from '~/components/keyword/FindKeyword' +import {ProgrammingLanguage} from '../filter/softwareFilterApi' + +export type LicenseWithCount = { + license: string; + cnt: number; +} + +type SoftwareFilterPanelProps = { + keywords: Keyword[] + keywordsList: Keyword[] + languages: ProgrammingLanguage[] + languagesList: ProgrammingLanguage[] + licenses: LicenseWithCount[] + licensesList: LicenseWithCount[] + orderBy: string, + setOrderBy: (order:string)=>void + handleQueryChange: (key: string, value: string | string[]) => void + getFilterCount: () => number + resetFilters: () => void +} + +export default function SoftwareFilters({settings}: { settings: SoftwareFilterPanelProps }) { + const { + keywords, + keywordsList, + languages, + languagesList, + licenses, + licensesList, + handleQueryChange, + orderBy, setOrderBy, + getFilterCount, + resetFilters + } = settings + + const filterCnt = getFilterCount() + + return ( + <> +
+
+ + {filterCnt} + + Filters +
+ + +
+ + {/* Order by */} + + Order by + + + + {/* Keywords */} +
+
+
Keywords
+
{keywordsList.length}
+
+ (option.keyword)} + isOptionEqualToValue={(option, value) => { + return option.keyword === value.keyword + }} + defaultValue={[]} + filterSelectedOptions + renderOption={(props, option) => ( +
  • +
    { + option.keyword + }
    +
    ({ + option.cnt + }) +
    +
  • + )} + renderInput={(params) => ( + + )} + onChange={(event, newValue) => { + // extract values into string[] for url query + const queryFilter = newValue.map(item => item.keyword) + handleQueryChange('keywords', queryFilter) + }} + /> +
    + + {/* Programme Languages */} +
    +
    +
    Program languages
    +
    {languagesList.length}
    +
    + option.prog_lang} + isOptionEqualToValue={(option, value) => { + return option.prog_lang === value.prog_lang + }} + defaultValue={[]} + filterSelectedOptions + renderOption={(props, option) => ( +
  • +
    { + option.prog_lang + }
    +
    ({ + option.cnt + }) +
    +
  • + )} + renderInput={(params) => ( + + )} + onChange={(event, newValue) => { + // extract values into string[] for url query + const queryFilter = newValue.map(item => item.prog_lang) + // update query url + handleQueryChange('prog_lang', queryFilter) + }} + /> +
    + + {/* Licenses */} +
    +
    +
    Licenses
    +
    {licensesList.length}
    +
    + option.license} + isOptionEqualToValue={(option, value) => { + return option.license === value.license + }} + defaultValue={[]} + filterSelectedOptions + renderOption={(props, option) => ( +
  • +
    {option.license}
    +
    ({option.cnt})
    +
  • + )} + renderInput={(params) => ( + + )} + onChange={(event, newValue) => { + // extract values into string[] for url query + const queryFilter = newValue.map(item => item.license) + // update query url + handleQueryChange('licenses', queryFilter) + }} + /> +
    + + ) +} diff --git a/frontend/components/software/overview/SoftwareFiltersPanel.tsx b/frontend/components/software/overview/SoftwareFiltersPanel.tsx new file mode 100644 index 000000000..179a36dc8 --- /dev/null +++ b/frontend/components/software/overview/SoftwareFiltersPanel.tsx @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +export default function SoftwareFiltersPanel({children}: { children: any }) { + return ( +
    + {children} +
    + ) +} diff --git a/frontend/components/software/overview/SoftwareOverviewGrid.tsx b/frontend/components/software/overview/SoftwareOverviewGrid.tsx new file mode 100644 index 000000000..655f445a2 --- /dev/null +++ b/frontend/components/software/overview/SoftwareOverviewGrid.tsx @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {SoftwareListItem} from '~/types/SoftwareTypes' +import {SoftwareCard} from './SoftwareCard' + +export default function SoftwareOverviewGrid({software=[]}: { software:SoftwareListItem[]}) { + return ( +
    + {/* xl:columns-4 */} + {software.map((item, index) => ( +
    + +
    + ))} +
    + ) +} diff --git a/frontend/components/software/overview/useSoftwareOverview.ts b/frontend/components/software/overview/useSoftwareOverview.ts new file mode 100644 index 000000000..a43e45803 --- /dev/null +++ b/frontend/components/software/overview/useSoftwareOverview.ts @@ -0,0 +1,210 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {useEffect, useState} from 'react' +import {useRouter} from 'next/router' + +import {getBaseUrl} from '~/utils/fetchHelpers' +import {ProgrammingLanguage} from '~/components/software/filter/softwareFilterApi' +import {SoftwareListItem} from '~/types/SoftwareTypes' +import {ssrSoftwareParams} from '~/utils/extractQueryParam' +import {QueryParams, buildFilterUrl, softwareListUrl} from '~/utils/postgrestUrl' +import {getSoftwareList} from '~/utils/getSoftware' +import {Keyword} from '~/components/keyword/FindKeyword' +import {License} from '~/components/softwarePage/softwarePagePanel' + +export default function useSoftwareOverview() { + const router = useRouter() + const baseUrl = getBaseUrl() + + const [orderBy, setOrderBy] = useState('') + const [search, setSearch] = useState('') + + // keyword list is an array of objects or a string + const [keywordsList, setKeywordsList] = useState([]) + const [keywords, setKeywords] = useState([]) + + const [languages, setLanguages] = useState([]) + const [languagesList, setLanguagesList] = useState([]) + + const [licenses, setLicenses] = useState([]) + const [licensesList, setLicensesList] = useState([]) + + const [software, setSoftware] = useState<{ count: number, items: SoftwareListItem[] }>({ + count: 0, + items: [] + }) + + useEffect(() => { + if (baseUrl) { + // fetch keywords list + fetch(`${baseUrl}/rpc/keyword_count_for_software?keyword=ilike.**&cnt=gt.0&order=cnt.desc.nullslast,keyword.asc`) + .then((response) => response.json()) + .then((data) => setKeywordsList(data)) + + // fetch programme languages list + fetch(`${baseUrl}/rpc/prog_lang_cnt_for_software?prog_lang=ilike.**&cnt=gt.0&order=cnt.desc.nullslast,prog_lang.asc`) + .then((response) => response.json()) + .then((data) => setLanguagesList(data)) + + // fetch licenses list + fetch(`${baseUrl}/rpc/license_cnt_for_software`) + .then((response) => response.json()) + .then((data) => { + setLicensesList(data) + }) + } + }, [baseUrl]) + + useEffect(() => { + if (search !== '') { + const {search:searchInput} = ssrSoftwareParams(router.query) + if (searchInput && searchInput !== '') { + setSearch(searchInput) + } else { + setSearch('') + } + } + }, [router.query,search]) + + useEffect(() => { + if (orderBy !== '') { + const {order} = ssrSoftwareParams(router.query) + if (order && order !== '') { + setOrderBy(order) + } else { + setOrderBy('') + } + } + }, [router.query, orderBy]) + + useEffect(() => { + if (keywordsList.length > 0) { + const {keywords} = ssrSoftwareParams(router.query) + if (keywords && keywords.length > 0) { + const selectedKeywords = keywordsList.filter(option => { + return keywords.includes(option.keyword) + }) + setKeywords(selectedKeywords) + } else { + setKeywords([]) + } + } + }, [keywordsList, router.query]) + + useEffect(() => { + if (languagesList.length > 0) { + const {prog_lang} = ssrSoftwareParams(router.query) + if (prog_lang && prog_lang.length > 0) { + const selectedProgLang = languagesList.filter(option => { + return prog_lang.includes(option.prog_lang) + }) + setLanguages(selectedProgLang) + } else { + setLanguages([]) + } + } + }, [languagesList, router.query]) + + useEffect(() => { + if (licensesList.length > 0) { + const {licenses} = ssrSoftwareParams(router.query) + if (licenses && licenses.length > 0) { + const selected = licensesList.filter(option => { + return licenses.includes(option.license) + }) + setLicenses(selected) + } else { + setLicenses([]) + } + } + }, [licensesList, router.query]) + + useEffect(() => { + let orderBy + // extract params from page-query + const {search, keywords, prog_lang, licenses, order, page} = ssrSoftwareParams(router.query) + // update components based on query params + if (order) { + setOrderBy(order) + orderBy=`${order}.desc.nullslast` + } + if (search) { + setSearch(search) + } + + //build api url + const url = softwareListUrl({ + baseUrl, + search, + keywords, + licenses, + order:orderBy, + prog_lang, + limit: 24, + offset: 24 * (page ?? 0) + }) + + // get software list from api + getSoftwareList({url}) + .then(resp => { + setSoftware({ + count: resp.count ?? 0, + items: resp.data ?? [] + }) + }) + + }, [router.query, baseUrl]) + + + function handleQueryChange(key: string, value: string | string[]) { + const params:QueryParams = { + // take existing params from url (query) + ...ssrSoftwareParams(router.query), + [key]: value, + } + if (key !== 'page') { + params['page'] = 0 + params['rows'] = 24 + } + + const url = buildFilterUrl(params, 'highlights') + // update page url + router.push(url) + } + + function getFilterCount() { + let count = 0 + if (orderBy !== '') count++ + if (keywords.length > 0) count++ + if (languages.length > 0) count++ + if (licenses.length > 0) count++ + if (search !== '') count++ + return count + } + + function resetFilters() { + // return pathname without filters/query + router.replace(router.pathname, undefined, {shallow: true}) + } + + return { + orderBy, + keywords, + keywordsList, + languages, + languagesList, + licenses, + licensesList, + software, + search, + setOrderBy, + handleQueryChange, + getFilterCount, + resetFilters + } +} + diff --git a/frontend/components/softwarePage/FeaturedSoftwareCarousel.tsx b/frontend/components/softwarePage/FeaturedSoftwareCarousel.tsx new file mode 100644 index 000000000..74f99b4dc --- /dev/null +++ b/frontend/components/softwarePage/FeaturedSoftwareCarousel.tsx @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {UIEventHandler, useRef, useState} from 'react' +import {SoftwareCard} from './SoftwareCard' + +export const FeaturedSoftwareCarousel = ({cards}: {cards:any}) => { + + const canrdMovement: number = 680 // card size + margin + // const canrdMovementVertical: number = 320 // card size + margin + + // Keep track of the current scroll position of the carousel. + const [scrollPosition, setScrollPosition] = useState(0) + const carousel = useRef(null) + + // Event handlers for the next and previous buttons. + const handleNextClick = () => { + // move the scroll to the left + if (carousel.current) { + carousel.current.scrollLeft -= canrdMovement + } + } + + const handlePrevClick = () => { + if (carousel.current) { + carousel.current.scrollLeft += canrdMovement + } + } + +// TODO + const handleScroll:UIEventHandler = (event:any) => { + // update the scroll position state variable whenever the user scrolls + setScrollPosition(event.target.scrollLeft) + } + + return ( +
    + {/* Left Button */} + {scrollPosition > 0 && + + } + + {/* Right Button */} + + + + {/* Carousel */} +
    + {/* TODO software card type */} + {cards.length > 0 && cards.map((card: any, index: number) => ( +
    + +
    + )) + } +
    +
    + ) +} diff --git a/frontend/components/softwarePage/SearchInput.tsx b/frontend/components/softwarePage/SearchInput.tsx new file mode 100644 index 000000000..c2156e70f --- /dev/null +++ b/frontend/components/softwarePage/SearchInput.tsx @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2022 - 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {useState, useEffect} from 'react' +import {useDebounce} from '~/utils/useDebounce' +import TextField from '@mui/material/TextField' + +type SearchInputProps = { + placeholder: string, + onSearch: Function, + delay?: number, + defaultValue?: string, +} + +export default function SearchInput({ + placeholder, + onSearch, + delay = 400, + defaultValue = '' +}: SearchInputProps) { + const [state, setState] = useState({ + value: defaultValue ?? '', + wait: true + }) + const searchFor = useDebounce(state.value, delay) + + useEffect(() => { + if ((searchFor !== '' && defaultValue === '') || defaultValue !== '') { + setState({value: defaultValue, wait: true}) + } + }, [searchFor, defaultValue]) + + useEffect(() => { + let abort = false + const {wait, value} = state + if (!wait && value === searchFor) { + if (abort) return + setState({ + wait: true, + value + }) + onSearch(searchFor) + } + return () => { + abort = true + } + }, [state, searchFor, onSearch]) + + return ( + setState({value: target.value, wait: false})} + /> + ) +} diff --git a/frontend/components/softwarePage/SoftwareCard.tsx b/frontend/components/softwarePage/SoftwareCard.tsx new file mode 100644 index 000000000..0b35ec1bf --- /dev/null +++ b/frontend/components/softwarePage/SoftwareCard.tsx @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @next/next/no-img-element */ +import Link from 'next/link' + +export const SoftwareCard = ({item, direction, index}: { + item: any; direction?: string; index: number; +}) => { + + const visibleNumberOfKeywords: number = 3 + const visibleNumberOfProgLang: number = 3 + + const isHorizontal = !!direction + return ( + +
    + {/* Cover image */} + {`Cover + + {/* Card content */} +
    + +

    + {item.brand_name} +

    +

    + {item.short_statement} +

    + {/* keywords */} +
      + + {// limits the keywords to 'visibleNumberOfKeywords' per software. + item.keywords?.slice(0, visibleNumberOfKeywords) + .map((keyword:string, index: number) => ( +
    • {keyword}
    • + ))} + + { // Show the number of keywords that are not visible. + (item.keywords?.length > 0) + && (item.keywords?.length > visibleNumberOfKeywords) + && (item.keywords?.length - visibleNumberOfKeywords > 0) + && `+ ${item.keywords?.length - visibleNumberOfKeywords}` + } +
    + +
    +
    + + {/* Languages */} +
      + {// limits the keywords to 'visibleNumberOfProgLang' per software. + item.prog_lang?.slice(0, visibleNumberOfProgLang) + .map((lang:string, index: number) => ( +
    • {lang}
    • + ))} + { // Show the number of keywords that are not visible. + (item.prog_lang?.length > 0) + && (item.prog_lang?.length > visibleNumberOfProgLang) + && (item.prog_lang?.length - visibleNumberOfProgLang > 0) + && `+ ${item.prog_lang?.length - visibleNumberOfProgLang}` + } +
    + {/* Metrics */} +
    +
    + + + + {item.contributor_cnt || 0} +
    + +
    + + + + {item.mention_cnt || 0} +
    + + {/* TODO Add download counts to the cards */} + {item.downloads > 0 && ( +
    + + + + + 34K +
    + )} +
    +
    +
    +
    + + ) +} +// TODO Only show images every 3rd card for testing purposes +// index % 3 === 0 <-- todo diff --git a/frontend/components/softwarePage/SoftwareFilterPanel.tsx b/frontend/components/softwarePage/SoftwareFilterPanel.tsx new file mode 100644 index 000000000..00e8fd451 --- /dev/null +++ b/frontend/components/softwarePage/SoftwareFilterPanel.tsx @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {ForwardedRef} from 'react' +import Button from '@mui/material/Button' +import FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' +import Select from '@mui/material/Select' +import MenuItem from '@mui/material/MenuItem' +import Autocomplete from '@mui/material/Autocomplete' +import TextField from '@mui/material/TextField' +import useSoftwareFilterPanel from '~/components/softwarePage/useSoftwarefilterPanel' + +// Ref needed to emebd a custom component inside a MUI modal: +// https://mui.com/material-ui/guides/composition/#caveat-with-refs +type Ref = { + ref?: ForwardedRef +} + +export default function SoftwareFilterPanel({ref}: Ref) { + const { + keywords, + keywordsList, + languages, + languagesList, + licenses, + licensesList, + handleQueryChange, + orderBy, setOrderBy, + getFilterCount, + resetFilters + } = useSoftwareFilterPanel() + + // @ts-ignore + return
    +
    +
    + + {getFilterCount()} + + Filters +
    + + +
    + + {/* Order by */} + + Order by + + + + {/* Keywords */} +
    +
    +
    Keywords
    +
    {keywordsList.length}
    +
    + (option.keyword)} + isOptionEqualToValue={(option, value) => { + return option.keyword === value.keyword + }} + defaultValue={[]} + filterSelectedOptions + renderOption={(props, option) => ( +
  • +
    { + option.keyword + }
    +
    ({ + option.cnt + }) +
    +
  • + )} + renderInput={(params) => ( + + )} + onChange={(event, newValue) => { + // extract values into string[] for url query + const queryFilter = newValue.map(item => item.keyword) + handleQueryChange('keywords', queryFilter) + }} + /> +
    + + {/* Programme Languages */} +
    +
    +
    Program languages
    +
    {languagesList.length}
    +
    + option.prog_lang} + isOptionEqualToValue={(option, value) => { + return option.prog_lang === value.prog_lang + }} + defaultValue={[]} + filterSelectedOptions + renderOption={(props, option) => ( +
  • +
    { + option.prog_lang + }
    +
    ({ + option.cnt + }) +
    +
  • + )} + renderInput={(params) => ( + + )} + onChange={(event, newValue) => { + // extract values into string[] for url query + const queryFilter = newValue.map(item => item.prog_lang) + // update query url + handleQueryChange('prog_lang', queryFilter) + }} + /> +
    + + {/* Licenses */} +
    +
    +
    Licenses
    +
    {licensesList.length}
    +
    + option.license} + isOptionEqualToValue={(option, value) => { + return option.license === value.license + }} + defaultValue={[]} + filterSelectedOptions + renderOption={(props, option) => ( +
  • +
    {option.license}
    +
    ({option.cnt})
    +
  • + )} + renderInput={(params) => ( + + )} + onChange={(event, newValue) => { + // extract values into string[] for url query + const queryFilter = newValue.map(item => item.license) + // update query url + handleQueryChange('licenses', queryFilter) + }} + /> +
    +
    +} diff --git a/frontend/components/softwarePage/softwarePagePanel.d.ts b/frontend/components/softwarePage/softwarePagePanel.d.ts new file mode 100644 index 000000000..395af70e6 --- /dev/null +++ b/frontend/components/softwarePage/softwarePagePanel.d.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +export type License = { + license: string; + cnt: number; +} diff --git a/frontend/components/softwarePage/useSoftwarefilterPanel.ts b/frontend/components/softwarePage/useSoftwarefilterPanel.ts new file mode 100644 index 000000000..94e6ebcd7 --- /dev/null +++ b/frontend/components/softwarePage/useSoftwarefilterPanel.ts @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {useRouter} from 'next/router' +import {getBaseUrl} from '~/utils/fetchHelpers' +import {useEffect, useState} from 'react' +import {ProgrammingLanguage} from '~/components/software/filter/softwareFilterApi' +import {SoftwareListItem} from '~/types/SoftwareTypes' +import {ssrSoftwareParams} from '~/utils/extractQueryParam' +import {buildFilterUrl, softwareListUrl} from '~/utils/postgrestUrl' +import {getSoftwareList} from '~/utils/getSoftware' +import {Keyword} from '../keyword/FindKeyword' +import {License} from '~/components/softwarePage/softwarePagePanel' + +export default function useSoftwarefilterPanel() { + const router = useRouter() + const baseUrl = getBaseUrl() + + const [orderBy, setOrderBy] = useState('') + const [search, setSearch] = useState('') + + // keyword list is an array of objects or a string + const [keywordsList, setKeywordsList] = useState([]) + const [keywords, setKeywords] = useState([]) + + const [languages, setLanguages] = useState([]) + const [languagesList, setLanguagesList] = useState([]) + + const [licenses, setLicenses] = useState([]) + const [licensesList, setLicensesList] = useState([]) + + const [software, setSoftware] = useState<{ count: number, items: SoftwareListItem[] }>({ + count: 0, + items: [] + }) + + useEffect(() => { + if (baseUrl) { + // fetch keywords list + fetch(`${baseUrl}/rpc/keyword_count_for_software?keyword=ilike.**&cnt=gt.0&order=cnt.desc.nullslast,keyword.asc`) + .then((response) => response.json()) + .then((data) => setKeywordsList(data)) + + // fetch programme languages list + fetch(`${baseUrl}/rpc/prog_lang_cnt_for_software?prog_lang=ilike.**&cnt=gt.0&order=cnt.desc.nullslast,prog_lang.asc`) + .then((response) => response.json()) + .then((data) => setLanguagesList(data)) + + // fetch licenses list + fetch(`${baseUrl}/rpc/license_cnt_for_software`) + .then((response) => response.json()) + .then((data) => { + setLicensesList(data) + }) + } + }, [baseUrl]) + + useEffect(() => { + if (search !== '') { + const {search:searchInput} = ssrSoftwareParams(router.query) + if (searchInput && searchInput !== '') { + setSearch(searchInput) + } else { + setSearch('') + } + } + }, [router.query,search]) + + useEffect(() => { + if (orderBy !== '') { + const {order} = ssrSoftwareParams(router.query) + if (order && order !== '') { + setOrderBy(order) + } else { + setOrderBy('') + } + } + }, [router.query, orderBy]) + + useEffect(() => { + if (keywordsList.length > 0) { + const {keywords} = ssrSoftwareParams(router.query) + if (keywords && keywords.length > 0) { + const selectedKeywords: Keyword[] = keywordsList.filter(option => { + return keywords.includes(option.keyword) + }) + setKeywords(selectedKeywords) + } else { + setKeywords([]) + } + } + }, [keywordsList, router.query]) + + useEffect(() => { + if (languagesList.length > 0) { + const {prog_lang} = ssrSoftwareParams(router.query) + if (prog_lang && prog_lang.length > 0) { + const selectedProgLang: ProgrammingLanguage[] = languagesList.filter(option => { + return prog_lang.includes(option.prog_lang) + }) + setLanguages(selectedProgLang) + } else { + setLanguages([]) + } + } + }, [languagesList, router.query]) + + useEffect(() => { + if (licensesList.length > 0) { + const {licenses} = ssrSoftwareParams(router.query) + if (licenses && licenses.length > 0) { + const selected: License[] = licensesList.filter(option => { + return licenses.includes(option.license) + }) + setLicenses(selected) + } else { + setLicenses([]) + } + } + }, [licensesList, router.query]) + + useEffect(() => { + let orderBy + // extract params from page-query + const {search, keywords, prog_lang, licenses, order, page} = ssrSoftwareParams(router.query) + + // update components based on query params + if (order) { + setOrderBy(order) + orderBy = `${orderBy}.desc.nullslast` + } + if (search) { + setSearch(search) + } + + //build api url + const url = softwareListUrl({ + baseUrl, + search, + keywords, + licenses, + order: orderBy, + prog_lang, + limit: 24, + offset: 24 * (page ?? 0) + }) + + // get software list from api + getSoftwareList({url}) + .then(resp => { + setSoftware({ + count: resp.count ?? 0, + items: resp.data ?? [] + }) + }) + + }, [router.query, baseUrl]) + + + function handleQueryChange(key: string, value: string | string[]) { + const url = buildFilterUrl({ + // take existing params from url (query) + ...ssrSoftwareParams(router.query), + [key]: value, + // start from first page + page: 0, + // use 24 items + rows: 24 + }, 'highlights') + + // update page url + router.push(url) + } + + function getFilterCount() { + let count = 0 + if (orderBy !== '') count++ + if (keywords.length > 0) count++ + if (languages.length > 0) count++ + if (licenses.length > 0) count++ + if (search !== '') count++ + return count + } + + function resetFilters() { + // return pathname without filters/query + router.replace(router.pathname, undefined, {shallow: true}) + } + + return { + orderBy, setOrderBy, + keywords, keywordsList, + languages, languagesList, + licenses, licensesList, + software, setSoftware, + search, setSearch, + handleQueryChange, + getFilterCount, + resetFilters + } +} + diff --git a/frontend/pages/highlights/index.tsx b/frontend/pages/highlights/index.tsx new file mode 100644 index 000000000..8c7d54d21 --- /dev/null +++ b/frontend/pages/highlights/index.tsx @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {useState} from 'react' +import {useRouter} from 'next/router' + +import useMediaQuery from '@mui/material/useMediaQuery' +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogTitle from '@mui/material/DialogTitle' +import DialogContent from '@mui/material/DialogContent' +import Pagination from '@mui/material/Pagination' +import Button from '@mui/material/Button' + +import SoftwareFiltersPanel from '~/components/software/overview/SoftwareFiltersPanel' +import SearchInput from '~/components/software/overview/SearchInput' +import SoftwareHighlights from '~/components/software/highlights/SoftwareHighlights' +import OverviewPageBackground from '~/components/software/overview/PageBackground' +import SoftwareOverviewGrid from '~/components/software/overview/SoftwareOverviewGrid' +import MainContent from '~/components/layout/MainContent' +import AppHeader from '~/components/AppHeader' +import AppFooter from '~/components/AppFooter' +import SoftwareFilters from '~/components/software/overview/SoftwareFilters' +import useSoftwareOverview from '~/components/software/overview/useSoftwareOverview' + +export default function SoftwareHighlightsPage() { + const router = useRouter() + const smallScreen = useMediaQuery('(max-width:640px)') + const page = router.query.page ? parseInt(router.query.page as string) + 1 : 1 + const settings = useSoftwareOverview() + const { + keywords, + software, + search, + orderBy, + handleQueryChange, + getFilterCount, + resetFilters + } = settings + + const [modal,setModal] = useState(false) + const numPages = Math.ceil(software.count / 24) + + // console.group('SoftwareHighlightsPage') + // console.log('page...', page) + // console.log('numPages...', numPages) + // console.log('smallScreen...', smallScreen) + // console.groupEnd() + + return ( + + + {/* Software Highlights Carousel */} + + {/* Main page body */} + + {/* All software */} +

    + All software +

    + {/* Filter panel & content panel */} +
    + {/* Filters panel large screen */} + {smallScreen===false && + + + + } + {/* Search & card grid section */} +
    + handleQueryChange('search', search)} + defaultValue={search} + /> + {/* Filter button for mobile */} + {smallScreen === true && + + } +
    + {software.count} results. + {/* Only show when filters are applied, and not just sorted */} + { + (orderBy !== '' && getFilterCount() > 1) || (orderBy === '' && getFilterCount() > 0) && { resetFilters() }} className="underline pl-2">Clear filters + } +
    + {/* Software Cards Grid */} + + {/* Pagination */} +
    + {numPages > 1 && + { + // api uses 0 index + const param = (page - 1).toString() + handleQueryChange('page',param) + }} + /> + } +
    +
    +
    +
    + + {/* MODAL filter for mobile */} + { + smallScreen===true && + + + Filters + + +
    + +
    +
    + + + + +
    + } +
    + ) +} + +// fetching data server side +// see documentation https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering +// export async function getServerSideProps(context:GetServerSidePropsContext) { +// // extract params from page-query +// const {search, keywords, prog_lang, rows, page} = ssrSoftwareParams(context.query) +// // construct postgREST api url with query params +// const url = softwareListUrl({ +// baseUrl: process.env.POSTGREST_URL || 'http://localhost:3500', +// search, +// keywords, +// prog_lang, +// order: 'mention_cnt.desc.nullslast,contributor_cnt.desc.nullslast,updated_at.desc.nullslast', +// limit: rows, +// offset: rows * page, +// }) + +// // console.log('software...url...', url) + +// // get software list, we do not pass the token +// // when token is passed it will return not published items too +// const software = await getSoftwareList({url}) + +// // will be passed as props to page +// // see params of SoftwareIndexPage function +// return { +// props: { +// search, +// keywords, +// prog_lang, +// count: software.count, +// page, +// rows, +// software: software.data, +// }, +// } +// } diff --git a/frontend/pages/software/index.tsx b/frontend/pages/software/index.tsx index 098d8bc05..8118a6065 100644 --- a/frontend/pages/software/index.tsx +++ b/frontend/pages/software/index.tsx @@ -25,7 +25,6 @@ import SoftwareFilter from '~/components/software/filter' import {useAdvicedDimensions} from '~/components/layout/FlexibleGridSection' import PageMeta from '~/components/seo/PageMeta' import CanonicalUrl from '~/components/seo/CanonicalUrl' -import {sortBySearchFor} from '~/utils/sortFn' type SoftwareIndexPageProps = { count: number, @@ -187,7 +186,7 @@ export async function getServerSideProps(context:GetServerSidePropsContext) { prog_lang, order: search ? undefined : 'mention_cnt.desc.nullslast,contributor_cnt.desc.nullslast,updated_at.desc.nullslast,brand_name.asc', limit: rows, - offset: rows * page, + offset: rows && page ? rows * page : undefined, }) // console.log('software...url...', url) @@ -200,9 +199,9 @@ export async function getServerSideProps(context:GetServerSidePropsContext) { // see params of SoftwareIndexPage function return { props: { - search, - keywords, - prog_lang, + search: search ?? null, + keywords: keywords ?? null, + prog_lang: prog_lang ?? null, count: software.count, page, rows, diff --git a/frontend/styles/custom.css b/frontend/styles/custom.css index 1bb46cbd0..217450187 100644 --- a/frontend/styles/custom.css +++ b/frontend/styles/custom.css @@ -79,6 +79,10 @@ body { transform: rotate(3deg) translate(0px, -4px); } +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + /* Remove these to get rid of the spinner */ /* WE DO NOT USE SPINNER - ONLY PROGRESS BAR AT THE TOP */ /* #nprogress .spinner { diff --git a/frontend/types/SoftwareTypes.ts b/frontend/types/SoftwareTypes.ts index ad8432152..6e6a53360 100644 --- a/frontend/types/SoftwareTypes.ts +++ b/frontend/types/SoftwareTypes.ts @@ -4,6 +4,7 @@ // SPDX-FileCopyrightText: 2022 - 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences // SPDX-FileCopyrightText: 2022 - 2023 Netherlands eScience Center // SPDX-FileCopyrightText: 2022 - 2023 dv4all +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) // // SPDX-License-Identifier: Apache-2.0 @@ -72,8 +73,11 @@ export type SoftwareListItem = { contributor_cnt: number | null mention_cnt: number | null is_published: boolean - is_featured?: boolean - image_id?: string | null + image_id: string | null + keywords: string[], + prog_lang: string[], + liceses: string, + downloads?: number } diff --git a/frontend/utils/extractQueryParam.test.ts b/frontend/utils/extractQueryParam.test.ts index 57946f26b..ab013b807 100644 --- a/frontend/utils/extractQueryParam.test.ts +++ b/frontend/utils/extractQueryParam.test.ts @@ -1,6 +1,5 @@ +// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 - 2023 dv4all -// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) // // SPDX-License-Identifier: Apache-2.0 @@ -83,13 +82,17 @@ it('extracts ssrSoftwareParams from url query', () => { 'search': 'test search', 'keywords': '["BAM","FAIR Sofware"]', 'prog_lang': '["Python","C++"]', + 'licenses': '["MIT","GPL-2.0-or-later"]', + 'order': 'test-order', 'page': '0', 'rows': '12' } const expected = { search: 'test search', keywords: ['BAM', 'FAIR Sofware'], - prog_lang: ['Python','C++'], + prog_lang: ['Python', 'C++'], + licenses: ['MIT', 'GPL-2.0-or-later'], + order: 'test-order', page: 0, rows: 12 } diff --git a/frontend/utils/extractQueryParam.ts b/frontend/utils/extractQueryParam.ts index c3e377a6a..5de364ea7 100644 --- a/frontend/utils/extractQueryParam.ts +++ b/frontend/utils/extractQueryParam.ts @@ -1,6 +1,5 @@ -// SPDX-FileCopyrightText: 2021 - 2022 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2021 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2021 - 2023 dv4all -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) // // SPDX-License-Identifier: Apache-2.0 @@ -34,42 +33,66 @@ export function extractQueryParam({query,param,castToType='string',defaultValue} }catch(e:any){ logger(`extractQueryParam: ${e.description}`,'error') throw e - }} + } +} -export function ssrSoftwareParams(query: ParsedUrlQuery) { +export type SoftwareParams = { + search?: string + order?: string, + keywords?: string[], + prog_lang?: string[], + licenses?: string[], + page?: number, + rows?: number +} + +export function ssrSoftwareParams(query: ParsedUrlQuery): SoftwareParams { // console.group('ssrSoftwareParams') // console.log('query...', query) - const rows = extractQueryParam({ + const rows:number = extractQueryParam({ query, param: 'rows', defaultValue: 12, castToType:'number' }) - const page = extractQueryParam({ + const page:number = extractQueryParam({ query, param: 'page', defaultValue: 0, castToType:'number' }) - const search = extractQueryParam({ + const search:string = extractQueryParam({ query, param: 'search', defaultValue: null, castToType:'string' }) - const keywords = extractQueryParam({ + const keywords:string[]|undefined = extractQueryParam({ query, param: 'keywords', castToType: 'json-encoded', defaultValue: null }) - const prog_lang = extractQueryParam({ + const prog_lang:string[]|undefined = extractQueryParam({ query, param: 'prog_lang', castToType: 'json-encoded', defaultValue: null }) + const licenses:string[]|undefined = extractQueryParam({ + query, + param: 'licenses', + castToType: 'json-encoded', + defaultValue: null + }) + + const order:string = extractQueryParam({ + query, + param: 'order', + castToType: 'string', + defaultValue: null + }) // console.log('keywords...', keywords) // console.log('keywords...', typeof keywords) // console.groupEnd() @@ -77,13 +100,15 @@ export function ssrSoftwareParams(query: ParsedUrlQuery) { search, keywords, prog_lang, + licenses, + order, rows, page, } } export function ssrProjectsParams(query: ParsedUrlQuery) { - const rows = extractQueryParam({ + const rows:number = extractQueryParam({ query, param: 'rows', defaultValue: 12, diff --git a/frontend/utils/postgrestUrl.ts b/frontend/utils/postgrestUrl.ts index 5ccc32881..de1be2bba 100644 --- a/frontend/utils/postgrestUrl.ts +++ b/frontend/utils/postgrestUrl.ts @@ -24,6 +24,7 @@ type baseQueryStringProps = { keywords?: string[] | null, domains?: string[] | null, prog_lang?: string[] | null, + licenses?: string[] | null, order?: string, limit?: number, offset?: number @@ -33,31 +34,33 @@ export type PostgrestParams = baseQueryStringProps & { baseUrl:string } -type QueryParams={ +export type QueryParams={ // query: ParsedUrlQuery search?:string + order?: string, keywords?:string[] domains?:string[], - prog_lang?:string[], + prog_lang?: string[], + licenses?: string[], page?:number, rows?:number } export function ssrSoftwareUrl(params:QueryParams){ const view = 'software' - const url = ssrUrl(params, view) + const url = buildFilterUrl(params, view) return url } export function ssrOrganisationUrl(params: QueryParams) { const view = 'organisations' - const url = ssrUrl(params,view) + const url = buildFilterUrl(params,view) return url } export function ssrProjectsUrl(params: QueryParams) { const view = 'projects' - const url = ssrUrl(params, view) + const url = buildFilterUrl(params, view) return url } @@ -91,11 +94,12 @@ function buildUrlQuery({query, param, value}: BuildUrlQueryProps) { } -function ssrUrl(params: QueryParams, view:string) { - const {search, keywords, domains, prog_lang, rows, page} = params - // console.log('ssrUrl...params...', params) +export function buildFilterUrl(params: QueryParams, view:string) { + const {search,order, keywords, domains, licenses, prog_lang, rows, page} = params + // console.log('buildFilterUrl...params...', params) let url = `/${view}?` let query = '' + // search query = buildUrlQuery({ query, @@ -120,6 +124,18 @@ function ssrUrl(params: QueryParams, view:string) { param: 'prog_lang', value: prog_lang }) + // licenses + query = buildUrlQuery({ + query, + param: 'licenses', + value: licenses + }) + // sortBy + query = buildUrlQuery({ + query, + param: 'order', + value: order + }) if (page || page === 0) { url += `page=${page}` } else { @@ -160,7 +176,7 @@ export function paginationUrlParams({rows=12, page=0}: * @returns string */ export function baseQueryString(props: baseQueryStringProps) { - const {keywords, domains, prog_lang,order,limit,offset} = props + const {keywords, domains, prog_lang,licenses,order,limit,offset} = props let query // console.group('baseQueryString') // console.log('keywords...', keywords) @@ -211,6 +227,20 @@ export function baseQueryString(props: baseQueryStringProps) { query = `prog_lang=cs.%7B${languagesAll}%7D` } } + // + if (typeof licenses !== 'undefined' && + licenses !== null && + typeof licenses === 'object') { + // sort and convert research domains array to comma separated string + // we need to sort because search is on ARRAY field in pgSql + const licensesAll = licenses.sort().map((item: string) => `"${encodeURIComponent(item)}"`).join(',') + // use cs. command to find + if (query) { + query = `${query}&licenses=cs.%7B${licensesAll}%7D` + } else { + query = `licenses=cs.%7B${licensesAll}%7D` + } + } // order if (order) { if (query) {