diff --git a/data-generation/main.js b/data-generation/main.js index 818d77f0e..e5481d06b 100644 --- a/data-generation/main.js +++ b/data-generation/main.js @@ -308,44 +308,72 @@ function generateKeywordsForEntity(idsEntity, idsKeyword, nameEntity) { return result; } -async function generateCategories(idsCommunities, maxDepth = 3) { - const communityPromises = []; +const categoriesPerCommunity = new Map(); +const categoriesPerOrganisation = new Map(); +const globalCategories = []; +async function generateCategories(idsCommunities, idsOrganisations, maxDepth = 3) { + const promises = []; + for (const commId of idsCommunities) { - communityPromises.push(generateAndSaveCategoriesForCommunity(commId, maxDepth)); + promises.push( + generateAndSaveCategoriesForEntity(commId, null, maxDepth).then(ids => + categoriesPerCommunity.set(commId, ids), + ), + ); + } + for (const orgId of idsOrganisations) { + promises.push( + generateAndSaveCategoriesForEntity(null, orgId, maxDepth).then(ids => + categoriesPerOrganisation.set(orgId, ids), + ), + ); } - communityPromises.push(generateAndSaveCategoriesForCommunity(null, maxDepth)); + promises.push(generateAndSaveCategoriesForEntity(null, null, maxDepth).then(ids => globalCategories.push(...ids))); - return await Promise.all(communityPromises); + return await Promise.all(promises); } -async function generateAndSaveCategoriesForCommunity(idCommunity, maxDepth) { +async function generateAndSaveCategoriesForEntity(idCommunity, idOrganisation, maxDepth) { return new Promise(async res => { - let parentIds = [null]; + let parentIdsAndFlags = [ + {id: null, forSoftware: faker.datatype.boolean(), forProjects: faker.datatype.boolean()}, + ]; + const idsAndFlags = []; for (let level = 1; level <= maxDepth; level++) { - const newParentIds = []; - for (const parent of parentIds) { + const newParentIdsAndFlags = []; + for (const parent of parentIdsAndFlags) { let toGenerateCount = faker.number.int(4); - if (idCommunity === null && level === 1) { + if (idCommunity === null && idOrganisation === null && level === 1) { toGenerateCount += 1; } for (let i = 0; i < toGenerateCount; i++) { - const name = `Parent ${parent}, level ${level}, item ${i + 1}`; + const name = `Parent ${parent.id}, level ${level}, item ${i + 1}`; const shortName = `Level ${level}, item ${i + 1}`; const body = { community: idCommunity, - parent: parent, + organisation: idOrganisation, + parent: parent.id, short_name: shortName, name: name, + allow_software: parent.forSoftware, + allow_projects: parent.forProjects, }; await postToBackend('/category', body) .then(resp => resp.json()) - .then(json => json[0].id) - .then(id => newParentIds.push(id)); + .then(json => ({ + id: json[0].id, + forSoftware: parent.forSoftware, + forProjects: parent.forProjects, + })) + .then(data => { + newParentIdsAndFlags.push(data); + idsAndFlags.push(data); + }); } } - parentIds = newParentIds; + parentIdsAndFlags = newParentIdsAndFlags; } - res(); + res(idsAndFlags); }); } @@ -1060,7 +1088,8 @@ const communityPromise = postToBackend('/community', generateCommunities()) .then(async commArray => { idsCommunities = commArray.map(comm => comm['id']); postToBackend('/keyword_for_community', generateKeywordsForEntity(idsCommunities, idsKeywords, 'community')); - generateCategories(idsCommunities); + await organisationPromise; + await generateCategories(idsCommunities, idsOrganisations); }); await postToBackend('/meta_pages', generateMetaPages()).then(() => console.log('meta pages done')); @@ -1079,13 +1108,52 @@ await postToBackend( '/software_for_project', generateRelationsForDifferingEntities(idsSoftware, idsProjects, 'software', 'project'), ).then(() => console.log('sw-pj done')); -await postToBackend( - '/software_for_organisation', - generateRelationsForDifferingEntities(idsSoftware, idsOrganisations, 'software', 'organisation'), -).then(() => console.log('sw-org done')); -await postToBackend('/project_for_organisation', generateProjectForOrganisation(idsProjects, idsOrganisations)).then( - () => console.log('pj-org done'), +const softwareForOrganisation = generateRelationsForDifferingEntities( + idsSoftware, + idsOrganisations, + 'software', + 'organisation', ); +await postToBackend('/software_for_organisation', softwareForOrganisation) + .then(async () => { + const allCategoriesForSoftware = []; + for (const entry of softwareForOrganisation) { + const orgId = entry.organisation; + const relations = generateRelationsForDifferingEntities( + [entry.software], + categoriesPerOrganisation + .get(orgId) + .filter(data => data.forSoftware) + .map(data => data.id), + 'software_id', + 'category_id', + ); + allCategoriesForSoftware.push(...relations); + } + // console.log(allCategoriesForSoftware); + await postToBackend('/category_for_software', allCategoriesForSoftware); + }) + .then(() => console.log('sw-org done')); +const projectForOrganisation = generateProjectForOrganisation(idsProjects, idsOrganisations); +await postToBackend('/project_for_organisation', generateProjectForOrganisation(idsProjects, idsOrganisations)) + .then(async () => { + const allCategoriesForProjects = []; + for (const entry of projectForOrganisation) { + const orgId = entry.organisation; + const relations = generateRelationsForDifferingEntities( + [entry.project], + categoriesPerOrganisation + .get(orgId) + .filter(data => data.forProjects) + .map(data => data.id), + 'project_id', + 'category_id', + ); + allCategoriesForProjects.push(...relations); + } + await postToBackend('/category_for_project', allCategoriesForProjects); + }) + .then(() => console.log('pj-org done')); await postToBackend('/software_for_community', generateSoftwareForCommunity(idsSoftware, idsCommunities)).then(() => console.log('sw-comm done'), ); diff --git a/database/008-community.sql b/database/008-create-community-table.sql similarity index 100% rename from database/008-community.sql rename to database/008-create-community-table.sql diff --git a/database/014-create-organisation-table.sql b/database/013-create-organisation-table.sql similarity index 100% rename from database/014-create-organisation-table.sql rename to database/013-create-organisation-table.sql diff --git a/database/009-create-keyword-and-category.sql b/database/014-create-keyword-and-category.sql similarity index 76% rename from database/009-create-keyword-and-category.sql rename to database/014-create-keyword-and-category.sql index 1204f38db..e9d3e971e 100644 --- a/database/009-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 @@ -88,29 +89,24 @@ CREATE TABLE category ( id UUID PRIMARY KEY, parent UUID REFERENCES category DEFAULT NULL, community UUID REFERENCES community(id) DEFAULT NULL, + organisation UUID REFERENCES organisation(id) DEFAULT NULL, + allow_software BOOLEAN NOT NULL DEFAULT FALSE, + allow_projects BOOLEAN NOT NULL DEFAULT FALSE, short_name VARCHAR(100) NOT NULL, name VARCHAR(250) NOT NULL, properties JSONB NOT NULL DEFAULT '{}'::jsonb, provenance_iri VARCHAR(250) DEFAULT NULL, -- e.g. https://www.w3.org/TR/skos-reference/#mapping - CONSTRAINT unique_short_name UNIQUE NULLS NOT DISTINCT (parent, short_name, community), - CONSTRAINT unique_name UNIQUE NULLS NOT DISTINCT (parent, name, community), + CONSTRAINT only_one_entity CHECK (community IS NULL OR organisation IS NULL), + CONSTRAINT unique_short_name UNIQUE NULLS NOT DISTINCT (parent, short_name, community, organisation), + CONSTRAINT unique_name UNIQUE NULLS NOT DISTINCT (parent, name, community, organisation), CONSTRAINT invalid_value_for_properties CHECK (properties - '{icon, is_highlight, description, subtitle, tree_level_labels}'::text[] = '{}'::jsonb), CONSTRAINT highlight_must_be_top_level_category CHECK (NOT ((properties->>'is_highlight')::boolean AND parent IS NOT NULL)) ); 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); - +CREATE INDEX category_organisation_idx ON category(organisation); -- sanitize categories @@ -126,6 +122,9 @@ BEGIN IF NEW.parent IS NOT NULL AND (SELECT community FROM category WHERE id = NEW.parent) IS DISTINCT FROM NEW.community THEN RAISE EXCEPTION USING MESSAGE = 'The community must be the same as of its parent.'; END IF; + IF NEW.parent IS NOT NULL AND (SELECT organisation FROM category WHERE id = NEW.parent) IS DISTINCT FROM NEW.organisation THEN + RAISE EXCEPTION USING MESSAGE = 'The organisation must be the same as of its parent.'; + END IF; NEW.id = gen_random_uuid(); RETURN NEW; END @@ -151,6 +150,12 @@ BEGIN IF NEW.parent IS NOT NULL AND (SELECT community FROM category WHERE id = NEW.parent) IS DISTINCT FROM NEW.community THEN RAISE EXCEPTION USING MESSAGE = 'The community must be the same as of its parent.'; END IF; + IF NEW.organisation IS DISTINCT FROM OLD.organisation THEN + RAISE EXCEPTION USING MESSAGE = 'The organisation this category belongs to may not be changed.'; + END IF; + IF NEW.parent IS NOT NULL AND (SELECT organisation FROM category WHERE id = NEW.parent) IS DISTINCT FROM NEW.organisation THEN + RAISE EXCEPTION USING MESSAGE = 'The organisation must be the same as of its parent.'; + END IF; RETURN NEW; END $$; @@ -224,6 +229,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 @@ -238,3 +256,45 @@ $$ 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); + +-- RPC for project page to show all project categories +CREATE FUNCTION category_paths_by_project_expanded(project_id UUID) +RETURNS JSON +LANGUAGE SQL STABLE AS +$$ + WITH + cat_ids AS + (SELECT + category_id + FROM + category_for_project + WHERE + category_for_project.project_id = category_paths_by_project_expanded.project_id + ), + paths AS + ( + SELECT + category_path_expanded(category_id) AS path + FROM cat_ids + ) + SELECT + CASE + WHEN EXISTS( + SELECT 1 FROM cat_ids + ) THEN ( + SELECT json_agg(path) FROM paths + ) + ELSE '[]'::json + END AS result +$$; diff --git a/database/020-row-level-security.sql b/database/020-row-level-security.sql index 4a2d1f002..e1cb811ff 100644 --- a/database/020-row-level-security.sql +++ b/database/020-row-level-security.sql @@ -279,7 +279,6 @@ CREATE POLICY admin_all_rights ON testimonial TO rsd_admin -- categories - ALTER TABLE category ENABLE ROW LEVEL SECURITY; -- allow everybody to read @@ -290,7 +289,11 @@ CREATE POLICY anyone_can_read ON category CREATE POLICY maintainer_all_rights ON category TO rsd_user - USING (community IN (SELECT * FROM communities_of_current_maintainer())); + USING ( + (community IS NOT NULL AND community IN (SELECT * FROM communities_of_current_maintainer())) + OR + (organisation IS NOT NULL AND organisation IN (SELECT * FROM organisations_of_current_maintainer())) + ); -- allow admins to have full read/write access CREATE POLICY admin_all_rights ON category @@ -299,14 +302,13 @@ CREATE POLICY admin_all_rights ON category -- categories for software - ALTER TABLE category_for_software ENABLE ROW LEVEL SECURITY; -- allow everybody to read metadata of published software CREATE POLICY anyone_can_read ON category_for_software FOR SELECT TO rsd_web_anon, rsd_user - USING (EXISTS(SELECT 1 FROM software WHERE id = software_id)); + USING (software_id IN (SELECT id FROM software)); -- allow software maintainers to have read/write access to their software CREATE POLICY maintainer_all_rights ON category_for_software @@ -319,6 +321,26 @@ CREATE POLICY admin_all_rights ON category_for_software USING (TRUE); +-- categories for project +ALTER TABLE category_for_project ENABLE ROW LEVEL SECURITY; + +-- allow everybody to read metadata of published projects +CREATE POLICY anyone_can_read ON category_for_project + FOR SELECT + TO rsd_web_anon, rsd_user + USING (project_id IN (SELECT id FROM project)); + +-- allow software maintainers to have read/write access to their project +CREATE POLICY maintainer_all_rights ON category_for_project + TO rsd_user + USING (project_id IN (SELECT * FROM projects_of_current_maintainer())); + +-- allow admins to have full read/write access +CREATE POLICY admin_all_rights ON category_for_project + TO rsd_admin + USING (TRUE); + + -- keywords ALTER TABLE keyword ENABLE ROW LEVEL SECURITY; diff --git a/database/109-category-functions.sql b/database/109-category-functions.sql index ed8795a2f..5e692ad9d 100644 --- a/database/109-category-functions.sql +++ b/database/109-category-functions.sql @@ -1,13 +1,48 @@ +-- 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 +-- DELETE organisation categories for specific community CREATE FUNCTION delete_community_categories_from_software(software_id UUID, community_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.community = community_id; + USING + category + WHERE + category_for_software.category_id = category.id AND + category_for_software.software_id = delete_community_categories_from_software.software_id AND + category.community = delete_community_categories_from_software.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 = delete_organisation_categories_from_software.software_id AND + category.organisation = delete_organisation_categories_from_software.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 = delete_organisation_categories_from_project.project_id AND + category.organisation = delete_organisation_categories_from_project.organisation_id; $$; diff --git a/database/114-organisation-views.sql b/database/113-organisation-views.sql similarity index 100% rename from database/114-organisation-views.sql rename to database/113-organisation-views.sql diff --git a/documentation/assets/screenshots-template.xcf b/documentation/assets/screenshots-template.xcf index 835d93a22..e391069e1 100644 Binary files a/documentation/assets/screenshots-template.xcf and b/documentation/assets/screenshots-template.xcf differ diff --git a/documentation/docs/01-users/05-adding-software.md b/documentation/docs/01-users/05-adding-software.md index bdae64f70..f5932bbc0 100644 --- a/documentation/docs/01-users/05-adding-software.md +++ b/documentation/docs/01-users/05-adding-software.md @@ -34,24 +34,24 @@ Another section with subtitle ## Code area -Use `three backticks` to create code area +Use `three backticks` to create a code area -``bash +`bash This example is code area -`` +` ## Simple table -|column 1| column 2| column 3| -|-|-|-| -|123242|234234|3| -|some text here|34|x=23| +| column 1 | column 2 | column 3 | +| -------------- | -------- | -------- | +| 123242 | 234234 | 3 | +| some text here | 34 | x=23 | ## Task list -* [x] Do this first -* [ ] Then this -* [ ] And this at last +- [x] Do this first +- [ ] Then this +- [ ] And this at last ## Links @@ -62,7 +62,6 @@ This example is code area You need to use the full URL of the image and the image needs to send CORS headers, otherwise the image will not be loaded ![Mozilla](https://cdn.glitch.me/4c9ebeb9-8b9a-4adc-ad0a-238d9ae00bb5%2Fmdn_logo-only_color.svg) - ``` ### Document URL @@ -89,8 +88,8 @@ The software logo is shown on the software page and in the software card (see ex The RSD supports URLs starting with https that point to the repository website, or to the actual git repository, for example: - * `https://github.com/research-software-directory/RSD-as-a-service` (website) - * `https://github.com/research-software-directory/RSD-as-a-service.git` (git repository) + - `https://github.com/research-software-directory/RSD-as-a-service` (website) + - `https://github.com/research-software-directory/RSD-as-a-service.git` (git repository) - A **Getting started URL** which refers to webpage with more information about the software. This is shown as the "Get started" button on the software page. @@ -149,13 +148,24 @@ If you have provided a **Software DOI** in the previous section, you can import ::: -## Organisations +## Participating organisations In this section, you can list which organisations contributed to the development of the software. You can use the search bar underneath "Add organisation" to search for organisations already registered in the RSD or in the [ROR](https://ROR.org) database. ![video](img/software-organisation.gif) +### Organisation categories + +If the maintainers of the organisation have created custom categories, you will see **an additional modal asking you to select organisation specific categories that apply to your software**. + +:::tip + +- Custom organisation categories will be shown on the software page below the logo and before the keywords. Use the "View Software" button to see how custom organisation categories are displayed on the software page. +- You can change the custom organisation categories using the categories button. + +::: + ## Mentions This section allows you to add mentions to your software page. You can use this to list reference papers, publications, presentations, videos, blogs, etc. that prominently feature your software, or the results produced by your software. @@ -164,7 +174,7 @@ This section allows you to add mentions to your software page. You can use this ### Reference papers -Use the *Search* box on the right hand side to find papers by DOI, OpenAlex ID or title. All the relevant data about the publication will be retrieved automatically. A background scraper will use [OpenAlex](https://openalex.org/) to collect all citations of reference papers that have a DOI or an OpenAlex ID. +Use the _Search_ box on the right hand side to find papers by DOI, OpenAlex ID or title. All the relevant data about the publication will be retrieved automatically. A background scraper will use [OpenAlex](https://openalex.org/) to collect all citations of reference papers that have a DOI or an OpenAlex ID. ### Citations @@ -176,7 +186,7 @@ You cannot edit the content of this section. All entries are automatically harve ### Related output -Here you can add all additional related output. Use search to find papers or other publications by DOI, OpenAlex ID or title. It is also possible to bulk add mentions, that have a DOI (use the *Import* button). On the popup, you can add one DOI per line, with a maximum of 50. After clicking on the *Next* button, we will fetch the data, which can take a moment. When that is done, you will see an overview of the data we fetched, including possible errors, where you can check the data and possibly disable some of the mentions. +Here you can add all additional related output. Use search to find papers or other publications by DOI, OpenAlex ID or title. It is also possible to bulk add mentions, that have a DOI (use the _Import_ button). On the popup, you can add one DOI per line, with a maximum of 50. After clicking on the _Next_ button, we will fetch the data, which can take a moment. When that is done, you will see an overview of the data we fetched, including possible errors, where you can check the data and possibly disable some of the mentions. ## Testimonials @@ -190,7 +200,7 @@ If your software is available through a package manager like Anaconda, PyPi or D We currently support the following package managers: [Anaconda](https://anaconda.org/), [Cran](https://cran.r-project.org/web/packages/index.html), [Chocolatey](https://community.chocolatey.org), [Debian](https://packages.debian.org), [Docker Hub](https://hub.docker.com/search?q=), [GitHub](https://github.com), [Gitlab](https://gitlab.com), [Go](https://pkg.go.dev), [Maven](https://mvnrepository.com/), [npm](https://www.npmjs.com/), [PyPI](https://pypi.org/), [Rust](https://crates.io), [Sonatype](https://central.sonatype.com/), [Snapcraft](https://snapcraft.io). -If your package manager is not listed above, you can still add it, but we will categorise it as *other* and cannot scrape it yet. You can [open an GitHub issue](https://github.com/research-software-directory/RSD-as-a-service/issues) (please check for existing issues first) or contact us if you want us to support an additional package manager. +If your package manager is not listed above, you can still add it, but we will categorise it as _other_ and cannot scrape it yet. You can [open an GitHub issue](https://github.com/research-software-directory/RSD-as-a-service/issues) (please check for existing issues first) or contact us if you want us to support an additional package manager. ![video](img/software-package-managers.gif) diff --git a/documentation/docs/01-users/07-adding-projects.md b/documentation/docs/01-users/07-adding-projects.md index a979acf01..0a19e9004 100644 --- a/documentation/docs/01-users/07-adding-projects.md +++ b/documentation/docs/01-users/07-adding-projects.md @@ -8,7 +8,7 @@ After signing in, use the **"+"** button next to your avatar icon on the top rig ![image](img/new-project.gif) -The RSD will automatically generate a *slug* for your project based on the project name you have provided. This slug will become part of the URL on which your project page can be found. +The RSD will automatically generate a _slug_ for your project based on the project name you have provided. This slug will become part of the URL on which your project page can be found. There is a small chance the generated slug is already in use by another project. If this is the case, an error will be shown, and you will need to change the slug manually to resolve this conflict. Once you click **"save"**, the RSD will initialize a new empty project page. This page will not be public yet to give you some time to provide additional information. Next, you can add additional information in the edit sections explained below. @@ -63,7 +63,7 @@ project. Therefore, you can safely change information about this person without When manually adding team members or adding images or email addresses of persons, please ensure you have **permission** to share this information! ::: -## Participating Organisations +## Participating organisations ![image](img/project-add-organisation.webp) @@ -83,6 +83,17 @@ Regular users cannot update an organisation after adding it to the RSD. Only the information. Please [contact us](mailto:rsd@esciencecenter.nl) if you need to update organisation information. ::: +### Organisation categories + +If the maintainers of the organisation have created custom categories, you will see **an additional modal asking you to select organisation specific categories that apply to your project**. + +:::tip + +- Custom organisation categories will be shown on the project page below the keywords. Use the "View Project" button to see how custom organisation categories are displayed on your project page. +- You can change the custom organisation categories using the categories button. + +::: + ## Mentions In this section, you can add mentions to your project. These entries may consist of other research outputs such as publications, dataset, book sections, blogs, etc. Where **Output** typically consists of research output being produced by the project team itself, **Impact** is generally triggered by events outside the project, such as re-use of results in other projects, publications, or society, items in the media, etc. diff --git a/documentation/docs/01-users/07-adding-projects.md.license b/documentation/docs/01-users/07-adding-projects.md.license index ebfe669e4..8e30ff427 100644 --- a/documentation/docs/01-users/07-adding-projects.md.license +++ b/documentation/docs/01-users/07-adding-projects.md.license @@ -1,6 +1,6 @@ SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center SPDX-FileCopyrightText: 2022 Jason Maassen (Netherlands eScience Center) -SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) +SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) SPDX-FileCopyrightText: 2024 Christian Meeßen (GFZ) SPDX-FileCopyrightText: 2024 Dusan Mijatovic (dv4all) (dv4all) SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center) diff --git a/documentation/docs/01-users/09-organisation.md b/documentation/docs/01-users/09-organisation.md index 2163fe111..e4dd94846 100644 --- a/documentation/docs/01-users/09-organisation.md +++ b/documentation/docs/01-users/09-organisation.md @@ -9,29 +9,30 @@ If you would like to claim an organisation, please have a look at [Register or c ## What can organisation maintainers do? -The RSD distinguishes between __primary__ and __invited maintainers__. +The RSD distinguishes between **primary** and **invited maintainers**. Here is an overview of what each of them can do: -| | Primary maintainer | Invited maintainer | -|----------------------------------------|:------------------:|:------------------:| -| Edit About section | x | x | -| Upload/delete logo | x | x | -| Edit organisation metadata | x | x | -| Reject affiliations (software/project) | x | x | -| Pin/unpin software/projects | x | x | -| Invite maintainers | x | x | -| Remove maintainers (not primary) | x | x | -| Add new research units | x | | -| Remove/change primary maintainer * | | | -| Change organisation URL path * | | | -| Set parent organisation * | | | -| Set as official member * | | | - -(*) These actions can only be performed by administators (see [Administration section](/rsd-instance/administration/#edit-organisation)). If necessary, contact us via [rsd@esciencecenter.nl](mailto:rsd@esciencecenter.nl). +| | Primary maintainer | Invited maintainer | +| --------------------------------------- | :----------------: | :----------------: | +| Edit About section | x | x | +| Upload/delete logo | x | x | +| Edit organisation metadata | x | x | +| Reject affiliations (software/project) | x | x | +| Pin/unpin software/projects | x | x | +| Invite maintainers | x | x | +| Remove maintainers (not primary) | x | x | +| Create and edit organisation categories | x | x | +| Add new research units | x | | +| Remove/change primary maintainer \* | | | +| Change organisation URL path \* | | | +| Set parent organisation \* | | | +| Set as official member \* | | | + +(\*) These actions can only be performed by administators (see [Administration section](/rsd-instance/administration/#edit-organisation)). If necessary, contact us via [rsd@esciencecenter.nl](mailto:rsd@esciencecenter.nl). ## My organisations -To see a list of organisations you are a maintainer of use __My organisations__ option in your __profile menu__. +To see a list of organisations you are a maintainer of use **My organisations** option in your **profile menu**. ![Screenshot of user menu](img/menu-my-organisations.webp) @@ -45,12 +46,16 @@ You can upload a new logo or remove it. The RSD supports common image formats, e.g. jpg, png or svg. :::tip -If possible, please upload an __SVG__ version of your organisation's logo for optimal visual presentation within the RSD. +If possible, please upload an **SVG** version of your organisation's logo for optimal visual presentation within the RSD. ::: -## General settings +## Settings -Under __"Settings - General settings"__ you can edit: +The organisation settings has following sections: General settings, Categories, Maintainers and About page. + +### General settings + +Under **General settings** you can edit: - Name: displayed in the organisation card and in the header of the organisation page - Short description: displayed in the organisation card and in the header of the organisation page @@ -59,15 +64,21 @@ Under __"Settings - General settings"__ you can edit: ![Screenshot of organisation settings](img/organisation-settings.webp) -## Manage maintainers +### Categories + +Each RSD organisation can define a set of custom categories to be assigned to software and/or projects. When a software/project maintainer adds this RSD organisation to their page, an additional modal is shown to software/project maintainer to assign specific organisation categories to the software/project. These organisation specific categories are shown on the software/project page. + +![Screenshot of organisation categories](img/organisation-categories.webp) + +### Manage maintainers As a maintainer, you can invite or remove other maintainers from your organisation. :::warning -The __primary maintainer__ of an organisation is set by RSD administrators. If you want to change the primary maintainer, contact us via [rsd@esciencecenter.nl](mailto:rsd@esciencecenter.nl). +The **primary maintainer** of an organisation is set by RSD administrators. If you want to change the primary maintainer, contact us via [rsd@esciencecenter.nl](mailto:rsd@esciencecenter.nl). ::: -To invite new maintainers, click on __"Generate invite link"__. A link will be generated. You can either copy this link or click on "Email this invite" to open your mail program with a pre-formulated email. +To invite new maintainers, click on **"Generate invite link"**. A link will be generated. You can either copy this link or click on "Email this invite" to open your mail program with a pre-formulated email. :::info @@ -78,10 +89,10 @@ To invite new maintainers, click on __"Generate invite link"__. A link will be g ![animation](img/organisation-maintainer-invite.gif) -## About page +### About page -In the __"About"__ section, relevant information about your organisation can be added in a free text form. -To edit the content of the section, please navigate to __"Settings"__ and scroll down to the __"About"__ section. +In the **"About"** section, relevant information about your organisation can be added in a free text form. +To edit the content of the section, please navigate to **"Settings"** and scroll down to the **"About"** section. The text can be formatted using [Markdown syntax](https://www.markdownguide.org/basic-syntax/). :::tip @@ -92,7 +103,7 @@ If there is no content for the About page, the page will not be shown. ## Pin software or project -To pin specific software or project to the start of the respective list, click on the three dots in the upper right corner of a software or project card and click on __Pin software__ / __Pin project__. It will then be shown at the beginning of the list. If you no longer want to pin a software or project, click on the dots in the software card and click __Unpin software__ / __Unpin project__. +To pin specific software or project to the start of the respective list, click on the three dots in the upper right corner of a software or project card and click on **Pin software** / **Pin project**. It will then be shown at the beginning of the list. If you no longer want to pin a software or project, click on the dots in the software card and click **Unpin software** / **Unpin project**. :::tip The "Pinned" order is the default order applied when visiting organisation page. @@ -102,7 +113,7 @@ The "Pinned" order is the default order applied when visiting organisation page. ## Deny affiliations -An organisation maintainer can deny affiliations with software or project. To do this, open the context menu of the respective software or project, and click on __Block affiliation__. +An organisation maintainer can deny affiliations with software or project. To do this, open the context menu of the respective software or project, and click on **Block affiliation**. :::tip When the affiliation with an entry has been denied @@ -112,29 +123,29 @@ When the affiliation with an entry has been denied - the entry will not be included in the metrics of the organisation - the organisation will be shown with "Blocked" icon on the edit page of the item (software or project) - the denied affiliation is visible to the organisation and the software/project maintainers -::: + ::: ![animation](img/organisation-block-affiliation.gif) ## Add research units The RSD is capable of representing the organisational structure of an organisation. -The structure is represented using __Research units__. A research unit has the same properties as an organisation and is maintained in the same manner, except for the fact that it has a parent organisation and is not listed on the organisations overview grid. +The structure is represented using **Research units**. A research unit has the same properties as an organisation and is maintained in the same manner, except for the fact that it has a parent organisation and is not listed on the organisations overview grid. It is possible to create several levels of nested research units. :::warning -Research units can only be added by the __primary maintainer__. +Research units can only be added by the **primary maintainer**. ::: -To create new research units, navigate to the __Research units__ in the sidebar, and click on the __+ Add__ button in the upper right corner. You will be presented with a modal: +To create new research units, navigate to the **Research units** in the sidebar, and click on the **+ Add** button in the upper right corner. You will be presented with a modal: ![Screenshot](img/organisation-add-unit.webp) The RSD path (second input field) will be automatically populated while the name is ist typed, but can be adjusted afterwards. :::warning -You cannot edit the __RSD Path__ afterwards. +You cannot edit the **RSD Path** afterwards. ::: If possible, a logo and a website URL can be added as well. diff --git a/documentation/docs/01-users/09-organisation.md.license b/documentation/docs/01-users/09-organisation.md.license index 75111bbfb..0ccc9eb34 100644 --- a/documentation/docs/01-users/09-organisation.md.license +++ b/documentation/docs/01-users/09-organisation.md.license @@ -1,6 +1,6 @@ +SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) SPDX-FileCopyrightText: 2023 - 2024 Ewan Cahen (Netherlands eScience Center) SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center -SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) SPDX-FileCopyrightText: 2024 Christian Meeßen (GFZ) SPDX-FileCopyrightText: 2024 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences diff --git a/documentation/docs/01-users/img/organisation-categories.webp b/documentation/docs/01-users/img/organisation-categories.webp new file mode 100644 index 000000000..e28c7146e Binary files /dev/null and b/documentation/docs/01-users/img/organisation-categories.webp differ diff --git a/documentation/docs/01-users/img/organisation-categories.webp.license b/documentation/docs/01-users/img/organisation-categories.webp.license new file mode 100644 index 000000000..ebbf407d4 --- /dev/null +++ b/documentation/docs/01-users/img/organisation-categories.webp.license @@ -0,0 +1,4 @@ +SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +SPDX-FileCopyrightText: 2024 Netherlands eScience Center + +SPDX-License-Identifier: CC-BY-4.0 diff --git a/documentation/docs/01-users/img/organisation-settings.webp b/documentation/docs/01-users/img/organisation-settings.webp index a60ac25cd..94a8e7250 100644 Binary files a/documentation/docs/01-users/img/organisation-settings.webp and b/documentation/docs/01-users/img/organisation-settings.webp differ diff --git a/documentation/docs/01-users/img/organisation-settings.webp.license b/documentation/docs/01-users/img/organisation-settings.webp.license index 09eedce0c..00618c563 100644 --- a/documentation/docs/01-users/img/organisation-settings.webp.license +++ b/documentation/docs/01-users/img/organisation-settings.webp.license @@ -1,4 +1,4 @@ -SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) -SPDX-FileCopyrightText: 2023 Netherlands eScience Center +SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center SPDX-License-Identifier: CC-BY-4.0 diff --git a/frontend/auth/permissions/isMaintainerOfOrganisation.ts b/frontend/auth/permissions/isMaintainerOfOrganisation.ts index 57b9d7177..15b828598 100644 --- a/frontend/auth/permissions/isMaintainerOfOrganisation.ts +++ b/frontend/auth/permissions/isMaintainerOfOrganisation.ts @@ -1,12 +1,14 @@ // SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 - 2023 dv4all -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2023 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 -import {createJsonHeaders, getBaseUrl} from '../../utils/fetchHelpers' -import logger from '../../utils/logger' +import {OrganisationsOfProject} from '~/types/Project' +import {EditOrganisation, OrganisationsForSoftware} from '~/types/Organisation' +import {createJsonHeaders, getBaseUrl} from '~/utils/fetchHelpers' +import logger from '~/utils/logger' import {RsdRole} from '../index' type IsOrganisationMaintainerProps = { @@ -92,5 +94,65 @@ export async function getMaintainerOrganisations({token}: } } +type CanEditOrganisationsProps={ + account?: string + token?: string + organisations: OrganisationsOfProject[]| OrganisationsForSoftware[] +} + +export async function canEditOrganisations({organisations,account,token}:CanEditOrganisationsProps){ + try{ + // collect isMaintainerRequests + const promises:Promise[] = [] + // prepare organisation list + const orgList = organisations.map((item, pos) => { + // save isMaintainer request + promises.push(isMaintainerOfOrganisation({ + organisation: item.id, + account, + token + })) + // extract only needed props + const org: 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 + } + return org + }) + // run all isMaintainer requests in parallel + const isMaintainer = await Promise.all(promises) + const canEditOrganisations = orgList.map((item, pos) => { + // update canEdit based on isMaintainer requests + if (isMaintainer[pos]) item.canEdit = isMaintainer[pos] + return item + }) + return canEditOrganisations + }catch(e:any){ + logger(`canEditOrganisations: ${e.message}`, 'error') + // on error all items set to false + return organisations.map((item, pos) => { + // extract only needed props + const org: 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 + } + return org + }) + } +} export default isMaintainerOfOrganisation diff --git a/frontend/components/admin/categories/index.tsx b/frontend/components/admin/categories/index.tsx index 6df0e080c..a62c143a5 100644 --- a/frontend/components/admin/categories/index.tsx +++ b/frontend/components/admin/categories/index.tsx @@ -27,6 +27,7 @@ export default function AdminCategories() { } diff --git a/frontend/components/category/CategoriesDialog.tsx b/frontend/components/category/CategoriesDialog.tsx new file mode 100644 index 000000000..40bac32ad --- /dev/null +++ b/frontend/components/category/CategoriesDialog.tsx @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import Dialog from '@mui/material/Dialog' +import DialogContent from '@mui/material/DialogContent' +import DialogTitle from '@mui/material/DialogTitle' +import 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 {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' + +type CategoriesDialogProps={ + title: string, + categories: TreeNode[], + selected: Set, + state: 'loading' | 'error' | 'ready' | 'saving', + errorMsg: string | null + noItemsMsg: string + onCancel: ()=>void, + onSave: (selected:Set)=>void +} + +export default function CategoriesDialog({ + title,categories,selected, + state,errorMsg,noItemsMsg, + onCancel,onSave +}:CategoriesDialogProps) { + const smallScreen = useMediaQuery('(max-width:600px)') + const [selectedCategoryIds, setSelectedCategoryIds] = useState>(new Set()) + + // console.group('CategoriesDialog') + // console.log('state...', state) + // console.log('selected...', selected) + // console.log('selectedCategoryIds...',selectedCategoryIds) + // console.groupEnd() + + useEffect(()=>{ + if (state==='ready'){ + setSelectedCategoryIds(selected) + } + },[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 ( + + {title} + + + {renderDialogContent()} + + + + + + + + ) +} diff --git a/frontend/components/category/CategoriesWithHeadlines.tsx b/frontend/components/category/CategoriesWithHeadlines.tsx index 1bbe75145..0bd4f739c 100644 --- a/frontend/components/category/CategoriesWithHeadlines.tsx +++ b/frontend/components/category/CategoriesWithHeadlines.tsx @@ -1,5 +1,6 @@ // SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) // SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) // SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center) // SPDX-FileCopyrightText: 2024 Netherlands eScience Center // @@ -7,8 +8,8 @@ import React from 'react' import {useCategoryTree} from '~/utils/categories' -import {SidebarHeadline} from '../typography/SidebarHeadline' import {CategoryPath} from '~/types/Category' +import {SidebarHeadline} from '~/components/typography/SidebarHeadline' import {CategoryTreeLevel} from '~/components/category/CategoryTree' type CategoriesWithHeadlinesProps = { diff --git a/frontend/components/category/CategoryChipFilter.tsx b/frontend/components/category/CategoryChipFilter.tsx new file mode 100644 index 000000000..5ef350919 --- /dev/null +++ b/frontend/components/category/CategoryChipFilter.tsx @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {Fragment} from 'react' +import {CategoryEntry, CategoryPath} from '~/types/Category' +import {TreeNode} from '~/types/TreeNode' +import TagChipFilter from '~/components/layout/TagChipFilter' + +export function CategoryChipFilter({nodes}:{nodes:TreeNode[]}){ + return nodes.map(node=>{ + const cat = node.getValue() + const children = node.children() + return ( + + + + + ) + }) +} + diff --git a/frontend/components/category/CategoryEditForm.tsx b/frontend/components/category/CategoryEditForm.tsx index f4299ba1e..d649a9ccc 100644 --- a/frontend/components/category/CategoryEditForm.tsx +++ b/frontend/components/category/CategoryEditForm.tsx @@ -15,16 +15,20 @@ import {CategoryEntry} from '~/types/Category' import {createJsonHeaders} from '~/utils/fetchHelpers' import useSnackbar from '~/components/snackbar/useSnackbar' import TextFieldWithCounter from '~/components/form/TextFieldWithCounter' +import ControlledSwitch from '../form/ControlledSwitch' type CategoryEditFormProps=Readonly<{ createNew: boolean data: CategoryEntry | null community: string | null + organisation: string | null onSuccess: (category:CategoryEntry)=>void onCancel: ()=>void }> -export default function CategoryEditForm({createNew, data, community, onSuccess, onCancel}:CategoryEditFormProps) { +export default function CategoryEditForm({ + createNew, data, community=null,organisation=null,onSuccess, onCancel +}:CategoryEditFormProps) { const {token} = useSession() const {showErrorMessage} = useSnackbar() const [disableSave, setDisableSave] = useState(false) @@ -32,23 +36,36 @@ export default function CategoryEditForm({createNew, data, community, onSuccess, mode: 'onChange' }) + // use id of current item as parentId of new (child) item + const parentId = createNew ? data?.id : data?.parent + // 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('parentId...',parentId) // console.groupEnd() - const onSubmit = (formData: CategoryEntry) => { + function onSubmit(formData: CategoryEntry){ setDisableSave(true) // debugger if (createNew) { - createNewCategory(formData) + createNewCategory(prepareDataForSave(formData)) } else { - updateCategory(formData) + updateCategory(prepareDataForSave(formData)) } } + function prepareDataForSave(formData: CategoryEntry){ + // fix provenance_iri empty value + if (formData.provenance_iri===''){ + formData.provenance_iri = null + } + return formData + } + async function createNewCategory(formData: CategoryEntry) { const resp = await fetch('/api/v1/category', { method: 'POST', @@ -56,7 +73,6 @@ export default function CategoryEditForm({createNew, data, community, onSuccess, ...createJsonHeaders(token), Prefer: 'return=representation', Accept: 'application/vnd.pgrst.object+json' - }, body: JSON.stringify(formData) }) @@ -119,14 +135,15 @@ export default function CategoryEditForm({createNew, data, community, onSuccess, {/* Different hidden values when creating new item.*/} {createNew ? // use id of current item as parent for new (child) item - + : <> - - + + } +

{getFormTitle()}

@@ -156,6 +173,7 @@ export default function CategoryEditForm({createNew, data, community, onSuccess, error: formState.errors?.name?.message !== undefined }} /> + - {/* Highlight options are only for the top level items and for general categories */} - {((createNew && data === null && community===null) || - (!createNew && data?.parent === null && community===null)) ? + {/* + Organisation categories can be used for software or project items + We show software/project switch only at top level (root nodes) + */} + { + organisation && !parentId ? +
+ + +
+ : + <> + {/* + for children nodes we use false as default value + */} + + + + } + + {/* Highlight options are only for the top level items of general categories */} + {((createNew && data === null && community===null && organisation===null) || + (!createNew && data?.parent === null && community===null && organisation===null)) ? <>
[], community: string | null + organisation: string | null onMutation: ()=>void title?:string }> -export default function CategoryEditTree({roots, community, title, onMutation}:CategoryEditTreeProps) { +export default function CategoryEditTree({roots, community, organisation, title, onMutation}:CategoryEditTreeProps) { const [showAddChildForm, setShowAddChildForm] = useState(false) @@ -61,6 +62,7 @@ export default function CategoryEditTree({roots, community, title, onMutation}:C setShowAddChildForm(false)} @@ -74,6 +76,7 @@ export default function CategoryEditTree({roots, community, title, onMutation}:C key={node.getValue().id} node={node} community={community} + organisation={organisation} onDelete={onDeleteChild} onMutation={onMutation} /> diff --git a/frontend/components/category/CategoryEditTreeNode.tsx b/frontend/components/category/CategoryEditTreeNode.tsx index 99d16b252..00ebf8f80 100644 --- a/frontend/components/category/CategoryEditTreeNode.tsx +++ b/frontend/components/category/CategoryEditTreeNode.tsx @@ -26,9 +26,10 @@ import CategoryEditForm from '~/components/category/CategoryEditForm' import useSnackbar from '~/components/snackbar/useSnackbar' import ConfirmDeleteModal from '~/components/layout/ConfirmDeleteModal' -export default function CategoryEditTreeNode({node, community, onDelete, onMutation}: Readonly<{ +export default function CategoryEditTreeNode({node, community, organisation, onDelete, onMutation}: Readonly<{ node: TreeNode community: string | null + organisation: string | null onDelete: (node: TreeNode) => void onMutation: ()=>void }>) { @@ -152,6 +153,7 @@ export default function CategoryEditTreeNode({node, community, onDelete, onMutat setShowItem('none')} @@ -162,6 +164,7 @@ export default function CategoryEditTreeNode({node, community, onDelete, onMutat setShowItem('none')} @@ -176,6 +179,7 @@ export default function CategoryEditTreeNode({node, community, onDelete, onMutat key={child.getValue().id} node={child} community={community} + organisation={organisation} onDelete={onDeleteChild} onMutation={onMutation} /> diff --git a/frontend/components/category/CategoryTree.tsx b/frontend/components/category/CategoryTree.tsx index e6f0e12bd..29e35e3f4 100644 --- a/frontend/components/category/CategoryTree.tsx +++ b/frontend/components/category/CategoryTree.tsx @@ -9,13 +9,13 @@ import CancelIcon from '@mui/icons-material/Cancel' import Tooltip from '@mui/material/Tooltip' import IconButton from '@mui/material/IconButton' -import {CategoryEntry, CategoryID} from '~/types/Category' +import {CategoryEntry} from '~/types/Category' import {TreeNode} from '~/types/TreeNode' export type CategoryTreeLevelProps = { items: TreeNode[] showLongNames?: boolean - onRemove?: (categoryId: CategoryID) => void + onRemove?: (categoryId: string) => void } export const CategoryTreeLevel = ({onRemove, ...props}: CategoryTreeLevelProps) => { 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 e6001048b..d5571f03b 100644 --- a/frontend/components/category/apiCategories.ts +++ b/frontend/components/category/apiCategories.ts @@ -4,15 +4,38 @@ // // SPDX-License-Identifier: Apache-2.0 -import {CategoryEntry, CategoryID} from '~/types/Category' +import {CategoryEntry} from '~/types/Category' import {getBaseUrl} from '~/utils/fetchHelpers' import {TreeNode} from '~/types/TreeNode' -export async function loadCategoryRoots(community: string | null): Promise[]> { +type LoadCategoryProps={ + community?: string | null, + organisation?: string | null, + allow_software?: boolean, + allow_projects?: boolean +} - const communityFilter = community === null ? 'community=is.null' : `community=eq.${community}` +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 + if (community){ + categoryFilter = `community=eq.${community}` + } + // organisation filter + 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?${communityFilter}`) + const resp = await fetch(`${getBaseUrl()}/category?${categoryFilter}`) if (!resp.ok) { throw new Error(`${await resp.text()}`) @@ -25,8 +48,8 @@ export async function loadCategoryRoots(community: string | null): Promise[] { - const idToNode: Map> = new Map() - const idToChildren: Map[]> = new Map() + const idToNode: Map> = new Map() + const idToChildren: Map[]> = new Map() for (const cat of categoriesArr) { const id = cat.id diff --git a/frontend/components/category/useCategories.ts b/frontend/components/category/useCategories.ts index fb697c2f3..eb8fbd345 100644 --- a/frontend/components/category/useCategories.ts +++ b/frontend/components/category/useCategories.ts @@ -6,12 +6,17 @@ import {useEffect, useState} from 'react' +import logger from '~/utils/logger' import {TreeNode} from '~/types/TreeNode' import {CategoryEntry} from '~/types/Category' import {loadCategoryRoots} from '~/components/category/apiCategories' -import logger from '~/utils/logger' -export default function useCategories({community}:{community:string|null}){ +type UseCategoriesProps={ + community?:string|null, + organisation?:string|null +} + +export default function useCategories({community,organisation}:UseCategoriesProps){ const [roots, setRoots] = useState[] | null> (null) const [error, setError] = useState (null) const [loading, setLoading] = useState (true) @@ -19,7 +24,7 @@ export default function useCategories({community}:{community:string|null}){ useEffect(() => { let abort: boolean = false // only if there is community value - loadCategoryRoots(community) + loadCategoryRoots({community,organisation}) .then(roots => { if (abort) return setRoots(roots) @@ -37,7 +42,7 @@ export default function useCategories({community}:{community:string|null}){ }) return ()=>{abort=true} - }, [community]) + }, [community,organisation]) function onMutation() { if (roots !== null) { diff --git a/frontend/components/communities/settings/categories/index.tsx b/frontend/components/communities/settings/categories/index.tsx index 52caaa735..f0fabf1f7 100644 --- a/frontend/components/communities/settings/categories/index.tsx +++ b/frontend/components/communities/settings/categories/index.tsx @@ -37,6 +37,7 @@ export default function CommunityCategories() { title="Categories" roots={roots} community={community.id} + organisation={null} onMutation={onMutation} /> } diff --git a/frontend/components/layout/SidebarPanel.tsx b/frontend/components/layout/SidebarPanel.tsx new file mode 100644 index 000000000..bb187884d --- /dev/null +++ b/frontend/components/layout/SidebarPanel.tsx @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +export default function SidebarPanel({children,className}:{children:any,className?:string}) { + return ( + + ) +} diff --git a/frontend/components/layout/SidebarSection.tsx b/frontend/components/layout/SidebarSection.tsx new file mode 100644 index 000000000..79b92ce98 --- /dev/null +++ b/frontend/components/layout/SidebarSection.tsx @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +export default function SidebarSection({children,className}:{children:any,className?:string}) { + + if (className){ + return ( +
+ {children} +
+ ) + } + + return ( +
+ {children} +
+ ) +} diff --git a/frontend/components/layout/SidebarTitle.tsx b/frontend/components/layout/SidebarTitle.tsx new file mode 100644 index 000000000..dfc4f6e62 --- /dev/null +++ b/frontend/components/layout/SidebarTitle.tsx @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +export default function SidebarTitle({children,className}:{children:any,className?:string}) { + return ( +
+ {children} +
+ ) +} diff --git a/frontend/components/layout/SortableListItemActions.tsx b/frontend/components/layout/SortableListItemActions.tsx index cf918ecd6..661f537be 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/layout/TagChipFilter.tsx b/frontend/components/layout/TagChipFilter.tsx index 2cc87e1c5..abd9d8199 100644 --- a/frontend/components/layout/TagChipFilter.tsx +++ b/frontend/components/layout/TagChipFilter.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,10 +10,30 @@ import SearchIcon from '@mui/icons-material/Search' import Link from 'next/link' export default function TagChipFilter({url, label, title}: - { label: string, url:string ,title?: string }) { - + {label: string, url?:string ,title?: string } +){ + // if no label no chip if (!label) return null + // simple chip without link + if (!url) return ( + + ) + return ( , status: 'Organisation details' }, + { + id:'categories', + label:()=>'Categories', + icon: , + status: 'Define categories', + }, { id:'maintainers', label:()=>'Maintainers', diff --git a/frontend/components/organisation/settings/SettingsPageContent.tsx b/frontend/components/organisation/settings/SettingsPageContent.tsx index 6a5735a09..4f41e00f5 100644 --- a/frontend/components/organisation/settings/SettingsPageContent.tsx +++ b/frontend/components/organisation/settings/SettingsPageContent.tsx @@ -1,12 +1,13 @@ -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2023 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 +import {useRouter} from 'next/router' import OrganisationSettingsAboutPage from './about-page' import OrganisationMaintainers from './maintainers' import OrganisationGeneralSettings from './general' -import {useRouter} from 'next/router' +import OrganisationCategories from './categories' export default function SettingsPageContent() { @@ -18,6 +19,8 @@ export default function SettingsPageContent() { return case 'maintainers': return + case 'categories': + return default: return } diff --git a/frontend/components/organisation/settings/categories/index.tsx b/frontend/components/organisation/settings/categories/index.tsx new file mode 100644 index 000000000..21c6a9313 --- /dev/null +++ b/frontend/components/organisation/settings/categories/index.tsx @@ -0,0 +1,46 @@ +// 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 Alert from '@mui/material/Alert' +import CircularProgress from '@mui/material/CircularProgress' +import CategoryEditTree from '~/components/category/CategoryEditTree' +import BaseSurfaceRounded from '~/components/layout/BaseSurfaceRounded' +import useCategories from '~/components/category/useCategories' +import useOrganisationContext from '~/components/organisation/context/useOrganisationContext' + +export default function OrganisationCategories() { + const {id} = useOrganisationContext() + const {loading,error,roots,onMutation} = useCategories({organisation:id}) + + if (loading) { + return ( + + + + ) + } + + return ( + + {error && {error}} + {roots && + + } + + ) +} diff --git a/frontend/components/projects/ProjectCategories.tsx b/frontend/components/projects/ProjectCategories.tsx new file mode 100644 index 000000000..17ec98012 --- /dev/null +++ b/frontend/components/projects/ProjectCategories.tsx @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {CategoryPath} from '~/types/Category' +import {useCategoryTree} from '~/utils/categories' +import SidebarSection from '../layout/SidebarSection' +import SidebarTitle from '../layout/SidebarTitle' +import {CategoryChipFilter} from '../category/CategoryChipFilter' + +export default function ProjectCategories({categories}:{categories:CategoryPath[]}) { + const tree = useCategoryTree(categories) + + // each root category is separate sidebar section + return tree.map(node => { + const category = node.getValue() + const children = node.children() + + return ( + + {category.name} +
+ +
+
+ ) + }) +} diff --git a/frontend/components/projects/ProjectFunding.tsx b/frontend/components/projects/ProjectFunding.tsx index 44449a8ca..7944f6c02 100644 --- a/frontend/components/projects/ProjectFunding.tsx +++ b/frontend/components/projects/ProjectFunding.tsx @@ -1,57 +1,52 @@ // 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 Link from 'next/link' import {ProjectOrganisationProps} from '~/types/Organisation' +import ProjectSidebarTitle from '~/components/layout/SidebarTitle' +import ProjectSidebarSection from '../layout/SidebarSection' -export default function ProjectFunding({grant_id, fundingOrganisations=[]}: - { grant_id: string | null, fundingOrganisations:ProjectOrganisationProps[] }) { - - function renderFundedUnder() { - if (grant_id) { - return ( - <> -
Funded under
-
Grant ID: {grant_id}
- - ) - } - return null - } - - function renderFundedBy() { - if (fundingOrganisations && fundingOrganisations.length > 0) { - return ( - <> -
Funded by
-
    - {fundingOrganisations.map(item => { - const link = `/organisations/${item.rsd_path}` - return ( -
  • - - {item.name} - -
  • - ) - })} -
- - ) - } - return null - } + +export function FundedUnder({grant_id}:{grant_id:string|null}){ + + if (!grant_id) return null return ( -
- {renderFundedUnder()} - {renderFundedBy()} -
+ + Funded under +
Grant ID: {grant_id}
+
) } + +export function FundedBy({fundingOrganisations}:{fundingOrganisations:ProjectOrganisationProps[]}){ + + if (!fundingOrganisations || fundingOrganisations.length===0) return null + + return ( + + Funded by +
    + {fundingOrganisations.map(item => { + const link = `/organisations/${item.rsd_path}` + return ( +
  • + + {item.name} + +
  • + ) + })} +
+
+ ) +} + diff --git a/frontend/components/projects/ProjectInfo.tsx b/frontend/components/projects/ProjectInfo.tsx index dea730f0b..5d59853e1 100644 --- a/frontend/components/projects/ProjectInfo.tsx +++ b/frontend/components/projects/ProjectInfo.tsx @@ -1,12 +1,13 @@ // SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 dv4all -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2023 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 import {ProjectOrganisationProps} from '~/types/Organisation' import {KeywordForProject, ProjectLink, ResearchDomain} from '~/types/Project' +import {CategoryPath} from '~/types/Category' import ProjectDescription from './ProjectDescription' import ProjectSidebar from './ProjectSidebar' @@ -22,13 +23,18 @@ type ProjectInfoProps = { researchDomains: ResearchDomain[], keywords: KeywordForProject[], fundingOrganisations: ProjectOrganisationProps[], + categories: CategoryPath[] } -export default function ProjectInfo( - {image_id, image_caption, image_contain, description, date_start, date_end, - grant_id, links, researchDomains, keywords, fundingOrganisations}: ProjectInfoProps -) { +export default function ProjectInfo({ + image_id, image_caption, + image_contain, description, + date_start, date_end, + grant_id, links, researchDomains, + keywords, fundingOrganisations, + categories +}: ProjectInfoProps) { return (
) diff --git a/frontend/components/projects/ProjectKeywords.tsx b/frontend/components/projects/ProjectKeywords.tsx index 2753a916a..3d8200893 100644 --- a/frontend/components/projects/ProjectKeywords.tsx +++ b/frontend/components/projects/ProjectKeywords.tsx @@ -1,13 +1,16 @@ // SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 - 2023 dv4all +// SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center // SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2022 Netherlands eScience Center +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) // // SPDX-License-Identifier: Apache-2.0 import {KeywordForProject} from '~/types/Project' import {ssrProjectsUrl} from '~/utils/postgrestUrl' import TagChipFilter from '../layout/TagChipFilter' +import ProjectSidebarSection from '../layout/SidebarSection' +import ProjectSidebarTitle from '../layout/SidebarTitle' export default function ProjectKeywords({keywords=[]}:{keywords:KeywordForProject[]}) { @@ -28,11 +31,9 @@ export default function ProjectKeywords({keywords=[]}:{keywords:KeywordForProjec } return ( -
-
- Keywords -
+ + Keywords {renderTags()} -
+ ) } diff --git a/frontend/components/projects/ProjectLinks.tsx b/frontend/components/projects/ProjectLinks.tsx index 177bb8ee0..4f61014e8 100644 --- a/frontend/components/projects/ProjectLinks.tsx +++ b/frontend/components/projects/ProjectLinks.tsx @@ -1,24 +1,30 @@ // 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 Link from 'next/link' -import {ProjectLink} from '../../types/Project' +import OpenInNewIcon from '@mui/icons-material/OpenInNew' + +import {ProjectLink} from '~/types/Project' +import ProjectSidebarSection from '../layout/SidebarSection' +import ProjectSidebarTitle from '../layout/SidebarTitle' export default function ProjectLinks({links}: { links: ProjectLink[] }) { if (!links || links?.length === 0) { return ( -
-
Project links
+ + Project links Not specified -
+ ) } return ( -
-
Project links
+ + Project links
    { links.map(link => { @@ -29,7 +35,13 @@ export default function ProjectLinks({links}: { links: ProjectLink[] }) { href={link.url} target="_blank" passHref + className='flex gap-2 items-center' > + {link.title} @@ -40,6 +52,6 @@ export default function ProjectLinks({links}: { links: ProjectLink[] }) { }) }
-
+ ) } diff --git a/frontend/components/projects/ProjectSidebar.tsx b/frontend/components/projects/ProjectSidebar.tsx index 21e4bcfa7..78ffbfa2a 100644 --- a/frontend/components/projects/ProjectSidebar.tsx +++ b/frontend/components/projects/ProjectSidebar.tsx @@ -1,18 +1,21 @@ -// SPDX-FileCopyrightText: 2022 - 2023 Netherlands eScience Center +// SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center // SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center) // SPDX-FileCopyrightText: 2022 dv4all -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) // // SPDX-License-Identifier: Apache-2.0 import {KeywordForProject, ProjectLink, ResearchDomain} from '~/types/Project' import {ProjectOrganisationProps} from '~/types/Organisation' +import {CategoryPath} from '~/types/Category' +import SidebarPanel from '~/components/layout/SidebarPanel' import ProjectStatus from './ProjectStatus' -import ProjectFunding from './ProjectFunding' +import {FundedBy, FundedUnder} from './ProjectFunding' import ProjectLinks from './ProjectLinks' import ProjectKeywords from './ProjectKeywords' import ResearchDomains from './ResearchDomains' +import ProjectCategories from './ProjectCategories' type ProjectSidebarProps = { date_start: string | null @@ -20,23 +23,27 @@ type ProjectSidebarProps = { grant_id: string | null researchDomains: ResearchDomain[], keywords: KeywordForProject[], - links: ProjectLink[] - fundingOrganisations: ProjectOrganisationProps[] + links: ProjectLink[], + fundingOrganisations: ProjectOrganisationProps[], + categories: CategoryPath[] } export default function ProjectSidebar({date_start, date_end, grant_id, links, researchDomains, - keywords, fundingOrganisations}: ProjectSidebarProps) { + keywords, categories, fundingOrganisations}: ProjectSidebarProps) { return ( - + + + ) } diff --git a/frontend/components/projects/ProjectStatus.tsx b/frontend/components/projects/ProjectStatus.tsx index 46fa80efc..01b88e45d 100644 --- a/frontend/components/projects/ProjectStatus.tsx +++ b/frontend/components/projects/ProjectStatus.tsx @@ -1,14 +1,16 @@ // SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 - 2023 dv4all // SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) (dv4all) -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2023 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 import {getMonthYearDate} from '~/utils/dateFn' import {getProjectStatus} from '~/utils/getProjects' import PeriodProgressBar from '~/components/charts/progress/PeriodProgressBar' +import SidebarTitle from '~/components/layout/SidebarTitle' +import ProjectSidebarSection from '../layout/SidebarSection' type ProjectStatusProps = { date_start: string | null, @@ -25,8 +27,8 @@ export default function ProjectStatus({date_start, date_end}: ProjectStatusProps }) return ( -
-
Status
+ + Status {getMonthYearDate(date_end)}
{status}
-
+ ) } diff --git a/frontend/components/projects/ResearchDomains.tsx b/frontend/components/projects/ResearchDomains.tsx index 972dd5534..ca001fc59 100644 --- a/frontend/components/projects/ResearchDomains.tsx +++ b/frontend/components/projects/ResearchDomains.tsx @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 - 2023 dv4all +// SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center // SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2022 Netherlands eScience Center +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) // // SPDX-License-Identifier: Apache-2.0 @@ -9,7 +10,9 @@ import Link from '@mui/material/Link' import LinkIcon from '@mui/icons-material/Link' import {ResearchDomain} from '~/types/Project' import {ssrProjectsUrl} from '~/utils/postgrestUrl' -import TagChipFilter from '../layout/TagChipFilter' +import TagChipFilter from '~/components/layout/TagChipFilter' +import ProjectSidebarSection from '~/components/layout/SidebarSection' +import ProjectSidebarTitle from '~/components/layout/SidebarTitle' export default function ResearchDomains({domains}:{domains:ResearchDomain[]}) { @@ -31,8 +34,8 @@ export default function ResearchDomains({domains}:{domains:ResearchDomain[]}) { } return ( -
-
+ + Research domains -
+ + {renderTags()} -
+ ) } diff --git a/frontend/components/projects/edit/organisations/EditProjectOrganisationsIndex.test.tsx b/frontend/components/projects/edit/organisations/EditProjectOrganisationsIndex.test.tsx index d7998f3ce..0407f45d8 100644 --- a/frontend/components/projects/edit/organisations/EditProjectOrganisationsIndex.test.tsx +++ b/frontend/components/projects/edit/organisations/EditProjectOrganisationsIndex.test.tsx @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) (dv4all) // SPDX-FileCopyrightText: 2022 - 2023 dv4all // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 @@ -17,11 +19,12 @@ import editProjectState from '../__mocks__/editProjectState' import mockOrganisationsOfProject from './__mocks__/organisationsOfProject.json' // 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)), + canEditOrganisations: jest.fn(({organisations,...other})=>organisations) })) // MOCK getOrganisationsOfProject @@ -261,48 +264,6 @@ describe('frontend/components/projects/edit/organisations/index.tsx', () => { }) }) - - it('maintainer of organisation can edit organisation', async() => { - // mock organisations response - mockGetOrganisationsOfProject.mockResolvedValueOnce(mockOrganisationsOfProject as any) - mockIsMainatainerOfOrganisation.mockResolvedValueOnce(true) - mockIsMainatainerOfOrganisation.mockResolvedValueOnce(false) - - render( - - - - - - ) - - // wait for loader to be removed - await waitForElementToBeRemoved(screen.getByRole('progressbar')) - - // render first project organisation with edit button - const editBtns = screen.getAllByTestId('EditIcon') - expect(editBtns.length).toEqual(1) - - // click on edit button - fireEvent.click(editBtns[0]) - - const modal = await screen.findByRole('dialog') - - // validate organisation name - const name = within(modal).getByRole('textbox', { - name: config.name.label - }) - expect(name).toHaveValue(mockOrganisationsOfProject[0].name) - - // cancel - const cancelBtn = within(modal).getByRole('button', { - name: 'Cancel' - }) - fireEvent.click(cancelBtn) - // modal should not be visible - expect(modal).not.toBeVisible() - }) - it('can remove organisation from project', async() => { // mock organisations response mockGetOrganisationsOfProject.mockResolvedValueOnce(mockOrganisationsOfProject as any) @@ -354,5 +315,43 @@ describe('frontend/components/projects/edit/organisations/index.tsx', () => { expect(remained.length).toEqual(mockOrganisationsOfProject.length-1) }) }) + + it('shows organisation categories modal',async()=>{ + // mock organisations response + mockGetOrganisationsOfProject.mockResolvedValueOnce(mockOrganisationsOfProject as any) + + render( + + + + + + ) + + // wait for loader to be removed + await waitForElementToBeRemoved(screen.getByRole('progressbar')) + + // renders project organisations + const organisations = screen.getAllByTestId('organisation-list-item') + expect(organisations.length).toEqual(mockOrganisationsOfProject.length) + + // get edit categories button from first organisation + const categoriesBtn = within(organisations[0]).getByRole('button', { + name: 'edit categories' + }) + // click edit categories + fireEvent.click(categoriesBtn) + + // get organisation categories modal + const modal = screen.getByRole('dialog') + + // close modal + const cancelBtn = within(modal).getByRole('button', { + name: 'Cancel' + }) + fireEvent.click(cancelBtn) + // confirm modal closed + expect(modal).not.toBeInTheDocument() + }) }) diff --git a/frontend/components/projects/edit/organisations/ProjectCategoriesDialog.tsx b/frontend/components/projects/edit/organisations/ProjectCategoriesDialog.tsx new file mode 100644 index 000000000..87cac0e1f --- /dev/null +++ b/frontend/components/projects/edit/organisations/ProjectCategoriesDialog.tsx @@ -0,0 +1,60 @@ +// 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 {EditOrganisation} from '~/types/Organisation' +import CategoriesDialog from '~/components/category/CategoriesDialog' +import useProjectCategories from './useProjectCategories' + +export type OrganisationCategoriesDialogProps = Readonly<{ + projectId: string + organisation: EditOrganisation + onCancel: () => void + onComplete: () => void + edit: boolean +}> + +export default function ProjectCategoriesDialog({ + projectId, organisation, edit, + onCancel, onComplete +}: OrganisationCategoriesDialogProps) { + + const { + categories,selectedIds, + state,error, + saveOrganisationCategories + } = useProjectCategories({ + organisationId:organisation.id, + projectId + }) + + 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 + saveOrganisationCategories(selected,onComplete) + }} + /> + ) +} diff --git a/frontend/components/projects/edit/organisations/apiProjectOrganisations.ts b/frontend/components/projects/edit/organisations/apiProjectOrganisations.ts new file mode 100644 index 000000000..4dca5b478 --- /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(`getCategoryListForProject: ${resp.status} [${url}]`, 'error') + throw new Error('Couldn\'t load the categories for this project') + } + } catch (e: any) { + logger(`getCategoryListForProject: ${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..f71d272cd 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 ProjectCategoriesDialog from './ProjectCategoriesDialog' 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, + edit: false + } + }) } 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 + edit: true + } + }) + } + } + 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/projects/edit/organisations/useParticipatingOrganisations.ts b/frontend/components/projects/edit/organisations/useParticipatingOrganisations.ts index 792db4a10..d5929fd5b 100644 --- a/frontend/components/projects/edit/organisations/useParticipatingOrganisations.ts +++ b/frontend/components/projects/edit/organisations/useParticipatingOrganisations.ts @@ -1,10 +1,12 @@ // 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 isMaintainerOfOrganisation, {canEditOrganisations} from '~/auth/permissions/isMaintainerOfOrganisation' import {EditOrganisation} from '~/types/Organisation' import {getOrganisationsOfProject} from '~/utils/getProjects' @@ -31,37 +33,11 @@ export function useParticipatingOrganisations({project, token, account}: UsePart frontend: true, roles:['participating','hosting'] }) - // 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 + // convert to EditOrganisation type and add canEdit flag + const organisations = await canEditOrganisations({ + organisations: resp, + account, + token }) if (abort === true) return // update organisation list @@ -71,7 +47,7 @@ export function useParticipatingOrganisations({project, token, account}: UsePart id: project, account }) - // upadate loading state + // update loading state setLoading(false) } if (project && token && account && diff --git a/frontend/components/projects/edit/organisations/useProjectCategories.tsx b/frontend/components/projects/edit/organisations/useProjectCategories.tsx new file mode 100644 index 000000000..9fc69a0af --- /dev/null +++ b/frontend/components/projects/edit/organisations/useProjectCategories.tsx @@ -0,0 +1,132 @@ +// 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 {createJsonHeaders, getBaseUrl} from '~/utils/fetchHelpers' +import {CategoryEntry} from '~/types/Category' +import {TreeNode} from '~/types/TreeNode' +import {loadCategoryRoots} from '~/components/category/apiCategories' +import {getCategoryListForProject, removeOrganisationCategoriesFromProject} from './apiProjectOrganisations' + +type UseProjectOrganisationCategoriesProps={ + organisationId:string|null, + projectId:string +} + +type ProjectCategory = { + project_id: string, + category_id: string +} + +export default function useProjectCategories({ + organisationId,projectId +}:UseProjectOrganisationCategoriesProps){ + const {token} = useSession() + const [categories, setCategories] = useState[] | null>(null) + const [error, setError] = useState(null) + const [state, setState] = useState<'loading' | 'error' | 'ready' | 'saving'>('loading') + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [availableIds, setAvailableIds] = useState>(new Set()) + + // console.group('useProjectOrganisationCategories') + // console.log('state...',state) + // console.log('categories...', categories) + // console.groupEnd() + + useEffect(() => { + let abort = false + if (organisationId && projectId && token){ + Promise.all([ + loadCategoryRoots({organisation:organisationId}), + getCategoryListForProject(projectId, token) + ]) + .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) + // 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) + } + }) + }) + if (abort) return + // debugger + // save values + setAvailableIds(availableIds) + setCategories(categories) + setSelectedIds(selected) + }) + .catch(e => { + if (abort) return + setError(`Couldn't load categories: ${e}`) + setState('error') + }) + .finally(()=>{ + if (abort) return + setState('ready') + }) + } + return ()=>{abort=true} + }, [organisationId,projectId,token]) + + async function saveOrganisationCategories(selected:Set,onComplete:()=>void) { + // delete old selection + if (organisationId){ + const deleteErrorMessage = await removeOrganisationCategoriesFromProject(projectId, organisationId, token) + if (deleteErrorMessage !== null) { + setError(`Failed to save categories: ${deleteErrorMessage}`) + setState('error') + return + } + } + + if (selected.size === 0) { + onComplete() + return + } + + // generate new collection + const categoriesArrayToSave:ProjectCategory[] = [] + selected.forEach(id => { + if (availableIds.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) + } + }) + + if (resp.ok) { + // signal we are done + onComplete() + } else { + setError(`Failed to save categories: ${await resp.text()}`) + setState('error') + } + }else{ + onComplete() + } + } + + return { + categories, + selectedIds, + error, + state, + saveOrganisationCategories + } +} diff --git a/frontend/components/software/edit/communities/CommunityAddCategoriesDialog.tsx b/frontend/components/software/edit/communities/CommunityAddCategoriesDialog.tsx index dc64be3c7..1e7e48e22 100644 --- a/frontend/components/software/edit/communities/CommunityAddCategoriesDialog.tsx +++ b/frontend/components/software/edit/communities/CommunityAddCategoriesDialog.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 // @@ -40,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) @@ -47,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) @@ -74,7 +74,7 @@ export default function CommunityAddCategoriesDialog({ useEffect(() => { setState('loading') - const promiseLoadRoots = loadCategoryRoots(community.id) + const promiseLoadRoots = loadCategoryRoots({community:community.id}) .then(roots => { // if there are no categories for this community, we don't show the modal if (roots.length === 0 && autoConfirm) { diff --git a/frontend/components/software/edit/links/AutosaveSoftwareCategories.tsx b/frontend/components/software/edit/links/AutosaveSoftwareCategories.tsx index 60ab4ed1c..7c008c91b 100644 --- a/frontend/components/software/edit/links/AutosaveSoftwareCategories.tsx +++ b/frontend/components/software/edit/links/AutosaveSoftwareCategories.tsx @@ -7,7 +7,7 @@ // SPDX-License-Identifier: Apache-2.0 import {Fragment, useMemo, useState} from 'react' -import {CategoryEntry, CategoryID} from '~/types/Category' +import {CategoryEntry} from '~/types/Category' import {categoryTreeNodesSort, ReorderedCategories} from '~/utils/categories' import TreeSelect from '~/components/software/TreeSelect' import {TreeNode} from '~/types/TreeNode' @@ -20,12 +20,12 @@ import EditSectionTitle from '~/components/layout/EditSectionTitle' export type SoftwareCategoriesProps = { softwareId: string reorderedCategories: ReorderedCategories - associatedCategoryIds: Set + associatedCategoryIds: Set } export default function AutosaveSoftwareCategories({softwareId, reorderedCategories, associatedCategoryIds}: Readonly) { // trick to force rerender - const [_, setAssociatedCategories] = useState>(associatedCategoryIds) + const [_, setAssociatedCategories] = useState>(associatedCategoryIds) const {token} = useSession() const selectedNodes: TreeNode[] = [] for (const root of reorderedCategories.all) { @@ -40,9 +40,15 @@ export default function AutosaveSoftwareCategories({softwareId, reorderedCategor const generalCategories = new TreeNode( { id: 'general', + parent: null, + community: null, + organisation: null, name: config.categories.title, + allow_software: false, + allow_projects: false, + short_name: '', properties: {subtitle: config.categories.subtitle}, - community: null, parent: null, provenance_iri: null, short_name: '', + provenance_iri: null } ) for (const generalRoot of reorderedCategories.general) { @@ -64,7 +70,7 @@ export default function AutosaveSoftwareCategories({softwareId, reorderedCategor return result }, [reorderedCategories]) - function deleteCategoryId(categoryId: CategoryID): void { + function deleteCategoryId(categoryId: string): void { deleteCategoryToSoftware(softwareId, categoryId, token) associatedCategoryIds.delete(categoryId) setAssociatedCategories(new Set(associatedCategoryIds)) diff --git a/frontend/components/software/edit/links/EditSoftwareMetadataForm.tsx b/frontend/components/software/edit/links/EditSoftwareMetadataForm.tsx index 887ea794d..4cdd03580 100644 --- a/frontend/components/software/edit/links/EditSoftwareMetadataForm.tsx +++ b/frontend/components/software/edit/links/EditSoftwareMetadataForm.tsx @@ -8,7 +8,6 @@ import {FormProvider, useForm} from 'react-hook-form' import {CategoriesForSoftware, CodePlatform, EditSoftwareItem, KeywordForSoftware, License} from '~/types/SoftwareTypes' import {AutocompleteOption} from '~/types/AutocompleteOptions' import EditSoftwareMetadataInputs from './EditSoftwareMetadataInputs' -import {CategoryID} from '~/types/Category' type EditSoftwareMetadataFormProps={ id: string @@ -20,7 +19,7 @@ type EditSoftwareMetadataFormProps={ licenses: AutocompleteOption[] keywords: KeywordForSoftware[] categories: CategoriesForSoftware - categoryIds: Set + categoryIds: Set } /** diff --git a/frontend/components/software/edit/organisations/EditSoftwareOrganisationsIndex.test.tsx b/frontend/components/software/edit/organisations/EditSoftwareOrganisationsIndex.test.tsx index f4fb9a37e..7f49bf017 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,14 @@ 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)), + canEditOrganisations: jest.fn(({organisations,...other})=>organisations) })) - // MOCK organisationForSoftware methods const mockCreateOrganisationAndAddToSoftware = jest.fn(props => Promise.resolve([] as any)) const mockAddOrganisationToSoftware = jest.fn(props => Promise.resolve([] as any)) @@ -51,6 +53,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() @@ -284,51 +291,6 @@ describe('frontend/components/software/edit/organisations/index.tsx', () => { }) - it('maintainer of organisation can edit organisation', async () => { - // required software id - softwareState.software.id = 'test-software-id' - // return list of organisations - mockGetOrganisationsForSoftware.mockResolvedValueOnce(organisationsOfSoftware) - // mock is Maintainer of first organisation - mockIsMainatainerOfOrganisation.mockResolvedValueOnce(true) - mockIsMainatainerOfOrganisation.mockResolvedValueOnce(false) - - render( - - - - - - ) - - // wait for loader to be removed - await waitForElementToBeRemoved(screen.getByRole('progressbar')) - - // render first project organisation with edit button - const editBtns = screen.getAllByTestId('EditIcon') - expect(editBtns.length).toEqual(1) - - // click on edit button - fireEvent.click(editBtns[0]) - - const modal = await screen.findByRole('dialog') - - // validate organisation name - const name = within(modal).getByRole('textbox', { - name: config.name.label - }) - expect(name).toHaveValue(organisationsOfSoftware[0].name) - - // cancel - const cancelBtn = within(modal).getByRole('button', { - name: 'Cancel' - }) - fireEvent.click(cancelBtn) - - // modal should not be visible - expect(modal).not.toBeVisible() - }) - it('can remove organisation from software', async () => { // required software id softwareState.software.id = 'test-software-id' @@ -389,4 +351,44 @@ describe('frontend/components/software/edit/organisations/index.tsx', () => { expect(remained.length).toEqual(organisationsOfSoftware.length-1) }) }) + + it('shows organisation categories modal',async()=>{ + // required software id + softwareState.software.id = 'test-software-id' + // return list of organisations + mockGetOrganisationsForSoftware.mockResolvedValueOnce(organisationsOfSoftware) + + render( + + + + + + ) + + // wait for loader to be removed + await waitForElementToBeRemoved(screen.getByRole('progressbar')) + + // renders project organisations + const organisations = screen.getAllByTestId('organisation-list-item') + expect(organisations.length).toEqual(organisationsOfSoftware.length) + + // get edit categories button from first organisation + const categoriesBtn = within(organisations[0]).getByRole('button', { + name: 'edit categories' + }) + // click edit categories + fireEvent.click(categoriesBtn) + + // get organisation categories modal + const modal = screen.getByRole('dialog') + + // close modal + const cancelBtn = within(modal).getByRole('button', { + name: 'Cancel' + }) + fireEvent.click(cancelBtn) + // confirm modal closed + expect(modal).not.toBeInTheDocument() + }) }) diff --git a/frontend/components/software/edit/organisations/SoftwareCategoriesDialog.tsx b/frontend/components/software/edit/organisations/SoftwareCategoriesDialog.tsx new file mode 100644 index 000000000..3c93b2643 --- /dev/null +++ b/frontend/components/software/edit/organisations/SoftwareCategoriesDialog.tsx @@ -0,0 +1,64 @@ +// 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 {EditOrganisation} from '~/types/Organisation' +import CategoriesDialog from '~/components/category/CategoriesDialog' +import useSoftwareCategories from './useSoftwareCategories' + +export type OrganisationCategoriesDialogProps = Readonly<{ + softwareId: string + organisation: EditOrganisation + onCancel: () => void + onComplete: () => void + edit: boolean +}> + +export default function SoftwareCategoriesDialog({ + softwareId, + organisation, + onCancel, + onComplete, + edit +}: OrganisationCategoriesDialogProps) { + + const { + categories,selectedCategoryIds, + state,error, + saveOrganisationCategories + } = useSoftwareCategories({ + organisationId:organisation.id, + softwareId + }) + + 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 + saveOrganisationCategories(selected,onComplete) + }} + /> + ) + +} 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..ef43f763f --- /dev/null +++ b/frontend/components/software/edit/organisations/apiSoftwareOrganisations.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {getOrganisationsForSoftware} from '~/utils/editOrganisation' +import {createJsonHeaders, getBaseUrl} from '~/utils/fetchHelpers' +import {canEditOrganisations} from '~/auth/permissions/isMaintainerOfOrganisation' + +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 +){ + 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..b4b2e26e6 100644 --- a/frontend/components/software/edit/organisations/index.tsx +++ b/frontend/components/software/edit/organisations/index.tsx @@ -8,40 +8,47 @@ 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 SoftwareCategoriesDialog from './SoftwareCategoriesDialog' + +export type OrganisationModalStates = ModalStates & { + categories: T +} export type EditOrganisationModalProps = ModalProps & { organisation?: EditOrganisation + // edit categories flag + edit?: boolean } export default function SoftwareOrganisations() { @@ -53,18 +60,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,6 +105,9 @@ export default function SoftwareOrganisations() { }, delete: { open:false + }, + categories:{ + open:false } }) } else if (item.source === 'RSD') { @@ -108,6 +122,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, + edit: false + } + }) } else { showErrorMessage(resp.message) } @@ -130,6 +158,9 @@ export default function SoftwareOrganisations() { }, delete: { open:false + }, + categories:{ + open:false } }) } @@ -145,6 +176,9 @@ export default function SoftwareOrganisations() { }, delete: { open:false + }, + categories:{ + open:false } }) } @@ -162,6 +196,9 @@ export default function SoftwareOrganisations() { open: true, pos, displayName + }, + categories:{ + open:false } }) } @@ -255,6 +292,9 @@ export default function SoftwareOrganisations() { }, delete: { open:false + }, + categories:{ + open:false } }) } @@ -310,6 +350,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 + edit: true + } + }) + } + } + return ( <> @@ -323,6 +383,7 @@ export default function SoftwareOrganisations() { onEdit={onEdit} onDelete={onDelete} onSorted={sortedOrganisations} + onCategory={onCategoryEdit} />
@@ -356,6 +417,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/components/software/edit/organisations/useSoftwareCategories.tsx b/frontend/components/software/edit/organisations/useSoftwareCategories.tsx new file mode 100644 index 000000000..e484c1f11 --- /dev/null +++ b/frontend/components/software/edit/organisations/useSoftwareCategories.tsx @@ -0,0 +1,130 @@ +// 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 {createJsonHeaders, getBaseUrl} from '~/utils/fetchHelpers' +import {loadCategoryRoots} from '~/components/category/apiCategories' +import {removeOrganisationCategoriesFromSoftware} from './apiSoftwareOrganisations' + +type UseSoftwareOrganisationCategoriesProps={ + organisationId:string|null, + softwareId:string +} + +export default function useSoftwareCategories({ + organisationId,softwareId +}:UseSoftwareOrganisationCategoriesProps){ + 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('useSoftwareCategories') + // console.log('state...',state) + // console.log('categories...', categories) + // console.groupEnd() + + useEffect(() => { + let abort = false + if (organisationId && softwareId && token){ + Promise.all([ + loadCategoryRoots({organisation:organisationId}), + getCategoryForSoftwareIds(softwareId, token) + ]) + .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) + const availableIds = new Set() + categories.forEach(root=>{ + root.forEach(node=>{ + if (node.children().length === 0) { + availableIds.add(node.getValue().id) + } + }) + }) + if (abort) return + // debugger + // save values + setAvailableCategoryIds(availableIds) + setCategories(categories) + setSelectedCategoryIds(selected) + }) + .catch(e => { + if (abort) return + setError(`Couldn't load categories: ${e}`) + setState('error') + }) + .finally(()=>{ + if (abort) return + setState('ready') + }) + } + return ()=>{abort=true} + }, [organisationId, softwareId, token]) + + + async function saveOrganisationCategories(selected:Set,onComplete:()=>void) { + // delete old selection + if (organisationId){ + const deleteErrorMessage = await removeOrganisationCategoriesFromSoftware(softwareId, organisationId, token) + if (deleteErrorMessage !== null) { + setError(`Failed to save categories: ${deleteErrorMessage}`) + setState('error') + return + } + } + + if (selectedCategoryIds.size === 0) { + onComplete() + return + } + + // generate new collection + const categoriesArrayToSave: {software_id: string, category_id: string}[] = [] + selected.forEach(id => { + if (availableCategoryIds.has(id)) { + categoriesArrayToSave.push({software_id: softwareId, category_id: id}) + } + }) + + // 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) { + // signal we are done + onComplete() + } else { + setError(`Failed to save categories: ${await resp.text()}`) + setState('error') + } + }else{ + onComplete() + } + } + + return { + categories, + selectedCategoryIds, + error, + state, + saveOrganisationCategories + } +} diff --git a/frontend/pages/projects/[slug]/index.tsx b/frontend/pages/projects/[slug]/index.tsx index 1e834d750..5d488c66f 100644 --- a/frontend/pages/projects/[slug]/index.tsx +++ b/frontend/pages/projects/[slug]/index.tsx @@ -16,7 +16,8 @@ import { getProjectItem, getRelatedSoftwareForProject, getTeamForProject, getResearchDomainsForProject, getKeywordsForProject, getRelatedProjectsForProject, - getMentionsForProject, getImpactByProject + getMentionsForProject, getImpactByProject, + getCategoriesForProject } from '~/utils/getProjects' import { KeywordForProject, Project, ProjectLink, @@ -45,6 +46,7 @@ import RelatedProjectsSection from '~/components/projects/RelatedProjectsSection import MentionsSection from '~/components/mention/MentionsSection' import {getTestimonialsForProject} from '~/components/projects/edit/testimonials/apiProjectTestimonial' import TestimonialSection from '~/components/software/TestimonialsSection' +import {CategoryPath} from '~/types/Category' export interface ProjectPageProps extends ScriptProps{ slug: string @@ -53,6 +55,7 @@ export interface ProjectPageProps extends ScriptProps{ organisations: ProjectOrganisationProps[], researchDomains: ResearchDomain[], keywords: KeywordForProject[], + categories: CategoryPath[], links: ProjectLink[], output: MentionItemProps[], impact: MentionItemProps[], @@ -64,14 +67,14 @@ export interface ProjectPageProps extends ScriptProps{ export default function ProjectPage(props: ProjectPageProps) { const {slug, project, isMaintainer, organisations, - researchDomains, keywords, links, output, impact, team, + researchDomains, keywords, categories, links, output, impact, team, relatedSoftware, relatedProjects, testimonials } = props if (!project?.title){ return } - // console.log('ProjectPage...testimonials...', testimonials) + // console.log('ProjectPage...categories...', categories) return ( <> {/* Page Head meta tags */} @@ -109,6 +112,7 @@ export default function ProjectPage(props: ProjectPageProps) { researchDomains={researchDomains} keywords={keywords} links={links} + categories={categories} /> {/*
*/} @@ -174,6 +178,7 @@ export async function getServerSideProps(context:any) { organisations, researchDomains, keywords, + categories, output, impact, testimonials, @@ -186,6 +191,8 @@ export async function getServerSideProps(context:any) { getOrganisations({project: project.id, token, frontend: false}), getResearchDomainsForProject({project: project.id, token, frontend: false}), getKeywordsForProject({project: project.id, token, frontend: false}), + // Project specific categories + getCategoriesForProject({project_id:project.id,token}), // Output getMentionsForProject({project: project.id, token, table:'output_for_project'}), // Impact @@ -211,6 +218,7 @@ export async function getServerSideProps(context:any) { organisations, researchDomains, keywords, + categories, output, impact, testimonials, diff --git a/frontend/types/Category.ts b/frontend/types/Category.ts index 4c95f026b..edc41514f 100644 --- a/frontend/types/Category.ts +++ b/frontend/types/Category.ts @@ -1,12 +1,11 @@ // SPDX-FileCopyrightText: 2023 - 2024 Felix Mühlbauer (GFZ) // SPDX-FileCopyrightText: 2023 - 2024 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences +// 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 -export type CategoryID = string // NOSONAR ignore: typescript:S6564 - export type CategoryProperties = { icon?: string is_highlight?: boolean @@ -16,9 +15,12 @@ export type CategoryProperties = { } export type CategoryEntry = { - id: CategoryID - parent: CategoryID | null + id: string + parent: string | null community: string | null + organisation: string | null + allow_software: boolean + allow_projects: boolean short_name: string name: string properties: CategoryProperties diff --git a/frontend/types/SoftwareTypes.ts b/frontend/types/SoftwareTypes.ts index e4ac5d970..0e9adfbc4 100644 --- a/frontend/types/SoftwareTypes.ts +++ b/frontend/types/SoftwareTypes.ts @@ -15,7 +15,7 @@ */ import {AutocompleteOption} from './AutocompleteOptions' -import {CategoryID, CategoryPath} from './Category' +import {CategoryPath} from './Category' import {Status} from './Organisation' export type CodePlatform = 'github' | 'gitlab' | 'bitbucket' | 'other' @@ -139,7 +139,7 @@ export type KeywordForSoftware = { export type CategoriesForSoftware = CategoryPath[] -export type CategoryForSoftwareIds = Set +export type CategoryForSoftwareIds = Set /** * LICENSES diff --git a/frontend/utils/__mocks__/getSoftware.ts b/frontend/utils/__mocks__/getSoftware.ts index 98d8260f3..7d77b6aa7 100644 --- a/frontend/utils/__mocks__/getSoftware.ts +++ b/frontend/utils/__mocks__/getSoftware.ts @@ -1,10 +1,10 @@ -// SPDX-FileCopyrightText: 2023 Netherlands eScience Center -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 -import {CategoriesForSoftware,} from '~/types/SoftwareTypes' -import {CategoryID, CategoryPath} from '~/types/Category' +import {CategoriesForSoftware, CategoryForSoftwareIds,} from '~/types/SoftwareTypes' +import {CategoryPath} from '~/types/Category' export async function getSoftwareList({url,token}:{url:string,token?:string }){ return [] @@ -43,15 +43,19 @@ 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 [] } -export async function addCategoryToSoftware(softwareId: string, categoryId: CategoryID, token: string) { +export async function addCategoryToSoftware(softwareId: string, categoryId: string, token: string) { return [] } -export async function deleteCategoryToSoftware(softwareId: string, categoryId: CategoryID, token: string) { +export async function deleteCategoryToSoftware(softwareId: string, categoryId: string, token: string) { return null } diff --git a/frontend/utils/categories.ts b/frontend/utils/categories.ts index 3ad66bff3..dd62b61d7 100644 --- a/frontend/utils/categories.ts +++ b/frontend/utils/categories.ts @@ -1,5 +1,6 @@ // SPDX-FileCopyrightText: 2023 - 2024 Felix Mühlbauer (GFZ) // SPDX-FileCopyrightText: 2023 - 2024 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) // SPDX-FileCopyrightText: 2024 Ewan Cahen (Netherlands eScience Center) // SPDX-FileCopyrightText: 2024 Netherlands eScience Center // @@ -22,8 +23,9 @@ export const categoryTreeNodesSort = (trees: TreeNode[]) => { } -export const genCategoryTreeNodes = (categories: CategoryPath[]) : TreeNode[] => { +export const genCategoryTreeNodes = (categories: CategoryPath[]=[]) : TreeNode[] => { const allEntries: CategoryEntry[] = [] + for (const path of categories) { for (const entry of path) { allEntries.push(entry) @@ -108,7 +110,7 @@ export function useReorderedCategories(community: string | null): ReorderedCateg }) useEffect(() => { - loadCategoryRoots(community) + loadCategoryRoots({community}) .then(roots => setReorderedCategories(reorderCategories(roots))) }, [community]) diff --git a/frontend/utils/getProjects.ts b/frontend/utils/getProjects.ts index e7bdb15ac..b9acbdfbe 100644 --- a/frontend/utils/getProjects.ts +++ b/frontend/utils/getProjects.ts @@ -14,6 +14,7 @@ import { ResearchDomain, SearchProject, TeamMember } from '~/types/Project' import {RelatedSoftwareOfProject} from '~/types/SoftwareTypes' +import {CategoryPath} from '~/types/Category' import {getImageUrl} from './editImage' import {extractCountFromHeader} from './extractCountFromHeader' import {createJsonHeaders, getBaseUrl} from './fetchHelpers' @@ -419,3 +420,25 @@ export async function searchForRelatedProjectByTitle({project, searchFor, token} return [] } } + +export async function getCategoriesForProject({project_id,token}:{project_id: string, token?: string}){ + try { + const query = `project_id=${project_id}` + const url = `${getBaseUrl()}/rpc/category_paths_by_project_expanded?${query}` + + const resp = await fetch(url, { + method: 'GET', + headers: createJsonHeaders(token) + }) + if (resp.status === 200) { + const data:CategoryPath[] = await resp.json() + return data + } else { + logger(`getCategoriesForProject: ${resp.status} - ${resp.statusText} [${url}]`, 'error') + return [] + } + } catch (e: any) { + logger(`getCategoriesForProject: ${e?.message}`, 'error') + return [] + } +} diff --git a/frontend/utils/getSoftware.ts b/frontend/utils/getSoftware.ts index 28499c9d4..2194d0a84 100644 --- a/frontend/utils/getSoftware.ts +++ b/frontend/utils/getSoftware.ts @@ -10,7 +10,6 @@ // SPDX-License-Identifier: Apache-2.0 import logger from './logger' -import {CategoryID} from '~/types/Category' import {RelatedProjectForSoftware} from '~/types/Project' import {CommunitiesOfSoftware} from '~/components/software/edit/communities/apiSoftwareCommunities' import { @@ -189,9 +188,7 @@ export async function getKeywordsForSoftware(uuid:string,token?:string){ } function prepareQueryURL(path: string, params?: Record) { - const baseURL = getBaseUrl() - logger(`prepareQueryURL baseURL:${baseURL}`) - let url = `${baseURL}${path}` + let url = `${getBaseUrl()}${path}` if (params) { const paramStr = Object.keys(params).map((key) => `${key}=${encodeURIComponent(params[key])}`).join('&') if (paramStr) url += '?' + paramStr @@ -199,7 +196,7 @@ function prepareQueryURL(path: string, params?: Record) { return url } -export async function getCategoriesForSoftware(software_id: string, token?: string): Promise { +export async function getCategoriesForSoftware(software_id: string, token?: string){ try { const url = prepareQueryURL('/rpc/category_paths_by_software_expanded', {software_id}) const resp = await fetch(url, { @@ -207,16 +204,16 @@ export async function getCategoriesForSoftware(software_id: string, token?: stri headers: createJsonHeaders(token) }) if (resp.status === 200) { - const data = await resp.json() - logger(`getCategoriesForSoftware response: ${JSON.stringify(data)}`) + const data:CategoriesForSoftware = await resp.json() return data - } else if (resp.status === 404) { - logger(`getCategoriesForSoftware: 404 [${url}]`, 'error') + } else { + logger(`getCategoriesForSoftware: ${resp.status} - ${resp.statusText} [${url}]`, 'error') + return [] } } catch (e: any) { logger(`getCategoriesForSoftware: ${e?.message}`, 'error') + return [] } - return [] } export async function getCategoryForSoftwareIds(software_id: string, token?: string): Promise { @@ -228,7 +225,6 @@ export async function getCategoryForSoftwareIds(software_id: string, token?: str }) if (resp.status === 200) { const data = await resp.json() - logger(`getCategoriesForSoftwareIds response: ${JSON.stringify(data)}`) return new Set(data.map((entry: any) => entry.category_id)) } else if (resp.status === 404) { logger(`getCategoriesForSoftwareIds: 404 [${url}]`, 'error') @@ -243,7 +239,7 @@ export async function getCategoryForSoftwareIds(software_id: string, token?: str } } -export async function addCategoryToSoftware(softwareId: string, categoryId: CategoryID, token: string) { +export async function addCategoryToSoftware(softwareId: string, categoryId: string, token: string) { const url = prepareQueryURL('/category_for_software') const data = {software_id: softwareId, category_id: categoryId} @@ -254,14 +250,14 @@ export async function addCategoryToSoftware(softwareId: string, categoryId: Cate }, body: JSON.stringify(data), }) - logger(`addCategoryToSoftware: resp: ${resp}`) + if (resp.ok) { return null } throw new Error(`API returned: ${resp.status} ${resp.statusText}`) } -export async function deleteCategoryToSoftware(softwareId: string, categoryId: CategoryID, token: string) { +export async function deleteCategoryToSoftware(softwareId: string, categoryId: string, token: string) { const url = prepareQueryURL(`/category_for_software?software_id=eq.${softwareId}&category_id=eq.${categoryId}`) const resp = await fetch(url, { @@ -270,7 +266,7 @@ export async function deleteCategoryToSoftware(softwareId: string, categoryId: C ...createJsonHeaders(token), }, }) - logger(`deleteCategoryToSoftware: resp: ${resp}`) + if (resp.ok) { return null }