From 5c78d5c1d39bc4bd3ae8c82587f996f5fde3fadc Mon Sep 17 00:00:00 2001 From: "Dusan Mijatovic (PC2020)" Date: Sun, 28 Apr 2024 16:21:59 +0200 Subject: [PATCH] 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} + +
+ + {/* hidden inputs */} + + + + + + +
+ } + /> + +
+ + + +
+ + + + +
+
+ ) +} 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 ( + <> +
+
+
+ + +
+ +
+
+ +
+
+ { + 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 ( +
+ + +
+ +
+
+ ) +} 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}
+ +
-
- - -
- -
-
+ } + />
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, +// } +// } +// }