From b09d5b20bf497fd9410ca7a962525ff8ce5a0b34 Mon Sep 17 00:00:00 2001 From: Dusan Mijatovic Date: Thu, 2 May 2024 17:00:01 +0200 Subject: [PATCH] Implement communities overview (#1188) * feat: implement communities overview, grid and list incl. search on name and short description * chore: add communities to data generation and small SQL style fixes --------- Co-authored-by: Ewan Cahen --- data-generation/main.js | 74 +++++++- database/024-community.sql | 15 +- database/124-community-views.sql | 50 +++++ .../permissions/isMaintainerOfCommunity.ts | 62 ++++++ frontend/components/AppHeader/DesktopMenu.tsx | 28 +++ .../components/AppHeader/ResponsiveMenu.tsx | 93 +++++++++ frontend/components/AppHeader/index.tsx | 103 ++-------- .../GlobalSearchAutocomplete/index.tsx | 4 +- .../components/communities/apiCommunities.ts | 141 ++++++++++++++ .../communities/metadata/CommunityLogo.tsx | 140 ++++++++++++++ .../components/communities/metadata/index.tsx | 51 +++++ .../communities/overview/CommunitiesGrid.tsx | 26 +++ .../communities/overview/CommunitiesList.tsx | 25 +++ .../communities/overview/CommunityCard.tsx | 63 +++++++ .../overview/CommunityListItem.tsx | 45 +++++ .../communities/tabs/CommunityTabItems.tsx | 64 +++++++ .../components/communities/tabs/index.tsx | 66 +++++++ .../PageBreadcrumbs.tsx} | 19 +- .../news/overview/useNewsOverviewParams.tsx | 53 ------ .../useSearchParams.test.tsx} | 42 ++++- .../useSearchParams.tsx} | 28 +-- .../software/edit/editSoftwareConfig.tsx | 2 +- .../organisations/EditOrganisationModal.tsx | 2 +- frontend/config/menuItems.ts | 9 +- .../pages/communities/[slug]/settings.tsx | 10 + .../pages/communities/[slug]/software.tsx | 155 +++++++++++++++ frontend/pages/communities/index.tsx | 176 ++++++++++++++++++ frontend/pages/news/index.tsx | 23 ++- frontend/pages/organisations/[...slug].tsx | 13 +- frontend/pages/organisations/index.tsx | 24 +-- frontend/public/data/settings.json | 10 + frontend/utils/extractQueryParam.test.ts | 10 +- frontend/utils/extractQueryParam.ts | 12 +- 33 files changed, 1423 insertions(+), 215 deletions(-) create mode 100644 database/124-community-views.sql create mode 100644 frontend/auth/permissions/isMaintainerOfCommunity.ts create mode 100644 frontend/components/AppHeader/DesktopMenu.tsx create mode 100644 frontend/components/AppHeader/ResponsiveMenu.tsx create mode 100644 frontend/components/communities/apiCommunities.ts create mode 100644 frontend/components/communities/metadata/CommunityLogo.tsx create mode 100644 frontend/components/communities/metadata/index.tsx create mode 100644 frontend/components/communities/overview/CommunitiesGrid.tsx create mode 100644 frontend/components/communities/overview/CommunitiesList.tsx create mode 100644 frontend/components/communities/overview/CommunityCard.tsx create mode 100644 frontend/components/communities/overview/CommunityListItem.tsx create mode 100644 frontend/components/communities/tabs/CommunityTabItems.tsx create mode 100644 frontend/components/communities/tabs/index.tsx rename frontend/components/{organisation/OrganisationBreadcrumbs.tsx => layout/PageBreadcrumbs.tsx} (61%) delete mode 100644 frontend/components/news/overview/useNewsOverviewParams.tsx rename frontend/components/{organisation/overview/useOrganisationOverviewParams.test.tsx => search/useSearchParams.test.tsx} (52%) rename frontend/components/{organisation/overview/useOrganisationOverviewParams.tsx => search/useSearchParams.tsx} (60%) create mode 100644 frontend/pages/communities/[slug]/settings.tsx create mode 100644 frontend/pages/communities/[slug]/software.tsx create mode 100644 frontend/pages/communities/index.tsx diff --git a/data-generation/main.js b/data-generation/main.js index 54959c62a..f489bb0d4 100644 --- a/data-generation/main.js +++ b/data-generation/main.js @@ -92,7 +92,7 @@ function generateMentions(amountExtra = 100) { return result; } -async function generateSoftware(amount=500) { +function generateSoftware(amount=500) { // real software has a real concept DOI const amountRealSoftware = Math.min(conceptDois.length, amount); const brandNames = []; @@ -317,7 +317,7 @@ function generateSoftwareHighlights(ids) { return result; } -async function generateProjects(amount=500) { +function generateProjects(amount=500) { const result = []; const projectStatuses = ['finished', 'running', 'starting']; @@ -452,7 +452,7 @@ function generateUrlsForProjects(ids) { return result; } -async function generateOrganisations(amount=500) { +function generateOrganisations(amount=500) { const rorIds = [ 'https://ror.org/000k1q888', 'https://ror.org/006hf6230', @@ -523,6 +523,25 @@ async function generateOrganisations(amount=500) { return result; } +function generateCommunities(amount = 50) { + const result = []; + + for (let index = 0; index < amount; index++) { + const maxWords = faker.helpers.maybe(() => 5, {probability: 0.8}) ?? 31; + const name = generateUniqueCaseInsensitiveString(() => ('Community: ' + faker.word.words(faker.number.int({max: maxWords, min: 1}))).substring(0, 200)); + + result.push({ + slug: faker.helpers.slugify(name).toLowerCase().replaceAll(/-{2,}/g, '-').replaceAll(/-+$/g, ''), // removes double dashes and trailing dashes + name: name, + short_description: faker.helpers.maybe(() => faker.lorem.paragraphs(1, '\n\n'), {probability: 0.8}) ?? null, + description: faker.helpers.maybe(() => faker.lorem.paragraphs(1, '\n\n'), {probability: 0.8}) ?? null, + logo_id: faker.helpers.maybe(() => localOrganisationLogoIds[index % localImageIds.length], {probability: 0.8}) ?? null, + }); + } + + return result; +} + function generateMetaPages() { const result = []; @@ -568,8 +587,8 @@ function generateNews() { slug: 'never-dependency' }, { - title: 'Shutting down the RSD', - slug: 'shutting-down-the-rsd' + title: 'Sunsetting the RSD', + slug: 'sunsetting-the-rsd' }, { title: 'The last package you will ever need', @@ -583,6 +602,22 @@ function generateNews() { title: 'The 5 best dependencies you never heard about', slug: '5-best-dependencies' }, + { + title: 'Rewriting the RSD in CrabLang', + slug: 'rewrite-rsd-crablang' + }, + { + title: 'The RSD joins forces with Big Company (tm)', + slug: 'rsd-joins-big-company' + }, + { + title: '3 features you didn\'t know about', + slug: '3-features' + }, + { + title: 'Interview with RSD founders', + slug: 'interview-rsd-founders' + }, ]; const result = []; @@ -646,6 +681,17 @@ function generateProjectForOrganisation(idsProjects, idsOrganisations) { return result; } +function generateSoftwareForCommunity(idsSoftware, idsCommunities) { + const result = generateRelationsForDifferingEntities(idsCommunities, idsSoftware, 'community', 'software'); + + const statuses = [{weight: 1, value: 'pending'}, {weight: 8, value: 'approved'}, {weight: 1, value: 'rejected'}]; + result.forEach(entry => { + entry['status'] = faker.helpers.weightedArrayElement(statuses); + }); + + return result; +} + function createJWT() { const secret = process.env.PGRST_JWT_SECRET; return jwt.sign({ 'role': 'rsd_admin' }, secret, {expiresIn: '2m'}); @@ -819,8 +865,8 @@ const researchDomainsPromise = getFromBackend('/research_domain?select=id') await Promise.all([mentionsPromise, keywordPromise, researchDomainsPromise]) .then(() => console.log('mentions, keywords, research domains done')); -let idsSoftware, idsFakeSoftware, idsRealSoftware, idsProjects, idsOrganisations; -const softwarePromise = postToBackend('/software', await generateSoftware()) +let idsSoftware, idsFakeSoftware, idsRealSoftware, idsProjects, idsOrganisations, idsCommunities; +const softwarePromise = postToBackend('/software', generateSoftware()) .then(resp => resp.json()) .then(async swArray => { idsSoftware = swArray.map(sw => sw['id']); @@ -836,7 +882,7 @@ const softwarePromise = postToBackend('/software', await generateSoftware()) postToBackend('/software_for_software', generateSoftwareForSoftware(idsSoftware)); postToBackend('/software_highlight', generateSoftwareHighlights(idsSoftware.slice(0,10))); }); -const projectPromise = postToBackend('/project', await generateProjects()) +const projectPromise = postToBackend('/project', generateProjects()) .then(resp => resp.json()) .then(async pjArray => { idsProjects = pjArray.map(sw => sw['id']); @@ -848,11 +894,18 @@ const projectPromise = postToBackend('/project', await generateProjects()) postToBackend('/research_domain_for_project', generateResearchDomainsForProjects(idsProjects, idsResearchDomains)); postToBackend('/project_for_project', generateSoftwareForSoftware(idsProjects)); }); -const organisationPromise = postToBackend('/organisation', await generateOrganisations()) +const organisationPromise = postToBackend('/organisation', generateOrganisations()) .then(resp => resp.json()) .then(async orgArray => { idsOrganisations = orgArray.map(org => org['id']); }); + +const communityPromise = postToBackend('/community', generateCommunities()) + .then(resp => resp.json()) + .then(async commArray => { + idsCommunities = commArray.map(comm => comm['id']); + }); + await postToBackend('/meta_pages', generateMetaPages()).then(() => console.log('meta pages done')); await postToBackend('/news?select=id', generateNews()) .then(() => getFromBackend("/news")) @@ -861,11 +914,12 @@ await postToBackend('/news?select=id', generateNews()) .then(newsIds => postToBackend('/image_for_news', generateImagesForNews(newsIds, localImageIds))) .then(() => console.log('news done')); -await Promise.all([softwarePromise, projectPromise, organisationPromise]).then(() => console.log('sw, pj, org done')); +await Promise.all([softwarePromise, projectPromise, organisationPromise, communityPromise]).then(() => console.log('sw, pj, org, comm done')); await postToBackend('/software_for_project', generateRelationsForDifferingEntities(idsSoftware, idsProjects, 'software', 'project')).then(() => console.log('sw-pj done')); await postToBackend('/software_for_organisation', generateRelationsForDifferingEntities(idsSoftware, idsOrganisations, 'software', 'organisation')).then(() => console.log('sw-org done')); await postToBackend('/project_for_organisation', generateProjectForOrganisation(idsProjects, idsOrganisations)).then(() => console.log('pj-org done')); +await postToBackend('/software_for_community', generateSoftwareForCommunity(idsSoftware, idsCommunities)).then(() => console.log('sw-comm done')); await postToBackend('/release', idsSoftware.map(id => ({software: id}))) .then(() => postToBackend('/release_version', generateRelationsForDifferingEntities(idsFakeSoftware, idsMentions, 'release_id', 'mention_id', 100))) .then(() => console.log('releases done')); diff --git a/database/024-community.sql b/database/024-community.sql index 59753fbd8..f0aeec524 100644 --- a/database/024-community.sql +++ b/database/024-community.sql @@ -1,17 +1,18 @@ -- SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +-- SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center) -- SPDX-FileCopyrightText: 2024 Netherlands eScience Center -- -- SPDX-License-Identifier: Apache-2.0 CREATE TABLE community ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - slug VARCHAR(200) UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'), + slug VARCHAR(200) UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'), name VARCHAR(200) NOT NULL, short_description VARCHAR(300), description VARCHAR(10000), - primary_maintainer UUID REFERENCES account (id), - logo_id VARCHAR(40) REFERENCES image(id), - created_at TIMESTAMPTZ NOT NULL, + primary_maintainer UUID REFERENCES account (id), + logo_id VARCHAR(40) REFERENCES image(id), + created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); @@ -91,17 +92,17 @@ CREATE TYPE request_status AS ENUM ( ); CREATE TABLE software_for_community ( - community UUID REFERENCES community (id), software UUID REFERENCES software (id), + community UUID REFERENCES community (id), status request_status NOT NULL DEFAULT 'pending', - PRIMARY KEY (community, software) + PRIMARY KEY (software, community) ); CREATE FUNCTION sanitise_update_software_for_community() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN - NEW.community = OLD.community; NEW.software = OLD.software; + NEW.community = OLD.community; return NEW; END $$; diff --git a/database/124-community-views.sql b/database/124-community-views.sql new file mode 100644 index 000000000..51253ca3b --- /dev/null +++ b/database/124-community-views.sql @@ -0,0 +1,50 @@ +-- SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +-- SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center) +-- SPDX-FileCopyrightText: 2024 Netherlands eScience Center +-- +-- SPDX-License-Identifier: Apache-2.0 + +-- Software count by community +CREATE FUNCTION software_count_by_community() RETURNS TABLE ( + community UUID, + software_cnt BIGINT +) LANGUAGE sql STABLE AS +$$ +SELECT + community.id, + COUNT(software_for_community.software) AS software_cnt +FROM + community +LEFT JOIN + software_for_community ON community.id = software_for_community.community +GROUP BY + community.id +; +$$; + +-- rpc for community overview page +-- incl. software count +CREATE FUNCTION communities_overview() RETURNS TABLE ( + id UUID, + slug VARCHAR, + name VARCHAR, + short_description VARCHAR, + logo_id VARCHAR, + software_cnt BIGINT, + created_at TIMESTAMPTZ +) LANGUAGE sql STABLE AS +$$ +SELECT + community.id, + community.slug, + community."name", + community.short_description, + community.logo_id, + software_count_by_community.software_cnt, + community.created_at +FROM + community +LEFT JOIN + software_count_by_community() ON community.id = software_count_by_community.community +; +$$; diff --git a/frontend/auth/permissions/isMaintainerOfCommunity.ts b/frontend/auth/permissions/isMaintainerOfCommunity.ts new file mode 100644 index 000000000..a97cdd3da --- /dev/null +++ b/frontend/auth/permissions/isMaintainerOfCommunity.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {RsdRole} from '~/auth/index' +import logger from '~/utils/logger' + +type isCommunityMaintainerProps = { + community: string + role?: RsdRole + account?: string + token?: string +} + +export async function isCommunityMaintainer({community, role, account, token}: isCommunityMaintainerProps) { + // if no account, token, role provided + if ( typeof account == 'undefined' || + typeof token == 'undefined' || + typeof role == 'undefined' + ) { + return false + } + + // if community provided and user role rsd_admin + if (community && role === 'rsd_admin' && account) { + return true + } + + const isMaintainer = await isMaintainerOfCommunity({ + community, + account, + token + }) + + return isMaintainer +} + +export async function isMaintainerOfCommunity({community, account, token}: isCommunityMaintainerProps) { + try { + if ( typeof account == 'undefined' || + typeof token == 'undefined' + ) { + // if no account, token, role provided + return false + } + console.error('isMaintainerOfCommunity...NOT IMPLEMENTED') + // const organisations = await getMaintainerOrganisations({ + // token + // }) + // // debugger + // if (organisations.length > 0) { + // const isMaintainer = organisations.includes(organisation) + // return isMaintainer + // } + return false + } catch (e:any) { + logger(`isMaintainerOfCommunity: ${e?.message}`, 'error') + // ERRORS AS NOT MAINTAINER + return false + } +} diff --git a/frontend/components/AppHeader/DesktopMenu.tsx b/frontend/components/AppHeader/DesktopMenu.tsx new file mode 100644 index 000000000..e67756c1f --- /dev/null +++ b/frontend/components/AppHeader/DesktopMenu.tsx @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {menuItems} from '~/config/menuItems' +import Link from 'next/link' + +import isActiveMenuItem from './isActiveMenuItem' + +export default function DesktopMenu({activePath}:{activePath:string}) { + // console.group('DesktopMenu') + // console.log('activePath...',activePath) + // console.groupEnd() + return ( +
+ {menuItems.map(item => { + const isActive = isActiveMenuItem({item, activePath}) + return ( + + {item.label} + + ) + })} +
+ ) +} diff --git a/frontend/components/AppHeader/ResponsiveMenu.tsx b/frontend/components/AppHeader/ResponsiveMenu.tsx new file mode 100644 index 000000000..33c0e4313 --- /dev/null +++ b/frontend/components/AppHeader/ResponsiveMenu.tsx @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {useState, MouseEvent} from 'react' +import Link from 'next/link' +import IconButton from '@mui/material/IconButton' +import Menu from '@mui/material/Menu' +import MenuItem from '@mui/material/MenuItem' +import MenuIcon from '@mui/icons-material/Menu' + +import {menuItems} from '~/config/menuItems' +import useDisableScrollLock from '~/utils/useDisableScrollLock' +import useRsdSettings from '~/config/useRsdSettings' +import FeedbackPanelButton from '~/components/feedback/FeedbackPanelButton' +import isActiveMenuItem from './isActiveMenuItem' + +export default function ResponsiveMenu({activePath}:{activePath:string}) { + const disable = useDisableScrollLock() + const {host} = useRsdSettings() + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + + // console.group('ResponsiveMenu') + // console.log('disable...',disable) + // console.log('open...',open) + // console.groupEnd() + + function handleClickResponsiveMenu(event: MouseEvent){ + setAnchorEl(event.currentTarget) + } + + function handleCloseResponsiveMenu(){ + setAnchorEl(null) + } + + return ( +
+ + + + + {menuItems.map(item => { + const isActive = isActiveMenuItem({item, activePath}) + return ( + + + {item.label} + + + ) + })} + + {/* {host.feedback?.enabled ? + + + + : null + } */} + + +
+ ) +} diff --git a/frontend/components/AppHeader/index.tsx b/frontend/components/AppHeader/index.tsx index 2cd004d80..f6b28f91d 100644 --- a/frontend/components/AppHeader/index.tsx +++ b/frontend/components/AppHeader/index.tsx @@ -6,15 +6,11 @@ // // SPDX-License-Identifier: Apache-2.0 -import {useState, useEffect, MouseEvent} from 'react' -import IconButton from '@mui/material/IconButton' -import Menu from '@mui/material/Menu' -import MenuItem from '@mui/material/MenuItem' -import MenuIcon from '@mui/icons-material/Menu' +import {useState, useEffect} from 'react' import Link from 'next/link' + // local dependencies (project components) import {useAuth} from '~/auth' -import {menuItems} from '~/config/menuItems' import useRsdSettings from '~/config/useRsdSettings' import AddMenu from './AddMenu' import LoginButton from '~/components/login/LoginButton' @@ -23,17 +19,19 @@ import LogoApp from '~/assets/LogoApp.svg' import LogoAppSmall from '~/assets/LogoAppSmall.svg' import GlobalSearchAutocomplete from '~/components/GlobalSearchAutocomplete' import FeedbackPanelButton from '~/components/feedback/FeedbackPanelButton' -import useDisableScrollLock from '~/utils/useDisableScrollLock' -import isActiveMenuItem from './isActiveMenuItem' +import ResponsiveMenu from './ResponsiveMenu' +import DesktopMenu from './DesktopMenu' export default function AppHeader() { - const [activePath, setActivePath] = useState('/') const {session} = useAuth() + const [activePath, setActivePath] = useState('/') const status = session?.status || 'loading' const {host} = useRsdSettings() - const disable = useDisableScrollLock() - // Responsive menu - const [anchorEl, setAnchorEl] = useState(null) + + // console.group('AppHeader') + // console.log('activePath...',activePath) + // console.log('status...',status) + // console.groupEnd() useEffect(() => { // set activePath to currently loaded route/page @@ -42,19 +40,10 @@ export default function AppHeader() { } }, []) - // Responsive menu - const open = Boolean(anchorEl) - const handleClickResponsiveMenu = (event: MouseEvent) => { - setAnchorEl(event.currentTarget) - } - const handleCloseResponsiveMenu = () => { - setAnchorEl(null) - } - return (
{/* keep these styles in sync with main in MainContent.tsx */}
+ {/* Global search for desktop */} {/* Large menu*/} -
- {menuItems.map(item => { - const isActive = isActiveMenuItem({item, activePath}) - return ( - - {item.label} - - ) - })} -
+
- - {/* FEEDBACK panel */}
{host.feedback?.enabled @@ -102,65 +80,16 @@ export default function AppHeader() { : null }
- {/* ADD menu button */} {status === 'authenticated' ? : null} - - {/* Responsive menu */} -
- - - - - {menuItems.map(item => { - const isActive = isActiveMenuItem({item, activePath}) - return ( - - - {item.label} - - - ) - })} -
  • - {host.feedback?.enabled - ? - : null - } -
  • -
    -
    - + {/* LOGIN / USER MENU */}
    + + {/* Global search for tablet & mobile */} diff --git a/frontend/components/GlobalSearchAutocomplete/index.tsx b/frontend/components/GlobalSearchAutocomplete/index.tsx index 262ff34cc..20c0b987b 100644 --- a/frontend/components/GlobalSearchAutocomplete/index.tsx +++ b/frontend/components/GlobalSearchAutocomplete/index.tsx @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 - 2023 dv4all +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) // SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) // SPDX-FileCopyrightText: 2024 Christian Meeßen (GFZ) // SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center) // SPDX-FileCopyrightText: 2024 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences @@ -162,7 +162,7 @@ export default function GlobalSearchAutocomplete(props: Props) { setOpen(false) }}>
    + className={`${props.className} relative flex w-full xl:w-[14.5rem] xl:max-w-[20rem] focus-within:w-full duration-700`}>
    {/* Search Icon */} diff --git a/frontend/components/communities/apiCommunities.ts b/frontend/components/communities/apiCommunities.ts new file mode 100644 index 000000000..8aea86c38 --- /dev/null +++ b/frontend/components/communities/apiCommunities.ts @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {RsdUser} from '~/auth' +import {extractCountFromHeader} from '~/utils/extractCountFromHeader' +import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers' +import logger from '~/utils/logger' +import {paginationUrlParams} from '~/utils/postgrestUrl' + +export type CommunityListProps = { + id: string, + slug: string, + name: string, + short_description: string | null, + logo_id: string | null + software_cnt: number | null +} + +type GetCommunityListParams={ + page: number, + rows: number, + token?: string + searchFor?:string, + orderBy?:string, +} + +export async function getCommunityList({page,rows,token,searchFor,orderBy}:GetCommunityListParams){ + try{ + let query = paginationUrlParams({rows, page}) + if (searchFor) { + // search in name and short description + query+=`&or=(name.ilike.*${searchFor}*,short_description.ilike.*${searchFor}*)` + } + if (orderBy) { + query+=`&order=${orderBy}` + } else { + query+='&order=name.asc' + } + // complete url + const url = `${getBaseUrl()}/rpc/communities_overview?${query}` + + // get community + const resp = await fetch(url, { + method: 'GET', + headers: { + ...createJsonHeaders(token), + // request record count to be returned + // note: it's returned in the header + 'Prefer': 'count=exact' + } + }) + + if ([200,206].includes(resp.status)) { + const communities: CommunityListProps[] = await resp.json() + return { + count: extractCountFromHeader(resp.headers) ?? 0, + communities + } + } + logger(`getCommunityList: ${resp.status}: ${resp.statusText}`,'warn') + return { + count: 0, + communities: [] + } + }catch(e:any){ + logger(`getCommunityList: ${e.message}`,'error') + return { + count: 0, + communities: [] + } + } +} + +type GetCommunityBySlug={ + slug: string|null, + user: RsdUser|null, + token?:string +} + +export async function getCommunityBySlug({slug,user,token}:GetCommunityBySlug){ + try{ + // ignore if no slug + if (slug===null) return null + // filter on slug value + const query = `slug=eq.${slug}` + const url = `${getBaseUrl()}/rpc/communities_overview?${query}` + + // get community + const resp = await fetch(url, { + method: 'GET', + headers: { + ...createJsonHeaders(token), + // request single record + 'Accept': 'application/vnd.pgrst.object+json' + } + }) + + if (resp.status === 200) { + const json:CommunityListProps = await resp.json() + return json + } + // NOT FOUND + logger(`getCommunityBySlug: ${resp.status}:${resp.statusText}`, 'warn') + return null + }catch(e:any){ + logger(`getCommunityBySlug: ${e?.message}`, 'error') + return null + } +} + + +type PatchCommunityProps = { + id: string, + slug?: string, + name?: string, + short_description?: string | null, + logo_id?: string | null + description?: string | null +} + +export async function patchCommunity({data, token}: + { data: PatchCommunityProps, token: string }) { + try { + const url = `/api/v1/community?id=eq.${data.id}` + const resp = await fetch(url, { + method: 'PATCH', + headers: { + ...createJsonHeaders(token) + }, + body: JSON.stringify(data) + }) + return extractReturnMessage(resp) + } catch (e: any) { + return { + status: 500, + message: e?.message + } + } +} diff --git a/frontend/components/communities/metadata/CommunityLogo.tsx b/frontend/components/communities/metadata/CommunityLogo.tsx new file mode 100644 index 000000000..df8c9362f --- /dev/null +++ b/frontend/components/communities/metadata/CommunityLogo.tsx @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2022 dv4all +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {useEffect, useState} from 'react' + +import {useSession} from '~/auth' +import {deleteImage,getImageUrl,upsertImage} from '~/utils/editImage' +import LogoAvatar from '~/components/layout/LogoAvatar' +import useSnackbar from '~/components/snackbar/useSnackbar' +import OrganisationLogoMenu from '~/components/organisation/metadata/OrganisationLogoMenu' +import {patchCommunity} from '../apiCommunities' +// import OrganisationLogoMenu from './OrganisationLogoMenu' +// import useOrganisationContext from '../context/useOrganisationContext' + +type CommunityLogoProps = { + id: string, + name: string, + logo_id: string | null, + isMaintainer: boolean +} + +export type ImageDataProps = { + data: string, + mime_type: string +} + +export default function CommunityLogo({id,name,logo_id,isMaintainer}:CommunityLogoProps) { + const {token} = useSession() + const {showErrorMessage} = useSnackbar() + // currently shown image + const [logo, setLogo] = useState(logo_id) + + // console.group('CommunityLogo') + // console.log('id...', id) + // console.log('name...', name) + // console.log('logo_id...', logo_id) + // console.log('logo...', logo) + // console.log('isMaintainer...', isMaintainer) + // console.groupEnd() + + // Update logo when new value + // received from parent + // useEffect(() => { + // if (logo_id) setLogo(logo_id) + // },[logo_id]) + + async function addLogo({data, mime_type}: ImageDataProps) { + // split base64 to use only encoded content + const b64data = data.split(',')[1] + const resp = await upsertImage({ + data:b64data, + mime_type, + token + }) + // console.log('addLogo...resp...', resp) + if (resp.status === 201 && id) { + // update logo_id reference + const patch = await patchCommunity({ + data: { + id, + logo_id: resp.message + }, + token + }) + if (patch.status === 200) { + // if we are replacing existing logo + if (logo !== null && resp.message && + logo !== resp.message + ) { + // try to remove old logo from db + // do not await for result + // NOTE! delete MUST be after patching organisation + // because we are removing logo_id reference + deleteImage({ + id: logo, + token + }) + } + setLogo(resp.message) + } else { + showErrorMessage(`Failed to upload logo. ${resp.message}`) + } + } else { + showErrorMessage(`Failed to upload logo. ${resp.message}`) + } + } + + async function removeLogo() { + if (logo && token && id) { + // remove logo_id from organisation + const resp = await patchCommunity({ + data: { + id, + logo_id: null + }, + token + }) + // console.log('removeLogo...',resp) + if (resp.status === 200) { + // delete logo without check + const del = await deleteImage({ + id: logo, + token + }) + setLogo(null) + } else { + showErrorMessage(`Failed to remove logo. ${resp.message}`) + } + } + } + + return ( + <> + + {isMaintainer && + + } + + ) + +} diff --git a/frontend/components/communities/metadata/index.tsx b/frontend/components/communities/metadata/index.tsx new file mode 100644 index 000000000..22fc6df12 --- /dev/null +++ b/frontend/components/communities/metadata/index.tsx @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import BaseSurfaceRounded from '~/components/layout/BaseSurfaceRounded' +import Links, {LinksProps} from '~/components/organisation/metadata/Links' +import CommunityLogo from './CommunityLogo' + +type CommunityMetadataProps={ + id: string, + name: string, + short_description: string | null + logo_id: string | null + isMaintainer: boolean + links: LinksProps[] +} + +export default function CommunityMetadata({ + id,name,short_description, + logo_id,isMaintainer,links +}:CommunityMetadataProps) { + + return ( +
    + + + + +
    +

    + {name} +

    +

    + {short_description} +

    +
    +
    + +
    + + + ) +} diff --git a/frontend/components/communities/overview/CommunitiesGrid.tsx b/frontend/components/communities/overview/CommunitiesGrid.tsx new file mode 100644 index 000000000..a26939b28 --- /dev/null +++ b/frontend/components/communities/overview/CommunitiesGrid.tsx @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import NoContent from '~/components/layout/NoContent' +import CommunityCard from './CommunityCard' +import {CommunityListProps} from '../apiCommunities' + + +export default function CommunitiesGrid({items}:{items:CommunityListProps[]}) { + + if (typeof items == 'undefined' || items.length===0){ + return + } + + return ( +
    + {items.map((item) => ( + + ))} +
    + ) +} diff --git a/frontend/components/communities/overview/CommunitiesList.tsx b/frontend/components/communities/overview/CommunitiesList.tsx new file mode 100644 index 000000000..a3d44df3b --- /dev/null +++ b/frontend/components/communities/overview/CommunitiesList.tsx @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {Community} from '~/components/admin/communities/apiCommunities' +import NoContent from '~/components/layout/NoContent' +import {CommunityListProps} from '../apiCommunities' +import CommunityListItem from './CommunityListItem' + + +export default function CommunitiesList({items}:{items:CommunityListProps[]}) { + if (typeof items == 'undefined' || items.length===0){ + return + } + return ( +
    + {items.map((item) => ( + + ))} +
    + ) +} diff --git a/frontend/components/communities/overview/CommunityCard.tsx b/frontend/components/communities/overview/CommunityCard.tsx new file mode 100644 index 000000000..de59308fa --- /dev/null +++ b/frontend/components/communities/overview/CommunityCard.tsx @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2022 - 2023 dv4all +// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import Link from 'next/link' +import {getImageUrl} from '~/utils/editImage' +import CardTitleSubtitle from '~/components/cards/CardTitleSubtitle' +import ImageWithPlaceholder from '~/components/layout/ImageWithPlaceholder' +import CardImageFrame from '~/components/cards/CardImageFrame' +import CardContentFrame from '~/components/cards/CardContentFrame' +import OrganisationCardMetrics from '~/components/organisation/overview/card/OrganisationCardMetrics' +import {CommunityListProps} from '../apiCommunities' +// import CountryLabel from './CountryLabel' + +export default function CommunityCard({community}:{community:CommunityListProps}) { + + return ( +
    + +
    + + + + +
    + {/* */} + +
    +
    + {/* Software packages count */} +
    +
    + {community.software_cnt ?? 0} +
    +
    + software
    package{community.software_cnt === 1 ? '' : 's'} +
    +
    +
    +
    +
    + +
    + ) +} diff --git a/frontend/components/communities/overview/CommunityListItem.tsx b/frontend/components/communities/overview/CommunityListItem.tsx new file mode 100644 index 000000000..d251e389c --- /dev/null +++ b/frontend/components/communities/overview/CommunityListItem.tsx @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import Link from 'next/link' + +import ListImageWithGradientPlaceholder from '~/components/projects/overview/list/ListImageWithGradientPlaceholder' +import OverviewListItem from '~/components/software/overview/list/OverviewListItem' +import {getImageUrl} from '~/utils/editImage' +import {CommunityListProps} from '../apiCommunities' + +export default function CommunityListItem({community}:{community:CommunityListProps}) { + + return ( + + + +
    + {/* basic info */} +
    +
    + {community.name} +
    +
    + {community.short_description} +
    +
    + {/* software count */} +
    + {community.software_cnt} software packages +
    +
    + +
    + ) +} diff --git a/frontend/components/communities/tabs/CommunityTabItems.tsx b/frontend/components/communities/tabs/CommunityTabItems.tsx new file mode 100644 index 000000000..9a9ac794f --- /dev/null +++ b/frontend/components/communities/tabs/CommunityTabItems.tsx @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import InfoIcon from '@mui/icons-material/Info' +import TerminalIcon from '@mui/icons-material/Terminal' +import SettingsIcon from '@mui/icons-material/Settings' + +import {OrganisationForOverview} from '~/types/Organisation' + +type IsVisibleProps = Partial & { + isMaintainer: boolean +} + +export type CommunityTabItemProps = { + id: string, + label: (props:any)=>string, + icon: JSX.Element, + isVisible: (props: IsVisibleProps) => boolean +} + +export type TabKey = 'about'|'software'|'settings' +export type CommunityTabProps = { + [key in TabKey]: CommunityTabItemProps +} + +/** + * Community Tab items. Defines tab values. + * NOTE! When changing the tab options also update + * TabContent.tsx file to load proper component. + */ +export const communityTabItems:CommunityTabProps = { + software: { + id:'software', + label:({software_cnt})=>`Software (${software_cnt ?? 0})`, + icon: , + isVisible: (props) => true, + }, + settings:{ + id:'settings', + label:()=>'Settings', + icon: , + // we do not show this option if not a maintainer + isVisible: ({isMaintainer}) => isMaintainer + }, + about: { + id:'about', + label:()=>'About', + icon: , + isVisible: ({description}) => { + // we always show about section to maintainer + // if (isMaintainer === true) return true + // we do not show to visitors if there is no content + if (typeof description === 'undefined') return false + else if (description === null) return false + else if (description.trim()==='') return false + // else the description is present and we show about section + else return true + }, + } +} diff --git a/frontend/components/communities/tabs/index.tsx b/frontend/components/communities/tabs/index.tsx new file mode 100644 index 000000000..f8dad7c73 --- /dev/null +++ b/frontend/components/communities/tabs/index.tsx @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import Tab from '@mui/material/Tab' +import Tabs from '@mui/material/Tabs' +import {useRouter} from 'next/router' +import {TabKey, communityTabItems} from './CommunityTabItems' + +// extract tab items (object keys) +const tabItems = Object.keys(communityTabItems) as TabKey[] + +type CommunityTabsProps={ + tab:TabKey + software_cnt: number + description: string | null + isMaintainer: boolean +} + +export default function CommunityTabs({ + tab,software_cnt,description, + isMaintainer}:CommunityTabsProps) { + + const router = useRouter() + // default tab is software + // let select_tab:TabKey = 'software' + + return ( + { + const query:any={ + slug: router.query['slug'], + tab: value, + } + // add default order for software and project tabs + if (value === 'software') { + query['order'] = 'is_featured' + } + // push route change + router.push({query},undefined,{scroll:false}) + }} + aria-label="community tabs" + > + {tabItems.map(key => { + const item = communityTabItems[key] + if (item.isVisible({ + isMaintainer, + software_cnt, + description + }) === true) { + return + }})} + + ) +} diff --git a/frontend/components/organisation/OrganisationBreadcrumbs.tsx b/frontend/components/layout/PageBreadcrumbs.tsx similarity index 61% rename from frontend/components/organisation/OrganisationBreadcrumbs.tsx rename to frontend/components/layout/PageBreadcrumbs.tsx index 1c0b68dd1..25ad3a33c 100644 --- a/frontend/components/organisation/OrganisationBreadcrumbs.tsx +++ b/frontend/components/layout/PageBreadcrumbs.tsx @@ -1,17 +1,22 @@ -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2023 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 -import Breadcrumbs, {SlugInfo} from '../layout/Breadcrumbs' +import Breadcrumbs, {SlugInfo} from './Breadcrumbs' -export default function OrganisationBreadcrumbs({slug}:{slug:string[]}) { +type PageBreadcrumbsProps={ + root: SlugInfo + slug: string[] +} + +export default function PageBreadcrumbs({root,slug}:PageBreadcrumbsProps) { function createSegments(slug: string[]) { + // debugger const segments:SlugInfo[] = [{ - label: 'organisations', - path:'/organisations' + ...root }] - let path='/organisations' + let path=root.path slug.forEach((item, pos) => { if (pos === slug.length - 1) { // last segment is current page diff --git a/frontend/components/news/overview/useNewsOverviewParams.tsx b/frontend/components/news/overview/useNewsOverviewParams.tsx deleted file mode 100644 index bfa27532d..000000000 --- a/frontend/components/news/overview/useNewsOverviewParams.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2023 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import {useRouter} from 'next/router' - -import {rowsPerPageOptions} from '~/config/pagination' -import {ssrOrganisationParams} from '~/utils/extractQueryParam' -import {QueryParams, buildFilterUrl} from '~/utils/postgrestUrl' -import {getDocumentCookie} from '~/utils/userSettings' - - -export default function useNewsOverviewParams() { - const router = useRouter() - - function handleQueryChange(key: string, value: string | string[]) { - const params: QueryParams = { - // take existing params from url (query) - ...ssrOrganisationParams(router.query), - [key]: value, - } - // on each param change we reset page - if (key !== 'page') { - params['page'] = 1 - } - if (typeof params['rows'] === 'undefined' || params['rows'] === null) { - // extract from cookie or use default - params['rows'] = getDocumentCookie('rsd_page_rows', rowsPerPageOptions[0]) - } - // construct url with all query params - const url = buildFilterUrl(params,'news') - if (key === 'page') { - // when changin page we scroll to top - router.push(url, url, {scroll: true}) - } else { - // update page url but keep scroll position - router.push(url, url, {scroll: false}) - } - } - - function resetFilters() { - // remove params from url and keep scroll position - router.push(router.pathname, router.pathname, {scroll: false}) - } - - return { - handleQueryChange, - resetFilters - } -} diff --git a/frontend/components/organisation/overview/useOrganisationOverviewParams.test.tsx b/frontend/components/search/useSearchParams.test.tsx similarity index 52% rename from frontend/components/organisation/overview/useOrganisationOverviewParams.test.tsx rename to frontend/components/search/useSearchParams.test.tsx index 6d53988f5..e41714a13 100644 --- a/frontend/components/organisation/overview/useOrganisationOverviewParams.test.tsx +++ b/frontend/components/search/useSearchParams.test.tsx @@ -1,9 +1,9 @@ -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2023 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 -import useOrganisationOverviewParams from './useOrganisationOverviewParams' +import useSearchParams from './useSearchParams' // mock next router const mockBack = jest.fn() @@ -30,7 +30,7 @@ beforeEach(() => { it('handlesQueryChange with search param', () => { // extract function - const {handleQueryChange} = useOrganisationOverviewParams() + const {handleQueryChange} = useSearchParams('organisations') // call it with random param handleQueryChange('search', 'test-value') @@ -45,7 +45,7 @@ it('handlesQueryChange with search param', () => { it('handlesQueryChange for pagination', () => { // extract function - const {handleQueryChange} = useOrganisationOverviewParams() + const {handleQueryChange} = useSearchParams('organisations') // call it with random param handleQueryChange('page', '2') @@ -62,7 +62,7 @@ it('handlesQueryChange for pagination', () => { it('resetFilters calls push without any params', () => { // extract function - const {resetFilters} = useOrganisationOverviewParams() + const {resetFilters} = useSearchParams('organisations') // call it with random param resetFilters() @@ -74,3 +74,33 @@ it('resetFilters calls push without any params', () => { {'scroll': false} ) }) + +it ('handlesQueryChange supports communities overview',()=>{ + // extract function + const {handleQueryChange} = useSearchParams('communities') + + // call it with random param + handleQueryChange('search', 'test-value') + + expect(mockPush).toBeCalledTimes(1) + expect(mockPush).toBeCalledWith( + '/communities?search=test-value&page=1&rows=12', + '/communities?search=test-value&page=1&rows=12', + {'scroll': false} + ) +}) + +it ('handlesQueryChange supports news overview',()=>{ + // extract function + const {handleQueryChange} = useSearchParams('news') + + // call it with random param + handleQueryChange('search', 'test-value') + + expect(mockPush).toBeCalledTimes(1) + expect(mockPush).toBeCalledWith( + '/news?search=test-value&page=1&rows=12', + '/news?search=test-value&page=1&rows=12', + {'scroll': false} + ) +}) diff --git a/frontend/components/organisation/overview/useOrganisationOverviewParams.tsx b/frontend/components/search/useSearchParams.tsx similarity index 60% rename from frontend/components/organisation/overview/useOrganisationOverviewParams.tsx rename to frontend/components/search/useSearchParams.tsx index 3a8c527be..51982a990 100644 --- a/frontend/components/organisation/overview/useOrganisationOverviewParams.tsx +++ b/frontend/components/search/useSearchParams.tsx @@ -1,26 +1,32 @@ -// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2023 dv4all -// SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 import {useRouter} from 'next/router' import {rowsPerPageOptions} from '~/config/pagination' -import {ssrOrganisationParams} from '~/utils/extractQueryParam' -import {QueryParams, ssrOrganisationUrl} from '~/utils/postgrestUrl' +import {ssrBasicParams} from '~/utils/extractQueryParam' +import {QueryParams,buildFilterUrl} from '~/utils/postgrestUrl' import {getDocumentCookie} from '~/utils/userSettings' +type RsdViews='organisations'|'communities'|'news' -export default function useOrganisationOverviewParams() { +/** + * Hook to extract basic query parameters rows, page and search from the url. + * This hook is used by organisation, news and communities overview. + * @param view the route of the overview page (organisations | communities | news) + * @returns handleQueryChange and resetFilters methods. + */ +export default function useBasicQueryParams(view:RsdViews){ const router = useRouter() function createUrl(key: string, value: string | string[]) { const params: QueryParams = { // take existing params from url (query) - ...ssrOrganisationParams(router.query), + // basic params are search, page and rows + ...ssrBasicParams(router.query), + // overwrite with new value [key]: value, } // on each param change we reset page @@ -32,14 +38,14 @@ export default function useOrganisationOverviewParams() { params['rows'] = getDocumentCookie('rsd_page_rows', rowsPerPageOptions[0]) } // construct url with all query params - const url = ssrOrganisationUrl(params) + const url = buildFilterUrl(params,view) return url } function handleQueryChange(key: string, value: string | string[]) { const url = createUrl(key, value) if (key === 'page') { - // when changin page we scroll to top + // when changing page we scroll to top router.push(url, url, {scroll: true}) } else { // update page url but keep scroll position diff --git a/frontend/components/software/edit/editSoftwareConfig.tsx b/frontend/components/software/edit/editSoftwareConfig.tsx index bf3a5d7c8..51808f4fc 100644 --- a/frontend/components/software/edit/editSoftwareConfig.tsx +++ b/frontend/components/software/edit/editSoftwareConfig.tsx @@ -173,7 +173,7 @@ export type ContributorInformationConfig = typeof contributorInformation export const organisationInformation = { title: 'Participating organisations', - modalTile: 'Organisation', + modalTitle: 'Organisation', findOrganisation: { title: 'Add organisation', subtitle: 'We search by name in the RSD and the ROR databases', diff --git a/frontend/components/software/edit/organisations/EditOrganisationModal.tsx b/frontend/components/software/edit/organisations/EditOrganisationModal.tsx index 9f6066535..a66e98caa 100644 --- a/frontend/components/software/edit/organisations/EditOrganisationModal.tsx +++ b/frontend/components/software/edit/organisations/EditOrganisationModal.tsx @@ -97,7 +97,7 @@ export default function EditOrganisationModal({open, onCancel, onSubmit, organis color: 'primary.main', fontWeight: 500 }}> - {config.modalTile} + {config.modalTitle}
    Software community page - settings + ) +} diff --git a/frontend/pages/communities/[slug]/software.tsx b/frontend/pages/communities/[slug]/software.tsx new file mode 100644 index 000000000..156e15de1 --- /dev/null +++ b/frontend/pages/communities/[slug]/software.tsx @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {GetServerSidePropsContext} from 'next' + +import {app} from '~/config/app' +import {getUserFromToken} from '~/auth' +import {getUserSettings} from '~/utils/userSettings' +import {CommunityListProps, getCommunityBySlug} from '~/components/communities/apiCommunities' +import {LayoutType} from '~/components/software/overview/search/ViewToggleGroup' +import PageMeta from '~/components/seo/PageMeta' +import CanonicalUrl from '~/components/seo/CanonicalUrl' +import BackgroundAndLayout from '~/components/layout/BackgroundAndLayout' +import {UserSettingsProvider} from '~/components/organisation/context/UserSettingsContext' +import PageBreadcrumbs from '~/components/layout/PageBreadcrumbs' +import BaseSurfaceRounded from '~/components/layout/BaseSurfaceRounded' +import CommunityMetadata from '~/components/communities/metadata' +import CommunityTabs from '~/components/communities/tabs' +import {isCommunityMaintainer} from '~/auth/permissions/isMaintainerOfCommunity' + +type CommunitySoftwareProps={ + community: CommunityListProps, + slug: string[], + isMaintainer: boolean, + rsd_page_rows: number, + rsd_page_layout: LayoutType +} + +export default function CommunitySoftwarePage({ + community,slug,isMaintainer, + rsd_page_rows, rsd_page_layout +}:CommunitySoftwareProps) { + + console.group('CommunitySoftwarePage') + console.log('community...', community) + console.log('slug....', slug) + console.log('isMaintainer....', isMaintainer) + console.log('rsd_page_rows....', rsd_page_rows) + console.log('rsd_page_layout....', rsd_page_layout) + console.groupEnd() + + + function getMetaDescription() { + // use organisation (short) description if available + if (community.short_description) return community.short_description + // else generate description message + return `${community?.name ?? 'The RSD community'} with ${community.software_cnt ?? 0} software packages.` + } + + return ( + <> + {/* Page Head meta tags */} + + + + + {/* COMMUNITY HEADER */} + + + + {/* TABS */} + + + + {/* TAB CONTENT */} +
    + {/* */} +

    Community software - TO DO!

    +
    +
    +
    + + ) +} + + +// fetching data server side +// see documentation https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering +export async function getServerSideProps(context:GetServerSidePropsContext) { + try{ + const {params, req, query} = context + // extract user settings from cookie + const {rsd_page_layout, rsd_page_rows} = getUserSettings(req) + // extract user id from session + const token = req?.cookies['rsd_token'] + const user = getUserFromToken(token) + // find community by slug + const community = await getCommunityBySlug({ + slug: params?.slug?.toString() ?? null, + token: req?.cookies['rsd_token'], + user + }) + // console.log('community...', community) + if (community === null){ + // returning notFound triggers 404 page + return { + notFound: true, + } + } + // get info if the user is maintainer + const isMaintainer = await isCommunityMaintainer({ + community: community.id, + role: user?.role, + account: user?.account, + token + }) + + return { + // passed to the page component as props + props: { + community, + slug: [params?.slug], + tab: query?.tab ?? null, + isMaintainer, + rsd_page_layout, + rsd_page_rows + }, + } + }catch(e){ + return { + notFound: true, + } + } +} diff --git a/frontend/pages/communities/index.tsx b/frontend/pages/communities/index.tsx new file mode 100644 index 000000000..9ff8b9c9b --- /dev/null +++ b/frontend/pages/communities/index.tsx @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {useState} from 'react' +import {GetServerSidePropsContext} from 'next/types' +import Pagination from '@mui/material/Pagination' +import PaginationItem from '@mui/material/PaginationItem' + +import {app} from '~/config/app' +import {getUserSettings, setDocumentCookie} from '~/utils/userSettings' +import {ssrBasicParams} from '~/utils/extractQueryParam' +import PageMeta from '~/components/seo/PageMeta' +import PageBackground from '~/components/layout/PageBackground' +import AppHeader from '~/components/AppHeader' +import MainContent from '~/components/layout/MainContent' +import AppFooter from '~/components/AppFooter' +import SearchInput from '~/components/search/SearchInput' +import useSearchParams from '~/components/search/useSearchParams' +import SelectRows from '~/components/software/overview/search/SelectRows' +import {LayoutType} from '~/components/software/overview/search/ViewToggleGroup' +import ViewToggleGroup,{ProjectLayoutType} from '~/components/projects/overview/search/ViewToggleGroup' + +import {Community} from '~/components/admin/communities/apiCommunities' +import CommunitiesList from '~/components/communities/overview/CommunitiesList' +import CommunitiesGrid from '~/components/communities/overview/CommunitiesGrid' +import {CommunityListProps, getCommunityList} from '~/components/communities/apiCommunities' + +const pageTitle = `Communities | ${app.title}` +const pageDesc = 'List of RSD communities.' + +type CommunitiesOverviewProps={ + count: number, + page: number, + rows: number, + layout: LayoutType, + search?: string, + communities: CommunityListProps[] +} + + +export default function CommunitiesOverview({count,page,rows,layout,search,communities}:CommunitiesOverviewProps) { + const {handleQueryChange,createUrl} = useSearchParams('communities') + const initView = layout === 'masonry' ? 'grid' : layout + const [view, setView] = useState(initView) + const numPages = Math.ceil(count / rows) + + // console.group('CommunitiesOverview') + // console.log('count...', count) + // console.log('page...', page) + // console.log('rows...', rows) + // console.log('layout...', layout) + // console.log('view...', view) + // console.log('search...', search) + // console.log('communities...', communities) + // console.groupEnd() + + function setLayout(view: ProjectLayoutType) { + // update local view + setView(view) + // save to cookie + setDocumentCookie(view,'rsd_page_layout') + } + + return ( + <> + {/* Page Head meta tags */} + + + + + + + {/* Page title with search and pagination */} +
    +

    + Communities +

    +
    + handleQueryChange('search', search)} + defaultValue={search ?? ''} + /> + + +
    +
    + + {/* news cards, grid is default */} + {view === 'list' ? + + : + + } + + {/* Pagination */} + {numPages > 1 && +
    + { + if (item.page !== null) { + return ( + + + + ) + } else { + return ( + + ) + } + }} + /> +
    + } +
    + + {/* App footer */} + +
    + + ) +} + +// see documentation https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering +export async function getServerSideProps(context:GetServerSidePropsContext) { + try{ + const {req} = context + const {search, rows, page} = ssrBasicParams(context.query) + const token = req?.cookies['rsd_token'] + + // extract user settings from cookie + const {rsd_page_layout,rsd_page_rows} = getUserSettings(context.req) + // use url param if present else user settings + let page_rows = rows ?? rsd_page_rows + + // get news items list to all pages server side + const {count,communities} = await getCommunityList({ + // api uses 0 based index + page: page>0 ? page-1 : 0, + rows: page_rows, + searchFor: search, + orderBy: 'software_cnt.desc,name.asc', + token + }) + + return { + // passed to the page component as props + props: { + search, + count, + page, + rows: page_rows, + layout: rsd_page_layout, + communities, + }, + } + }catch(e){ + return { + notFound: true, + } + } +} diff --git a/frontend/pages/news/index.tsx b/frontend/pages/news/index.tsx index 7390a273e..0a18f4833 100644 --- a/frontend/pages/news/index.tsx +++ b/frontend/pages/news/index.tsx @@ -6,9 +6,10 @@ import {useState} from 'react' import {GetServerSidePropsContext} from 'next/types' import Pagination from '@mui/material/Pagination' +import PaginationItem from '@mui/material/PaginationItem' import {app} from '~/config/app' -import {ssrOrganisationParams} from '~/utils/extractQueryParam' +import {ssrBasicParams} from '~/utils/extractQueryParam' import {getUserSettings, setDocumentCookie} from '~/utils/userSettings' import PageMeta from '~/components/seo/PageMeta' import PageBackground from '~/components/layout/PageBackground' @@ -16,11 +17,11 @@ import AppHeader from '~/components/AppHeader' import MainContent from '~/components/layout/MainContent' import AppFooter from '~/components/AppFooter' import SearchInput from '~/components/search/SearchInput' +import useSearchParams from '~/components/search/useSearchParams' import SelectRows from '~/components/software/overview/search/SelectRows' import {LayoutType} from '~/components/software/overview/search/ViewToggleGroup' import ViewToggleGroup,{ProjectLayoutType} from '~/components/projects/overview/search/ViewToggleGroup' import NewsGrid from '~/components/news/overview/NewsGrid' -import useNewsOverviewParams from '~/components/news/overview/useNewsOverviewParams' import {NewsListItem, getNewsList} from '~/components/news/apiNews' import NewsList from '~/components/news/overview/list' @@ -37,7 +38,7 @@ type NewsOverviewProps={ } export default function NewsOverview({count,page,rows,layout,search,news}:NewsOverviewProps) { - const {handleQueryChange} = useNewsOverviewParams() + const {handleQueryChange,createUrl} = useSearchParams('news') const initView = layout === 'masonry' ? 'grid' : layout const [view, setView] = useState(initView) const numPages = Math.ceil(count / rows) @@ -106,8 +107,18 @@ export default function NewsOverview({count,page,rows,layout,search,news}:NewsOv { - handleQueryChange('page',page.toString()) + renderItem={item => { + if (item.page !== null) { + return ( + + + + ) + } else { + return ( + + ) + } }} />
    @@ -126,7 +137,7 @@ export default function NewsOverview({count,page,rows,layout,search,news}:NewsOv export async function getServerSideProps(context:GetServerSidePropsContext) { try{ const {req} = context - const {search, rows, page} = ssrOrganisationParams(context.query) + const {search, rows, page} = ssrBasicParams(context.query) const token = req?.cookies['rsd_token'] // extract user settings from cookie diff --git a/frontend/pages/organisations/[...slug].tsx b/frontend/pages/organisations/[...slug].tsx index 1363940a7..8c18bac75 100644 --- a/frontend/pages/organisations/[...slug].tsx +++ b/frontend/pages/organisations/[...slug].tsx @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 - 2023 dv4all // SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) // SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 @@ -18,7 +19,7 @@ import OrganisationMetadata from '~/components/organisation/metadata' import PageMeta from '~/components/seo/PageMeta' import BackgroundAndLayout from '~/components/layout/BackgroundAndLayout' import CanonicalUrl from '~/components/seo/CanonicalUrl' -import OrganisationBreadcrumbs from '~/components/organisation/OrganisationBreadcrumbs' +import PageBreadcrumbs from '~/components/layout/PageBreadcrumbs' import BaseSurfaceRounded from '~/components/layout/BaseSurfaceRounded' import OrganisationTabs from '~/components/organisation/tabs/OrganisationTabs' import TabContent from '~/components/organisation/tabs/TabContent' @@ -81,7 +82,13 @@ export default function OrganisationPage({ isMaintainer={isMaintainer} > {/* ORGANISATION HEADER */} - + {/* TABS */} diff --git a/frontend/pages/organisations/index.tsx b/frontend/pages/organisations/index.tsx index 8509328f4..349a09885 100644 --- a/frontend/pages/organisations/index.tsx +++ b/frontend/pages/organisations/index.tsx @@ -1,30 +1,30 @@ // SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 - 2023 dv4all +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) // SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) // SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center) // // SPDX-License-Identifier: Apache-2.0 import {MouseEvent, ChangeEvent} from 'react' import {GetServerSidePropsContext} from 'next/types' +import Link from 'next/link' import TablePagination from '@mui/material/TablePagination' import Pagination from '@mui/material/Pagination' -import Link from 'next/link' import PaginationItem from '@mui/material/PaginationItem' -import {app} from '../../config/app' -import PageTitle from '../../components/layout/PageTitle' -import Searchbox from '../../components/form/Searchbox' -import {OrganisationList} from '../../types/Organisation' -import {rowsPerPageOptions} from '../../config/pagination' -import {ssrOrganisationParams} from '../../utils/extractQueryParam' -import {getOrganisationsList} from '../../components/organisation/apiOrganisations' +import {app} from '~/config/app' +import PageTitle from '~/components/layout/PageTitle' +import Searchbox from '~/components/form/Searchbox' +import {OrganisationList} from '~/types/Organisation' +import {rowsPerPageOptions} from '~/config/pagination' +import {ssrBasicParams} from '~/utils/extractQueryParam' +import {getOrganisationsList} from '~/components/organisation/apiOrganisations' import PageMeta from '~/components/seo/PageMeta' import AppFooter from '~/components/AppFooter' import AppHeader from '~/components/AppHeader' import {getUserSettings, setDocumentCookie} from '~/utils/userSettings' -import useOrganisationOverviewParams from '~/components/organisation/overview/useOrganisationOverviewParams' +import useSearchParams from '~/components/search/useSearchParams' import OrganisationGrid from '~/components/organisation/overview/OrganisationGrid' import PageBackground from '~/components/layout/PageBackground' import CanonicalUrl from '~/components/seo/CanonicalUrl' @@ -44,7 +44,7 @@ const pageDesc = 'List of organizations involved in the development of research export default function OrganisationsOverviewPage({ organisations = [], count, page, rows, search }: OrganisationsOverviewPageProps) { - const {handleQueryChange, createUrl} = useOrganisationOverviewParams() + const {handleQueryChange,createUrl} = useSearchParams('organisations') const numPages = Math.ceil(count / rows) // console.group('OrganisationsOverviewPage') @@ -158,7 +158,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { // extract params from page-query // extract rsd_token const {req} = context - const {search, rows, page} = ssrOrganisationParams(context.query) + const {search, rows, page} = ssrBasicParams(context.query) const token = req?.cookies['rsd_token'] // extract user settings from cookie const {rsd_page_rows} = getUserSettings(context.req) diff --git a/frontend/public/data/settings.json b/frontend/public/data/settings.json index 36bef97cf..8af3b0939 100644 --- a/frontend/public/data/settings.json +++ b/frontend/public/data/settings.json @@ -39,6 +39,16 @@ "label": "Netherlands eScienceCenter", "url": "https://www.esciencecenter.nl/", "target": "_blank" + }, + { + "label": "Communities", + "url": "/communities", + "target": "_self" + }, + { + "label": "News", + "url": "/news", + "target": "_self" } ], "theme": { diff --git a/frontend/utils/extractQueryParam.test.ts b/frontend/utils/extractQueryParam.test.ts index 387cb61b2..098b49647 100644 --- a/frontend/utils/extractQueryParam.test.ts +++ b/frontend/utils/extractQueryParam.test.ts @@ -1,14 +1,14 @@ // SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 - 2023 dv4all -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2023 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 import {ParsedUrlQuery} from 'node:querystring' import { decodeQueryParam, ssrSoftwareParams, - ssrProjectsParams, ssrOrganisationParams + ssrProjectsParams, ssrBasicParams } from './extractQueryParam' @@ -129,7 +129,7 @@ it('extracts ssrProjectsParams from url query', () => { expect(params).toEqual(expected) }) -it('extracts ssrOrganisationParams from url query', () => { +it('extracts ssrBasicParams from url query', () => { const query: ParsedUrlQuery = { 'search': 'another search', 'page': '3', @@ -140,7 +140,7 @@ it('extracts ssrOrganisationParams from url query', () => { page: 3, rows: 48 } - const params = ssrOrganisationParams(query) + const params = ssrBasicParams(query) expect(params).toEqual(expected) }) diff --git a/frontend/utils/extractQueryParam.ts b/frontend/utils/extractQueryParam.ts index 86c1cd192..40753bb0a 100644 --- a/frontend/utils/extractQueryParam.ts +++ b/frontend/utils/extractQueryParam.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2021 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2021 - 2023 dv4all -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2023 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 @@ -256,7 +256,13 @@ export function ssrProjectsParams(query: ParsedUrlQuery) { } } -export function ssrOrganisationParams(query: ParsedUrlQuery) { +/** + * Extract basic query parameters search, page and rows. + * Used by organisation, news and communities overview pages. + * @param query + * @returns + */ +export function ssrBasicParams(query: ParsedUrlQuery) { const rows = decodeQueryParam({ query, param: 'rows',