diff --git a/data-generation/main.js b/data-generation/main.js index e5481d06b..ca6a94eb0 100644 --- a/data-generation/main.js +++ b/data-generation/main.js @@ -336,7 +336,11 @@ async function generateCategories(idsCommunities, idsOrganisations, maxDepth = 3 async function generateAndSaveCategoriesForEntity(idCommunity, idOrganisation, maxDepth) { return new Promise(async res => { let parentIdsAndFlags = [ - {id: null, forSoftware: faker.datatype.boolean(), forProjects: faker.datatype.boolean()}, + { + id: null, + forSoftware: faker.datatype.boolean(), + forProjects: idCommunity ? false : faker.datatype.boolean(), + }, ]; const idsAndFlags = []; for (let level = 1; level <= maxDepth; level++) { @@ -347,8 +351,17 @@ async function generateAndSaveCategoriesForEntity(idCommunity, idOrganisation, m toGenerateCount += 1; } for (let i = 0; i < toGenerateCount; i++) { - const name = `Parent ${parent.id}, level ${level}, item ${i + 1}`; - const shortName = `Level ${level}, item ${i + 1}`; + let name = `Global, level ${level}, item ${i + 1}${parent.id ? `, parent${parent.id.substring(0, 5)}` : ''}`; + let shortName = `G-${level}-${i + 1}${parent.id ? `, P-${parent.id.substring(0, 5)}` : ''}`; + + if (idCommunity) { + name = `Level ${level}, item ${i + 1}, community-${idCommunity.substring(0, 5)}${parent.id ? `, parent-${parent.id.substring(0, 5)}` : ''}`; + shortName = `L-${level}-${i + 1}, C-${idCommunity.substring(0, 5)}${parent.id ? `, P-${parent.id.substring(0, 5)}` : ''}`; + } else if (idOrganisation) { + name = `Level ${level}, item ${i + 1}, organisation-${idOrganisation.substring(0, 5)}${parent.id ? `, parent-${parent.id.substring(0, 5)}` : ''}`; + shortName = `L-${level}-${i + 1}, O-${idOrganisation.substring(0, 5)}${parent.id ? `, P-${parent.id.substring(0, 5)}` : ''}`; + } + const body = { community: idCommunity, organisation: idOrganisation, diff --git a/frontend/components/category/CategoriesDialog.tsx b/frontend/components/category/CategoriesDialog.tsx index 40bac32ad..d43b2d5f4 100644 --- a/frontend/components/category/CategoriesDialog.tsx +++ b/frontend/components/category/CategoriesDialog.tsx @@ -3,10 +3,11 @@ // // 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' @@ -14,9 +15,7 @@ import SaveIcon from '@mui/icons-material/Save' import {TreeNode} from '~/types/TreeNode' import {CategoryEntry} from '~/types/Category' -import ContentLoader from '../layout/ContentLoader' -import {RecursivelyGenerateItems} from '~/components/software/TreeSelect' -import {useEffect, useState} from 'react' +import CategoriesDialogBody from './CategoriesDialogBody' type CategoriesDialogProps={ title: string, @@ -49,72 +48,10 @@ export default function CategoriesDialog({ } },[selected,state]) - 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 isSaveDisabled(){ return categories === null || categories.length === 0 || state !== 'ready' } - function renderDialogContent(): JSX.Element { - switch (state) { - case 'loading': - case 'saving': - return ( -
- -
- ) - - case 'error': - return ( - - {errorMsg ?? '500 - Unexpected error'} - - ) - - case 'ready': - return ( - <> - {(categories === null || categories.length === 0) - ? - - {noItemsMsg} - - : - - } - - ) - } - } - return ( - {renderDialogContent()} + [], + state: 'loading' | 'error' | 'ready' | 'saving', + errorMsg: string | null + noItemsMsg: string + selectedCategoryIds: Set, + setSelectedCategoryIds: (ids:Set)=>void +}> + +export default function CategoriesDialogBody({ + categories,state,errorMsg,noItemsMsg, + selectedCategoryIds, setSelectedCategoryIds +}:CategoriesDialogBodyProps) { + + + function isSelected(node: TreeNode) { + const val = node.getValue() + + // directly selected + if (selectedCategoryIds.has(val.id)) return true + + // any of children selected? + const found = node.children().find(item=>{ + return isSelected(item) + }) + if (found) { + // add parent to list of selected items + // if not already in the list + if (selectedCategoryIds.has(val.id)===false){ + // debugger + selectedCategoryIds.add(val.id) + // update state at the end of cycle to avoid render error + setTimeout(()=>{ + setSelectedCategoryIds(new Set(selectedCategoryIds)) + },0) + } + return true + } + // none of children selected either + return false + } + + function onSelect(node: TreeNode,parent:boolean=false) { + const val = node.getValue() + if (selectedCategoryIds.has(val.id)) { + // debugger + selectedCategoryIds.delete(val.id) + // deselect all children too + node.children().forEach(item=>{ + // debugger + onSelect(item,true) + }) + } else if (parent===false) { + // we toggle the value if onSelect + // is NOT called by the parent node + selectedCategoryIds.add(val.id) + } + // update state at the end of cycle to avoid render error + setTimeout(()=>{ + setSelectedCategoryIds(new Set(selectedCategoryIds)) + },1) + } + + switch (state) { + case 'loading': + case 'saving': + return ( +
+ +
+ ) + + case 'error': + return ( + + {errorMsg ?? '500 - Unexpected error'} + + ) + + case 'ready': + return ( + <> + {(categories === null || categories.length === 0) + ? + + {noItemsMsg} + + : + + + + } + + ) + } + +} diff --git a/frontend/components/category/CategoriesWithHeadlines.tsx b/frontend/components/category/CategoriesWithHeadlines.tsx index b09983c4b..c0c6c996a 100644 --- a/frontend/components/category/CategoriesWithHeadlines.tsx +++ b/frontend/components/category/CategoriesWithHeadlines.tsx @@ -7,10 +7,10 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react' -import {useCategoryTree} from '~/utils/categories' import {CategoryPath} from '~/types/Category' import {SidebarHeadline} from '~/components/typography/SidebarHeadline' import {CategoryTreeLevel} from '~/components/category/CategoryTree' +import {useCategoryTree} from './useCategoryTree' type CategoriesWithHeadlinesProps = { categories: CategoryPath[] diff --git a/frontend/components/category/CategoryEditForm.tsx b/frontend/components/category/CategoryEditForm.tsx index d649a9ccc..19d575582 100644 --- a/frontend/components/category/CategoryEditForm.tsx +++ b/frontend/components/category/CategoryEditForm.tsx @@ -157,7 +157,8 @@ export default function CategoryEditForm({ defaultValue: createNew ? undefined : data?.short_name, helperTextCnt: `${watch('short_name')?.length ?? 0}/100`, helperTextMessage: `${formState.errors?.short_name?.message ?? ''}`, - error: formState.errors?.short_name?.message !== undefined + error: formState.errors?.short_name?.message !== undefined, + autofocus: true }} /> + onSelect: (node: TreeNode) => void + isSelected: (node: TreeNode) => boolean +}> + +function NodeWithChildren({ + node, + isSelected, + onSelect +}:NodeWithChildrenProps){ + // open/close children panel + const [open,setOpen] = useState(true) + // get category + const cat = node.getValue() + + return ( + <> + setOpen(!open)} + > + {open ? : } + + } + disablePadding + dense + > + onSelect(node)}> + + + + + {/* Children block */} + + + + + + + ) +} + +export type CategoryListProps = { + onSelect: (node: TreeNode) => void + isSelected: (node: TreeNode) => boolean + categories: TreeNode[] +} + +export function CategoryList({ + categories, + isSelected, + onSelect +}: CategoryListProps) { + // loop all categories + return categories.map(node => { + const cat = node.getValue() + + // single cat element without children + if (node.childrenCount() === 0) { + return ( + + onSelect(node)}> + + + + + ) + } + + return ( + + ) + }) +} diff --git a/frontend/components/category/CategoryTable.tsx b/frontend/components/category/CategoryTable.tsx index 204b3fed5..9337b6579 100644 --- a/frontend/components/category/CategoryTable.tsx +++ b/frontend/components/category/CategoryTable.tsx @@ -8,7 +8,15 @@ import {CategoryEntry} from '~/types/Category' import {TreeNode} from '~/types/TreeNode' -import {calcTreeLevelDepth} from '~/utils/categories' + +export function calcTreeLevelDepth(tree: TreeNode): number { + + function walk (tree: TreeNode, depth:number): number { + return Math.max(depth, ...tree.children().map(sub => walk(sub, depth+1))) + } + + return walk(tree, 0) +} export type CategoryTableProps = Readonly<{ tree: TreeNode diff --git a/frontend/components/software/TreeSelect.tsx b/frontend/components/category/TreeSelect.tsx similarity index 96% rename from frontend/components/software/TreeSelect.tsx rename to frontend/components/category/TreeSelect.tsx index 0b1acd767..35c29e70f 100644 --- a/frontend/components/software/TreeSelect.tsx +++ b/frontend/components/category/TreeSelect.tsx @@ -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 // diff --git a/frontend/components/category/useCategories.ts b/frontend/components/category/useCategories.ts index eb8fbd345..c94f845f4 100644 --- a/frontend/components/category/useCategories.ts +++ b/frontend/components/category/useCategories.ts @@ -10,6 +10,7 @@ import logger from '~/utils/logger' import {TreeNode} from '~/types/TreeNode' import {CategoryEntry} from '~/types/Category' import {loadCategoryRoots} from '~/components/category/apiCategories' +import {sortCategoriesByName} from './useCategoryTree' type UseCategoriesProps={ community?:string|null, @@ -27,6 +28,9 @@ export default function useCategories({community,organisation}:UseCategoriesProp loadCategoryRoots({community,organisation}) .then(roots => { if (abort) return + // sort categories + sortCategoriesByName(roots) + // set state setRoots(roots) setError(null) }) @@ -46,6 +50,9 @@ export default function useCategories({community,organisation}:UseCategoriesProp function onMutation() { if (roots !== null) { + // sort categories + sortCategoriesByName(roots) + // update state setRoots([...roots]) } } diff --git a/frontend/components/category/useCategoryTree.tsx b/frontend/components/category/useCategoryTree.tsx new file mode 100644 index 000000000..1a0d73f82 --- /dev/null +++ b/frontend/components/category/useCategoryTree.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 {useMemo} from 'react' +import {CategoryEntry, CategoryPath} from '~/types/Category' +import {TreeNode} from '~/types/TreeNode' +import {categoryEntriesToRoots} from '~/components/category/apiCategories' + +const compareCategoryEntry = (p1: CategoryEntry, p2: CategoryEntry) => p1.name.localeCompare(p2.name) +const compareCategoryTreeNode = (p1: TreeNode, p2: TreeNode) => compareCategoryEntry(p1.getValue(), p2.getValue()) + +export const categoryTreeNodesSort = (trees: TreeNode[]) => { + trees.sort(compareCategoryTreeNode) + for (const root of trees) { + root.sortRecursively(compareCategoryEntry) + } +} + +/** + * Sort (ascending) the complete category tree, at all levels, on name property . + * @param trees TreeNode[] + */ +export function sortCategoriesByName(trees: TreeNode[]){ + trees.sort(compareCategoryTreeNode) + for (const root of trees) { + // sort children first + if (root.childrenCount()>0){ + sortCategoriesByName(root.children()) + } + // sort roots + root.sortRecursively(compareCategoryEntry) + } +} + +export const genCategoryTreeNodes = (categories: CategoryPath[]=[]) : TreeNode[] => { + const allEntries: CategoryEntry[] = [] + + for (const path of categories) { + for (const entry of path) { + allEntries.push(entry) + } + } + + const result = categoryEntriesToRoots(allEntries) + + sortCategoriesByName(result) + + return result +} + +export function useCategoryTree(categories: CategoryPath[]) : TreeNode[]{ + return useMemo(() => genCategoryTreeNodes(categories), [categories]) +} + diff --git a/frontend/utils/categories.ts b/frontend/components/category/useReorderedCategories.tsx similarity index 60% rename from frontend/utils/categories.ts rename to frontend/components/category/useReorderedCategories.tsx index dd62b61d7..68698de0c 100644 --- a/frontend/utils/categories.ts +++ b/frontend/components/category/useReorderedCategories.tsx @@ -6,43 +6,10 @@ // // SPDX-License-Identifier: Apache-2.0 -import {useEffect, useMemo, useState} from 'react' +import {useEffect, useState} from 'react' import {CategoryEntry, CategoryPath} from '~/types/Category' -import {categoryEntriesToRoots, loadCategoryRoots} from '~/components/category/apiCategories' import {TreeNode} from '~/types/TreeNode' - -const compareCategoryEntry = (p1: CategoryEntry, p2: CategoryEntry) => p1.name.localeCompare(p2.name) -const compareCategoryTreeNode = (p1: TreeNode, p2: TreeNode) => compareCategoryEntry(p1.getValue(), p2.getValue()) - - -export const categoryTreeNodesSort = (trees: TreeNode[]) => { - trees.sort(compareCategoryTreeNode) - for (const root of trees) { - root.sortRecursively(compareCategoryEntry) - } -} - - -export const genCategoryTreeNodes = (categories: CategoryPath[]=[]) : TreeNode[] => { - const allEntries: CategoryEntry[] = [] - - for (const path of categories) { - for (const entry of path) { - allEntries.push(entry) - } - } - - const result = categoryEntriesToRoots(allEntries) - - categoryTreeNodesSort(result) - - return result -} - - -export const useCategoryTree = (categories: CategoryPath[]) : TreeNode[] => { - return useMemo(() => genCategoryTreeNodes(categories), [categories]) -} +import {loadCategoryRoots} from '~/components/category/apiCategories' export type ReorderedCategories = { paths: CategoryPath[], @@ -51,7 +18,7 @@ export type ReorderedCategories = { general: TreeNode[], } -export function reorderCategories(categoryRoots: TreeNode[]): ReorderedCategories { +function reorderCategories(categoryRoots: TreeNode[]): ReorderedCategories { const all: TreeNode[] = categoryRoots const highlighted: TreeNode[] = [] const general: TreeNode[] = [] @@ -117,11 +84,11 @@ export function useReorderedCategories(community: string | null): ReorderedCateg return reorderedCategories } -export function calcTreeLevelDepth(tree: TreeNode): number { +// export function calcTreeLevelDepth(tree: TreeNode): number { - function walk (tree: TreeNode, depth:number): number { - return Math.max(depth, ...tree.children().map(sub => walk(sub, depth+1))) - } +// function walk (tree: TreeNode, depth:number): number { +// return Math.max(depth, ...tree.children().map(sub => walk(sub, depth+1))) +// } - return walk(tree, 0) -} +// return walk(tree, 0) +// } diff --git a/frontend/components/projects/ProjectCategories.tsx b/frontend/components/projects/ProjectCategories.tsx index 17ec98012..6e40d4e09 100644 --- a/frontend/components/projects/ProjectCategories.tsx +++ b/frontend/components/projects/ProjectCategories.tsx @@ -4,7 +4,7 @@ // SPDX-License-Identifier: Apache-2.0 import {CategoryPath} from '~/types/Category' -import {useCategoryTree} from '~/utils/categories' +import {useCategoryTree} from '~/components/category/useCategoryTree' import SidebarSection from '../layout/SidebarSection' import SidebarTitle from '../layout/SidebarTitle' import {CategoryChipFilter} from '../category/CategoryChipFilter' @@ -12,6 +12,11 @@ import {CategoryChipFilter} from '../category/CategoryChipFilter' export default function ProjectCategories({categories}:{categories:CategoryPath[]}) { const tree = useCategoryTree(categories) + // console.group('ProjectCategories') + // console.log('categories...', categories) + // console.log('tree...', tree) + // console.groupEnd() + // each root category is separate sidebar section return tree.map(node => { const category = node.getValue() diff --git a/frontend/components/projects/edit/organisations/EditProjectOrganisationsIndex.test.tsx b/frontend/components/projects/edit/organisations/EditProjectOrganisationsIndex.test.tsx index 7bea80fb9..8ff37a3d8 100644 --- a/frontend/components/projects/edit/organisations/EditProjectOrganisationsIndex.test.tsx +++ b/frontend/components/projects/edit/organisations/EditProjectOrganisationsIndex.test.tsx @@ -18,6 +18,8 @@ import {cfgOrganisations as config} from './config' import editProjectState from '../__mocks__/editProjectState' import mockOrganisationsOfProject from './__mocks__/organisationsOfProject.json' +// MOCK removeOrganisationCategoriesFromProject +jest.mock('./apiProjectOrganisations') // MOCK isMaintainerOfOrganisation // eslint-disable-next-line @typescript-eslint/no-unused-vars const mockIsMaintainerOfOrganisation = jest.fn(props => Promise.resolve(false)) diff --git a/frontend/components/projects/edit/organisations/__mocks__/apiProjectOrganisations.ts b/frontend/components/projects/edit/organisations/__mocks__/apiProjectOrganisations.ts new file mode 100644 index 000000000..e400f6a26 --- /dev/null +++ b/frontend/components/projects/edit/organisations/__mocks__/apiProjectOrganisations.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/no-unused-vars */ +export async function removeOrganisationCategoriesFromProject( + projectId: string, + organisationId: string, + token: string +){ + return {status:200} +} + +export async function getCategoryListForProject(project_id: string, token?: string){ + const categories:Set = new Set() + return categories +} diff --git a/frontend/components/projects/edit/organisations/index.tsx b/frontend/components/projects/edit/organisations/index.tsx index 91ab12746..4002ebca8 100644 --- a/frontend/components/projects/edit/organisations/index.tsx +++ b/frontend/components/projects/edit/organisations/index.tsx @@ -34,6 +34,7 @@ import useProjectContext from '../useProjectContext' import useParticipatingOrganisations from './useParticipatingOrganisations' import {cfgOrganisations as config} from './config' import ProjectCategoriesDialog from './ProjectCategoriesDialog' +import {removeOrganisationCategoriesFromProject} from './apiProjectOrganisations' export default function ProjectOrganisations() { const {token,user} = useSession() @@ -190,6 +191,9 @@ export default function ProjectOrganisations() { const organisation = organisations[pos] // if it has id if (organisation?.id) { + // remove categories from project - do not wait for result + removeOrganisationCategoriesFromProject(project.id,organisation.id,token) + // remove organisation from project const resp = await deleteOrganisationFromProject({ project: project.id, organisation: organisation.id, diff --git a/frontend/components/projects/edit/organisations/useProjectCategories.tsx b/frontend/components/projects/edit/organisations/useProjectCategories.tsx index 9fc69a0af..81b08039d 100644 --- a/frontend/components/projects/edit/organisations/useProjectCategories.tsx +++ b/frontend/components/projects/edit/organisations/useProjectCategories.tsx @@ -9,6 +9,7 @@ import {createJsonHeaders, getBaseUrl} from '~/utils/fetchHelpers' import {CategoryEntry} from '~/types/Category' import {TreeNode} from '~/types/TreeNode' import {loadCategoryRoots} from '~/components/category/apiCategories' +import {sortCategoriesByName} from '~/components/category/useCategoryTree' import {getCategoryListForProject, removeOrganisationCategoriesFromProject} from './apiProjectOrganisations' type UseProjectOrganisationCategoriesProps={ @@ -46,13 +47,13 @@ export default function useProjectCategories({ .then(([roots,selected]) => { // filter top level categories for projects (only top level items have this flag) const categories = roots.filter(item=>item.getValue().allow_projects) + // sort categories + sortCategoriesByName(categories) // collect tree leaves ids (end nodes) const availableIds = new Set() categories.forEach(root=>{ root.forEach(node=>{ - if (node.children().length === 0) { - availableIds.add(node.getValue().id) - } + availableIds.add(node.getValue().id) }) }) if (abort) return diff --git a/frontend/components/software/CategoriesSection.tsx b/frontend/components/software/CategoriesSection.tsx index 163ff00da..6b1f1a908 100644 --- a/frontend/components/software/CategoriesSection.tsx +++ b/frontend/components/software/CategoriesSection.tsx @@ -1,3 +1,4 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) // SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center) // SPDX-FileCopyrightText: 2024 Felix Mühlbauer (GFZ) // SPDX-FileCopyrightText: 2024 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences @@ -6,8 +7,8 @@ // SPDX-License-Identifier: Apache-2.0 import {CategoryPath} from '~/types/Category' -import PageContainer from '../layout/PageContainer' -import {useCategoryTree} from '~/utils/categories' +import PageContainer from '~/components/layout/PageContainer' +import {useCategoryTree} from '~/components/category/useCategoryTree' import {CategoryTable} from '~/components//category/CategoryTable' import {CategoryTreeLevel} from '~/components/category/CategoryTree' diff --git a/frontend/components/software/edit/communities/CommunityAddCategoriesDialog.tsx b/frontend/components/software/edit/communities/CommunityAddCategoriesDialog.tsx index 1e7e48e22..31ee3d68e 100644 --- a/frontend/components/software/edit/communities/CommunityAddCategoriesDialog.tsx +++ b/frontend/components/software/edit/communities/CommunityAddCategoriesDialog.tsx @@ -13,7 +13,7 @@ import {CategoryEntry} from '~/types/Category' import ContentLoader from '~/components/layout/ContentLoader' import Alert from '@mui/material/Alert' import {loadCategoryRoots} from '~/components/category/apiCategories' -import {RecursivelyGenerateItems} from '~/components/software/TreeSelect' +import {RecursivelyGenerateItems} from '~/components/category/TreeSelect' import {CategoryForSoftwareIds} from '~/types/SoftwareTypes' import DialogActions from '@mui/material/DialogActions' import Button from '@mui/material/Button' diff --git a/frontend/components/software/edit/communities/CommunityCategoriesDialog.tsx b/frontend/components/software/edit/communities/CommunityCategoriesDialog.tsx new file mode 100644 index 000000000..09da89a37 --- /dev/null +++ b/frontend/components/software/edit/communities/CommunityCategoriesDialog.tsx @@ -0,0 +1,70 @@ +// 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} from 'react' + +import CategoriesDialog from '~/components/category/CategoriesDialog' +import {CommunityListProps} from '~/components/communities/apiCommunities' +import useCommunityCategories from './useCommunityCategories' + +export type OrganisationCategoriesDialogProps = Readonly<{ + softwareId: string + community: CommunityListProps + onCancel: () => void + onComplete: () => void + edit: boolean +}> + +export default function CommunityCategoriesDialog({ + softwareId, + community, + onCancel, + onComplete, + edit +}: OrganisationCategoriesDialogProps) { + + const { + categories,selectedCategoryIds, + state,error, + saveCommunityCategories + } = useCommunityCategories({ + communityId:community.id, + softwareId + }) + + // console.group('CategoriesDialog') + // console.log('state...', state) + // console.log('categories...', categories) + // console.log('selectedCategoryIds...',selectedCategoryIds) + // console.groupEnd() + + useEffect(()=>{ + // if there are no categories and not an edit "request" + // we call onComplete immediately and don't show the modal + // this "approach" is used to add RSD organisation + // which does not have organisation categories defined + if (state==='ready' && edit===false && categories?.length===0){ + onComplete() + } + },[state,edit,categories,onComplete]) + + return ( + { + // pass onComplete to call when done + saveCommunityCategories(selected,onComplete) + }} + /> + ) + +} diff --git a/frontend/components/software/edit/communities/index.tsx b/frontend/components/software/edit/communities/index.tsx index 2bdccb593..7051a6777 100644 --- a/frontend/components/software/edit/communities/index.tsx +++ b/frontend/components/software/edit/communities/index.tsx @@ -17,68 +17,80 @@ import {useSoftwareCommunities} from './useSoftwareCommunities' import FindCommunity from './FindCommunity' import SoftwareCommunityList from './SoftwareCommunityList' import SoftwareCommunitiesInfo from './SoftwareCommunitiesInfo' -import CommunityAddCategoriesDialog from '~/components/software/edit/communities/CommunityAddCategoriesDialog' +import CommunityCategoriesDialog from './CommunityCategoriesDialog' + +type ModalProps={ + delete:{ + open: boolean, + id?: string|null + name?: string | null + }, + categories:{ + open:boolean, + community?: CommunityListProps + edit?:boolean + } +} export default function SoftwareCommunities() { const {software} = useSoftwareContext() const {loading,communities,joinCommunity,leaveCommunity} = useSoftwareCommunities(software.id) - const [modal, setModal] = useState<{ - open: boolean, - id: string | null, - name: string | null - }>({ - open: false, - id: null, - name: null + const [modal,setModal] = useState({ + delete:{ + open:false + }, + categories:{ + open:false + } }) - const [openCategoryModalProps, setOpenCategoryModalProps] = useState<{autoConfirm: boolean, onSave: (community: CommunityListProps) => void} | null>(null) - const [selectedCommunity, setSelectedCommunity] = useState(null) - if (loading) return ( ) - function onDeleteCommunity(id:string){ const com = communities.find(item=>item.id===id) if (com){ setModal({ - open: true, - id: com.id, - name: com.name + delete:{ + open: true, + id: com.id, + name: com.name + }, + categories:{ + open:false + } }) } } - function deleteCommunity(id:string){ - // console.log('deleteCommunity...', id) - leaveCommunity({ - software:software.id, - community: id - }) - } - function onAddCommunity(community: CommunityListProps){ - setSelectedCommunity(community) - setOpenCategoryModalProps({autoConfirm: true, onSave: onConfirmAddCommunity}) + setModal({ + categories:{ + open: true, + edit: false, + community + }, + delete:{open:false} + }) } function onOpenEditCategories(community: CommunityListProps) { - setSelectedCommunity(community) - setOpenCategoryModalProps({autoConfirm: false, onSave: () => { - setOpenCategoryModalProps(null) - setSelectedCommunity(null) - }}) + setModal({ + categories:{ + open: true, + edit: true, + community + }, + delete:{open:false} + }) } - function onConfirmAddCommunity(community: CommunityListProps) { - setOpenCategoryModalProps(null) - joinCommunity({ - software: software.id, - community: community + function closeModals(){ + setModal({ + categories:{open:false}, + delete: {open:false}, }) - setSelectedCommunity(null) } return ( @@ -108,32 +120,46 @@ export default function SoftwareCommunities() { - {modal.open && + {modal.delete.open ? Are you sure you want to remove {modal.name ?? ''}? This will also delete all related (if any) categories.

+

Are you sure you want to remove {modal.delete.name ?? ''}? This will also delete all related (if any) categories.

} - onCancel={()=>setModal({open:false,id:null,name:null})} + onCancel={closeModals} onDelete={()=>{ // only if id present - if(modal.id) { - deleteCommunity(modal.id) + if(modal.delete.id) { + leaveCommunity({ + software: software.id, + community: modal.delete.id + }) } // we close modal anyway - setModal({open:false,id:null,name:null}) + closeModals() }} /> + : null } - {openCategoryModalProps!== null && - {setOpenCategoryModalProps(null); setSelectedCommunity(null)}} - onConfirm={openCategoryModalProps.onSave} - autoConfirm={openCategoryModalProps.autoConfirm ?? false} - /> + {modal.categories.open && modal.categories.community ? + { + // if new community we also need to join + if (modal.categories.community && modal.categories.edit===false){ + joinCommunity({ + software: software.id, + community: modal.categories.community + }) + } + closeModals() + }} + /> + :null } ) diff --git a/frontend/components/software/edit/communities/useCommunityCategories.tsx b/frontend/components/software/edit/communities/useCommunityCategories.tsx new file mode 100644 index 000000000..c4be37211 --- /dev/null +++ b/frontend/components/software/edit/communities/useCommunityCategories.tsx @@ -0,0 +1,120 @@ +// 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 {useSession} from '~/auth' +import {CategoryEntry} from '~/types/Category' +import {TreeNode} from '~/types/TreeNode' +import {getCategoryForSoftwareIds} from '~/utils/getSoftware' +import {loadCategoryRoots} from '~/components/category/apiCategories' +import {sortCategoriesByName} from '~/components/category/useCategoryTree' +import {saveSoftwareCategories, SoftwareCategories} from '../organisations/apiSoftwareOrganisations' +import {removeCommunityCategoriesFromSoftware} from './apiSoftwareCommunities' + +type UseSoftwareCommunityCategoriesProps={ + communityId:string|null, + softwareId:string +} + +export default function useCommunityCategories({ + communityId,softwareId +}:UseSoftwareCommunityCategoriesProps){ + const {token} = useSession() + 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()) + + // console.group('useCommunityCategories') + // console.log('state...',state) + // console.log('categories...', categories) + // console.groupEnd() + + useEffect(() => { + let abort = false + if (communityId && softwareId && token){ + Promise.all([ + loadCategoryRoots({community:communityId}), + getCategoryForSoftwareIds(softwareId, token) + ]) + .then(([roots,selected]) => { + // sort categories + sortCategoriesByName(roots) + // collect ids + const availableIds = new Set() + roots.forEach(root=>{ + root.forEach(node=>{ + availableIds.add(node.getValue().id) + }) + }) + if (abort) return + // save values + setAvailableCategoryIds(availableIds) + setCategories(roots) + setSelectedCategoryIds(selected) + }) + .catch(e => { + if (abort) return + setError(`Couldn't load categories: ${e}`) + setState('error') + }) + .finally(()=>{ + if (abort) return + setState('ready') + }) + } + return ()=>{abort=true} + }, [communityId, softwareId, token]) + + + async function saveCommunityCategories(selected:Set,onComplete:()=>void) { + // delete old selection + if (communityId){ + const deleteErrorMessage = await removeCommunityCategoriesFromSoftware(softwareId, communityId, token) + if (deleteErrorMessage !== null) { + setError(`Failed to save categories: ${deleteErrorMessage}`) + setState('error') + return + } + } + + if (selectedCategoryIds.size === 0) { + onComplete() + return + } + + // generate new collection + const categoriesArrayToSave: SoftwareCategories[] = [] + selected.forEach(id => { + if (availableCategoryIds.has(id)) { + categoriesArrayToSave.push({software_id: softwareId, category_id: id}) + } + }) + + // save community categories for software (if any) + if (categoriesArrayToSave.length > 0){ + const resp = await saveSoftwareCategories(categoriesArrayToSave,token) + // debugger + if (resp.status===200) { + // signal we are done + onComplete() + } else { + setError(`Failed to save categories: ${resp.message}`) + setState('error') + } + }else{ + onComplete() + } + } + + return { + categories, + selectedCategoryIds, + error, + state, + saveCommunityCategories + } +} diff --git a/frontend/components/software/edit/communities/useSoftwareCommunities.tsx b/frontend/components/software/edit/communities/useSoftwareCommunities.tsx index c610aed50..a7655a187 100644 --- a/frontend/components/software/edit/communities/useSoftwareCommunities.tsx +++ b/frontend/components/software/edit/communities/useSoftwareCommunities.tsx @@ -65,6 +65,9 @@ export function useSoftwareCommunities(software:string){ return } + // remove all community categories without waiting + void removeCommunityCategoriesFromSoftware(software, community, token) + const resp = await removeSoftwareFromCommunity({ software, community, diff --git a/frontend/components/software/edit/links/AutosaveSoftwareCategories.tsx b/frontend/components/software/edit/links/AutosaveSoftwareCategories.tsx index e74d489e2..72f86438c 100644 --- a/frontend/components/software/edit/links/AutosaveSoftwareCategories.tsx +++ b/frontend/components/software/edit/links/AutosaveSoftwareCategories.tsx @@ -7,15 +7,16 @@ // SPDX-License-Identifier: Apache-2.0 import {Fragment, useMemo, useState} from 'react' +import {useSession} from '~/auth' import {CategoryEntry} from '~/types/Category' -import {categoryTreeNodesSort, ReorderedCategories} from '~/utils/categories' -import TreeSelect from '~/components/software/TreeSelect' import {TreeNode} from '~/types/TreeNode' import {addCategoryToSoftware, deleteCategoryToSoftware} from '~/utils/getSoftware' -import {useSession} from '~/auth' +import EditSectionTitle from '~/components/layout/EditSectionTitle' +import {categoryTreeNodesSort} from '~/components/category/useCategoryTree' +import TreeSelect from '~/components/category/TreeSelect' import {CategoryTreeLevel} from '~/components/category/CategoryTree' +import {ReorderedCategories} from '~/components/category/useReorderedCategories' import {config} from '~/components/software/edit/links/config' -import EditSectionTitle from '~/components/layout/EditSectionTitle' export type SoftwareCategoriesProps = { softwareId: string diff --git a/frontend/components/software/edit/links/EditSoftwareMetadataInputs.tsx b/frontend/components/software/edit/links/EditSoftwareMetadataInputs.tsx index 53b321e91..d370b45fd 100644 --- a/frontend/components/software/edit/links/EditSoftwareMetadataInputs.tsx +++ b/frontend/components/software/edit/links/EditSoftwareMetadataInputs.tsx @@ -22,7 +22,7 @@ import AutosaveSoftwareCategories from './AutosaveSoftwareCategories' import AutosaveSoftwareKeywords from './AutosaveSoftwareKeywords' import AutosaveSoftwareLicenses from './AutosaveSoftwareLicenses' import SoftwareLinksInfo from './SoftwareLinksInfo' -import {ReorderedCategories, useReorderedCategories} from '~/utils/categories' +import {ReorderedCategories, useReorderedCategories} from '~/components/category/useReorderedCategories' export default function EditSoftwareMetadataInputs() { // use form context to interact with form data diff --git a/frontend/components/software/edit/links/SoftwareLinksInfo.tsx b/frontend/components/software/edit/links/SoftwareLinksInfo.tsx index a0d029430..672942538 100644 --- a/frontend/components/software/edit/links/SoftwareLinksInfo.tsx +++ b/frontend/components/software/edit/links/SoftwareLinksInfo.tsx @@ -11,7 +11,7 @@ import {Fragment} from 'react' import Alert from '@mui/material/Alert' -import {ReorderedCategories} from '~/utils/categories' +import {ReorderedCategories} from '~/components/category/useReorderedCategories' import {config} from '~/components/software/edit/links/config' import {CategoryEntry} from '~/types/Category' diff --git a/frontend/components/software/edit/organisations/EditSoftwareOrganisationsIndex.test.tsx b/frontend/components/software/edit/organisations/EditSoftwareOrganisationsIndex.test.tsx index 20f146cf5..8dcb81bd7 100644 --- a/frontend/components/software/edit/organisations/EditSoftwareOrganisationsIndex.test.tsx +++ b/frontend/components/software/edit/organisations/EditSoftwareOrganisationsIndex.test.tsx @@ -66,6 +66,8 @@ jest.mock('./organisationForSoftware', () => ({ // by default we return no categories jest.mock('~/components/category/apiCategories') jest.mock('~/utils/getSoftware') +// MOCK removeOrganisationCategoriesFromSoftware +jest.mock('./apiSoftwareOrganisations') describe('frontend/components/software/edit/organisations/index.tsx', () => { beforeEach(() => { diff --git a/frontend/components/software/edit/organisations/__mocks__/apiSoftwareOrganisations.ts b/frontend/components/software/edit/organisations/__mocks__/apiSoftwareOrganisations.ts new file mode 100644 index 000000000..2004c5cca --- /dev/null +++ b/frontend/components/software/edit/organisations/__mocks__/apiSoftwareOrganisations.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import {canEditOrganisations} from '~/auth/permissions/isMaintainerOfOrganisation' +import {getOrganisationsForSoftware} from '~/utils/editOrganisation' + + +export type UseParticipatingOrganisationsProps = { + software: string, + token: string, + account: string +} + +export async function getParticipatingOrganisationsForSoftware({software, token, account}: UseParticipatingOrganisationsProps) { + const resp = await getOrganisationsForSoftware({ + software, + token + }) + // convert to EditOrganisation type and add canEdit flag + const organisations = await canEditOrganisations({ + organisations: resp, + account, + token + }) + // debugger + return organisations +} + + +export async function removeOrganisationCategoriesFromSoftware( + softwareId: string, + organisationId: string, + token: string +){ + return {status:200} +} diff --git a/frontend/components/software/edit/organisations/apiSoftwareOrganisations.ts b/frontend/components/software/edit/organisations/apiSoftwareOrganisations.ts index ef43f763f..fe58ba4d5 100644 --- a/frontend/components/software/edit/organisations/apiSoftwareOrganisations.ts +++ b/frontend/components/software/edit/organisations/apiSoftwareOrganisations.ts @@ -4,7 +4,7 @@ // SPDX-License-Identifier: Apache-2.0 import {getOrganisationsForSoftware} from '~/utils/editOrganisation' -import {createJsonHeaders, getBaseUrl} from '~/utils/fetchHelpers' +import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers' import {canEditOrganisations} from '~/auth/permissions/isMaintainerOfOrganisation' export type UseParticipatingOrganisationsProps = { @@ -47,3 +47,27 @@ export async function removeOrganisationCategoriesFromSoftware( return resp.ok ? null : resp.text() } + +export type SoftwareCategories={ + software_id: string, + category_id: string +} + +export async function saveSoftwareCategories(categories:SoftwareCategories[],token:string){ + try{ + const categoryUrl = `${getBaseUrl()}/category_for_software` + const resp = await fetch(categoryUrl, { + method: 'POST', + body: JSON.stringify(categories), + headers: { + ...createJsonHeaders(token) + } + }) + return extractReturnMessage(resp) + }catch(e:any){ + return { + status:500, + message: e.message + } + } +} diff --git a/frontend/components/software/edit/organisations/index.tsx b/frontend/components/software/edit/organisations/index.tsx index 7ce5db665..6d35f93e2 100644 --- a/frontend/components/software/edit/organisations/index.tsx +++ b/frontend/components/software/edit/organisations/index.tsx @@ -40,6 +40,7 @@ import { deleteOrganisationFromSoftware, patchOrganisationPositions } from './organisationForSoftware' import SoftwareCategoriesDialog from './SoftwareCategoriesDialog' +import {removeOrganisationCategoriesFromSoftware} from './apiSoftwareOrganisations' export type OrganisationModalStates = ModalStates & { categories: T @@ -211,9 +212,12 @@ export default function SoftwareOrganisations() { // get organisation const organisation = organisations[pos] // if it has id - if (organisation?.id) { + if (organisation?.id && software?.id) { + // remove categories from software - do not wait for result + removeOrganisationCategoriesFromSoftware(software?.id, organisation.id, token) + // remove organisation from software const resp = await deleteOrganisationFromSoftware({ - software: software?.id ?? undefined, + software: software?.id, organisation: organisation.id, token }) diff --git a/frontend/components/software/edit/organisations/useSoftwareCategories.tsx b/frontend/components/software/edit/organisations/useSoftwareCategories.tsx index e484c1f11..6cc9f99f6 100644 --- a/frontend/components/software/edit/organisations/useSoftwareCategories.tsx +++ b/frontend/components/software/edit/organisations/useSoftwareCategories.tsx @@ -9,9 +9,13 @@ import {useSession} from '~/auth' import {CategoryEntry} from '~/types/Category' import {TreeNode} from '~/types/TreeNode' import {getCategoryForSoftwareIds} from '~/utils/getSoftware' -import {createJsonHeaders, getBaseUrl} from '~/utils/fetchHelpers' import {loadCategoryRoots} from '~/components/category/apiCategories' -import {removeOrganisationCategoriesFromSoftware} from './apiSoftwareOrganisations' +import {sortCategoriesByName} from '~/components/category/useCategoryTree' +import { + removeOrganisationCategoriesFromSoftware, + saveSoftwareCategories, + SoftwareCategories +} from './apiSoftwareOrganisations' type UseSoftwareOrganisationCategoriesProps={ organisationId:string|null, @@ -43,13 +47,13 @@ export default function useSoftwareCategories({ .then(([roots,selected]) => { // filter top level categories for software (only top level items have this flag) const categories = roots.filter(item=>item.getValue().allow_software) - // collect tree leaves ids (end nodes) + // sort categories + sortCategoriesByName(categories) + // collect ids const availableIds = new Set() categories.forEach(root=>{ root.forEach(node=>{ - if (node.children().length === 0) { - availableIds.add(node.getValue().id) - } + availableIds.add(node.getValue().id) }) }) if (abort) return @@ -84,35 +88,28 @@ export default function useSoftwareCategories({ } } - if (selectedCategoryIds.size === 0) { + if (selected.size === 0) { onComplete() return } // generate new collection - const categoriesArrayToSave: {software_id: string, category_id: string}[] = [] + const categoriesArrayToSave: SoftwareCategories[] = [] selected.forEach(id => { if (availableCategoryIds.has(id)) { categoriesArrayToSave.push({software_id: softwareId, category_id: id}) } }) - // save organisation categories (if any) + // save organisation categories for software (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) - } - }) + const resp = await saveSoftwareCategories(categoriesArrayToSave,token) // debugger - if (resp.ok) { + if (resp.status===200) { // signal we are done onComplete() } else { - setError(`Failed to save categories: ${await resp.text()}`) + setError(`Failed to save categories: ${resp.message()}`) setState('error') } }else{