diff --git a/database/014-create-keyword-and-category.sql b/database/014-create-keyword-and-category.sql index 18c730915..7c0a82fdf 100644 --- a/database/014-create-keyword-and-category.sql +++ b/database/014-create-keyword-and-category.sql @@ -3,6 +3,7 @@ -- SPDX-FileCopyrightText: 2023 - 2024 Felix Mühlbauer (GFZ) -- SPDX-FileCopyrightText: 2023 - 2024 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences -- SPDX-FileCopyrightText: 2024 Christian Meeßen (GFZ) +-- SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) -- -- SPDX-License-Identifier: Apache-2.0 @@ -107,15 +108,6 @@ CREATE INDEX category_parent_idx ON category(parent); CREATE INDEX category_community_idx ON category(community); -CREATE TABLE category_for_software ( - software_id UUID REFERENCES software (id), - category_id UUID REFERENCES category (id), - PRIMARY KEY (software_id, category_id) -); - -CREATE INDEX category_for_software_category_id_idx ON category_for_software(category_id); - - -- sanitize categories CREATE FUNCTION sanitise_insert_category() @@ -228,6 +220,19 @@ $$ $$; +-- TABLE FOR software categories +-- includes organisation, community and general categories +-- Note! to filter specific categories of an community or organisation use join with community table + +CREATE TABLE category_for_software ( + software_id UUID REFERENCES software (id), + category_id UUID REFERENCES category (id), + PRIMARY KEY (software_id, category_id) +); + +CREATE INDEX category_for_software_category_id_idx ON category_for_software(category_id); + +-- RPC for software page to show all software categories CREATE FUNCTION category_paths_by_software_expanded(software_id UUID) RETURNS JSON LANGUAGE SQL STABLE AS @@ -242,3 +247,14 @@ $$ ELSE '[]'::json END AS result $$; + + +-- TABLE FOR project categories +-- currently used only for organisation categories +CREATE TABLE category_for_project ( + project_id UUID REFERENCES project (id), + category_id UUID REFERENCES category (id), + PRIMARY KEY (project_id, category_id) +); + +CREATE INDEX category_for_project_category_id_idx ON category_for_project(category_id); diff --git a/database/109-category-functions.sql b/database/109-category-functions.sql index ed8795a2f..80e449c4e 100644 --- a/database/109-category-functions.sql +++ b/database/109-category-functions.sql @@ -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 -- @@ -11,3 +12,33 @@ DELETE FROM category_for_software USING category WHERE category_for_software.category_id = category.id AND category_for_software.software_id = software_id AND category.community = community_id; $$; + + +-- DELETE organisation categories for specific software +CREATE FUNCTION delete_organisation_categories_from_software(software_id UUID, organisation_id UUID) +RETURNS VOID +LANGUAGE sql AS +$$ +DELETE FROM category_for_software + USING + category + WHERE + category_for_software.category_id = category.id AND + category_for_software.software_id = software_id AND + category.organisation = organisation_id; +$$; + + +-- DELETE organisation categories for specific project +CREATE FUNCTION delete_organisation_categories_from_project(project_id UUID, organisation_id UUID) +RETURNS VOID +LANGUAGE sql AS +$$ +DELETE FROM category_for_project + USING + category + WHERE + category_for_project.category_id = category.id AND + category_for_project.project_id = project_id AND + category.organisation = organisation_id; +$$; diff --git a/frontend/components/category/CategoryEditForm.tsx b/frontend/components/category/CategoryEditForm.tsx index 7d0c4363a..5f43eb193 100644 --- a/frontend/components/category/CategoryEditForm.tsx +++ b/frontend/components/category/CategoryEditForm.tsx @@ -38,14 +38,14 @@ export default function CategoryEditForm({ const [parent] = watch(['parent']) - console.group('CategoryEditForm') - console.log('createNew...',createNew) + // console.group('CategoryEditForm') + // console.log('createNew...',createNew) // console.log('data...',data) // console.log('disableSave...',disableSave) // console.log('community...',community) // console.log('organisation...',organisation) - console.log('parent...',parent) - console.groupEnd() + // console.log('parent...',parent) + // console.groupEnd() function onSubmit(formData: CategoryEntry){ @@ -181,7 +181,7 @@ export default function CategoryEditForm({ {/* Organisation categories can be used for software or project items - We show software/project switch only at top level + We show software/project switch only at top level (root nodes) */} { organisation && !parent ? @@ -203,9 +203,11 @@ export default function CategoryEditForm({ : <> - {/* By default categories are used by software in communities and by project for organisations */} - - + {/* + for children nodes we use false as default value + */} + + } diff --git a/frontend/components/category/__mocks__/apiCategories.ts b/frontend/components/category/__mocks__/apiCategories.ts new file mode 100644 index 000000000..4a47952fe --- /dev/null +++ b/frontend/components/category/__mocks__/apiCategories.ts @@ -0,0 +1,28 @@ +// 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 {CategoryEntry} from '~/types/Category' +import {TreeNode} from '~/types/TreeNode' + +type LoadCategoryProps={ + community?: string | null, + organisation?: string | null, + allow_software?: boolean, + allow_projects?: boolean +} + + +// DEFAULT mock return empty array of categories +export async function loadCategoryRoots({community, organisation, allow_software, allow_projects}:LoadCategoryProps){ + const result: TreeNode[] = [] + return result +} + +// DEFAULT mock return empty array of categories +export function categoryEntriesToRoots(categoriesArr: CategoryEntry[]): TreeNode[] { + const result: TreeNode[] = [] + return result +} diff --git a/frontend/components/category/apiCategories.ts b/frontend/components/category/apiCategories.ts index e9cb0091e..d5571f03b 100644 --- a/frontend/components/category/apiCategories.ts +++ b/frontend/components/category/apiCategories.ts @@ -10,10 +10,12 @@ import {TreeNode} from '~/types/TreeNode' type LoadCategoryProps={ community?: string | null, - organisation?: string | null + organisation?: string | null, + allow_software?: boolean, + allow_projects?: boolean } -export async function loadCategoryRoots({community, organisation}:LoadCategoryProps){ +export async function loadCategoryRoots({community, organisation, allow_software, allow_projects}:LoadCategoryProps){ // global categories is default let categoryFilter = 'community=is.null&organisation=is.null' // community filter @@ -24,6 +26,14 @@ export async function loadCategoryRoots({community, organisation}:LoadCategoryPr if (organisation){ categoryFilter = `organisation=eq.${organisation}` } + // software specific categories + if (allow_software){ + categoryFilter+='&allow_software=eq.true' + } + // project specific categories + if (allow_projects){ + categoryFilter+='&allow_projects=eq.true' + } const resp = await fetch(`${getBaseUrl()}/category?${categoryFilter}`) diff --git a/frontend/components/layout/SortableListItemActions.tsx b/frontend/components/layout/SortableListItemActions.tsx index cf918ecd6..49335fcb8 100644 --- a/frontend/components/layout/SortableListItemActions.tsx +++ b/frontend/components/layout/SortableListItemActions.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 @@ -8,16 +10,37 @@ import DeleteIcon from '@mui/icons-material/Delete' import EditIcon from '@mui/icons-material/Edit' import IconButton from '@mui/material/IconButton' import DragIndicatorIcon from '@mui/icons-material/DragIndicator' +import CategoryIcon from '@mui/icons-material/Category' type SortableListItemActionsProps = { pos: number listeners?: SyntheticListenerMap onEdit?:(pos:number)=>void, onDelete?:(pos:number)=>void, + onCategory?:(pos:number)=>void } +export default function SortableListItemActions({pos,listeners,onEdit,onDelete,onCategory}:SortableListItemActionsProps){ -export default function SortableListItemActions({pos,listeners,onEdit,onDelete}:SortableListItemActionsProps){ + function categoryAction() { + if (typeof onCategory !== 'undefined') { + return ( + { + // alert(`Edit...${item.id}`) + onCategory(pos) + }} + > + + + ) + } + return null + } function editAction() { if (typeof onEdit !== 'undefined') { @@ -76,6 +99,7 @@ export default function SortableListItemActions({pos,listeners,onEdit,onDelete}: return ( <> + {categoryAction()} {editAction()} {deleteAction()} {dragAction()} diff --git a/frontend/components/projects/edit/organisations/OrganisationProjectCategoriesDialog.tsx b/frontend/components/projects/edit/organisations/OrganisationProjectCategoriesDialog.tsx new file mode 100644 index 000000000..3a2da4afc --- /dev/null +++ b/frontend/components/projects/edit/organisations/OrganisationProjectCategoriesDialog.tsx @@ -0,0 +1,260 @@ +// 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 {useEffect, useState} from 'react' +import Dialog from '@mui/material/Dialog' +import DialogContent from '@mui/material/DialogContent' +import DialogTitle from '@mui/material/DialogTitle' +import Alert from '@mui/material/Alert' +import DialogActions from '@mui/material/DialogActions' +import Button from '@mui/material/Button' +import useMediaQuery from '@mui/material/useMediaQuery' +import SaveIcon from '@mui/icons-material/Save' + +import {useSession} from '~/auth' +import {TreeNode} from '~/types/TreeNode' +import {CategoryEntry} from '~/types/Category' +import {CategoryForSoftwareIds} from '~/types/SoftwareTypes' +import {EditOrganisation} from '~/types/Organisation' +import {createJsonHeaders, getBaseUrl} from '~/utils/fetchHelpers' +import ContentLoader from '~/components/layout/ContentLoader' +import {loadCategoryRoots} from '~/components/category/apiCategories' +import {RecursivelyGenerateItems} from '~/components/software/TreeSelect' +import {getCategoryListForProject, removeOrganisationCategoriesFromProject} from './apiProjectOrganisations' + +export type OrganisationCategoriesDialogProps = Readonly<{ + projectId: string + organisation: EditOrganisation + onCancel: () => void + onComplete: () => void + autoConfirm: boolean +}> + +type ProjectCategory = { + project_id: string, + category_id: string +} + +export default function OrganisationProjectCategoriesDialog({ + projectId, + organisation, + onCancel, + onComplete, + autoConfirm +}: OrganisationCategoriesDialogProps) { + const {token} = useSession() + const smallScreen = useMediaQuery('(max-width:600px)') + const [categories, setCategories] = useState[] | null>(null) + const [error, setError] = useState(null) + const [state, setState] = useState<'loading' | 'error' | 'ready' | 'saving'>('loading') + const [selectedCategoryIds, setSelectedCategoryIds] = useState(new Set()) + const [availableCategoryIds, setAvailableCategoryIds] = useState(new Set()) + + useEffect(() => { + let abort = false + setState('loading') + // load project specific organisation items + const promiseLoadRoots = loadCategoryRoots({organisation:organisation.id,allow_projects:true}) + .then(roots => { + // if there are no categories we don't show the modal + if (roots.length === 0 && autoConfirm) { + onComplete() + return + } + const leaveIds = new Set() + for (const root of roots) { + root.forEach(node => { + if (node.children().length === 0) { + leaveIds.add(node.getValue().id) + } + }) + } + if (abort) return + setAvailableCategoryIds(leaveIds) + setCategories(roots) + }) + + const promiseLoadAssociatedCategories = getCategoryListForProject(projectId, token) + .then(setSelectedCategoryIds) + + Promise.all([promiseLoadRoots, promiseLoadAssociatedCategories]) + .then(() => { + if (abort) return + setState('ready') + }) + .catch(e => { + if (abort) return + setError(`Couldn't load categories: ${e}`) + setState('error') + }) + + return ()=>{abort=true} + }, [organisation, onComplete, autoConfirm, projectId, token]) + + function isSelected(node: TreeNode) { + const val = node.getValue() + return selectedCategoryIds.has(val.id) + } + + function textExtractor(value: CategoryEntry) { + return value.name + } + + function keyExtractor(value: CategoryEntry) { + return value.id + } + + function onSelect(node: TreeNode) { + const val = node.getValue() + if (selectedCategoryIds.has(val.id)) { + selectedCategoryIds.delete(val.id) + } else { + selectedCategoryIds.add(val.id) + } + setSelectedCategoryIds(new Set(selectedCategoryIds)) + } + + function isCancelEnabled() { + return state === 'saving' + } + + function isSaveDisabled(){ + return categories === null || categories.length === 0 || state !== 'ready' + } + + async function saveCategoriesAndOrganisation() { + if (selectedCategoryIds.size === 0) { + onComplete() + return + } + + // delete old selection + if (organisation.id){ + const deleteErrorMessage = await removeOrganisationCategoriesFromProject(projectId, organisation.id, token) + if (deleteErrorMessage !== null) { + setError(`Couldn't delete the existing categories: ${deleteErrorMessage}`) + setState('error') + return + } + } + + // generate new collection + const categoriesArrayToSave:ProjectCategory[] = [] + selectedCategoryIds + .forEach(id => { + if (availableCategoryIds.has(id)) { + categoriesArrayToSave.push({project_id: projectId, category_id: id}) + } + }) + + // save organisation categories (if any) + if (categoriesArrayToSave.length > 0){ + const categoryUrl = `${getBaseUrl()}/category_for_project` + const resp = await fetch(categoryUrl, { + method: 'POST', + body: JSON.stringify(categoriesArrayToSave), + headers: { + ...createJsonHeaders(token) + } + }) + // debugger + if (!resp.ok) { + setError(`Couldn't save categories: ${await resp.text()}`) + setState('error') + } else { + onComplete() + } + }else{ + onComplete() + } + } + + function renderDialogContent(): JSX.Element { + switch (state) { + case 'loading': + case 'saving': + return ( +
+ +
+ ) + + case 'error': + return ( + + {error} + + ) + + case 'ready': + return ( + <> + {(categories === null || categories.length === 0) + ? + + This community doesn‘t have any categories. + + : + + } + + ) + } + } + + return ( + + Add categories of {organisation.name} + + {renderDialogContent()} + + + + + + + ) +} diff --git a/frontend/components/projects/edit/organisations/apiProjectOrganisations.ts b/frontend/components/projects/edit/organisations/apiProjectOrganisations.ts new file mode 100644 index 000000000..0030bfa7b --- /dev/null +++ b/frontend/components/projects/edit/organisations/apiProjectOrganisations.ts @@ -0,0 +1,48 @@ +// 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' + +export async function removeOrganisationCategoriesFromProject( + projectId: string, + organisationId: string, + token: string +){ + const url = `${getBaseUrl()}/rpc/delete_organisation_categories_from_project` + const body = JSON.stringify({project_id: projectId, organisation_id: organisationId}) + + const resp = await fetch(url, { + method: 'POST', + body: body, + headers: { + ...createJsonHeaders(token) + } + }) + + return resp.ok ? null : resp.text() +} + +export async function getCategoryListForProject(project_id: string, token?: string){ + try { + const query = `project_id=eq.${project_id}` + const url = `${getBaseUrl()}/category_for_project?select=category_id&${query}` + const resp = await fetch(url, { + method: 'GET', + headers: createJsonHeaders(token) + }) + if (resp.status === 200) { + const data = await resp.json() + const categories:Set = new Set(data.map((entry: any) => entry.category_id)) + return categories + } else { + logger(`getCategoriesForSoftwareIds: ${resp.status} [${url}]`, 'error') + throw new Error('Couldn\'t load the categories for this software') + } + } catch (e: any) { + logger(`getCategoriesForSoftwareIds: ${e?.message}`, 'error') + throw e + } +} diff --git a/frontend/components/projects/edit/organisations/index.tsx b/frontend/components/projects/edit/organisations/index.tsx index 128f09d9b..0c81d57b0 100644 --- a/frontend/components/projects/edit/organisations/index.tsx +++ b/frontend/components/projects/edit/organisations/index.tsx @@ -1,27 +1,15 @@ // SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 - 2023 Ewan Cahen (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2022 - 2023 Netherlands eScience Center // SPDX-FileCopyrightText: 2022 - 2023 dv4all -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) // // SPDX-License-Identifier: Apache-2.0 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 useProjectContext from '../useProjectContext' -import useParticipatingOrganisations from './useParticipatingOrganisations' -import {cfgOrganisations as config} from './config' -import FindOrganisation from '~/components/software/edit/organisations/FindOrganisation' -import EditOrganisationModal from '~/components/software/edit/organisations/EditOrganisationModal' -import ConfirmDeleteModal from '~/components/layout/ConfirmDeleteModal' -import {EditOrganisationModalProps} from '~/components/software/edit/organisations' -import {ModalStates} from '~/components/software/edit/editSoftwareTypes' import {columsForUpdate, EditOrganisation, SearchOrganisation} from '~/types/Organisation' -import useSnackbar from '~/components/snackbar/useSnackbar' import { newOrganisationProps, searchToEditOrganisation, updateOrganisation @@ -34,6 +22,18 @@ import { import SortableOrganisationsList from '~/components/software/edit/organisations/SortableOrganisationsList' import {upsertImage} from '~/utils/editImage' import {getPropsFromObject} from '~/utils/getPropsFromObject' +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 useSnackbar from '~/components/snackbar/useSnackbar' +import {EditOrganisationModalProps, OrganisationModalStates} from '~/components/software/edit/organisations' +import FindOrganisation from '~/components/software/edit/organisations/FindOrganisation' +import EditOrganisationModal from '~/components/software/edit/organisations/EditOrganisationModal' +import useProjectContext from '../useProjectContext' +import useParticipatingOrganisations from './useParticipatingOrganisations' +import {cfgOrganisations as config} from './config' +import OrganisationProjectCategoriesDialog from './OrganisationProjectCategoriesDialog' export default function ProjectOrganisations() { const {token,user} = useSession() @@ -44,12 +44,15 @@ export default function ProjectOrganisations() { token: token, account: user?.account }) - const [modal, setModal] = useState>({ + const [modal, setModal] = useState>({ edit: { open: false, }, delete: { open: false + }, + categories:{ + open: false } }) @@ -79,6 +82,9 @@ export default function ProjectOrganisations() { }, delete: { open:false + }, + categories:{ + open: false } }) } else if (item.source === 'RSD' && addOrganisation.id) { @@ -94,6 +100,20 @@ export default function ProjectOrganisations() { // update status received in message addOrganisation.status = resp.message addOrganisationToList(addOrganisation) + // show categories modal + setModal({ + edit: { + open: false, + }, + delete: { + open:false + }, + categories:{ + open: true, + organisation: addOrganisation, + autoConfirm: true + } + }) } else { showErrorMessage(resp.message) } @@ -116,6 +136,9 @@ export default function ProjectOrganisations() { }, delete: { open:false + }, + categories:{ + open: false } }) } @@ -131,6 +154,9 @@ export default function ProjectOrganisations() { }, delete: { open:false + }, + categories:{ + open: false } }) } @@ -148,6 +174,9 @@ export default function ProjectOrganisations() { open: true, pos, displayName + }, + categories:{ + open: false } }) } @@ -241,6 +270,9 @@ export default function ProjectOrganisations() { }, delete: { open:false + }, + categories:{ + open: false } }) } @@ -293,6 +325,26 @@ export default function ProjectOrganisations() { } } + function onCategoryEdit(pos:number){ + const organisation = organisations[pos] + if (organisation){ + setModal({ + edit: { + open:false + }, + delete: { + open:false + }, + categories:{ + open:true, + organisation, + // editing categories + autoConfirm: false + } + }) + } + } + if (loading) { return ( @@ -312,6 +364,7 @@ export default function ProjectOrganisations() { onEdit={onEdit} onDelete={onDelete} onSorted={sortedOrganisations} + onCategory={onCategoryEdit} />
@@ -345,6 +398,16 @@ export default function ProjectOrganisations() { onDelete={()=>deleteOrganisation(modal.delete.pos)} /> } + {modal.categories.open && modal.categories.organisation ? + + : null + } ) } diff --git a/frontend/components/software/edit/communities/CommunityAddCategoriesDialog.tsx b/frontend/components/software/edit/communities/CommunityAddCategoriesDialog.tsx index e64a31f37..1e7e48e22 100644 --- a/frontend/components/software/edit/communities/CommunityAddCategoriesDialog.tsx +++ b/frontend/components/software/edit/communities/CommunityAddCategoriesDialog.tsx @@ -41,6 +41,7 @@ export default function CommunityAddCategoriesDialog({ onConfirm, autoConfirm }: communityAddCategoriesDialogProps) { + const {token} = useSession() const smallScreen = useMediaQuery('(max-width:600px)') const [categories, setCategories] = useState[] | null>(null) const [error, setError] = useState(null) @@ -48,8 +49,6 @@ export default function CommunityAddCategoriesDialog({ const [selectedCategoryIds, setSelectedCategoryIds] = useState(new Set()) const [availableCategoryIds, setAvailableCategoryIds] = useState(new Set()) - const {token} = useSession() - function isSelected(node: TreeNode) { const val = node.getValue() return selectedCategoryIds.has(val.id) diff --git a/frontend/components/software/edit/organisations/EditSoftwareOrganisationsIndex.test.tsx b/frontend/components/software/edit/organisations/EditSoftwareOrganisationsIndex.test.tsx index f4fb9a37e..64c94e874 100644 --- a/frontend/components/software/edit/organisations/EditSoftwareOrganisationsIndex.test.tsx +++ b/frontend/components/software/edit/organisations/EditSoftwareOrganisationsIndex.test.tsx @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) // 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 @@ -31,14 +33,13 @@ jest.mock('~/utils/editOrganisation', () => ({ })) // MOCK isMaintainerOfOrganisation -const mockIsMainatainerOfOrganisation = jest.fn(props => Promise.resolve(false)) +const mockIsMaintainerOfOrganisation = jest.fn(props => Promise.resolve(false)) jest.mock('~/auth/permissions/isMaintainerOfOrganisation', () => ({ __esModule: true, - default: jest.fn(props=>mockIsMainatainerOfOrganisation(props)), - isMaintainerOfOrganisation: jest.fn(props=>mockIsMainatainerOfOrganisation(props)) + default: jest.fn(props=>mockIsMaintainerOfOrganisation(props)), + isMaintainerOfOrganisation: jest.fn(props=>mockIsMaintainerOfOrganisation(props)) })) - // MOCK organisationForSoftware methods const mockCreateOrganisationAndAddToSoftware = jest.fn(props => Promise.resolve([] as any)) const mockAddOrganisationToSoftware = jest.fn(props => Promise.resolve([] as any)) @@ -51,6 +52,11 @@ jest.mock('./organisationForSoftware', () => ({ patchOrganisationPositions: jest.fn(props=>mockPatchOrganisationPositions(props)) })) +// MOCK software category calls +// by default we return no categories +jest.mock('~/components/category/apiCategories') +jest.mock('~/utils/getSoftware') + describe('frontend/components/software/edit/organisations/index.tsx', () => { beforeEach(() => { jest.clearAllMocks() @@ -290,8 +296,8 @@ describe('frontend/components/software/edit/organisations/index.tsx', () => { // return list of organisations mockGetOrganisationsForSoftware.mockResolvedValueOnce(organisationsOfSoftware) // mock is Maintainer of first organisation - mockIsMainatainerOfOrganisation.mockResolvedValueOnce(true) - mockIsMainatainerOfOrganisation.mockResolvedValueOnce(false) + mockIsMaintainerOfOrganisation.mockResolvedValueOnce(true) + mockIsMaintainerOfOrganisation.mockResolvedValueOnce(false) render( diff --git a/frontend/components/software/edit/organisations/OrganisationSoftwareCategoriesDialog.tsx b/frontend/components/software/edit/organisations/OrganisationSoftwareCategoriesDialog.tsx new file mode 100644 index 000000000..7dfa73869 --- /dev/null +++ b/frontend/components/software/edit/organisations/OrganisationSoftwareCategoriesDialog.tsx @@ -0,0 +1,257 @@ +// 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 {useEffect, useState} from 'react' +import Dialog from '@mui/material/Dialog' +import DialogContent from '@mui/material/DialogContent' +import DialogTitle from '@mui/material/DialogTitle' +import Alert from '@mui/material/Alert' +import DialogActions from '@mui/material/DialogActions' +import Button from '@mui/material/Button' +import useMediaQuery from '@mui/material/useMediaQuery' +import SaveIcon from '@mui/icons-material/Save' + +import {useSession} from '~/auth' +import {TreeNode} from '~/types/TreeNode' +import {CategoryEntry} from '~/types/Category' +import {CategoryForSoftwareIds} from '~/types/SoftwareTypes' +import {EditOrganisation} from '~/types/Organisation' +import {createJsonHeaders, getBaseUrl} from '~/utils/fetchHelpers' +import {getCategoryForSoftwareIds} from '~/utils/getSoftware' +import ContentLoader from '~/components/layout/ContentLoader' +import {loadCategoryRoots} from '~/components/category/apiCategories' +import {RecursivelyGenerateItems} from '~/components/software/TreeSelect' +import {removeOrganisationCategoriesFromSoftware} from './apiSoftwareOrganisations' + +export type OrganisationCategoriesDialogProps = Readonly<{ + softwareId: string + organisation: EditOrganisation + onCancel: () => void + onComplete: () => void + autoConfirm: boolean +}> + +export default function OrganisationSoftwareCategoriesDialog({ + softwareId, + organisation, + onCancel, + onComplete, + autoConfirm +}: OrganisationCategoriesDialogProps) { + const {token} = useSession() + const smallScreen = useMediaQuery('(max-width:600px)') + const [categories, setCategories] = useState[] | null>(null) + const [error, setError] = useState(null) + const [state, setState] = useState<'loading' | 'error' | 'ready' | 'saving'>('loading') + const [selectedCategoryIds, setSelectedCategoryIds] = useState(new Set()) + const [availableCategoryIds, setAvailableCategoryIds] = useState(new Set()) + + useEffect(() => { + let abort = false + setState('loading') + // load software specific organisation items + const promiseLoadRoots = loadCategoryRoots({organisation:organisation.id,allow_software:true}) + .then(roots => { + // if there are no categories we don't show the modal + if (roots.length === 0 && autoConfirm) { + onComplete() + return + } + const leaveIds = new Set() + for (const root of roots) { + root.forEach(node => { + if (node.children().length === 0) { + leaveIds.add(node.getValue().id) + } + }) + } + if (abort) return + setAvailableCategoryIds(leaveIds) + setCategories(roots) + }) + + const promiseLoadAssociatedCategories = getCategoryForSoftwareIds(softwareId, token) + .then(setSelectedCategoryIds) + + Promise.all([promiseLoadRoots, promiseLoadAssociatedCategories]) + .then(() => { + if (abort) return + setState('ready') + }) + .catch(e => { + if (abort) return + setError(`Couldn't load categories: ${e}`) + setState('error') + }) + + return ()=>{abort=true} + }, [organisation, onComplete, autoConfirm, softwareId, token]) + + function isSelected(node: TreeNode) { + const val = node.getValue() + return selectedCategoryIds.has(val.id) + } + + function textExtractor(value: CategoryEntry) { + return value.name + } + + function keyExtractor(value: CategoryEntry) { + return value.id + } + + function onSelect(node: TreeNode) { + const val = node.getValue() + if (selectedCategoryIds.has(val.id)) { + selectedCategoryIds.delete(val.id) + } else { + selectedCategoryIds.add(val.id) + } + setSelectedCategoryIds(new Set(selectedCategoryIds)) + } + + function isCancelEnabled() { + return state === 'saving' + } + + function isSaveDisabled(){ + return categories === null || categories.length === 0 || state !== 'ready' + } + + async function saveCategoriesAndOrganisation() { + if (selectedCategoryIds.size === 0) { + onComplete() + return + } + + // delete old selection + if (organisation.id){ + const deleteErrorMessage = await removeOrganisationCategoriesFromSoftware(softwareId, organisation.id, token) + if (deleteErrorMessage !== null) { + setError(`Couldn't delete the existing categories: ${deleteErrorMessage}`) + setState('error') + return + } + } + + // generate new collection + const categoriesArrayToSave: {software_id: string, category_id: string}[] = [] + selectedCategoryIds + .forEach(id => { + if (availableCategoryIds.has(id)) { + categoriesArrayToSave.push({software_id: softwareId, category_id: id}) + } + }) + + // debugger + // save organisation categories (if any) + if (categoriesArrayToSave.length > 0){ + const categoryUrl = `${getBaseUrl()}/category_for_software` + const resp = await fetch(categoryUrl, { + method: 'POST', + body: JSON.stringify(categoriesArrayToSave), + headers: { + ...createJsonHeaders(token) + } + }) + // debugger + if (!resp.ok) { + setError(`Couldn't save categories: ${await resp.text()}`) + setState('error') + } else { + onComplete() + } + }else{ + onComplete() + } + } + + function renderDialogContent(): JSX.Element { + switch (state) { + case 'loading': + case 'saving': + return ( +
+ +
+ ) + + case 'error': + return ( + + {error} + + ) + + case 'ready': + return ( + <> + {(categories === null || categories.length === 0) + ? + + This community doesn‘t have any categories. + + : + + } + + ) + } + } + + return ( + + Add categories of {organisation.name} + + {renderDialogContent()} + + + + + + + ) +} diff --git a/frontend/components/software/edit/organisations/SortableOrganisationItem.tsx b/frontend/components/software/edit/organisations/SortableOrganisationItem.tsx index 4d6a4d915..fa69139c3 100644 --- a/frontend/components/software/edit/organisations/SortableOrganisationItem.tsx +++ b/frontend/components/software/edit/organisations/SortableOrganisationItem.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 @@ -20,9 +22,10 @@ type OrganisationsListItemProps = { pos: number onEdit: (pos:number)=>void onDelete: (pos:number)=>void + onCategory: (pos:number)=>void } -export default function SortableOrganisationsItem({organisation, pos, onEdit, onDelete}: OrganisationsListItemProps) { +export default function SortableOrganisationsItem({organisation, pos, onEdit, onDelete, onCategory}: OrganisationsListItemProps) { const { attributes,listeners,setNodeRef, transform,transition,isDragging @@ -52,6 +55,7 @@ export default function SortableOrganisationsItem({organisation, pos, onEdit, on listeners={listeners} onEdit={onEdit} onDelete={onDelete} + onCategory={onCategory} /> ) } @@ -59,8 +63,8 @@ export default function SortableOrganisationsItem({organisation, pos, onEdit, on ) } @@ -83,7 +87,7 @@ export default function SortableOrganisationsItem({organisation, pos, onEdit, on sx={{ // position:'relative', // this makes space for buttons - paddingRight:'7.5rem', + paddingRight:'10rem', '&:hover': { backgroundColor:'grey.100' }, diff --git a/frontend/components/software/edit/organisations/SortableOrganisationsList.tsx b/frontend/components/software/edit/organisations/SortableOrganisationsList.tsx index 809c8354b..9134e5ba9 100644 --- a/frontend/components/software/edit/organisations/SortableOrganisationsList.tsx +++ b/frontend/components/software/edit/organisations/SortableOrganisationsList.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 @@ -15,9 +17,10 @@ type OrganisationListProps = { onEdit: (pos: number) => void onDelete: (pos: number) => void onSorted: (organisation:EditOrganisation[])=>void + onCategory: (pos: number) => void } -export default function SortableOrganisationsList({organisations,onEdit,onDelete,onSorted}:OrganisationListProps) { +export default function SortableOrganisationsList({organisations,onEdit,onDelete,onSorted,onCategory}:OrganisationListProps) { if (organisations.length === 0) { return ( @@ -35,6 +38,7 @@ export default function SortableOrganisationsList({organisations,onEdit,onDelete organisation={item} onEdit={onEdit} onDelete={onDelete} + onCategory={onCategory} /> } diff --git a/frontend/components/software/edit/organisations/apiSoftwareOrganisations.ts b/frontend/components/software/edit/organisations/apiSoftwareOrganisations.ts new file mode 100644 index 000000000..9058b5399 --- /dev/null +++ b/frontend/components/software/edit/organisations/apiSoftwareOrganisations.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {getOrganisationsForSoftware} from '~/utils/editOrganisation' +import isMaintainerOfOrganisation from '~/auth/permissions/isMaintainerOfOrganisation' +import {EditOrganisation} from '~/types/Organisation' +import {createJsonHeaders, getBaseUrl} from '~/utils/fetchHelpers' + +export type UseParticipatingOrganisationsProps = { + software: string, + token: string, + account: string +} + +export async function getParticipatingOrganisationsForSoftware({software, token, account}: UseParticipatingOrganisationsProps) { + const resp = await getOrganisationsForSoftware({ + software, + token + }) + // collect isMaintainerRequests + const promises: Promise[] = [] + // prepare organisation list + const orgList = resp.map((item, pos) => { + // save isMaintainer request + promises.push(isMaintainerOfOrganisation({ + organisation: item.id, + account, + token + })) + // extract only needed props + const organisation: EditOrganisation = { + ...item, + // additional props for edit type + position: pos + 1, + logo_b64: null, + logo_mime_type: null, + source: 'RSD' as 'RSD', + status: item.status, + // false by default + canEdit: false, + // description: null + } + return organisation + }) + // run all isMaintainer requests in parallel + const isMaintainer = await Promise.all(promises) + const organisations = orgList.map((item, pos) => { + // update canEdit based on isMaintainer requests + if (isMaintainer[pos]) item.canEdit = isMaintainer[pos] + return item + }) + return organisations +} + + +export async function removeOrganisationCategoriesFromSoftware( + softwareId: string, + organisationId: string, + token: string +){ + const url = `${getBaseUrl()}/rpc/delete_organisation_categories_from_software` + const body = JSON.stringify({software_id: softwareId, organisation_id: organisationId}) + + const resp = await fetch(url, { + method: 'POST', + body: body, + headers: { + ...createJsonHeaders(token) + } + }) + + return resp.ok ? null : resp.text() +} diff --git a/frontend/components/software/edit/organisations/index.tsx b/frontend/components/software/edit/organisations/index.tsx index 7717ebab4..0be7ae0cd 100644 --- a/frontend/components/software/edit/organisations/index.tsx +++ b/frontend/components/software/edit/organisations/index.tsx @@ -8,40 +8,48 @@ import {useState} from 'react' -import {useSession} from '../../../../auth' -import useSnackbar from '../../../snackbar/useSnackbar' -import ContentLoader from '../../../layout/ContentLoader' -import ConfirmDeleteModal from '../../../layout/ConfirmDeleteModal' +import {useSession} from '~/auth' import { columsForUpdate, EditOrganisation, SearchOrganisation, SoftwareForOrganisation -} from '../../../../types/Organisation' +} from '~/types/Organisation' import { newOrganisationProps, searchToEditOrganisation, updateOrganisation, -} from '../../../../utils/editOrganisation' -import useParticipatingOrganisations from './useParticipatingOrganisations' +} from '~/utils/editOrganisation' +import {upsertImage} from '~/utils/editImage' +import {getSlugFromString} from '~/utils/getSlugFromString' +import {getPropsFromObject} from '~/utils/getPropsFromObject' +import useSnackbar from '~/components/snackbar/useSnackbar' +import ContentLoader from '~/components/layout/ContentLoader' +import ConfirmDeleteModal from '~/components/layout/ConfirmDeleteModal' +import EditSectionTitle from '~/components/layout/EditSectionTitle' +import EditSection from '~/components/layout/EditSection' import {organisationInformation as config} from '../editSoftwareConfig' -import EditSection from '../../../layout/EditSection' +import useSoftwareContext from '../useSoftwareContext' +import useParticipatingOrganisations from './useParticipatingOrganisations' import {ModalProps, ModalStates} from '../editSoftwareTypes' -import EditSectionTitle from '../../../layout/EditSectionTitle' import FindOrganisation from './FindOrganisation' import EditOrganisationModal from './EditOrganisationModal' -import {getSlugFromString} from '../../../../utils/getSlugFromString' -import useSoftwareContext from '../useSoftwareContext' import SortableOrganisationsList from './SortableOrganisationsList' import { addOrganisationToSoftware, createOrganisationAndAddToSoftware, deleteOrganisationFromSoftware, patchOrganisationPositions } from './organisationForSoftware' -import {upsertImage} from '~/utils/editImage' -import {getPropsFromObject} from '~/utils/getPropsFromObject' +import OrganisationSoftwareCategoriesDialog from './OrganisationSoftwareCategoriesDialog' + +export type OrganisationModalStates = ModalStates & { + categories: T +} export type EditOrganisationModalProps = ModalProps & { organisation?: EditOrganisation + // categories modal flag + // true -> + autoConfirm?: boolean } export default function SoftwareOrganisations() { @@ -53,18 +61,22 @@ export default function SoftwareOrganisations() { account: user?.account ?? '', token }) - const [modal, setModal] = useState>({ + const [modal, setModal] = useState>({ edit: { open: false, }, delete: { open: false + }, + categories:{ + open: false } }) // console.group('SoftwareOrganisations') // console.log('loading...', loading) // console.log('organisations...', organisations) + // console.log('modal...', modal) // console.groupEnd() // if loading show loader @@ -94,7 +106,8 @@ export default function SoftwareOrganisations() { }, delete: { open:false - } + }, + categories:{open:false} }) } else if (item.source === 'RSD') { // we add organisation directly @@ -108,6 +121,20 @@ export default function SoftwareOrganisations() { // update status received in message addOrganisation.status = resp.message as SoftwareForOrganisation['status'] addOrganisationToList(addOrganisation) + // show categories modal + setModal({ + edit: { + open: false, + }, + delete: { + open:false + }, + categories:{ + open: true, + organisation: addOrganisation, + autoConfirm: true + } + }) } else { showErrorMessage(resp.message) } @@ -130,7 +157,8 @@ export default function SoftwareOrganisations() { }, delete: { open:false - } + }, + categories:{open:false} }) } @@ -145,7 +173,8 @@ export default function SoftwareOrganisations() { }, delete: { open:false - } + }, + categories:{open:false} }) } } @@ -162,7 +191,8 @@ export default function SoftwareOrganisations() { open: true, pos, displayName - } + }, + categories:{open:false} }) } } @@ -255,6 +285,9 @@ export default function SoftwareOrganisations() { }, delete: { open:false + }, + categories:{ + open:false } }) } @@ -310,6 +343,26 @@ export default function SoftwareOrganisations() { } } + function onCategoryEdit(pos:number){ + const organisation = organisations[pos] + if (organisation){ + setModal({ + edit: { + open:false + }, + delete: { + open:false + }, + categories:{ + open:true, + organisation, + // editing categories + autoConfirm: false + } + }) + } + } + return ( <> @@ -323,6 +376,7 @@ export default function SoftwareOrganisations() { onEdit={onEdit} onDelete={onDelete} onSorted={sortedOrganisations} + onCategory={onCategoryEdit} />
@@ -356,6 +410,16 @@ export default function SoftwareOrganisations() { onDelete={()=>deleteOrganisation(modal.delete.pos)} /> } + {modal.categories.open===true && modal.categories.organisation ? + + : null + } ) } diff --git a/frontend/components/software/edit/organisations/useParticipatingOrganisations.ts b/frontend/components/software/edit/organisations/useParticipatingOrganisations.ts index 2d52ae9ab..b90233ccf 100644 --- a/frontend/components/software/edit/organisations/useParticipatingOrganisations.ts +++ b/frontend/components/software/edit/organisations/useParticipatingOrganisations.ts @@ -1,60 +1,16 @@ // 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 {useEffect, useState} from 'react' - -import isMaintainerOfOrganisation from '~/auth/permissions/isMaintainerOfOrganisation' -import {EditOrganisation} from '../../../../types/Organisation' -import {getOrganisationsForSoftware} from '../../../../utils/editOrganisation' - -type UseParticipatingOrganisationsProps = { - software: string, - token: string, - account: string -} - -async function getParticipatingOrganisationsForSoftware({software, token, account}: UseParticipatingOrganisationsProps) { - const resp = await getOrganisationsForSoftware({ - software, - token - }) - // collect isMaintainerRequests - const promises: Promise[] = [] - // prepare organisation list - const orgList = resp.map((item, pos) => { - // save isMaintainer request - promises.push(isMaintainerOfOrganisation({ - organisation: item.id, - account, - token - })) - // extract only needed props - const organisation: EditOrganisation = { - ...item, - // additional props for edit type - position: pos + 1, - logo_b64: null, - logo_mime_type: null, - source: 'RSD' as 'RSD', - status: item.status, - // false by default - canEdit: false, - // description: null - } - return organisation - }) - // run all isMaintainer requests in parallel - const isMaintainer = await Promise.all(promises) - const organisations = orgList.map((item, pos) => { - // update canEdit based on isMaintainer requests - if (isMaintainer[pos]) item.canEdit = isMaintainer[pos] - return item - }) - return organisations -} - +import {EditOrganisation} from '~/types/Organisation' +import { + getParticipatingOrganisationsForSoftware, + UseParticipatingOrganisationsProps +} from './apiSoftwareOrganisations' export function useParticipatingOrganisations({software, token, account}: UseParticipatingOrganisationsProps) { const [organisations, setOrganisations] = useState([]) diff --git a/frontend/utils/__mocks__/getSoftware.ts b/frontend/utils/__mocks__/getSoftware.ts index a0d34fdec..7d77b6aa7 100644 --- a/frontend/utils/__mocks__/getSoftware.ts +++ b/frontend/utils/__mocks__/getSoftware.ts @@ -3,7 +3,7 @@ // // SPDX-License-Identifier: Apache-2.0 -import {CategoriesForSoftware,} from '~/types/SoftwareTypes' +import {CategoriesForSoftware, CategoryForSoftwareIds,} from '~/types/SoftwareTypes' import {CategoryPath} from '~/types/Category' export async function getSoftwareList({url,token}:{url:string,token?:string }){ @@ -43,6 +43,10 @@ export async function getCategoriesForSoftware(software_id: string, token?: stri return [] } +export async function getCategoryForSoftwareIds(software_id: string, token?: string): Promise { + return new Set() +} + export async function getAvailableCategories(): Promise { return [] }