From de4201a306b51dc9533d2f4d342ee39350f59d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20M=C3=BChlbauer?= Date: Tue, 12 Sep 2023 14:39:05 +0200 Subject: [PATCH 1/2] feat: add categories for software --- database/007-create-keyword-and-category.sql | 213 ++++++++++++++++++ database/007-create-keyword.sql | 59 ----- database/020-row-level-security.sql | 41 +++- database/999-add-categories.sql.example | 73 ++++++ frontend/__tests__/SoftwareEditPage.test.tsx | 4 + .../category/CategoriesWithHeadlines.tsx | 28 +++ frontend/components/category/CategoryTree.tsx | 56 +++++ frontend/components/software/AboutSection.tsx | 12 +- .../software/edit/editSoftwareConfig.tsx | 7 +- .../AutosaveSoftwareCategories.tsx | 105 +++++++++ .../information/SoftwareInformationForm.tsx | 8 + .../__mocks__/useSoftwareToEditData.json | 34 +++ .../information/useSoftwareToEdit.test.tsx | 12 +- .../edit/information/useSoftwareToEdit.tsx | 13 +- frontend/components/typography/Icon.tsx | 39 ++++ .../components/typography/SidebarHeadline.tsx | 17 ++ frontend/package.json | 2 +- frontend/pages/software/[slug]/index.tsx | 13 +- frontend/tsconfig.json | 2 +- frontend/types/Category.ts | 23 ++ frontend/types/SoftwareTypes.ts | 5 + frontend/utils/__mocks__/getSoftware.ts | 102 +++++++++ frontend/utils/categories.ts | 50 ++++ frontend/utils/getSoftware.ts | 92 +++++++- 24 files changed, 932 insertions(+), 78 deletions(-) create mode 100644 database/007-create-keyword-and-category.sql delete mode 100644 database/007-create-keyword.sql create mode 100644 database/999-add-categories.sql.example create mode 100644 frontend/components/category/CategoriesWithHeadlines.tsx create mode 100644 frontend/components/category/CategoryTree.tsx create mode 100644 frontend/components/software/edit/information/AutosaveSoftwareCategories.tsx create mode 100644 frontend/components/typography/Icon.tsx create mode 100644 frontend/components/typography/SidebarHeadline.tsx create mode 100644 frontend/types/Category.ts create mode 100644 frontend/utils/__mocks__/getSoftware.ts create mode 100644 frontend/utils/categories.ts diff --git a/database/007-create-keyword-and-category.sql b/database/007-create-keyword-and-category.sql new file mode 100644 index 000000000..d93133508 --- /dev/null +++ b/database/007-create-keyword-and-category.sql @@ -0,0 +1,213 @@ +-- SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center) +-- SPDX-FileCopyrightText: 2022 Netherlands eScience Center +-- SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) +-- SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences +-- +-- SPDX-License-Identifier: Apache-2.0 + +-------------- +-- Keywords -- +-------------- + +CREATE TABLE keyword ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + value CITEXT UNIQUE CHECK (value ~ '^\S+( \S+)*$') +); + +CREATE FUNCTION sanitise_insert_keyword() RETURNS TRIGGER LANGUAGE plpgsql AS +$$ +BEGIN + NEW.id = gen_random_uuid(); + return NEW; +END +$$; + +CREATE TRIGGER sanitise_insert_keyword BEFORE INSERT ON keyword FOR EACH ROW EXECUTE PROCEDURE sanitise_insert_keyword(); + + +CREATE FUNCTION sanitise_update_keyword() RETURNS TRIGGER LANGUAGE plpgsql AS +$$ +BEGIN + NEW.id = OLD.id; + return NEW; +END +$$; + +CREATE TRIGGER sanitise_update_keyword BEFORE UPDATE ON keyword FOR EACH ROW EXECUTE PROCEDURE sanitise_update_keyword(); + + +CREATE TABLE keyword_for_software ( + software UUID references software (id), + keyword UUID references keyword (id), + PRIMARY KEY (software, keyword) +); + +CREATE TABLE keyword_for_project ( + project UUID references project (id), + keyword UUID references keyword (id), + PRIMARY KEY (project, keyword) +); + +-- ADD basic keywords from topics and tags +INSERT into keyword (value) +VALUES + ('Big data'), + ('GPU'), + ('High performance computing'), + ('Image processing'), + ('Inter-operability & linked data'), + ('Machine learning'), + ('Multi-scale & multi model simulations'), + ('Optimized data handling'), + ('Real time data analysis'), + ('Text analysis & natural language processing'), + ('Visualization'), + ('Workflow technologies'); + +---------------- +-- Categories -- +---------------- + +CREATE TABLE category ( + id UUID PRIMARY KEY, + parent UUID REFERENCES category DEFAULT NULL, + short_name VARCHAR NOT NULL, + name VARCHAR NOT NULL, + icon VARCHAR DEFAULT NULL, + + CONSTRAINT unique_short_name UNIQUE NULLS NOT DISTINCT (parent, short_name), + CONSTRAINT unique_name UNIQUE NULLS NOT DISTINCT (parent, name) +); + +CREATE TABLE category_for_software ( + software_id UUID references software (id), + category_id UUID references category (id), + PRIMARY KEY (software_id, category_id) +); + + +-- sanitize categories + +CREATE FUNCTION sanitise_insert_category() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER AS +$$ +BEGIN + IF NEW.id IS NOT NULL THEN + RAISE EXCEPTION USING MESSAGE = 'The category id is generated automatically and may not be set.'; + END IF; + NEW.id = gen_random_uuid(); + RETURN NEW; +END +$$; + +CREATE TRIGGER sanitise_insert_category + BEFORE INSERT ON category + FOR EACH ROW EXECUTE PROCEDURE sanitise_insert_category(); + + +CREATE FUNCTION sanitise_update_category() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER AS +$$ +BEGIN + IF NEW.id != OLD.id THEN + RAISE EXCEPTION USING MESSAGE = 'The category id may not be changed.'; + END IF; + RETURN NEW; +END +$$; + +CREATE TRIGGER sanitise_update_category + BEFORE UPDATE ON category + FOR EACH ROW EXECUTE PROCEDURE sanitise_update_category(); + + +CREATE FUNCTION check_cycle_categories() +RETURNS TRIGGER STABLE +LANGUAGE plpgsql +SECURITY DEFINER AS +$$ + DECLARE first_id UUID = NEW.id; + DECLARE current_id UUID = NEW.parent; +BEGIN + WHILE current_id IS NOT NULL LOOP + IF current_id = first_id THEN + RAISE EXCEPTION USING MESSAGE = 'Cycle detected for category with id ' || NEW.id; + END IF; + SELECT parent FROM category WHERE id = current_id INTO current_id; + END LOOP; + RETURN NEW; +END +$$; + +CREATE TRIGGER zzz_check_cycle_categories -- triggers are executed in alphabetical order + AFTER INSERT OR UPDATE ON category + FOR EACH ROW EXECUTE PROCEDURE check_cycle_categories(); + + +-- helper functions + +CREATE FUNCTION category_path(category_id UUID) +RETURNS TABLE (like category) +LANGUAGE SQL STABLE AS +$$ + WITH RECURSIVE cat_path AS ( + SELECT *, 1 AS r_index + FROM category WHERE id = category_id + UNION ALL + SELECT category.*, cat_path.r_index+1 + FROM category + JOIN cat_path + ON category.id = cat_path.parent + ) + -- 1. How can we reverse the output rows without injecting a new column (r_index)? + -- 2. How a table row "type" could be used here Now we have to list all columns of `category` explicitely + -- I want to have something like `* without 'r_index'` to be independant from modifications of `category` + -- 3. Maybe this could be improved by using SEARCH keyword. + SELECT id, parent, short_name, name, icon + FROM cat_path + ORDER BY r_index DESC; +$$; + +-- returns a list of `category` entries traversing from the tree root to entry with `category_id` +CREATE FUNCTION category_path_expanded(category_id UUID) +RETURNS JSON +LANGUAGE SQL STABLE AS +$$ + SELECT json_agg(row_to_json) AS path FROM (SELECT row_to_json(category_path(category_id))) AS cats; +$$; + + +CREATE FUNCTION category_paths_by_software_expanded(software_id UUID) +RETURNS JSON +LANGUAGE SQL STABLE AS +$$ + WITH + cat_ids AS + (SELECT category_id FROM category_for_software AS c4s WHERE c4s.software_id = category_paths_by_software_expanded.software_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 +$$; + + +CREATE FUNCTION available_categories_expanded() +RETURNS JSON +LANGUAGE SQL STABLE AS +$$ + WITH + cat_ids AS + (SELECT id AS category_id FROM category AS node WHERE NOT EXISTS (SELECT 1 FROM category AS sub WHERE node.id = sub.parent)), + 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) AS result FROM paths) + ELSE '[]'::json + END +$$ diff --git a/database/007-create-keyword.sql b/database/007-create-keyword.sql deleted file mode 100644 index 9f0862435..000000000 --- a/database/007-create-keyword.sql +++ /dev/null @@ -1,59 +0,0 @@ --- SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center) --- SPDX-FileCopyrightText: 2022 Netherlands eScience Center --- --- SPDX-License-Identifier: Apache-2.0 - -CREATE TABLE keyword ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - value CITEXT UNIQUE CHECK (value ~ '^\S+( \S+)*$') -); - -CREATE FUNCTION sanitise_insert_keyword() RETURNS TRIGGER LANGUAGE plpgsql AS -$$ -BEGIN - NEW.id = gen_random_uuid(); - return NEW; -END -$$; - -CREATE TRIGGER sanitise_insert_keyword BEFORE INSERT ON keyword FOR EACH ROW EXECUTE PROCEDURE sanitise_insert_keyword(); - - -CREATE FUNCTION sanitise_update_keyword() RETURNS TRIGGER LANGUAGE plpgsql AS -$$ -BEGIN - NEW.id = OLD.id; - return NEW; -END -$$; - -CREATE TRIGGER sanitise_update_keyword BEFORE UPDATE ON keyword FOR EACH ROW EXECUTE PROCEDURE sanitise_update_keyword(); - - -CREATE TABLE keyword_for_software ( - software UUID references software (id), - keyword UUID references keyword (id), - PRIMARY KEY (software, keyword) -); - -CREATE TABLE keyword_for_project ( - project UUID references project (id), - keyword UUID references keyword (id), - PRIMARY KEY (project, keyword) -); - --- ADD basic keywords from topics and tags -INSERT into keyword (value) -VALUES - ('Big data'), - ('GPU'), - ('High performance computing'), - ('Image processing'), - ('Inter-operability & linked data'), - ('Machine learning'), - ('Multi-scale & multi model simulations'), - ('Optimized data handling'), - ('Real time data analysis'), - ('Text analysis & natural language processing'), - ('Visualization'), - ('Workflow technologies'); diff --git a/database/020-row-level-security.sql b/database/020-row-level-security.sql index b4f72be18..37cc0baa7 100644 --- a/database/020-row-level-security.sql +++ b/database/020-row-level-security.sql @@ -2,9 +2,10 @@ -- SPDX-FileCopyrightText: 2021 - 2023 Netherlands eScience Center -- SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) -- SPDX-FileCopyrightText: 2022 - 2023 dv4all +-- SPDX-FileCopyrightText: 2022 - 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences -- SPDX-FileCopyrightText: 2022 Christian Meeßen (GFZ) --- SPDX-FileCopyrightText: 2022 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences -- SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) +-- SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) -- -- SPDX-License-Identifier: Apache-2.0 @@ -276,6 +277,44 @@ CREATE POLICY admin_all_rights ON testimonial TO rsd_admin USING (TRUE) WITH CHECK (TRUE); + +-- categories + +ALTER TABLE category ENABLE ROW LEVEL SECURITY; + +-- allow everybody to read +CREATE POLICY anyone_can_read ON category + FOR SELECT + TO rsd_web_anon, rsd_user + USING (TRUE); + +-- allow admins to have full read/write access +CREATE POLICY admin_all_rights ON category + TO rsd_admin + USING (TRUE); + + +-- 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)); + +-- allow software maintainers to have read/write access to their software +CREATE POLICY maintainer_all_rights ON category_for_software + TO rsd_user + USING (software_id IN (SELECT * FROM software_of_current_maintainer())); + +-- allow admins to have full read/write access +CREATE POLICY admin_all_rights ON category_for_software + TO rsd_admin + USING (TRUE); + + -- keywords ALTER TABLE keyword ENABLE ROW LEVEL SECURITY; diff --git a/database/999-add-categories.sql.example b/database/999-add-categories.sql.example new file mode 100644 index 000000000..cde46c849 --- /dev/null +++ b/database/999-add-categories.sql.example @@ -0,0 +1,73 @@ +-- SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) +-- SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences +-- +-- SPDX-License-Identifier: Apache-2.0 + +-- EXAMPLE FILE FOR SETTING UP SOME TEST CATEGORIES + +-- Remarks: +-- using icons: until #975 is addressed check/edit frontend/components/typography/Icon.tsx for available icons + +CREATE FUNCTION add_test_categories() +RETURNS TABLE (LIKE category) +LANGUAGE plpgsql +AS $$ + DECLARE parent1 uuid; + DECLARE parent2 uuid; + DECLARE parent3 uuid; +BEGIN + INSERT INTO category(short_name, name, icon) VALUES + ('Research fruits', 'Some simple research fruits', 'science') RETURNING id INTO parent1; + -- + INSERT INTO category(parent, short_name, name) VALUES + (parent1, 'Apple', 'Apple: An apple a day keeps...'), + (parent1, 'Bananas', 'Bananas, who bent the bananas?'), + (parent1, 'Grapes', 'Grapes, I like grapes.'), + (parent1, 'Pineapple', 'Pineapple is very sweet and tasty.'); + -- + -- + INSERT INTO category(short_name, name) VALUES + ('Top level 1', 'Top Level Category 1') RETURNING id INTO parent1; + -- + INSERT INTO category(parent, short_name, name) VALUES + (parent1, 'Category A', 'Category A aka 1.1') RETURNING id INTO parent2; + INSERT INTO category(parent, short_name, name) VALUES + (parent2, 'Category 1 bit deeper', 'Category 1 bit deeper aka 1.1.1') RETURNING id INTO parent3; + -- + INSERT INTO category(parent, short_name, name) VALUES + (parent2, 'Category 2', 'Category 2 aka 1.1.2'), + (parent2, 'Category 3', 'Category 3 aka 1.1.3'), + -- + (parent3, 'Category a bit deeper', 'Category a bit deeper aka 1.1.1.1'), + (parent3, 'Category b bit deeper', 'Category b bit deeper aka 1.1.1.2'), + (parent3, 'Category c bit deeper and longer', 'Category c bit deeper and longer aka 1.1.1.3'), + (parent3, 'Category d bit deeper and longer', 'Category d bit deeper and longer aka 1.1.1.4'); + -- + INSERT INTO category(parent, short_name, name) VALUES + (parent1, 'Category B', 'Category B aka 1.2') RETURNING id INTO parent2; + INSERT INTO category(parent, short_name, name) VALUES + (parent2, 'Category 1', 'Category 1 aka 1.2.1'), + (parent2, 'Category 2', 'Category 2 aka 1.2.2'), + (parent2, 'Category 3', 'Category 3 aka 1.2.3'); + -- + -- + INSERT INTO category(short_name, name) VALUES + ('Top level 2 bit longer', 'Top level 2 bit longer aka 2') RETURNING id INTO parent1; + -- + INSERT INTO category(parent, short_name, name) VALUES + (parent1, 'Category A bit longer', 'Category A bit longer aka 2.1') RETURNING id INTO parent2; + INSERT INTO category(parent, short_name, name) VALUES + (parent2, 'Category 1 bit longer', 'Category 1 bit longer aka 2.1.1'), + (parent2, 'Category 2 bit longer', 'Category 2 bit longer aka 2.1.2'), + (parent2, 'Category 3 bit longer', 'Category 3 bit longer aka 2.1.3'); + -- + INSERT INTO category(parent, short_name, name) VALUES + (parent1, 'Category B even more longer', 'Category B even more longer aka 2.2') RETURNING id INTO parent2; + INSERT INTO category(parent, short_name, name) VALUES + (parent2, 'Category 1 even more longer', 'Category 1 even more longer aka 2.2.1'), + (parent2, 'Category 2 even more longer', 'Category 2 even more longer aka 2.2.2'), + (parent2, 'Category 3 even more longer', 'Category 3 even more longer aka 2.2.3'); +END +$$; + +SELECT add_test_categories(); diff --git a/frontend/__tests__/SoftwareEditPage.test.tsx b/frontend/__tests__/SoftwareEditPage.test.tsx index 3756cf8b1..577c7948c 100644 --- a/frontend/__tests__/SoftwareEditPage.test.tsx +++ b/frontend/__tests__/SoftwareEditPage.test.tsx @@ -1,5 +1,7 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 Netherlands eScience Center // SPDX-FileCopyrightText: 2023 dv4all // // SPDX-License-Identifier: Apache-2.0 @@ -41,6 +43,8 @@ window.IntersectionObserver = jest.fn(() => ({ // use default mocks jest.mock('~/components/software/edit/information/useSoftwareToEdit') jest.mock('~/components/software/edit/information/searchForSoftwareKeyword') +// mock all default software calls +jest.mock('~/utils/getSoftware') const mockProps = { // information page diff --git a/frontend/components/category/CategoriesWithHeadlines.tsx b/frontend/components/category/CategoriesWithHeadlines.tsx new file mode 100644 index 000000000..d86cad80b --- /dev/null +++ b/frontend/components/category/CategoriesWithHeadlines.tsx @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) +// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences +// +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react' +import {useCategoryTree} from '~/utils/categories' +import {SidebarHeadline} from '../typography/SidebarHeadline' +import {CategoryTreeLevel, CategoryTreeLevelProps} from './CategoryTree' +import {CategoryPath} from '~/types/Category' + +type CategoriesWithHeadlinesProps = { + categories: CategoryPath[] + onRemove?: CategoryTreeLevelProps['onRemove'] +} + +export const CategoriesWithHeadlines = ({categories, onRemove}: CategoriesWithHeadlinesProps) => { + const tree = useCategoryTree(categories) + + return tree.map(({category, children}) => { + return + +
+ +
+
+ }) +} diff --git a/frontend/components/category/CategoryTree.tsx b/frontend/components/category/CategoryTree.tsx new file mode 100644 index 000000000..8d7eec43f --- /dev/null +++ b/frontend/components/category/CategoryTree.tsx @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) +// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences +// +// SPDX-License-Identifier: Apache-2.0 + +import CancelIcon from '@mui/icons-material/Cancel' +import Tooltip from '@mui/material/Tooltip' +import IconButton from '@mui/material/IconButton' +import {CategoryID, CategoryPath, CategoryTree as TCategoryTree} from '~/types/Category' +import {useCategoryTree} from '~/utils/categories' + +export type CategoryTreeLevelProps = { + items: TCategoryTree + onRemove?: (categoryId: CategoryID) => void +} +export const CategoryTreeLevel = ({items, onRemove}: CategoryTreeLevelProps) => { + + const onRemoveHandler = (event: React.MouseEvent) => { + event.stopPropagation() + const categoryId = event.currentTarget.dataset.id! + onRemove?.(categoryId) + } + + return +} + + +type TreeLevelProps = { + items: TCategoryTree + onRemoveHandler? : (event: React.MouseEvent) => void +} +const TreeLevel = ({items, onRemoveHandler}: TreeLevelProps) => { + return
    + {items.map((item, index) => ( +
  • +
    + + {item.category.short_name} + + {onRemoveHandler && item.children.length === 0 && } +
    + {item.children.length > 0 && } +
  • + ))} +
