From 5c78d5c1d39bc4bd3ae8c82587f996f5fde3fadc Mon Sep 17 00:00:00 2001
From: "Dusan Mijatovic (PC2020)"
Date: Sun, 28 Apr 2024 16:21:59 +0200
Subject: [PATCH 1/9] feat: add communities to rsd admin section
---
database/024-community.sql | 193 ++++++++++++++
frontend/components/admin/AdminNav.tsx | 9 +-
.../admin/communities/AddCommunityModal.tsx | 237 ++++++++++++++++++
.../admin/communities/CommunityList.tsx | 83 ++++++
.../admin/communities/CommunityListItem.tsx | 91 +++++++
.../admin/communities/NoCommunityAlert.tsx | 18 ++
.../admin/communities/apiCommunities.ts | 153 +++++++++++
.../components/admin/communities/config.ts | 60 +++++
.../components/admin/communities/index.tsx | 66 +++++
.../admin/communities/useAdminCommunities.tsx | 118 +++++++++
.../components/admin/organisations/index.tsx | 4 +-
.../components/form/ControlledImageInput.tsx | 128 ++++++++++
.../software/edit/editSoftwareConfig.tsx | 1 +
.../organisations/EditOrganisationModal.tsx | 120 ++-------
frontend/pages/admin/communities.tsx | 70 ++++++
15 files changed, 1248 insertions(+), 103 deletions(-)
create mode 100644 database/024-community.sql
create mode 100644 frontend/components/admin/communities/AddCommunityModal.tsx
create mode 100644 frontend/components/admin/communities/CommunityList.tsx
create mode 100644 frontend/components/admin/communities/CommunityListItem.tsx
create mode 100644 frontend/components/admin/communities/NoCommunityAlert.tsx
create mode 100644 frontend/components/admin/communities/apiCommunities.ts
create mode 100644 frontend/components/admin/communities/config.ts
create mode 100644 frontend/components/admin/communities/index.tsx
create mode 100644 frontend/components/admin/communities/useAdminCommunities.tsx
create mode 100644 frontend/components/form/ControlledImageInput.tsx
create mode 100644 frontend/pages/admin/communities.tsx
diff --git a/database/024-community.sql b/database/024-community.sql
new file mode 100644
index 000000000..59753fbd8
--- /dev/null
+++ b/database/024-community.sql
@@ -0,0 +1,193 @@
+-- SPDX-FileCopyrightText: 2024 Dusan Mijatovic (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]+)*$'),
+ 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,
+ updated_at TIMESTAMPTZ NOT NULL
+);
+
+-- SANITISE insert and update
+-- ONLY rsd_admin and primary_maintainer can change community table
+-- ONLY rsd_admin can change primary_maintainer value
+CREATE FUNCTION sanitise_insert_community() RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+ NEW.id = gen_random_uuid();
+ NEW.created_at = LOCALTIMESTAMP;
+ NEW.updated_at = NEW.created_at;
+
+ IF CURRENT_USER = 'rsd_admin' OR (SELECT rolsuper FROM pg_roles WHERE rolname = CURRENT_USER) THEN
+ RETURN NEW;
+ END IF;
+
+ IF NOT NEW.is_tenant AND NEW.parent IS NULL AND NEW.primary_maintainer IS NULL THEN
+ RETURN NEW;
+ END IF;
+
+ IF (SELECT primary_maintainer FROM community o WHERE o.id = NEW.parent) = uuid(current_setting('request.jwt.claims', FALSE)::json->>'account')
+ AND
+ NEW.primary_maintainer = (SELECT primary_maintainer FROM community o WHERE o.id = NEW.parent)
+ THEN
+ RETURN NEW;
+ END IF;
+
+ RAISE EXCEPTION USING MESSAGE = 'You are not allowed to add this community';
+END
+$$;
+
+CREATE TRIGGER sanitise_insert_community BEFORE INSERT ON community FOR EACH ROW EXECUTE PROCEDURE sanitise_insert_community();
+
+
+CREATE FUNCTION sanitise_update_community() RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+ NEW.id = OLD.id;
+ NEW.created_at = OLD.created_at;
+ NEW.updated_at = LOCALTIMESTAMP;
+
+ IF NEW.slug IS DISTINCT FROM OLD.slug AND CURRENT_USER IS DISTINCT FROM 'rsd_admin' AND (SELECT rolsuper FROM pg_roles WHERE rolname = CURRENT_USER) IS DISTINCT FROM TRUE THEN
+ RAISE EXCEPTION USING MESSAGE = 'You are not allowed to change the slug';
+ END IF;
+
+ IF CURRENT_USER <> 'rsd_admin' AND NOT (SELECT rolsuper FROM pg_roles WHERE rolname = CURRENT_USER) THEN
+ IF NEW.primary_maintainer IS DISTINCT FROM OLD.primary_maintainer THEN
+ RAISE EXCEPTION USING MESSAGE = 'You are not allowed to change the primary maintainer for community ' || OLD.name;
+ END IF;
+ END IF;
+
+ RETURN NEW;
+END
+$$;
+
+CREATE TRIGGER sanitise_update_community BEFORE UPDATE ON community FOR EACH ROW EXECUTE PROCEDURE sanitise_update_community();
+
+-- RLS community table
+ALTER TABLE community ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY anyone_can_read ON community FOR SELECT TO rsd_web_anon, rsd_user
+ USING (TRUE);
+
+CREATE POLICY admin_all_rights ON community TO rsd_admin
+ USING (TRUE)
+ WITH CHECK (TRUE);
+
+
+-- SOFTWARE FOR COMMUNITY
+-- request status of software to be added to community
+-- default value is pending
+CREATE TYPE request_status AS ENUM (
+ 'pending',
+ 'approved',
+ 'rejected'
+);
+
+CREATE TABLE software_for_community (
+ community UUID REFERENCES community (id),
+ software UUID REFERENCES software (id),
+ status request_status NOT NULL DEFAULT 'pending',
+ PRIMARY KEY (community, software)
+);
+
+CREATE FUNCTION sanitise_update_software_for_community() RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+ NEW.community = OLD.community;
+ NEW.software = OLD.software;
+ return NEW;
+END
+$$;
+
+CREATE TRIGGER sanitise_update_software_for_community BEFORE UPDATE ON software_for_community FOR EACH ROW EXECUTE PROCEDURE sanitise_update_software_for_community();
+
+
+-- MAINTAINER OF COMMUNITY
+
+CREATE TABLE maintainer_for_community (
+ maintainer UUID REFERENCES account (id),
+ community UUID REFERENCES community (id),
+ PRIMARY KEY (maintainer, community)
+);
+
+-- INVITES FOR COMMUNITY MAINTAINER (magic link)
+CREATE TABLE invite_maintainer_for_community (
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
+ community UUID REFERENCES community (id) NOT NULL,
+ created_by UUID REFERENCES account (id),
+ claimed_by UUID REFERENCES account (id),
+ claimed_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT LOCALTIMESTAMP
+);
+
+CREATE FUNCTION sanitise_insert_invite_maintainer_for_community() RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+ NEW.id = gen_random_uuid();
+ NEW.created_at = LOCALTIMESTAMP;
+ NEW.claimed_by = NULL;
+ NEW.claimed_at = NULL;
+ return NEW;
+END
+$$;
+
+CREATE TRIGGER sanitise_insert_invite_maintainer_for_community BEFORE INSERT ON invite_maintainer_for_community FOR EACH ROW EXECUTE PROCEDURE sanitise_insert_invite_maintainer_for_community();
+
+CREATE FUNCTION sanitise_update_invite_maintainer_for_community() RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+ NEW.id = OLD.id;
+ NEW.software = OLD.software;
+ NEW.created_by = OLD.created_by;
+ NEW.created_at = OLD.created_at;
+ return NEW;
+END
+$$;
+
+CREATE TRIGGER sanitise_update_invite_maintainer_for_community BEFORE UPDATE ON invite_maintainer_for_community FOR EACH ROW EXECUTE PROCEDURE sanitise_update_invite_maintainer_for_community();
+
+CREATE FUNCTION accept_invitation_community(invitation UUID) RETURNS TABLE(
+ name VARCHAR,
+ slug VARCHAR
+) LANGUAGE plpgsql VOLATILE SECURITY DEFINER AS
+$$
+DECLARE invitation_row invite_maintainer_for_community%ROWTYPE;
+DECLARE account UUID;
+BEGIN
+ account = uuid(current_setting('request.jwt.claims', FALSE)::json->>'account');
+ IF account IS NULL THEN
+ RAISE EXCEPTION USING MESSAGE = 'Please login first';
+ END IF;
+
+ IF invitation IS NULL THEN
+ RAISE EXCEPTION USING MESSAGE = 'Please provide an invitation id';
+ END IF;
+
+ SELECT * FROM invite_maintainer_for_community WHERE id = invitation INTO invitation_row;
+ IF invitation_row.id IS NULL THEN
+ RAISE EXCEPTION USING MESSAGE = 'Invitation with id ' || invitation || ' does not exist';
+ END IF;
+
+ IF invitation_row.claimed_by IS NOT NULL OR invitation_row.claimed_at IS NOT NULL THEN
+ RAISE EXCEPTION USING MESSAGE = 'Invitation with id ' || invitation || ' is expired';
+ END IF;
+
+-- Only use the invitation if not already a maintainer
+ IF NOT EXISTS(SELECT 1 FROM maintainer_for_community WHERE maintainer = account AND community = invitation_row.community) THEN
+ UPDATE invite_maintainer_for_community SET claimed_by = account, claimed_at = LOCALTIMESTAMP WHERE id = invitation;
+ INSERT INTO maintainer_for_community VALUES (account, invitation_row.community);
+ END IF;
+
+ RETURN QUERY
+ SELECT community.name, community.slug FROM community WHERE community.id = invitation_row.community;
+ RETURN;
+END
+$$;
+
diff --git a/frontend/components/admin/AdminNav.tsx b/frontend/components/admin/AdminNav.tsx
index fa2ea2ed5..0eabc622f 100644
--- a/frontend/components/admin/AdminNav.tsx
+++ b/frontend/components/admin/AdminNav.tsx
@@ -26,6 +26,7 @@ import FluorescentIcon from '@mui/icons-material/Fluorescent'
import CampaignIcon from '@mui/icons-material/Campaign'
import BugReportIcon from '@mui/icons-material/BugReport'
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong'
+import Diversity3Icon from '@mui/icons-material/Diversity3'
import {editMenuItemButtonSx} from '~/config/menuItems'
@@ -66,6 +67,12 @@ export const adminPages = {
icon: ,
path: '/admin/organisations',
},
+ communities: {
+ title: 'Communities',
+ subtitle: '',
+ icon: ,
+ path: '/admin/communities',
+ },
keywords:{
title: 'Keywords',
subtitle: '',
@@ -94,7 +101,7 @@ export const adminPages = {
// extract page types from the object
type pageTypes = keyof typeof adminPages
-// extract page properties from forst admin item
+// extract page properties from first admin item
type pageProps = typeof adminPages.accounts
export default function AdminNav() {
diff --git a/frontend/components/admin/communities/AddCommunityModal.tsx b/frontend/components/admin/communities/AddCommunityModal.tsx
new file mode 100644
index 000000000..43e207d9e
--- /dev/null
+++ b/frontend/components/admin/communities/AddCommunityModal.tsx
@@ -0,0 +1,237 @@
+// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all)
+// SPDX-FileCopyrightText: 2022 - 2023 dv4all
+// SPDX-FileCopyrightText: 2022 Christian Meeßen (GFZ)
+// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) (dv4all)
+// SPDX-FileCopyrightText: 2022 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
+// SPDX-FileCopyrightText: 2022 Matthias Rüster (GFZ)
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useEffect, useState} from 'react'
+import Button from '@mui/material/Button'
+import useMediaQuery from '@mui/material/useMediaQuery'
+import Dialog from '@mui/material/Dialog'
+import DialogTitle from '@mui/material/DialogTitle'
+import DialogContent from '@mui/material/DialogContent'
+import DialogActions from '@mui/material/DialogActions'
+
+import {UseFormSetValue, useForm} from 'react-hook-form'
+
+import {useSession} from '~/auth'
+import {useDebounce} from '~/utils/useDebounce'
+import {getSlugFromString} from '~/utils/getSlugFromString'
+import TextFieldWithCounter from '~/components/form/TextFieldWithCounter'
+import SlugTextField from '~/components/form/SlugTextField'
+import SubmitButtonWithListener from '~/components/form/SubmitButtonWithListener'
+import ControlledImageInput, {FormInputsForImage} from '~/components/form/ControlledImageInput'
+import config from './config'
+import {Community, validCommunitySlug} from './apiCommunities'
+
+type AddCommunityModalProps = {
+ open: boolean,
+ onCancel: () => void,
+ onSubmit: (item:EditCommunityProps) => Promise
+}
+
+export type EditCommunityProps = Community & {
+ logo_b64?: string | null
+ logo_mime_type?: string | null
+}
+
+let lastValidatedSlug = ''
+const formId='add-community-form'
+
+export default function AddPageModal({open,onCancel,onSubmit}:AddCommunityModalProps) {
+ const {token} = useSession()
+ const smallScreen = useMediaQuery('(max-width:600px)')
+ const [baseUrl, setBaseUrl] = useState('')
+ const [slugValue, setSlugValue] = useState('')
+ const [validating, setValidating]=useState(false)
+ const {register, handleSubmit, watch, formState, setError, setValue} = useForm({
+ mode: 'onChange',
+ defaultValues: {
+ slug:'',
+ name: '',
+ short_description: null,
+ description: null,
+ primary_maintainer: null,
+ logo_id: null,
+ logo_b64: null,
+ logo_mime_type: null
+ }
+ })
+ const {errors, isValid} = formState
+ // watch for data change in the form
+ const [slug,name,short_description,logo_id,logo_b64] = watch(['slug','name','short_description','logo_id','logo_b64'])
+ // construct slug from title
+ const bouncedSlug = useDebounce(slugValue,700)
+
+ useEffect(() => {
+ if (typeof location != 'undefined') {
+ setBaseUrl(`${location.origin}/${config.rsdRootPath}/`)
+ }
+ }, [])
+
+ /**
+ * Convert name value into slugValue.
+ * The name is then debounced and produces bouncedSlug
+ * We use bouncedSlug value later on to perform call to api
+ */
+ useEffect(() => {
+ const softwareSlug = getSlugFromString(name)
+ setSlugValue(softwareSlug)
+ }, [name])
+ /**
+ * When bouncedSlug value is changed,
+ * we need to update slug value (value in the input) shown to user.
+ * This change occurs when brand_name value is changed
+ */
+ useEffect(() => {
+ setValue('slug', bouncedSlug, {
+ shouldValidate: true
+ })
+ }, [bouncedSlug, setValue])
+
+ useEffect(() => {
+ let abort = false
+ async function validateSlug() {
+ setValidating(true)
+ const isUsed = await validCommunitySlug({slug,token})
+ // if (abort) return
+ if (isUsed === true) {
+ const message = `${slug} is already taken. Use letters, numbers and dash "-" to modify slug value.`
+ setError('slug', {
+ type: 'validate',
+ message
+ })
+ }
+ lastValidatedSlug = slug
+ // we need to wait some time
+ setValidating(false)
+ }
+ if (slug !== lastValidatedSlug) {
+ // debugger
+ validateSlug()
+ }
+ return ()=>{abort=true}
+ },[slug,token,setError])
+
+ function isSaveDisabled() {
+ // during async validation we disable button
+ if (validating === true) return true
+ // if isValid is not true
+ return isValid===false
+ }
+
+ function handleCancel(e:any,reason: 'backdropClick' | 'escapeKeyDown') {
+ // close only on escape, not if user clicks outside of the modal
+ if (reason==='escapeKeyDown') onCancel()
+ }
+
+ return (
+
+
+ {config.modalTitle}
+
+
+
+ )
+}
diff --git a/frontend/components/admin/communities/CommunityList.tsx b/frontend/components/admin/communities/CommunityList.tsx
new file mode 100644
index 000000000..66b296ab4
--- /dev/null
+++ b/frontend/components/admin/communities/CommunityList.tsx
@@ -0,0 +1,83 @@
+// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
+// SPDX-FileCopyrightText: 2023 dv4all
+// 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 List from '@mui/material/List'
+
+import ConfirmDeleteModal from '~/components/layout/ConfirmDeleteModal'
+import ContentLoader from '~/components/layout/ContentLoader'
+import {Community} from './apiCommunities'
+import CommunityListItem from './CommunityListItem'
+import NoCommunityAlert from './NoCommunityAlert'
+
+type DeleteOrganisationModal = {
+ open: boolean,
+ item?: Community
+}
+
+type OrganisationsAdminListProps = {
+ communities: Community[]
+ loading: boolean
+ page: number
+ onDeleteItem: (id:string,logo_id:string|null)=>void
+}
+
+export default function CommunityList({communities,loading,page,onDeleteItem}:OrganisationsAdminListProps) {
+ const [modal, setModal] = useState({
+ open: false
+ })
+
+ if (loading && !page) return
+
+ if (communities.length===0) return
+
+ return (
+ <>
+
+ {
+ communities.map(item => {
+ return (
+ setModal({
+ open: true,
+ item
+ })}
+ />
+ )
+ })
+ }
+
+
+
+ Are you sure you want to delete community {modal?.item?.name} ?
+
+ >
+ }
+ onCancel={() => {
+ setModal({
+ open: false
+ })
+ }}
+ onDelete={() => {
+ // call remove method if id present
+ if (modal.item && modal.item?.id) onDeleteItem(modal.item?.id,modal.item?.logo_id)
+ setModal({
+ open: false
+ })
+ }}
+ />
+ >
+ )
+}
diff --git a/frontend/components/admin/communities/CommunityListItem.tsx b/frontend/components/admin/communities/CommunityListItem.tsx
new file mode 100644
index 000000000..a1ee7a74c
--- /dev/null
+++ b/frontend/components/admin/communities/CommunityListItem.tsx
@@ -0,0 +1,91 @@
+// 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 IconButton from '@mui/material/IconButton'
+import ListItem from '@mui/material/ListItem'
+import DeleteIcon from '@mui/icons-material/Delete'
+import EditIcon from '@mui/icons-material/Edit'
+
+import ListItemText from '@mui/material/ListItemText'
+import ListItemAvatar from '@mui/material/ListItemAvatar'
+import Avatar from '@mui/material/Avatar'
+import {getImageUrl} from '~/utils/editImage'
+import {Community} from './apiCommunities'
+import config from './config'
+
+type OrganisationItemProps = {
+ item: Community,
+ onDelete: () => void
+}
+
+export default function CommunityListItem({item, onDelete}: OrganisationItemProps) {
+ const router = useRouter()
+ return (
+
+ {/* onEdit we open community settings */}
+
+
+
+ 0}
+ edge="end"
+ aria-label="delete"
+ onClick={() => {
+ onDelete()
+ }}
+ >
+
+
+ >
+ }
+ sx={{
+ // this makes space for buttons
+ paddingRight:'6.5rem',
+ }}
+ >
+
+
+ {item.name.slice(0,3)}
+
+
+
+ {item.short_description}
+
+ Software: 0 (not implemented!)
+ >
+ }
+ />
+
+ )
+}
diff --git a/frontend/components/admin/communities/NoCommunityAlert.tsx b/frontend/components/admin/communities/NoCommunityAlert.tsx
new file mode 100644
index 000000000..9e7aca1dd
--- /dev/null
+++ b/frontend/components/admin/communities/NoCommunityAlert.tsx
@@ -0,0 +1,18 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import Alert from '@mui/material/Alert'
+import AlertTitle from '@mui/material/AlertTitle'
+
+export default function NoCommunityAlert() {
+ return (
+
+ No communities defined
+ To add community to RSD use Add button on the right .
+
+ )
+}
diff --git a/frontend/components/admin/communities/apiCommunities.ts b/frontend/components/admin/communities/apiCommunities.ts
new file mode 100644
index 000000000..a2bd682fd
--- /dev/null
+++ b/frontend/components/admin/communities/apiCommunities.ts
@@ -0,0 +1,153 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {extractCountFromHeader} from '~/utils/extractCountFromHeader'
+import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers'
+import logger from '~/utils/logger'
+import {paginationUrlParams} from '~/utils/postgrestUrl'
+
+export type Community={
+ id?:string,
+ slug:string,
+ name:string,
+ short_description: string|null,
+ description: string|null,
+ primary_maintainer: string|null,
+ logo_id: string|null
+}
+
+type GetCommunitiesParams={
+ page: number,
+ rows: number,
+ token: string
+ searchFor?:string,
+ orderBy?:string,
+}
+
+export async function getCommunities({page, rows, token, searchFor, orderBy}:GetCommunitiesParams){
+ try{
+ let query = paginationUrlParams({rows, page})
+ if (searchFor) {
+ query+=`&name=ilike.*${searchFor}*`
+ }
+ if (orderBy) {
+ query+=`&order=${orderBy}`
+ } else {
+ query+='&order=name.asc'
+ }
+ // complete url
+ const url = `${getBaseUrl()}/community?${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: Community[] = await resp.json()
+ return {
+ count: extractCountFromHeader(resp.headers) ?? 0,
+ communities
+ }
+ }
+ logger(`getCommunities: ${resp.status}: ${resp.statusText}`,'warn')
+ return {
+ count: 0,
+ communities: []
+ }
+ }catch(e:any){
+ logger(`getCommunities: ${e.message}`,'error')
+ return {
+ count: 0,
+ communities: []
+ }
+ }
+}
+
+export async function validCommunitySlug({slug, token}: { slug: string, token: string }) {
+ try{
+ // use server side when available
+ const baseUrl = getBaseUrl()
+ // get community by slug
+ let query = `community?select=slug&slug=eq.${slug}`
+ const url = `${baseUrl}/${query}`
+ // get community
+ const resp = await fetch(url, {
+ method: 'GET',
+ headers: {
+ ...createJsonHeaders(token)
+ }
+ })
+
+ if (resp.status === 200) {
+ const json: [] = await resp.json()
+ return json.length > 0
+ }
+ return false
+ }catch(e:any){
+ logger(`validCommunitySlug: ${e?.message}`, 'error')
+ return false
+ }
+}
+
+export async function addCommunity({data,token}:{data:Community,token:string}) {
+ try {
+ const query = 'community'
+ const url = `/api/v1/${query}`
+
+ const resp = await fetch(url,{
+ method: 'POST',
+ headers: {
+ ...createJsonHeaders(token),
+ 'Prefer': 'return=representation'
+ },
+ body: JSON.stringify(data)
+ })
+ if (resp.status === 201) {
+ const json = await resp.json()
+ // return created page
+ return {
+ status: 200,
+ message: json[0]
+ }
+ } else {
+ return extractReturnMessage(resp, '')
+ }
+ } catch (e: any) {
+ logger(`addCommunity: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
+
+export async function deleteCommunityById({id,token}:{id:string,token:string}) {
+ try {
+ const query = `community?id=eq.${id}`
+ const url = `/api/v1/${query}`
+
+ const resp = await fetch(url,{
+ method: 'DELETE',
+ headers: {
+ ...createJsonHeaders(token)
+ }
+ })
+ return extractReturnMessage(resp, '')
+ } catch (e: any) {
+ logger(`deleteCommunityById: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
+
diff --git a/frontend/components/admin/communities/config.ts b/frontend/components/admin/communities/config.ts
new file mode 100644
index 000000000..24f0c3030
--- /dev/null
+++ b/frontend/components/admin/communities/config.ts
@@ -0,0 +1,60 @@
+// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all)
+// SPDX-FileCopyrightText: 2022 - 2023 dv4all
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+const config = {
+ rsdRootPath:'communities',
+ modalTitle:'Add community',
+ slug: {
+ label: 'RSD path (slug)',
+ help: 'The location of this community',
+ baseUrl: () => {
+ if (typeof location != 'undefined') {
+ return `${location.origin}/community/`
+ }
+ return '/community'
+ },
+ // react-hook-form validation rules
+ validation: {
+ required: 'Slug is required',
+ minLength: {value: 3, message: 'Minimum length is 3'},
+ maxLength: {value: 100, message: 'Maximum length is 100'},
+ pattern: {
+ value: /^[a-z0-9]+(-[a-z0-9]+)*$/,
+ message: 'Use letters, numbers and dash "-". Other characters are not allowed.'
+ }
+ }
+ },
+ name: {
+ label: 'Name',
+ help: 'Community name shown in the card.',
+ // react-hook-form validation rules
+ validation: {
+ required: 'Name is required',
+ minLength: {value: 3, message: 'Minimum length is 3'},
+ maxLength: {value: 100, message: 'Maximum length is 100'},
+ }
+ },
+ short_description: {
+ label: 'Short description',
+ help: 'Describe in short what is this community about.',
+ validation: {
+ // we do not show error message for this one, we use only maxLength value
+ maxLength: {value: 300, message: 'Maximum length is 300'},
+ }
+ },
+ // field for markdown
+ description: {
+ label: 'About page',
+ help: '',
+ validation: {
+ // we do not show error message for this one, we use only maxLength value
+ maxLength: {value: 10000, message: 'Maximum length is 10000'},
+ }
+ },
+}
+
+export default config
diff --git a/frontend/components/admin/communities/index.tsx b/frontend/components/admin/communities/index.tsx
new file mode 100644
index 000000000..bce12e55a
--- /dev/null
+++ b/frontend/components/admin/communities/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 {useState,useContext} from 'react'
+import Button from '@mui/material/Button'
+import AddIcon from '@mui/icons-material/Add'
+
+import Searchbox from '~/components/search/Searchbox'
+import Pagination from '~/components/pagination/Pagination'
+import PaginationContext from '~/components/pagination/PaginationContext'
+import AddCommunityModal, {EditCommunityProps} from './AddCommunityModal'
+import CommunityList from './CommunityList'
+import {useAdminCommunities} from './useAdminCommunities'
+
+export default function AdminCommunities() {
+ const [modal, setModal] = useState(false)
+ const {pagination:{page}} = useContext(PaginationContext)
+ const {loading, communities, addCommunity, deleteCommunity} = useAdminCommunities()
+
+ async function onAddCommunity(data:EditCommunityProps){
+ // add community
+ const ok = await addCommunity(data)
+ // if all ok close the modal
+ // on error snackbar will be shown and we leave modal open for possible corrections
+ if (ok===true) setModal(false)
+ }
+
+ return (
+ <>
+
+
+
+ }
+ onClick={()=>setModal(true)}
+ >
+ Add
+
+
+
+ {
+ modal ?
+ setModal(false)}
+ onSubmit={onAddCommunity}
+ />
+ : null
+ }
+ >
+ )
+}
diff --git a/frontend/components/admin/communities/useAdminCommunities.tsx b/frontend/components/admin/communities/useAdminCommunities.tsx
new file mode 100644
index 000000000..24a85e5dc
--- /dev/null
+++ b/frontend/components/admin/communities/useAdminCommunities.tsx
@@ -0,0 +1,118 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useSession} from '~/auth'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import usePaginationWithSearch from '~/utils/usePaginationWithSearch'
+import {Community, getCommunities, addCommunity as addCommunityToRsd, deleteCommunityById} from './apiCommunities'
+import {useCallback, useEffect, useState} from 'react'
+import {EditCommunityProps} from './AddCommunityModal'
+import {deleteImage, upsertImage} from '~/utils/editImage'
+
+export function useAdminCommunities(){
+ const {token} = useSession()
+ const {showErrorMessage} = useSnackbar()
+ const {searchFor, page, rows, setCount} = usePaginationWithSearch('Find community by name')
+ const [communities, setCommunities] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const loadCommunities = useCallback(async() => {
+ setLoading(true)
+ const {communities, count} = await getCommunities({
+ token,
+ searchFor,
+ page,
+ rows
+ })
+ setCommunities(communities)
+ setCount(count ?? 0)
+ setLoading(false)
+ // we do not include setCount in order to avoid loop
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [token, searchFor, page, rows])
+
+ useEffect(() => {
+ if (token) {
+ loadCommunities()
+ }
+ // we do not include setCount in order to avoid loop
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [token,searchFor,page,rows])
+
+ const addCommunity = useCallback(async(data:EditCommunityProps)=>{
+ try{
+ // UPLOAD LOGO
+ if (data.logo_b64 && data.logo_mime_type) {
+ // split base64 to use only encoded content
+ const b64data = data.logo_b64.split(',')[1]
+ const upload = await upsertImage({
+ data: b64data,
+ mime_type: data.logo_mime_type,
+ token
+ })
+ // debugger
+ if (upload.status === 201) {
+ // update data values
+ data.logo_id = upload.message
+ } else {
+ data.logo_id = null
+ showErrorMessage(`Failed to upload image. ${upload.message}`)
+ return false
+ }
+ }
+ // remove temp props
+ delete data?.logo_b64
+ delete data?.logo_mime_type
+
+ const resp = await addCommunityToRsd({
+ data,
+ token
+ })
+
+ if (resp.status === 200) {
+ // return created item
+ loadCommunities()
+ return true
+ } else {
+ // show error
+ showErrorMessage(`Failed to add community. Error: ${resp.message}`)
+ return false
+ }
+ }catch(e:any){
+ showErrorMessage(`Failed to add community. Error: ${e.message}`)
+ return false
+ }
+ // we do not include showErrorMessage in order to avoid loop
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[token,loadCommunities])
+
+ const deleteCommunity = useCallback(async(id:string,logo_id:string|null)=>{
+ // console.log('deleteCommunity...', item)
+ const resp = await deleteCommunityById({id,token})
+ if (resp.status!==200){
+ showErrorMessage(`Failed to delete community. Error: ${resp.message}`)
+ } else {
+ // optionally try to delete logo
+ // but not wait on response
+ if (logo_id){
+ await deleteImage({
+ id: logo_id,
+ token
+ })
+ }
+ // reload list
+ loadCommunities()
+ }
+ // we do not include showErrorMessage in order to avoid loop
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[token])
+
+ return {
+ loading,
+ communities,
+ addCommunity,
+ deleteCommunity
+ }
+}
diff --git a/frontend/components/admin/organisations/index.tsx b/frontend/components/admin/organisations/index.tsx
index 7c040b470..f536f06d8 100644
--- a/frontend/components/admin/organisations/index.tsx
+++ b/frontend/components/admin/organisations/index.tsx
@@ -27,10 +27,10 @@ export default function OrganisationsAdminPage() {
return (
-
+ {/*
RSD organisations
{count}
-
+ */}
diff --git a/frontend/components/form/ControlledImageInput.tsx b/frontend/components/form/ControlledImageInput.tsx
new file mode 100644
index 000000000..c6438560f
--- /dev/null
+++ b/frontend/components/form/ControlledImageInput.tsx
@@ -0,0 +1,128 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {ChangeEvent, useRef} from 'react'
+
+import Avatar from '@mui/material/Avatar'
+import Button from '@mui/material/Button'
+import DeleteIcon from '@mui/icons-material/Delete'
+
+import {UseFormSetValue} from 'react-hook-form'
+
+import {useSession} from '~/auth'
+import {deleteImage, getImageUrl} from '~/utils/editImage'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import {handleFileUpload} from '~/utils/handleFileUpload'
+
+export type FormInputsForImage={
+ logo_id: string|null
+ logo_b64?: string|null
+ logo_mime_type?: string|null
+}
+
+type ImageInputProps={
+ name:string
+ logo_b64?: string|null
+ logo_id?: string|null
+ setValue: UseFormSetValue
+}
+
+export default function ControlledImageInput({name,logo_b64,logo_id,setValue}:ImageInputProps) {
+ const {token} = useSession()
+ const {showWarningMessage,showErrorMessage} = useSnackbar()
+ const imgInputRef = useRef(null)
+
+ async function onFileUpload(e:ChangeEvent|undefined) {
+ if (typeof e !== 'undefined') {
+ const {status, message, image_b64, image_mime_type} = await handleFileUpload(e)
+ if (status === 200 && image_b64 && image_mime_type) {
+ // save image
+ replaceLogo(image_b64,image_mime_type)
+ } else if (status===413) {
+ showWarningMessage(message)
+ } else {
+ showErrorMessage(message)
+ }
+ }
+ }
+
+ async function replaceLogo(logo_b64:string, logo_mime_type:string) {
+ if (logo_id) {
+ // remove old logo from db
+ const del = await deleteImage({
+ id: logo_id,
+ token
+ })
+ setValue('logo_id', null)
+ }
+ // write new logo to logo_b64
+ // we upload the image after submit
+ setValue('logo_b64', logo_b64)
+ setValue('logo_mime_type', logo_mime_type, {shouldDirty: true})
+ }
+
+ async function deleteLogo() {
+ if (logo_id) {
+ // remove old logo from db
+ await deleteImage({
+ id: logo_id,
+ token
+ })
+ }
+ // remove from form values
+ setValue('logo_id', null)
+ setValue('logo_b64', null)
+ setValue('logo_mime_type', null, {shouldDirty: true})
+ // remove image value from input
+ if (imgInputRef.current){
+ imgInputRef.current.value = ''
+ }
+ }
+
+ return (
+
+
+
+ {name ? name.slice(0,3) : ''}
+
+
+
+
+ }
+ >
+ Remove
+
+
+
+ )
+}
diff --git a/frontend/components/software/edit/editSoftwareConfig.tsx b/frontend/components/software/edit/editSoftwareConfig.tsx
index f15a5b4d6..d288ecec0 100644
--- a/frontend/components/software/edit/editSoftwareConfig.tsx
+++ b/frontend/components/software/edit/editSoftwareConfig.tsx
@@ -169,6 +169,7 @@ export type ContributorInformationConfig = typeof contributorInformation
export const organisationInformation = {
title: 'Participating organisations',
+ modalTile: '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 b811f1b54..9f6066535 100644
--- a/frontend/components/software/edit/organisations/EditOrganisationModal.tsx
+++ b/frontend/components/software/edit/organisations/EditOrganisationModal.tsx
@@ -2,33 +2,28 @@
// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 - 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
// SPDX-FileCopyrightText: 2022 - 2024 dv4all
+// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (dv4all) (dv4all)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
+// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0
-import {ChangeEvent, useEffect} from 'react'
-import Avatar from '@mui/material/Avatar'
+import {useEffect} from 'react'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import useMediaQuery from '@mui/material/useMediaQuery'
-import DeleteIcon from '@mui/icons-material/Delete'
import Alert from '@mui/material/Alert'
import AlertTitle from '@mui/material/AlertTitle'
-import {useForm} from 'react-hook-form'
+import {UseFormSetValue, useForm} from 'react-hook-form'
-import {useSession} from '~/auth'
import {EditOrganisation} from '~/types/Organisation'
-import {deleteImage, getImageUrl} from '~/utils/editImage'
-import {handleFileUpload} from '~/utils/handleFileUpload'
-import useSnackbar from '~/components/snackbar/useSnackbar'
import ControlledTextField from '~/components/form/ControlledTextField'
import SubmitButtonWithListener from '~/components/form/SubmitButtonWithListener'
import {organisationInformation as config} from '../editSoftwareConfig'
+import ControlledImageInput, {FormInputsForImage} from '~/components/form/ControlledImageInput'
type EditOrganisationModalProps = {
@@ -44,8 +39,6 @@ type EditOrganisationModalProps = {
const formId='edit-organisation-modal'
export default function EditOrganisationModal({open, onCancel, onSubmit, organisation, pos}: EditOrganisationModalProps) {
- const {token} = useSession()
- const {showWarningMessage,showErrorMessage} = useSnackbar()
const smallScreen = useMediaQuery('(max-width:600px)')
const {handleSubmit, watch, formState, reset, control, register, setValue, trigger} = useForm({
mode: 'onChange',
@@ -70,7 +63,7 @@ export default function EditOrganisationModal({open, onCancel, onSubmit, organis
// validate name on opening of the form
// we validate organisation name because we take it
// over from ROR or user input (which might not be valid entry)
- // it needs to be at the end of the cicle, so we need to use setTimeout
+ // it needs to be at the end of the cycle, so we need to use setTimeout
trigger('name')
}, 0)
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -90,48 +83,6 @@ export default function EditOrganisationModal({open, onCancel, onSubmit, organis
onCancel()
}
- async function onFileUpload(e:ChangeEvent|undefined) {
- if (typeof e !== 'undefined') {
- const {status, message, image_b64, image_mime_type} = await handleFileUpload(e)
- if (status === 200 && image_b64 && image_mime_type) {
- // save image
- replaceLogo(image_b64,image_mime_type)
- } else if (status===413) {
- showWarningMessage(message)
- } else {
- showErrorMessage(message)
- }
- }
- }
-
- async function replaceLogo(logo_b64:string, logo_mime_type:string) {
- if (formData.logo_id) {
- // remove old logo from db
- const del = await deleteImage({
- id: formData.logo_id,
- token
- })
- setValue('logo_id', null)
- }
- // write new logo to logo_b64
- // we upload the image after submit
- setValue('logo_b64', logo_b64)
- setValue('logo_mime_type', logo_mime_type, {shouldDirty: true})
- }
-
- async function deleteLogo() {
- if (formData.logo_id) {
- // remove old logo from db
- const del = await deleteImage({
- id: formData.logo_id,
- token
- })
- }
- setValue('logo_id', null)
- setValue('logo_b64', null)
- setValue('logo_mime_type', null, {shouldDirty: true})
- }
-
return (
- Organisation
+ {config.modalTile}
+
+
-
-
-
- {formData.name ? formData.name.slice(0,3) : ''}
-
-
-
-
- }
- >
- Remove
-
-
-
+ }
+ />
Do you have a logo?
- You are the first to reference this organisation and can add a logo now. After clicking on "Save", logos can only be added by organisation maintainers.
+ You are the first to reference this organisation and can add a logo now. After clicking on "Save", logos can only be added by organisation maintainers.
+
+ {pageTitle}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// 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 token = req?.cookies['rsd_token']
+
+// // get links to all pages server side
+// const resp = await getCommunities({
+// page: 0,
+// rows: 12,
+// token: token ?? ''
+// })
+
+// return {
+// // passed to the page component as props
+// props: {
+// count: resp?.count,
+// communities: resp.communities
+// },
+// }
+// }catch(e){
+// return {
+// notFound: true,
+// }
+// }
+// }
From ac03a932ca13a2fbd752268e2e2937f05e58d013 Mon Sep 17 00:00:00 2001
From: Dusan Mijatovic
Date: Thu, 2 May 2024 17:00:01 +0200
Subject: [PATCH 2/9] 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 | 102 ++++++----
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, 1429 insertions(+), 237 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 7433aa4e3..2dbf9a689 100644
--- a/data-generation/main.js
+++ b/data-generation/main.js
@@ -91,7 +91,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 = [];
@@ -358,7 +358,7 @@ function generateSoftwareHighlights(ids) {
return result;
}
-async function generateProjects(amount = 500) {
+function generateProjects(amount=500) {
const result = [];
const projectStatuses = ['finished', 'running', 'starting'];
@@ -509,7 +509,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',
@@ -591,6 +591,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 = [];
@@ -636,8 +655,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',
@@ -651,6 +670,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 = [];
@@ -723,6 +758,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'});
@@ -937,8 +983,8 @@ await Promise.all([mentionsPromise, keywordPromise, researchDomainsPromise]).the
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']);
@@ -954,7 +1000,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']);
@@ -969,11 +1015,18 @@ const projectPromise = postToBackend('/project', await generateProjects())
);
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'))
@@ -982,29 +1035,14 @@ 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 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(
- '/release',
- idsSoftware.map(id => ({software: id})),
-)
- .then(() =>
- postToBackend(
- '/release_version',
- generateRelationsForDifferingEntities(idsFakeSoftware, idsMentions, 'release_id', 'mention_id', 100),
- ),
- )
+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'));
console.log('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 (
+
+
+
+
+
+
+ )
+}
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 (
+
+ {/* 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 d288ecec0..5c444b220 100644
--- a/frontend/components/software/edit/editSoftwareConfig.tsx
+++ b/frontend/components/software/edit/editSoftwareConfig.tsx
@@ -169,7 +169,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}
@@ -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',
From cd6d9731472c813072c217e109415962904ee635 Mon Sep 17 00:00:00 2001
From: Dusan Mijatovic
Date: Thu, 16 May 2024 15:46:46 +0200
Subject: [PATCH 3/9] feat: implement community settings page (#1195) feat:
implement community settings page chore: refactor maintainers sections fix:
do not show highlights section when javascript is disabled chore: add row
level security for communities and improve database functions feat: add
community keywords (#1199) feat: add community keywords chore: improve SQL
and add keywords for communities to data generation
---------
Co-authored-by: Ewan Cahen
---------
Co-authored-by: Ewan Cahen
---
data-generation/main.js | 1 +
database/024-community.sql | 308 +++++++++++++++---
database/124-community-views.sql | 81 ++++-
frontend/auth/api/authHelpers.ts | 46 ++-
.../permissions/isMaintainerOfCommunity.ts | 47 ++-
frontend/components/AppHeader/DesktopMenu.tsx | 2 +-
frontend/components/AppHeader/index.tsx | 10 +-
.../components/communities/CommunityPage.tsx | 74 +++++
.../communities/__mocks__/mockCommunity.ts | 22 ++
.../about/CommunityAboutIndex.test.tsx | 21 ++
.../components/communities/about/index.tsx | 27 ++
.../components/communities/apiCommunities.ts | 42 ++-
.../components/communities/context/index.tsx | 69 ++++
.../communities/metadata/CommunityLogo.tsx | 26 +-
.../components/communities/metadata/index.tsx | 57 ++--
.../communities/overview/CommunityCard.tsx | 41 ++-
.../overview/CommunityListItem.tsx | 8 +-
.../communities/overview/CommunityMetrics.tsx | 32 ++
.../communities/settings/SettingsContent.tsx | 26 ++
.../communities/settings/SettingsNav.tsx | 54 +++
.../communities/settings/SettingsNavItems.tsx | 36 ++
.../AutosaveCommunityDescription.tsx | 80 +++++
.../communities/settings/about-page/config.ts | 17 +
.../communities/settings/about-page/index.tsx | 58 ++++
.../general/AutosaveCommunityKeywords.tsx | 174 ++++++++++
.../general/AutosaveCommunityTextField.tsx | 71 ++++
.../general/CommunityAdminSection.tsx | 49 +++
.../settings/general/apiCommunityKeywords.ts | 90 +++++
.../communities/settings/general/config.ts | 55 ++++
.../communities/settings/general/index.tsx | 89 +++++
.../general/searchForCommunityKeyword.test.ts | 27 ++
.../general/searchForCommunityKeyword.ts | 42 +++
.../components/communities/settings/index.tsx | 34 ++
.../CommunityMaintainersIndex.test.tsx | 243 ++++++++++++++
.../maintainers/CommunityMaintainersLinks.tsx | 47 +++
.../maintainers/apiCommunityMaintainers.ts | 102 ++++++
.../settings/maintainers/index.tsx | 99 ++++++
.../maintainers/useCommunityInvitations.tsx | 81 +++++
.../maintainers/useCommunityMaintainers.tsx | 67 ++++
.../communities/tabs/CommunityTabItems.tsx | 6 +-
.../components/communities/tabs/index.tsx | 22 +-
.../feedback/FeedbackPanelButton.test.tsx | 29 +-
.../feedback/FeedbackPanelButton.tsx | 21 +-
frontend/components/layout/InvitationList.tsx | 5 +-
.../components/layout/ProtectedContent.tsx | 58 ++++
.../components/maintainers/InvitationList.tsx | 98 ++++++
.../MaintainerItem.tsx} | 13 +-
.../maintainers/MaintainersList.tsx | 52 +++
.../components/maintainers/apiMaintainers.ts | 122 +++++++
.../ProtectedOrganisationPage.tsx | 39 ---
.../components/organisation/about/index.tsx | 10 +-
.../context/OrganisationContext.tsx | 6 +-
.../organisation/settings/SettingsNav.tsx | 2 +-
.../general/AutosaveOrganisationTextField.tsx | 6 +-
.../organisation/settings/index.tsx | 11 +-
.../OrganisationMaintainerLink.tsx | 115 -------
.../OrganisationMaintainerLinks.tsx | 50 +++
.../OrganisationMaintainersIndex.test.tsx | 198 +++++------
.../OrganisationMaintainersList.tsx | 61 ----
....ts => apiOrganisationMaintainers.test.ts} | 6 +-
.../maintainers/apiOrganisationMaintainers.ts | 95 ++++++
.../getMaintainersOfOrganisation.ts | 38 ---
.../settings/maintainers/index.tsx | 85 ++---
.../useOrganisationInvitations.tsx | 81 +++++
.../useOrganisationMaintainers.tsx | 176 ++--------
.../EditProjectMaintainersIndex.test.tsx | 118 ++++++-
.../maintainers/ProjectMaintainerLink.tsx | 106 ------
.../maintainers/ProjectMaintainerLinks.tsx | 49 +++
.../maintainers/ProjectMaintainersList.tsx | 70 ----
.../edit/maintainers/apiProjectMaintainers.ts | 54 +++
.../projects/edit/maintainers/index.tsx | 85 ++---
.../maintainers/useProjectInvitations.tsx | 81 +++++
.../edit/maintainers/useProjectMaintainer.tsx | 156 ---------
.../maintainers/useProjectMaintainers.tsx | 79 +++++
.../EditSoftwareMaintainersIndex.test.tsx | 117 ++++++-
.../maintainers/SoftwareMaintainerLink.tsx | 107 ------
.../maintainers/SoftwareMaintainerLinks.tsx | 46 +++
.../maintainers/apiSoftwareMaintainers.ts | 94 ++++++
.../software/edit/maintainers/index.tsx | 80 +----
.../maintainers/useSoftwareInvitations.tsx | 78 +++++
.../maintainers/useSoftwareMaintainers.tsx | 203 +++---------
.../software/overview/SoftwareHighlights.tsx | 8 +-
frontend/next.config.js | 17 +-
frontend/pages/communities/[slug]/about.tsx | 126 +++++++
.../pages/communities/[slug]/settings.tsx | 121 ++++++-
.../pages/communities/[slug]/software.tsx | 101 ++----
frontend/pages/communities/index.tsx | 6 +-
frontend/pages/invite/community/[id].tsx | 123 +++++++
frontend/pages/invite/organisation/[id].tsx | 4 +-
frontend/pages/organisations/[...slug].tsx | 5 +-
frontend/styles/custom.css | 13 +-
frontend/styles/global.css | 6 +-
frontend/utils/jest/WithCommunityContext.tsx | 22 ++
frontend/utils/useDisableScrollLock.tsx | 4 +-
94 files changed, 4329 insertions(+), 1610 deletions(-)
create mode 100644 frontend/components/communities/CommunityPage.tsx
create mode 100644 frontend/components/communities/__mocks__/mockCommunity.ts
create mode 100644 frontend/components/communities/about/CommunityAboutIndex.test.tsx
create mode 100644 frontend/components/communities/about/index.tsx
create mode 100644 frontend/components/communities/context/index.tsx
create mode 100644 frontend/components/communities/overview/CommunityMetrics.tsx
create mode 100644 frontend/components/communities/settings/SettingsContent.tsx
create mode 100644 frontend/components/communities/settings/SettingsNav.tsx
create mode 100644 frontend/components/communities/settings/SettingsNavItems.tsx
create mode 100644 frontend/components/communities/settings/about-page/AutosaveCommunityDescription.tsx
create mode 100644 frontend/components/communities/settings/about-page/config.ts
create mode 100644 frontend/components/communities/settings/about-page/index.tsx
create mode 100644 frontend/components/communities/settings/general/AutosaveCommunityKeywords.tsx
create mode 100644 frontend/components/communities/settings/general/AutosaveCommunityTextField.tsx
create mode 100644 frontend/components/communities/settings/general/CommunityAdminSection.tsx
create mode 100644 frontend/components/communities/settings/general/apiCommunityKeywords.ts
create mode 100644 frontend/components/communities/settings/general/config.ts
create mode 100644 frontend/components/communities/settings/general/index.tsx
create mode 100644 frontend/components/communities/settings/general/searchForCommunityKeyword.test.ts
create mode 100644 frontend/components/communities/settings/general/searchForCommunityKeyword.ts
create mode 100644 frontend/components/communities/settings/index.tsx
create mode 100644 frontend/components/communities/settings/maintainers/CommunityMaintainersIndex.test.tsx
create mode 100644 frontend/components/communities/settings/maintainers/CommunityMaintainersLinks.tsx
create mode 100644 frontend/components/communities/settings/maintainers/apiCommunityMaintainers.ts
create mode 100644 frontend/components/communities/settings/maintainers/index.tsx
create mode 100644 frontend/components/communities/settings/maintainers/useCommunityInvitations.tsx
create mode 100644 frontend/components/communities/settings/maintainers/useCommunityMaintainers.tsx
create mode 100644 frontend/components/layout/ProtectedContent.tsx
create mode 100644 frontend/components/maintainers/InvitationList.tsx
rename frontend/components/{projects/edit/maintainers/ProjectMaintainer.tsx => maintainers/MaintainerItem.tsx} (81%)
create mode 100644 frontend/components/maintainers/MaintainersList.tsx
create mode 100644 frontend/components/maintainers/apiMaintainers.ts
delete mode 100644 frontend/components/organisation/ProtectedOrganisationPage.tsx
delete mode 100644 frontend/components/organisation/settings/maintainers/OrganisationMaintainerLink.tsx
create mode 100644 frontend/components/organisation/settings/maintainers/OrganisationMaintainerLinks.tsx
delete mode 100644 frontend/components/organisation/settings/maintainers/OrganisationMaintainersList.tsx
rename frontend/components/organisation/settings/maintainers/{getMaintainersOfOrganisation.test.ts => apiOrganisationMaintainers.test.ts} (85%)
create mode 100644 frontend/components/organisation/settings/maintainers/apiOrganisationMaintainers.ts
delete mode 100644 frontend/components/organisation/settings/maintainers/getMaintainersOfOrganisation.ts
create mode 100644 frontend/components/organisation/settings/maintainers/useOrganisationInvitations.tsx
delete mode 100644 frontend/components/projects/edit/maintainers/ProjectMaintainerLink.tsx
create mode 100644 frontend/components/projects/edit/maintainers/ProjectMaintainerLinks.tsx
delete mode 100644 frontend/components/projects/edit/maintainers/ProjectMaintainersList.tsx
create mode 100644 frontend/components/projects/edit/maintainers/apiProjectMaintainers.ts
create mode 100644 frontend/components/projects/edit/maintainers/useProjectInvitations.tsx
delete mode 100644 frontend/components/projects/edit/maintainers/useProjectMaintainer.tsx
create mode 100644 frontend/components/projects/edit/maintainers/useProjectMaintainers.tsx
delete mode 100644 frontend/components/software/edit/maintainers/SoftwareMaintainerLink.tsx
create mode 100644 frontend/components/software/edit/maintainers/SoftwareMaintainerLinks.tsx
create mode 100644 frontend/components/software/edit/maintainers/apiSoftwareMaintainers.ts
create mode 100644 frontend/components/software/edit/maintainers/useSoftwareInvitations.tsx
create mode 100644 frontend/pages/communities/[slug]/about.tsx
create mode 100644 frontend/pages/invite/community/[id].tsx
create mode 100644 frontend/utils/jest/WithCommunityContext.tsx
diff --git a/data-generation/main.js b/data-generation/main.js
index 2dbf9a689..bd5498068 100644
--- a/data-generation/main.js
+++ b/data-generation/main.js
@@ -1025,6 +1025,7 @@ const communityPromise = postToBackend('/community', generateCommunities())
.then(resp => resp.json())
.then(async commArray => {
idsCommunities = commArray.map(comm => comm['id']);
+ postToBackend('/keyword_for_community', generateKeywordsForEntity(idsCommunities, idsKeywords, 'community'))
});
await postToBackend('/meta_pages', generateMetaPages()).then(() => console.log('meta pages done'));
diff --git a/database/024-community.sql b/database/024-community.sql
index f0aeec524..5a446eedb 100644
--- a/database/024-community.sql
+++ b/database/024-community.sql
@@ -30,17 +30,6 @@ BEGIN
RETURN NEW;
END IF;
- IF NOT NEW.is_tenant AND NEW.parent IS NULL AND NEW.primary_maintainer IS NULL THEN
- RETURN NEW;
- END IF;
-
- IF (SELECT primary_maintainer FROM community o WHERE o.id = NEW.parent) = uuid(current_setting('request.jwt.claims', FALSE)::json->>'account')
- AND
- NEW.primary_maintainer = (SELECT primary_maintainer FROM community o WHERE o.id = NEW.parent)
- THEN
- RETURN NEW;
- END IF;
-
RAISE EXCEPTION USING MESSAGE = 'You are not allowed to add this community';
END
$$;
@@ -59,10 +48,8 @@ BEGIN
RAISE EXCEPTION USING MESSAGE = 'You are not allowed to change the slug';
END IF;
- IF CURRENT_USER <> 'rsd_admin' AND NOT (SELECT rolsuper FROM pg_roles WHERE rolname = CURRENT_USER) THEN
- IF NEW.primary_maintainer IS DISTINCT FROM OLD.primary_maintainer THEN
- RAISE EXCEPTION USING MESSAGE = 'You are not allowed to change the primary maintainer for community ' || OLD.name;
- END IF;
+ IF NEW.primary_maintainer IS DISTINCT FROM OLD.primary_maintainer AND CURRENT_USER IS DISTINCT FROM 'rsd_admin' AND (SELECT rolsuper FROM pg_roles WHERE rolname = CURRENT_USER) IS DISTINCT FROM TRUE THEN
+ RAISE EXCEPTION USING MESSAGE = 'You are not allowed to change the primary maintainer for community ' || OLD.name;
END IF;
RETURN NEW;
@@ -71,52 +58,66 @@ $$;
CREATE TRIGGER sanitise_update_community BEFORE UPDATE ON community FOR EACH ROW EXECUTE PROCEDURE sanitise_update_community();
+
+-- MAINTAINER OF COMMUNITY
+CREATE TABLE maintainer_for_community (
+ maintainer UUID REFERENCES account (id),
+ community UUID REFERENCES community (id),
+ PRIMARY KEY (maintainer, community)
+);
+
+
+-- Needed for RLS on various tables
+CREATE FUNCTION communities_of_current_maintainer() RETURNS SETOF UUID STABLE
+LANGUAGE sql SECURITY DEFINER AS
+$$
+ SELECT
+ id
+ FROM
+ community
+ WHERE
+ primary_maintainer = uuid(current_setting('request.jwt.claims', FALSE)::json->>'account')
+ UNION
+ SELECT
+ community
+ FROM
+ maintainer_for_community
+ WHERE
+ maintainer = uuid(current_setting('request.jwt.claims', FALSE)::json->>'account');
+$$;
+
+
-- RLS community table
ALTER TABLE community ENABLE ROW LEVEL SECURITY;
CREATE POLICY anyone_can_read ON community FOR SELECT TO rsd_web_anon, rsd_user
USING (TRUE);
+CREATE POLICY maintainer_all_rights ON community TO rsd_user
+ USING (id IN (SELECT * FROM communities_of_current_maintainer()))
+ WITH CHECK (TRUE);
+
CREATE POLICY admin_all_rights ON community TO rsd_admin
USING (TRUE)
WITH CHECK (TRUE);
--- SOFTWARE FOR COMMUNITY
--- request status of software to be added to community
--- default value is pending
-CREATE TYPE request_status AS ENUM (
- 'pending',
- 'approved',
- 'rejected'
-);
-
-CREATE TABLE software_for_community (
- software UUID REFERENCES software (id),
- community UUID REFERENCES community (id),
- status request_status NOT NULL DEFAULT 'pending',
- PRIMARY KEY (software, community)
-);
+-- RLS maintainer_for_community table
+ALTER TABLE maintainer_for_community ENABLE ROW LEVEL SECURITY;
-CREATE FUNCTION sanitise_update_software_for_community() RETURNS TRIGGER LANGUAGE plpgsql AS
-$$
-BEGIN
- NEW.software = OLD.software;
- NEW.community = OLD.community;
- return NEW;
-END
-$$;
+CREATE POLICY maintainer_select ON maintainer_for_community FOR SELECT TO rsd_user
+ USING (community IN (SELECT * FROM communities_of_current_maintainer()));
-CREATE TRIGGER sanitise_update_software_for_community BEFORE UPDATE ON software_for_community FOR EACH ROW EXECUTE PROCEDURE sanitise_update_software_for_community();
+CREATE POLICY maintainer_delete ON maintainer_for_community FOR DELETE TO rsd_user
+ USING (community IN (SELECT * FROM communities_of_current_maintainer()));
+CREATE POLICY maintainer_insert ON maintainer_for_community FOR INSERT TO rsd_user
+ WITH CHECK (community IN (SELECT * FROM communities_of_current_maintainer()));
--- MAINTAINER OF COMMUNITY
+CREATE POLICY admin_all_rights ON maintainer_for_community TO rsd_admin
+ USING (TRUE)
+ WITH CHECK (TRUE);
-CREATE TABLE maintainer_for_community (
- maintainer UUID REFERENCES account (id),
- community UUID REFERENCES community (id),
- PRIMARY KEY (maintainer, community)
-);
-- INVITES FOR COMMUNITY MAINTAINER (magic link)
CREATE TABLE invite_maintainer_for_community (
@@ -145,7 +146,7 @@ CREATE FUNCTION sanitise_update_invite_maintainer_for_community() RETURNS TRIGGE
$$
BEGIN
NEW.id = OLD.id;
- NEW.software = OLD.software;
+ NEW.community = OLD.community;
NEW.created_by = OLD.created_by;
NEW.created_at = OLD.created_at;
return NEW;
@@ -154,7 +155,99 @@ $$;
CREATE TRIGGER sanitise_update_invite_maintainer_for_community BEFORE UPDATE ON invite_maintainer_for_community FOR EACH ROW EXECUTE PROCEDURE sanitise_update_invite_maintainer_for_community();
+-- RLS invite_maintainer_for_community table
+ALTER TABLE invite_maintainer_for_community ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY maintainer_select ON invite_maintainer_for_community FOR SELECT TO rsd_user
+ USING (community IN (SELECT * FROM communities_of_current_maintainer())
+ OR created_by = uuid(current_setting('request.jwt.claims', FALSE)::json->>'account')
+ OR claimed_by = uuid(current_setting('request.jwt.claims', FALSE)::json->>'account'));
+
+CREATE POLICY maintainer_delete ON invite_maintainer_for_community FOR DELETE TO rsd_user
+ USING (community IN (SELECT * FROM communities_of_current_maintainer())
+ OR created_by = uuid(current_setting('request.jwt.claims', FALSE)::json->>'account')
+ OR claimed_by = uuid(current_setting('request.jwt.claims', FALSE)::json->>'account'));
+
+CREATE POLICY maintainer_insert ON invite_maintainer_for_community FOR INSERT TO rsd_user
+ WITH CHECK (community IN (SELECT * FROM communities_of_current_maintainer()) AND created_by = uuid(current_setting('request.jwt.claims', FALSE)::json->>'account'));
+
+CREATE POLICY admin_all_rights ON invite_maintainer_for_community TO rsd_admin
+ USING (TRUE)
+ WITH CHECK (TRUE);
+
+
+-- COMMUNITY maintainers list with basic personal info
+-- used in the community maintainers page
+CREATE FUNCTION maintainers_of_community(community_id UUID) RETURNS TABLE (
+ maintainer UUID,
+ name VARCHAR[],
+ email VARCHAR[],
+ affiliation VARCHAR[],
+ is_primary BOOLEAN
+) LANGUAGE plpgsql STABLE SECURITY DEFINER AS
+$$
+DECLARE account_authenticated UUID;
+BEGIN
+ account_authenticated = uuid(current_setting('request.jwt.claims', FALSE)::json->>'account');
+ IF account_authenticated IS NULL THEN
+ RAISE EXCEPTION USING MESSAGE = 'Please login first';
+ END IF;
+
+ IF community_id IS NULL THEN
+ RAISE EXCEPTION USING MESSAGE = 'Please provide a community id';
+ END IF;
+
+ IF NOT community_id IN (SELECT * FROM communities_of_current_maintainer()) AND
+ CURRENT_USER IS DISTINCT FROM 'rsd_admin' AND (
+ SELECT rolsuper FROM pg_roles WHERE rolname = CURRENT_USER
+ ) IS DISTINCT FROM TRUE THEN
+ RAISE EXCEPTION USING MESSAGE = 'You are not a maintainer of this community';
+ END IF;
+
+ RETURN QUERY
+ -- primary maintainer of community
+ SELECT
+ community.primary_maintainer AS maintainer,
+ ARRAY_AGG(login_for_account."name") AS name,
+ ARRAY_AGG(login_for_account.email) AS email,
+ ARRAY_AGG(login_for_account.home_organisation) AS affiliation,
+ TRUE AS is_primary
+ FROM
+ community
+ INNER JOIN
+ login_for_account ON community.primary_maintainer = login_for_account.account
+ WHERE
+ community.id = community_id
+ GROUP BY
+ community.id,community.primary_maintainer
+ -- append second selection
+ UNION
+ -- other maintainers of community
+ SELECT
+ maintainer_for_community.maintainer,
+ ARRAY_AGG(login_for_account."name") AS name,
+ ARRAY_AGG(login_for_account.email) AS email,
+ ARRAY_AGG(login_for_account.home_organisation) AS affiliation,
+ FALSE AS is_primary
+ FROM
+ maintainer_for_community
+ INNER JOIN
+ login_for_account ON maintainer_for_community.maintainer = login_for_account.account
+ WHERE
+ maintainer_for_community.community = community_id
+ GROUP BY
+ maintainer_for_community.community, maintainer_for_community.maintainer
+ -- primary as first record
+ ORDER BY is_primary DESC;
+ RETURN;
+END
+$$;
+
+-- ACCEPT MAGIC LINK INVITATION
+-- REGISTER user with this link as maintainer of community
+-- RETURN basic info about community on SUCCESS
CREATE FUNCTION accept_invitation_community(invitation UUID) RETURNS TABLE(
+ id UUID,
name VARCHAR,
slug VARCHAR
) LANGUAGE plpgsql VOLATILE SECURITY DEFINER AS
@@ -171,7 +264,11 @@ BEGIN
RAISE EXCEPTION USING MESSAGE = 'Please provide an invitation id';
END IF;
- SELECT * FROM invite_maintainer_for_community WHERE id = invitation INTO invitation_row;
+ SELECT * FROM
+ invite_maintainer_for_community
+ WHERE
+ invite_maintainer_for_community.id = invitation INTO invitation_row;
+
IF invitation_row.id IS NULL THEN
RAISE EXCEPTION USING MESSAGE = 'Invitation with id ' || invitation || ' does not exist';
END IF;
@@ -181,14 +278,123 @@ BEGIN
END IF;
-- Only use the invitation if not already a maintainer
- IF NOT EXISTS(SELECT 1 FROM maintainer_for_community WHERE maintainer = account AND community = invitation_row.community) THEN
- UPDATE invite_maintainer_for_community SET claimed_by = account, claimed_at = LOCALTIMESTAMP WHERE id = invitation;
- INSERT INTO maintainer_for_community VALUES (account, invitation_row.community);
+ IF NOT EXISTS(
+ SELECT
+ maintainer_for_community.maintainer
+ FROM
+ maintainer_for_community
+ WHERE
+ maintainer_for_community.maintainer=account AND maintainer_for_community.community=invitation_row.community
+ UNION
+ SELECT
+ community.primary_maintainer AS maintainer
+ FROM
+ community
+ WHERE
+ community.primary_maintainer=account AND community.id=invitation_row.community
+ LIMIT 1
+ ) THEN
+
+ UPDATE invite_maintainer_for_community
+ SET claimed_by = account, claimed_at = LOCALTIMESTAMP
+ WHERE invite_maintainer_for_community.id = invitation;
+
+ INSERT INTO maintainer_for_community
+ VALUES (account, invitation_row.community);
+
END IF;
RETURN QUERY
- SELECT community.name, community.slug FROM community WHERE community.id = invitation_row.community;
+ SELECT
+ community.id,
+ community.name,
+ community.slug
+ FROM
+ community
+ WHERE
+ community.id = invitation_row.community;
RETURN;
END
$$;
+
+-- SOFTWARE FOR COMMUNITY
+-- request status of software to be added to community
+-- default value is pending
+CREATE TYPE request_status AS ENUM (
+ 'pending',
+ 'approved',
+ 'rejected'
+);
+
+CREATE TABLE software_for_community (
+ software UUID REFERENCES software (id),
+ community UUID REFERENCES community (id),
+ status request_status NOT NULL DEFAULT 'pending',
+ PRIMARY KEY (software, community)
+);
+
+CREATE FUNCTION sanitise_update_software_for_community() RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+ NEW.software = OLD.software;
+ NEW.community = OLD.community;
+ return NEW;
+END
+$$;
+
+CREATE TRIGGER sanitise_update_software_for_community BEFORE UPDATE ON software_for_community FOR EACH ROW EXECUTE PROCEDURE sanitise_update_software_for_community();
+
+
+-- RLS software_for_community table
+ALTER TABLE software_for_community ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY anyone_can_read ON software_for_community FOR SELECT TO rsd_web_anon, rsd_user
+ USING (software IN (SELECT id FROM software));
+
+CREATE POLICY maintainer_can_read ON software_for_community FOR SELECT TO rsd_user
+ USING (software IN (SELECT * FROM software_of_current_maintainer()) OR community IN (SELECT * FROM communities_of_current_maintainer()));
+
+CREATE POLICY maintainer_software_insert ON software_for_community FOR INSERT TO rsd_user
+ WITH CHECK (status = 'pending' AND software IN (SELECT * FROM software_of_current_maintainer()));
+
+CREATE POLICY maintainer_community_insert ON software_for_community FOR INSERT TO rsd_user
+ WITH CHECK (community IN (SELECT * FROM communities_of_current_maintainer()));
+
+CREATE POLICY maintainer_community_update ON software_for_community FOR UPDATE TO rsd_user
+ USING (community IN (SELECT * FROM communities_of_current_maintainer()));
+
+CREATE POLICY maintainer_software_delete ON software_for_community FOR DELETE TO rsd_user
+ USING ((status = 'pending' OR status = 'approved') AND software IN (SELECT * FROM software_of_current_maintainer()));
+
+CREATE POLICY maintainer_community_delete ON software_for_community FOR DELETE TO rsd_user
+ USING (community IN (SELECT * FROM communities_of_current_maintainer()));
+
+CREATE POLICY admin_all_rights ON software_for_community TO rsd_admin
+ USING (TRUE)
+ WITH CHECK (TRUE);
+
+
+-- KEYWORDS for community
+CREATE TABLE keyword_for_community (
+ community UUID REFERENCES community (id),
+ keyword UUID REFERENCES keyword (id),
+ PRIMARY KEY (community, keyword)
+);
+
+
+-- RLS keyword_for_community table
+ALTER TABLE keyword_for_community ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY anyone_can_read ON keyword_for_community FOR SELECT TO rsd_web_anon, rsd_user
+ USING (TRUE);
+
+CREATE POLICY maintainer_insert ON keyword_for_community FOR INSERT TO rsd_user
+ WITH CHECK (community IN (SELECT * FROM communities_of_current_maintainer()));
+
+CREATE POLICY maintainer_delete ON keyword_for_community FOR DELETE TO rsd_user
+ USING (community IN (SELECT * FROM communities_of_current_maintainer()));
+
+CREATE POLICY admin_all_rights ON keyword_for_community TO rsd_admin
+ USING (TRUE)
+ WITH CHECK (TRUE);
diff --git a/database/124-community-views.sql b/database/124-community-views.sql
index 51253ca3b..93d1abcff 100644
--- a/database/124-community-views.sql
+++ b/database/124-community-views.sql
@@ -22,15 +22,89 @@ GROUP BY
;
$$;
+
+-- Keywords with the count used by
+-- by search to show existing keywords with the count
+CREATE FUNCTION keyword_count_for_community() RETURNS TABLE (
+ id UUID,
+ keyword CITEXT,
+ cnt BIGINT
+) LANGUAGE sql STABLE AS
+$$
+ SELECT
+ keyword.id,
+ keyword.value AS keyword,
+ keyword_count.cnt
+ FROM
+ keyword
+ LEFT JOIN
+ (SELECT
+ keyword_for_community.keyword,
+ COUNT(keyword_for_community.keyword) AS cnt
+ FROM
+ keyword_for_community
+ GROUP BY keyword_for_community.keyword
+ ) AS keyword_count ON keyword.id = keyword_count.keyword;
+$$;
+
+-- Keywords by community
+-- for editing keywords of specific community
+CREATE FUNCTION keywords_by_community() RETURNS TABLE (
+ id UUID,
+ keyword CITEXT,
+ community UUID
+) LANGUAGE sql STABLE AS
+$$
+ SELECT
+ keyword.id,
+ keyword.value AS keyword,
+ keyword_for_community.community
+ FROM
+ keyword_for_community
+ INNER JOIN
+ keyword ON keyword.id = keyword_for_community.keyword;
+$$;
+-- using filter ?community=eq.UUID
+
+-- Keywords grouped by community for filtering
+-- We use array for selecting community with specific keywords
+-- We use text value for "wild card" search
+CREATE FUNCTION keyword_filter_for_community() RETURNS TABLE (
+ community UUID,
+ keywords CITEXT[],
+ keywords_text TEXT
+) LANGUAGE sql STABLE AS
+$$
+ SELECT
+ keyword_for_community.community AS community,
+ ARRAY_AGG(
+ keyword.value
+ ORDER BY value
+ ) AS keywords,
+ STRING_AGG(
+ keyword.value,' '
+ ORDER BY value
+ ) AS keywords_text
+ FROM
+ keyword_for_community
+ INNER JOIN
+ keyword ON keyword.id = keyword_for_community.keyword
+ GROUP BY keyword_for_community.community;
+$$;
+
+
-- rpc for community overview page
--- incl. software count
+-- incl. software count and keyword list (for card)
CREATE FUNCTION communities_overview() RETURNS TABLE (
id UUID,
slug VARCHAR,
name VARCHAR,
short_description VARCHAR,
logo_id VARCHAR,
+ primary_maintainer UUID,
software_cnt BIGINT,
+ keywords CITEXT[],
+ description VARCHAR,
created_at TIMESTAMPTZ
) LANGUAGE sql STABLE AS
$$
@@ -40,11 +114,16 @@ SELECT
community."name",
community.short_description,
community.logo_id,
+ community.primary_maintainer,
software_count_by_community.software_cnt,
+ keyword_filter_for_community.keywords,
+ community.description,
community.created_at
FROM
community
LEFT JOIN
software_count_by_community() ON community.id = software_count_by_community.community
+LEFT JOIN
+ keyword_filter_for_community() ON community.id=keyword_filter_for_community.community
;
$$;
diff --git a/frontend/auth/api/authHelpers.ts b/frontend/auth/api/authHelpers.ts
index cb7ef0ad4..ac03ff3d9 100644
--- a/frontend/auth/api/authHelpers.ts
+++ b/frontend/auth/api/authHelpers.ts
@@ -1,13 +1,13 @@
// 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-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center)
//
// SPDX-License-Identifier: Apache-2.0
import logger from '~/utils/logger'
-import {createJsonHeaders, extractReturnMessage} from '~/utils/fetchHelpers'
+import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers'
export type RedirectToProps = {
authorization_endpoint: string,
@@ -184,3 +184,45 @@ export async function claimOrganisationMaintainerInvite({id, token, frontend = f
}
}
}
+
+
+export async function claimCommunityMaintainerInvite({id, token}:
+ { id: string, token?: string}) {
+ try {
+ const query = 'rpc/accept_invitation_community'
+ let url = `${getBaseUrl()}/${query}`
+
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: {
+ ...createJsonHeaders(token),
+ 'Accept': 'application/vnd.pgrst.object + json',
+ },
+ body: JSON.stringify({
+ 'invitation': id
+ })
+ })
+ if (resp.status === 200) {
+ const json = await resp.json()
+ return {
+ communityInfo: json,
+ error: null
+ }
+ }
+ logger(`claimCommunityMaintainerInvite failed: ${resp?.status} ${resp.statusText}`, 'error')
+ const error = await extractReturnMessage(resp)
+ return {
+ communityInfo: null,
+ error
+ }
+ } catch (e: any) {
+ logger(`claimCommunityMaintainerInvite failed: ${e?.message}`, 'error')
+ return {
+ communityInfo: null,
+ error: {
+ status: 500,
+ message: e?.message
+ }
+ }
+ }
+}
diff --git a/frontend/auth/permissions/isMaintainerOfCommunity.ts b/frontend/auth/permissions/isMaintainerOfCommunity.ts
index a97cdd3da..1146a5832 100644
--- a/frontend/auth/permissions/isMaintainerOfCommunity.ts
+++ b/frontend/auth/permissions/isMaintainerOfCommunity.ts
@@ -4,6 +4,7 @@
// SPDX-License-Identifier: Apache-2.0
import {RsdRole} from '~/auth/index'
+import {createJsonHeaders, getBaseUrl} from '~/utils/fetchHelpers'
import logger from '~/utils/logger'
type isCommunityMaintainerProps = {
@@ -44,15 +45,15 @@ export async function isMaintainerOfCommunity({community, account, token}: isCom
// 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
- // }
+ const communities = await getCommunitiesOfMaintainer({
+ token
+ })
+ // console.log('isMaintainerOfCommunity...',communities)
+ // debugger
+ if (communities.length > 0) {
+ const isMaintainer = communities.includes(community)
+ return isMaintainer
+ }
return false
} catch (e:any) {
logger(`isMaintainerOfCommunity: ${e?.message}`, 'error')
@@ -60,3 +61,31 @@ export async function isMaintainerOfCommunity({community, account, token}: isCom
return false
}
}
+
+export async function getCommunitiesOfMaintainer({token}:
+ {token: string}) {
+ try {
+ // without token api request is not needed
+ if (!token) return []
+ // build url
+ const query = 'rpc/communities_of_current_maintainer'
+ let url = `${getBaseUrl()}/${query}`
+ const resp = await fetch(url, {
+ method: 'GET',
+ headers: createJsonHeaders(token)
+ })
+ if (resp.status === 200) {
+ const json = await resp.json()
+ return json
+ }
+ // ERRORS AS NOT MAINTAINER
+ logger(`getCommunitiesOfMaintainer: ${resp.status}:${resp.statusText}`, 'warn')
+ return []
+ } catch(e:any) {
+ // ERRORS AS NOT MAINTAINER
+ logger(`getCommunitiesOfMaintainer: ${e.message}`, 'error')
+ return []
+ }
+}
+
+export default isMaintainerOfCommunity
diff --git a/frontend/components/AppHeader/DesktopMenu.tsx b/frontend/components/AppHeader/DesktopMenu.tsx
index e67756c1f..c5a5f0b9e 100644
--- a/frontend/components/AppHeader/DesktopMenu.tsx
+++ b/frontend/components/AppHeader/DesktopMenu.tsx
@@ -14,7 +14,7 @@ export default function DesktopMenu({activePath}:{activePath:string}) {
// console.groupEnd()
return (
+ className="hidden text-center lg:flex-1 lg:flex lg:justify-evenly lg:gap-5 xl:justify-start">
{menuItems.map(item => {
const isActive = isActiveMenuItem({item, activePath})
return (
diff --git a/frontend/components/AppHeader/index.tsx b/frontend/components/AppHeader/index.tsx
index f6b28f91d..3242b4c27 100644
--- a/frontend/components/AppHeader/index.tsx
+++ b/frontend/components/AppHeader/index.tsx
@@ -74,12 +74,10 @@ export default function AppHeader() {
{/* FEEDBACK panel */}
-
- {host.feedback?.enabled
- ?
- : null
- }
-
+ {host.feedback?.enabled
+ ?
+ : null
+ }
{/* ADD menu button */}
{status === 'authenticated' ?
: null}
{/* Responsive menu */}
diff --git a/frontend/components/communities/CommunityPage.tsx b/frontend/components/communities/CommunityPage.tsx
new file mode 100644
index 000000000..15582144a
--- /dev/null
+++ b/frontend/components/communities/CommunityPage.tsx
@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import BackgroundAndLayout from '../layout/BackgroundAndLayout'
+import BaseSurfaceRounded from '../layout/BaseSurfaceRounded'
+import PageBreadcrumbs from '../layout/PageBreadcrumbs'
+import {UserSettingsProvider} from '../organisation/context/UserSettingsContext'
+import {LayoutType} from '../software/overview/search/ViewToggleGroup'
+import CommunityMetadata from './metadata'
+import {TabKey} from './tabs/CommunityTabItems'
+import CommunityTabs from './tabs'
+import {CommunityProvider} from './context'
+import {EditCommunityProps} from './apiCommunities'
+
+type CommunityPageProps={
+ selectTab: TabKey
+ community: EditCommunityProps
+ slug: string[]
+ isMaintainer: boolean
+ rsd_page_layout: LayoutType
+ rsd_page_rows: number
+ children: JSX.Element | JSX.Element[]
+}
+
+export default function CommunityPage({
+ community,rsd_page_layout,isMaintainer,
+ rsd_page_rows,slug, children, selectTab
+}:CommunityPageProps) {
+ return (
+
+
+
+ {/* COMMUNITY HEADER */}
+
+
+
+
+ {/* TABS */}
+
+
+
+ {/* TAB CONTENT */}
+
+
+
+
+ )
+}
diff --git a/frontend/components/communities/__mocks__/mockCommunity.ts b/frontend/components/communities/__mocks__/mockCommunity.ts
new file mode 100644
index 000000000..29d5a641d
--- /dev/null
+++ b/frontend/components/communities/__mocks__/mockCommunity.ts
@@ -0,0 +1,22 @@
+// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
+// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) (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 {CommunityListProps} from '../apiCommunities'
+
+const mockCommunity: CommunityListProps = {
+ 'id': '91c2ffa7-bce6-4488-be00-6613a2d99f51',
+ 'slug': 'dutch-research-council',
+ 'name': 'Dutch Research Council Community',
+ 'short_description': 'This is short description',
+ 'logo_id': null,
+ 'software_cnt': 1,
+ 'primary_maintainer': '91c2ffa7-bce6-4488-be00-6613a2d99f52',
+ 'description': '# About page\n\nThis is short text for about page.\n\n## Subtitle\n\nThis is subtitle.'
+}
+
+export default mockCommunity
diff --git a/frontend/components/communities/about/CommunityAboutIndex.test.tsx b/frontend/components/communities/about/CommunityAboutIndex.test.tsx
new file mode 100644
index 000000000..e1d6b2f55
--- /dev/null
+++ b/frontend/components/communities/about/CommunityAboutIndex.test.tsx
@@ -0,0 +1,21 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {screen, render} from '@testing-library/react'
+
+import AboutPage from './index'
+import mockCommunity from '../__mocks__/mockCommunity'
+
+describe('frontend/components/community/about/index.tsx', () => {
+
+ it('renders markdown title # About page', () => {
+ render(
+
+ )
+ const aboutPage = screen.getByText(/# About page/)
+ expect(aboutPage).toBeInTheDocument()
+ })
+
+})
diff --git a/frontend/components/communities/about/index.tsx b/frontend/components/communities/about/index.tsx
new file mode 100644
index 000000000..921560fa0
--- /dev/null
+++ b/frontend/components/communities/about/index.tsx
@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import ReactMarkdownWithSettings from '~/components/layout/ReactMarkdownWithSettings'
+import BaseSurfaceRounded from '~/components/layout/BaseSurfaceRounded'
+
+export default function AboutCommunityPage({description}:{description?:string}) {
+ // if description is present we return markdown page
+ if (description) {
+ return (
+
+ {description ?
+
+ : null
+ }
+
+ )
+ }
+}
diff --git a/frontend/components/communities/apiCommunities.ts b/frontend/components/communities/apiCommunities.ts
index 8aea86c38..85f71163a 100644
--- a/frontend/components/communities/apiCommunities.ts
+++ b/frontend/components/communities/apiCommunities.ts
@@ -8,16 +8,26 @@ import {extractCountFromHeader} from '~/utils/extractCountFromHeader'
import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers'
import logger from '~/utils/logger'
import {paginationUrlParams} from '~/utils/postgrestUrl'
+import {KeywordForCommunity} from './settings/general/apiCommunityKeywords'
+import {Community} from '../admin/communities/apiCommunities'
-export type CommunityListProps = {
+// New type based on Community but replace
+// id with new type
+export type CommunityListProps = Omit
& {
+ // id is always present
id: string,
- slug: string,
- name: string,
- short_description: string | null,
- logo_id: string | null
- software_cnt: number | null
+ // additional props
+ software_cnt: number | null,
+ keywords: string[] | null
}
+// New type based on CommunityListProps but replace
+// the keywords type
+export type EditCommunityProps = Omit & {
+ keywords: KeywordForCommunity[]
+}
+
+
type GetCommunityListParams={
page: number,
rows: number,
@@ -79,7 +89,7 @@ type GetCommunityBySlug={
token?:string
}
-export async function getCommunityBySlug({slug,user,token}:GetCommunityBySlug){
+export async function getCommunityBySlug({slug,token}:GetCommunityBySlug){
try{
// ignore if no slug
if (slug===null) return null
@@ -113,17 +123,19 @@ export async function getCommunityBySlug({slug,user,token}:GetCommunityBySlug){
type PatchCommunityProps = {
id: string,
- slug?: string,
- name?: string,
- short_description?: string | null,
- logo_id?: string | null
- description?: string | null
+ data:{
+ slug?: string,
+ name?: string,
+ short_description?: string | null,
+ logo_id?: string | null
+ description?: string | null
+ },
+ token: string
}
-export async function patchCommunity({data, token}:
- { data: PatchCommunityProps, token: string }) {
+export async function patchCommunityTable({id, data, token}:PatchCommunityProps) {
try {
- const url = `/api/v1/community?id=eq.${data.id}`
+ const url = `/api/v1/community?id=eq.${id}`
const resp = await fetch(url, {
method: 'PATCH',
headers: {
diff --git a/frontend/components/communities/context/index.tsx b/frontend/components/communities/context/index.tsx
new file mode 100644
index 000000000..fe8a1b4a0
--- /dev/null
+++ b/frontend/components/communities/context/index.tsx
@@ -0,0 +1,69 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {PropsWithChildren, createContext, useCallback, useContext, useState} from 'react'
+import {EditCommunityProps} from '~/components/communities/apiCommunities'
+
+type UpdateCommunityProps = {
+ key: keyof EditCommunityProps,
+ value: any
+}
+
+type CommunityContextProps = PropsWithChildren & {
+ community: EditCommunityProps,
+ isMaintainer: boolean,
+ updateCommunity: ({key,value}:UpdateCommunityProps)=>void
+}
+
+const emptyCommunity:EditCommunityProps = {
+ id:'',
+ name: '',
+ slug: '',
+ short_description: null,
+ description: null,
+ primary_maintainer: null,
+ logo_id: null,
+ software_cnt: null,
+ keywords: [],
+}
+
+const CommunityContext = createContext({
+ community: emptyCommunity,
+ isMaintainer: false,
+ updateCommunity: ({key,value}:UpdateCommunityProps)=> {}
+})
+
+export function CommunityProvider({community:initCommunity,isMaintainer:initMaintainer,...props}:any){
+ const [community, setCommunity] = useState(initCommunity)
+ const [isMaintainer] = useState(initMaintainer ?? false)
+
+ const updateCommunity = useCallback(({key,value}:UpdateCommunityProps)=>{
+ if (community){
+ const comm = {
+ ...community,
+ [key]:value
+ }
+ setCommunity(comm)
+ }
+ },[community])
+
+ return (
+
+ )
+}
+
+export function useCommunityContext(){
+ const {community,isMaintainer,updateCommunity} = useContext(CommunityContext)
+ return {
+ community,
+ isMaintainer,
+ updateCommunity
+ }
+}
diff --git a/frontend/components/communities/metadata/CommunityLogo.tsx b/frontend/components/communities/metadata/CommunityLogo.tsx
index df8c9362f..58f043214 100644
--- a/frontend/components/communities/metadata/CommunityLogo.tsx
+++ b/frontend/components/communities/metadata/CommunityLogo.tsx
@@ -1,20 +1,16 @@
-// 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-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0
-import {useEffect, useState} from 'react'
+import {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'
+import {patchCommunityTable} from '../apiCommunities'
type CommunityLogoProps = {
id: string,
@@ -42,12 +38,6 @@ export default function CommunityLogo({id,name,logo_id,isMaintainer}:CommunityLo
// 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]
@@ -59,9 +49,9 @@ export default function CommunityLogo({id,name,logo_id,isMaintainer}:CommunityLo
// console.log('addLogo...resp...', resp)
if (resp.status === 201 && id) {
// update logo_id reference
- const patch = await patchCommunity({
+ const patch = await patchCommunityTable({
+ id,
data: {
- id,
logo_id: resp.message
},
token
@@ -92,9 +82,9 @@ export default function CommunityLogo({id,name,logo_id,isMaintainer}:CommunityLo
async function removeLogo() {
if (logo && token && id) {
// remove logo_id from organisation
- const resp = await patchCommunity({
+ const resp = await patchCommunityTable({
+ id,
data: {
- id,
logo_id: null
},
token
diff --git a/frontend/components/communities/metadata/index.tsx b/frontend/components/communities/metadata/index.tsx
index 22fc6df12..11dc74924 100644
--- a/frontend/components/communities/metadata/index.tsx
+++ b/frontend/components/communities/metadata/index.tsx
@@ -4,47 +4,44 @@
// SPDX-License-Identifier: Apache-2.0
import BaseSurfaceRounded from '~/components/layout/BaseSurfaceRounded'
-import Links, {LinksProps} from '~/components/organisation/metadata/Links'
+import {useCommunityContext} from '../context'
import CommunityLogo from './CommunityLogo'
+import KeywordList from '~/components/cards/KeywordList'
-type CommunityMetadataProps={
- id: string,
- name: string,
- short_description: string | null
- logo_id: string | null
- isMaintainer: boolean
- links: LinksProps[]
-}
+export default function CommunityMetadata() {
+ const {community,isMaintainer} = useCommunityContext()
+ // generate simple list
+ const keywordList = community?.keywords?.map(keyword=>keyword.keyword)
-export default function CommunityMetadata({
- id,name,short_description,
- logo_id,isMaintainer,links
-}:CommunityMetadataProps) {
+ // console.group('CommunityMetadata')
+ // console.log('isMaintainer...', isMaintainer)
+ // console.log('keywordList...', keywordList)
+ // console.log('community...', community)
+ // console.groupEnd()
return (
-
-
-
- {name}
-
-
- {short_description}
-
-
-
-
-
+
+
+ {community?.name}
+
+
+ {community?.short_description}
+
+
)
diff --git a/frontend/components/communities/overview/CommunityCard.tsx b/frontend/components/communities/overview/CommunityCard.tsx
index de59308fa..6828a6fe6 100644
--- a/frontend/components/communities/overview/CommunityCard.tsx
+++ b/frontend/components/communities/overview/CommunityCard.tsx
@@ -1,8 +1,5 @@
-// 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-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0
@@ -12,9 +9,9 @@ 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'
+import KeywordList from '~/components/cards/KeywordList'
+import CommunityMetrics from './CommunityMetrics'
export default function CommunityCard({community}:{community:CommunityListProps}) {
@@ -37,23 +34,23 @@ export default function CommunityCard({community}:{community:CommunityListProps}
/>
-
- {/*
*/}
-
+
+ {/* keywords */}
+
+
-
- {/* Software packages count */}
-
-
- {community.software_cnt ?? 0}
-
-
- software package{community.software_cnt === 1 ? '' : 's'}
-
-
+
+ {/* Metrics */}
+
+
diff --git a/frontend/components/communities/overview/CommunityListItem.tsx b/frontend/components/communities/overview/CommunityListItem.tsx
index d251e389c..8d8734763 100644
--- a/frontend/components/communities/overview/CommunityListItem.tsx
+++ b/frontend/components/communities/overview/CommunityListItem.tsx
@@ -9,8 +9,10 @@ import ListImageWithGradientPlaceholder from '~/components/projects/overview/lis
import OverviewListItem from '~/components/software/overview/list/OverviewListItem'
import {getImageUrl} from '~/utils/editImage'
import {CommunityListProps} from '../apiCommunities'
+import CommunityMetrics from './CommunityMetrics'
export default function CommunityListItem({community}:{community:CommunityListProps}) {
+ const imgSrc = getImageUrl(community.logo_id ?? null)
return (
@@ -21,7 +23,7 @@ export default function CommunityListItem({community}:{community:CommunityListPr
className='flex-1 flex items-center hover:text-inherit bg-base-100 rounded-sm'
>
@@ -35,8 +37,8 @@ export default function CommunityListItem({community}:{community:CommunityListPr
{/* software count */}
-
- {community.software_cnt} software packages
+
+
diff --git a/frontend/components/communities/overview/CommunityMetrics.tsx b/frontend/components/communities/overview/CommunityMetrics.tsx
new file mode 100644
index 000000000..806729a17
--- /dev/null
+++ b/frontend/components/communities/overview/CommunityMetrics.tsx
@@ -0,0 +1,32 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import Tooltip from '@mui/material/Tooltip'
+import TerminalIcon from '@mui/icons-material/Terminal'
+
+type CommunityMetricsProps = {
+ software_cnt: number
+}
+
+export default function CommunityMetrics({software_cnt}:CommunityMetricsProps) {
+
+ function softwareMessage(){
+ if (software_cnt && software_cnt === 1) {
+ return `${software_cnt} software package`
+ }
+ return `${software_cnt ?? 0} software packages`
+ }
+
+ return (
+ <>
+
+
+
+ {software_cnt ?? 0}
+
+
+ >
+ )
+}
diff --git a/frontend/components/communities/settings/SettingsContent.tsx b/frontend/components/communities/settings/SettingsContent.tsx
new file mode 100644
index 000000000..996118d37
--- /dev/null
+++ b/frontend/components/communities/settings/SettingsContent.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 {useRouter} from 'next/router'
+
+import CommunityAboutPage from './about-page'
+import CommunityMaintainers from './maintainers'
+import CommunityGeneralSettings from './general'
+
+
+export default function CommunitySettingsContent() {
+ const router = useRouter()
+ const tab = router.query['tab']?.toString() ?? ''
+
+ switch (tab) {
+ case 'about':
+ return
+ case 'maintainers':
+ return
+ default:
+ return
+ }
+
+}
diff --git a/frontend/components/communities/settings/SettingsNav.tsx b/frontend/components/communities/settings/SettingsNav.tsx
new file mode 100644
index 000000000..3740ac79f
--- /dev/null
+++ b/frontend/components/communities/settings/SettingsNav.tsx
@@ -0,0 +1,54 @@
+// 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 List from '@mui/material/List'
+import ListItemButton from '@mui/material/ListItemButton'
+import ListItemIcon from '@mui/material/ListItemIcon'
+import ListItemText from '@mui/material/ListItemText'
+
+import {editMenuItemButtonSx} from '~/config/menuItems'
+import {settingsMenu} from './SettingsNavItems'
+
+export default function CommunitySettingsNav() {
+ const router = useRouter()
+ const tab = router.query['tab'] ?? 'general'
+ // console.group('CommunitySettingsNav')
+ // console.log('description...', organisation.description)
+ // console.groupEnd()
+ return (
+
+ {settingsMenu.map((item, pos) => {
+ const selected = tab === settingsMenu[pos].id
+ return (
+ {
+ router.push({
+ query: {
+ ...router.query,
+ tab: item.id
+ }
+ },{},{scroll:false})
+ }}
+ sx={editMenuItemButtonSx}
+ >
+
+ {item.icon}
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/components/communities/settings/SettingsNavItems.tsx b/frontend/components/communities/settings/SettingsNavItems.tsx
new file mode 100644
index 000000000..893229181
--- /dev/null
+++ b/frontend/components/communities/settings/SettingsNavItems.tsx
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import InfoIcon from '@mui/icons-material/Info'
+import PersonIcon from '@mui/icons-material/Person'
+import SettingsIcon from '@mui/icons-material/Settings'
+
+export type SettingsMenuProps = {
+ id: string,
+ status: string,
+ label: (props?:any)=>string,
+ icon: JSX.Element
+}
+
+export const settingsMenu: SettingsMenuProps[] = [
+ {
+ id:'general',
+ label:()=>'General settings',
+ icon: ,
+ status: 'Community details'
+ },
+ {
+ id:'maintainers',
+ label:()=>'Maintainers',
+ icon: ,
+ status: 'Maintainers of community',
+ },
+ {
+ id:'about',
+ label:()=>'About',
+ icon: ,
+ status: 'Custom about page',
+ }
+]
diff --git a/frontend/components/communities/settings/about-page/AutosaveCommunityDescription.tsx b/frontend/components/communities/settings/about-page/AutosaveCommunityDescription.tsx
new file mode 100644
index 000000000..7afe09e45
--- /dev/null
+++ b/frontend/components/communities/settings/about-page/AutosaveCommunityDescription.tsx
@@ -0,0 +1,80 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useController, useFormContext} from 'react-hook-form'
+
+import {useSession} from '~/auth'
+import MarkdownInputWithPreview from '~/components/form/MarkdownInputWithPreview'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import {Community} from '~/components/admin/communities/apiCommunities'
+import {patchCommunityTable} from '../../apiCommunities'
+import {useCommunityContext} from '../../context'
+
+type AutosaveControlledMarkdownProps = {
+ name: keyof Community
+ maxLength: number
+}
+
+export default function AutosaveCommunityDescription(props: AutosaveControlledMarkdownProps) {
+ const {token} = useSession()
+ const {showErrorMessage} = useSnackbar()
+ const {name,maxLength} = props
+ const {register,control,resetField} = useFormContext()
+ const {community,updateCommunity} = useCommunityContext()
+ const {field:{value},fieldState:{isDirty,error}} = useController({
+ control,
+ name
+ })
+
+ async function saveMarkdown() {
+ let description = null
+ // we do not save when error or no change
+ if (isDirty === false || error) return
+ // only if not empty string, we use null when empty
+ if (value !== '') description = value
+ // patch community table
+ const resp = await patchCommunityTable({
+ id: community?.id ?? '',
+ data: {
+ [name]: description
+ },
+ token
+ })
+
+ // console.group('AutosaveCommunityDescription')
+ // console.log('saved...', name)
+ // console.log('status...', resp?.status)
+ // console.groupEnd()
+
+ if (resp?.status !== 200) {
+ showErrorMessage(`Failed to save ${name}. ${resp?.message}`)
+ } else {
+ // debugger
+ updateCommunity({
+ key: name,
+ value
+ })
+ // debugger
+ resetField(name, {
+ defaultValue:value
+ })
+ }
+ }
+
+ return (
+ saveMarkdown()}
+ />
+ )
+}
diff --git a/frontend/components/communities/settings/about-page/config.ts b/frontend/components/communities/settings/about-page/config.ts
new file mode 100644
index 000000000..e7496a232
--- /dev/null
+++ b/frontend/components/communities/settings/about-page/config.ts
@@ -0,0 +1,17 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+const config = {
+ description: {
+ title: 'About page',
+ subtitle: 'Provide the content of about page. If content is not provided about page is hidden.',
+ validation: {
+ minLength: {value: 16, message: 'Minimum length is 16'},
+ maxLength: {value: 10000, message: 'Maximum length is 10.000'},
+ }
+ }
+}
+
+export default config
diff --git a/frontend/components/communities/settings/about-page/index.tsx b/frontend/components/communities/settings/about-page/index.tsx
new file mode 100644
index 000000000..2c5005ff8
--- /dev/null
+++ b/frontend/components/communities/settings/about-page/index.tsx
@@ -0,0 +1,58 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {FormProvider, useForm} from 'react-hook-form'
+
+import BaseSurfaceRounded from '~/components/layout/BaseSurfaceRounded'
+import EditSectionTitle from '~/components/layout/EditSectionTitle'
+import config from './config'
+import AutosaveOrganisationDescription from './AutosaveCommunityDescription'
+
+import {useCommunityContext} from '~/components/communities/context'
+
+type AboutPageFormProps = {
+ id?: string
+ description?: string | null
+}
+
+
+export default function CommunityAboutPage() {
+ const {community} = useCommunityContext()
+
+ const methods = useForm({
+ mode: 'onChange',
+ defaultValues: {
+ id: community?.id,
+ description: community?.description
+ }
+ })
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/frontend/components/communities/settings/general/AutosaveCommunityKeywords.tsx b/frontend/components/communities/settings/general/AutosaveCommunityKeywords.tsx
new file mode 100644
index 000000000..c4d6beb85
--- /dev/null
+++ b/frontend/components/communities/settings/general/AutosaveCommunityKeywords.tsx
@@ -0,0 +1,174 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import Chip from '@mui/material/Chip'
+import {useFormContext} from 'react-hook-form'
+
+import {useSession} from '~/auth'
+import {createOrGetKeyword, silentKeywordDelete} from '~/utils/editKeywords'
+import {sortOnStrProp} from '~/utils/sortFn'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import FindKeyword, {Keyword} from '~/components/keyword/FindKeyword'
+import {EditCommunityProps} from '~/components/communities/apiCommunities'
+import {
+ KeywordForCommunity,
+ addKeywordsToCommunity, deleteKeywordFromCommunity
+} from './apiCommunityKeywords'
+import {searchForCommunityKeyword} from './searchForCommunityKeyword'
+import config from './config'
+import {useCommunityContext} from '../../context'
+
+export type SoftwareKeywordsProps={
+ software_id:string,
+ concept_doi?:string
+}
+
+export default function AutosaveCommunityKeywords(){
+ const {token} = useSession()
+ const {showErrorMessage, showInfoMessage} = useSnackbar()
+ const {watch,setValue} = useFormContext()
+ const {updateCommunity} = useCommunityContext()
+ const [id,keywords] = watch(['id','keywords'])
+
+ // console.group('AutosaveCommunityKeywords')
+ // console.log('id...', id)
+ // console.log('keywords...', keywords)
+ // console.groupEnd()
+
+ function setKeywords(items:KeywordForCommunity[]){
+ // save keywords in the form context
+ setValue('keywords',items,{
+ shouldDirty: false,
+ shouldTouch: false,
+ shouldValidate: false
+ })
+ // update in the context
+ updateCommunity({
+ key:'keywords',
+ value: items
+ })
+ }
+
+ async function onAdd(selected: Keyword) {
+ // check if already added
+ const find = keywords.filter(item => item.keyword.trim().toLowerCase() === selected.keyword.trim().toLowerCase())
+ // debugger
+ let resp
+ if (find.length === 0) {
+ resp = await addKeywordsToCommunity({
+ data:{
+ community:id,
+ keyword: selected.id
+ },
+ token
+ })
+ if (resp.status===200){
+ const items = [
+ ...keywords,
+ {
+ ...selected,
+ community:id
+ }
+ ].sort((a,b)=>sortOnStrProp(a,b,'keyword'))
+ setKeywords(items)
+ }else{
+ showErrorMessage(`Failed to save keyword. ${resp.message}`)
+ }
+ }else{
+ showInfoMessage(`${selected.keyword.trim()} is already in the list`)
+ }
+ }
+
+ async function onCreate(selected: string) {
+ // check if already exists
+ const find = keywords.filter(item => item.keyword.trim().toLowerCase() === selected.trim().toLowerCase())
+ // debugger
+ if (find.length === 0) {
+ // create or get existing keyword
+ let resp = await createOrGetKeyword({
+ keyword: selected,
+ token
+ })
+ if (resp.status === 201) {
+ const keyword = {
+ id: resp.message.id,
+ keyword: resp.message.value,
+ software: id,
+ cnt: null
+ }
+ // add keyword after created
+ await onAdd(keyword)
+ }else{
+ showErrorMessage(`Failed to save keyword. ${resp.message}`)
+ }
+ }else{
+ showInfoMessage(`${selected.trim()} is already in the list`)
+ }
+ }
+
+ async function onRemove(pos:number) {
+ const item = keywords[pos]
+ if (item.community && item.id){
+ const resp = await deleteKeywordFromCommunity({
+ community: item.community,
+ keyword: item.id,
+ token
+ })
+ if (resp.status===200){
+ const items=[
+ ...keywords.slice(0,pos),
+ ...keywords.slice(pos+1)
+ ]
+ setKeywords(items)
+ // try to delete this keyword from keyword table
+ // delete will fail if the keyword is referenced
+ // therefore we do not check the status
+ const del = await silentKeywordDelete({
+ keyword: item.keyword,
+ token
+ })
+ }else{
+ showErrorMessage(`Failed to delete keyword. ${resp.message}`)
+ }
+ }
+ }
+
+ return (
+
+
+
+ {keywords.map((item, pos) => {
+ return(
+
+ onRemove(pos)}
+ sx={{
+ textTransform:'capitalize'
+ }}
+ />
+
+ )
+ })}
+
+
+ )
+}
diff --git a/frontend/components/communities/settings/general/AutosaveCommunityTextField.tsx b/frontend/components/communities/settings/general/AutosaveCommunityTextField.tsx
new file mode 100644
index 000000000..7c3016f7c
--- /dev/null
+++ b/frontend/components/communities/settings/general/AutosaveCommunityTextField.tsx
@@ -0,0 +1,71 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useFormContext} from 'react-hook-form'
+import {useRouter} from 'next/router'
+
+import {useSession} from '~/auth'
+import AutosaveControlledTextField, {OnSaveProps} from '~/components/form/AutosaveControlledTextField'
+import {ControlledTextFieldOptions} from '~/components/form/ControlledTextField'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import {Community} from '~/components/admin/communities/apiCommunities'
+import {patchCommunityTable} from '~/components/communities/apiCommunities'
+import {useCommunityContext} from '~/components/communities/context'
+
+export type AutosaveCommunityTextFieldProps = {
+ options: ControlledTextFieldOptions
+ rules?: any
+}
+
+export default function AutosaveCommunityTextField({options,rules}:AutosaveCommunityTextFieldProps) {
+ const router = useRouter()
+ const {token} = useSession()
+ const {community,updateCommunity} = useCommunityContext()
+ const {showErrorMessage} = useSnackbar()
+ const {control, resetField} = useFormContext()
+
+ async function saveCommunityInfo({name, value}: OnSaveProps) {
+ // console.group('AutosaveCommunityTextField')
+ // console.log('name...', name)
+ // console.log('value...', value)
+ // console.log('id...', id)
+ // console.groupEnd()
+ // patch project table
+ const resp = await patchCommunityTable({
+ id: community?.id ?? '',
+ data: {
+ [name]:value
+ },
+ token
+ })
+
+ if (resp?.status !== 200) {
+ showErrorMessage(`Failed to save ${name}. ${resp?.message}`)
+ } else {
+ // debugger
+ updateCommunity({
+ key: options.name,
+ value
+ })
+ // debugger
+ resetField(options.name, {
+ defaultValue:value
+ })
+ if (name === 'slug') {
+ const url = `/communities/${value}/settings?tab=general`
+ router.push(url, url, {scroll: false})
+ }
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/components/communities/settings/general/CommunityAdminSection.tsx b/frontend/components/communities/settings/general/CommunityAdminSection.tsx
new file mode 100644
index 000000000..7112a50a4
--- /dev/null
+++ b/frontend/components/communities/settings/general/CommunityAdminSection.tsx
@@ -0,0 +1,49 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useFormContext} from 'react-hook-form'
+
+import config from './config'
+import AutosaveOrganisationTextField from './AutosaveCommunityTextField'
+
+export default function CommunityAdminSection() {
+ const {watch, control, resetField} = useFormContext()
+ const [
+ id, slug, primary_maintainer
+ ] = watch([
+ 'id', 'slug', 'primary_maintainer'
+ ])
+
+ return (
+ <>
+ Admin section
+
+ {/* Community ID: {id}
*/}
+
+
+
+ >
+ )
+}
diff --git a/frontend/components/communities/settings/general/apiCommunityKeywords.ts b/frontend/components/communities/settings/general/apiCommunityKeywords.ts
new file mode 100644
index 000000000..99628df94
--- /dev/null
+++ b/frontend/components/communities/settings/general/apiCommunityKeywords.ts
@@ -0,0 +1,90 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import logger from '~/utils/logger'
+import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers'
+
+export type CommunityKeyword = {
+ community: string
+ keyword: string
+}
+
+export type KeywordForCommunity = {
+ id: string
+ community: string
+ keyword: string
+}
+
+/**
+ * Loading community keywords for editing
+ * @param uuid
+ * @param token
+ * @returns
+ */
+export async function getKeywordsByCommunity(uuid:string,token?:string){
+ try{
+ const query = `rpc/keywords_by_community?community=eq.${uuid}&order=keyword.asc`
+ let url = `${getBaseUrl()}/${query}`
+ const resp = await fetch(url, {
+ method: 'GET',
+ headers: createJsonHeaders(token)
+ })
+ if (resp.status===200){
+ const data:KeywordForCommunity[] = await resp.json()
+ return data
+ }
+ logger(`getKeywordsByCommunity ${resp.status} ${resp.statusText}`,'warn')
+ return []
+ }catch(e:any){
+ logger(`getKeywordsByCommunity: ${e?.message}`,'error')
+ return []
+ }
+}
+
+export async function addKeywordsToCommunity({data, token}:
+ {data: CommunityKeyword | CommunityKeyword[], token: string }) {
+ try {
+ // POST
+ const url = '/api/v1/keyword_for_community'
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: {
+ ...createJsonHeaders(token),
+ // this will add new items and update existing
+ 'Prefer': 'resolution=merge-duplicates'
+ },
+ body: JSON.stringify(data)
+ })
+ return extractReturnMessage(resp, '')
+ } catch (e: any) {
+ logger(`addKeywordsToCommunity: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
+
+export async function deleteKeywordFromCommunity({community, keyword, token}:
+ { community: string, keyword: string, token: string }) {
+ try {
+ // DELETE record based on community and keyword uuid
+ const query = `keyword_for_community?community=eq.${community}&keyword=eq.${keyword}`
+ const url = `/api/v1/${query}`
+ const resp = await fetch(url, {
+ method: 'DELETE',
+ headers: {
+ ...createJsonHeaders(token)
+ }
+ })
+ return extractReturnMessage(resp, community ?? '')
+ } catch (e: any) {
+ logger(`deleteKeywordFromCommunity: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
diff --git a/frontend/components/communities/settings/general/config.ts b/frontend/components/communities/settings/general/config.ts
new file mode 100644
index 000000000..383ef9232
--- /dev/null
+++ b/frontend/components/communities/settings/general/config.ts
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+const config = {
+ name: {
+ label: 'Name',
+ help: 'Community name',
+ validation: {
+ required: 'Name is required',
+ minLength: {value: 2, message: 'Minimum length is 2'},
+ maxLength: {value: 200, message: 'Maximum length is 200'},
+ }
+ },
+ short_description: {
+ label: 'Short description',
+ help: 'Short text used in the community card',
+ validation: {
+ minLength: {value: 6, message: 'Minimum length is 6'},
+ maxLength: {value: 300, message: 'Maximum length is 300'},
+ }
+ },
+ slug: {
+ label: 'RSD path',
+ help: 'Use letters, numbers and dash "-". Other characters are not allowed.',
+ validation: {
+ required: 'Rsd path is required',
+ minLength: {value: 2, message: 'Minimum length is 2'},
+ maxLength: {value: 200, message: 'Maximum length is 200'},
+ pattern: {
+ value: /^[a-z0-9]+(-[a-z0-9]+)*$/,
+ message: 'Use letters, numbers and dash "-". Other characters are not allowed.'
+ }
+ }
+ },
+ primary_maintainer: {
+ label: 'Primary maintainer',
+ help: 'Provide account id of the primary maintainer.',
+ validation: {
+ minLength: {value: 36, message: 'Minimum length is 36'},
+ maxLength: {value: 36, message: 'Maximum length is 36'}
+ }
+ },
+ keywords: {
+ label: 'Find or add keyword',
+ help: 'Select from top 30 list or start typing for more suggestions',
+ validation: {
+ //use in find keyword input box
+ minLength: 1,
+ }
+ },
+}
+
+export default config
diff --git a/frontend/components/communities/settings/general/index.tsx b/frontend/components/communities/settings/general/index.tsx
new file mode 100644
index 000000000..4656b28a0
--- /dev/null
+++ b/frontend/components/communities/settings/general/index.tsx
@@ -0,0 +1,89 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {FormProvider, useForm} from 'react-hook-form'
+
+import {useSession} from '~/auth'
+import BaseSurfaceRounded from '~/components/layout/BaseSurfaceRounded'
+import {useCommunityContext} from '~/components/communities/context'
+import config from './config'
+import CommunityAdminSection from './CommunityAdminSection'
+import AutosaveCommunityTextField from './AutosaveCommunityTextField'
+import AutosaveCommunityKeywords from './AutosaveCommunityKeywords'
+import {EditCommunityProps} from '../../apiCommunities'
+
+
+export default function CommunityGeneralSettings() {
+ const {user} = useSession()
+ const {community} = useCommunityContext()
+ const methods = useForm({
+ mode: 'onChange',
+ defaultValues: community
+ })
+ // extract used methods
+ const {watch, register} = methods
+
+ const [name,short_description]=watch(['name','short_description'])
+
+ // console.group('OrganisationGeneralSettings')
+ // console.log('short_description...', short_description)
+ // console.log('website....', website)
+ // console.log('isMaintainer....', isMaintainer)
+ // console.groupEnd()
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/frontend/components/communities/settings/general/searchForCommunityKeyword.test.ts b/frontend/components/communities/settings/general/searchForCommunityKeyword.test.ts
new file mode 100644
index 000000000..15cad532e
--- /dev/null
+++ b/frontend/components/communities/settings/general/searchForCommunityKeyword.test.ts
@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {mockResolvedValueOnce} from '~/utils/jest/mockFetch'
+import {searchForCommunityKeyword} from './searchForCommunityKeyword'
+
+beforeEach(() => {
+ jest.clearAllMocks()
+})
+
+it('searchForCommunityKeyword calls api with proper params', async() => {
+ const searchFor = 'Search for keyword'
+ const expectUrl = `/api/v1/rpc/keyword_count_for_community?keyword=ilike.*${encodeURIComponent(searchFor)}*&order=cnt.desc.nullslast,keyword.asc&limit=30`
+ const expectPayload = {
+ 'method': 'GET'
+ }
+
+ mockResolvedValueOnce('OK')
+
+ const resp = await searchForCommunityKeyword({searchFor})
+
+ // validate api call
+ expect(global.fetch).toBeCalledTimes(1)
+ expect(global.fetch).toBeCalledWith(expectUrl, expectPayload)
+})
diff --git a/frontend/components/communities/settings/general/searchForCommunityKeyword.ts b/frontend/components/communities/settings/general/searchForCommunityKeyword.ts
new file mode 100644
index 000000000..612a567c0
--- /dev/null
+++ b/frontend/components/communities/settings/general/searchForCommunityKeyword.ts
@@ -0,0 +1,42 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import logger from '~/utils/logger'
+import {Keyword} from '~/components/keyword/FindKeyword'
+import {getBaseUrl} from '~/utils/fetchHelpers'
+
+
+// this is always frontend call
+export async function searchForCommunityKeyword(
+ {searchFor}: { searchFor: string }
+) {
+ try {
+ const searchForEncoded = encodeURIComponent(searchFor)
+ const baseUrl = getBaseUrl()
+ let query = ''
+ if (searchForEncoded) {
+ query = `keyword=ilike.*${searchForEncoded}*&order=cnt.desc.nullslast,keyword.asc&limit=30`
+ } else {
+ query = 'order=cnt.desc.nullslast,keyword.asc&limit=30'
+ }
+ // GET top 30 matches
+ const url = `${baseUrl}/rpc/keyword_count_for_community?${query}`
+ const resp = await fetch(url, {
+ method: 'GET'
+ })
+
+ if (resp.status === 200) {
+ const json: Keyword[] = await resp.json()
+ return json
+ }
+ // return extractReturnMessage(resp, project ?? '')
+ logger(`searchForCommunityKeyword: ${resp.status} ${resp.statusText}`, 'warn')
+ return []
+ } catch (e: any) {
+ logger(`searchForCommunityKeyword: ${e?.message}`, 'error')
+ return []
+ }
+}
+
diff --git a/frontend/components/communities/settings/index.tsx b/frontend/components/communities/settings/index.tsx
new file mode 100644
index 000000000..8827f361c
--- /dev/null
+++ b/frontend/components/communities/settings/index.tsx
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useSession} from '~/auth'
+import UserAgreementModal from '~/components/user/settings/UserAgreementModal'
+import ProtectedContent from '~/components/layout/ProtectedContent'
+import BaseSurfaceRounded from '~/components/layout/BaseSurfaceRounded'
+import SettingsNav from './SettingsNav'
+import SettingsContent from './SettingsContent'
+
+export default function CommunitySettingsContent({isMaintainer}:{isMaintainer:boolean}) {
+ const {status,user} = useSession()
+
+ return (
+
+
+
+
+
+
+ {/* dynamic load of settings tabs */}
+
+
+
+ )
+}
diff --git a/frontend/components/communities/settings/maintainers/CommunityMaintainersIndex.test.tsx b/frontend/components/communities/settings/maintainers/CommunityMaintainersIndex.test.tsx
new file mode 100644
index 000000000..6ae2058f3
--- /dev/null
+++ b/frontend/components/communities/settings/maintainers/CommunityMaintainersIndex.test.tsx
@@ -0,0 +1,243 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {fireEvent, render, screen, within} from '@testing-library/react'
+import {WithAppContext, mockSession} from '~/utils/jest/WithAppContext'
+import {WithCommunityContext} from '~/utils/jest/WithCommunityContext'
+
+import {defaultSession} from '~/auth'
+import CommunityMaintainers from './index'
+import mockCommunity from '../../__mocks__/mockCommunity'
+
+// MOCK user agreement call
+// jest.mock('~/components/user/settings/useUserAgreements')
+
+// MOCK useCommunityMaintainers hook
+const mockDeleteMaintainer = jest.fn()
+const dummyMaintainersData={
+ loading: false,
+ maintainers:[],
+ deleteMaintainer: mockDeleteMaintainer
+}
+const mockUseCommunityMaintainers = jest.fn((props)=>dummyMaintainersData)
+jest.mock('./useCommunityMaintainers', () => ({
+ useCommunityMaintainers:jest.fn((props)=>mockUseCommunityMaintainers(props))
+}))
+
+// MOCK useCommunityInvitations hook
+const mockCreateInvitation = jest.fn()
+const mockDeleteInvitation = jest.fn()
+const dummyInvitationData={
+ magicLink: null,
+ unusedInvitations:[],
+ createInvitation: mockCreateInvitation,
+ deleteInvitation: mockDeleteInvitation
+}
+const mockUseCommunityInvitations = jest.fn((props)=>dummyInvitationData)
+jest.mock('./useCommunityInvitations', () => ({
+ useCommunityInvitations:jest.fn((props)=>mockUseCommunityInvitations(props))
+}))
+
+const dummyProps = {
+ ...mockCommunity,
+ isMaintainer: false
+}
+
+const dummyMaintainer = [
+ {account: 'test-account-id-1', name: 'John Doe 1', email: 'test1@email.com', affiliation: 'Company 1', disableDelete: false},
+ {account: 'test-account-id-2', name: 'John Doe 2', email: null, affiliation: null, disableDelete: false},
+]
+
+const dummyInvitations = [
+ {id:'test-link-id-1',created_at: new Date().toISOString(),type:'community'},
+ {id:'test-link-id-2',created_at: new Date().toISOString(),type:'community'},
+ {id:'test-link-id-3',created_at: new Date().toISOString(),type:'community'}
+]
+
+describe('frontend/components/organisation/maintainers/index.tsx', () => {
+
+ beforeEach(() => {
+ // reset mock counters
+ jest.clearAllMocks()
+ })
+
+ it('shows loader when hook returns loading=true', () => {
+ // user is authenticated
+ defaultSession.status = 'authenticated'
+ defaultSession.token = 'test-token'
+ // it is maintainer of this organisation
+ dummyProps.isMaintainer = true
+ dummyMaintainersData.loading = true
+ // mock hook return with loading true
+ mockUseCommunityMaintainers.mockReturnValueOnce(dummyMaintainersData)
+
+ render(
+
+
+
+
+
+ )
+
+ const loader = screen.getByRole('progressbar')
+ expect(loader).toBeInTheDocument()
+ })
+
+ it('shows "No maintainers" message', async () => {
+ // user is authenticated
+ defaultSession.status = 'authenticated'
+ defaultSession.token = 'test-token'
+ // it is maintainer of this organisation
+ dummyProps.isMaintainer = true
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseCommunityMaintainers.mockReturnValueOnce(dummyMaintainersData)
+
+ render(
+
+
+
+
+
+ )
+ const noMaintainers = await screen.findByText('No maintainers')
+ expect(noMaintainers).toBeInTheDocument()
+ })
+
+ it('renders component with "Generate invite link" button', () => {
+ // it is maintainer of this organisation
+ dummyProps.isMaintainer = true
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseCommunityMaintainers.mockReturnValueOnce(dummyMaintainersData)
+
+ render(
+
+
+
+
+
+ )
+
+ const inviteBtn = screen.getByRole('button', {name: 'Generate invite link'})
+ expect(inviteBtn).toBeInTheDocument()
+ // screen.debug()
+ })
+
+ it('shows maintainer name (in the list)', async () => {
+ // MOCK maintainers call
+ const dummyRawMaintainers = [{
+ 'maintainer': 'a050aaf3-9c46-490c-ade3-aeeb6a05b1d1',
+ 'name': 'Jordan Ross Belfort',
+ 'email': 'Jordan.Belfort@harvard-example.edu',
+ 'affiliation': 'harvard-example.edu',
+ 'is_primary': false
+ }]
+ // user is authenticated
+ defaultSession.status = 'authenticated'
+ defaultSession.token = 'test-token'
+ dummyMaintainersData.maintainers = dummyRawMaintainers as any
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseCommunityMaintainers.mockReturnValueOnce(dummyMaintainersData)
+ // it is maintainer of this organisation
+ dummyProps.isMaintainer = true
+
+ render(
+
+
+
+
+
+ )
+
+ const maintainer = await screen.findByText(dummyRawMaintainers[0].name)
+ expect(maintainer).toBeInTheDocument()
+ })
+
+ it('shows maintainer list with all items', () => {
+ dummyMaintainersData.maintainers = dummyMaintainer as any
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseCommunityMaintainers.mockReturnValueOnce(dummyMaintainersData)
+ // it is maintainer of this organisation
+ dummyProps.isMaintainer = true
+
+ render(
+
+
+
+
+
+ )
+
+ const maintainerItem = screen.getAllByTestId('maintainer-list-item')
+ expect(maintainerItem.length).toEqual(dummyMaintainer.length)
+ })
+
+ it('maintainer cannot be deleted when disableDelete===true', () => {
+ // set disable flag on first item
+ dummyMaintainer[0].disableDelete = true
+ dummyMaintainersData.maintainers = dummyMaintainer as any
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseCommunityMaintainers.mockReturnValueOnce(dummyMaintainersData)
+ // it is maintainer of this organisation
+ dummyProps.isMaintainer = true
+
+ render(
+
+
+
+
+
+ )
+
+ const maintainerItem = screen.getAllByTestId('maintainer-list-item')
+ const deleteBtn = within(maintainerItem[0]).getByRole('button',{name:'delete'})
+ expect(deleteBtn).toBeDisabled()
+ })
+
+ it('calls createInvitation method to create new invitation',()=>{
+ // it is maintainer of this organisation
+ dummyProps.isMaintainer = true
+
+ render(
+
+
+
+
+
+ )
+
+ // click on generate invite
+ const btnInvite = screen.getByRole('button',{name:'Generate invite link'})
+ fireEvent.click(btnInvite)
+
+ expect(mockCreateInvitation).toBeCalledTimes(1)
+ })
+
+ it('shows unused links list',()=>{
+ // it is maintainer of this organisation
+ dummyProps.isMaintainer = true
+ dummyInvitationData.magicLink = null
+ dummyInvitationData.unusedInvitations = dummyInvitations as any
+
+ mockUseCommunityInvitations.mockReturnValueOnce(dummyInvitationData)
+
+ render(
+
+
+
+
+
+ )
+
+ // check number of unused invitations
+ const unusedInvites = screen.getAllByTestId('unused-invitation-item')
+ expect(unusedInvites.length).toEqual(dummyInvitations.length)
+ })
+
+})
diff --git a/frontend/components/communities/settings/maintainers/CommunityMaintainersLinks.tsx b/frontend/components/communities/settings/maintainers/CommunityMaintainersLinks.tsx
new file mode 100644
index 000000000..390d3427e
--- /dev/null
+++ b/frontend/components/communities/settings/maintainers/CommunityMaintainersLinks.tsx
@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import Button from '@mui/material/Button'
+import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
+
+import InvitationList from '~/components/maintainers/InvitationList'
+import {useCommunityContext} from '~/components/communities/context'
+import {useCommunityInvitations} from './useCommunityInvitations'
+
+export default function CommunityMaintainerLinks() {
+ const {community} = useCommunityContext()
+ const {unusedInvitations,createInvitation,deleteInvitation} = useCommunityInvitations({community:community?.id ?? ''})
+
+ // console.group('CommunityMaintainerLinks')
+ // console.log('id...', id)
+ // console.log('name...', name)
+ // console.log('magicLink...', magicLink)
+ // console.log('unusedInvitations...', unusedInvitations)
+ // console.groupEnd()
+
+ return (
+ <>
+ }
+ onClick={createInvitation}
+ >
+ Generate invite link
+
+
+
+ >
+ )
+}
diff --git a/frontend/components/communities/settings/maintainers/apiCommunityMaintainers.ts b/frontend/components/communities/settings/maintainers/apiCommunityMaintainers.ts
new file mode 100644
index 000000000..babc90fe1
--- /dev/null
+++ b/frontend/components/communities/settings/maintainers/apiCommunityMaintainers.ts
@@ -0,0 +1,102 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers'
+import logger from '~/utils/logger'
+
+export type MaintainerOfCommunity = {
+ // unique maintainer id
+ maintainer: string
+ name: string[]
+ email: string[]
+ affiliation: string[],
+ is_primary?: boolean
+}
+
+export async function getMaintainersOfCommunity({community, token}:
+ { community: string, token: string}) {
+ try {
+ let query = `rpc/maintainers_of_community?community_id=${community}`
+ let url = `${getBaseUrl()}/${query}`
+
+ const resp = await fetch(url, {
+ method: 'GET',
+ headers: createJsonHeaders(token)
+ })
+
+ if (resp.status === 200) {
+ const json: MaintainerOfCommunity[] = await resp.json()
+ return json
+ }
+ // ERRORS
+ logger(`getMaintainersOfCommunity: ${resp.status}:${resp.statusText} community: ${community}`, 'warn')
+ return []
+ } catch (e: any) {
+ logger(`getMaintainersOfCommunity: ${e?.message}`, 'error')
+ return []
+ }
+}
+
+export async function deleteMaintainerFromCommunity({maintainer,community,token}:
+ {maintainer:string,community:string,token:string}) {
+ try {
+ let query = `maintainer_for_community?maintainer=eq.${maintainer}&community=eq.${community}`
+ let url = `${getBaseUrl()}/${query}`
+
+ const resp = await fetch(url, {
+ method: 'DELETE',
+ headers: createJsonHeaders(token)
+ })
+
+ return extractReturnMessage(resp)
+
+ } catch (e: any) {
+ logger(`deleteMaintainerFromCommunity: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
+
+export async function communityMaintainerLink({community, account, token}:
+ { community: string, account: string, token: string }) {
+ try {
+ // POST
+ const url = `${getBaseUrl()}/invite_maintainer_for_community`
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: {
+ ...createJsonHeaders(token),
+ 'Prefer': 'return=headers-only'
+ },
+ body: JSON.stringify({
+ community,
+ created_by:account
+ })
+ })
+ if (resp.status === 201) {
+ const id = resp.headers.get('location')?.split('.')[1]
+ if (id) {
+ const link = `${location.origin}/invite/community/${id}`
+ return {
+ status: 201,
+ message: link
+ }
+ }
+ return {
+ status: 400,
+ message: 'Id is missing'
+ }
+ }
+ return extractReturnMessage(resp, community ?? '')
+ } catch (e: any) {
+ logger(`communityMaintainerLink: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
diff --git a/frontend/components/communities/settings/maintainers/index.tsx b/frontend/components/communities/settings/maintainers/index.tsx
new file mode 100644
index 000000000..413320251
--- /dev/null
+++ b/frontend/components/communities/settings/maintainers/index.tsx
@@ -0,0 +1,99 @@
+// 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 BaseSurfaceRounded from '~/components/layout/BaseSurfaceRounded'
+import EditSectionTitle from '~/components/layout/EditSectionTitle'
+import ContentLoader from '~/components/layout/ContentLoader'
+import ConfirmDeleteModal from '~/components/layout/ConfirmDeleteModal'
+import MaintainersList from '~/components/maintainers/MaintainersList'
+import {maintainers as config} from '~/components/projects/edit/maintainers/config'
+import {useCommunityContext} from '~/components/communities/context'
+import {useCommunityMaintainers} from './useCommunityMaintainers'
+import CommunityMaintainerLinks from './CommunityMaintainersLinks'
+
+type DeleteModal = {
+ open: boolean,
+ // unique account id
+ account?: string,
+ displayName?: string
+}
+
+export default function CommunityMaintainersPage() {
+ const {community} = useCommunityContext()
+ const {loading,maintainers,deleteMaintainer} = useCommunityMaintainers({community: community?.id})
+ const [modal, setModal] = useState({
+ open: false
+ })
+
+ // console.group('CommunityMaintainersPage')
+ // console.log('id...', id)
+ // console.log('modal...', modal)
+ // console.log('maintainers...', maintainers)
+ // console.log('loading...', loading)
+ // console.groupEnd()
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ function closeModal() {
+ setModal({
+ open: false,
+ })
+ }
+
+ function onDeleteMaintainer(pos: number) {
+ const maintainer = maintainers[pos]
+ if (maintainer) {
+ setModal({
+ open: true,
+ account: maintainer.account,
+ displayName: maintainer.name
+ })
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ Are you sure you want to remove {modal.displayName ?? 'No name'} ?
+ }
+ onCancel={closeModal}
+ onDelete={()=>{
+ deleteMaintainer(modal.account)
+ closeModal()
+ }}
+ />
+ >
+ )
+}
+
diff --git a/frontend/components/communities/settings/maintainers/useCommunityInvitations.tsx b/frontend/components/communities/settings/maintainers/useCommunityInvitations.tsx
new file mode 100644
index 000000000..aaadb3638
--- /dev/null
+++ b/frontend/components/communities/settings/maintainers/useCommunityInvitations.tsx
@@ -0,0 +1,81 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useCallback, useEffect, useState} from 'react'
+
+import {useSession} from '~/auth'
+import {Invitation, deleteMaintainerLink, getUnusedInvitations} from '~/components/maintainers/apiMaintainers'
+import {communityMaintainerLink} from './apiCommunityMaintainers'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+
+export function useCommunityInvitations({community}:{community?:string}) {
+ const {token,user} = useSession()
+ const {showErrorMessage} = useSnackbar()
+ const [unusedInvitations,setUnusedInvitations] = useState([])
+ const [magicLink, setMagicLink] = useState(null)
+
+ const loadUnusedInvitations = useCallback(()=>{
+ // get unused invitation
+ getUnusedInvitations({
+ id: community ?? '',
+ type: 'community',
+ token
+ }).then(items=>{
+ // update
+ setUnusedInvitations(items)
+ }).catch(e=>{
+ // update on error to empty array
+ setUnusedInvitations([])
+ })
+ },[community,token])
+
+ useEffect(()=>{
+ let abort = false
+ if (community && token){
+ loadUnusedInvitations()
+ }
+ return ()=>{abort=true}
+ },[community,token,loadUnusedInvitations])
+
+ const createInvitation = useCallback(async()=>{
+ if (community && user?.account){
+ const resp = await communityMaintainerLink({
+ community,
+ account: user?.account,
+ token
+ })
+ if (resp.status===201){
+ // set magic link prop to new link
+ setMagicLink(resp.message)
+ // reload unused invitations
+ loadUnusedInvitations()
+ }else{
+ showErrorMessage(`Failed to create invitation. ${resp.message}`)
+ }
+ }
+ // IGNORE showErrorMessage dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[community,user?.account,token,loadUnusedInvitations])
+
+ const deleteInvitation = useCallback(async(invitation:Invitation)=>{
+ const resp = await deleteMaintainerLink({
+ invitation,
+ token
+ })
+ if (resp.status===200){
+ loadUnusedInvitations()
+ }else{
+ showErrorMessage(`Failed to delete invitation. ${resp.message}`)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[token,loadUnusedInvitations])
+
+ return {
+ magicLink,
+ unusedInvitations,
+ deleteInvitation,
+ createInvitation
+ }
+}
diff --git a/frontend/components/communities/settings/maintainers/useCommunityMaintainers.tsx b/frontend/components/communities/settings/maintainers/useCommunityMaintainers.tsx
new file mode 100644
index 000000000..040e0f674
--- /dev/null
+++ b/frontend/components/communities/settings/maintainers/useCommunityMaintainers.tsx
@@ -0,0 +1,67 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useState,useEffect} from 'react'
+
+import {useSession} from '~/auth'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import {MaintainerProps, rawMaintainersToMaintainers} from '~/components/maintainers/apiMaintainers'
+import {deleteMaintainerFromCommunity, getMaintainersOfCommunity} from './apiCommunityMaintainers'
+
+export function useCommunityMaintainers({community}:{community?: string}) {
+ const {token} = useSession()
+ const {showErrorMessage} = useSnackbar()
+ const [maintainers, setMaintainers] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let abort = false
+ async function getMaintainers() {
+ if (community && token) {
+ setLoading(true)
+
+ const raw_maintainers = await getMaintainersOfCommunity({
+ community,
+ token
+ })
+ const maintainers = rawMaintainersToMaintainers(raw_maintainers)
+
+ if (abort) return null
+ // update maintainers state
+ setMaintainers(maintainers)
+ // update loading flag
+ setLoading(false)
+ }
+ }
+
+ getMaintainers()
+
+ return ()=>{abort=true}
+ }, [community,token])
+
+ async function deleteMaintainer(account?: string) {
+ // console.log('delete maintainer...pos...', pos)
+ if (account && community) {
+ const resp = await deleteMaintainerFromCommunity({
+ maintainer: account,
+ community,
+ token
+ })
+ if (resp.status === 200) {
+ // remove account
+ const newMaintainersList = maintainers.filter(item=>item.account!==account)
+ setMaintainers(newMaintainersList)
+ } else {
+ showErrorMessage(`Failed to remove maintainer. ${resp.message}`)
+ }
+ }
+ }
+
+ return {
+ loading,
+ maintainers,
+ deleteMaintainer
+ }
+}
diff --git a/frontend/components/communities/tabs/CommunityTabItems.tsx b/frontend/components/communities/tabs/CommunityTabItems.tsx
index 9a9ac794f..30f198a13 100644
--- a/frontend/components/communities/tabs/CommunityTabItems.tsx
+++ b/frontend/components/communities/tabs/CommunityTabItems.tsx
@@ -1,7 +1,5 @@
-// 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-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0
diff --git a/frontend/components/communities/tabs/index.tsx b/frontend/components/communities/tabs/index.tsx
index f8dad7c73..dc4f57a69 100644
--- a/frontend/components/communities/tabs/index.tsx
+++ b/frontend/components/communities/tabs/index.tsx
@@ -19,12 +19,10 @@ type CommunityTabsProps={
}
export default function CommunityTabs({
- tab,software_cnt,description,
- isMaintainer}:CommunityTabsProps) {
+ 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,
+ // create url
+ const url:any={
+ pathname:`/communities/[slug]/${value}`,
+ query:{
+ slug: router.query['slug']
+ }
}
// add default order for software and project tabs
if (value === 'software') {
- query['order'] = 'is_featured'
+ url.query['order'] = 'is_featured'
}
// push route change
- router.push({query},undefined,{scroll:false})
+ router.push(url,undefined,{scroll:false})
}}
aria-label="community tabs"
>
@@ -59,6 +60,9 @@ export default function CommunityTabs({
software_cnt,
})}
value={key}
+ sx={{
+ minWidth:'9rem'
+ }}
/>
}})}
diff --git a/frontend/components/feedback/FeedbackPanelButton.test.tsx b/frontend/components/feedback/FeedbackPanelButton.test.tsx
index 10b1060db..e681aa371 100644
--- a/frontend/components/feedback/FeedbackPanelButton.test.tsx
+++ b/frontend/components/feedback/FeedbackPanelButton.test.tsx
@@ -1,5 +1,9 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
-import {render, screen} from '@testing-library/react'
+import {fireEvent, render, screen, within} from '@testing-library/react'
import FeedbackPanelButton from './FeedbackPanelButton'
// todo: add tests for FeedbackPanelButton
@@ -10,6 +14,27 @@ it('it has feedback button', () => {
issues_page_url="test-issue-url"
/>
)
- const btn = screen.getByRole('button',{name:'Send feedback'})
+ const btn = screen.getByTestId('feedback-button')
expect(btn).toBeInTheDocument()
+ expect(btn).toBeEnabled()
+})
+
+it('shows feedback dialog with cancel and sendfeedback', () => {
+ render(
+
+ )
+ // click feedback button
+ const btn = screen.getByTestId('feedback-button')
+ fireEvent.click(btn)
+
+
+ const dialog = screen.getByRole('dialog')
+ // has cancel button
+ const cancel = within(dialog).getByRole('button',{name:'Cancel'})
+ // has save link
+ const send = within(dialog).getByRole('link',{name:'Send feedback'})
+ // screen.debug()
})
diff --git a/frontend/components/feedback/FeedbackPanelButton.tsx b/frontend/components/feedback/FeedbackPanelButton.tsx
index 4802c07b0..830270bf5 100644
--- a/frontend/components/feedback/FeedbackPanelButton.tsx
+++ b/frontend/components/feedback/FeedbackPanelButton.tsx
@@ -1,19 +1,18 @@
// 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 * as React from 'react'
import {useState} from 'react'
-import {MailOutlineOutlined} from '@mui/icons-material'
+import MailOutlineOutlined from '@mui/icons-material/MailOutlineOutlined'
import Dialog from '@mui/material/Dialog'
import useMediaQuery from '@mui/material/useMediaQuery'
import {useTheme} from '@mui/material/styles'
import LinkIcon from '@mui/icons-material/Link'
import WebIcon from '@mui/icons-material/Web'
-import Divider from '@mui/material/Divider'
import CaretIcon from '~/components/icons/caret.svg'
import getBrowser from '~/utils/getBrowser'
@@ -63,20 +62,14 @@ User Agent: ${navigator.userAgent}`
return (
- {/* If desktop size */}
-
Feedback
- {/*If mobile size */}
-
+ }
+
+ // rsd_admin has full access
+ if (status === 'authenticated' && role==='rsd_admin') {
+ logger('ProtectedContent...authenticated user...rsd_admin', 'info')
+ return children
+ }
+
+ // authenticated maintainer has access
+ if (status === 'authenticated' && isMaintainer===true) {
+ logger('ProtectedContent...authenticated user...isMaintainer', 'info')
+ return children
+ }
+
+ // 403 FORBIDDEN
+ if (status === 'authenticated' && isMaintainer===false){
+ logger('ProtectedContent...403...FORBIDDEN', 'info')
+ return (
+
+ )
+ }
+
+ // ELSE 401
+ logger('ProtectedContent...401...FORBIDDEN', 'info')
+ return (
+
+ )
+}
diff --git a/frontend/components/maintainers/InvitationList.tsx b/frontend/components/maintainers/InvitationList.tsx
new file mode 100644
index 000000000..614ab9c29
--- /dev/null
+++ b/frontend/components/maintainers/InvitationList.tsx
@@ -0,0 +1,98 @@
+// SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center
+// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
+// SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2022 dv4all
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import List from '@mui/material/List'
+import ListItem from '@mui/material/ListItem'
+import CopyIcon from '@mui/icons-material/ContentCopy'
+import DeleteIcon from '@mui/icons-material/Delete'
+import EmailIcon from '@mui/icons-material/Email'
+import IconButton from '@mui/material/IconButton'
+import ListItemText from '@mui/material/ListItemText'
+
+import copyToClipboard from '~/utils/copyToClipboard'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import EditSectionTitle from '~/components/layout/EditSectionTitle'
+import {Invitation} from './apiMaintainers'
+
+
+type InvitationListProps={
+ subject:string
+ body:string
+ invitations: Invitation[],
+ onDelete: (invitation:Invitation)=>Promise
+}
+
+export default function InvitationList({subject,body,invitations,onDelete}:InvitationListProps) {
+ const {showErrorMessage, showInfoMessage} = useSnackbar()
+
+ async function toClipboard(message?: string) {
+ if (message) {
+ const copied = await copyToClipboard(message)
+ // notify user about copy action
+ if (copied) {
+ showInfoMessage('Copied to clipboard')
+ } else {
+ showErrorMessage(`Failed to copy link ${message}`)
+ }
+ }
+ }
+
+ if(invitations.length === 0) return null
+
+ return (
+ <>
+
+
+ {invitations.map(inv => {
+ const currentLink = `${location.origin}/invite/${inv.type}/${inv.id}`
+ return (
+
+
+
+
+ toClipboard(currentLink)}>
+
+
+ onDelete(inv)}>
+
+
+
+ }
+ sx={{
+ // make space for 3 buttons
+ paddingRight:'9rem'
+ }}
+ >
+
+
+ )
+ })}
+
+ >
+ )
+}
diff --git a/frontend/components/projects/edit/maintainers/ProjectMaintainer.tsx b/frontend/components/maintainers/MaintainerItem.tsx
similarity index 81%
rename from frontend/components/projects/edit/maintainers/ProjectMaintainer.tsx
rename to frontend/components/maintainers/MaintainerItem.tsx
index 7877cfd8b..8a8cf738a 100644
--- a/frontend/components/projects/edit/maintainers/ProjectMaintainer.tsx
+++ b/frontend/components/maintainers/MaintainerItem.tsx
@@ -1,5 +1,7 @@
// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 dv4all
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0
@@ -9,20 +11,19 @@ import ListItemAvatar from '@mui/material/ListItemAvatar'
import DeleteIcon from '@mui/icons-material/Delete'
import IconButton from '@mui/material/IconButton'
-import {MaintainerOfProject} from './useProjectMaintainer'
-import ContributorAvatar from '~/components/software/ContributorAvatar'
import {getDisplayInitials, splitName} from '~/utils/getDisplayName'
+import ContributorAvatar from '~/components/software/ContributorAvatar'
+import {MaintainerProps} from './apiMaintainers'
-type ProjectMaintainerProps = {
+type MaintainerItemProps = {
pos:number
- maintainer: MaintainerOfProject
- onEdit: (pos: number) => void
+ maintainer: MaintainerProps
onDelete: (pos: number) => void
disableDelete?: boolean
}
-export default function ProjectMaintainer({pos, maintainer, onEdit, onDelete, disableDelete}: ProjectMaintainerProps) {
+export default function MaintainerItem({pos, maintainer, onDelete, disableDelete}: MaintainerItemProps) {
const {name, email, affiliation} = maintainer
const displayInitials = getDisplayInitials(splitName(name))
return (
diff --git a/frontend/components/maintainers/MaintainersList.tsx b/frontend/components/maintainers/MaintainersList.tsx
new file mode 100644
index 000000000..c9b5da27d
--- /dev/null
+++ b/frontend/components/maintainers/MaintainersList.tsx
@@ -0,0 +1,52 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import Alert from '@mui/material/Alert'
+import AlertTitle from '@mui/material/AlertTitle'
+import List from '@mui/material/List'
+
+import MaintainerItem from './MaintainerItem'
+import {MaintainerProps} from './apiMaintainers'
+
+
+type MaintainerListProps = {
+ maintainers: MaintainerProps[]
+ onDelete:(pos:number)=>void
+}
+
+export default function MaintainersList({maintainers,onDelete}:MaintainerListProps) {
+
+ if (maintainers.length === 0) {
+ return (
+
+ No maintainers
+ Add maintainer by using invite link button!
+
+ )
+ }
+
+ function renderList() {
+ return maintainers.map((item, pos) => {
+ return (
+
+ )
+ })
+ }
+
+ return (
+
+ {renderList()}
+
+ )
+}
diff --git a/frontend/components/maintainers/apiMaintainers.ts b/frontend/components/maintainers/apiMaintainers.ts
new file mode 100644
index 000000000..bd47b7352
--- /dev/null
+++ b/frontend/components/maintainers/apiMaintainers.ts
@@ -0,0 +1,122 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import logger from '~/utils/logger'
+import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers'
+
+export type RawMaintainerProps = {
+ // unique maintainer id
+ maintainer: string
+ name: string[]
+ email: string[]
+ affiliation: string[],
+ is_primary?: boolean
+}
+
+export type MaintainerProps = {
+ // unique maintainer id
+ account: string
+ name: string
+ email: string
+ affiliation: string,
+ // primary maintainer cannot be deleted
+ // last maintainer can be deleted only by rsd-admin
+ disableDelete?: boolean
+}
+
+export function rawMaintainersToMaintainers(raw_maintainers: RawMaintainerProps[]) {
+ try {
+ const maintainers:MaintainerProps[] = []
+ raw_maintainers.forEach(item => {
+ let maintainerWithMostInfo: MaintainerProps | null = null
+ let bestScore = -1
+ // use name as second loop indicator
+ item.name.forEach((name, pos) => {
+ let score = 0
+ if (name) {
+ score += 1
+ }
+ if (item.email[pos]) {
+ score += 1
+ }
+ if (item.affiliation[pos]) {
+ score += 1
+ }
+
+ if (score <= bestScore) {
+ return
+ }
+ const maintainer: MaintainerProps = {
+ account: item.maintainer,
+ name,
+ email: item.email[pos] ? item.email[pos] : '',
+ affiliation: item.affiliation[pos] ? item.affiliation[pos] : '',
+ // primary maintainer cannot be deleted
+ disableDelete: item?.is_primary ?? false
+ }
+
+ maintainerWithMostInfo = maintainer
+ bestScore = score
+ })
+ maintainers.push(maintainerWithMostInfo as unknown as MaintainerProps)
+ })
+ return maintainers
+ } catch (e:any) {
+ logger(`rawMaintainersToMaintainers: ${e?.message}`,'error')
+ return []
+ }
+}
+
+export async function deleteMaintainerLink({invitation,token}:{invitation: Invitation,token:string}) {
+ try{
+ const query = `invite_maintainer_for_${invitation.type}?id=eq.${invitation.id}`
+ const url = `${getBaseUrl()}/${query}`
+ const resp = await fetch(url, {
+ headers: createJsonHeaders(token),
+ method: 'DELETE'
+ })
+ return extractReturnMessage(resp)
+ }catch(e:any){
+ return {
+ status:500,
+ message: e.message
+ }
+ }
+}
+
+export type InvitationType = 'software' | 'project' | 'organisation' | 'community'
+
+export type Invitation = {
+ id: string,
+ created_at: string,
+ expires_at: string,
+ type: InvitationType
+}
+
+type getUnusedInvitationsProps={
+ id: string,
+ type: InvitationType
+ token?: string
+}
+
+export async function getUnusedInvitations({id,type,token}:getUnusedInvitationsProps) {
+ try{
+ const query = `invite_maintainer_for_${type}?select=id,created_at,expires_at&order=created_at&${type}=eq.${id}&claimed_by=is.null&claimed_at=is.null`
+
+ const url = `${getBaseUrl()}/${query}`
+
+ const resp = await fetch(url, {
+ headers: createJsonHeaders(token)
+ })
+ const invitations: Invitation[] = await resp.json()
+ invitations.forEach(invitation => {
+ invitation.type = type
+ })
+ return invitations
+ }catch(e:any){
+ logger(`getUnusedInvitations: ${e?.message}`,'error')
+ return []
+ }
+}
diff --git a/frontend/components/organisation/ProtectedOrganisationPage.tsx b/frontend/components/organisation/ProtectedOrganisationPage.tsx
deleted file mode 100644
index e2baff747..000000000
--- a/frontend/components/organisation/ProtectedOrganisationPage.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2022 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import {useSession} from '~/auth'
-import PageErrorMessage from '../layout/PageErrorMessage'
-
-type ProtectOrganisationPageProps={
- children: any,
- isMaintainer: boolean
-}
-
-export default function ProtectedOrganisationPage({children,isMaintainer}:ProtectOrganisationPageProps) {
- const {status,user} = useSession()
- // not authenticated
- if (status !== 'authenticated') {
- return (
-
- )
- }
- // authenticated but not the maintainer or rsd_admin = 403
- if (status === 'authenticated' &&
- user?.role !== 'rsd_admin' &&
- isMaintainer === false) {
- return (
-
- )
- }
- // authenticated mantainer or rsd_admin
- // can see the content (children)
- return children
-}
diff --git a/frontend/components/organisation/about/index.tsx b/frontend/components/organisation/about/index.tsx
index 07f11e098..b8ad614a0 100644
--- a/frontend/components/organisation/about/index.tsx
+++ b/frontend/components/organisation/about/index.tsx
@@ -1,7 +1,7 @@
// 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
@@ -27,7 +27,7 @@ export function AboutPagePlaceholder() {
About section not defined
- The about section is not visible to vistors because it does not have any content.
+ The about section is not visible to visitors because it does not have any content.
To activate the about section, add content to the about section
in the settings.
@@ -43,8 +43,8 @@ export default function AboutPage() {
if (description) {
return (
({
})
export function OrganisationProvider(props: any) {
- // desctucture organisation
+ // destucture organisation
const {organisation:initOrganisation, isMaintainer:initMaintainer} = props
// set state - use initOrganisation at start
const [organisation, setOrganisation] = useState(initOrganisation)
diff --git a/frontend/components/organisation/settings/SettingsNav.tsx b/frontend/components/organisation/settings/SettingsNav.tsx
index 46856463e..390eb6b2f 100644
--- a/frontend/components/organisation/settings/SettingsNav.tsx
+++ b/frontend/components/organisation/settings/SettingsNav.tsx
@@ -14,7 +14,7 @@ import ListItemText from '@mui/material/ListItemText'
import {editMenuItemButtonSx} from '~/config/menuItems'
import {settingsMenu} from './SettingsNavItems'
-export default function SettingsNav() {
+export default function OrganisationSettingsNav() {
const router = useRouter()
const settings = router.query['settings'] ?? 'general'
// console.group('OrganisationNav')
diff --git a/frontend/components/organisation/settings/general/AutosaveOrganisationTextField.tsx b/frontend/components/organisation/settings/general/AutosaveOrganisationTextField.tsx
index 0ef55708b..6dc9eaf2a 100644
--- a/frontend/components/organisation/settings/general/AutosaveOrganisationTextField.tsx
+++ b/frontend/components/organisation/settings/general/AutosaveOrganisationTextField.tsx
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 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-FileCopyrightText: 2024 Christian Meeßen (GFZ)
// SPDX-FileCopyrightText: 2024 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
//
@@ -40,7 +40,7 @@ export default function AutosaveOrganisationTextField({options,rules}:AutosaveOr
token
})
- // console.group('AutosaveSoftwareTextField')
+ // console.group('AutosaveOrganisationTextField')
// console.log('saved...', options.name)
// console.log('value...', value)
// console.log('status...', resp?.status)
diff --git a/frontend/components/organisation/settings/index.tsx b/frontend/components/organisation/settings/index.tsx
index 3c9601eee..7be04817d 100644
--- a/frontend/components/organisation/settings/index.tsx
+++ b/frontend/components/organisation/settings/index.tsx
@@ -7,20 +7,23 @@
//
// SPDX-License-Identifier: Apache-2.0
-import ProtectedOrganisationPage from '../ProtectedOrganisationPage'
+import {useSession} from '~/auth'
import UserAgreementModal from '~/components/user/settings/UserAgreementModal'
import useOrganisationContext from '../context/useOrganisationContext'
import BaseSurfaceRounded from '~/components/layout/BaseSurfaceRounded'
import SettingsNav from './SettingsNav'
import SettingsPageContent from './SettingsPageContent'
+import ProtectedContent from '~/components/layout/ProtectedContent'
-// const formId='organisation-settings-form'
export default function OrganisationSettings() {
const {isMaintainer} = useOrganisationContext()
+ const {status,user} = useSession()
return (
-
@@ -33,6 +36,6 @@ export default function OrganisationSettings() {
{/* dynamic load of settings page */}
-
+
)
}
diff --git a/frontend/components/organisation/settings/maintainers/OrganisationMaintainerLink.tsx b/frontend/components/organisation/settings/maintainers/OrganisationMaintainerLink.tsx
deleted file mode 100644
index f23af1385..000000000
--- a/frontend/components/organisation/settings/maintainers/OrganisationMaintainerLink.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-// SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2022 dv4all
-// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center)
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import {useEffect, useState} from 'react'
-import Button from '@mui/material/Button'
-import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
-import EmailIcon from '@mui/icons-material/Email'
-import CopyIcon from '@mui/icons-material/ContentCopy'
-
-import {copyToClipboard,canCopyToClipboard} from '~/utils/copyToClipboard'
-import useSnackbar from '~/components/snackbar/useSnackbar'
-import {organisationMaintainerLink} from './useOrganisationMaintainers'
-import InvitationList from '~/components/layout/InvitationList'
-import {Invitation} from '~/types/Invitation'
-import {getUnusedInvitations} from '~/utils/getUnusedInvitations'
-import CopyToClipboard from '~/components/layout/CopyToClipboard'
-
-export default function OrganisationMaintainerLink({organisation, name, account, token}:
- {organisation: string, name: string, account: string, token: string}) {
- const {showErrorMessage,showInfoMessage} = useSnackbar()
- const [magicLink, setMagicLink] = useState(null)
- const [unusedInvitations, setUnusedInvitations] = useState([])
- const canCopy = useState(canCopyToClipboard())
-
- async function fetchUnusedInvitations() {
- setUnusedInvitations(await getUnusedInvitations('organisation', organisation, token))
- }
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
- useEffect(() => {fetchUnusedInvitations()}, [])
-
- async function createInviteLink() {
- const resp = await organisationMaintainerLink({
- organisation,
- account,
- token
- })
- if (resp.status === 201) {
- setMagicLink(resp.message)
- fetchUnusedInvitations()
- } else {
- showErrorMessage(`Failed to generate maintainer link. ${resp.message}`)
- }
- }
-
- async function toClipboard() {
- if (magicLink) {
- // copy doi to clipboard
- const copied = await copyToClipboard(magicLink)
- // notify user about copy action
- if (copied) {
- showInfoMessage('Copied to clipboard')
- } else {
- showErrorMessage(`Failed to copy link ${magicLink}`)
- }
- }
- }
-
- function renderLinkOptions() {
- if (magicLink) {
- return (
-
- )
- }
- return null
- }
-
- return (
- <>
- }
- onClick={createInviteLink}
- >
- Generate invite link
-
- {renderLinkOptions()}
-
- fetchUnusedInvitations()}
- />
- >
- )
-}
diff --git a/frontend/components/organisation/settings/maintainers/OrganisationMaintainerLinks.tsx b/frontend/components/organisation/settings/maintainers/OrganisationMaintainerLinks.tsx
new file mode 100644
index 000000000..c5ef5c6db
--- /dev/null
+++ b/frontend/components/organisation/settings/maintainers/OrganisationMaintainerLinks.tsx
@@ -0,0 +1,50 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import Button from '@mui/material/Button'
+import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
+
+import InvitationList from '~/components/maintainers/InvitationList'
+import useOrganisationContext from '~/components/organisation/context/useOrganisationContext'
+import {useOrganisationInvitations} from './useOrganisationInvitations'
+
+export default function OrganisationMaintainerLinks() {
+ const {id,name} = useOrganisationContext()
+ const {
+ unusedInvitations,magicLink,
+ createInvitation,deleteInvitation
+ } = useOrganisationInvitations({organisation:id})
+
+ // console.group('OrganisationMaintainerLinks')
+ // console.log('id...', id)
+ // console.log('name...', name)
+ // console.log('magicLink...', magicLink)
+ // console.log('unusedInvitations...', unusedInvitations)
+ // console.groupEnd()
+
+ return (
+ <>
+ }
+ onClick={createInvitation}
+ >
+ Generate invite link
+
+
+
+ >
+ )
+}
diff --git a/frontend/components/organisation/settings/maintainers/OrganisationMaintainersIndex.test.tsx b/frontend/components/organisation/settings/maintainers/OrganisationMaintainersIndex.test.tsx
index 761526846..8056e2050 100644
--- a/frontend/components/organisation/settings/maintainers/OrganisationMaintainersIndex.test.tsx
+++ b/frontend/components/organisation/settings/maintainers/OrganisationMaintainersIndex.test.tsx
@@ -1,12 +1,12 @@
// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 - 2023 dv4all
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
+// 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 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0
-import {render, screen, waitForElementToBeRemoved} from '@testing-library/react'
+import {render, screen, within} from '@testing-library/react'
import {WithAppContext, mockSession} from '~/utils/jest/WithAppContext'
import {WithOrganisationContext} from '~/utils/jest/WithOrganisationContext'
@@ -15,30 +15,50 @@ import OrganisationMaintainers from './index'
import mockOrganisation from '../../__mocks__/mockOrganisation'
// MOCK user agreement call
-jest.mock('~/components/user/settings/useUserAgreements')
-
-const mockMaintainerOfOrganisation=jest.fn,any[]>((props)=>Promise.resolve([]))
-jest.mock('./getMaintainersOfOrganisation', () => ({
- getMaintainersOfOrganisation:jest.fn((props)=>mockMaintainerOfOrganisation(props))
+// jest.mock('~/components/user/settings/useUserAgreements')
+
+// MOCK useOrganisationMaintainers hook
+const mockDeleteMaintainer = jest.fn()
+const dummyMaintainersData={
+ loading: false,
+ maintainers:[],
+ deleteMaintainer: mockDeleteMaintainer
+}
+const mockUseOrganisationMaintainers = jest.fn((props)=>dummyMaintainersData)
+jest.mock('./useOrganisationMaintainers', () => ({
+ useOrganisationMaintainers:jest.fn((props)=>mockUseOrganisationMaintainers(props))
}))
-// MOCK unused invitations api call
-const getUnusedInvitations = jest.fn((props) => {
- // console.log('mocked...getUnusedInvitations...props...', props)
- return Promise.resolve([])
-})
-// Mock
-jest.mock('~/utils/getUnusedInvitations', () => {
- return {
- getUnusedInvitations:jest.fn((props)=>getUnusedInvitations(props))
- }
-})
+// MOCK useOrganisationInvitations hook
+const mockCreateInvitation = jest.fn()
+const mockDeleteInvitation = jest.fn()
+const dummyInvitationData={
+ magicLink: null,
+ unusedInvitations:[],
+ createInvitation: mockCreateInvitation,
+ deleteInvitation: mockDeleteInvitation
+}
+const mockUseOrganisationInvitations = jest.fn((props)=>dummyInvitationData)
+jest.mock('./useOrganisationInvitations', () => ({
+ useOrganisationInvitations:jest.fn((props)=>mockUseOrganisationInvitations(props))
+}))
const dummyProps = {
organisation: mockOrganisation,
isMaintainer: false
}
+const mockMaintainers = [
+ {account: 'test-account-id-1', name: 'John Doe 1', email: 'test1@email.com', affiliation: 'Company 1', disableDelete: false},
+ {account: 'test-account-id-2', name: 'John Doe 2', email: null, affiliation: null, disableDelete: false},
+]
+
+const dummyInvitations = [
+ {id:'test-link-id-1',created_at: new Date().toISOString(),type:'community'},
+ {id:'test-link-id-2',created_at: new Date().toISOString(),type:'community'},
+ {id:'test-link-id-3',created_at: new Date().toISOString(),type:'community'}
+]
+
describe('frontend/components/organisation/maintainers/index.tsx', () => {
beforeEach(() => {
@@ -46,25 +66,15 @@ describe('frontend/components/organisation/maintainers/index.tsx', () => {
jest.clearAllMocks()
})
-
- it('shows 401 when no token provided', () => {
- render(
-
-
-
- )
- const msg401 = screen.getByRole('heading', {
- name: '401'
- })
- expect(msg401).toBeInTheDocument()
- })
-
- it('shows 403 when user is not organisation maintainer', async () => {
+ it('shows loader when hook returns loading=true', () => {
// user is authenticated
defaultSession.status = 'authenticated'
defaultSession.token = 'test-token'
- // it is not maintainer of this organisation
- dummyProps.isMaintainer = false
+ // it is maintainer of this organisation
+ dummyProps.isMaintainer = true
+ dummyMaintainersData.loading = true
+ // mock hook return with loading true
+ mockUseOrganisationMaintainers.mockReturnValueOnce(dummyMaintainersData)
render(
@@ -73,22 +83,20 @@ describe('frontend/components/organisation/maintainers/index.tsx', () => {
)
- const msg403 = await screen.findByRole('heading', {
- name: '403'
- })
- expect(msg403).toBeInTheDocument()
+
+ const loader = screen.getByRole('progressbar')
+ expect(loader).toBeInTheDocument()
})
it('shows "No maintainers" message', async () => {
// user is authenticated
defaultSession.status = 'authenticated'
defaultSession.token = 'test-token'
-
// it is maintainer of this organisation
dummyProps.isMaintainer = true
-
- // mock empty response array
- mockMaintainerOfOrganisation.mockResolvedValueOnce([])
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseOrganisationMaintainers.mockReturnValueOnce(dummyMaintainersData)
render(
@@ -101,78 +109,85 @@ describe('frontend/components/organisation/maintainers/index.tsx', () => {
expect(noMaintainers).toBeInTheDocument()
})
- it('shows maintainer list item', async () => {
- // MOCK maintainers call
- const dummyRawMaintainers = [{
- 'maintainer': 'a050aaf3-9c46-490c-ade3-aeeb6a05b1d1',
- 'name': ['Jordan Ross Belfort'],
- 'email': ['Jordan.Belfort@harvard-example.edu'],
- 'affiliation': ['harvard-example.edu'],
- 'is_primary': false
- }]
- // mock empty response array
- mockMaintainerOfOrganisation.mockResolvedValueOnce(dummyRawMaintainers)
- // user is authenticated
- defaultSession.status = 'authenticated'
- defaultSession.token = 'test-token'
+ it('renders component with "Generate invite link" button', () => {
// it is maintainer of this organisation
dummyProps.isMaintainer = true
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseOrganisationMaintainers.mockReturnValueOnce(dummyMaintainersData)
render(
-
+
+ {/* */}
)
- await waitForElementToBeRemoved(() => screen.getByRole('progressbar'))
-
- const maintainer = await screen.findByText(dummyRawMaintainers[0].name[0])
- expect(maintainer).toBeInTheDocument()
+ const inviteBtn = screen.getByRole('button', {name: 'Generate invite link'})
+ expect(inviteBtn).toBeInTheDocument()
+ // screen.debug()
})
- it('renders component with "Generate invite link" button', async () => {
+ it('shows maintainer name (in the list)', async () => {
+ // MOCK maintainers call
+ const dummyRawMaintainers = [{
+ 'maintainer': 'a050aaf3-9c46-490c-ade3-aeeb6a05b1d1',
+ 'name': 'Jordan Ross Belfort',
+ 'email': 'Jordan.Belfort@harvard-example.edu',
+ 'affiliation': 'harvard-example.edu',
+ 'is_primary': false
+ }]
+ // user is authenticated
+ defaultSession.status = 'authenticated'
+ defaultSession.token = 'test-token'
+ dummyMaintainersData.maintainers = dummyRawMaintainers as any
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseOrganisationMaintainers.mockReturnValueOnce(dummyMaintainersData)
// it is maintainer of this organisation
dummyProps.isMaintainer = true
render(
-
+
- {/* */}
)
- // wait loader to be removed
- await waitForElementToBeRemoved(screen.getByRole('progressbar'))
- const inviteBtn = screen.getByRole('button', {name: 'Generate invite link'})
- expect(inviteBtn).toBeInTheDocument()
- // screen.debug()
+ const maintainer = await screen.findByText(dummyRawMaintainers[0].name)
+ expect(maintainer).toBeInTheDocument()
})
- it('protects maintainer page when isMaintainer=false', async () => {
+ it('shows maintainer list with all items', () => {
+ dummyMaintainersData.maintainers = mockMaintainers as any
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseOrganisationMaintainers.mockReturnValueOnce(dummyMaintainersData)
// it is maintainer of this organisation
- dummyProps.isMaintainer = false
+ dummyProps.isMaintainer = true
render(
- {/* */}
)
- // wait loader to be removed
- await waitForElementToBeRemoved(screen.getByRole('progressbar'))
- const page403 = screen.getByRole('heading', {name: '403'})
- expect(page403).toBeInTheDocument()
- // screen.debug()
+ const maintainerItem = screen.getAllByTestId('maintainer-list-item')
+ expect(maintainerItem.length).toEqual(mockMaintainers.length)
})
- it('shows no maintainer message when maintainers=[]', async () => {
+ it('maintainer cannot be deleted when disableDelete===true', () => {
+ // set disable flag on first item
+ mockMaintainers[0].disableDelete = true
+ dummyMaintainersData.maintainers = mockMaintainers as any
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseOrganisationMaintainers.mockReturnValueOnce(dummyMaintainersData)
// it is maintainer of this organisation
dummyProps.isMaintainer = true
@@ -183,21 +198,19 @@ describe('frontend/components/organisation/maintainers/index.tsx', () => {
)
- // wait loader to be removed
- await waitForElementToBeRemoved(screen.getByRole('progressbar'))
- const noItemsMsg = screen.getByText('No maintainers')
- expect(noItemsMsg).toBeInTheDocument()
+ const maintainerItem = screen.getAllByTestId('maintainer-list-item')
+ const deleteBtn = within(maintainerItem[0]).getByRole('button',{name:'delete'})
+ expect(deleteBtn).toBeDisabled()
})
- it('shows maintainer list', async () => {
- const mockMaintainers = [
- {account: 'test-account-id', name: ['John Doe'], email: [], affiliation: [], is_primary: false},
- {account: 'test-account-id', name: ['John Doe'], email: [], affiliation: [], is_primary: false},
- ]
- mockMaintainerOfOrganisation.mockResolvedValueOnce(mockMaintainers)
+ it('shows unused links list',()=>{
// it is maintainer of this organisation
dummyProps.isMaintainer = true
+ dummyInvitationData.magicLink = null
+ dummyInvitationData.unusedInvitations = dummyInvitations as any
+
+ mockUseOrganisationInvitations.mockReturnValueOnce(dummyInvitationData)
render(
@@ -206,11 +219,10 @@ describe('frontend/components/organisation/maintainers/index.tsx', () => {
)
- // wait loader to be removed
- await waitForElementToBeRemoved(screen.getByRole('progressbar'))
- const maintainerItem = screen.getAllByTestId('maintainer-list-item')
- expect(maintainerItem.length).toEqual(mockMaintainers.length)
+ // check number of unused invitations
+ const unusedInvites = screen.getAllByTestId('unused-invitation-item')
+ expect(unusedInvites.length).toEqual(dummyInvitations.length)
})
})
diff --git a/frontend/components/organisation/settings/maintainers/OrganisationMaintainersList.tsx b/frontend/components/organisation/settings/maintainers/OrganisationMaintainersList.tsx
deleted file mode 100644
index b4a3f5d9c..000000000
--- a/frontend/components/organisation/settings/maintainers/OrganisationMaintainersList.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2022 dv4all
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import Alert from '@mui/material/Alert'
-import AlertTitle from '@mui/material/AlertTitle'
-import List from '@mui/material/List'
-import logger from '~/utils/logger'
-
-import ProjectMaintainer from '~/components/projects/edit/maintainers/ProjectMaintainer'
-import {MaintainerOfOrganisation} from './useOrganisationMaintainers'
-
-type ProjectMaintainerListProps = {
- maintainers: MaintainerOfOrganisation[]
- onDelete:(pos:number)=>void
-}
-
-export default function OrganisationMaintainersList({maintainers,onDelete}:ProjectMaintainerListProps) {
-
- if (maintainers.length === 0) {
- return (
-
- No maintainers
- Add project mantainer by using invite link button!
-
- )
- }
-
- function onEdit(pos:number) {
- logger('onEdit...NOT SUPPORTED FOR MAINTAINERS','info')
- }
-
- function renderList() {
- return maintainers.map((item, pos) => {
- return (
-
- )
- })
- }
-
- // console.log('OrganisationMaintainersList...maintainers...', maintainers)
-
- return (
-
- {renderList()}
-
- )
-}
diff --git a/frontend/components/organisation/settings/maintainers/getMaintainersOfOrganisation.test.ts b/frontend/components/organisation/settings/maintainers/apiOrganisationMaintainers.test.ts
similarity index 85%
rename from frontend/components/organisation/settings/maintainers/getMaintainersOfOrganisation.test.ts
rename to frontend/components/organisation/settings/maintainers/apiOrganisationMaintainers.test.ts
index 10f9e941a..a41eee2f8 100644
--- a/frontend/components/organisation/settings/maintainers/getMaintainersOfOrganisation.test.ts
+++ b/frontend/components/organisation/settings/maintainers/apiOrganisationMaintainers.test.ts
@@ -1,11 +1,11 @@
// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 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 {getMaintainersOfOrganisation} from './getMaintainersOfOrganisation'
+import {getMaintainersOfOrganisation} from './apiOrganisationMaintainers'
import {mockResolvedValueOnce} from '~/utils/jest/mockFetch'
const mockResponse = [{
diff --git a/frontend/components/organisation/settings/maintainers/apiOrganisationMaintainers.ts b/frontend/components/organisation/settings/maintainers/apiOrganisationMaintainers.ts
new file mode 100644
index 000000000..87fedd9b8
--- /dev/null
+++ b/frontend/components/organisation/settings/maintainers/apiOrganisationMaintainers.ts
@@ -0,0 +1,95 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import logger from '~/utils/logger'
+import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers'
+import {RawMaintainerProps} from '~/components/maintainers/apiMaintainers'
+
+export async function getMaintainersOfOrganisation({organisation, token}:
+ { organisation: string, token: string}) {
+ try {
+ // console.log('getMaintainersOfOrganisation.organisation', organisation)
+ const query = `rpc/maintainers_of_organisation?organisation_id=${organisation}`
+ const url = `${getBaseUrl()}/${query}`
+
+ const resp = await fetch(url, {
+ method: 'GET',
+ headers: createJsonHeaders(token)
+ })
+
+ if (resp.status === 200) {
+ const json: RawMaintainerProps[] = await resp.json()
+ return json
+ }
+ // ERRORS
+ logger(`getMaintainersOfOrganisation: ${resp.status}:${resp.statusText} organisation: ${organisation}`, 'warn')
+ return []
+ } catch (e: any) {
+ logger(`getMaintainersOfOrganisation: ${e?.message}`, 'error')
+ return []
+ }
+}
+
+export async function deleteMaintainerFromOrganisation({maintainer,organisation,token}:
+ {maintainer:string,organisation:string,token:string}) {
+ try {
+ const query = `maintainer_for_organisation?maintainer=eq.${maintainer}&organisation=eq.${organisation}`
+ const url = `${getBaseUrl()}/${query}`
+
+ const resp = await fetch(url, {
+ method: 'DELETE',
+ headers: createJsonHeaders(token)
+ })
+
+ return extractReturnMessage(resp)
+
+ } catch (e: any) {
+ logger(`deleteMaintainerFromSoftware: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
+
+export async function organisationMaintainerLink({organisation, account, token}:
+ { organisation: string, account: string, token: string }) {
+ try {
+ // POST
+ const url = `${getBaseUrl()}/invite_maintainer_for_organisation`
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: {
+ ...createJsonHeaders(token),
+ 'Prefer': 'return=headers-only'
+ },
+ body: JSON.stringify({
+ organisation,
+ created_by:account
+ })
+ })
+ if (resp.status === 201) {
+ const id = resp.headers.get('location')?.split('.')[1]
+ if (id) {
+ const link = `${location.origin}/invite/organisation/${id}`
+ return {
+ status: 201,
+ message: link
+ }
+ }
+ return {
+ status: 400,
+ message: 'Id is missing'
+ }
+ }
+ return extractReturnMessage(resp, organisation ?? '')
+ } catch (e: any) {
+ logger(`organisationMaintainerLink: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
diff --git a/frontend/components/organisation/settings/maintainers/getMaintainersOfOrganisation.ts b/frontend/components/organisation/settings/maintainers/getMaintainersOfOrganisation.ts
deleted file mode 100644
index 3626173fb..000000000
--- a/frontend/components/organisation/settings/maintainers/getMaintainersOfOrganisation.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2022 dv4all
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import {createJsonHeaders} from '~/utils/fetchHelpers'
-import logger from '~/utils/logger'
-import {RawMaintainerOfOrganisation} from './useOrganisationMaintainers'
-
-export async function getMaintainersOfOrganisation({organisation, token, frontend = true}:
- { organisation: string, token: string, frontend?: boolean }) {
- try {
- // console.log('getMaintainersOfOrganisation.organisation', organisation)
- let query = `rpc/maintainers_of_organisation?organisation_id=${organisation}`
- let url = `/api/v1/${query}`
- if (frontend === false) {
- url = `${process.env.POSTGREST_URL}/${query}`
- }
-
- const resp = await fetch(url, {
- method: 'GET',
- headers: createJsonHeaders(token)
- })
-
- if (resp.status === 200) {
- const json: RawMaintainerOfOrganisation[] = await resp.json()
- return json
- }
- // ERRORS
- logger(`getMaintainersOfOrganisation: ${resp.status}:${resp.statusText} organisation: ${organisation}`, 'warn')
- return []
- } catch (e: any) {
- logger(`getMaintainersOfOrganisation: ${e?.message}`, 'error')
- return []
- }
-}
diff --git a/frontend/components/organisation/settings/maintainers/index.tsx b/frontend/components/organisation/settings/maintainers/index.tsx
index ce1e6e0ea..dbf90a273 100644
--- a/frontend/components/organisation/settings/maintainers/index.tsx
+++ b/frontend/components/organisation/settings/maintainers/index.tsx
@@ -1,53 +1,44 @@
// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2022 - 2023 Netherlands eScience Center
// SPDX-FileCopyrightText: 2022 - 2023 dv4all
+// SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center
// SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2023 Christian Meeßen (GFZ)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
//
// SPDX-License-Identifier: Apache-2.0
import {useState} from 'react'
-import {useSession} from '~/auth'
import ContentLoader from '~/components/layout/ContentLoader'
import EditSectionTitle from '~/components/layout/EditSectionTitle'
import {maintainers as config} from '~/components/projects/edit/maintainers/config'
import ConfirmDeleteModal from '~/components/layout/ConfirmDeleteModal'
-import useSnackbar from '~/components/snackbar/useSnackbar'
-import useOrganisationMaintainers, {
- deleteMaintainerFromOrganisation
-} from './useOrganisationMaintainers'
-import OrganisationMaintainerLink from './OrganisationMaintainerLink'
-import OrganisationMaintainersList from './OrganisationMaintainersList'
-import ProtectedOrganisationPage from '../../ProtectedOrganisationPage'
import BaseSurfaceRounded from '~/components/layout/BaseSurfaceRounded'
-import useOrganisationContext from '../../context/useOrganisationContext'
+import MaintainersList from '~/components/maintainers/MaintainersList'
+import useOrganisationContext from '~/components/organisation/context/useOrganisationContext'
+import {useOrganisationMaintainers} from './useOrganisationMaintainers'
+import OrganisationMaintainerLinks from './OrganisationMaintainerLinks'
type DeleteModal = {
open: boolean,
- pos?: number,
- displayName?:string
+ // unique account id
+ account?: string,
+ displayName?: string
}
-
export default function OrganisationMaintainers() {
- const {token,user} = useSession()
- const {id,name,isMaintainer} = useOrganisationContext()
- const {showErrorMessage} = useSnackbar()
- const {loading,maintainers,setMaintainers} = useOrganisationMaintainers({
- organisation: id ?? '',
- token
+ const {id} = useOrganisationContext()
+ const {loading,maintainers,deleteMaintainer} = useOrganisationMaintainers({
+ organisation: id ?? ''
})
const [modal, setModal] = useState({
open: false
})
// console.group('OrganisationMaintainers')
- // console.log('OrganisationMaintainers.maintainers...', maintainers)
- // console.log('OrganisationMaintainers.organisationMaintainers...', organisationMaintainers)
- // console.log('OrganisationMaintainers.loading...', loading)
+ // console.log('maintainers...', maintainers)
+ // console.log('loading...', loading)
// console.groupEnd()
if (loading) {
@@ -67,39 +58,14 @@ export default function OrganisationMaintainers() {
if (maintainer) {
setModal({
open: true,
- pos,
+ account: maintainer.account,
displayName: maintainer.name
})
}
}
- async function deleteMaintainer(pos: number) {
- // console.log('delete maintainer...pos...', pos)
- closeModal()
- const admin = maintainers[pos]
- if (admin) {
- const resp = await deleteMaintainerFromOrganisation({
- maintainer: admin.account,
- organisation: id ?? '',
- token,
- frontend: true
- })
- if (resp.status === 200) {
- const newMaintainersList = [
- ...maintainers.slice(0, pos),
- ...maintainers.slice(pos+1)
- ]
- setMaintainers(newMaintainersList)
- } else {
- showErrorMessage(`Failed to remove maintainer. ${resp.message}`)
- }
- }
- }
-
return (
-
+ <>
-
@@ -118,12 +84,7 @@ export default function OrganisationMaintainers() {
title={config.inviteLink.title}
subtitle={config.inviteLink.subtitle}
/>
-
+
Are you sure you want to remove {modal.displayName ?? 'No name'} ?
}
- onCancel={() => {
- setModal({
- open:false
- })
- }}
- onDelete={()=>deleteMaintainer(modal.pos ?? 0)}
+ onCancel={closeModal}
+ onDelete={()=>deleteMaintainer(modal.account)}
/>
-
+ >
)
}
diff --git a/frontend/components/organisation/settings/maintainers/useOrganisationInvitations.tsx b/frontend/components/organisation/settings/maintainers/useOrganisationInvitations.tsx
new file mode 100644
index 000000000..5e960ccbc
--- /dev/null
+++ b/frontend/components/organisation/settings/maintainers/useOrganisationInvitations.tsx
@@ -0,0 +1,81 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useCallback, useEffect, useState} from 'react'
+
+import {useSession} from '~/auth'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import {Invitation, deleteMaintainerLink, getUnusedInvitations} from '~/components/maintainers/apiMaintainers'
+import {organisationMaintainerLink} from './apiOrganisationMaintainers'
+
+export function useOrganisationInvitations({organisation}:{organisation?:string}) {
+ const {token,user} = useSession()
+ const {showErrorMessage} = useSnackbar()
+ const [unusedInvitations,setUnusedInvitations] = useState([])
+ const [magicLink, setMagicLink] = useState(null)
+
+ const loadUnusedInvitations = useCallback(()=>{
+ // get unused invitation
+ getUnusedInvitations({
+ id: organisation ?? '',
+ type: 'organisation',
+ token
+ }).then(items=>{
+ // update
+ setUnusedInvitations(items)
+ }).catch(e=>{
+ // update on error to empty array
+ setUnusedInvitations([])
+ })
+ },[organisation,token])
+
+ useEffect(()=>{
+ let abort = false
+ if (organisation && token){
+ loadUnusedInvitations()
+ }
+ return ()=>{abort=true}
+ },[organisation,token,loadUnusedInvitations])
+
+ const createInvitation = useCallback(async()=>{
+ if (organisation && user?.account){
+ const resp = await organisationMaintainerLink({
+ organisation,
+ account: user?.account,
+ token
+ })
+ if (resp.status===201){
+ // set magic link prop to new link
+ setMagicLink(resp.message)
+ // reload unused invitations
+ loadUnusedInvitations()
+ }else{
+ showErrorMessage(`Failed to create invitation. ${resp.message}`)
+ }
+ }
+ // IGNORE showErrorMessage dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[organisation,user?.account,token,loadUnusedInvitations])
+
+ const deleteInvitation = useCallback(async(invitation:Invitation)=>{
+ const resp = await deleteMaintainerLink({
+ invitation,
+ token
+ })
+ if (resp.status===200){
+ loadUnusedInvitations()
+ }else{
+ showErrorMessage(`Failed to delete invitation. ${resp.message}`)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[token,loadUnusedInvitations])
+
+ return {
+ magicLink,
+ unusedInvitations,
+ deleteInvitation,
+ createInvitation
+ }
+}
diff --git a/frontend/components/organisation/settings/maintainers/useOrganisationMaintainers.tsx b/frontend/components/organisation/settings/maintainers/useOrganisationMaintainers.tsx
index 61d9d2605..10a170b2e 100644
--- a/frontend/components/organisation/settings/maintainers/useOrganisationMaintainers.tsx
+++ b/frontend/components/organisation/settings/maintainers/useOrganisationMaintainers.tsx
@@ -1,50 +1,36 @@
// 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-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center)
//
// SPDX-License-Identifier: Apache-2.0
import {useState,useEffect} from 'react'
-import {createJsonHeaders, extractReturnMessage} from '~/utils/fetchHelpers'
-import logger from '~/utils/logger'
-import {getMaintainersOfOrganisation} from './getMaintainersOfOrganisation'
-export type RawMaintainerOfOrganisation = {
- // unique maintainer id
- maintainer: string
- name: string[]
- email: string[]
- affiliation: string[],
- is_primary?: boolean
-}
+import {useSession} from '~/auth'
+import {MaintainerProps, rawMaintainersToMaintainers} from '~/components/maintainers/apiMaintainers'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import {
+ deleteMaintainerFromOrganisation,
+ getMaintainersOfOrganisation
+} from './apiOrganisationMaintainers'
-export type MaintainerOfOrganisation = {
- // unique maintainer id
- account: string
- name: string
- email: string
- affiliation: string,
- is_primary?: boolean
-}
-export default function useOrganisationMaintainers({organisation, token}:
- {organisation: string, token: string }) {
- const [maintainers, setMaintainers] = useState([])
+export function useOrganisationMaintainers({organisation}:{organisation: string}) {
+ const {token} = useSession()
+ const {showErrorMessage} = useSnackbar()
+ const [maintainers, setMaintainers] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let abort = false
async function getMaintainers() {
- // console.log('useOrganisationMaintainers.useEffect.getMaintainers')
setLoading(true)
const raw_maintainers = await getMaintainersOfOrganisation({
organisation,
- token,
- frontend:true
+ token
})
- // console.log('useOrganisationMaintainers.useEffect...raw_maintainers',raw_maintainers)
const maintainers = rawMaintainersToMaintainers(raw_maintainers)
if (abort) return null
// update maintainers state
@@ -52,7 +38,6 @@ export default function useOrganisationMaintainers({organisation, token}:
// update loading flag
setLoading(false)
}
- // console.log('useOrganisationMaintainers.useEffect...')
if (organisation && token) {
getMaintainers()
} else if (token==='') {
@@ -61,128 +46,29 @@ export default function useOrganisationMaintainers({organisation, token}:
return ()=>{abort=true}
}, [organisation,token])
- if (token === '') {
- return {
- loading: false,
- maintainers,
- setMaintainers
+
+ async function deleteMaintainer(account?:string) {
+ // console.log('delete maintainer...pos...', pos)
+ if (account && organisation) {
+ const resp = await deleteMaintainerFromOrganisation({
+ maintainer: account,
+ organisation,
+ token
+ })
+ if (resp.status === 200) {
+ // remove account
+ const newMaintainersList = maintainers.filter(item=>item.account!==account)
+ setMaintainers(newMaintainersList)
+ // setMaintainers(newMaintainersList)
+ } else {
+ showErrorMessage(`Failed to remove maintainer. ${resp.message}`)
+ }
}
}
- // console.log('useOrganisationMaintainers.loading...', loading)
- // console.log('useOrganisationMaintainers.organisation...',organisation)
- // console.log('useOrganisationMaintainers.token...',token)
return {
loading,
maintainers,
- setMaintainers
- }
-}
-
-export function rawMaintainersToMaintainers(raw_maintainers: RawMaintainerOfOrganisation[]) {
- try {
- const maintainers:MaintainerOfOrganisation[] = []
- raw_maintainers.forEach(item => {
- let maintainerWithMostInfo: MaintainerOfOrganisation | null = null
- let bestScore = -1
- // use name as second loop indicator
- item.name.forEach((name, pos) => {
- let score = 0
- if (name) {
- score += 1
- }
- if (item.email[pos]) {
- score += 1
- }
- if (item.affiliation[pos]) {
- score += 1
- }
-
- if (score <= bestScore) {
- return
- }
- const maintainer: MaintainerOfOrganisation = {
- account: item.maintainer,
- name,
- email: item.email[pos] ? item.email[pos] : '',
- affiliation: item.affiliation[pos] ? item.affiliation[pos] : '',
- is_primary: item?.is_primary ?? false
- }
-
- maintainerWithMostInfo = maintainer
- bestScore = score
- })
- maintainers.push(maintainerWithMostInfo as unknown as MaintainerOfOrganisation)
- })
- return maintainers
- } catch (e:any) {
- logger(`rawMaintainersToMaintainers: ${e?.message}`,'error')
- return []
- }
-}
-
-
-export async function deleteMaintainerFromOrganisation({maintainer,organisation,token,frontend=true}:
- {maintainer:string,organisation:string,token:string,frontend?:boolean}) {
- try {
- let query = `maintainer_for_organisation?maintainer=eq.${maintainer}&organisation=eq.${organisation}`
- let url = `/api/v1/${query}`
- if (frontend === false) {
- url = `${process.env.POSTGREST_URL}/${query}`
- }
-
- const resp = await fetch(url, {
- method: 'DELETE',
- headers: createJsonHeaders(token)
- })
-
- return extractReturnMessage(resp)
-
- } catch (e: any) {
- logger(`deleteMaintainerFromSoftware: ${e?.message}`, 'error')
- return {
- status: 500,
- message: e?.message
- }
- }
-}
-
-export async function organisationMaintainerLink({organisation, account, token}:
- { organisation: string, account: string, token: string }) {
- try {
- // POST
- const url = '/api/v1/invite_maintainer_for_organisation'
- const resp = await fetch(url, {
- method: 'POST',
- headers: {
- ...createJsonHeaders(token),
- 'Prefer': 'return=headers-only'
- },
- body: JSON.stringify({
- organisation,
- created_by:account
- })
- })
- if (resp.status === 201) {
- const id = resp.headers.get('location')?.split('.')[1]
- if (id) {
- const link = `${location.origin}/invite/organisation/${id}`
- return {
- status: 201,
- message: link
- }
- }
- return {
- status: 400,
- message: 'Id is missing'
- }
- }
- return extractReturnMessage(resp, organisation ?? '')
- } catch (e: any) {
- logger(`organisationMaintainerLink: ${e?.message}`, 'error')
- return {
- status: 500,
- message: e?.message
- }
+ deleteMaintainer
}
}
diff --git a/frontend/components/projects/edit/maintainers/EditProjectMaintainersIndex.test.tsx b/frontend/components/projects/edit/maintainers/EditProjectMaintainersIndex.test.tsx
index 5c299df30..1ef7e823e 100644
--- a/frontend/components/projects/edit/maintainers/EditProjectMaintainersIndex.test.tsx
+++ b/frontend/components/projects/edit/maintainers/EditProjectMaintainersIndex.test.tsx
@@ -1,35 +1,88 @@
// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) (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 {render, screen, waitForElementToBeRemoved} from '@testing-library/react'
+import {render, screen} from '@testing-library/react'
import {WithAppContext, mockSession} from '~/utils/jest/WithAppContext'
import {WithProjectContext} from '~/utils/jest/WithProjectContext'
-import {mockResolvedValueOnce} from '~/utils/jest/mockFetch'
import ProjectMaintainers from './index'
import editProjectState from '../__mocks__/editProjectState'
-// MOCK user agreement call
-jest.mock('~/components/user/settings/useUserAgreements')
-// MOCKS
-const mockGetUnusedInvitations=jest.fn(props=>Promise.resolve([]))
-jest.mock('~/utils/getUnusedInvitations', () => ({
- getUnusedInvitations: jest.fn(props=>mockGetUnusedInvitations(props))
+// MOCK useProjectMaintainers hook
+const mockDeleteMaintainer = jest.fn()
+const dummyMaintainersData={
+ loading: false,
+ maintainers:[],
+ deleteMaintainer: mockDeleteMaintainer
+}
+const mockUseProjectMaintainers = jest.fn((props)=>dummyMaintainersData)
+jest.mock('./useProjectMaintainers', () => ({
+ useProjectMaintainers:jest.fn((props)=>mockUseProjectMaintainers(props))
}))
+// MOCK useProjectInvitations hook
+const mockCreateInvitation = jest.fn()
+const mockDeleteInvitation = jest.fn()
+const dummyInvitationData={
+ magicLink: null,
+ unusedInvitations:[],
+ createInvitation: mockCreateInvitation,
+ deleteInvitation: mockDeleteInvitation
+}
+const mockUseProjectInvitations = jest.fn((props)=>dummyInvitationData)
+jest.mock('./useProjectInvitations', () => ({
+ useProjectInvitations:jest.fn((props)=>mockUseProjectInvitations(props))
+}))
+
+
+const dummyMaintainers = [
+ {account: 'test-account-id-1', name: 'John Doe 1', email: 'test1@email.com', affiliation: 'Company 1', disableDelete: false},
+ {account: 'test-account-id-2', name: 'John Doe 2', email: null, affiliation: null, disableDelete: false},
+]
+
+const dummyInvitations = [
+ {id:'test-link-id-1',created_at: new Date().toISOString(),type:'community'},
+ {id:'test-link-id-2',created_at: new Date().toISOString(),type:'community'},
+ {id:'test-link-id-3',created_at: new Date().toISOString(),type:'community'}
+]
+
+
describe('frontend/components/projects/edit/maintainers/index.tsx', () => {
beforeEach(() => {
jest.clearAllMocks()
})
+ it('shows loader when hook returns loading=true', () => {
+ // user is authenticated
+ mockSession.status = 'authenticated'
+ mockSession.token = 'test-token'
+ // it is maintainer of this organisation
+ dummyMaintainersData.loading = true
+ // mock hook return with loading true
+ mockUseProjectMaintainers.mockReturnValueOnce(dummyMaintainersData)
+
+ render(
+
+
+
+
+
+ )
+
+ const loader = screen.getByRole('progressbar')
+ expect(loader).toBeInTheDocument()
+ })
+
it('renders no maintainers', async() => {
// mock no maintainers
- mockResolvedValueOnce([])
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseProjectMaintainers.mockReturnValueOnce(dummyMaintainersData)
render(
@@ -39,11 +92,48 @@ describe('frontend/components/projects/edit/maintainers/index.tsx', () => {
)
- // wait for loader to be removed
- await waitForElementToBeRemoved(screen.getByRole('progressbar'))
-
// shows no maintainers message
const noMaintainers = screen.getByText('No maintainers')
expect(noMaintainers).toBeInTheDocument()
})
+
+ it('shows maintainer list with all items', async() => {
+ // mock maintainers
+ dummyMaintainersData.maintainers = dummyMaintainers as any
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseProjectMaintainers.mockReturnValueOnce(dummyMaintainersData)
+
+ render(
+
+
+
+
+
+ )
+
+ const maintainerItem = screen.getAllByTestId('maintainer-list-item')
+ expect(maintainerItem.length).toEqual(dummyMaintainers.length)
+ })
+
+ it('shows unused links list',()=>{
+ // it is maintainer of this organisation
+ dummyInvitationData.magicLink = null
+ dummyInvitationData.unusedInvitations = dummyInvitations as any
+
+ mockUseProjectInvitations.mockReturnValueOnce(dummyInvitationData)
+
+ render(
+
+
+
+
+
+ )
+
+ // check number of unused invitations
+ const unusedInvites = screen.getAllByTestId('unused-invitation-item')
+ expect(unusedInvites.length).toEqual(dummyInvitations.length)
+ })
+
})
diff --git a/frontend/components/projects/edit/maintainers/ProjectMaintainerLink.tsx b/frontend/components/projects/edit/maintainers/ProjectMaintainerLink.tsx
deleted file mode 100644
index b8cf8f813..000000000
--- a/frontend/components/projects/edit/maintainers/ProjectMaintainerLink.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-// SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2022 dv4all
-// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import {useEffect, useState} from 'react'
-import Button from '@mui/material/Button'
-import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
-import EmailIcon from '@mui/icons-material/Email'
-import CopyIcon from '@mui/icons-material/ContentCopy'
-
-import {createMaintainerLink} from '~/utils/editProject'
-import {copyToClipboard,canCopyToClipboard} from '~/utils/copyToClipboard'
-import useSnackbar from '~/components/snackbar/useSnackbar'
-import InvitationList from '~/components/layout/InvitationList'
-import {Invitation} from '~/types/Invitation'
-import {getUnusedInvitations} from '~/utils/getUnusedInvitations'
-import CopyToClipboard from '~/components/layout/CopyToClipboard'
-
-export default function ProjectMaintainerLink({project,title,account,token}: {project: string, title: string, account: string, token: string}) {
- const {showErrorMessage,showInfoMessage} = useSnackbar()
- const [magicLink, setMagicLink] = useState(null)
- const [unusedInvitations, setUnusedInvitations] = useState([])
- const canCopy = useState(canCopyToClipboard())
-
- async function fetchUnusedInvitations() {
- setUnusedInvitations(await getUnusedInvitations('project', project, token))
- }
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
- useEffect(() => {fetchUnusedInvitations()}, [])
-
- async function createInviteLink() {
- const resp = await createMaintainerLink({
- project,
- account,
- token
- })
- if (resp.status === 201) {
- setMagicLink(resp.message)
- fetchUnusedInvitations()
- } else {
- showErrorMessage(`Failed to generate maintainer link. ${resp.message}`)
- }
- }
-
- async function toClipboard(copied:boolean) {
- // notify user about copy action
- if (copied) {
- showInfoMessage('Copied to clipboard')
- } else {
- showErrorMessage(`Failed to copy link ${magicLink}`)
- }
- }
-
- function renderLinkOptions() {
- if (magicLink) {
- return (
-
- )
- }
- return null
- }
-
- return (
- <>
- }
- onClick={createInviteLink}
- >
- Generate invite link
-
-
- {renderLinkOptions()}
- fetchUnusedInvitations()}/>
- >
- )
-}
diff --git a/frontend/components/projects/edit/maintainers/ProjectMaintainerLinks.tsx b/frontend/components/projects/edit/maintainers/ProjectMaintainerLinks.tsx
new file mode 100644
index 000000000..281777110
--- /dev/null
+++ b/frontend/components/projects/edit/maintainers/ProjectMaintainerLinks.tsx
@@ -0,0 +1,49 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import Button from '@mui/material/Button'
+import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
+
+import InvitationList from '~/components/maintainers/InvitationList'
+import {useProjectInvitations} from './useProjectInvitations'
+import useProjectContext from '../useProjectContext'
+
+export default function ProjectMaintainerLinks() {
+ const {project} = useProjectContext()
+ const {
+ unusedInvitations,magicLink,
+ createInvitation,deleteInvitation
+ } = useProjectInvitations({project:project.id})
+
+ // console.group('ProjectMaintainerLinks')
+ // console.log('project...', project)
+ // console.log('magicLink...', magicLink)
+ // console.log('unusedInvitations...', unusedInvitations)
+ // console.groupEnd()
+
+ return (
+ <>
+ }
+ onClick={createInvitation}
+ >
+ Generate invite link
+
+
+
+ >
+ )
+}
diff --git a/frontend/components/projects/edit/maintainers/ProjectMaintainersList.tsx b/frontend/components/projects/edit/maintainers/ProjectMaintainersList.tsx
deleted file mode 100644
index d6383eda1..000000000
--- a/frontend/components/projects/edit/maintainers/ProjectMaintainersList.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2022 - 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import Alert from '@mui/material/Alert'
-import AlertTitle from '@mui/material/AlertTitle'
-import List from '@mui/material/List'
-import {useSession} from '~/auth'
-import logger from '~/utils/logger'
-
-import ProjectMaintainer from './ProjectMaintainer'
-import {MaintainerOfProject} from './useProjectMaintainer'
-
-type ProjectMaintainerListProps = {
- maintainers: MaintainerOfProject[]
- onDelete:(pos:number)=>void
-}
-
-export default function ProjectMaintainersList({maintainers,onDelete}:ProjectMaintainerListProps) {
- const {user} = useSession()
-
- if (maintainers.length === 0) {
- return (
-
- No maintainers
- Add project mantainer by using invite link button!
-
- )
- }
-
- function onEdit(pos:number) {
- logger('onEdit...NOT SUPPORTED FOR MAINTAINERS','info')
- }
-
- function isDeleteDisabled() {
- // we allow rsd_admin to remove last mantainer too
- if (user?.role === 'rsd_admin' && maintainers?.length > 0) {
- return false
- } else if (maintainers?.length > 1) {
- // common maintainer can remove untill the last mantainer
- return false
- }
- return true
- }
-
- function renderList() {
- return maintainers.map((item, pos) => {
- return (
-
- )
- })
- }
-
- return (
-
- {renderList()}
-
- )
-}
diff --git a/frontend/components/projects/edit/maintainers/apiProjectMaintainers.ts b/frontend/components/projects/edit/maintainers/apiProjectMaintainers.ts
new file mode 100644
index 000000000..9c1b000ef
--- /dev/null
+++ b/frontend/components/projects/edit/maintainers/apiProjectMaintainers.ts
@@ -0,0 +1,54 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import logger from '~/utils/logger'
+import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers'
+import {RawMaintainerProps} from '~/components/maintainers/apiMaintainers'
+
+export async function getMaintainersOfProject({project, token}:
+ {project: string, token: string}) {
+ try {
+ const query = `rpc/maintainers_of_project?project_id=${project}`
+ const url = `${getBaseUrl()}/${query}`
+
+ const resp = await fetch(url, {
+ method: 'GET',
+ headers: createJsonHeaders(token)
+ })
+
+ if (resp.status === 200) {
+ const json:RawMaintainerProps[] = await resp.json()
+ return json
+ }
+ // ERRORS
+ logger(`getMaintainersOfProject: ${resp.status}:${resp.statusText} project: ${project}`, 'warn')
+ return []
+ } catch (e: any) {
+ logger(`getMaintainersOfProject: ${e?.message}`, 'error')
+ return []
+ }
+}
+
+export async function deleteMaintainerFromProject({maintainer,project,token}:
+ {maintainer:string,project:string,token:string}) {
+ try {
+ const query = `maintainer_for_project?maintainer=eq.${maintainer}&project=eq.${project}`
+ const url = `${getBaseUrl()}/${query}`
+
+ const resp = await fetch(url, {
+ method: 'DELETE',
+ headers: createJsonHeaders(token)
+ })
+
+ return extractReturnMessage(resp)
+
+ } catch (e: any) {
+ logger(`deleteMaintainerFromProject: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
diff --git a/frontend/components/projects/edit/maintainers/index.tsx b/frontend/components/projects/edit/maintainers/index.tsx
index 3760919f7..afcf5ec73 100644
--- a/frontend/components/projects/edit/maintainers/index.tsx
+++ b/frontend/components/projects/edit/maintainers/index.tsx
@@ -1,53 +1,39 @@
// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 - 2023 dv4all
+// SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center
// SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2022 Netherlands eScience Center
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
//
// SPDX-License-Identifier: Apache-2.0
-import {useEffect,useState} from 'react'
+import {useState} from 'react'
-import {useSession} from '~/auth'
import ContentLoader from '~/components/layout/ContentLoader'
import EditSection from '~/components/layout/EditSection'
import EditSectionTitle from '~/components/layout/EditSectionTitle'
+import ConfirmDeleteModal from '~/components/layout/ConfirmDeleteModal'
+import MaintainersList from '~/components/maintainers/MaintainersList'
import useProjectContext from '../useProjectContext'
-import useProjectMaintainers, {deleteMaintainerFromProject, MaintainerOfProject} from './useProjectMaintainer'
-import ProjectMaintainersList from './ProjectMaintainersList'
-import ProjectMaintainerLink from './ProjectMaintainerLink'
import {maintainers as config} from './config'
-import ConfirmDeleteModal from '~/components/layout/ConfirmDeleteModal'
-import useSnackbar from '~/components/snackbar/useSnackbar'
+import ProjectMaintainerLinks from './ProjectMaintainerLinks'
+import {useProjectMaintainers} from './useProjectMaintainers'
type DeleteModal = {
open: boolean,
- pos?: number,
- displayName?:string
+ // unique account id
+ account?: string,
+ displayName?: string
}
-
export default function ProjectMaintainers() {
- const {token,user} = useSession()
- const {showErrorMessage} = useSnackbar()
const {project} = useProjectContext()
- const {loading,maintainers} = useProjectMaintainers({
- project: project.id,
- token
+ const {loading,maintainers,deleteMaintainer} = useProjectMaintainers({
+ project: project.id
})
- const [projectMaintainers, setProjectMaintaners] = useState([])
const [modal, setModal] = useState({
open: false
})
- useEffect(() => {
- let abort = false
- if (loading === false &&
- abort === false) {
- setProjectMaintaners(maintainers)
- }
- return () => { abort = true }
- },[maintainers,loading])
-
if (loading) {
return (
@@ -56,7 +42,7 @@ export default function ProjectMaintainers() {
function closeModal() {
setModal({
- open: false
+ open: false,
})
}
@@ -65,35 +51,12 @@ export default function ProjectMaintainers() {
if (maintainer) {
setModal({
open: true,
- pos,
+ account: maintainer.account,
displayName: maintainer.name
})
}
}
- async function deleteMaintainer(pos: number) {
- // console.log('delete maintainer...pos...', pos)
- closeModal()
- const admin = maintainers[pos]
- if (admin) {
- const resp = await deleteMaintainerFromProject({
- maintainer: admin.account,
- project: project.id,
- token,
- frontend: true
- })
- if (resp.status === 200) {
- const newMaintainersList = [
- ...maintainers.slice(0, pos),
- ...maintainers.slice(pos+1)
- ]
- setProjectMaintaners(newMaintainersList)
- } else {
- showErrorMessage(`Failed to remove maintainer. ${resp.message}`)
- }
- }
- }
-
return (
<>
@@ -101,9 +64,9 @@ export default function ProjectMaintainers() {
-
@@ -111,12 +74,7 @@ export default function ProjectMaintainers() {
title={config.inviteLink.title}
subtitle={config.inviteLink.subtitle}
/>
-
+
Are you sure you want to remove {modal.displayName ?? 'No name'} ?
}
- onCancel={() => {
- setModal({
- open:false
- })
+ onCancel={closeModal}
+ onDelete={()=>{
+ deleteMaintainer(modal.account)
+ closeModal()
}}
- onDelete={()=>deleteMaintainer(modal.pos ?? 0)}
/>
>
)
diff --git a/frontend/components/projects/edit/maintainers/useProjectInvitations.tsx b/frontend/components/projects/edit/maintainers/useProjectInvitations.tsx
new file mode 100644
index 000000000..2023bcbba
--- /dev/null
+++ b/frontend/components/projects/edit/maintainers/useProjectInvitations.tsx
@@ -0,0 +1,81 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useCallback, useEffect, useState} from 'react'
+
+import {useSession} from '~/auth'
+import {Invitation, deleteMaintainerLink, getUnusedInvitations} from '~/components/maintainers/apiMaintainers'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import {createMaintainerLink} from '~/utils/editProject'
+
+export function useProjectInvitations({project}:{project?:string}) {
+ const {token,user} = useSession()
+ const {showErrorMessage} = useSnackbar()
+ const [unusedInvitations,setUnusedInvitations] = useState([])
+ const [magicLink, setMagicLink] = useState(null)
+
+ const loadUnusedInvitations = useCallback(()=>{
+ // get unused invitation
+ getUnusedInvitations({
+ id: project ?? '',
+ type: 'project',
+ token
+ }).then(items=>{
+ // update
+ setUnusedInvitations(items)
+ }).catch(e=>{
+ // update on error to empty array
+ setUnusedInvitations([])
+ })
+ },[project,token])
+
+ useEffect(()=>{
+ let abort = false
+ if (project && token){
+ loadUnusedInvitations()
+ }
+ return ()=>{abort=true}
+ },[project,token,loadUnusedInvitations])
+
+ const createInvitation = useCallback(async()=>{
+ if (project && user?.account){
+ const resp = await createMaintainerLink({
+ project,
+ account:user?.account,
+ token
+ })
+ if (resp.status===201){
+ // set magic link prop to new link
+ setMagicLink(resp.message)
+ // reload unused invitations
+ loadUnusedInvitations()
+ }else{
+ showErrorMessage(`Failed to create invitation. ${resp.message}`)
+ }
+ }
+ // IGNORE showErrorMessage dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[project,user?.account,token,loadUnusedInvitations])
+
+ const deleteInvitation = useCallback(async(invitation:Invitation)=>{
+ const resp = await deleteMaintainerLink({
+ invitation,
+ token
+ })
+ if (resp.status===200){
+ loadUnusedInvitations()
+ }else{
+ showErrorMessage(`Failed to delete invitation. ${resp.message}`)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[token,loadUnusedInvitations])
+
+ return {
+ magicLink,
+ unusedInvitations,
+ deleteInvitation,
+ createInvitation
+ }
+}
diff --git a/frontend/components/projects/edit/maintainers/useProjectMaintainer.tsx b/frontend/components/projects/edit/maintainers/useProjectMaintainer.tsx
deleted file mode 100644
index 9e455b08e..000000000
--- a/frontend/components/projects/edit/maintainers/useProjectMaintainer.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) (dv4all)
-// SPDX-FileCopyrightText: 2022 dv4all
-// SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import {useState,useEffect} from 'react'
-import {createJsonHeaders, extractReturnMessage} from '~/utils/fetchHelpers'
-import logger from '~/utils/logger'
-
-export type RawMaintainerOfProject = {
- // unique maintainer id
- maintainer: string
- name: string[]
- email: string[]
- affiliation: string[],
-}
-
-export type MaintainerOfProject = {
- // unique maintainer id
- account: string
- name: string
- email: string
- affiliation: string,
-}
-
-export async function getMaintainersOfProject({project, token, frontend=true}:
- {project: string, token: string,frontend?:boolean}) {
- try {
- let query = `rpc/maintainers_of_project?project_id=${project}`
- let url = `/api/v1/${query}`
- if (frontend === false) {
- url = `${process.env.POSTGREST_URL}/${query}`
- }
-
- const resp = await fetch(url, {
- method: 'GET',
- headers: createJsonHeaders(token)
- })
-
- if (resp.status === 200) {
- const json:RawMaintainerOfProject[] = await resp.json()
- return json
- }
- // ERRORS
- logger(`getMaintainersOfSoftware: ${resp.status}:${resp.statusText} project: ${project}`, 'warn')
- return []
- } catch (e: any) {
- logger(`getMaintainersOfSoftware: ${e?.message}`, 'error')
- return []
- }
-}
-
-export default function useProjectMaintainers({project, token}:
- {project: string, token: string }) {
- const [maintainers, setMaintainers] = useState([])
- const [loading, setLoading] = useState(true)
- const [loadedProject, setLoadedProject] = useState('')
-
- useEffect(() => {
- let abort = false
- async function getMaintainers() {
- setLoading(true)
- const raw_maintainers = await getMaintainersOfProject({
- project,
- token,
- frontend:true
- })
- const maintainers = rawMaintainersToMaintainers(raw_maintainers)
- if (abort) return null
- // update maintainers state
- setMaintainers(maintainers)
- setLoadedProject(project)
- // update loading flag
- setLoading(false)
- }
-
- if (project && token && project!==loadedProject) {
- getMaintainers()
- }
- return ()=>{abort=true}
- },[project,token,loadedProject])
-
- return {
- maintainers,
- loading
- }
-}
-
-export function rawMaintainersToMaintainers(raw_maintainers: RawMaintainerOfProject[]) {
- try {
- const maintainers:MaintainerOfProject[] = []
- raw_maintainers.forEach(item => {
- let maintainerWithMostInfo: MaintainerOfProject | null = null
- let bestScore = -1
- // use name as second loop indicator
- item.name.forEach((name, pos) => {
- let score = 0
- if (name) {
- score += 1
- }
- if (item.email[pos]) {
- score += 1
- }
- if (item.affiliation[pos]) {
- score += 1
- }
-
- if (score <= bestScore) {
- return
- }
- const maintainer: MaintainerOfProject = {
- account: item.maintainer,
- name,
- email: item.email[pos] ? item.email[pos] : '',
- affiliation: item.affiliation[pos] ? item.affiliation[pos] : ''
- }
-
- maintainerWithMostInfo = maintainer
- bestScore = score
- })
- maintainers.push(maintainerWithMostInfo as unknown as MaintainerOfProject)
- })
- return maintainers
- } catch (e:any) {
- logger(`rawMaintainersToMaintainers: ${e?.message}`,'error')
- return []
- }
-}
-
-export async function deleteMaintainerFromProject({maintainer,project,token,frontend=true}:
- {maintainer:string,project:string,token:string,frontend?:boolean}) {
- try {
- let query = `maintainer_for_project?maintainer=eq.${maintainer}&project=eq.${project}`
- let url = `/api/v1/${query}`
- if (frontend === false) {
- url = `${process.env.POSTGREST_URL}/${query}`
- }
-
- const resp = await fetch(url, {
- method: 'DELETE',
- headers: createJsonHeaders(token)
- })
-
- return extractReturnMessage(resp)
-
- } catch (e: any) {
- logger(`deleteMaintainerFromProject: ${e?.message}`, 'error')
- return {
- status: 500,
- message: e?.message
- }
- }
-}
diff --git a/frontend/components/projects/edit/maintainers/useProjectMaintainers.tsx b/frontend/components/projects/edit/maintainers/useProjectMaintainers.tsx
new file mode 100644
index 000000000..a7d7ea0c0
--- /dev/null
+++ b/frontend/components/projects/edit/maintainers/useProjectMaintainers.tsx
@@ -0,0 +1,79 @@
+// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
+// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) (dv4all)
+// SPDX-FileCopyrightText: 2022 dv4all
+// 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
+
+import {useState,useEffect} from 'react'
+
+import {useSession} from '~/auth'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import {MaintainerProps, rawMaintainersToMaintainers} from '~/components/maintainers/apiMaintainers'
+import {deleteMaintainerFromProject, getMaintainersOfProject} from './apiProjectMaintainers'
+
+export function useProjectMaintainers({project}:{project: string}) {
+ const {token,user} = useSession()
+ const {showErrorMessage} = useSnackbar()
+ const [maintainers, setMaintainers] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ // console.group('useProjectMaintainers')
+ // console.log('project...',project)
+ // console.log('token...',token)
+ // console.groupEnd()
+
+ useEffect(() => {
+ let abort = false
+ async function getMaintainers() {
+ setLoading(true)
+ const raw_maintainers = await getMaintainersOfProject({
+ project,
+ token
+ })
+ const maintainers = rawMaintainersToMaintainers(raw_maintainers)
+ if (abort) return null
+ // update maintainers state
+ setMaintainers(maintainers)
+ // update loading flag
+ setLoading(false)
+ }
+
+ if (project && token) {
+ getMaintainers()
+ }
+ return ()=>{abort=true}
+ },[project,token])
+
+ async function deleteMaintainer(account?:string){
+ if (account && project){
+ const resp = await deleteMaintainerFromProject({
+ maintainer: account,
+ project,
+ token
+ })
+ if (resp.status === 200) {
+ // remove account
+ const newMaintainersList = maintainers.filter(item=>item.account!==account)
+ setMaintainers(newMaintainersList)
+ } else {
+ showErrorMessage(`Failed to remove maintainer. ${resp.message}`)
+ }
+ }
+ }
+
+ // last maintainer can be deleted only by rsd-admin
+ if (maintainers?.length===1 && user?.role!=='rsd_admin'){
+ // disable delete button on last maintainer
+ maintainers[0].disableDelete = true
+ }
+
+ return {
+ loading,
+ maintainers,
+ setMaintainers,
+ deleteMaintainer
+ }
+}
diff --git a/frontend/components/software/edit/maintainers/EditSoftwareMaintainersIndex.test.tsx b/frontend/components/software/edit/maintainers/EditSoftwareMaintainersIndex.test.tsx
index 679d50d27..8b8ec898d 100644
--- a/frontend/components/software/edit/maintainers/EditSoftwareMaintainersIndex.test.tsx
+++ b/frontend/components/software/edit/maintainers/EditSoftwareMaintainersIndex.test.tsx
@@ -1,37 +1,92 @@
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all)
// SPDX-FileCopyrightText: 2023 dv4all
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0
-import {render, screen, waitForElementToBeRemoved} from '@testing-library/react'
+import {render, screen} from '@testing-library/react'
import {WithAppContext, mockSession} from '~/utils/jest/WithAppContext'
import {WithSoftwareContext} from '~/utils/jest/WithSoftwareContext'
-import {mockResolvedValueOnce} from '~/utils/jest/mockFetch'
import SoftwareMaintainers from './index'
// MOCKS
import {initialState as softwareState} from '~/components/software/edit/editSoftwareContext'
-// MOCK getUnusedInvitations
-const mockGetUnusedInvitations=jest.fn(props=>Promise.resolve([]))
-jest.mock('~/utils/getUnusedInvitations', () => ({
- getUnusedInvitations: jest.fn(props=>mockGetUnusedInvitations(props))
+// MOCK useSoftwareMaintainers hook
+const mockDeleteMaintainer = jest.fn()
+const dummyMaintainersData={
+ loading: false,
+ maintainers:[],
+ deleteMaintainer: mockDeleteMaintainer
+}
+const mockUseSoftwareMaintainers = jest.fn((props)=>dummyMaintainersData)
+jest.mock('./useSoftwareMaintainers', () => ({
+ useSoftwareMaintainers:jest.fn((props)=>mockUseSoftwareMaintainers(props))
}))
+// MOCK useSoftwareInvitations hook
+const mockCreateInvitation = jest.fn()
+const mockDeleteInvitation = jest.fn()
+const dummyInvitationData={
+ magicLink: null,
+ unusedInvitations:[],
+ createInvitation: mockCreateInvitation,
+ deleteInvitation: mockDeleteInvitation
+}
+const mockUseSoftwareInvitations = jest.fn((props)=>dummyInvitationData)
+jest.mock('./useSoftwareInvitations', () => ({
+ useSoftwareInvitations:jest.fn((props)=>mockUseSoftwareInvitations(props))
+}))
+
+
+const dummyMaintainers = [
+ {account: 'test-account-id-1', name: 'John Doe 1', email: 'test1@email.com', affiliation: 'Company 1', disableDelete: false},
+ {account: 'test-account-id-2', name: 'John Doe 2', email: null, affiliation: null, disableDelete: false},
+]
+
+const dummyInvitations = [
+ {id:'test-link-id-1',created_at: new Date().toISOString(),type:'community'},
+ {id:'test-link-id-2',created_at: new Date().toISOString(),type:'community'},
+ {id:'test-link-id-3',created_at: new Date().toISOString(),type:'community'}
+]
+
describe('frontend/components/software/edit/maintainers/index.tsx', () => {
beforeEach(() => {
jest.clearAllMocks()
})
- it('renders no maintainers', async() => {
- // resolve no maintainers
- mockResolvedValueOnce([])
+ it('shows loader when hook returns loading=true', () => {
+ // user is authenticated
+ mockSession.status = 'authenticated'
+ mockSession.token = 'test-token'
+ // it is maintainer of this organisation
+ dummyMaintainersData.loading = true
+ // mock hook return with loading true
+ mockUseSoftwareMaintainers.mockReturnValueOnce(dummyMaintainersData)
+ render(
+
+
+
+
+
+ )
+
+ const loader = screen.getByRole('progressbar')
+ expect(loader).toBeInTheDocument()
+ })
+
+ it('renders no maintainers', async() => {
// software id required for requests
softwareState.software.id = 'software-test-id'
+ // it is maintainer of this organisation
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseSoftwareMaintainers.mockReturnValueOnce(dummyMaintainersData)
render(
@@ -41,9 +96,47 @@ describe('frontend/components/software/edit/maintainers/index.tsx', () => {
)
- // wait for loader to be removed
- await waitForElementToBeRemoved(screen.getByRole('progressbar'))
// validate no maintainers message
- const noMaintainersMsg = screen.getByText('No maintainers')
+ screen.getByText('No maintainers')
+ })
+
+ it('shows maintainer list with all items', async() => {
+ // mock maintainers
+ dummyMaintainersData.maintainers = dummyMaintainers as any
+ dummyMaintainersData.loading = false
+ // mock hook return with loading true
+ mockUseSoftwareMaintainers.mockReturnValueOnce(dummyMaintainersData)
+
+ render(
+
+
+
+
+
+ )
+
+ const maintainerItem = screen.getAllByTestId('maintainer-list-item')
+ expect(maintainerItem.length).toEqual(dummyMaintainers.length)
})
+
+ it('shows unused links list',()=>{
+ // it is maintainer of this organisation
+ dummyInvitationData.magicLink = null
+ dummyInvitationData.unusedInvitations = dummyInvitations as any
+
+ mockUseSoftwareInvitations.mockReturnValueOnce(dummyInvitationData)
+
+ render(
+
+
+
+
+
+ )
+
+ // check number of unused invitations
+ const unusedInvites = screen.getAllByTestId('unused-invitation-item')
+ expect(unusedInvites.length).toEqual(dummyInvitations.length)
+ })
+
})
diff --git a/frontend/components/software/edit/maintainers/SoftwareMaintainerLink.tsx b/frontend/components/software/edit/maintainers/SoftwareMaintainerLink.tsx
deleted file mode 100644
index 7468b0886..000000000
--- a/frontend/components/software/edit/maintainers/SoftwareMaintainerLink.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-// SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2022 dv4all
-// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import {useState} from 'react'
-import Button from '@mui/material/Button'
-import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
-import EmailIcon from '@mui/icons-material/Email'
-import CopyIcon from '@mui/icons-material/ContentCopy'
-
-import {copyToClipboard,canCopyToClipboard} from '~/utils/copyToClipboard'
-import useSnackbar from '~/components/snackbar/useSnackbar'
-import {softwareMaintainerLink} from './useSoftwareMaintainers'
-import {useEffect} from 'react'
-
-import {Invitation} from '~/types/Invitation'
-import InvitationList from '~/components/layout/InvitationList'
-import {getUnusedInvitations} from '~/utils/getUnusedInvitations'
-import CopyToClipboard from '~/components/layout/CopyToClipboard'
-
-export default function SoftwareMaintainerLink({software,brand_name,account,token}: {software: string, brand_name: string, account: string,token: string}) {
- const {showErrorMessage,showInfoMessage} = useSnackbar()
- const [magicLink, setMagicLink] = useState(null)
- const [unusedInvitations, setUnusedInvitations] = useState([])
- const canCopy = useState(canCopyToClipboard())
-
- async function fetchUnusedInvitations() {
- setUnusedInvitations(await getUnusedInvitations('software', software, token))
- }
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
- useEffect(() => {fetchUnusedInvitations()}, [])
-
- async function createInviteLink() {
- const resp = await softwareMaintainerLink({
- software,
- account,
- token
- })
- if (resp.status === 201) {
- setMagicLink(resp.message)
- fetchUnusedInvitations()
- } else {
- showErrorMessage(`Failed to generate maintainer link. ${resp.message}`)
- }
- }
-
- async function toClipboard(copied:boolean) {
- // notify user about copy action
- if (copied) {
- showInfoMessage('Copied to clipboard')
- } else {
- showErrorMessage(`Failed to copy link ${magicLink}`)
- }
- }
-
- function renderLinkOptions() {
- if (magicLink) {
- return (
-
- )
- }
- return null
- }
-
- return (
- <>
- }
- onClick={createInviteLink}
- >
- Generate invite link
-
-
- {renderLinkOptions()}
- fetchUnusedInvitations()}/>
- >
- )
-}
diff --git a/frontend/components/software/edit/maintainers/SoftwareMaintainerLinks.tsx b/frontend/components/software/edit/maintainers/SoftwareMaintainerLinks.tsx
new file mode 100644
index 000000000..88a434c82
--- /dev/null
+++ b/frontend/components/software/edit/maintainers/SoftwareMaintainerLinks.tsx
@@ -0,0 +1,46 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import Button from '@mui/material/Button'
+import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
+
+import InvitationList from '~/components/maintainers/InvitationList'
+import useSoftwareContext from '../useSoftwareContext'
+import {useSoftwareInvitations} from './useSoftwareInvitations'
+
+export default function SoftwareMaintainerLinks() {
+ const {software} = useSoftwareContext()
+ const {unusedInvitations,createInvitation,deleteInvitation} = useSoftwareInvitations({software:software.id})
+
+ // console.group('SoftwareMaintainerLinks')
+ // console.log('software...', software)
+ // console.log('magicLink...', magicLink)
+ // console.log('unusedInvitations...', unusedInvitations)
+ // console.groupEnd()
+
+ return (
+ <>
+ }
+ onClick={createInvitation}
+ >
+ Generate invite link
+
+
+
+ >
+ )
+}
diff --git a/frontend/components/software/edit/maintainers/apiSoftwareMaintainers.ts b/frontend/components/software/edit/maintainers/apiSoftwareMaintainers.ts
new file mode 100644
index 000000000..7a05a6be7
--- /dev/null
+++ b/frontend/components/software/edit/maintainers/apiSoftwareMaintainers.ts
@@ -0,0 +1,94 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import logger from '~/utils/logger'
+import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers'
+import {RawMaintainerProps} from '~/components/maintainers/apiMaintainers'
+
+export async function getMaintainersOfSoftware({software, token}:
+ {software: string, token: string}) {
+ try {
+ const query = `rpc/maintainers_of_software?software_id=${software}`
+ const url = `${getBaseUrl()}/${query}`
+
+ const resp = await fetch(url, {
+ method: 'GET',
+ headers: createJsonHeaders(token)
+ })
+
+ if (resp.status === 200) {
+ const json:RawMaintainerProps[] = await resp.json()
+ return json
+ }
+ // ERRORS
+ logger(`getMaintainersOfSoftware: ${resp.status}:${resp.statusText} software: ${software}`, 'warn')
+ return []
+ } catch (e: any) {
+ logger(`getMaintainersOfSoftware: ${e?.message}`, 'error')
+ return []
+ }
+}
+
+export async function deleteMaintainerFromSoftware({maintainer,software,token}:
+ {maintainer:string,software:string,token:string}) {
+ try {
+ const query = `maintainer_for_software?maintainer=eq.${maintainer}&software=eq.${software}`
+ const url = `${getBaseUrl()}/${query}`
+
+ const resp = await fetch(url, {
+ method: 'DELETE',
+ headers: createJsonHeaders(token)
+ })
+
+ return extractReturnMessage(resp)
+
+ } catch (e: any) {
+ logger(`deleteMaintainerFromSoftware: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
+
+export async function softwareMaintainerLink({software, account, token}:
+ { software: string, account: string, token: string }) {
+ try {
+ // POST
+ const url = `${getBaseUrl()}/invite_maintainer_for_software`
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: {
+ ...createJsonHeaders(token),
+ 'Prefer': 'return=headers-only'
+ },
+ body: JSON.stringify({
+ software,
+ created_by:account
+ })
+ })
+ if (resp.status === 201) {
+ const id = resp.headers.get('location')?.split('.')[1]
+ if (id) {
+ const link = `${location.origin}/invite/software/${id}`
+ return {
+ status: 201,
+ message: link
+ }
+ }
+ return {
+ status: 400,
+ message: 'Id is missing'
+ }
+ }
+ return extractReturnMessage(resp, software ?? '')
+ } catch (e: any) {
+ logger(`softwareMaintainerLink: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
diff --git a/frontend/components/software/edit/maintainers/index.tsx b/frontend/components/software/edit/maintainers/index.tsx
index 6ca140e1c..dde62829f 100644
--- a/frontend/components/software/edit/maintainers/index.tsx
+++ b/frontend/components/software/edit/maintainers/index.tsx
@@ -1,52 +1,37 @@
// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 - 2023 dv4all
+// SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center
// SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2022 Netherlands eScience Center
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
//
// SPDX-License-Identifier: Apache-2.0
-import {useEffect,useState} from 'react'
+import {useState} from 'react'
-import {useSession} from '~/auth'
import ContentLoader from '~/components/layout/ContentLoader'
import EditSection from '~/components/layout/EditSection'
import EditSectionTitle from '~/components/layout/EditSectionTitle'
-import ProjectMaintainersList from '~/components/projects/edit/maintainers/ProjectMaintainersList'
import {maintainers as config} from '~/components/projects/edit/maintainers/config'
import ConfirmDeleteModal from '~/components/layout/ConfirmDeleteModal'
-import useSnackbar from '~/components/snackbar/useSnackbar'
+import MaintainersList from '~/components/maintainers/MaintainersList'
import useSoftwareContext from '../useSoftwareContext'
-import useSoftwareMaintainers, {
- deleteMaintainerFromSoftware, MaintainerOfSoftware
-} from './useSoftwareMaintainers'
-import SoftwareMaintainerLink from './SoftwareMaintainerLink'
+import {useSoftwareMaintainers} from './useSoftwareMaintainers'
+import SoftwareMaintainerLinks from './SoftwareMaintainerLinks'
type DeleteModal = {
open: boolean,
- pos?: number,
- displayName?:string
+ // unique account id
+ account?: string,
+ displayName?: string
}
export default function SoftwareMaintainers() {
- const {token,user} = useSession()
- const {showErrorMessage} = useSnackbar()
const {software} = useSoftwareContext()
- const {loading,maintainers} = useSoftwareMaintainers()
- const [projectMaintainers, setProjectMaintaners] = useState([])
+ const {loading,maintainers,deleteMaintainer} = useSoftwareMaintainers({software:software.id})
const [modal, setModal] = useState({
open: false
})
- useEffect(() => {
- let abort = false
- if (loading === false &&
- abort === false) {
- setProjectMaintaners(maintainers)
- // setLoading(false)
- }
- return () => { abort = true }
- },[maintainers,loading])
-
if (loading) {
return (
@@ -64,35 +49,12 @@ export default function SoftwareMaintainers() {
if (maintainer) {
setModal({
open: true,
- pos,
+ account: maintainer.account,
displayName: maintainer.name
})
}
}
- async function deleteMaintainer(pos: number) {
- // console.log('delete maintainer...pos...', pos)
- closeModal()
- const admin = maintainers[pos]
- if (admin) {
- const resp = await deleteMaintainerFromSoftware({
- maintainer: admin.account,
- software: software.id ?? '',
- token,
- frontend: true
- })
- if (resp.status === 200) {
- const newMaintainersList = [
- ...maintainers.slice(0, pos),
- ...maintainers.slice(pos+1)
- ]
- setProjectMaintaners(newMaintainersList)
- } else {
- showErrorMessage(`Failed to remove maintainer. ${resp.message}`)
- }
- }
- }
-
return (
<>
@@ -100,9 +62,9 @@ export default function SoftwareMaintainers() {
-
@@ -110,12 +72,7 @@ export default function SoftwareMaintainers() {
title={config.inviteLink.title}
subtitle={config.inviteLink.subtitle}
/>
-
+
Are you sure you want to remove {modal.displayName ?? 'No name'} ?
}
- onCancel={() => {
- setModal({
- open:false
- })
+ onCancel={closeModal}
+ onDelete={()=>{
+ deleteMaintainer(modal.account)
+ closeModal()
}}
- onDelete={()=>deleteMaintainer(modal.pos ?? 0)}
/>
>
)
diff --git a/frontend/components/software/edit/maintainers/useSoftwareInvitations.tsx b/frontend/components/software/edit/maintainers/useSoftwareInvitations.tsx
new file mode 100644
index 000000000..9f44a470a
--- /dev/null
+++ b/frontend/components/software/edit/maintainers/useSoftwareInvitations.tsx
@@ -0,0 +1,78 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useCallback, useEffect, useState} from 'react'
+
+import {useSession} from '~/auth'
+import {Invitation, deleteMaintainerLink, getUnusedInvitations} from '~/components/maintainers/apiMaintainers'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import {softwareMaintainerLink} from './apiSoftwareMaintainers'
+
+export function useSoftwareInvitations({software}:{software?:string}) {
+ const {token,user} = useSession()
+ const {showErrorMessage} = useSnackbar()
+ const [unusedInvitations,setUnusedInvitations] = useState([])
+ // const [magicLink, setMagicLink] = useState(null)
+
+ const loadUnusedInvitations = useCallback(()=>{
+ // get unused invitation
+ getUnusedInvitations({
+ id: software ?? '',
+ type: 'software',
+ token
+ }).then(items=>{
+ // update
+ setUnusedInvitations(items)
+ }).catch(e=>{
+ // update on error to empty array
+ setUnusedInvitations([])
+ })
+ },[software,token])
+
+ useEffect(()=>{
+ let abort = false
+ if (software && token){
+ loadUnusedInvitations()
+ }
+ return ()=>{abort=true}
+ },[software,token,loadUnusedInvitations])
+
+ const createInvitation = useCallback(async()=>{
+ if (software && user?.account){
+ const resp = await softwareMaintainerLink({
+ software,
+ account:user?.account,
+ token
+ })
+ if (resp.status===201){
+ // reload unused invitation list
+ loadUnusedInvitations()
+ }else{
+ showErrorMessage(`Failed to create invitation. ${resp.message}`)
+ }
+ }
+ // IGNORE showErrorMessage dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[software,user?.account,token,loadUnusedInvitations])
+
+ const deleteInvitation = useCallback(async(invitation:Invitation)=>{
+ const resp = await deleteMaintainerLink({
+ invitation,
+ token
+ })
+ if (resp.status===200){
+ loadUnusedInvitations()
+ }else{
+ showErrorMessage(`Failed to delete invitation. ${resp.message}`)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[token,loadUnusedInvitations])
+
+ return {
+ unusedInvitations,
+ deleteInvitation,
+ createInvitation
+ }
+}
diff --git a/frontend/components/software/edit/maintainers/useSoftwareMaintainers.tsx b/frontend/components/software/edit/maintainers/useSoftwareMaintainers.tsx
index 0070d6b70..cb3972c63 100644
--- a/frontend/components/software/edit/maintainers/useSoftwareMaintainers.tsx
+++ b/frontend/components/software/edit/maintainers/useSoftwareMaintainers.tsx
@@ -1,198 +1,79 @@
// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 dv4all
+// 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
import {useState,useEffect} from 'react'
-import {useSession} from '~/auth'
-import {createJsonHeaders, extractReturnMessage} from '~/utils/fetchHelpers'
-import logger from '~/utils/logger'
-import useSoftwareContext from '../useSoftwareContext'
-
-export type RawMaintainerOfSoftware = {
- // unique maintainer id
- maintainer: string
- name: string[]
- email: string[]
- affiliation: string[],
-}
-
-export type MaintainerOfSoftware = {
- // unique maintainer id
- account: string
- name: string
- email: string
- affiliation: string,
-}
-
-export async function getMaintainersOfSoftware({software, token, frontend=true}:
- {software: string, token: string,frontend?:boolean}) {
- try {
- let query = `rpc/maintainers_of_software?software_id=${software}`
- let url = `/api/v1/${query}`
- if (frontend === false) {
- url = `${process.env.POSTGREST_URL}/${query}`
- }
-
- const resp = await fetch(url, {
- method: 'GET',
- headers: createJsonHeaders(token)
- })
-
- if (resp.status === 200) {
- const json:RawMaintainerOfSoftware[] = await resp.json()
- return json
- }
- // ERRORS
- logger(`getMaintainersOfSoftware: ${resp.status}:${resp.statusText} software: ${software}`, 'warn')
- return []
- } catch (e: any) {
- logger(`getMaintainersOfSoftware: ${e?.message}`, 'error')
- return []
- }
-}
-export default function useSoftwareMaintainers() {
- const {token} = useSession()
- const {software} = useSoftwareContext()
- const [maintainers, setMaintainers] = useState([])
+import {useSession} from '~/auth'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import {MaintainerProps, rawMaintainersToMaintainers} from '~/components/maintainers/apiMaintainers'
+import {deleteMaintainerFromSoftware, getMaintainersOfSoftware} from './apiSoftwareMaintainers'
+
+export function useSoftwareMaintainers({software}:{software: string}) {
+ const {token,user} = useSession()
+ const {showErrorMessage} = useSnackbar()
+ const [maintainers, setMaintainers] = useState([])
const [loading, setLoading] = useState(true)
- const [loadedSoftware, setLoadedSoftware] = useState('')
+
+ // console.group('useSoftwareMaintainers')
+ // console.log('software...',software)
+ // console.log('token...',token)
+ // console.groupEnd()
useEffect(() => {
let abort = false
async function getMaintainers() {
setLoading(true)
const raw_maintainers = await getMaintainersOfSoftware({
- software: software.id ?? '',
- token,
- frontend:true
+ software,
+ token
})
const maintainers = rawMaintainersToMaintainers(raw_maintainers)
if (abort) return null
// update maintainers state
setMaintainers(maintainers)
// keep track what is loaded
- setLoadedSoftware(software?.id ?? '')
+ // setLoadedSoftware(software?.id ?? '')
// update loading flag
setLoading(false)
}
- if (software.id && token &&
- software.id !== loadedSoftware) {
+ if (software && token) {
getMaintainers()
}
return()=>{abort=true}
- },[software?.id,token,loadedSoftware])
-
- return {maintainers, loading}
-}
-
-export function rawMaintainersToMaintainers(raw_maintainers: RawMaintainerOfSoftware[]) {
- try {
- const maintainers:MaintainerOfSoftware[] = []
- raw_maintainers.forEach(item => {
- let maintainerWithMostInfo: MaintainerOfSoftware | null = null
- let bestScore = -1
- // use name as second loop indicator
- item.name.forEach((name, pos) => {
- let score = 0
- if (name) {
- score += 1
- }
- if (item.email[pos]) {
- score += 1
- }
- if (item.affiliation[pos]) {
- score += 1
- }
+ },[software,token])
- if (score <= bestScore) {
- return
- }
- const maintainer: MaintainerOfSoftware = {
- account: item.maintainer,
- name,
- email: item.email[pos] ? item.email[pos] : '',
- affiliation: item.affiliation[pos] ? item.affiliation[pos] : ''
- }
-
- maintainerWithMostInfo = maintainer
- bestScore = score
+ async function deleteMaintainer(account?:string){
+ if (account && software){
+ const resp = await deleteMaintainerFromSoftware({
+ maintainer: account,
+ software,
+ token
})
- maintainers.push(maintainerWithMostInfo as unknown as MaintainerOfSoftware)
- })
- return maintainers
- } catch (e:any) {
- logger(`rawMaintainersToMaintainers: ${e?.message}`,'error')
- return []
- }
-}
-
-
-export async function deleteMaintainerFromSoftware({maintainer,software,token,frontend=true}:
- {maintainer:string,software:string,token:string,frontend?:boolean}) {
- try {
- let query = `maintainer_for_software?maintainer=eq.${maintainer}&software=eq.${software}`
- let url = `/api/v1/${query}`
- if (frontend === false) {
- url = `${process.env.POSTGREST_URL}/${query}`
+ if (resp.status === 200) {
+ // remove account
+ const newMaintainersList = maintainers.filter(item=>item.account!==account)
+ setMaintainers(newMaintainersList)
+ } else {
+ showErrorMessage(`Failed to remove maintainer. ${resp.message}`)
+ }
}
+ }
- const resp = await fetch(url, {
- method: 'DELETE',
- headers: createJsonHeaders(token)
- })
-
- return extractReturnMessage(resp)
-
- } catch (e: any) {
- logger(`deleteMaintainerFromSoftware: ${e?.message}`, 'error')
- return {
- status: 500,
- message: e?.message
- }
+ // last maintainer can be deleted only by rsd-admin
+ if (maintainers?.length===1 && user?.role!=='rsd_admin'){
+ // disable delete button on last maintainer
+ maintainers[0].disableDelete = true
}
-}
-export async function softwareMaintainerLink({software, account, token}:
- { software: string, account: string, token: string }) {
- try {
- // POST
- const url = '/api/v1/invite_maintainer_for_software'
- const resp = await fetch(url, {
- method: 'POST',
- headers: {
- ...createJsonHeaders(token),
- 'Prefer': 'return=headers-only'
- },
- body: JSON.stringify({
- software,
- created_by:account
- })
- })
- if (resp.status === 201) {
- const id = resp.headers.get('location')?.split('.')[1]
- if (id) {
- const link = `${location.origin}/invite/software/${id}`
- return {
- status: 201,
- message: link
- }
- }
- return {
- status: 400,
- message: 'Id is missing'
- }
- }
- return extractReturnMessage(resp, software ?? '')
- } catch (e: any) {
- logger(`createMaintainerLink: ${e?.message}`, 'error')
- return {
- status: 500,
- message: e?.message
- }
+ return {
+ loading,
+ maintainers,
+ deleteMaintainer
}
}
diff --git a/frontend/components/software/overview/SoftwareHighlights.tsx b/frontend/components/software/overview/SoftwareHighlights.tsx
index 291cd2c28..5856c69ae 100644
--- a/frontend/components/software/overview/SoftwareHighlights.tsx
+++ b/frontend/components/software/overview/SoftwareHighlights.tsx
@@ -1,9 +1,9 @@
// SPDX-FileCopyrightText: 2023 - 2024 Christian Meeßen (GFZ)
+// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2023 - 2024 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
// SPDX-FileCopyrightText: 2023 dv4all
//
// SPDX-License-Identifier: Apache-2.0
@@ -24,14 +24,14 @@ export default function SoftwareHighlights({highlights}: { highlights: SoftwareH
const {host} = useRsdSettings()
const router = useRouter()
- // if there are no hightlights we do not show this section
+ // if there are no highlights we do not show this section
if (highlights.length===0) return null
// show carousel only on first page
if (typeof router.query.page === 'string' && parseInt(router.query.page) > 1) return null
return (
-
+
// SPDX-FileCopyrightText: 2022 Jesús García Gonzalez (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center)
//
// SPDX-License-Identifier: Apache-2.0
@@ -18,7 +18,7 @@ module.exports = {
output: 'standalone',
// enable source maps in production?
productionBrowserSourceMaps: true,
- // disable strict mode if you want react to render compent once
+ // disable strict mode if you want react to render component once
// see for more info https://nextjs.org/docs/api-reference/next.config.js/react-strict-mode
reactStrictMode: false,
eslint: {
@@ -39,6 +39,17 @@ module.exports = {
]
},
+ async redirects() {
+ return [
+ // default community redirect to software page
+ {
+ source: '/communities/:slug',
+ destination: '/communities/:slug/software',
+ permanent: true,
+ }
+ ]
+ },
+
webpack(config) {
config.module.rules.push({
test: /\.svg$/i,
diff --git a/frontend/pages/communities/[slug]/about.tsx b/frontend/pages/communities/[slug]/about.tsx
new file mode 100644
index 000000000..022492d26
--- /dev/null
+++ b/frontend/pages/communities/[slug]/about.tsx
@@ -0,0 +1,126 @@
+// 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 {EditCommunityProps, 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 {isCommunityMaintainer} from '~/auth/permissions/isMaintainerOfCommunity'
+import AboutCommunityPage from '~/components/communities/about'
+import CommunityPage from '~/components/communities/CommunityPage'
+import {getKeywordsByCommunity} from '~/components/communities/settings/general/apiCommunityKeywords'
+
+type CommunityAboutPage={
+ community: EditCommunityProps,
+ slug: string[],
+ isMaintainer: boolean,
+ rsd_page_rows: number,
+ rsd_page_layout: LayoutType
+}
+
+export default function CommunityAboutPage({
+ community,slug,isMaintainer,
+ rsd_page_rows, rsd_page_layout
+}:CommunityAboutPage) {
+
+ // console.group('CommunityAboutPage')
+ // 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 */}
+
+
+ {/* */}
+
+
+
+ >
+ )
+}
+
+
+// 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 || community?.description === null){
+ // returning notFound triggers 404 page
+ return {
+ notFound: true,
+ }
+ }
+ // get info if the user is maintainer
+ const [isMaintainer,keywords] = await Promise.all([
+ isCommunityMaintainer({
+ community: community.id ?? '',
+ role: user?.role,
+ account: user?.account,
+ token
+ }),
+ getKeywordsByCommunity(community.id,token)
+ ])
+
+ return {
+ // passed to the page component as props
+ props: {
+ community:{
+ ...community,
+ // use keywords for editing
+ keywords
+ },
+ 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/[slug]/settings.tsx b/frontend/pages/communities/[slug]/settings.tsx
index 89bc5e4c9..d34b30f64 100644
--- a/frontend/pages/communities/[slug]/settings.tsx
+++ b/frontend/pages/communities/[slug]/settings.tsx
@@ -3,8 +3,125 @@
//
// SPDX-License-Identifier: Apache-2.0
-export default function CommunitySettings() {
+import {GetServerSidePropsContext} from 'next'
+
+import {app} from '~/config/app'
+import {getUserFromToken} from '~/auth'
+import {isCommunityMaintainer} from '~/auth/permissions/isMaintainerOfCommunity'
+import {getUserSettings} from '~/utils/userSettings'
+import {EditCommunityProps, 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 CommunitySettingsContent from '~/components/communities/settings'
+import CommunityPage from '~/components/communities/CommunityPage'
+import {getKeywordsByCommunity} from '~/components/communities/settings/general/apiCommunityKeywords'
+
+type CommunitySoftwareProps={
+ community: EditCommunityProps,
+ slug: string[],
+ isMaintainer: boolean,
+ rsd_page_rows: number,
+ rsd_page_layout: LayoutType
+}
+
+export default function CommunitySettingsPage({
+ community,slug, isMaintainer,
+ rsd_page_rows, rsd_page_layout
+}:CommunitySoftwareProps) {
+
+ // console.group('CommunitySettingsPage')
+ // 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 (
- Software community page - settings
+ <>
+ {/* Page Head meta tags */}
+
+
+
+
+
+ >
)
}
+
+// 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,keywords] = await Promise.all([
+ isCommunityMaintainer({
+ community: community.id ?? '',
+ role: user?.role,
+ account: user?.account,
+ token
+ }),
+ getKeywordsByCommunity(community.id,token)
+ ])
+
+ return {
+ // passed to the page component as props
+ props: {
+ community:{
+ ...community,
+ // use keywords for editing
+ keywords
+ },
+ 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/[slug]/software.tsx b/frontend/pages/communities/[slug]/software.tsx
index 156e15de1..4c5e5a2f0 100644
--- a/frontend/pages/communities/[slug]/software.tsx
+++ b/frontend/pages/communities/[slug]/software.tsx
@@ -8,20 +8,16 @@ 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 {EditCommunityProps, 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'
+import CommunityPage from '~/components/communities/CommunityPage'
+import {getKeywordsByCommunity} from '~/components/communities/settings/general/apiCommunityKeywords'
type CommunitySoftwareProps={
- community: CommunityListProps,
+ community: EditCommunityProps,
slug: string[],
isMaintainer: boolean,
rsd_page_rows: number,
@@ -33,14 +29,13 @@ export default function CommunitySoftwarePage({
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()
-
+ // 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
@@ -57,49 +52,16 @@ export default function CommunitySoftwarePage({
description={getMetaDescription()}
/>
-
-
- {/* COMMUNITY HEADER */}
-
-
-
- {/* TABS */}
-
-
-
- {/* TAB CONTENT */}
-
- {/* */}
- Community software - TO DO!
-
-
-
+
+ Community software - TO DO!
+
>
)
}
@@ -129,17 +91,24 @@ export async function getServerSideProps(context:GetServerSidePropsContext) {
}
}
// get info if the user is maintainer
- const isMaintainer = await isCommunityMaintainer({
- community: community.id,
- role: user?.role,
- account: user?.account,
- token
- })
+ const [isMaintainer,keywords] = await Promise.all([
+ isCommunityMaintainer({
+ community: community.id ?? '',
+ role: user?.role,
+ account: user?.account,
+ token
+ }),
+ getKeywordsByCommunity(community.id,token)
+ ])
return {
// passed to the page component as props
props: {
- community,
+ community:{
+ ...community,
+ // use keywords for editing
+ keywords
+ },
slug: [params?.slug],
tab: query?.tab ?? null,
isMaintainer,
diff --git a/frontend/pages/communities/index.tsx b/frontend/pages/communities/index.tsx
index 9ff8b9c9b..ffb4f103b 100644
--- a/frontend/pages/communities/index.tsx
+++ b/frontend/pages/communities/index.tsx
@@ -5,6 +5,7 @@
import {useState} from 'react'
import {GetServerSidePropsContext} from 'next/types'
+import Link from 'next/link'
import Pagination from '@mui/material/Pagination'
import PaginationItem from '@mui/material/PaginationItem'
@@ -22,7 +23,6 @@ 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'
@@ -113,9 +113,9 @@ export default function CommunitiesOverview({count,page,rows,layout,search,commu
renderItem={item => {
if (item.page !== null) {
return (
-
+
-
+
)
} else {
return (
diff --git a/frontend/pages/invite/community/[id].tsx b/frontend/pages/invite/community/[id].tsx
new file mode 100644
index 000000000..f8d54f921
--- /dev/null
+++ b/frontend/pages/invite/community/[id].tsx
@@ -0,0 +1,123 @@
+// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all)
+// SPDX-FileCopyrightText: 2022 - 2023 dv4all
+// 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 Head from 'next/head'
+import Link from 'next/link'
+
+import {claimCommunityMaintainerInvite} from '~/auth/api/authHelpers'
+import {getAccountFromToken} from '~/auth/jwtUtils'
+import ContentInTheMiddle from '~/components/layout/ContentInTheMiddle'
+import DefaultLayout from '~/components/layout/DefaultLayout'
+import PageErrorMessage from '~/components/layout/PageErrorMessage'
+import PageTitle from '~/components/layout/PageTitle'
+
+type InviteCommunityMaintainerProps = {
+ communityInfo: {
+ id: string,
+ name: string,
+ slug: string
+ }|null,
+ error: {
+ status: number,
+ message: string
+ }|null
+}
+
+export default function InviteCommunityMaintainer({communityInfo, error}: InviteCommunityMaintainerProps) {
+
+ // console.group('InviteCommunityMaintainer')
+ // console.log('communityInfo..', communityInfo)
+ // console.log('error..', error)
+ // console.groupEnd()
+
+ function renderContent() {
+ if (typeof error == 'undefined' || error === null) {
+ return (
+
+
+ You are now a maintainer of {communityInfo?.name ?? 'missing'}!
+
+
+ Open community page
+
+
+
+ )
+ }
+ return (
+
+ )
+ }
+
+ return (
+
+
+ Community Maintainer Invite | RSD
+
+
+ {renderContent()}
+
+ )
+}
+
+// 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 from page-query
+ const {params,req} = context
+ // extract rsd_token
+ const token = req?.cookies['rsd_token']
+ // extract id
+ const id = params?.id
+
+ // extract account id from token
+ const account = getAccountFromToken(token)
+ // console.log('account...', account)
+ if (typeof account == 'undefined') {
+ return {
+ props: {
+ projectInfo: null,
+ error: {
+ status: 401,
+ message: 'You need to sign in to RSD first!'
+ }
+ }
+ }
+ }
+
+ if (id) {
+ // claim the software maintainer invite
+ const resp:InviteCommunityMaintainerProps = await claimCommunityMaintainerInvite({
+ id: id.toString(),
+ token
+ })
+ // returns id of community
+ if (resp?.communityInfo?.id) {
+ // pass software info to page component as props
+ return {
+ props: {
+ communityInfo: resp.communityInfo
+ }
+ }
+ }
+ // error from first request
+ return {
+ props: resp
+ }
+ }
+
+ return {
+ props: {
+ communityInfo:null,
+ error: {
+ status: 404,
+ message: 'This invite is invalid. It\'s missing invite id. Please ask the community maintainer to provide you a new link.'
+ }
+ }
+ }
+}
diff --git a/frontend/pages/invite/organisation/[id].tsx b/frontend/pages/invite/organisation/[id].tsx
index e475e6ee7..7108dac7d 100644
--- a/frontend/pages/invite/organisation/[id].tsx
+++ b/frontend/pages/invite/organisation/[id].tsx
@@ -1,5 +1,7 @@
// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 - 2023 dv4all
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0
@@ -132,7 +134,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
organisationInfo:null,
error: {
status: 404,
- message: 'This invite is invalid. It\'s missing invite id. Please ask the organisation mantainer to provide you a new link.'
+ message: 'This invite is invalid. It\'s missing invite id. Please ask the organisation maintainer to provide you a new link.'
}
}
}
diff --git a/frontend/pages/organisations/[...slug].tsx b/frontend/pages/organisations/[...slug].tsx
index 8c18bac75..c7d5ba3dd 100644
--- a/frontend/pages/organisations/[...slug].tsx
+++ b/frontend/pages/organisations/[...slug].tsx
@@ -1,9 +1,8 @@
// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 - 2023 dv4all
-// SPDX-FileCopyrightText: 2023 - 2024 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-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center)
//
// SPDX-License-Identifier: Apache-2.0
@@ -99,7 +98,7 @@ export default function OrganisationPage({
{/* TAB CONTENT */}
-
+
diff --git a/frontend/styles/custom.css b/frontend/styles/custom.css
index dee2d556f..5c4668b01 100644
--- a/frontend/styles/custom.css
+++ b/frontend/styles/custom.css
@@ -1,8 +1,8 @@
/*
* 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
*/
@@ -179,4 +179,11 @@ body {
opacity: 0;
animation-name: fadeInUp;
-webkit-animation-name: fadeInUp;
-}
\ No newline at end of file
+}
+
+/* fallback styles when JS is not supported */
+@media (scripting: none) {
+ .hide-on-no-script{
+ display: none;
+ }
+}
diff --git a/frontend/styles/global.css b/frontend/styles/global.css
index f6f50293a..50b5641b4 100644
--- a/frontend/styles/global.css
+++ b/frontend/styles/global.css
@@ -1,8 +1,8 @@
/*
* 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
*/
@@ -26,7 +26,7 @@ body{
NOTE ! when changing this value we need to update
useDisableScrollLock hook in utils folder too.
*/
- min-width: 23rem;
+ min-width: 30rem;
}
#__next {
diff --git a/frontend/utils/jest/WithCommunityContext.tsx b/frontend/utils/jest/WithCommunityContext.tsx
new file mode 100644
index 000000000..2580ce897
--- /dev/null
+++ b/frontend/utils/jest/WithCommunityContext.tsx
@@ -0,0 +1,22 @@
+// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) (dv4all)
+// SPDX-FileCopyrightText: 2022 - 2023 dv4all
+// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
+// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {CommunityProvider} from '~/components/communities/context/index'
+
+/**
+ * Wraps tested component with the EditProjectProvider (context)
+ * @param param0
+ * @returns
+ */
+export function WithCommunityContext(props?:any) {
+ return (
+
+ )
+}
diff --git a/frontend/utils/useDisableScrollLock.tsx b/frontend/utils/useDisableScrollLock.tsx
index 8a2e66de4..95de514f4 100644
--- a/frontend/utils/useDisableScrollLock.tsx
+++ b/frontend/utils/useDisableScrollLock.tsx
@@ -1,5 +1,7 @@
// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 dv4all
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0
@@ -14,6 +16,6 @@ import useMediaQuery from '@mui/material/useMediaQuery'
* @returns boolean
*/
export default function useDisableScrollLock() {
- const disable = useMediaQuery('(max-width:23rem)')
+ const disable = useMediaQuery('(max-width:30rem)')
return disable
}
From f8516f70f2d4cd6740f70cbb80ee8ba5aa728ab3 Mon Sep 17 00:00:00 2001
From: Dusan Mijatovic
Date: Thu, 30 May 2024 11:04:50 +0200
Subject: [PATCH 4/9] feat: implement community software page and refactor
organisation software page (#1213) feat: add seprate tabs for pending
requests and rejected requirest fix: data generation fix formatting (main.js)
---
data-generation/main.js | 68 ++--
database/124-community-views.sql | 309 +++++++++++++++++-
.../permissions/isMaintainerOfCommunity.ts | 2 +-
.../components/communities/CommunityPage.tsx | 2 +
.../components/communities/apiCommunities.ts | 77 ++++-
.../components/communities/context/index.tsx | 2 +
.../communities/overview/CommunityCard.tsx | 7 +-
.../overview/CommunityListItem.tsx | 7 +-
.../communities/overview/CommunityMetrics.tsx | 17 +-
.../software/CommunitySoftwareOverview.tsx | 84 +++++
.../software/apiCommunitySoftware.ts | 246 ++++++++++++++
.../software/card/AdminSoftwareGridCard.tsx | 69 ++++
.../software/card/StatusBanner.tsx | 56 ++++
.../software/card/useAdminMenuOptions.tsx | 59 ++++
.../software/card/useSoftwareCardActions.tsx | 75 +++++
.../filters/OrderCommunitySoftwareBy.tsx | 45 +++
.../filters/apiCommunitySoftwareFilters.ts | 150 +++++++++
.../communities/software/filters/index.tsx | 88 +++++
.../components/communities/software/index.tsx | 105 ++++++
.../software/list/AdminSoftwareListItem.tsx | 54 +++
.../communities/software/search/index.tsx | 92 ++++++
.../communities/tabs/CommunityTabItems.tsx | 26 +-
.../components/communities/tabs/index.tsx | 15 +-
.../FiltersModal.tsx} | 30 +-
.../filter/useFilterQueryChange.test.tsx | 70 ++++
.../filter/useFilterQueryChange.tsx | 60 ++++
.../projects/filters/OrgOrderProjectsBy.tsx | 56 +++-
.../filters/OrgProjectFiltersModal.tsx | 60 ----
.../filters/OrgProjectKeywordsFilter.tsx | 29 --
.../filters/OrgProjectOrganisationsFilter.tsx | 28 --
.../filters/OrgResearchDomainFilter.tsx | 34 --
.../{OrgProjectFilters.tsx => index.tsx} | 47 +--
.../filters/useProjectOrderOptions.tsx | 47 ---
.../organisation/projects/index.tsx | 2 +-
.../search/OrgSearchProjectSection.tsx | 28 +-
.../projects/useOrganisationProjects.tsx | 7 +-
.../organisation/projects/useQueryChange.tsx | 51 +--
.../software/filters/OrgOrderSoftwareBy.tsx | 55 +++-
.../filters/OrgSoftwareKeywordsFilter.tsx | 29 --
.../filters/OrgSoftwareLanguagesFilter.tsx | 28 --
.../filters/OrgSoftwareLicensesFilter.tsx | 29 --
.../{OrgSoftwareFilters.tsx => index.tsx} | 46 ++-
.../filters/useSoftwareOrderOptions.tsx | 45 ---
.../software/filters/useSoftwareParams.tsx | 11 +-
.../organisation/software/index.tsx | 2 +-
.../search/OrgSearchSoftwareSection.tsx | 24 +-
.../software/useOrganisationSoftware.tsx | 6 +-
.../overview/filters/ProjectFiltersModal.tsx | 9 +-
.../filters/ProjectKeywordsFilter.tsx | 28 --
.../filters/ProjectOrganisationsFilter.tsx | 33 --
.../filters/ProjectResearchDomainFilter.tsx | 34 --
.../filters/{ProjectFilters.tsx => index.tsx} | 46 +--
.../filters/SoftwareKeywordsFilter.tsx | 82 -----
.../filters/SoftwareLanguagesFilter.tsx | 28 --
.../filters/SoftwareLicensesFilter.tsx | 29 --
.../software/overview/filters/index.tsx | 50 +--
.../overview/search/SoftwareSearchSection.tsx | 6 +-
frontend/pages/communities/[slug]/about.tsx | 17 +-
.../pages/communities/[slug]/rejected.tsx | 156 +++++++++
.../pages/communities/[slug]/requests.tsx | 156 +++++++++
.../pages/communities/[slug]/settings.tsx | 17 +-
.../pages/communities/[slug]/software.tsx | 86 +++--
frontend/pages/communities/index.tsx | 2 +-
frontend/pages/projects/index.tsx | 6 +-
frontend/utils/pagination.ts | 5 +
65 files changed, 2376 insertions(+), 893 deletions(-)
create mode 100644 frontend/components/communities/software/CommunitySoftwareOverview.tsx
create mode 100644 frontend/components/communities/software/apiCommunitySoftware.ts
create mode 100644 frontend/components/communities/software/card/AdminSoftwareGridCard.tsx
create mode 100644 frontend/components/communities/software/card/StatusBanner.tsx
create mode 100644 frontend/components/communities/software/card/useAdminMenuOptions.tsx
create mode 100644 frontend/components/communities/software/card/useSoftwareCardActions.tsx
create mode 100644 frontend/components/communities/software/filters/OrderCommunitySoftwareBy.tsx
create mode 100644 frontend/components/communities/software/filters/apiCommunitySoftwareFilters.ts
create mode 100644 frontend/components/communities/software/filters/index.tsx
create mode 100644 frontend/components/communities/software/index.tsx
create mode 100644 frontend/components/communities/software/list/AdminSoftwareListItem.tsx
create mode 100644 frontend/components/communities/software/search/index.tsx
rename frontend/components/{organisation/software/filters/OrgSoftwareFiltersModal.tsx => filter/FiltersModal.tsx} (67%)
create mode 100644 frontend/components/filter/useFilterQueryChange.test.tsx
create mode 100644 frontend/components/filter/useFilterQueryChange.tsx
delete mode 100644 frontend/components/organisation/projects/filters/OrgProjectFiltersModal.tsx
delete mode 100644 frontend/components/organisation/projects/filters/OrgProjectKeywordsFilter.tsx
delete mode 100644 frontend/components/organisation/projects/filters/OrgProjectOrganisationsFilter.tsx
delete mode 100644 frontend/components/organisation/projects/filters/OrgResearchDomainFilter.tsx
rename frontend/components/organisation/projects/filters/{OrgProjectFilters.tsx => index.tsx} (68%)
delete mode 100644 frontend/components/organisation/projects/filters/useProjectOrderOptions.tsx
delete mode 100644 frontend/components/organisation/software/filters/OrgSoftwareKeywordsFilter.tsx
delete mode 100644 frontend/components/organisation/software/filters/OrgSoftwareLanguagesFilter.tsx
delete mode 100644 frontend/components/organisation/software/filters/OrgSoftwareLicensesFilter.tsx
rename frontend/components/organisation/software/filters/{OrgSoftwareFilters.tsx => index.tsx} (61%)
delete mode 100644 frontend/components/organisation/software/filters/useSoftwareOrderOptions.tsx
delete mode 100644 frontend/components/projects/overview/filters/ProjectKeywordsFilter.tsx
delete mode 100644 frontend/components/projects/overview/filters/ProjectOrganisationsFilter.tsx
delete mode 100644 frontend/components/projects/overview/filters/ProjectResearchDomainFilter.tsx
rename frontend/components/projects/overview/filters/{ProjectFilters.tsx => index.tsx} (61%)
delete mode 100644 frontend/components/software/overview/filters/SoftwareKeywordsFilter.tsx
delete mode 100644 frontend/components/software/overview/filters/SoftwareLanguagesFilter.tsx
delete mode 100644 frontend/components/software/overview/filters/SoftwareLicensesFilter.tsx
create mode 100644 frontend/pages/communities/[slug]/rejected.tsx
create mode 100644 frontend/pages/communities/[slug]/requests.tsx
diff --git a/data-generation/main.js b/data-generation/main.js
index bd5498068..bc489aeea 100644
--- a/data-generation/main.js
+++ b/data-generation/main.js
@@ -91,7 +91,7 @@ function generateMentions(amountExtra = 100) {
return result;
}
-function generateSoftware(amount=500) {
+function generateSoftware(amount = 500) {
// real software has a real concept DOI
const amountRealSoftware = Math.min(conceptDois.length, amount);
const brandNames = [];
@@ -358,7 +358,7 @@ function generateSoftwareHighlights(ids) {
return result;
}
-function generateProjects(amount=500) {
+function generateProjects(amount = 500) {
const result = [];
const projectStatuses = ['finished', 'running', 'starting'];
@@ -509,7 +509,7 @@ function generateUrlsForProjects(ids) {
return result;
}
-function generateOrganisations(amount=500) {
+function generateOrganisations(amount = 500) {
const rorIds = [
'https://ror.org/000k1q888',
'https://ror.org/006hf6230',
@@ -596,14 +596,18 @@ function generateCommunities(amount = 50) {
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));
+ 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,
+ logo_id:
+ faker.helpers.maybe(() => localOrganisationLogoIds[index % localImageIds.length], {probability: 0.8}) ??
+ null,
});
}
@@ -656,7 +660,7 @@ function generateNews() {
},
{
title: 'Sunsetting the RSD',
- slug: 'sunsetting-the-rsd'
+ slug: 'sunsetting-the-rsd',
},
{
title: 'The last package you will ever need',
@@ -672,19 +676,19 @@ function generateNews() {
},
{
title: 'Rewriting the RSD in CrabLang',
- slug: 'rewrite-rsd-crablang'
+ slug: 'rewrite-rsd-crablang',
},
{
title: 'The RSD joins forces with Big Company (tm)',
- slug: 'rsd-joins-big-company'
+ slug: 'rsd-joins-big-company',
},
{
- title: '3 features you didn\'t know about',
- slug: '3-features'
+ title: "3 features you didn't know about",
+ slug: '3-features',
},
{
title: 'Interview with RSD founders',
- slug: 'interview-rsd-founders'
+ slug: 'interview-rsd-founders',
},
];
@@ -761,7 +765,11 @@ function generateProjectForOrganisation(idsProjects, idsOrganisations) {
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'}];
+ const statuses = [
+ {weight: 1, value: 'pending'},
+ {weight: 8, value: 'approved'},
+ {weight: 1, value: 'rejected'},
+ ];
result.forEach(entry => {
entry['status'] = faker.helpers.weightedArrayElement(statuses);
});
@@ -1025,7 +1033,7 @@ const communityPromise = postToBackend('/community', generateCommunities())
.then(resp => resp.json())
.then(async commArray => {
idsCommunities = commArray.map(comm => comm['id']);
- postToBackend('/keyword_for_community', generateKeywordsForEntity(idsCommunities, idsKeywords, 'community'))
+ postToBackend('/keyword_for_community', generateKeywordsForEntity(idsCommunities, idsKeywords, 'community'));
});
await postToBackend('/meta_pages', generateMetaPages()).then(() => console.log('meta pages done'));
@@ -1036,14 +1044,34 @@ 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, communityPromise]).then(() => console.log('sw, pj, org, comm 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)))
+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'));
console.log('Done');
diff --git a/database/124-community-views.sql b/database/124-community-views.sql
index 93d1abcff..ad4aa5843 100644
--- a/database/124-community-views.sql
+++ b/database/124-community-views.sql
@@ -5,26 +5,69 @@
-- SPDX-License-Identifier: Apache-2.0
-- Software count by community
-CREATE FUNCTION software_count_by_community() RETURNS TABLE (
+-- BY DEFAULT we return count of approved software
+-- IF public is FALSE we return approved software that is not published too
+CREATE FUNCTION software_count_by_community(public BOOLEAN DEFAULT TRUE) RETURNS TABLE (
community UUID,
software_cnt BIGINT
) LANGUAGE sql STABLE AS
$$
SELECT
- community.id,
- COUNT(software_for_community.software) AS software_cnt
+ software_for_community.community,
+ COUNT(DISTINCT software_for_community.software) AS software_cnt
FROM
- community
-LEFT JOIN
- software_for_community ON community.id = software_for_community.community
+ software_for_community
+WHERE
+ software_for_community.status = 'approved' AND (
+ NOT public OR software IN (SELECT id FROM software WHERE is_published)
+ )
GROUP BY
- community.id
+ software_for_community.community
;
$$;
+-- Pending software count by community
+-- BY DEFAULT we return count of approved software
+-- IF public is FALSE we return total count (as far as RLS allows)
+CREATE FUNCTION pending_count_by_community() RETURNS TABLE (
+ community UUID,
+ pending_cnt BIGINT
+) LANGUAGE sql STABLE AS
+$$
+SELECT
+ software_for_community.community,
+ COUNT(DISTINCT software_for_community.software) AS pending_cnt
+FROM
+ software_for_community
+WHERE
+ software_for_community.status = 'pending'
+GROUP BY
+ software_for_community.community
+;
+$$;
--- Keywords with the count used by
--- by search to show existing keywords with the count
+-- Rejected software count by community
+-- BY DEFAULT we return count of approved software
+-- IF public is FALSE we return total count (as far as RLS allows)
+CREATE FUNCTION rejected_count_by_community() RETURNS TABLE (
+ community UUID,
+ rejected_cnt BIGINT
+) LANGUAGE sql STABLE AS
+$$
+SELECT
+ software_for_community.community,
+ COUNT(DISTINCT software_for_community.software) AS rejected_cnt
+FROM
+ software_for_community
+WHERE
+ software_for_community.status = 'rejected'
+GROUP BY
+ software_for_community.community
+;
+$$;
+
+-- Keywords with the count used by keyword settings
+-- to show existing keywords with the count
CREATE FUNCTION keyword_count_for_community() RETURNS TABLE (
id UUID,
keyword CITEXT,
@@ -94,8 +137,9 @@ $$;
-- rpc for community overview page
--- incl. software count and keyword list (for card)
-CREATE FUNCTION communities_overview() RETURNS TABLE (
+-- BY DEFAULT we return count of approved software
+-- IF public is FALSE we return total count (as far as RLS allows)
+CREATE FUNCTION communities_overview(public BOOLEAN DEFAULT TRUE) RETURNS TABLE (
id UUID,
slug VARCHAR,
name VARCHAR,
@@ -103,6 +147,8 @@ CREATE FUNCTION communities_overview() RETURNS TABLE (
logo_id VARCHAR,
primary_maintainer UUID,
software_cnt BIGINT,
+ pending_cnt BIGINT,
+ rejected_cnt BIGINT,
keywords CITEXT[],
description VARCHAR,
created_at TIMESTAMPTZ
@@ -116,14 +162,253 @@ SELECT
community.logo_id,
community.primary_maintainer,
software_count_by_community.software_cnt,
+ pending_count_by_community.pending_cnt,
+ rejected_count_by_community.rejected_cnt,
keyword_filter_for_community.keywords,
community.description,
community.created_at
FROM
community
LEFT JOIN
- software_count_by_community() ON community.id = software_count_by_community.community
+ software_count_by_community(public) ON community.id = software_count_by_community.community
+LEFT JOIN
+ pending_count_by_community() ON community.id = pending_count_by_community.community
+LEFT JOIN
+ rejected_count_by_community() ON community.id = rejected_count_by_community.community
LEFT JOIN
keyword_filter_for_community() ON community.id=keyword_filter_for_community.community
;
$$;
+
+
+-- SOFTWARE info by community
+-- we filter this view at least by community_id (uuid)
+CREATE FUNCTION software_by_community(community_id UUID) RETURNS TABLE (
+ id UUID,
+ slug VARCHAR,
+ brand_name VARCHAR,
+ short_statement VARCHAR,
+ image_id VARCHAR,
+ is_published BOOLEAN,
+ updated_at TIMESTAMPTZ,
+ status request_status,
+ keywords CITEXT[],
+ prog_lang TEXT[],
+ licenses VARCHAR[],
+ contributor_cnt BIGINT,
+ mention_cnt BIGINT
+) LANGUAGE sql STABLE AS
+$$
+
+SELECT DISTINCT ON (software.id)
+ software.id,
+ software.slug,
+ software.brand_name,
+ software.short_statement,
+ software.image_id,
+ software.is_published,
+ software.updated_at,
+ software_for_community.status,
+ keyword_filter_for_software.keywords,
+ prog_lang_filter_for_software.prog_lang,
+ license_filter_for_software.licenses,
+ count_software_contributors.contributor_cnt,
+ count_software_mentions.mention_cnt
+FROM
+ software
+LEFT JOIN
+ software_for_community ON software.id=software_for_community.software
+LEFT JOIN
+ count_software_contributors() ON software.id=count_software_contributors.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
+WHERE
+ software_for_community.community = community_id
+;
+$$;
+
+
+-- SOFTWARE OF COMMUNITY LIST FOR SEARCH
+-- WITH keywords, programming languages and licenses for filtering
+CREATE FUNCTION software_by_community_search(
+ community_id UUID,
+ search VARCHAR
+) RETURNS TABLE (
+ id UUID,
+ slug VARCHAR,
+ brand_name VARCHAR,
+ short_statement VARCHAR,
+ image_id VARCHAR,
+ is_published BOOLEAN,
+ updated_at TIMESTAMPTZ,
+ status request_status,
+ keywords CITEXT[],
+ prog_lang TEXT[],
+ licenses VARCHAR[],
+ contributor_cnt BIGINT,
+ mention_cnt BIGINT
+) LANGUAGE sql STABLE AS
+$$
+SELECT DISTINCT ON (software.id)
+ software.id,
+ software.slug,
+ software.brand_name,
+ software.short_statement,
+ software.image_id,
+ software.is_published,
+ software.updated_at,
+ software_for_community.status,
+ keyword_filter_for_software.keywords,
+ prog_lang_filter_for_software.prog_lang,
+ license_filter_for_software.licenses,
+ count_software_contributors.contributor_cnt,
+ count_software_mentions.mention_cnt
+FROM
+ software
+LEFT JOIN
+ software_for_community ON software.id=software_for_community.software
+LEFT JOIN
+ count_software_contributors() ON software.id=count_software_contributors.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
+WHERE
+ software_for_community.community = community_id AND (
+ software.brand_name ILIKE CONCAT('%', search, '%')
+ OR
+ software.slug ILIKE CONCAT('%', search, '%')
+ OR
+ software.short_statement ILIKE CONCAT('%', search, '%')
+ OR
+ keyword_filter_for_software.keywords_text ILIKE CONCAT('%', search, '%')
+ )
+ORDER BY
+ software.id,
+ CASE
+ WHEN brand_name ILIKE search THEN 0
+ WHEN brand_name ILIKE CONCAT(search, '%') THEN 1
+ WHEN brand_name ILIKE CONCAT('%', search, '%') THEN 2
+ ELSE 3
+ END,
+ CASE
+ WHEN slug ILIKE search THEN 0
+ WHEN slug ILIKE CONCAT(search, '%') THEN 1
+ WHEN slug ILIKE CONCAT('%', search, '%') THEN 2
+ ELSE 3
+ END,
+ CASE
+ WHEN short_statement ILIKE search THEN 0
+ WHEN short_statement ILIKE CONCAT(search, '%') THEN 1
+ WHEN short_statement ILIKE CONCAT('%', search, '%') THEN 2
+ ELSE 3
+ END
+;
+$$;
+
+
+-- REACTIVE KEYWORD FILTER WITH COUNTS FOR SOFTWARE
+-- PROVIDES AVAILABLE KEYWORDS FOR APPLIED FILTERS
+CREATE FUNCTION com_software_keywords_filter(
+ community_id UUID,
+ software_status request_status DEFAULT 'approved',
+ search_filter TEXT DEFAULT '',
+ keyword_filter CITEXT[] DEFAULT '{}',
+ prog_lang_filter TEXT[] DEFAULT '{}',
+ license_filter VARCHAR[] DEFAULT '{}'
+) RETURNS TABLE (
+ keyword CITEXT,
+ keyword_cnt INTEGER
+) LANGUAGE sql STABLE AS
+$$
+SELECT
+ UNNEST(keywords) AS keyword,
+ COUNT(id) AS keyword_cnt
+FROM
+ software_by_community_search(community_id,search_filter)
+WHERE
+ software_by_community_search.status = software_status
+ AND
+ COALESCE(keywords, '{}') @> keyword_filter
+ AND
+ COALESCE(prog_lang, '{}') @> prog_lang_filter
+ AND
+ COALESCE(licenses, '{}') @> license_filter
+GROUP BY
+ keyword
+;
+$$;
+
+-- REACTIVE PROGRAMMING LANGUAGES WITH COUNTS FOR SOFTWARE
+-- PROVIDES AVAILABLE PROGRAMMING LANGUAGES FOR APPLIED FILTERS
+CREATE FUNCTION com_software_languages_filter(
+ community_id UUID,
+ software_status request_status DEFAULT 'approved',
+ search_filter TEXT DEFAULT '',
+ keyword_filter CITEXT[] DEFAULT '{}',
+ prog_lang_filter TEXT[] DEFAULT '{}',
+ license_filter VARCHAR[] DEFAULT '{}'
+) RETURNS TABLE (
+ prog_language TEXT,
+ prog_language_cnt INTEGER
+) LANGUAGE sql STABLE AS
+$$
+SELECT
+ UNNEST(prog_lang) AS prog_language,
+ COUNT(id) AS prog_language_cnt
+FROM
+ software_by_community_search(community_id,search_filter)
+WHERE
+ software_by_community_search.status = software_status
+ AND
+ COALESCE(keywords, '{}') @> keyword_filter
+ AND
+ COALESCE(prog_lang, '{}') @> prog_lang_filter
+ AND
+ COALESCE(licenses, '{}') @> license_filter
+GROUP BY
+ prog_language
+;
+$$;
+
+-- REACTIVE LICENSES FILTER WITH COUNTS FOR SOFTWARE
+-- PROVIDES AVAILABLE LICENSES FOR APPLIED FILTERS
+CREATE FUNCTION com_software_licenses_filter(
+ community_id UUID,
+ software_status request_status DEFAULT 'approved',
+ search_filter TEXT DEFAULT '',
+ keyword_filter CITEXT[] DEFAULT '{}',
+ prog_lang_filter TEXT[] DEFAULT '{}',
+ license_filter VARCHAR[] DEFAULT '{}'
+) RETURNS TABLE (
+ license VARCHAR,
+ license_cnt INTEGER
+) LANGUAGE sql STABLE AS
+$$
+SELECT
+ UNNEST(licenses) AS license,
+ COUNT(id) AS license_cnt
+FROM
+ software_by_community_search(community_id,search_filter)
+WHERE
+ software_by_community_search.status = software_status
+ AND
+ COALESCE(keywords, '{}') @> keyword_filter
+ AND
+ COALESCE(prog_lang, '{}') @> prog_lang_filter
+ AND
+ COALESCE(licenses, '{}') @> license_filter
+GROUP BY
+ license
+;
+$$;
diff --git a/frontend/auth/permissions/isMaintainerOfCommunity.ts b/frontend/auth/permissions/isMaintainerOfCommunity.ts
index 1146a5832..8dda42fa2 100644
--- a/frontend/auth/permissions/isMaintainerOfCommunity.ts
+++ b/frontend/auth/permissions/isMaintainerOfCommunity.ts
@@ -75,7 +75,7 @@ export async function getCommunitiesOfMaintainer({token}:
headers: createJsonHeaders(token)
})
if (resp.status === 200) {
- const json = await resp.json()
+ const json:string[] = await resp.json()
return json
}
// ERRORS AS NOT MAINTAINER
diff --git a/frontend/components/communities/CommunityPage.tsx b/frontend/components/communities/CommunityPage.tsx
index 15582144a..e995e767f 100644
--- a/frontend/components/communities/CommunityPage.tsx
+++ b/frontend/components/communities/CommunityPage.tsx
@@ -59,6 +59,8 @@ export default function CommunityPage({
diff --git a/frontend/components/communities/apiCommunities.ts b/frontend/components/communities/apiCommunities.ts
index 85f71163a..865c3441a 100644
--- a/frontend/components/communities/apiCommunities.ts
+++ b/frontend/components/communities/apiCommunities.ts
@@ -10,6 +10,7 @@ import logger from '~/utils/logger'
import {paginationUrlParams} from '~/utils/postgrestUrl'
import {KeywordForCommunity} from './settings/general/apiCommunityKeywords'
import {Community} from '../admin/communities/apiCommunities'
+import {isCommunityMaintainer} from '~/auth/permissions/isMaintainerOfCommunity'
// New type based on Community but replace
// id with new type
@@ -18,6 +19,8 @@ export type CommunityListProps = Omit & {
id: string,
// additional props
software_cnt: number | null,
+ pending_cnt: number | null,
+ rejected_cnt: number | null,
keywords: string[] | null
}
@@ -89,13 +92,76 @@ type GetCommunityBySlug={
token?:string
}
-export async function getCommunityBySlug({slug,token}:GetCommunityBySlug){
+export async function getCommunityBySlug({slug,user,token}:GetCommunityBySlug){
+ try{
+ // ignore if no slug
+ if (slug===null) return {
+ community: null,
+ isMaintainer:false
+ }
+ // get id from slug
+ const com = await getCommunityIdFromSlug({slug,token})
+ if (com===null) return {
+ community: null,
+ isMaintainer:false
+ }
+ // console.log('com...',com)
+ // get info if the user is maintainer
+ const isMaintainer = await isCommunityMaintainer({
+ community: com.id,
+ role: user?.role,
+ account: user?.account,
+ token
+ })
+ // console.log('isMaintainer...',isMaintainer)
+ // filter on id value
+ let query = `id=eq.${com.id}`
+ if (isMaintainer===true) {
+ //if user is maintainer of this community
+ //we request the counts of all items
+ query +='&public=false'
+ }
+ const url = `${getBaseUrl()}/rpc/communities_overview?${query}`
+ // console.log('url...',url)
+ // 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 {
+ community: json,
+ isMaintainer
+ }
+ }
+ // NOT FOUND
+ logger(`getCommunityBySlug: ${resp.status}:${resp.statusText}`, 'warn')
+ return {
+ community: null,
+ isMaintainer
+ }
+ }catch(e:any){
+ logger(`getCommunityBySlug: ${e?.message}`, 'error')
+ return {
+ community: null,
+ isMaintainer: false
+ }
+ }
+}
+
+export async function getCommunityIdFromSlug({slug,token}:{slug:string,token?:string}){
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}`
+ const url = `${getBaseUrl()}/community?select=id&${query}`
// get community
const resp = await fetch(url, {
@@ -108,19 +174,18 @@ export async function getCommunityBySlug({slug,token}:GetCommunityBySlug){
})
if (resp.status === 200) {
- const json:CommunityListProps = await resp.json()
+ const json:{id:string} = await resp.json()
return json
}
// NOT FOUND
- logger(`getCommunityBySlug: ${resp.status}:${resp.statusText}`, 'warn')
+ logger(`getCommunityId: ${resp.status}:${resp.statusText}`, 'warn')
return null
}catch(e:any){
- logger(`getCommunityBySlug: ${e?.message}`, 'error')
+ logger(`getCommunityId: ${e?.message}`, 'error')
return null
}
}
-
type PatchCommunityProps = {
id: string,
data:{
diff --git a/frontend/components/communities/context/index.tsx b/frontend/components/communities/context/index.tsx
index fe8a1b4a0..d5ca41c9b 100644
--- a/frontend/components/communities/context/index.tsx
+++ b/frontend/components/communities/context/index.tsx
@@ -26,6 +26,8 @@ const emptyCommunity:EditCommunityProps = {
primary_maintainer: null,
logo_id: null,
software_cnt: null,
+ pending_cnt: null,
+ rejected_cnt: null,
keywords: [],
}
diff --git a/frontend/components/communities/overview/CommunityCard.tsx b/frontend/components/communities/overview/CommunityCard.tsx
index 6828a6fe6..828fd4f22 100644
--- a/frontend/components/communities/overview/CommunityCard.tsx
+++ b/frontend/components/communities/overview/CommunityCard.tsx
@@ -19,7 +19,7 @@ export default function CommunityCard({community}:{community:CommunityListProps}
@@ -50,7 +50,10 @@ export default function CommunityCard({community}:{community:CommunityListProps}
{/* Metrics */}
-
+
diff --git a/frontend/components/communities/overview/CommunityListItem.tsx b/frontend/components/communities/overview/CommunityListItem.tsx
index 8d8734763..4b85c8b01 100644
--- a/frontend/components/communities/overview/CommunityListItem.tsx
+++ b/frontend/components/communities/overview/CommunityListItem.tsx
@@ -19,7 +19,7 @@ export default function CommunityListItem({community}:{community:CommunityListPr
{/* software count */}
-
+
diff --git a/frontend/components/communities/overview/CommunityMetrics.tsx b/frontend/components/communities/overview/CommunityMetrics.tsx
index 806729a17..f7e928fc6 100644
--- a/frontend/components/communities/overview/CommunityMetrics.tsx
+++ b/frontend/components/communities/overview/CommunityMetrics.tsx
@@ -5,12 +5,14 @@
import Tooltip from '@mui/material/Tooltip'
import TerminalIcon from '@mui/icons-material/Terminal'
+import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined'
type CommunityMetricsProps = {
software_cnt: number
+ pending_cnt: number
}
-export default function CommunityMetrics({software_cnt}:CommunityMetricsProps) {
+export default function CommunityMetrics({software_cnt,pending_cnt}:CommunityMetricsProps) {
function softwareMessage(){
if (software_cnt && software_cnt === 1) {
@@ -19,6 +21,13 @@ export default function CommunityMetrics({software_cnt}:CommunityMetricsProps) {
return `${software_cnt ?? 0} software packages`
}
+ function pendingMessage(){
+ if (pending_cnt && pending_cnt === 1) {
+ return `${pending_cnt} request to join`
+ }
+ return `${pending_cnt ?? 0} requests to join`
+ }
+
return (
<>
@@ -27,6 +36,12 @@ export default function CommunityMetrics({software_cnt}:CommunityMetricsProps) {
{software_cnt ?? 0}
+
+
+
+ {pending_cnt ?? 0}
+
+
>
)
}
diff --git a/frontend/components/communities/software/CommunitySoftwareOverview.tsx b/frontend/components/communities/software/CommunitySoftwareOverview.tsx
new file mode 100644
index 000000000..09d3e6d4b
--- /dev/null
+++ b/frontend/components/communities/software/CommunitySoftwareOverview.tsx
@@ -0,0 +1,84 @@
+// 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 NoContent from '~/components/layout/NoContent'
+import {ProjectLayoutType} from '~/components/projects/overview/search/ViewToggleGroup'
+import SoftwareGridCard from '~/components/software/overview/cards/SoftwareGridCard'
+import SoftwareListItemContent from '~/components/software/overview/list/SoftwareListItemContent'
+import SoftwareOverviewGrid from '~/components/software/overview/cards/SoftwareOverviewGrid'
+import SoftwareOverviewList from '~/components/software/overview/list/SoftwareOverviewList'
+import OverviewListItem from '~/components/software/overview/list/OverviewListItem'
+import {useCommunityContext} from '~/components/communities/context'
+import AdminSoftwareGridCard from './card/AdminSoftwareGridCard'
+import AdminSoftwareListItem from './list/AdminSoftwareListItem'
+import {SoftwareOfCommunity} from './apiCommunitySoftware'
+
+type CommunitySoftwareOverviewProps = {
+ layout: ProjectLayoutType
+ software: SoftwareOfCommunity[]
+ rows: number
+}
+
+export default function CommunitySoftwareOverview({layout,software,rows}: CommunitySoftwareOverviewProps) {
+ const {community:{software_cnt},isMaintainer} = useCommunityContext()
+ // max item to be set to rows
+ let itemCnt = rows
+ if (software_cnt && software_cnt < rows) itemCnt = software_cnt
+
+ // console.group('CommunitySoftwareOverview')
+ // console.log('isMaintainer...', isMaintainer)
+ // console.log('software_cnt...', software_cnt)
+ // console.log('software...', software)
+ // console.log('loading...', loading)
+ // console.log('layout...', layout)
+ // console.groupEnd()
+
+ if (!software || software.length === 0) {
+ return
+ }
+
+ if (layout === 'list') {
+ return (
+
+ {software.map(item => {
+ if (isMaintainer) {
+ return
+ }
+
+ return (
+
+
+
+
+
+ )
+ })}
+
+ )
+ }
+
+ // GRID as default
+ return (
+
+ {software.map((item) => {
+ if (isMaintainer) {
+ return (
+
+ )
+ }
+ return
+ })}
+
+ )
+
+}
diff --git a/frontend/components/communities/software/apiCommunitySoftware.ts b/frontend/components/communities/software/apiCommunitySoftware.ts
new file mode 100644
index 000000000..85cf4bb31
--- /dev/null
+++ b/frontend/components/communities/software/apiCommunitySoftware.ts
@@ -0,0 +1,246 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {ParsedUrlQuery} from 'querystring'
+
+import logger from '~/utils/logger'
+import {extractCountFromHeader} from '~/utils/extractCountFromHeader'
+import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers'
+import {baseQueryString} from '~/utils/postgrestUrl'
+import {ssrSoftwareParams} from '~/utils/extractQueryParam'
+import {getKeywordsByCommunity} from '~/components/communities/settings/general/apiCommunityKeywords'
+import {CommunityListProps} from '~/components/communities/apiCommunities'
+import {comSoftwareKeywordsFilter, comSoftwareLanguagesFilter, comSoftwareLicensesFilter} from './filters/apiCommunitySoftwareFilters'
+import {getSoftwareOrderOptions} from './filters/OrderCommunitySoftwareBy'
+
+export type CommunityRequestStatus='approved'|'pending'|'rejected'
+
+export type SoftwareForCommunityParams = {
+ community: string,
+ software_status: CommunityRequestStatus
+ searchFor?: string
+ keywords?: string[] | null
+ prog_lang?: string[] | null
+ licenses?: string[] | null
+ order?: string
+ page: number,
+ rows: number,
+ isMaintainer: boolean
+ token?: string,
+}
+
+export type SoftwareOfCommunity = {
+ id: string
+ slug: string
+ brand_name: string
+ short_statement: string
+ image_id: string|null
+ is_published: boolean
+ updated_at: string
+ status: CommunityRequestStatus
+ keywords: string[],
+ prog_lang: string[],
+ licenses: string,
+ contributor_cnt: number | null
+ mention_cnt: number | null
+}
+
+export async function getSoftwareForCommunity({
+ community, searchFor, keywords, prog_lang,
+ licenses, order, page, rows, token,
+ isMaintainer, software_status
+}: SoftwareForCommunityParams) {
+ try {
+ // baseUrl
+ const baseUrl = getBaseUrl()
+ let url = `${baseUrl}/rpc/software_by_community?community_id=${community}`
+ // SEARCH
+ if (searchFor) {
+ // use different RPC for search
+ const encodedSearch = encodeURIComponent(searchFor)
+ url = `${baseUrl}/rpc/software_by_community_search?community_id=${community}&search=${encodedSearch}`
+ }
+ // filter for status
+ url+= `&status=eq.${software_status}`
+ // filter for approved only if not maintainer
+ if (!isMaintainer) {
+ url += '&is_published=eq.true'
+ }
+ // FILTERS
+ let filters = baseQueryString({
+ keywords,
+ prog_lang,
+ licenses,
+ limit: rows,
+ offset: page ? page * rows : undefined
+ })
+ if (filters) {
+ url += `&${filters}`
+ }
+ // ORDER
+ if (order) {
+ // extract order direction from definitions
+ const orderInfo = getSoftwareOrderOptions(isMaintainer).find(item=>item.key===order)
+ // ordering options require "stable" secondary order
+ // to ensure proper pagination. We use slug for this purpose
+ if (orderInfo) url += `&order=${order}.${orderInfo.direction},slug.asc`
+ }else {
+ // default order is mentions count
+ url += '&order=mention_cnt.desc.nullslast,slug.asc'
+ }
+ // console.log('getSoftwareForCommunity...url...', url)
+ 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 json: SoftwareOfCommunity[] = await resp.json()
+ return {
+ count: extractCountFromHeader(resp.headers) ?? 0,
+ data: json
+ }
+ }
+ // otherwise request failed
+ logger(`getSoftwareForCommunity: ${resp.status} ${resp.statusText}`, 'warn')
+ // we log and return zero
+ return {
+ count: 0,
+ data: []
+ }
+ } catch (e: any) {
+ // otherwise request failed
+ logger(`getSoftwareForCommunity: ${e.message}`, 'error')
+ // we log and return zero
+ return {
+ count: 0,
+ data: []
+ }
+ }
+}
+
+type SsrCommunitySoftwareDataProps={
+ community: CommunityListProps,
+ software_status: CommunityRequestStatus
+ query: ParsedUrlQuery
+ isMaintainer: boolean
+ token?: string
+}
+
+/**
+ * Get community software page data server side.
+ */
+export async function ssrCommunitySoftwareProps({community,software_status,query,isMaintainer,token}:SsrCommunitySoftwareDataProps){
+ try{
+ // extract and decode query params
+ const {search, keywords, prog_lang, licenses, order, rows, page} = ssrSoftwareParams(query)
+
+ // get other data
+ const [
+ software,
+ keywordsList,
+ languagesList,
+ licensesList,
+ communityKeywords
+ ] = await Promise.all([
+ getSoftwareForCommunity({
+ community: community.id,
+ software_status,
+ searchFor: search,
+ keywords,
+ prog_lang,
+ licenses,
+ order,
+ rows: rows ?? 12,
+ page: page ? page-1 : 0,
+ isMaintainer,
+ token
+ }),
+ comSoftwareKeywordsFilter({
+ id: community.id,
+ software_status,
+ search,
+ keywords,
+ prog_lang,
+ licenses
+ }),
+ comSoftwareLanguagesFilter({
+ id: community.id,
+ software_status,
+ search,
+ keywords,
+ prog_lang,
+ licenses
+ }),
+ comSoftwareLicensesFilter({
+ id: community.id,
+ software_status,
+ search,
+ keywords,
+ prog_lang,
+ licenses
+ }),
+ getKeywordsByCommunity(community.id,token)
+ ])
+
+ return{
+ isMaintainer,
+ software,
+ keywordsList,
+ languagesList,
+ licensesList,
+ community:{
+ ...community,
+ // use keywords for editing
+ keywords: communityKeywords
+ }
+ }
+
+ }catch(e:any){
+ // otherwise request failed
+ logger(`ssrCommunitySoftwareProps: ${e.message}`, 'error')
+ // we log and return zero
+ return {
+ isMaintainer: false,
+ communityKeywords:[],
+ software:{
+ count:0,
+ data: []
+ },
+ keywordsList:[],
+ languagesList:[],
+ licensesList:[],
+ community
+ }
+ }
+}
+
+
+export async function patchSoftwareForCommunity({software, community, data, token}:
+ { software: string, community: string, data: any, token: string }) {
+ try {
+ const query = `software=eq.${software}&community=eq.${community}`
+ const url = `${getBaseUrl()}/software_for_community?${query}`
+ const resp = await fetch(url, {
+ method: 'PATCH',
+ headers: {
+ ...createJsonHeaders(token),
+ 'Prefer': 'return=headers-only'
+ },
+ body: JSON.stringify(data)
+ })
+ return extractReturnMessage(resp)
+ } catch (e: any) {
+ // debugger
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
diff --git a/frontend/components/communities/software/card/AdminSoftwareGridCard.tsx b/frontend/components/communities/software/card/AdminSoftwareGridCard.tsx
new file mode 100644
index 000000000..aa3eb60a1
--- /dev/null
+++ b/frontend/components/communities/software/card/AdminSoftwareGridCard.tsx
@@ -0,0 +1,69 @@
+// 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 Dusan Mijatovic (dv4all) (dv4all)
+// SPDX-FileCopyrightText: 2023 dv4all
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import Link from 'next/link'
+
+import IconBtnMenuOnAction from '~/components/menu/IconBtnMenuOnAction'
+import SoftwareCardContent from '~/components/software/overview/cards/SoftwareCardContent'
+import {SoftwareOfCommunity} from '../apiCommunitySoftware'
+import {useSoftwareCardActions} from './useSoftwareCardActions'
+import StatusBanner from './StatusBanner'
+
+type AdminSoftwareCardProps = {
+ item: SoftwareOfCommunity
+}
+
+export default function AdminSoftwareGridCard({item:software}: AdminSoftwareCardProps) {
+ const {menuOptions, onAction} = useSoftwareCardActions({software})
+
+ // console.group('AdminSoftwareGridCard')
+ // console.log('item...', item)
+ // console.log('status...', software.status)
+ // console.log('is_published...', software.is_published)
+ // console.log('is_featured...', software.is_featured)
+ // console.groupEnd()
+
+ return (
+
+
+ {/* standard software card with link */}
+
+
+
+
+ {/* menu and status icons - at the top of the card */}
+
+
+ )
+
+}
diff --git a/frontend/components/communities/software/card/StatusBanner.tsx b/frontend/components/communities/software/card/StatusBanner.tsx
new file mode 100644
index 000000000..b88b8c805
--- /dev/null
+++ b/frontend/components/communities/software/card/StatusBanner.tsx
@@ -0,0 +1,56 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {CommunityRequestStatus} from '../apiCommunitySoftware'
+
+type StatusBannerProps = {
+ status: CommunityRequestStatus
+ is_published: boolean
+ width?: string
+ borderRadius?: string
+ letterSpacing?: string
+}
+
+export default function StatusBanner({
+ status,
+ is_published,
+ width = '10rem',
+ borderRadius = '0 0.5rem 0.5rem 0',
+ letterSpacing = '0.125rem'
+}: StatusBannerProps) {
+
+ if (status==='rejected') {
+ return (
+
+ Rejected
+
+ )
+ }
+ if (status==='pending'){
+ return (
+
+ Pending
+
+ )
+ }
+
+ if (is_published === false){
+ return (
+
+ Not published
+
+ )
+ }
+
+}
diff --git a/frontend/components/communities/software/card/useAdminMenuOptions.tsx b/frontend/components/communities/software/card/useAdminMenuOptions.tsx
new file mode 100644
index 000000000..abb2f68f4
--- /dev/null
+++ b/frontend/components/communities/software/card/useAdminMenuOptions.tsx
@@ -0,0 +1,59 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useEffect, useState} from 'react'
+import RemoveCircleIcon from '@mui/icons-material/RemoveCircle'
+import Check from '@mui/icons-material/Check'
+
+import {IconBtnMenuOption} from '~/components/menu/IconBtnMenuOnAction'
+import {CommunityRequestStatus} from '../apiCommunitySoftware'
+import {SoftwareMenuAction} from './useSoftwareCardActions'
+
+type AdminMenuOptionsProps = {
+ status: CommunityRequestStatus
+ is_published: boolean
+}
+
+export default function useAdminMenuOptions({
+ status, is_published
+}: AdminMenuOptionsProps) {
+
+ const [menuOptions, setMenuOptions] = useState
[]>([])
+
+ useEffect(() => {
+ let abort = false
+ if (typeof status !='undefined') {
+ const options: IconBtnMenuOption[] = []
+ switch(status){
+ case 'approved':
+
+ }
+ if (status === 'approved') {
+ options.push({
+ type: 'action',
+ key: 'deny',
+ label: 'Reject affiliation',
+ icon: ,
+ action: {type: 'DENY'}
+ })
+ } else {
+ options.push({
+ type: 'action',
+ key: 'approve',
+ label: 'Allow affiliation',
+ icon: ,
+ action: {type: 'APPROVE'}
+ })
+ }
+ if (abort) return
+ setMenuOptions(options)
+ }
+ return ()=>{abort=true}
+ }, [status,is_published])
+
+ return {
+ menuOptions
+ }
+}
diff --git a/frontend/components/communities/software/card/useSoftwareCardActions.tsx b/frontend/components/communities/software/card/useSoftwareCardActions.tsx
new file mode 100644
index 000000000..0fe8aaeda
--- /dev/null
+++ b/frontend/components/communities/software/card/useSoftwareCardActions.tsx
@@ -0,0 +1,75 @@
+// 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 {useState} from 'react'
+
+import {useSession} from '~/auth'
+import logger from '~/utils/logger'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import {useCommunityContext} from '../../context'
+import {CommunityRequestStatus, SoftwareOfCommunity, patchSoftwareForCommunity} from '../apiCommunitySoftware'
+import useAdminMenuOptions from './useAdminMenuOptions'
+
+export type SoftwareCardWithMenuProps = {
+ software: SoftwareOfCommunity
+}
+
+export type SoftwareMenuAction = {
+ type: 'APPROVE' | 'DENY',
+ payload?: string
+}
+
+export function useSoftwareCardActions({software}: SoftwareCardWithMenuProps) {
+ const {token} = useSession()
+ const {community:{id}} = useCommunityContext()
+ const {showErrorMessage} = useSnackbar()
+ const {menuOptions} = useAdminMenuOptions({
+ status: software.status,
+ is_published: software.is_published
+ })
+
+ // refresh "signal" for child component(s) to reload project item after update
+ // and updated menuOptions
+ const [_, setRefresh] = useState(0)
+
+ async function setStatus(status: CommunityRequestStatus) {
+ const resp = await patchSoftwareForCommunity({
+ software: software.id,
+ community: id ?? '',
+ token,
+ data: {
+ status
+ }
+ })
+ if (resp.status !== 200) {
+ showErrorMessage(`Failed to update ${software.brand_name}. ${resp.message}`)
+ } else {
+ // directly update object value
+ software.status = status
+ // send refresh signal - to reload changes
+ setRefresh(v=>v+1)
+ }
+ }
+
+ function onAction(action: SoftwareMenuAction) {
+ switch (action.type) {
+ case 'APPROVE':
+ setStatus('approved')
+ break
+ case 'DENY':
+ setStatus('rejected')
+ break
+ default:
+ logger(`Action type ${action.type} NOT SUPPORTED. Check your spelling.`, 'warn')
+ }
+ }
+
+ return {
+ menuOptions,
+ onAction
+ }
+}
diff --git a/frontend/components/communities/software/filters/OrderCommunitySoftwareBy.tsx b/frontend/components/communities/software/filters/OrderCommunitySoftwareBy.tsx
new file mode 100644
index 000000000..1780c8a57
--- /dev/null
+++ b/frontend/components/communities/software/filters/OrderCommunitySoftwareBy.tsx
@@ -0,0 +1,45 @@
+// 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-FileCopyrightText: 2024 Christian Meeßen (GFZ)
+// SPDX-FileCopyrightText: 2024 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import OrderBy from '~/components/filter/OrderBy'
+import useSoftwareParams from '~/components/organisation/software/filters/useSoftwareParams'
+import {softwareOrderOptions} from '~/components/software/overview/filters/OrderSoftwareBy'
+import useFilterQueryChange from '~/components/filter/useFilterQueryChange'
+import {useCommunityContext} from '../../context'
+
+const adminOrderOptions = [
+ {key: 'is_published', label: 'Not published', direction: 'asc.nullslast'},
+]
+
+export function getSoftwareOrderOptions(isMaintainer:boolean) {
+ if (isMaintainer) {
+ const order = [
+ ...softwareOrderOptions,
+ ...adminOrderOptions
+ ]
+ return order
+ } else {
+ return softwareOrderOptions
+ }
+}
+
+export default function OrderCommunitySoftwareBy() {
+ const {isMaintainer} = useCommunityContext()
+ const {order} = useSoftwareParams()
+ const {handleQueryChange} = useFilterQueryChange()
+ const orderOptions = getSoftwareOrderOptions(isMaintainer)
+
+ return (
+
+ )
+}
diff --git a/frontend/components/communities/software/filters/apiCommunitySoftwareFilters.ts b/frontend/components/communities/software/filters/apiCommunitySoftwareFilters.ts
new file mode 100644
index 000000000..6c8bebbcb
--- /dev/null
+++ b/frontend/components/communities/software/filters/apiCommunitySoftwareFilters.ts
@@ -0,0 +1,150 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import logger from '~/utils/logger'
+import {createJsonHeaders, getBaseUrl} from '~/utils/fetchHelpers'
+import {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
+import {buildSoftwareFilter} from '~/components/software/overview/filters/softwareFiltersApi'
+import {LicensesFilterOption} from '~/components/filter/LicensesFilter'
+import {LanguagesFilterOption} from '~/components/filter/ProgrammingLanguagesFilter'
+import {CommunityRequestStatus} from '../apiCommunitySoftware'
+
+export type CommunitySoftwareFilterProps = {
+ id: string
+ software_status: CommunityRequestStatus
+ search?: string | null
+ keywords?: string[] | null
+ prog_lang?: string[] | null
+ licenses?: string[] | null
+ token?:string
+}
+
+export function buildCommunitySoftwareFilter({id, software_status, search, keywords, prog_lang, licenses}: CommunitySoftwareFilterProps) {
+ const filter = {
+ // additional organisation filter
+ community_id: id,
+ software_status,
+ // add default software filter params
+ ...buildSoftwareFilter({
+ search,
+ keywords,
+ prog_lang,
+ licenses
+ })
+ }
+ // console.group('buildCommunitySoftwareFilter')
+ // console.log('filter...', filter)
+ // console.groupEnd()
+ return filter
+}
+
+export async function comSoftwareKeywordsFilter({
+ id, software_status, search, keywords, prog_lang, licenses, token
+}: CommunitySoftwareFilterProps) {
+ try {
+
+ const query = 'rpc/com_software_keywords_filter?order=keyword'
+ const url = `${getBaseUrl()}/${query}`
+ const filter = buildCommunitySoftwareFilter({
+ id,
+ software_status,
+ search,
+ keywords,
+ prog_lang,
+ licenses
+ })
+
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: createJsonHeaders(token),
+ // we pass params in the body of POST
+ body: JSON.stringify(filter)
+ })
+
+ if (resp.status === 200) {
+ const json: KeywordFilterOption[] = await resp.json()
+ return json
+ }
+
+ logger(`comSoftwareKeywordsFilter: ${resp.status} ${resp.statusText}`, 'warn')
+ return []
+
+ } catch (e: any) {
+ logger(`comSoftwareKeywordsFilter: ${e?.message}`, 'error')
+ return []
+ }
+}
+
+export async function comSoftwareLanguagesFilter({
+ id,software_status,search, keywords, prog_lang, licenses, token}: CommunitySoftwareFilterProps) {
+ try {
+ const query = 'rpc/com_software_languages_filter?order=prog_language'
+ const url = `${getBaseUrl()}/${query}`
+ const filter = buildCommunitySoftwareFilter({
+ id,
+ software_status,
+ search,
+ keywords,
+ prog_lang,
+ licenses
+ })
+
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: createJsonHeaders(token),
+ // we pass params in the body of POST
+ body: JSON.stringify(filter)
+ })
+
+ if (resp.status === 200) {
+ const json: LanguagesFilterOption[] = await resp.json()
+ return json
+ }
+
+ logger(`comSoftwareLanguagesFilter: ${resp.status} ${resp.statusText}`, 'warn')
+ return []
+
+ } catch (e: any) {
+ logger(`comSoftwareLanguagesFilter: ${e?.message}`, 'error')
+ return []
+ }
+}
+
+export async function comSoftwareLicensesFilter({
+ id,software_status,search, keywords, prog_lang, licenses, token}: CommunitySoftwareFilterProps) {
+ try {
+ const query = 'rpc/com_software_licenses_filter?order=license'
+ const url = `${getBaseUrl()}/${query}`
+ const filter = buildCommunitySoftwareFilter({
+ id,
+ software_status,
+ search,
+ keywords,
+ prog_lang,
+ licenses
+ })
+
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: createJsonHeaders(token),
+ // we pass params in the body of POST
+ body: JSON.stringify(filter)
+ })
+
+ if (resp.status === 200) {
+ const json: LicensesFilterOption[] = await resp.json()
+ return json
+ }
+
+ logger(`comSoftwareLicensesFilter: ${resp.status} ${resp.statusText}`, 'warn')
+ return []
+
+ } catch (e: any) {
+ logger(`comSoftwareLicensesFilter: ${e?.message}`, 'error')
+ return []
+ }
+}
+
+
diff --git a/frontend/components/communities/software/filters/index.tsx b/frontend/components/communities/software/filters/index.tsx
new file mode 100644
index 000000000..f066e0725
--- /dev/null
+++ b/frontend/components/communities/software/filters/index.tsx
@@ -0,0 +1,88 @@
+// 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 {decodeJsonParam} from '~/utils/extractQueryParam'
+import FilterHeader from '~/components/filter/FilterHeader'
+import KeywordsFilter, {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
+import LicensesFilter, {LicensesFilterOption} from '~/components/filter/LicensesFilter'
+import ProgrammingLanguagesFilter, {LanguagesFilterOption} from '~/components/filter/ProgrammingLanguagesFilter'
+import useFilterQueryChange from '~/components/filter/useFilterQueryChange'
+
+import useSoftwareParams from '~/components/organisation/software/filters/useSoftwareParams'
+import OrderCommunitySoftwareBy from './OrderCommunitySoftwareBy'
+
+
+type CommunitySoftwareFiltersProps = {
+ keywordsList: KeywordFilterOption[]
+ languagesList: LanguagesFilterOption[]
+ licensesList: LicensesFilterOption[]
+}
+
+export default function CommunitySoftwareFilters({
+ keywordsList,languagesList,licensesList
+}:CommunitySoftwareFiltersProps) {
+ const router = useRouter()
+ const {handleQueryChange} = useFilterQueryChange()
+ // extract query params
+ const {filterCnt,keywords_json,prog_lang_json,licenses_json} = useSoftwareParams()
+ // decode query params
+ const keywords = decodeJsonParam(keywords_json, [])
+ const prog_lang = decodeJsonParam(prog_lang_json, [])
+ const licenses= decodeJsonParam(licenses_json,[])
+
+ // debugger
+ function clearDisabled() {
+ if (filterCnt && filterCnt > 0) return false
+ return true
+ }
+
+ function resetFilters(){
+ // use basic params
+ const query: any = {
+ slug: router.query.slug,
+ // keep order if provided
+ order: router.query?.order ? router.query?.order : 'mention_cnt'
+ }
+ router.push({query},undefined,{scroll: false})
+ }
+
+ return (
+ <>
+
+ {/* Order by */}
+
+ {/* Keywords */}
+
+
+
+ {/* Program languages */}
+
+ {/* Licenses */}
+
+
+
+ >
+ )
+}
diff --git a/frontend/components/communities/software/index.tsx b/frontend/components/communities/software/index.tsx
new file mode 100644
index 000000000..a9d0612ef
--- /dev/null
+++ b/frontend/components/communities/software/index.tsx
@@ -0,0 +1,105 @@
+// 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 useMediaQuery from '@mui/material/useMediaQuery'
+import Pagination from '@mui/material/Pagination'
+
+import {setDocumentCookie} from '~/utils/userSettings'
+import UserAgreementModal from '~/components/user/settings/UserAgreementModal'
+import {LayoutType} from '~/components/software/overview/search/ViewToggleGroup'
+import {ProjectLayoutType} from '~/components/projects/overview/search/ViewToggleGroup'
+import FiltersPanel from '~/components/filter/FiltersPanel'
+import {useCommunityContext} from '~/components/communities/context'
+import {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
+import {LanguagesFilterOption} from '~/components/filter/ProgrammingLanguagesFilter'
+import {LicensesFilterOption} from '~/components/filter/LicensesFilter'
+import useFilterQueryChange from '~/components/filter/useFilterQueryChange'
+
+import CommunitySoftwareFilters from './filters'
+import SearchCommunitySoftwareSection from './search'
+import CommunitySoftwareOverview from './CommunitySoftwareOverview'
+import {SoftwareOfCommunity} from './apiCommunitySoftware'
+
+type CommunitySoftwareProps={
+ software: SoftwareOfCommunity[]
+ count: number
+ rows: number
+ page: number
+ rsd_page_layout: LayoutType
+ keywordsList: KeywordFilterOption[],
+ languagesList: LanguagesFilterOption[],
+ licensesList: LicensesFilterOption[],
+}
+
+export default function CommunitySoftware({
+ software,count,page,rows,rsd_page_layout,
+ keywordsList, languagesList, licensesList
+}:CommunitySoftwareProps) {
+ const smallScreen = useMediaQuery('(max-width:640px)')
+ const {isMaintainer} = useCommunityContext()
+ const {handleQueryChange} = useFilterQueryChange()
+ // if masonry we change to grid
+ const initView = rsd_page_layout === 'masonry' ? 'grid' : rsd_page_layout
+ const [view, setView] = useState(initView ?? 'grid')
+ const numPages = Math.ceil(count / rows)
+
+ function setLayout(view: ProjectLayoutType) {
+ // update local view
+ setView(view)
+ // save to cookie
+ setDocumentCookie(view,'rsd_page_layout')
+ }
+
+ return (
+ <>
+ {/* Only when maintainer */}
+ {isMaintainer && }
+ {/* Page grid with 2 sections: left filter panel and main content */}
+
+ {/* Filters panel large screen */}
+ {smallScreen === false &&
+
+
+
+ }
+
+ {/* Search & mobile filter modal */}
+
+ {/* software overview/content */}
+
+ {/* Pagination */}
+ {numPages > 1 &&
+
+
{
+ handleQueryChange('page',page.toString())
+ }}
+ />
+
+ }
+
+
+ >
+ )
+}
diff --git a/frontend/components/communities/software/list/AdminSoftwareListItem.tsx b/frontend/components/communities/software/list/AdminSoftwareListItem.tsx
new file mode 100644
index 000000000..a625541af
--- /dev/null
+++ b/frontend/components/communities/software/list/AdminSoftwareListItem.tsx
@@ -0,0 +1,54 @@
+// 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 IconBtnMenuOnAction from '~/components/menu/IconBtnMenuOnAction'
+import OverviewListItem from '~/components/software/overview/list/OverviewListItem'
+import SoftwareListItemContent from '~/components/software/overview/list/SoftwareListItemContent'
+import StatusBanner from '~/components/communities/software/card/StatusBanner'
+import {useSoftwareCardActions} from '~/components/communities/software/card/useSoftwareCardActions'
+import {SoftwareOfCommunity} from '~/components/communities/software/apiCommunitySoftware'
+
+type AdminSoftwareListItem = {
+ item: SoftwareOfCommunity
+}
+
+export default function AdminSoftwareListItem({item:software}: AdminSoftwareListItem) {
+ const {menuOptions, onAction} = useSoftwareCardActions({software})
+
+ return (
+
+ {/* standard software list item with link */}
+
+
+ }
+ {...software}
+ />
+
+ {/* admin menu */}
+
+
+ )
+}
diff --git a/frontend/components/communities/software/search/index.tsx b/frontend/components/communities/software/search/index.tsx
new file mode 100644
index 000000000..a6637ef1f
--- /dev/null
+++ b/frontend/components/communities/software/search/index.tsx
@@ -0,0 +1,92 @@
+// 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 Button from '@mui/material/Button'
+
+import {getPageRange} from '~/utils/pagination'
+import SearchInput from '~/components/search/SearchInput'
+import SelectRows from '~/components/software/overview/search/SelectRows'
+import ViewToggleGroup, {ProjectLayoutType} from '~/components/projects/overview/search/ViewToggleGroup'
+import useSoftwareParams from '~/components/organisation/software/filters/useSoftwareParams'
+import FiltersModal from '~/components/filter/FiltersModal'
+import {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
+import {LanguagesFilterOption} from '~/components/filter/ProgrammingLanguagesFilter'
+import {LicensesFilterOption} from '~/components/filter/LicensesFilter'
+import useFilterQueryChange from '~/components/filter/useFilterQueryChange'
+import CommunitySoftwareFilters from '../filters/index'
+
+type SearchSoftwareSectionProps = {
+ count: number
+ keywordsList: KeywordFilterOption[],
+ languagesList: LanguagesFilterOption[],
+ licensesList: LicensesFilterOption[],
+ smallScreen: boolean
+ layout: ProjectLayoutType
+ setView: (view:ProjectLayoutType) => void
+}
+
+export default function SearchCommunitySoftwareSection({
+ count, layout, keywordsList, smallScreen,
+ languagesList, licensesList, setView
+}: SearchSoftwareSectionProps) {
+ const {search,page,rows,filterCnt} = useSoftwareParams()
+ const {handleQueryChange} = useFilterQueryChange()
+ const [modal, setModal] = useState(false)
+
+ const placeholder = filterCnt > 0 ? 'Find within selection' : 'Find software'
+
+ // console.group('SearchCommunitySoftwareSection')
+ // console.log('page...', page)
+ // console.log('rows...', rows)
+ // console.log('search...', search)
+ // console.groupEnd()
+
+ return (
+
+
+ handleQueryChange('search', search)}
+ defaultValue={search ?? ''}
+ />
+
+
+
+
+
+ {getPageRange(rows, page, count)}
+
+ {smallScreen === true &&
+
setModal(true)}
+ variant="outlined"
+ >
+ Filters
+
+ }
+
+ {smallScreen ?
+
+
+
+ : undefined
+ }
+
+ )
+}
diff --git a/frontend/components/communities/tabs/CommunityTabItems.tsx b/frontend/components/communities/tabs/CommunityTabItems.tsx
index 30f198a13..85c9cc039 100644
--- a/frontend/components/communities/tabs/CommunityTabItems.tsx
+++ b/frontend/components/communities/tabs/CommunityTabItems.tsx
@@ -3,9 +3,11 @@
//
// 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 BlockIcon from '@mui/icons-material/Block'
+import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'
+import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined'
+import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
import {OrganisationForOverview} from '~/types/Organisation'
@@ -20,7 +22,7 @@ export type CommunityTabItemProps = {
isVisible: (props: IsVisibleProps) => boolean
}
-export type TabKey = 'about'|'software'|'settings'
+export type TabKey = 'about'|'software'|'requests'|'rejected'|'settings'
export type CommunityTabProps = {
[key in TabKey]: CommunityTabItemProps
}
@@ -37,17 +39,31 @@ export const communityTabItems:CommunityTabProps = {
icon: ,
isVisible: (props) => true,
},
+ requests:{
+ id:'requests',
+ label:({pending_cnt})=>`Requests (${pending_cnt ?? 0})`,
+ icon: ,
+ // we do not show this option if not a maintainer
+ isVisible: ({isMaintainer}) => isMaintainer
+ },
+ rejected:{
+ id:'rejected',
+ label:({rejected_cnt})=>`Rejected (${rejected_cnt ?? 0})`,
+ icon: ,
+ // we do not show this option if not a maintainer
+ isVisible: ({isMaintainer}) => isMaintainer
+ },
settings:{
id:'settings',
label:()=>'Settings',
- icon: ,
+ icon: ,
// we do not show this option if not a maintainer
isVisible: ({isMaintainer}) => isMaintainer
},
about: {
id:'about',
label:()=>'About',
- icon: ,
+ icon: ,
isVisible: ({description}) => {
// we always show about section to maintainer
// if (isMaintainer === true) return true
diff --git a/frontend/components/communities/tabs/index.tsx b/frontend/components/communities/tabs/index.tsx
index dc4f57a69..3c9ff8e9b 100644
--- a/frontend/components/communities/tabs/index.tsx
+++ b/frontend/components/communities/tabs/index.tsx
@@ -14,12 +14,15 @@ const tabItems = Object.keys(communityTabItems) as TabKey[]
type CommunityTabsProps={
tab:TabKey
software_cnt: number
+ pending_cnt: number
+ rejected_cnt: number
description: string | null
isMaintainer: boolean
}
export default function CommunityTabs({
- tab,software_cnt,description,isMaintainer
+ tab,software_cnt,pending_cnt,rejected_cnt,
+ description,isMaintainer
}:CommunityTabsProps) {
const router = useRouter()
@@ -38,8 +41,11 @@ export default function CommunityTabs({
}
}
// add default order for software and project tabs
- if (value === 'software') {
- url.query['order'] = 'is_featured'
+ if (value === 'software' ||
+ value === 'requests' ||
+ value === 'rejected'
+ ) {
+ url.query['order'] = 'mention_cnt'
}
// push route change
router.push(url,undefined,{scroll:false})
@@ -50,7 +56,6 @@ export default function CommunityTabs({
const item = communityTabItems[key]
if (item.isVisible({
isMaintainer,
- software_cnt,
description
}) === true) {
return void
+ title?: string
+ children?: JSX.Element | JSX.Element[]
}
-export default function OrgSoftwareFiltersModal({
- open, setModal
-}:SoftwareFiltersModalProps) {
+export default function FiltersModal({open,setModal,children,title='Filters'}:FiltersModalProps) {
const smallScreen = useMediaQuery('(max-width:640px)')
+
return (
- Filters
+ {title}
-
+ {/* the filter content component is added here */}
+ {children}
+ setModal(false)}
+ color="secondary"
+ sx={{marginRight:'2rem'}}
+ >
+ Cancel
+
setModal(false)}
color="primary"
>
- Close
+ Apply
diff --git a/frontend/components/filter/useFilterQueryChange.test.tsx b/frontend/components/filter/useFilterQueryChange.test.tsx
new file mode 100644
index 000000000..c540282e6
--- /dev/null
+++ b/frontend/components/filter/useFilterQueryChange.test.tsx
@@ -0,0 +1,70 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useEffect} from 'react'
+import {render} from '@testing-library/react'
+import useFilterQueryChange from './useFilterQueryChange'
+
+// mock next router
+const mockBack = jest.fn()
+const mockReplace = jest.fn()
+const mockPush = jest.fn()
+
+jest.mock('next/router', () => ({
+ useRouter: () => ({
+ back: mockBack,
+ replace: mockReplace,
+ push: mockPush,
+ pathname: '/organisations',
+ query: {
+ slug:'test-slug-project',
+ rows: 12,
+ page: 1,
+ }
+ })
+}))
+
+beforeEach(() => {
+ jest.resetAllMocks()
+})
+
+function WrappedHandleChangeHook({param,value}:{param: string, value: string | string[]}) {
+ // extract function
+ const {handleQueryChange} = useFilterQueryChange()
+
+ useEffect(() => {
+ // call it with random param
+ handleQueryChange(param, value)
+ },[param,value,handleQueryChange])
+
+ return (
+ WrappedHandleChangeHook
+ )
+}
+
+
+it('handleQueryChange with search param', () => {
+
+ render( )
+
+ expect(mockPush).toBeCalledTimes(1)
+ expect(mockPush).toBeCalledWith(
+ {'query': {'page': 1, 'rows': 12, 'slug': 'test-slug-project','search': 'test-value'}},
+ undefined,
+ {'scroll': false}
+ )
+})
+
+it('handleQueryChange pagination', () => {
+
+ render( )
+
+ expect(mockPush).toBeCalledTimes(1)
+ expect(mockPush).toBeCalledWith(
+ {'query': {'page': '2', 'rows': 12, 'slug': 'test-slug-project'}},
+ undefined,
+ {'scroll': true}
+ )
+})
diff --git a/frontend/components/filter/useFilterQueryChange.tsx b/frontend/components/filter/useFilterQueryChange.tsx
new file mode 100644
index 000000000..f1277b943
--- /dev/null
+++ b/frontend/components/filter/useFilterQueryChange.tsx
@@ -0,0 +1,60 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useCallback} from 'react'
+import {useRouter} from 'next/router'
+
+import {rowsPerPageOptions} from '~/config/pagination'
+import {encodeQueryValue} from '~/utils/extractQueryParam'
+import {QueryParams} from '~/utils/postgrestUrl'
+import {getDocumentCookie} from '~/utils/userSettings'
+
+
+export default function useFilterQueryChange(){
+ const router = useRouter()
+
+ const handleQueryChange = useCallback((key: string, value: string | string[]) => {
+ const params: QueryParams = {
+ [key]: encodeQueryValue(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])
+ }
+
+ // update query parameters
+ const query:any = {
+ ...router.query,
+ ...params
+ }
+
+ if (value === '' || value === null || typeof value === 'undefined') {
+ // remove query param
+ delete query[key]
+ }
+
+ // construct url with all query params
+ if (key === 'page') {
+ // console.group('useFilterQueryChange')
+ // console.log('scroll...true')
+ // console.groupEnd()
+ // on page change we scroll to top
+ router.push({query},undefined,{scroll: true})
+ } else {
+ // console.group('useFilterQueryChange')
+ // console.log('scroll...false')
+ // console.groupEnd()
+ router.push({query},undefined,{scroll: false})
+ }
+ }, [router])
+
+ return {
+ handleQueryChange
+ }
+}
diff --git a/frontend/components/organisation/projects/filters/OrgOrderProjectsBy.tsx b/frontend/components/organisation/projects/filters/OrgOrderProjectsBy.tsx
index f0662aa34..bc2902edb 100644
--- a/frontend/components/organisation/projects/filters/OrgOrderProjectsBy.tsx
+++ b/frontend/components/organisation/projects/filters/OrgOrderProjectsBy.tsx
@@ -1,6 +1,6 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
+// 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 Netherlands eScience Center
// SPDX-FileCopyrightText: 2023 dv4all
//
// SPDX-License-Identifier: Apache-2.0
@@ -8,24 +8,46 @@
import OrderBy from '~/components/filter/OrderBy'
import useQueryChange from '../useQueryChange'
import useProjectParams from '../useProjectParams'
-import useProjectOrderOptions from './useProjectOrderOptions'
+import {projectOrderOptions} from '~/components/projects/overview/filters/OrderProjectsBy'
+import useOrganisationContext from '~/components/organisation/context/useOrganisationContext'
-export default function OrgOrderProjectsBy() {
- const {order} = useProjectParams()
- const {handleQueryChange} = useQueryChange()
- const orderOptions = useProjectOrderOptions()
+// additional admin options
+export const adminOptions = [
+ {key: 'status', label: 'Blocked', direction: 'asc.nullslast'},
+ {key: 'is_published', label: 'Not published', direction:'asc.nullslast'}
+]
- // we load component only if there are options
- if (orderOptions.length > 0) {
- return (
-
- )
+export function getProjectOrderOptions(isMaintainer:boolean) {
+ // if maintainer additional order options are added
+ if (isMaintainer) {
+ const order = [
+ ...projectOrderOptions,
+ // organisation specific option
+ {key: 'is_featured', label: 'Pinned', direction: 'desc.nullslast'},
+ ...adminOptions
+ ]
+ return order
+ } else {
+ return [
+ ...projectOrderOptions,
+ // organisation specific option
+ {key: 'is_featured', label: 'Pinned', direction: 'desc.nullslast'},
+ ]
}
+}
- return null
+export default function OrgOrderProjectsBy() {
+ const {isMaintainer} = useOrganisationContext()
+ const {order} = useProjectParams()
+ const orderOptions = getProjectOrderOptions(isMaintainer)
+ const {handleQueryChange} = useQueryChange()
+
+ return (
+
+ )
}
diff --git a/frontend/components/organisation/projects/filters/OrgProjectFiltersModal.tsx b/frontend/components/organisation/projects/filters/OrgProjectFiltersModal.tsx
deleted file mode 100644
index b3d4453a1..000000000
--- a/frontend/components/organisation/projects/filters/OrgProjectFiltersModal.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import Dialog from '@mui/material/Dialog'
-import DialogContent from '@mui/material/DialogContent'
-import DialogTitle from '@mui/material/DialogTitle'
-import useMediaQuery from '@mui/material/useMediaQuery'
-import DialogActions from '@mui/material/DialogActions'
-import Button from '@mui/material/Button'
-import OrgProjectFilters from './OrgProjectFilters'
-
-type ProjectFiltersModalProps = {
- open: boolean,
- setModal:(open:boolean)=>void
-}
-
-export default function OrgProjectFiltersModal({
- open, setModal
-}:ProjectFiltersModalProps) {
- const smallScreen = useMediaQuery('(max-width:640px)')
- return (
-
-
- Filters
-
-
-
-
-
-
-
- setModal(false)}
- color="primary"
- >
- Close
-
-
-
- )
-}
diff --git a/frontend/components/organisation/projects/filters/OrgProjectKeywordsFilter.tsx b/frontend/components/organisation/projects/filters/OrgProjectKeywordsFilter.tsx
deleted file mode 100644
index 01c7f07e1..000000000
--- a/frontend/components/organisation/projects/filters/OrgProjectKeywordsFilter.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import KeywordsFilter, {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
-import useQueryChange from '../useQueryChange'
-
-type ProjectKeywordsFilterProps = {
- keywords: string[],
- keywordsList: KeywordFilterOption[]
-}
-
-export default function OrgProjectKeywordsFilter({keywords, keywordsList}: ProjectKeywordsFilterProps) {
- const {handleQueryChange} = useQueryChange()
-
- return (
-
-
-
- )
-
-}
diff --git a/frontend/components/organisation/projects/filters/OrgProjectOrganisationsFilter.tsx b/frontend/components/organisation/projects/filters/OrgProjectOrganisationsFilter.tsx
deleted file mode 100644
index 97bc577ad..000000000
--- a/frontend/components/organisation/projects/filters/OrgProjectOrganisationsFilter.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import OrganisationsFilter, {OrganisationOption} from '~/components/filter/OrganisationsFilter'
-import useQueryChange from '../useQueryChange'
-
-type OrganisationFilterProps = {
- organisations: string[],
- organisationsList: OrganisationOption[]
-}
-
-export default function OrgProjectOrganisationsFilter({organisations, organisationsList}: OrganisationFilterProps) {
- const {handleQueryChange} = useQueryChange()
-
- return (
-
-
-
- )
-}
diff --git a/frontend/components/organisation/projects/filters/OrgResearchDomainFilter.tsx b/frontend/components/organisation/projects/filters/OrgResearchDomainFilter.tsx
deleted file mode 100644
index 02f8bc9c8..000000000
--- a/frontend/components/organisation/projects/filters/OrgResearchDomainFilter.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import ResearchDomainFilter, {ResearchDomainOption} from '~/components/filter/ResearchDomainFilter'
-import useQueryChange from '../useQueryChange'
-
-type ResearchDomainFilterProps = {
- domains: string[],
- domainsList: ResearchDomainOption[]
-}
-
-export default function OrgResearchDomainFilter({domains, domainsList}: ResearchDomainFilterProps) {
- const {handleQueryChange} = useQueryChange()
-
- // console.group('OrgResearchDomainFilter')
- // console.log('domainsList...', domainsList)
- // console.log('options...', options)
- // console.groupEnd()
-
- return (
-
-
-
- )
-
-}
diff --git a/frontend/components/organisation/projects/filters/OrgProjectFilters.tsx b/frontend/components/organisation/projects/filters/index.tsx
similarity index 68%
rename from frontend/components/organisation/projects/filters/OrgProjectFilters.tsx
rename to frontend/components/organisation/projects/filters/index.tsx
index 8cd7866d4..9ca27f6bb 100644
--- a/frontend/components/organisation/projects/filters/OrgProjectFilters.tsx
+++ b/frontend/components/organisation/projects/filters/index.tsx
@@ -1,6 +1,6 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
+// 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 Netherlands eScience Center
// SPDX-FileCopyrightText: 2023 dv4all
//
// SPDX-License-Identifier: Apache-2.0
@@ -8,16 +8,16 @@
import FilterHeader from '~/components/filter/FilterHeader'
import {decodeJsonParam} from '~/utils/extractQueryParam'
+import KeywordsFilter from '~/components/filter/KeywordsFilter'
+import ResearchDomainFilter from '~/components/filter/ResearchDomainFilter'
+import OrganisationsFilter from '~/components/filter/OrganisationsFilter'
+import ProjectStatusFilter from '~/components/projects/overview/filters/ProjectStatusFilter'
import useQueryChange from '../useQueryChange'
-import OrgOrderProjectsBy from './OrgOrderProjectsBy'
-import OrgProjectKeywordsFilter from './OrgProjectKeywordsFilter'
import useProjectParams from '../useProjectParams'
+import OrgOrderProjectsBy from './OrgOrderProjectsBy'
import useOrgProjectKeywordsList from './useOrgProjectKeywordsList'
-import OrgResearchDomainFilter from './OrgResearchDomainFilter'
import useOrgProjectDomainsFilter from './useOrgProjectDomainsList'
-import OrgProjectOrganisationsFilter from './OrgProjectOrganisationsFilter'
import useOrgProjectOrganisationList from './useOrgProjectOrganisationsList'
-import ProjectStatusFilter from '~/components/projects/overview/filters/ProjectStatusFilter'
import useOrgProjectStatusList from './useOrgProjectStatusList'
export default function OrgProjectFilters() {
@@ -54,20 +54,29 @@ export default function OrgProjectFilters() {
handleQueryChange={handleQueryChange}
/>
{/* Keywords */}
-
+
+
+
{/* Research domains */}
-
+
+
+
{/* Participating organisations */}
-
+
+
+
>
)
}
diff --git a/frontend/components/organisation/projects/filters/useProjectOrderOptions.tsx b/frontend/components/organisation/projects/filters/useProjectOrderOptions.tsx
deleted file mode 100644
index 26107e1c1..000000000
--- a/frontend/components/organisation/projects/filters/useProjectOrderOptions.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import {useEffect, useState} from 'react'
-import {OrderOption} from '~/components/filter/OrderBy'
-import {projectOrderOptions} from '~/components/projects/overview/filters/OrderProjectsBy'
-import useOrganisationContext from '../../context/useOrganisationContext'
-
-// additional admin options
-export const adminOptions = [
- {key: 'status', label: 'Blocked', direction: 'asc.nullslast'},
- {key: 'is_published', label: 'Not published', direction:'asc.nullslast'}
-]
-
-export function getProjectOrderOptions(isMaintainer:boolean) {
- // if maintainer additional order options are added
- if (isMaintainer) {
- const order = [
- ...projectOrderOptions,
- // organisation specific option
- {key: 'is_featured', label: 'Pinned', direction: 'desc.nullslast'},
- ...adminOptions
- ]
- return order
- } else {
- return [
- ...projectOrderOptions,
- // organisation specific option
- {key: 'is_featured', label: 'Pinned', direction: 'desc.nullslast'},
- ]
- }
-}
-
-export default function useProjectOrderOptions() {
- const {isMaintainer} = useOrganisationContext()
- const [orderOptions, setOrderOptions] = useState([])
-
- useEffect(() => {
- const orderOptions = getProjectOrderOptions(isMaintainer)
- setOrderOptions(orderOptions)
- },[isMaintainer])
-
- return orderOptions
-
-}
diff --git a/frontend/components/organisation/projects/index.tsx b/frontend/components/organisation/projects/index.tsx
index d17b10b29..e864c50cb 100644
--- a/frontend/components/organisation/projects/index.tsx
+++ b/frontend/components/organisation/projects/index.tsx
@@ -15,7 +15,7 @@ import {ProjectLayoutType} from '~/components/projects/overview/search/ViewToggl
import {setDocumentCookie} from '~/utils/userSettings'
import useOrganisationContext from '../context/useOrganisationContext'
import {useUserSettings} from '../context/UserSettingsContext'
-import OrgProjectFilters from './filters/OrgProjectFilters'
+import OrgProjectFilters from './filters'
import useOrganisationProjects from './useOrganisationProjects'
import OrgSearchProjectSection from './search/OrgSearchProjectSection'
import useProjectParams from './useProjectParams'
diff --git a/frontend/components/organisation/projects/search/OrgSearchProjectSection.tsx b/frontend/components/organisation/projects/search/OrgSearchProjectSection.tsx
index c30e6ac26..018c83a99 100644
--- a/frontend/components/organisation/projects/search/OrgSearchProjectSection.tsx
+++ b/frontend/components/organisation/projects/search/OrgSearchProjectSection.tsx
@@ -1,31 +1,27 @@
+// 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: 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2023 dv4all
// SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center)
//
// SPDX-License-Identifier: Apache-2.0
+import {useState} from 'react'
import useMediaQuery from '@mui/material/useMediaQuery'
import Button from '@mui/material/Button'
+import {getPageRange} from '~/utils/pagination'
import SearchInput from '~/components/search/SearchInput'
import SelectRows from '~/components/software/overview/search/SelectRows'
+import FiltersModal from '~/components/filter/FiltersModal'
import ViewToggleGroup, {ProjectLayoutType} from '~/components/projects/overview/search/ViewToggleGroup'
-import useQueryChange from '../useQueryChange'
-import {useState} from 'react'
-import OrgProjectFiltersModal from '../filters/OrgProjectFiltersModal'
-import useProjectParams from '../useProjectParams'
-import {getPageRange} from '~/utils/pagination'
+import OrgProjectFilters from '~/components/organisation/projects/filters/index'
+import useQueryChange from '~/components/organisation/projects/useQueryChange'
+import useProjectParams from '~/components/organisation/projects/useProjectParams'
type SearchSectionProps = {
- // search?: string | null
- // page: number
- // rows: number
count: number
- // placeholder: string
layout: ProjectLayoutType
- // setModal: (modal: boolean) => void
setView: (view:ProjectLayoutType)=>void
}
@@ -77,12 +73,14 @@ export default function OrganisationSearchProjectSection({
}
- {
- smallScreen === true &&
-
+ >
+
+
+ : undefined
}
diff --git a/frontend/components/organisation/projects/useOrganisationProjects.tsx b/frontend/components/organisation/projects/useOrganisationProjects.tsx
index 5925f7f63..1359cd960 100644
--- a/frontend/components/organisation/projects/useOrganisationProjects.tsx
+++ b/frontend/components/organisation/projects/useOrganisationProjects.tsx
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 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
@@ -13,7 +13,8 @@ import {decodeJsonParam} from '~/utils/extractQueryParam'
import {getProjectsForOrganisation} from '../apiOrganisations'
import useProjectParams from './useProjectParams'
import useOrganisationContext from '../context/useOrganisationContext'
-import {getProjectOrderOptions} from './filters/useProjectOrderOptions'
+import {getProjectOrderOptions} from './filters/OrgOrderProjectsBy'
+
type State = {
count: number,
diff --git a/frontend/components/organisation/projects/useQueryChange.tsx b/frontend/components/organisation/projects/useQueryChange.tsx
index 1fec8c592..236503cf3 100644
--- a/frontend/components/organisation/projects/useQueryChange.tsx
+++ b/frontend/components/organisation/projects/useQueryChange.tsx
@@ -1,63 +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 {useCallback} from 'react'
import {useRouter} from 'next/router'
-import {rowsPerPageOptions} from '~/config/pagination'
-import {encodeQueryValue} from '~/utils/extractQueryParam'
-import {QueryParams} from '~/utils/postgrestUrl'
-import {getDocumentCookie} from '~/utils/userSettings'
-import {TabKey} from '../tabs/OrganisationTabItems'
+import useFilterQueryChange from '~/components/filter/useFilterQueryChange'
+import {TabKey} from '~/components/organisation/tabs/OrganisationTabItems'
export default function useQueryChange() {
const router = useRouter()
+ const {handleQueryChange} = useFilterQueryChange()
// console.group('useQueryChange')
// console.log('hook called...')
// console.groupEnd()
- const handleQueryChange = useCallback((key: string, value: string | string[]) => {
- const params: QueryParams = {
- [key]: encodeQueryValue(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])
- }
-
- // update query parameters
- const query:any = {
- ...router.query,
- ...params
- }
-
- if (value === '' || value === null || typeof value === 'undefined') {
- // remove query param
- delete query[key]
- }
-
- // construct url with all query params
- if (key === 'page') {
- // console.group('useQueryChange')
- // console.log('scroll...true')
- // console.groupEnd()
- // on page change we scroll to top
- router.push({query},undefined,{scroll: true})
- } else {
- // console.group('useQueryChange')
- // console.log('scroll...false')
- // console.groupEnd()
- router.push({query},undefined,{scroll: false})
- }
- }, [router])
-
const resetFilters = useCallback((tab: TabKey) => {
// use basic params
const query: any = {
diff --git a/frontend/components/organisation/software/filters/OrgOrderSoftwareBy.tsx b/frontend/components/organisation/software/filters/OrgOrderSoftwareBy.tsx
index 5494e5d9d..ee2975f2e 100644
--- a/frontend/components/organisation/software/filters/OrgOrderSoftwareBy.tsx
+++ b/frontend/components/organisation/software/filters/OrgOrderSoftwareBy.tsx
@@ -1,30 +1,51 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
+// 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 Netherlands eScience Center
// SPDX-FileCopyrightText: 2023 dv4all
//
// SPDX-License-Identifier: Apache-2.0
import OrderBy from '~/components/filter/OrderBy'
import useQueryChange from '~/components/organisation/projects/useQueryChange'
+import {softwareOrderOptions} from '~/components/software/overview/filters/OrderSoftwareBy'
+import useOrganisationContext from '~/components/organisation/context/useOrganisationContext'
import useSoftwareParams from './useSoftwareParams'
-import useSoftwareOrderOptions from './useSoftwareOrderOptions'
-export default function OrgOrderProjectsBy() {
- const {order} = useSoftwareParams()
- const {handleQueryChange} = useQueryChange()
- const softwareOrderOptions = useSoftwareOrderOptions()
+const adminOrderOptions = [
+ {key: 'status', label: 'Blocked', direction: 'asc.nullslast'},
+ {key: 'is_published', label: 'Not published', direction: 'asc.nullslast'},
+]
- // we load component only if there are options
- if (softwareOrderOptions.length > 0) {
- return (
-
- )
+export function getSoftwareOrderOptions(isMaintainer:boolean) {
+ if (isMaintainer) {
+ const order = [
+ ...softwareOrderOptions,
+ // additional organisation option (should be default)
+ {key: 'is_featured', label: 'Pinned', direction: 'desc.nullslast'},
+ ...adminOrderOptions
+ ]
+ return order
+ } else {
+ return [
+ ...softwareOrderOptions,
+ // additional organisation option (should be default)
+ {key: 'is_featured', label: 'Pinned', direction: 'desc.nullslast'}
+ ]
}
+}
+
+
+export default function OrgOrderSoftwareBy() {
+ const {isMaintainer} = useOrganisationContext()
+ const {order} = useSoftwareParams()
+ const orderOptions = getSoftwareOrderOptions(isMaintainer)
+ const {handleQueryChange} = useQueryChange()
- return null
+ return (
+
+ )
}
diff --git a/frontend/components/organisation/software/filters/OrgSoftwareKeywordsFilter.tsx b/frontend/components/organisation/software/filters/OrgSoftwareKeywordsFilter.tsx
deleted file mode 100644
index f30525d46..000000000
--- a/frontend/components/organisation/software/filters/OrgSoftwareKeywordsFilter.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import KeywordsFilter, {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
-import useQueryChange from '~/components/organisation/projects/useQueryChange'
-
-type SoftwareKeywordsFilterProps = {
- keywords: string[],
- keywordsList: KeywordFilterOption[]
-}
-
-export default function OrgSoftwareKeywordsFilter({keywords, keywordsList}: SoftwareKeywordsFilterProps) {
- const {handleQueryChange} = useQueryChange()
-
- return (
-
-
-
- )
-
-}
diff --git a/frontend/components/organisation/software/filters/OrgSoftwareLanguagesFilter.tsx b/frontend/components/organisation/software/filters/OrgSoftwareLanguagesFilter.tsx
deleted file mode 100644
index ce3456f72..000000000
--- a/frontend/components/organisation/software/filters/OrgSoftwareLanguagesFilter.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import ProgrammingLanguagesFilter, {LanguagesFilterOption} from '~/components/filter/ProgrammingLanguagesFilter'
-import useQueryChange from '../../projects/useQueryChange'
-
-type ProgrammingLanguagesFilterProps = {
- prog_lang: string[],
- languagesList: LanguagesFilterOption[]
-}
-
-export default function SoftwareLanguagesFilter({prog_lang, languagesList}: ProgrammingLanguagesFilterProps) {
- const {handleQueryChange} = useQueryChange()
-
- return (
-
- )
-}
diff --git a/frontend/components/organisation/software/filters/OrgSoftwareLicensesFilter.tsx b/frontend/components/organisation/software/filters/OrgSoftwareLicensesFilter.tsx
deleted file mode 100644
index dde0fdc4e..000000000
--- a/frontend/components/organisation/software/filters/OrgSoftwareLicensesFilter.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import useQueryChange from '~/components/organisation/projects/useQueryChange'
-import LicensesFilter, {LicensesFilterOption} from '~/components/filter/LicensesFilter'
-
-
-type LicensesFilterProps = {
- licenses: string[],
- licensesList: LicensesFilterOption[],
-}
-
-export default function SoftwareLicensesFilter({licenses, licensesList}: LicensesFilterProps) {
- const {handleQueryChange} = useQueryChange()
-
- return (
-
-
-
- )
-}
diff --git a/frontend/components/organisation/software/filters/OrgSoftwareFilters.tsx b/frontend/components/organisation/software/filters/index.tsx
similarity index 61%
rename from frontend/components/organisation/software/filters/OrgSoftwareFilters.tsx
rename to frontend/components/organisation/software/filters/index.tsx
index fcff5b898..11f206dbb 100644
--- a/frontend/components/organisation/software/filters/OrgSoftwareFilters.tsx
+++ b/frontend/components/organisation/software/filters/index.tsx
@@ -1,6 +1,6 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
+// 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 Netherlands eScience Center
// SPDX-FileCopyrightText: 2023 dv4all
//
// SPDX-License-Identifier: Apache-2.0
@@ -9,17 +9,18 @@ import FilterHeader from '~/components/filter/FilterHeader'
import {decodeJsonParam} from '~/utils/extractQueryParam'
import useQueryChange from '~/components/organisation/projects/useQueryChange'
+import KeywordsFilter from '~/components/filter/KeywordsFilter'
+import ProgrammingLanguagesFilter from '~/components/filter/ProgrammingLanguagesFilter'
+import LicensesFilter from '~/components/filter/LicensesFilter'
+
import OrgOrderSoftwareBy from './OrgOrderSoftwareBy'
-import OrgProjectKeywordsFilter from './OrgSoftwareKeywordsFilter'
import useSoftwareParams from './useSoftwareParams'
import useOrgSoftwareKeywordsList from './useOrgSoftwareKeywordsList'
-import OrgSoftwareLicensesFilter from './OrgSoftwareLicensesFilter'
import useOrgSoftwareLicensesList from './useOrgSoftwareLicensesList'
-import OrgSoftwareLanguagesFilter from './OrgSoftwareLanguagesFilter'
import useOrgSoftwareLanguagesList from './useOrgSoftwareLanguagesList'
export default function OrgSoftwareFilters() {
- const {resetFilters} = useQueryChange()
+ const {resetFilters,handleQueryChange} = useQueryChange()
const {filterCnt,keywords_json,prog_lang_json,licenses_json} = useSoftwareParams()
const {keywordsList} = useOrgSoftwareKeywordsList()
const {languagesList} = useOrgSoftwareLanguagesList()
@@ -45,20 +46,29 @@ export default function OrgSoftwareFilters() {
{/* Order by */}
{/* Keywords */}
-
+
+
+
{/* Program languages */}
-
+
{/* Licenses */}
-
+
+
+
>
)
}
diff --git a/frontend/components/organisation/software/filters/useSoftwareOrderOptions.tsx b/frontend/components/organisation/software/filters/useSoftwareOrderOptions.tsx
deleted file mode 100644
index 86197eb39..000000000
--- a/frontend/components/organisation/software/filters/useSoftwareOrderOptions.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import {useEffect, useState} from 'react'
-import {softwareOrderOptions} from '~/components/software/overview/filters/OrderSoftwareBy'
-import useOrganisationContext from '../../context/useOrganisationContext'
-import {OrderOption} from '~/components/filter/OrderBy'
-
-const adminOrderOptions = [
- {key: 'status', label: 'Blocked', direction: 'asc.nullslast'},
- {key: 'is_published', label: 'Not published', direction: 'asc.nullslast'},
-]
-
-export function getSoftwareOrderOptions(isMaintainer:boolean) {
- if (isMaintainer) {
- const order = [
- ...softwareOrderOptions,
- // additional organisation option (should be default)
- {key: 'is_featured', label: 'Pinned', direction: 'desc.nullslast'},
- ...adminOrderOptions
- ]
- return order
- } else {
- return [
- ...softwareOrderOptions,
- // additional organisation option (should be default)
- {key: 'is_featured', label: 'Pinned', direction: 'desc.nullslast'}
- ]
- }
-}
-
-export default function useSoftwareOrderOptions() {
- const {isMaintainer} = useOrganisationContext()
- const [softwareOrder,setSoftwareOrder] = useState([])
-
- useEffect(() => {
- const order = getSoftwareOrderOptions(isMaintainer)
- setSoftwareOrder(order)
- }, [isMaintainer])
-
- return softwareOrder
-
-}
diff --git a/frontend/components/organisation/software/filters/useSoftwareParams.tsx b/frontend/components/organisation/software/filters/useSoftwareParams.tsx
index 70f86155c..dfe62e0b5 100644
--- a/frontend/components/organisation/software/filters/useSoftwareParams.tsx
+++ b/frontend/components/organisation/software/filters/useSoftwareParams.tsx
@@ -1,5 +1,5 @@
-// 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
@@ -8,12 +8,10 @@ import {getSoftwareParams} from '~/utils/extractQueryParam'
import {useUserSettings} from '../../context/UserSettingsContext'
export default function useSoftwareParams() {
- // initalise router
+ // initialise router
const router = useRouter()
// get user preferences
const {rsd_page_rows} = useUserSettings()
- // extract project specific params
- // const params = ssrProjectsParams(router.query)
// use encoded array params as json string to avoid
// useEffect re-renders in api hooks
const params = getSoftwareParams(router.query)
@@ -37,9 +35,8 @@ export default function useSoftwareParams() {
// console.log('params...', params)
// console.log('rsd_page_rows...', rsd_page_rows)
// console.groupEnd()
- // extract user prefference
- // return these
+ // return params & count
return {
...params,
filterCnt
diff --git a/frontend/components/organisation/software/index.tsx b/frontend/components/organisation/software/index.tsx
index f135c4764..7cb82af0a 100644
--- a/frontend/components/organisation/software/index.tsx
+++ b/frontend/components/organisation/software/index.tsx
@@ -15,7 +15,7 @@ import {useUserSettings} from '../context/UserSettingsContext'
import {ProjectLayoutType} from '~/components/projects/overview/search/ViewToggleGroup'
import {setDocumentCookie} from '~/utils/userSettings'
import FiltersPanel from '~/components/filter/FiltersPanel'
-import OrgSoftwareFilters from './filters/OrgSoftwareFilters'
+import OrgSoftwareFilters from './filters/index'
import useSoftwareParams from './filters/useSoftwareParams'
import useQueryChange from '../projects/useQueryChange'
import OrgSearchSoftwareSection from './search/OrgSearchSoftwareSection'
diff --git a/frontend/components/organisation/software/search/OrgSearchSoftwareSection.tsx b/frontend/components/organisation/software/search/OrgSearchSoftwareSection.tsx
index 07501db6a..a0460e6b7 100644
--- a/frontend/components/organisation/software/search/OrgSearchSoftwareSection.tsx
+++ b/frontend/components/organisation/software/search/OrgSearchSoftwareSection.tsx
@@ -1,5 +1,5 @@
+// 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: 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2023 dv4all
// SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center)
@@ -10,22 +10,18 @@ import {useState} from 'react'
import useMediaQuery from '@mui/material/useMediaQuery'
import Button from '@mui/material/Button'
+import {getPageRange} from '~/utils/pagination'
import SearchInput from '~/components/search/SearchInput'
import SelectRows from '~/components/software/overview/search/SelectRows'
import ViewToggleGroup, {ProjectLayoutType} from '~/components/projects/overview/search/ViewToggleGroup'
import useQueryChange from '~/components/organisation/projects/useQueryChange'
-import OrgSoftwareFiltersModal from '../filters/OrgSoftwareFiltersModal'
-import useSoftwareParams from '../filters/useSoftwareParams'
-import {getPageRange} from '~/utils/pagination'
+import FiltersModal from '~/components/filter/FiltersModal'
+import useSoftwareParams from '~/components/organisation/software/filters/useSoftwareParams'
+import OrgSoftwareFilters from '~/components/organisation/software/filters/index'
type SearchSectionProps = {
- // search?: string | null
- // page: number
- // rows: number
count: number
- // placeholder: string
layout: ProjectLayoutType
- // setModal: (modal: boolean) => void
setView: (view:ProjectLayoutType)=>void
}
@@ -77,12 +73,14 @@ export default function OrgSearchSoftwareSection({
}
- {
- smallScreen === true &&
-
+ >
+
+
+ : undefined
}
diff --git a/frontend/components/organisation/software/useOrganisationSoftware.tsx b/frontend/components/organisation/software/useOrganisationSoftware.tsx
index 3601618fb..36a00475d 100644
--- a/frontend/components/organisation/software/useOrganisationSoftware.tsx
+++ b/frontend/components/organisation/software/useOrganisationSoftware.tsx
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 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
@@ -13,7 +13,7 @@ import {SoftwareOfOrganisation} from '~/types/Organisation'
import {getSoftwareForOrganisation} from '../apiOrganisations'
import useOrganisationContext from '../context/useOrganisationContext'
import useSoftwareParams from './filters/useSoftwareParams'
-import {getSoftwareOrderOptions} from './filters/useSoftwareOrderOptions'
+import {getSoftwareOrderOptions} from './filters/OrgOrderSoftwareBy'
type State = {
count: number,
diff --git a/frontend/components/projects/overview/filters/ProjectFiltersModal.tsx b/frontend/components/projects/overview/filters/ProjectFiltersModal.tsx
index da1bc8502..f8fd620a1 100644
--- a/frontend/components/projects/overview/filters/ProjectFiltersModal.tsx
+++ b/frontend/components/projects/overview/filters/ProjectFiltersModal.tsx
@@ -1,6 +1,6 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
+// 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 Netherlands eScience Center
// SPDX-FileCopyrightText: 2023 dv4all
//
// SPDX-License-Identifier: Apache-2.0
@@ -13,8 +13,9 @@ import DialogActions from '@mui/material/DialogActions'
import Button from '@mui/material/Button'
import {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
import {ResearchDomainOption} from '~/components/filter/ResearchDomainFilter'
-import {OrganisationOption} from './ProjectOrganisationsFilter'
-import ProjectFilters from './ProjectFilters'
+import {OrganisationOption} from '~/components/filter/OrganisationsFilter'
+
+import ProjectFilters from './index'
import {StatusFilterOption} from './ProjectStatusFilter'
type ProjectFiltersModalProps = {
diff --git a/frontend/components/projects/overview/filters/ProjectKeywordsFilter.tsx b/frontend/components/projects/overview/filters/ProjectKeywordsFilter.tsx
deleted file mode 100644
index 74ea59788..000000000
--- a/frontend/components/projects/overview/filters/ProjectKeywordsFilter.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import useProjectOverviewParams from '../useProjectOverviewParams'
-import KeywordsFilter, {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
-
-type ProjectKeywordsFilterProps = {
- keywords: string[],
- keywordsList: KeywordFilterOption[]
-}
-
-export default function ProjectKeywordsFilter({keywords, keywordsList}: ProjectKeywordsFilterProps) {
- const {handleQueryChange} = useProjectOverviewParams()
-
- return (
-
-
-
- )
-}
diff --git a/frontend/components/projects/overview/filters/ProjectOrganisationsFilter.tsx b/frontend/components/projects/overview/filters/ProjectOrganisationsFilter.tsx
deleted file mode 100644
index 575a8232e..000000000
--- a/frontend/components/projects/overview/filters/ProjectOrganisationsFilter.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import useProjectOverviewParams from '../useProjectOverviewParams'
-import OrganisationsFilter from '~/components/filter/OrganisationsFilter'
-
-export type OrganisationOption = {
- organisation: string,
- organisation_cnt: number
-}
-
-type OrganisationFilterProps = {
- organisations: string[],
- organisationsList: OrganisationOption[]
-}
-
-export default function ProjectOrganisationsFilter({organisations, organisationsList}: OrganisationFilterProps) {
- const {handleQueryChange} = useProjectOverviewParams()
-
- return (
-
-
-
- )
-}
diff --git a/frontend/components/projects/overview/filters/ProjectResearchDomainFilter.tsx b/frontend/components/projects/overview/filters/ProjectResearchDomainFilter.tsx
deleted file mode 100644
index 8f66abd40..000000000
--- a/frontend/components/projects/overview/filters/ProjectResearchDomainFilter.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import ResearchDomainFilter, {ResearchDomainOption} from '~/components/filter/ResearchDomainFilter'
-import useProjectOverviewParams from '../useProjectOverviewParams'
-
-type ResearchDomainFilterProps = {
- domains: string[],
- domainsList: ResearchDomainOption[]
-}
-
-export default function ProjectResearchDomainFilter({domains, domainsList}: ResearchDomainFilterProps) {
- const {handleQueryChange} = useProjectOverviewParams()
-
- // console.group('ResearchDomainFilter')
- // console.log('domainsList...', domainsList)
- // console.log('options...', options)
- // console.groupEnd()
-
- return (
-
-
-
- )
-
-}
diff --git a/frontend/components/projects/overview/filters/ProjectFilters.tsx b/frontend/components/projects/overview/filters/index.tsx
similarity index 61%
rename from frontend/components/projects/overview/filters/ProjectFilters.tsx
rename to frontend/components/projects/overview/filters/index.tsx
index 9f5cd3965..347ef9c1f 100644
--- a/frontend/components/projects/overview/filters/ProjectFilters.tsx
+++ b/frontend/components/projects/overview/filters/index.tsx
@@ -1,19 +1,17 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
+// 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 Netherlands eScience Center
// SPDX-FileCopyrightText: 2023 dv4all
//
// SPDX-License-Identifier: Apache-2.0
import FilterHeader from '~/components/filter/FilterHeader'
-import {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
-import {ResearchDomainOption} from '~/components/filter/ResearchDomainFilter'
+import KeywordsFilter, {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
+import OrganisationsFilter, {OrganisationOption} from '~/components/filter/OrganisationsFilter'
+import ResearchDomainFilter, {ResearchDomainOption} from '~/components/filter/ResearchDomainFilter'
import useProjectOverviewParams from '../useProjectOverviewParams'
-import ProjectKeywordsFilter from './ProjectKeywordsFilter'
-import ProjectResearchDomainFilter from './ProjectResearchDomainFilter'
import OrderProjectsBy from './OrderProjectsBy'
-import ProjectOrganisationsFilter, {OrganisationOption} from './ProjectOrganisationsFilter'
import ProjectStatusFilter, {StatusFilterOption} from './ProjectStatusFilter'
type ProjectFiltersProps = {
@@ -68,21 +66,29 @@ export default function ProjectFilters({
handleQueryChange={handleQueryChange}
/>
{/* Keywords */}
-
+
+
+
{/* Research domains */}
-
+
+
+
{/* Participating organisations */}
-
-
+
+
+
>
)
}
diff --git a/frontend/components/software/overview/filters/SoftwareKeywordsFilter.tsx b/frontend/components/software/overview/filters/SoftwareKeywordsFilter.tsx
deleted file mode 100644
index dc09f316e..000000000
--- a/frontend/components/software/overview/filters/SoftwareKeywordsFilter.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import {useEffect, useState} from 'react'
-import Autocomplete from '@mui/material/Autocomplete'
-import TextField from '@mui/material/TextField'
-
-import {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
-import FilterTitle from '~/components/filter/FilterTitle'
-import FilterOption from '~/components/filter/FilterOption'
-import useSoftwareOverviewParams from '../useSoftwareOverviewParams'
-
-type SoftwareKeywordsFilterProps = {
- keywords: string[],
- keywordsList: KeywordFilterOption[]
-}
-
-export default function SoftwareKeywordsFilter({keywords, keywordsList}: SoftwareKeywordsFilterProps) {
- const {handleQueryChange} = useSoftwareOverviewParams()
- const [selected, setSelected] = useState([])
- const [options, setOptions] = useState(keywordsList)
-
- // console.group('KeywordsFilter')
- // console.log('keywordsList...', keywordsList)
- // console.log('options...', options)
- // console.groupEnd()
-
- useEffect(() => {
- if (keywords.length > 0 && keywordsList.length) {
- const selectedKeywords = keywordsList.filter(option => {
- return keywords.includes(option.keyword)
- })
- setSelected(selectedKeywords)
- } else {
- setSelected([])
- }
- setOptions(keywordsList)
- },[keywords,keywordsList])
-
- return (
-
-
-
(option.keyword)}
- isOptionEqualToValue={(option, value) => {
- return option.keyword === value.keyword
- }}
- defaultValue={[]}
- filterSelectedOptions
- renderOption={(props, option) => (
-
- )}
- renderInput={(params) => (
-
- )}
- onChange={(event, newValue) => {
- // extract values into string[] for url query
- const queryFilter = newValue.map(item => item.keyword)
- handleQueryChange('keywords', queryFilter)
- }}
- />
-
- )
-}
diff --git a/frontend/components/software/overview/filters/SoftwareLanguagesFilter.tsx b/frontend/components/software/overview/filters/SoftwareLanguagesFilter.tsx
deleted file mode 100644
index 7ab9adea9..000000000
--- a/frontend/components/software/overview/filters/SoftwareLanguagesFilter.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import useSoftwareOverviewParams from '../useSoftwareOverviewParams'
-import ProgrammingLanguagesFilter, {LanguagesFilterOption} from '~/components/filter/ProgrammingLanguagesFilter'
-
-type ProgrammingLanguagesFilterProps = {
- prog_lang: string[],
- languagesList: LanguagesFilterOption[]
-}
-
-export default function SoftwareLanguagesFilter({prog_lang, languagesList}: ProgrammingLanguagesFilterProps) {
- const {handleQueryChange} = useSoftwareOverviewParams()
-
- return (
-
- )
-}
diff --git a/frontend/components/software/overview/filters/SoftwareLicensesFilter.tsx b/frontend/components/software/overview/filters/SoftwareLicensesFilter.tsx
deleted file mode 100644
index 363fd520c..000000000
--- a/frontend/components/software/overview/filters/SoftwareLicensesFilter.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
-// SPDX-FileCopyrightText: 2023 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import useSoftwareOverviewParams from '../useSoftwareOverviewParams'
-import LicensesFilter, {LicensesFilterOption} from '~/components/filter/LicensesFilter'
-
-
-type LicensesFilterProps = {
- licenses: string[],
- licensesList: LicensesFilterOption[],
-}
-
-export default function SoftwareLicensesFilter({licenses, licensesList}: LicensesFilterProps) {
- const {handleQueryChange} = useSoftwareOverviewParams()
-
- return (
-
-
-
- )
-}
diff --git a/frontend/components/software/overview/filters/index.tsx b/frontend/components/software/overview/filters/index.tsx
index 8ac1d2bc2..d01d6dfd7 100644
--- a/frontend/components/software/overview/filters/index.tsx
+++ b/frontend/components/software/overview/filters/index.tsx
@@ -1,7 +1,7 @@
-// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
+// 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 Dusan Mijatovic (dv4all) (dv4all)
-// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
// SPDX-FileCopyrightText: 2023 dv4all
// SPDX-FileCopyrightText: 2024 Christian Meeßen (GFZ)
// SPDX-FileCopyrightText: 2024 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
@@ -9,14 +9,11 @@
// SPDX-License-Identifier: Apache-2.0
import FilterHeader from '~/components/filter/FilterHeader'
-import {LicensesFilterOption} from '~/components/filter/LicensesFilter'
-import {LanguagesFilterOption} from '~/components/filter/ProgrammingLanguagesFilter'
-import {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
+import KeywordsFilter, {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
+import ProgrammingLanguagesFilter, {LanguagesFilterOption} from '~/components/filter/ProgrammingLanguagesFilter'
+import LicensesFilter, {LicensesFilterOption} from '~/components/filter/LicensesFilter'
import useSoftwareOverviewParams from '../useSoftwareOverviewParams'
import OrderSoftwareBy, {OrderHighlightsBy} from './OrderSoftwareBy'
-import SoftwareKeywordsFilter from './SoftwareKeywordsFilter'
-import SoftwareLanguagesFilter from './SoftwareLanguagesFilter'
-import SoftwareLicensesFilter from './SoftwareLicensesFilter'
export type LicenseWithCount = {
license: string;
@@ -46,7 +43,7 @@ export default function SoftwareFilters({
orderBy,
highlightsOnly = false
}:SoftwareFilterProps) {
- const {resetFilters} = useSoftwareOverviewParams()
+ const {resetFilters,handleQueryChange} = useSoftwareOverviewParams()
function clearDisabled() {
if (filterCnt && filterCnt > 0) return false
@@ -64,20 +61,29 @@ export default function SoftwareFilters({
{highlightsOnly && }
{!highlightsOnly && }
{/* Keywords */}
-
- {/* Programme Languages */}
-
+
+
+
+ {/* Programming Languages */}
+
{/* Licenses */}
-
+
+
+
>
)
}
diff --git a/frontend/components/software/overview/search/SoftwareSearchSection.tsx b/frontend/components/software/overview/search/SoftwareSearchSection.tsx
index 7b296099e..68763b892 100644
--- a/frontend/components/software/overview/search/SoftwareSearchSection.tsx
+++ b/frontend/components/software/overview/search/SoftwareSearchSection.tsx
@@ -1,5 +1,5 @@
+// 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: 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2023 dv4all
// SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center)
@@ -9,11 +9,11 @@
import useMediaQuery from '@mui/material/useMediaQuery'
import Button from '@mui/material/Button'
+import {getPageRange} from '~/utils/pagination'
import SearchInput from '~/components/search/SearchInput'
+import useSoftwareOverviewParams from '../useSoftwareOverviewParams'
import ViewToggleGroup, {LayoutType} from './ViewToggleGroup'
import SelectRows from './SelectRows'
-import useSoftwareOverviewParams from '../useSoftwareOverviewParams'
-import {getPageRange} from '~/utils/pagination'
type SearchSectionProps = {
page: number
diff --git a/frontend/pages/communities/[slug]/about.tsx b/frontend/pages/communities/[slug]/about.tsx
index 022492d26..8beac7688 100644
--- a/frontend/pages/communities/[slug]/about.tsx
+++ b/frontend/pages/communities/[slug]/about.tsx
@@ -12,7 +12,6 @@ import {EditCommunityProps, getCommunityBySlug} from '~/components/communities/a
import {LayoutType} from '~/components/software/overview/search/ViewToggleGroup'
import PageMeta from '~/components/seo/PageMeta'
import CanonicalUrl from '~/components/seo/CanonicalUrl'
-import {isCommunityMaintainer} from '~/auth/permissions/isMaintainerOfCommunity'
import AboutCommunityPage from '~/components/communities/about'
import CommunityPage from '~/components/communities/CommunityPage'
import {getKeywordsByCommunity} from '~/components/communities/settings/general/apiCommunityKeywords'
@@ -80,34 +79,26 @@ export async function getServerSideProps(context:GetServerSidePropsContext) {
const token = req?.cookies['rsd_token']
const user = getUserFromToken(token)
// find community by slug
- const community = await getCommunityBySlug({
+ const {community:com, isMaintainer} = await getCommunityBySlug({
slug: params?.slug?.toString() ?? null,
token: req?.cookies['rsd_token'],
user
})
// console.log('community...', community)
- if (community === null || community?.description === null){
+ if (com === null || com?.description === null){
// returning notFound triggers 404 page
return {
notFound: true,
}
}
// get info if the user is maintainer
- const [isMaintainer,keywords] = await Promise.all([
- isCommunityMaintainer({
- community: community.id ?? '',
- role: user?.role,
- account: user?.account,
- token
- }),
- getKeywordsByCommunity(community.id,token)
- ])
+ const keywords = await getKeywordsByCommunity(com.id,token)
return {
// passed to the page component as props
props: {
community:{
- ...community,
+ ...com,
// use keywords for editing
keywords
},
diff --git a/frontend/pages/communities/[slug]/rejected.tsx b/frontend/pages/communities/[slug]/rejected.tsx
new file mode 100644
index 000000000..6ef68f87b
--- /dev/null
+++ b/frontend/pages/communities/[slug]/rejected.tsx
@@ -0,0 +1,156 @@
+// 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 PageMeta from '~/components/seo/PageMeta'
+import CanonicalUrl from '~/components/seo/CanonicalUrl'
+import {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
+import {LanguagesFilterOption} from '~/components/filter/ProgrammingLanguagesFilter'
+import {LicensesFilterOption} from '~/components/filter/LicensesFilter'
+import {LayoutType} from '~/components/software/overview/search/ViewToggleGroup'
+import {EditCommunityProps, getCommunityBySlug} from '~/components/communities/apiCommunities'
+import CommunityPage from '~/components/communities/CommunityPage'
+import CommunitySoftware from '~/components/communities/software'
+import {SoftwareOfCommunity, ssrCommunitySoftwareProps} from '~/components/communities/software/apiCommunitySoftware'
+
+type CommunitySoftwareProps={
+ community: EditCommunityProps,
+ software: SoftwareOfCommunity[],
+ slug: string[],
+ isMaintainer: boolean,
+ rsd_page_rows: number,
+ rsd_page_layout: LayoutType,
+ count: number,
+ keywordsList: KeywordFilterOption[],
+ languagesList: LanguagesFilterOption[],
+ licensesList: LicensesFilterOption[],
+}
+
+export default function CommunityRejectedSoftwarePage({
+ community,slug,isMaintainer,
+ rsd_page_rows, rsd_page_layout,
+ software, count, keywordsList,
+ languagesList, licensesList
+}:CommunitySoftwareProps) {
+
+ // console.group('CommunityRejectedSoftwarePage')
+ // console.log('community...', community)
+ // console.log('slug....', slug)
+ // console.log('software....', software)
+ // console.log('isMaintainer....', isMaintainer)
+ // console.log('rsd_page_rows....', rsd_page_rows)
+ // console.log('rsd_page_layout....', rsd_page_layout)
+ // console.log('keywordsList....', keywordsList)
+ // console.log('languagesList....', languagesList)
+ // console.log('licensesList....', licensesList)
+ // 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 */}
+
+
+
+
+
+ >
+ )
+}
+
+
+// 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)
+
+ // get community by slug and isMaintainer info
+ const {community:com,isMaintainer} = await getCommunityBySlug({
+ slug: params?.slug?.toString() ?? null,
+ token: req?.cookies['rsd_token'],
+ user
+ })
+ // console.log('community...', community)
+ if (com === null){
+ // returning notFound triggers 404 page
+ return {
+ notFound: true,
+ }
+ }
+ // deconstruct data
+ const {
+ software,
+ keywordsList,
+ languagesList,
+ licensesList,
+ // community with updated keywords
+ community
+ } = await ssrCommunitySoftwareProps({
+ community: com,
+ software_status: 'rejected',
+ query: query,
+ isMaintainer,
+ token
+ })
+
+ // update community count to actual count
+ // community.software_cnt = software.count
+ return {
+ // passed to the page component as props
+ props: {
+ community,
+ slug: [params?.slug],
+ isMaintainer,
+ rsd_page_layout,
+ rsd_page_rows,
+ count: software.count,
+ software: software.data,
+ keywordsList,
+ languagesList,
+ licensesList
+ },
+ }
+ }catch(e){
+ return {
+ notFound: true,
+ }
+ }
+}
diff --git a/frontend/pages/communities/[slug]/requests.tsx b/frontend/pages/communities/[slug]/requests.tsx
new file mode 100644
index 000000000..92b05aaf0
--- /dev/null
+++ b/frontend/pages/communities/[slug]/requests.tsx
@@ -0,0 +1,156 @@
+// 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 PageMeta from '~/components/seo/PageMeta'
+import CanonicalUrl from '~/components/seo/CanonicalUrl'
+import {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
+import {LanguagesFilterOption} from '~/components/filter/ProgrammingLanguagesFilter'
+import {LicensesFilterOption} from '~/components/filter/LicensesFilter'
+import {LayoutType} from '~/components/software/overview/search/ViewToggleGroup'
+import {EditCommunityProps, getCommunityBySlug} from '~/components/communities/apiCommunities'
+import CommunityPage from '~/components/communities/CommunityPage'
+import CommunitySoftware from '~/components/communities/software'
+import {SoftwareOfCommunity, ssrCommunitySoftwareProps} from '~/components/communities/software/apiCommunitySoftware'
+
+type CommunitySoftwareProps={
+ community: EditCommunityProps,
+ software: SoftwareOfCommunity[],
+ slug: string[],
+ isMaintainer: boolean,
+ rsd_page_rows: number,
+ rsd_page_layout: LayoutType,
+ count: number,
+ keywordsList: KeywordFilterOption[],
+ languagesList: LanguagesFilterOption[],
+ licensesList: LicensesFilterOption[],
+}
+
+export default function RequestsToJoinCommunity({
+ community,slug,isMaintainer,
+ rsd_page_rows, rsd_page_layout,
+ software, count, keywordsList,
+ languagesList, licensesList
+}:CommunitySoftwareProps) {
+
+ // console.group('RequestsToJoinCommunity')
+ // console.log('community...', community)
+ // console.log('slug....', slug)
+ // console.log('software....', software)
+ // console.log('isMaintainer....', isMaintainer)
+ // console.log('rsd_page_rows....', rsd_page_rows)
+ // console.log('rsd_page_layout....', rsd_page_layout)
+ // console.log('keywordsList....', keywordsList)
+ // console.log('languagesList....', languagesList)
+ // console.log('licensesList....', licensesList)
+ // 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 */}
+
+
+
+
+
+ >
+ )
+}
+
+
+// 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)
+
+ // get community by slug and isMaintainer info
+ const {community:com,isMaintainer} = await getCommunityBySlug({
+ slug: params?.slug?.toString() ?? null,
+ token: req?.cookies['rsd_token'],
+ user
+ })
+ // console.log('community...', community)
+ if (com === null){
+ // returning notFound triggers 404 page
+ return {
+ notFound: true,
+ }
+ }
+ // deconstruct data
+ const {
+ software,
+ keywordsList,
+ languagesList,
+ licensesList,
+ // community with updated keywords
+ community
+ } = await ssrCommunitySoftwareProps({
+ community: com,
+ software_status: 'pending',
+ query: query,
+ isMaintainer,
+ token
+ })
+
+ // update community count to actual count
+ // community.software_cnt = software.count
+ return {
+ // passed to the page component as props
+ props: {
+ community,
+ slug: [params?.slug],
+ isMaintainer,
+ rsd_page_layout,
+ rsd_page_rows,
+ count: software.count,
+ software: software.data,
+ keywordsList,
+ languagesList,
+ licensesList
+ },
+ }
+ }catch(e){
+ return {
+ notFound: true,
+ }
+ }
+}
diff --git a/frontend/pages/communities/[slug]/settings.tsx b/frontend/pages/communities/[slug]/settings.tsx
index d34b30f64..005e1c4a5 100644
--- a/frontend/pages/communities/[slug]/settings.tsx
+++ b/frontend/pages/communities/[slug]/settings.tsx
@@ -80,35 +80,28 @@ export async function getServerSideProps(context:GetServerSidePropsContext) {
// extract user id from session
const token = req?.cookies['rsd_token']
const user = getUserFromToken(token)
+
// find community by slug
- const community = await getCommunityBySlug({
+ const {community:com,isMaintainer} = await getCommunityBySlug({
slug: params?.slug?.toString() ?? null,
token: req?.cookies['rsd_token'],
user
})
// console.log('community...', community)
- if (community === null){
+ if (com === null){
// returning notFound triggers 404 page
return {
notFound: true,
}
}
// get info if the user is maintainer
- const [isMaintainer,keywords] = await Promise.all([
- isCommunityMaintainer({
- community: community.id ?? '',
- role: user?.role,
- account: user?.account,
- token
- }),
- getKeywordsByCommunity(community.id,token)
- ])
+ const keywords = await getKeywordsByCommunity(com.id,token)
return {
// passed to the page component as props
props: {
community:{
- ...community,
+ ...com,
// use keywords for editing
keywords
},
diff --git a/frontend/pages/communities/[slug]/software.tsx b/frontend/pages/communities/[slug]/software.tsx
index 4c5e5a2f0..fb7efdacf 100644
--- a/frontend/pages/communities/[slug]/software.tsx
+++ b/frontend/pages/communities/[slug]/software.tsx
@@ -8,33 +8,47 @@ import {GetServerSidePropsContext} from 'next'
import {app} from '~/config/app'
import {getUserFromToken} from '~/auth'
import {getUserSettings} from '~/utils/userSettings'
-import {EditCommunityProps, 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 {isCommunityMaintainer} from '~/auth/permissions/isMaintainerOfCommunity'
+import {KeywordFilterOption} from '~/components/filter/KeywordsFilter'
+import {LanguagesFilterOption} from '~/components/filter/ProgrammingLanguagesFilter'
+import {LicensesFilterOption} from '~/components/filter/LicensesFilter'
+import {LayoutType} from '~/components/software/overview/search/ViewToggleGroup'
+import {EditCommunityProps, getCommunityBySlug} from '~/components/communities/apiCommunities'
import CommunityPage from '~/components/communities/CommunityPage'
-import {getKeywordsByCommunity} from '~/components/communities/settings/general/apiCommunityKeywords'
+import CommunitySoftware from '~/components/communities/software'
+import {SoftwareOfCommunity, ssrCommunitySoftwareProps} from '~/components/communities/software/apiCommunitySoftware'
type CommunitySoftwareProps={
community: EditCommunityProps,
+ software: SoftwareOfCommunity[],
slug: string[],
isMaintainer: boolean,
rsd_page_rows: number,
- rsd_page_layout: LayoutType
+ rsd_page_layout: LayoutType,
+ count: number,
+ keywordsList: KeywordFilterOption[],
+ languagesList: LanguagesFilterOption[],
+ licensesList: LicensesFilterOption[],
}
export default function CommunitySoftwarePage({
community,slug,isMaintainer,
- rsd_page_rows, rsd_page_layout
+ rsd_page_rows, rsd_page_layout,
+ software, count, keywordsList,
+ languagesList, licensesList
}:CommunitySoftwareProps) {
// console.group('CommunitySoftwarePage')
// console.log('community...', community)
// console.log('slug....', slug)
+ // console.log('software....', software)
// console.log('isMaintainer....', isMaintainer)
// console.log('rsd_page_rows....', rsd_page_rows)
// console.log('rsd_page_layout....', rsd_page_layout)
+ // console.log('keywordsList....', keywordsList)
+ // console.log('languagesList....', languagesList)
+ // console.log('licensesList....', licensesList)
// console.groupEnd()
function getMetaDescription() {
@@ -60,7 +74,16 @@ export default function CommunitySoftwarePage({
rsd_page_layout={rsd_page_layout}
selectTab='software'
>
- Community software - TO DO!
+
>
)
@@ -74,46 +97,55 @@ export async function getServerSideProps(context:GetServerSidePropsContext) {
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({
+
+ // get community by slug and isMaintainer info
+ const {community:com,isMaintainer} = await getCommunityBySlug({
slug: params?.slug?.toString() ?? null,
token: req?.cookies['rsd_token'],
user
})
// console.log('community...', community)
- if (community === null){
+ if (com === null){
// returning notFound triggers 404 page
return {
notFound: true,
}
}
- // get info if the user is maintainer
- const [isMaintainer,keywords] = await Promise.all([
- isCommunityMaintainer({
- community: community.id ?? '',
- role: user?.role,
- account: user?.account,
- token
- }),
- getKeywordsByCommunity(community.id,token)
- ])
+ // deconstruct data
+ const {
+ software,
+ keywordsList,
+ languagesList,
+ licensesList,
+ // community with updated keywords
+ community
+ } = await ssrCommunitySoftwareProps({
+ community: com,
+ software_status: 'approved',
+ query: query,
+ isMaintainer,
+ token
+ })
+ // update community count to actual count
+ // community.software_cnt = software.count
return {
// passed to the page component as props
props: {
- community:{
- ...community,
- // use keywords for editing
- keywords
- },
+ community,
slug: [params?.slug],
- tab: query?.tab ?? null,
isMaintainer,
rsd_page_layout,
- rsd_page_rows
+ rsd_page_rows,
+ count: software.count,
+ software: software.data,
+ keywordsList,
+ languagesList,
+ licensesList
},
}
}catch(e){
diff --git a/frontend/pages/communities/index.tsx b/frontend/pages/communities/index.tsx
index ffb4f103b..0e5188b2c 100644
--- a/frontend/pages/communities/index.tsx
+++ b/frontend/pages/communities/index.tsx
@@ -153,7 +153,7 @@ export async function getServerSideProps(context:GetServerSidePropsContext) {
page: page>0 ? page-1 : 0,
rows: page_rows,
searchFor: search,
- orderBy: 'software_cnt.desc,name.asc',
+ orderBy: 'software_cnt.desc.nullslast,name.asc',
token
})
diff --git a/frontend/pages/projects/index.tsx b/frontend/pages/projects/index.tsx
index 7f50abe03..963b392e8 100644
--- a/frontend/pages/projects/index.tsx
+++ b/frontend/pages/projects/index.tsx
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: 2021 - 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2021 - 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
@@ -41,7 +41,7 @@ import {
projectStatusFilter
} from '~/components/projects/overview/filters/projectFiltersApi'
import {projectOrderOptions} from '~/components/projects/overview/filters/OrderProjectsBy'
-import ProjectFilters from '~/components/projects/overview/filters/ProjectFilters'
+import ProjectFilters from '~/components/projects/overview/filters/index'
import ProjectSearchSection from '~/components/projects/overview/search/ProjectSearchSection'
import ProjectOverviewContent from '~/components/projects/overview/ProjectOverviewContent'
import ProjectFiltersModal from '~/components/projects/overview/filters/ProjectFiltersModal'
@@ -67,7 +67,7 @@ export type ProjectOverviewPageProps = {
}
const pageTitle = `Projects | ${app.title}`
-const pageDesc = 'The list of research projects registerd in the Research Software Directory.'
+const pageDesc = 'The list of research projects in the Research Software Directory.'
export default function ProjectsOverviewPage({
search, order,
diff --git a/frontend/utils/pagination.ts b/frontend/utils/pagination.ts
index 27821d2f1..4ba963db5 100644
--- a/frontend/utils/pagination.ts
+++ b/frontend/utils/pagination.ts
@@ -1,3 +1,4 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
//
@@ -8,5 +9,9 @@ export function getPageRange(rows: number, page: number, total: number): string
page = 1
}
+ if (total===0){
+ return '0-0 of 0'
+ }
+
return `${(page - 1) * rows + 1}-${Math.min(total, page * rows)} of ${total}`
}
From 096881a42668e8bc7adf841d2b14f2952b565449 Mon Sep 17 00:00:00 2001
From: Dusan Mijatovic
Date: Tue, 4 Jun 2024 10:03:13 +0200
Subject: [PATCH 5/9] feat: create support for modules in settings.json feat:
improve delete of communities in admin section docs: add communities to
documentation chore: update docusaurus and lock versions chore: remove
communities from modules fix: revert back font size when less than 4 items in
the menu fix: remove unused variable
---
documentation/assets/screenshots-template.xcf | Bin 4442522 -> 4761307 bytes
.../assets/screenshots-template.xcf.license | 4 +-
.../03-rsd-instance/01-getting-started.md | 13 +-
.../01-getting-started.md.license | 2 +-
.../docs/03-rsd-instance/02-configurations.md | 20 +-
.../02-configurations.md.license | 2 +-
.../docs/03-rsd-instance/03-administration.md | 35 +-
.../img/admin-communities.webp | Bin 0 -> 92288 bytes
.../img/admin-communities.webp.license | 4 +
documentation/package-lock.json | 3014 ++++++++---------
documentation/package-lock.json.license | 4 +-
documentation/package.json | 18 +-
documentation/package.json.license | 4 +-
.../components/AppHeader/AppHeader.test.tsx | 26 +-
frontend/components/AppHeader/DesktopMenu.tsx | 5 +-
.../components/AppHeader/ResponsiveMenu.tsx | 17 +-
.../admin/communities/CommunityList.tsx | 6 +-
.../admin/communities/CommunityListItem.tsx | 28 +-
.../admin/communities/apiCommunities.ts | 56 -
.../admin/communities/useAdminCommunities.tsx | 7 +-
frontend/components/cards/KeywordList.tsx | 6 +-
.../feedback/FeedbackPanelButton.tsx | 8 +-
frontend/config/defaultSettings.json | 3 +-
frontend/config/getSettingsServerSide.ts | 10 +-
frontend/config/menuItems.ts | 12 +-
frontend/config/rsdSettingsReducer.ts | 7 +-
frontend/config/useMenuItems.tsx | 24 +
frontend/config/userMenuItems.tsx | 27 +-
frontend/public/data/settings.json | 8 +-
frontend/utils/useDisableScrollLock.tsx | 4 +-
30 files changed, 1562 insertions(+), 1812 deletions(-)
create mode 100644 documentation/docs/03-rsd-instance/img/admin-communities.webp
create mode 100644 documentation/docs/03-rsd-instance/img/admin-communities.webp.license
create mode 100644 frontend/config/useMenuItems.tsx
diff --git a/documentation/assets/screenshots-template.xcf b/documentation/assets/screenshots-template.xcf
index 1308201df0d6730ecedc705a6b9bc8640d5dce28..835d93a225947bfb5eadaec372874b055b95a5c6 100644
GIT binary patch
delta 837196
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
z
z(((XWmdOfS;WRgzFJiTFxndi9drY|$i0hfs7K%=`o3bOpM!9qtF18F^WUkDU=vWfC
zf?aK7syaNW1tSQ6MhX{*snHjpwka&^Pu1_hwl@YHS+JeKosA{^iO#kr(C5iQ!x=3`
z>cwlP1&zAM3s|~OA-SjB0Zm_q3P~W>r^5Z?^d&mGVBc940he<|xxl}6p@{Z)=jFZM
zLZkeM$3cFPXriE4L;{j*Zzn>EQEzWUAkkq;+;A2Tk(nxA{UDw`Z!IYZmCgE_3X#?a
zw1Nlk7jMnRuI{{fGS8t;2VI=AL{t?B<+#wALfl6oW0tVS$7v*rvPRBaAT08cwUCe7
zVX1*~1J#1+Ivtbiv{Vz+2qsl(*eJ1x$}Fj{Hr_381!g&8;9r4`syhaD0?7i0%>HJY
zf6#NEe-L`wfY^OAkQQGi
zjwpgn9r$5j!O+pYouTXZ8yUmT9dLxGjZZWj7i2=QwNV7NJXt{@N9xhjEd(0eR~k7zhfwzc
z=T8w=_Y|Bx0=;L|@ZUPnMC`S6@cLQ$0a!-h^Ma4YWG(|-0z?Jy`~VayU_c23zeITk
zhW9_>jVaJKs?T`xjYhs7FLS_alPRQ&*C+~#z4W+T
zT2FxLBh!jB>5)09JkN<5;xm`0l$rT(VMgK6Y#t&XuSAxV?qGRKC}Alh7Lej|M6rDUPoPB^nl0er*}fs5)QYto_)fSJMV_e&%pVGZW@MOE
zG?vnu0?0X#=z*#i!DKTU>I2ixxT92pmW6I2$${3>4R+ZeEg10ki30Okw)tD$t)x|N
zAH+Y8OkPuSE}_~}TZRDK`$W5*+70eOQb|o2grQZ1a@`;pf<2Z&wj4>LC>XBEC<0b-
z6;p`XfuVHaN>CvgbA*iq>KOnAOpw>q3#Rn22tqd4kT|%b+OU97!jlM#hbIvcQy3@$
z^{|cQDZ*?R89vtW51w@J4~)kxkT_xEpP>K=k_9jpv{tq9umdF0kxe!Daae%V8n;0H
zG=X>0no0xIfLc?n5#F|UyBz?ywh1YQSNNS$cQo;AJcH^#xO?`O9dN-v`9}5X=?7nY
zzTSPFK-Zq*{(Hk@?ZKx9%Q&v+RKnr9t+bzt){F=B8A&8v`3yEvI8M*39au9T>tI5M
zJEABbk#u_d_6aTKeg89;ZLfFZ!)f|EV3X$&ke(T!NFXp-Fqu^oaN7Xld99S0&pGF6O
zbe|k`wR1Ds!<60;0#UNK59nxGsR1z@ow9`f~CGB
zV)Iy&%Xn}Disb$p6zG0-^ysnd2VG#EI&%l|*8q+13XUI@;fTlp_wa%g5C(L&j$Hs1
zq2bms7z|TB|GoVZdBB9X(k-&H=0lwuGZIIhz
zhf2WrhF^mqJnV=SJKR7p+7YhiC>hzsJo!f5i{FjlaMe^Z8H+8fKUB5{t24zTG&7Kr
zo!i7pmylB!4mW2?CScMoA&&Yh$u6m+2}chvwumc|`
zcp^xD2$#k1RJm1wH-jZuoe35f$gHivM3DT(C}vO-44@O~b0S1GFs5})(>dV#-@wY2
zK#>$EnhFH?oY=$e)czr=17w3)B?|~v6n-+thPD$o%z{Q#S035OiLhfuPF;fD7QgJu
zYh=q>9DND+g4|+j115B&_~|CME;3nVqX@ufrJLB?9ncJn2y=^i(Ux$+Py?ZueG=sg
z3{k2qXk=Yk;)>!3H6oSGz{z##Dqz#k%B3R=DhBbhNl;q14rBrb3S?rU5q|Q{r8{JT
zWes77mT_-E(G8aP8ECLb&peCjV)*DN;(Zt|Vx)}Ef$aoIM4d540Xj#3!TlYG0
z@oZG<+pM)!wAaVtvHGmyw0NBn+G?dW!qs
zf!s&|&Nn?$_>XMo71$;3^YB#JmlHtYoF~__k}lb6Dk6yw8e3%h&Dhn}=A0izR3Akr
zZoxj#_dEd|Ng!}5p)>!NOzWd4g4fXsKJ(OFhpsTAHz43zrl8@TMhOCN_QjEE
zZ}jzS7k1@isHG5C_^5j#`FoV`8~Lo^z*CA_{&mlCi