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 (
+ 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{