+} + + +type CategoryTreeProps = { + categories: CategoryPath[] + onRemove?: CategoryTreeLevelProps['onRemove'] +} +export const CategoryTree = ({categories, onRemove}: CategoryTreeProps) => { + const tree = useCategoryTree(categories) + return +} diff --git a/frontend/components/software/AboutSection.tsx b/frontend/components/software/AboutSection.tsx index b0acbd710..a6239cfa1 100644 --- a/frontend/components/software/AboutSection.tsx +++ b/frontend/components/software/AboutSection.tsx @@ -1,11 +1,12 @@ // SPDX-FileCopyrightText: 2021 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2021 - 2023 dv4all +// SPDX-FileCopyrightText: 2022 - 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences // SPDX-FileCopyrightText: 2022 Christian Meeßen (GFZ) -// SPDX-FileCopyrightText: 2022 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) // // SPDX-License-Identifier: Apache-2.0 -import {License, ProgramingLanguages, CodePlatform, KeywordForSoftware} from '../../types/SoftwareTypes' +import {License, ProgramingLanguages, CodePlatform, KeywordForSoftware, CategoriesForSoftware} from '../../types/SoftwareTypes' import PageContainer from '../layout/PageContainer' import AboutStatement from './AboutStatement' import SoftwareKeywords from './SoftwareKeywords' @@ -13,23 +14,25 @@ import AboutLanguages from './AboutLanguages' import AboutLicense from './AboutLicense' import AboutSourceCode from './AboutSourceCode' import SoftwareLogo from './SoftwareLogo' +import {CategoriesWithHeadlines} from '../category/CategoriesWithHeadlines' type AboutSectionType = { brand_name: string description: string description_type: 'link' | 'markdown' keywords: KeywordForSoftware[] + categories: CategoriesForSoftware licenses: License[] repository: string | null platform: CodePlatform - languages: ProgramingLanguages, + languages: ProgramingLanguages image_id: string | null } export default function AboutSection(props:AboutSectionType) { const { - brand_name = '', description = '', keywords, licenses, + brand_name = '', description = '', keywords, categories, licenses, repository, languages, platform, description_type = 'markdown', image_id } = props @@ -58,6 +61,7 @@ export default function AboutSection(props:AboutSectionType) {
{getSoftwareLogo()} + diff --git a/frontend/components/software/edit/editSoftwareConfig.tsx b/frontend/components/software/edit/editSoftwareConfig.tsx index fb5025c2a..ef5a0304d 100644 --- a/frontend/components/software/edit/editSoftwareConfig.tsx +++ b/frontend/components/software/edit/editSoftwareConfig.tsx @@ -1,10 +1,11 @@ // SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2022 - 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences // SPDX-FileCopyrightText: 2022 - 2023 dv4all // SPDX-FileCopyrightText: 2022 Christian Meeßen (GFZ) // SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2022 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences // SPDX-FileCopyrightText: 2022 Matthias Rüster (GFZ) // SPDX-FileCopyrightText: 2022 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) // // SPDX-License-Identifier: Apache-2.0 @@ -124,6 +125,10 @@ export const softwareInformation = { is_published: { label: 'Published', }, + categories: { + title: 'Categories', + subtitle: 'Tell us more about your software.', + }, keywords: { title: 'Keywords', subtitle: 'Find, add or import using concept DOI.', diff --git a/frontend/components/software/edit/information/AutosaveSoftwareCategories.tsx b/frontend/components/software/edit/information/AutosaveSoftwareCategories.tsx new file mode 100644 index 000000000..20c1319fb --- /dev/null +++ b/frontend/components/software/edit/information/AutosaveSoftwareCategories.tsx @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) +// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences +// +// SPDX-License-Identifier: Apache-2.0 + +import {ChangeEventHandler, Fragment, useEffect, useMemo, useState} from 'react' +import {useSession} from '~/auth' +import {softwareInformation as config} from '../editSoftwareConfig' +import useSnackbar from '~/components/snackbar/useSnackbar' +import EditSectionTitle from '~/components/layout/EditSectionTitle' +import {addCategoryToSoftware, deleteCategoryToSoftware, getAvailableCategories} from '~/utils/getSoftware' +import {CategoryID, CategoryPath, CategoryTree} from '~/types/Category' +import {CategoriesWithHeadlines} from '~/components/category/CategoriesWithHeadlines' +import {genCategoryTree, leaf} from '~/utils/categories' + +export type SoftwareCategoriesProps = { + softwareId: string + categories: CategoryPath[] +} +export default function AutosaveSoftwareCategories({softwareId, categories: defaultCategoryPaths}: SoftwareCategoriesProps) { + const {token} = useSession() + const {showErrorMessage} = useSnackbar() + + const [availableCategoryPaths, setAvailableCategoryPaths] = useState([]) + const [availableCategories, setAvailableCategories] = useState([]) + + const [categoryPaths, setCategoryPaths] = useState(defaultCategoryPaths) + const selectedCategoryIDs = useMemo(() => { + const ids = new Set() + for (const category of categoryPaths ) { + ids.add(leaf(category).id) + } + return ids + },[categoryPaths]) + + useEffect(() => { + getAvailableCategories() + .then((categories) => { + setAvailableCategoryPaths(categories) + setAvailableCategories(genCategoryTree(categories)) + }) + }, []) + + const onAdd: ChangeEventHandler = (event) => { + const categoryId = event.target.value + event.target.value = 'none' + + const categoryPath = availableCategoryPaths.find(path => leaf(path).id === categoryId) + if (!categoryPath) return + + const category = leaf(categoryPath) + addCategoryToSoftware(softwareId, category.id, token).then(() => { + // Should we trust that this is the current value or should we re-fetch the values from backend? + setCategoryPaths([...categoryPaths, categoryPath]) + }).catch((error) => { + showErrorMessage(error.message) + }) + } + + const onRemove = (categoryId: CategoryID) => { + deleteCategoryToSoftware(softwareId, categoryId, token).then(() => { + // Should we trust that this is the current value or should we re-fetch the values from backend? + setCategoryPaths(categoryPaths.filter(path => leaf(path).id != categoryId)) + }).catch((error) => { + showErrorMessage(error.message) + }) + } + + const OptionsTree = ({items, indent=0}: {items: CategoryTree, indent?: number}) => { + return items.map((item, index) => { + const isLeaf = item.children.length === 0 + const isSelected = selectedCategoryIDs.has(item.category.id) + const title = ' '.repeat(indent)+item.category.name + return + {isLeaf ? + + : + + } + + + }) + } + + if (availableCategories.length === 0) return null + + return ( + <> + +
+ + +
+ +
+
+ + ) +} diff --git a/frontend/components/software/edit/information/SoftwareInformationForm.tsx b/frontend/components/software/edit/information/SoftwareInformationForm.tsx index 1eabc57dd..d935818b4 100644 --- a/frontend/components/software/edit/information/SoftwareInformationForm.tsx +++ b/frontend/components/software/edit/information/SoftwareInformationForm.tsx @@ -1,4 +1,6 @@ // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) +// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences // SPDX-FileCopyrightText: 2023 dv4all // // SPDX-License-Identifier: Apache-2.0 @@ -18,6 +20,7 @@ import AutosaveRepositoryUrl from './AutosaveRepositoryUrl' import AutosaveSoftwareLicenses from './AutosaveSoftwareLicenses' import AutosaveSoftwareMarkdown from './AutosaveSoftwareMarkdown' import AutosaveSoftwareLogo from './AutosaveSoftwareLogo' +import AutosaveSoftwareCategories from './AutosaveSoftwareCategories' type SoftwareInformationFormProviderProps = { editSoftware: EditSoftwareItem @@ -140,6 +143,11 @@ export default function SoftwareInformationForm({editSoftware}: SoftwareInformat
+ +
({ // MOCK getKeywordsForSoftware, getLicenseForSoftware const mockGetKeywordsForSoftware = jest.fn(props => Promise.resolve([] as any)) +const mockGetCategoriesForSoftware = jest.fn(props => Promise.resolve([] as CategoriesForSoftware)) const mockGetLicenseForSoftware = jest.fn(props => Promise.resolve([] as any)) jest.mock('~/utils/getSoftware', () => ({ getKeywordsForSoftware: jest.fn(props => mockGetKeywordsForSoftware(props)), - getLicenseForSoftware: jest.fn(props => mockGetLicenseForSoftware(props)) + getCategoriesForSoftware: jest.fn(props => mockGetCategoriesForSoftware(props)), + getLicenseForSoftware: jest.fn(props => mockGetLicenseForSoftware(props)), })) const copySoftware = { @@ -31,6 +36,10 @@ const mockKeywords = [ ...copySoftware.keywords ] +const mockCategories = [ + ...copySoftware.categories +] + const mockLicenses = [ ...copySoftware.licenses ] @@ -78,6 +87,7 @@ it('renders software returned by api', async() => { // mock api responses mockGetSoftwareToEdit.mockResolvedValueOnce(copySoftware) mockGetKeywordsForSoftware.mockResolvedValueOnce(mockKeywords) + mockGetCategoriesForSoftware.mockResolvedValueOnce(mockCategories as any) mockGetLicenseForSoftware.mockResolvedValueOnce(mockLicenses) render( diff --git a/frontend/components/software/edit/information/useSoftwareToEdit.tsx b/frontend/components/software/edit/information/useSoftwareToEdit.tsx index f809bbd54..f392e6137 100644 --- a/frontend/components/software/edit/information/useSoftwareToEdit.tsx +++ b/frontend/components/software/edit/information/useSoftwareToEdit.tsx @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2022 - 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences // SPDX-FileCopyrightText: 2022 - 2023 dv4all // SPDX-FileCopyrightText: 2022 Christian Meeßen (GFZ) -// SPDX-FileCopyrightText: 2022 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) // // SPDX-License-Identifier: Apache-2.0 @@ -10,7 +11,7 @@ import {useEffect, useState} from 'react' import {AutocompleteOption} from '../../../../types/AutocompleteOptions' import {EditSoftwareItem, KeywordForSoftware, License} from '../../../../types/SoftwareTypes' import {getSoftwareToEdit} from '../../../../utils/editSoftware' -import {getKeywordsForSoftware, getLicenseForSoftware} from '../../../../utils/getSoftware' +import {getCategoriesForSoftware, getKeywordsForSoftware, getLicenseForSoftware} from '../../../../utils/getSoftware' function prepareLicenses(rawLicense: License[]=[]) { const license:AutocompleteOption[] = rawLicense?.map((item: any) => { @@ -30,14 +31,16 @@ export async function getSoftwareInfoForEdit({slug, token}: { slug: string, toke if (software) { const requests = [ getKeywordsForSoftware(software.id, true, token), - getLicenseForSoftware(software.id, true, token) - ] + getCategoriesForSoftware(software.id, token), + getLicenseForSoftware(software.id, true, token), + ] as const // other api requests - const [keywords, respLicense,] = await Promise.all(requests) + const [keywords, categories, respLicense] = await Promise.all(requests) const data:EditSoftwareItem = { ...software, keywords: keywords as KeywordForSoftware[], + categories, licenses: prepareLicenses(respLicense as License[]), image_b64: null, image_mime_type: null, diff --git a/frontend/components/typography/Icon.tsx b/frontend/components/typography/Icon.tsx new file mode 100644 index 000000000..28ddf78aa --- /dev/null +++ b/frontend/components/typography/Icon.tsx @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) +// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences +// +// SPDX-License-Identifier: Apache-2.0 + +import QuestionMarkIcon from '@mui/icons-material/QuestionMark' +import CategoryIcon from '@mui/icons-material/Category' +import ScienceIcon from '@mui/icons-material/Science' + +import type SvgIcon from '@mui/material/SvgIcon' + +// => This is a workaround before we can load icons dynamically (see #975). + +// create a component map with lowercase icon names as index +const iconMap = (() => { + const icons = { + QuestionMarkIcon, + CategoryIcon, + ScienceIcon, + // => extend the list above if necessary + } as Record + // magically generate icon names + return Object.keys(icons).reduce((map, key)=>{ + const iconName = key.toLowerCase().slice(0, -4) + map[iconName] = icons[key] + return map + },{} as Record) +})() + +type IconProps = { + name: string +} & React.ComponentProps + +export const Icon = ({name, ...props} : IconProps) => { + const MuiIcon = (name && iconMap[name.toLowerCase()]) || QuestionMarkIcon + return +} + + diff --git a/frontend/components/typography/SidebarHeadline.tsx b/frontend/components/typography/SidebarHeadline.tsx new file mode 100644 index 000000000..335cad340 --- /dev/null +++ b/frontend/components/typography/SidebarHeadline.tsx @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) +// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences +// +// SPDX-License-Identifier: Apache-2.0 + +import {Icon} from './Icon' + +type SidebarHeadlineProps = { + iconName?: string + title: string +} +export const SidebarHeadline = ({iconName, title}: SidebarHeadlineProps) => { + return
+ {iconName && } + {title} +
+} diff --git a/frontend/package.json b/frontend/package.json index 32fcf020e..f16692357 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "dev:docker": "NODE_ENV=docker NODE_OPTIONS='--inspect=0.0.0.0:9229' next dev", + "dev:docker": "NODE_ENV=docker next dev # backup: NODE_ENV=docker NODE_OPTIONS='--inspect=0.0.0.0:9229' next dev", "build": "next build", "start": "next start", "lint": "next lint", diff --git a/frontend/pages/software/[slug]/index.tsx b/frontend/pages/software/[slug]/index.tsx index b06ec18b9..61b9ed326 100644 --- a/frontend/pages/software/[slug]/index.tsx +++ b/frontend/pages/software/[slug]/index.tsx @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: 2021 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2021 - 2023 dv4all +// SPDX-FileCopyrightText: 2022 - 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences // SPDX-FileCopyrightText: 2022 Christian Meeßen (GFZ) -// SPDX-FileCopyrightText: 2022 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) // SPDX-FileCopyrightText: 2023 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 @@ -39,6 +40,7 @@ import { getRemoteMarkdown, ContributorMentionCount, getKeywordsForSoftware, + getCategoriesForSoftware, getRelatedProjectsForSoftware, getReleasesForSoftware, SoftwareVersion, @@ -51,6 +53,7 @@ import {getRelatedSoftwareForSoftware} from '~/utils/editRelatedSoftware' import {getMentionsForSoftware} from '~/utils/editMentions' import {getParticipatingOrganisations} from '~/utils/editOrganisation' import { + CategoriesForSoftware, KeywordForSoftware, License, RepositoryInfo, SoftwareItem, SoftwareOverviewItemProps } from '~/types/SoftwareTypes' @@ -66,6 +69,7 @@ interface SoftwareIndexData extends ScriptProps{ software: SoftwareItem releases: SoftwareVersion[] keywords: KeywordForSoftware[] + categories: CategoriesForSoftware licenseInfo: License[] repositoryInfo: RepositoryInfo softwareIntroCounts: ContributorMentionCount @@ -82,7 +86,7 @@ export default function SoftwareIndexPage(props:SoftwareIndexData) { const [author, setAuthor] = useState('') // extract data from props const { - software, releases, keywords, + software, releases, keywords, categories, licenseInfo, repositoryInfo, softwareIntroCounts, mentions, testimonials, contributors, relatedSoftware, relatedProjects, isMaintainer, @@ -149,6 +153,7 @@ export default function SoftwareIndexPage(props:SoftwareIndexData) { description={software?.description ?? ''} description_type={software?.description_type} keywords={keywords} + categories={categories} licenses={licenseInfo} languages={repositoryInfo?.languages} repository={repositoryInfo?.url} @@ -215,6 +220,7 @@ export async function getServerSideProps(context:GetServerSidePropsContext) { const [ releases, keywords, + categories, licenseInfo, repositoryInfo, softwareIntroCounts, @@ -230,6 +236,8 @@ export async function getServerSideProps(context:GetServerSidePropsContext) { getReleasesForSoftware(software.id,token), // keywords getKeywordsForSoftware(software.id,false,token), + // categories + getCategoriesForSoftware(software.id, token), // licenseInfo getLicenseForSoftware(software.id, false, token), // repositoryInfo: url, languages and commits @@ -257,6 +265,7 @@ export async function getServerSideProps(context:GetServerSidePropsContext) { software, releases, keywords, + categories, licenseInfo, repositoryInfo, softwareIntroCounts, diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index e4e4fda5c..baf9a7677 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es6", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, diff --git a/frontend/types/Category.ts b/frontend/types/Category.ts new file mode 100644 index 000000000..de2295785 --- /dev/null +++ b/frontend/types/Category.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) +// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences +// +// SPDX-License-Identifier: Apache-2.0 + +export type CategoryID = string // NOSONAR ignore: typescript:S6564 + +export type CategoryEntry = { + id: CategoryID + parent: CategoryID | null + short_name: string + name: string + icon?: string +} + + +export type CategoryPath = CategoryEntry[] + +export type CategoryTreeLevel = { + category: CategoryEntry + children: CategoryTreeLevel[] +} +export type CategoryTree = CategoryTreeLevel[] diff --git a/frontend/types/SoftwareTypes.ts b/frontend/types/SoftwareTypes.ts index a8844906d..a6dfa3a0d 100644 --- a/frontend/types/SoftwareTypes.ts +++ b/frontend/types/SoftwareTypes.ts @@ -5,6 +5,7 @@ // SPDX-FileCopyrightText: 2022 - 2023 Netherlands eScience Center // SPDX-FileCopyrightText: 2022 - 2023 dv4all // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) // // SPDX-License-Identifier: Apache-2.0 @@ -13,6 +14,7 @@ */ import {AutocompleteOption} from './AutocompleteOptions' +import {CategoryPath} from './Category' import {Status} from './Organisation' export type CodePlatform = 'github' | 'gitlab' | 'bitbucket' | 'other' @@ -108,6 +110,7 @@ export const SoftwarePropsToSave = [ export type EditSoftwareItem = SoftwareItem & { keywords: KeywordForSoftware[] + categories: CategoriesForSoftware licenses: AutocompleteOption[] image_b64: string | null image_mime_type: string | null @@ -127,6 +130,8 @@ export type KeywordForSoftware = { pos?: number } +export type CategoriesForSoftware = CategoryPath[] + /** * LiCENSES */ diff --git a/frontend/utils/__mocks__/getSoftware.ts b/frontend/utils/__mocks__/getSoftware.ts new file mode 100644 index 000000000..98d8260f3 --- /dev/null +++ b/frontend/utils/__mocks__/getSoftware.ts @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2023 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) +// +// SPDX-License-Identifier: Apache-2.0 + +import {CategoriesForSoftware,} from '~/types/SoftwareTypes' +import {CategoryID, CategoryPath} from '~/types/Category' + +export async function getSoftwareList({url,token}:{url:string,token?:string }){ + return [] +} + +export async function getSoftwareItem(slug:string|undefined, token?:string){ + return [] +} + +export async function getRepostoryInfoForSoftware(software: string | undefined, token?: string) { + return [] +} + + +/** + * CITATIONS + * @param uuid as software_id + * @returns SoftwareVersion[] | null + */ + +export type SoftwareVersion = { + doi: string, + version: string, + doi_registration_date: string +} + +export async function getReleasesForSoftware(uuid:string,token?:string){ + return [] +} + +export async function getKeywordsForSoftware(uuid:string,frontend?:boolean,token?:string){ + return [] +} + +export async function getCategoriesForSoftware(software_id: string, token?: string): Promise { + return [] +} + +export async function getAvailableCategories(): Promise { + return [] +} + +export async function addCategoryToSoftware(softwareId: string, categoryId: CategoryID, token: string) { + return [] +} + +export async function deleteCategoryToSoftware(softwareId: string, categoryId: CategoryID, token: string) { + return null +} + + +/** + * LICENSE + */ + +export type License = { + id:string + software:string + license: string +} + +export async function getLicenseForSoftware(uuid:string,frontend?:boolean,token?:string){ + return [] +} + +/** + * Contributors and mentions counts + */ + +export type ContributorMentionCount = { + id: string + contributor_cnt: number | null + mention_cnt: number | null +} + +export async function getContributorMentionCount(uuid: string,token?: string){ + return [] +} + +/** + * REMOTE MARKDOWN FILE + */ +export async function getRemoteMarkdown(url: string) { + return [] +} + +export function getRemoteMarkdownTest(url: string) { + return [] +} + +// RELATED PROJECTS FOR SORFTWARE +export async function getRelatedProjectsForSoftware({software, token, frontend, approved=true}: + { software: string, token?: string, frontend?: boolean, approved?:boolean }) { + return [] +} diff --git a/frontend/utils/categories.ts b/frontend/utils/categories.ts new file mode 100644 index 000000000..792d90b3a --- /dev/null +++ b/frontend/utils/categories.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) +// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences +// +// SPDX-License-Identifier: Apache-2.0 + +import {useMemo} from 'react' +import {CategoryEntry, CategoryPath, CategoryTree, CategoryTreeLevel} from '~/types/Category' +import logger from './logger' + +export const leaf = (list: T[]) => list[list.length - 1] + +const compareCategoryEntry = (p1: CategoryEntry, p2: CategoryEntry) => p1.short_name.localeCompare(p2.short_name) +const compareCategoryTreeLevel = (p1: CategoryTreeLevel, p2: CategoryTreeLevel) => compareCategoryEntry(p1.category, p2.category) + +const categoryTreeSort = (tree: CategoryTree) => { + tree.sort(compareCategoryTreeLevel) + for (const item of tree) { + categoryTreeSort(item.children) + } +} + +export const genCategoryTree = (categories: CategoryPath[]) : CategoryTree => { + const tree: CategoryTree = [] + try { + for (const path of categories) { + let cursor = tree + for (const item of path) { + const found = cursor.find(el => el.category.id == item.id) + if (found) { + cursor = found.children + } else { + const sub: CategoryTreeLevel = {category: item, children: []} + cursor.push(sub) + cursor = sub.children + } + } + } + + categoryTreeSort(tree) + + return tree + } catch (e: any) { + logger(`genCategoryTree failed to process data: ${e.message}`, 'error') + return [] + } +} + +export const useCategoryTree = (categories: CategoryPath[]) : CategoryTree => { + return useMemo(() => genCategoryTree(categories), [categories]) +} diff --git a/frontend/utils/getSoftware.ts b/frontend/utils/getSoftware.ts index 3cb312f60..e583f179b 100644 --- a/frontend/utils/getSoftware.ts +++ b/frontend/utils/getSoftware.ts @@ -3,14 +3,17 @@ // SPDX-FileCopyrightText: 2022 - 2023 Ewan Cahen (Netherlands eScience Center) // SPDX-FileCopyrightText: 2022 - 2023 Netherlands eScience Center // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) +// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences // // SPDX-License-Identifier: Apache-2.0 import logger from './logger' -import {KeywordForSoftware, RepositoryInfo, SoftwareItem, SoftwareOverviewItemProps} from '../types/SoftwareTypes' +import {CategoriesForSoftware, KeywordForSoftware, RepositoryInfo, SoftwareItem, SoftwareOverviewItemProps} from '../types/SoftwareTypes' import {extractCountFromHeader} from './extractCountFromHeader' import {createJsonHeaders, getBaseUrl} from './fetchHelpers' import {RelatedProjectForSoftware} from '~/types/Project' +import {CategoryID, CategoryPath} from '~/types/Category' /* * Software list for the software overview page @@ -33,7 +36,7 @@ export async function getSoftwareList({url,token}:{url:string,token?:string }){ count: extractCountFromHeader(resp.headers), data: json } - } else{ + } else { logger(`getSoftwareList failed: ${resp.status} ${resp.statusText} ${url}`, 'warn') return { count:0, @@ -151,7 +154,6 @@ export async function getReleasesForSoftware(uuid:string,token?:string){ } } - export async function getKeywordsForSoftware(uuid:string,frontend?:boolean,token?:string){ try{ // this request is always perfomed from backend @@ -179,6 +181,90 @@ export async function getKeywordsForSoftware(uuid:string,frontend?:boolean,token } } +function prepareQueryURL(path: string, params?: Record) { + const baseURL = getBaseUrl() + logger(`prepareQueryURL baseURL:${baseURL}`) + let url = `${baseURL}${path}` + if (params) { + const paramStr = Object.keys(params).map((key) => `${key}=${encodeURIComponent(params[key])}`).join('&') + if (paramStr) url += '?' + paramStr + } + return url +} + +export async function getCategoriesForSoftware(software_id: string, token?: string): Promise { + try { + const url = prepareQueryURL('/rpc/category_paths_by_software_expanded', {software_id}) + const resp = await fetch(url, { + method: 'GET', + headers: createJsonHeaders(token) + }) + if (resp.status === 200) { + const data = await resp.json() + logger(`getCategoriesForSoftware response: ${JSON.stringify(data)}`) + return data + } else if (resp.status === 404) { + logger(`getCategoriesForSoftware: 404 [${url}]`, 'error') + } + } catch (e: any) { + logger(`getCategoriesForSoftware: ${e?.message}`, 'error') + } + return [] +} + +export async function getAvailableCategories(): Promise { + try { + const url = prepareQueryURL('/rpc/available_categories_expanded') + const resp = await fetch(url, { + method: 'GET', + }) + if (resp.status === 200) { + const data = await resp.json() + return data + } else if (resp.status === 404) { + logger(`getAvailableCategories: 404 [${url}]`, 'error') + } + } catch (e: any) { + logger(`getAvailableCategories: ${e?.message}`, 'error') + } + return [] +} + +export async function addCategoryToSoftware(softwareId: string, categoryId: CategoryID, token: string) { + const url = prepareQueryURL('/category_for_software') + const data = {software_id: softwareId, category_id: categoryId} + + const resp = await fetch(url, { + method: 'POST', + headers: { + ...createJsonHeaders(token), + }, + 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) { + const url = prepareQueryURL(`/category_for_software?software_id=eq.${softwareId}&category_id=eq.${categoryId}`) + + const resp = await fetch(url, { + method: 'DELETE', + headers: { + ...createJsonHeaders(token), + }, + }) + logger(`deleteCategoryToSoftware: resp: ${resp}`) + if (resp.ok) { + return null + } + throw new Error(`API returned: ${resp.status} ${resp.statusText}`) +} + + /** * LICENSE */ From 5ac0b9b768410914143a2a11f963175054217595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20M=C3=BChlbauer?= Date: Fri, 15 Sep 2023 14:44:41 +0200 Subject: [PATCH 2/2] test: add tests for categories --- backend-tests/pom.xml | 18 +- .../AuthenticationIntegrationTest.java | 283 ++++++++++++++---- .../test/java/nl/esciencecenter/Commons.java | 13 + .../src/test/java/nl/esciencecenter/User.java | 102 +++++++ 4 files changed, 350 insertions(+), 66 deletions(-) create mode 100644 backend-tests/src/test/java/nl/esciencecenter/Commons.java create mode 100644 backend-tests/src/test/java/nl/esciencecenter/User.java diff --git a/backend-tests/pom.xml b/backend-tests/pom.xml index 4e478111c..dd0424028 100644 --- a/backend-tests/pom.xml +++ b/backend-tests/pom.xml @@ -8,8 +8,8 @@ SPDX-License-Identifier: Apache-2.0 --> + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 nl.research-software @@ -38,7 +38,8 @@ SPDX-License-Identifier: Apache-2.0 3.1.0 - + org.apache.maven.surefire surefire-junit-platform @@ -62,6 +63,15 @@ SPDX-License-Identifier: Apache-2.0 + + + + com.google.code.gson + gson + 2.10.1 + test + + com.auth0 @@ -86,4 +96,4 @@ SPDX-License-Identifier: Apache-2.0 - + \ No newline at end of file diff --git a/backend-tests/src/test/java/nl/esciencecenter/AuthenticationIntegrationTest.java b/backend-tests/src/test/java/nl/esciencecenter/AuthenticationIntegrationTest.java index 81869d1dd..36b416c27 100644 --- a/backend-tests/src/test/java/nl/esciencecenter/AuthenticationIntegrationTest.java +++ b/backend-tests/src/test/java/nl/esciencecenter/AuthenticationIntegrationTest.java @@ -1,4 +1,6 @@ // SPDX-FileCopyrightText: 2023 Ewan Cahen (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 Felix Mühlbauer (GFZ) +// SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences // SPDX-FileCopyrightText: 2023 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 @@ -7,10 +9,16 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; +import com.google.gson.JsonObject; + import io.restassured.RestAssured; import io.restassured.http.ContentType; import io.restassured.http.Header; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; + import org.hamcrest.CoreMatchers; +import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -22,37 +30,10 @@ import java.net.http.HttpResponse; import java.util.Date; import java.util.List; -import java.util.UUID; public class AuthenticationIntegrationTest { - static final long ONE_HOUR_IN_MILLISECONDS = 3600_000L; // 60 * 60 * 1000 - static String adminToken; - - static String userJwt(String account) { - String secret = System.getenv("PGRST_JWT_SECRET"); - Algorithm signingAlgorithm = Algorithm.HMAC256(secret); - - return JWT.create() - .withClaim("account", account) - .withClaim("iss", "rsd_test") - .withClaim("role", "rsd_user") - .withExpiresAt(new Date(System.currentTimeMillis() + ONE_HOUR_IN_MILLISECONDS)) - .sign(signingAlgorithm); - } - - static String createUser() { - return RestAssured.given() - .header(new Header("Authorization", "bearer " + adminToken)) - .header(new Header("Prefer", "return=representation")) - .when() - .post("account") - .then() - .statusCode(201) - .contentType(ContentType.JSON) - .extract() - .path("[0].id"); - } + static Header adminAuthHeader; @BeforeAll static void checkBackendAvailable() throws InterruptedException { @@ -63,10 +44,12 @@ static void checkBackendAvailable() throws InterruptedException { for (int i = 1; i <= maxTries; i++) { try { client.send(request, HttpResponse.BodyHandlers.discarding()); - System.out.println("Attempt %d/%d to connect to the backend on %s succeeded, continuing with the tests".formatted(i, maxTries,backendUri)); + System.out.println("Attempt %d/%d to connect to the backend on %s succeeded, continuing with the tests" + .formatted(i, maxTries, backendUri)); return; } catch (IOException e) { - System.out.println("Attempt %d/%d to connect to the backend on %s failed, trying again in 1 second".formatted(i, maxTries,backendUri)); + System.out.println("Attempt %d/%d to connect to the backend on %s failed, trying again in 1 second" + .formatted(i, maxTries, backendUri)); Thread.sleep(1000); } } @@ -82,27 +65,28 @@ static void setupRestAssured() { String secret = System.getenv("PGRST_JWT_SECRET"); Algorithm signingAlgorithm = Algorithm.HMAC256(secret); - adminToken = JWT.create() + String adminToken = JWT.create() .withClaim("iss", "rsd_test") .withClaim("role", "rsd_admin") - .withExpiresAt(new Date(System.currentTimeMillis() + ONE_HOUR_IN_MILLISECONDS)) + .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60)) // expires in one hour .sign(signingAlgorithm); + adminAuthHeader = new Header("Authorization", "bearer " + adminToken); + + User.adminAuthHeader = adminAuthHeader; } @Test void givenAdmin_whenCreatingAccount_thenSuccess() { - createUser(); + User.create(false); } @Test void givenUserWithoutAgreeingOnTerms_whenCreatingSoftware_thenNotAllowed() { - String accountId = createUser(); - - String userToken = userJwt(accountId); + User user = User.create(false); String expectedMessage = "You need to agree to our Terms of Service and the Privacy Statement before proceeding. Please open your user profile settings to agree."; RestAssured.given() - .header(new Header("Authorization", "bearer " + userToken)) + .header(user.authHeader) .contentType(ContentType.JSON) .body("{\"slug\": \"test-slug-user\", \"brand_name\": \"Test software user\", \"is_published\": true, \"short_statement\": \"Test software for testing\"}") .when() @@ -114,24 +98,23 @@ void givenUserWithoutAgreeingOnTerms_whenCreatingSoftware_thenNotAllowed() { @Test void givenUserWhoAgreedOnTerms_whenCreatingAndEditingSoftware_thenSuccess() { - String accountId = createUser(); + User user = User.create(false); - String userToken = userJwt(accountId); - - RestAssured.given() - .header(new Header("Authorization", "bearer " + userToken)) + RestAssured.given().log().all() + .header(user.authHeader) .contentType(ContentType.JSON) .body("{\"agree_terms\": true, \"notice_privacy_statement\": true}") .when() - .patch("account?id=eq." + accountId) + .patch("account?id=eq." + user.accountId) .then() .statusCode(204); - String slug = UUID.randomUUID().toString(); + String slug = Commons.createUUID(); RestAssured.given() - .header(new Header("Authorization", "bearer " + userToken)) + .header(user.authHeader) .contentType(ContentType.JSON) - .body("{\"slug\": \"%s\", \"brand_name\": \"Test software user\", \"is_published\": true, \"short_statement\": \"Test software for testing\"}".formatted(slug)) + .body("{\"slug\": \"%s\", \"brand_name\": \"Test software user\", \"is_published\": true, \"short_statement\": \"Test software for testing\"}" + .formatted(slug)) .when() .post("software") .then() @@ -139,8 +122,8 @@ void givenUserWhoAgreedOnTerms_whenCreatingAndEditingSoftware_thenSuccess() { String getStartedUrl = "https://www.example.com"; String patchedGetStartedUrl = RestAssured.given() - .header(new Header("Authorization", "bearer " + userToken)) - .header(new Header("Prefer", "return=representation")) + .header(user.authHeader) + .header(Commons.requestEntry) .contentType(ContentType.JSON) .body("{\"get_started_url\": \"%s\"}".formatted(getStartedUrl)) .when() @@ -154,33 +137,31 @@ void givenUserWhoAgreedOnTerms_whenCreatingAndEditingSoftware_thenSuccess() { @Test void givenUserWhoAgreedOnTerms_whenEditingSoftwareNotMaintainer_thenNowAllowed() { - String slug = UUID.randomUUID().toString(); + String slug = Commons.createUUID(); RestAssured.given() - .header(new Header("Authorization", "bearer " + adminToken)) + .header(adminAuthHeader) .contentType(ContentType.JSON) - .body("{\"slug\": \"%s\", \"brand_name\": \"Test software user\", \"is_published\": true, \"short_statement\": \"Test software for testing\"}".formatted(slug)) + .body("{\"slug\": \"%s\", \"brand_name\": \"Test software user\", \"is_published\": true, \"short_statement\": \"Test software for testing\"}" + .formatted(slug)) .when() .post("software") .then() .statusCode(201); - String accountId = createUser(); - - String userToken = userJwt(accountId); + User user = User.create(false); RestAssured.given() - .header(new Header("Authorization", "bearer " + userToken)) + .header(user.authHeader) .contentType(ContentType.JSON) .body("{\"agree_terms\": true, \"notice_privacy_statement\": true}") .when() - .patch("account?id=eq." + accountId) + .patch("account?id=eq." + user.accountId) .then() .statusCode(204); - String getStartedUrl = "https://www.example.com"; List response = RestAssured.given() - .header(new Header("Authorization", "bearer " + userToken)) + .header(user.authHeader) .header(new Header("Prefer", "return=representation")) .contentType(ContentType.JSON) .body("{\"get_started_url\": \"%s\"}".formatted(getStartedUrl)) @@ -211,18 +192,19 @@ void givenUnauthenticatedUser_whenViewingTables_thenSuccess() { @Test void givenUnauthenticatedUser_whenViewingUnpublishedSoftware_thenNothingReturned() { - String slug = UUID.randomUUID().toString(); + String slug = Commons.createUUID(); RestAssured.given() - .header(new Header("Authorization", "bearer " + adminToken)) + .header(adminAuthHeader) .contentType(ContentType.JSON) - .body("{\"slug\": \"%s\", \"brand_name\": \"Test software user\", \"is_published\": false, \"short_statement\": \"Test software for testing\"}".formatted(slug)) + .body("{\"slug\": \"%s\", \"brand_name\": \"Test software user\", \"is_published\": false, \"short_statement\": \"Test software for testing\"}" + .formatted(slug)) .when() .post("software") .then() .statusCode(201); List response = RestAssured.when() - .get("software?slug=eq." + slug) + .get("software?slug=eq." + slug) .then() .statusCode(200) .extract() @@ -249,4 +231,181 @@ void givenUnauthenticatedUser_whenEditingAnyTable_thenNotAllowed() { .then() .statusCode(401); } + + /* + * ============================ + * === Tests for categories === + * ============================ + */ + + @Test + void givenUnauthenticatedUser_createCategory() { + requestCreateDummyCategory(null) + .then() + .statusCode(401); + + } + + @Test + void givenAuthenticatedUser_createCategory() { + requestCreateDummyCategory(User.create().authHeader) + .then() + .statusCode(403); + } + + @Test + void givenAdmin_createCategory() { + requestCreateDummyCategory(adminAuthHeader) + .then() + .statusCode(201); + } + + @Test + void assignCategory_toOwnSoftware() { + String categoryId = createUniqueCategory("long name", "short name", null); + + User user = User.create(); + String softwareId = user.createSoftware("Software 1"); + requestAddCategoryForSoftware(user, softwareId, categoryId) + .then() + .statusCode(201); + } + + @Test + void assignCategory_toNotOwnSoftware() { + String categoryId = createUniqueCategory("long name", "short name", null); + + User user1 = User.create(); + String softwareId = user1.createSoftware("Software 1"); + + User user2 = User.create(); + requestAddCategoryForSoftware(user2, softwareId, categoryId) + .then() + .statusCode(403); + } + + @Test + void checkRPC_categoryPathsBySoftwareExpanded() { + String[] catIds = createCategoryTreeExample1(); + + User user = User.create(); + String softwareId = user.createSoftware("Software 1"); + + addCategoryForSoftware(user, softwareId, catIds[0]); + addCategoryForSoftware(user, softwareId, catIds[1]); + + JsonObject obj = new JsonObject(); + obj.addProperty("software_id", softwareId); + + RestAssured.given() + .contentType(ContentType.JSON) + .body(obj.toString()) + .when() + .post("/rpc/category_paths_by_software_expanded") + .then() + .contentType(ContentType.JSON) + // check if category IDs appear in proper location of result (=CategoryPath[]) + .body("[0][1].id", CoreMatchers.equalTo(catIds[0])) + .body("[1][1].id", CoreMatchers.equalTo(catIds[1])); + } + + @Test + void categoriesMustNotContainCycles() { + String catId1 = createUniqueCategory("category 1", "category 1", null); + String catId2 = createUniqueCategory("category 2", "category 2", catId1); + String catId3 = createUniqueCategory("category 3", "category 3", catId2); + + // now patch parent of category 1 to category 3 + JsonObject obj = new JsonObject(); + obj.addProperty("parent", catId3); + + RestAssured.given() + .header(adminAuthHeader) + .contentType(ContentType.JSON) + .body(obj.toString()) + .when() + .patch("category?id=eq." + catId1) + .then() + .statusCode(400) + .body("message", Matchers.containsStringIgnoringCase("cycle detected")); + } + + String[] createCategoryTreeExample1() { + String catId1 = createUniqueCategory("top level 1", "top level 1 long", null); + String catId1_1 = createUniqueCategory("sub category 1.1", "sub category 1.1 long", catId1); + String catId1_2 = createUniqueCategory("sub category 1.2", "sub category 1.2 long", catId1); + String catId2 = createUniqueCategory("top level 2", "top level 2 long", null); + String catId2_1 = createUniqueCategory("sub category 2.1", "sub category 2.1 long", catId2); + String catId2_2 = createUniqueCategory("sub category 2.2", "sub category 2.2 long", catId2); + return new String[] { catId1_1, catId1_2, catId2_1, catId2_2 }; + } + + String createCategory(String name, String short_name, String parentId, boolean makeUnique) { + + if (makeUnique) { + String unique = " (" + Commons.createUUID() + ")"; + name += unique; + short_name += unique; + } + + JsonObject obj = new JsonObject(); + obj.addProperty("name", name); + obj.addProperty("short_name", short_name); + obj.addProperty("parent", parentId); + + return RestAssured.given() + .header(adminAuthHeader) + .header(Commons.requestEntry) + .contentType(ContentType.JSON) + .body(obj.toString()) + .when() + .post("category") + .then() + .statusCode(201) + .extract() + .path("[0].id"); + } + + String createUniqueCategory(String name, String short_name, String parentId) { + return createCategory(name, short_name, parentId, true); + } + + void addCategoryForSoftware(User user, String softwareId, String categoryId) { + requestAddCategoryForSoftware(user, softwareId, categoryId) + .then() + .statusCode(201); + } + + Response requestAddCategoryForSoftware(User user, String softwareId, String categoryId) { + + JsonObject obj = new JsonObject(); + obj.addProperty("software_id", softwareId); + obj.addProperty("category_id", categoryId); + + return RestAssured.given() + .header(user.authHeader) + .contentType(ContentType.JSON) + .body(obj.toString()) + .when() + .post("category_for_software"); + } + + Response requestCreateDummyCategory(Header authHeader) { + + String unique = Commons.createUUID(); + + JsonObject obj = new JsonObject(); + obj.addProperty("name", unique); + obj.addProperty("short_name", unique); + + RequestSpecification request = RestAssured.given(); + if (authHeader != null) { + request.header(authHeader); + } + return request + .contentType(ContentType.JSON) + .body(obj.toString()) + .when() + .post("category"); + } } diff --git a/backend-tests/src/test/java/nl/esciencecenter/Commons.java b/backend-tests/src/test/java/nl/esciencecenter/Commons.java new file mode 100644 index 000000000..a12961968 --- /dev/null +++ b/backend-tests/src/test/java/nl/esciencecenter/Commons.java @@ -0,0 +1,13 @@ +package nl.esciencecenter; + +import java.util.UUID; + +import io.restassured.http.Header; + +public class Commons { + static final Header requestEntry = new Header("Prefer", "return=representation"); + + static String createUUID() { + return UUID.randomUUID().toString(); + } +} diff --git a/backend-tests/src/test/java/nl/esciencecenter/User.java b/backend-tests/src/test/java/nl/esciencecenter/User.java new file mode 100644 index 000000000..4e3dcf154 --- /dev/null +++ b/backend-tests/src/test/java/nl/esciencecenter/User.java @@ -0,0 +1,102 @@ +package nl.esciencecenter; + +import java.util.Date; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.google.gson.JsonObject; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.http.Header; + +public class User { + String accountId; + String token; + Header authHeader; + + static Header adminAuthHeader; + + static User create(boolean hasAgreedTerms) { + if (adminAuthHeader == null) { + // throw new Exception("User.adminAuthHeader is not initialized."); + System.out.println("ERROR: User.adminAuthHeader is not initialized."); + } + + String accountId = RestAssured.given() + .header(adminAuthHeader) + .header(Commons.requestEntry) + .when() + .post("account") + .then() + .statusCode(201) + .extract() + .path("[0].id"); + + return new User(accountId, hasAgreedTerms); + } + + static User create() { + return create(true); + } + + User agreeTerms() { + JsonObject obj = new JsonObject(); + obj.addProperty("agree_terms", true); + obj.addProperty("notice_privacy_statement", true); + + RestAssured.given() + .header(authHeader) + .contentType(ContentType.JSON) + .body(obj.toString()) + .when() + .patch("account?id=eq." + accountId) + .then() + .statusCode(204); + return this; + } + + String createSoftware(String brand_name) { + + JsonObject obj = new JsonObject(); + obj.addProperty("slug", "slug-" + Commons.createUUID()); + obj.addProperty("brand_name", brand_name); + obj.addProperty("is_published", true); + obj.addProperty("short_statement", "Test software for testing"); + + return RestAssured.given() + .header(authHeader) + .header(Commons.requestEntry) + .contentType(ContentType.JSON) + .body(obj.toString()) + .when() + .post("software") + .then() + .statusCode(201) + .extract() + .path("[0].id"); + } + + // To create User objects use create() instead. + User(String accountId, boolean hasAgreedTerms) { + this.accountId = accountId; + token = createJwtToken(accountId); + authHeader = new Header("Authorization", "bearer " + token); + + if (hasAgreedTerms) { + agreeTerms(); + } + } + + static String createJwtToken(String accountId) { + String secret = System.getenv("PGRST_JWT_SECRET"); + Algorithm signingAlgorithm = Algorithm.HMAC256(secret); + + return JWT.create() + .withClaim("account", accountId) + .withClaim("iss", "rsd_test") + .withClaim("role", "rsd_user") + .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60)) // expires in one hour + .sign(signingAlgorithm); + } +} \ No newline at end of file