diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 4f650ecb..7c0f04f3 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -35,6 +35,7 @@ jobs:
requirements-level: [pypi]
db-service: [postgresql10, postgresql13]
search-service: [opensearch2,elasticsearch7]
+ node-version: [16.x]
exclude:
- python-version: 3.7
db-service: postgresql13
@@ -55,6 +56,13 @@ jobs:
- name: Checkout
uses: actions/checkout@v2
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v1
+ with:
+ node-version: ${{ matrix.node-version }}
+ - name: Run eslint test
+ run: ./run-js-linter.sh -i
+
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
@@ -82,3 +90,15 @@ jobs:
- name: Run tests
run: |
./run-tests.sh
+
+ - name: Install deps for frontend tests
+ working-directory: ./invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies
+ run: npm install
+
+ - name: Install deps for frontend tests - translations
+ working-directory: ./invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies
+ run: npm install
+
+ - name: Run frontend tests
+ working-directory: ./invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies
+ run: npm test
diff --git a/MANIFEST.in b/MANIFEST.in
index 2f4fd341..46507706 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -26,6 +26,8 @@ recursive-include invenio_vocabularies *.html
recursive-include invenio_vocabularies *.json
recursive-include invenio_vocabularies *.py
recursive-include invenio_vocabularies/translations *.po *.pot *.mo
+recursive-include invenio_vocabularies *.js *.prettierrc *.yml
+recursive-include invenio_vocabularies *.png
recursive-include tests *.json
recursive-include tests *.py
recursive-include tests *.yaml
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/.eslintrc.yml b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/.eslintrc.yml
new file mode 100644
index 00000000..6afc3c23
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/.eslintrc.yml
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of Invenio.
+# Copyright (C) 2023 CERN.
+#
+# Invenio is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+
+extends:
+ - '@inveniosoftware/eslint-config-invenio'
+ - '@inveniosoftware/eslint-config-invenio/prettier'
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/.prettierrc b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/.prettierrc
new file mode 100644
index 00000000..057ddf29
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/.prettierrc
@@ -0,0 +1 @@
+"@inveniosoftware/eslint-config-invenio/prettier-config.js"
\ No newline at end of file
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/index.js b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/index.js
new file mode 100644
index 00000000..6f1ac340
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/index.js
@@ -0,0 +1,7 @@
+// This file is part of InvenioVocabularies
+// Copyright (C) 2023 CERN.
+//
+// Invenio is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+export * from "./src";
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/package.json b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/package.json
new file mode 100644
index 00000000..e927f1f9
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/package.json
@@ -0,0 +1,31 @@
+{
+ "@comment": [
+ "This package.json is needed to run the JS tests, locally and CI."
+ ],
+ "scripts": {
+ "test": "react-scripts test"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^4.2.0",
+ "@testing-library/react": "^9.5.0",
+ "@testing-library/user-event": "^7.2.0",
+ "axios": "^0.21.0",
+ "coveralls": "^3.0.0",
+ "enzyme": "^3.10.0",
+ "enzyme-adapter-react-16": "^1.15.0",
+ "enzyme-to-json": "^3.4.0",
+ "expect": "^26.0.0",
+ "lodash": "^4.17.0",
+ "luxon": "^1.23.0",
+ "react": "^16.13.0",
+ "react-dom": "^16.13.0",
+ "react-scripts": "^5.0.1",
+ "semantic-ui-react": "^2.1.0",
+ "react-overridable": "^0.0.3"
+ },
+ "jest": {
+ "snapshotSerializers": [
+ "enzyme-to-json/serializer"
+ ]
+ }
+}
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/AwardResults.js b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/AwardResults.js
new file mode 100644
index 00000000..57cf579d
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/AwardResults.js
@@ -0,0 +1,95 @@
+// This file is part of InvenioVocabularies
+// Copyright (C) 2021-2023 CERN.
+// Copyright (C) 2021 Northwestern University.
+//
+// Invenio is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+import React from "react";
+import PropTypes from "prop-types";
+import _get from "lodash/get";
+import { Item, Header, Radio, Label, Icon } from "semantic-ui-react";
+import { withState } from "react-searchkit";
+import { FastField } from "formik";
+
+export const AwardResults = withState(
+ ({
+ currentResultsState: results,
+ deserializeAward,
+ deserializeFunder,
+ computeFundingContents,
+ }) => {
+ return (
+
+ {({ form: { values, setFieldValue } }) => {
+ return (
+
+ {results.data.hits.map((award) => {
+ let funder = award?.funder;
+ const deserializedAward = deserializeAward(award);
+ const deserializedFunder = deserializeFunder(funder);
+ const funding = {
+ award: deserializedAward,
+ funder: deserializedFunder,
+ };
+ let { headerContent, descriptionContent, awardOrFunder } =
+ computeFundingContents(funding);
+
+ return (
+ - setFieldValue("selectedFunding", funding)}
+ className="license-item"
+ >
+ setFieldValue("selectedFunding", funding)}
+ />
+
+
+ {headerContent}
+ {awardOrFunder === "award"
+ ? award.number && (
+
+ )
+ : ""}
+ {awardOrFunder === "award"
+ ? award.url && (
+
+
+
+ )
+ : ""}
+
+
+ {descriptionContent}
+
+
+
+ );
+ })}
+
+ );
+ }}
+
+ );
+ }
+);
+
+AwardResults.propTypes = {
+ deserializeAward: PropTypes.func.isRequired,
+ deserializeFunder: PropTypes.func.isRequired,
+ computeFundingContents: PropTypes.func.isRequired,
+};
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/CustomAwardForm.js b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/CustomAwardForm.js
new file mode 100644
index 00000000..5abb6f45
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/CustomAwardForm.js
@@ -0,0 +1,122 @@
+// This file is part of InvenioVocabularies
+// Copyright (C) 2021-2023 CERN.
+// Copyright (C) 2021 Northwestern University.
+//
+// Invenio is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+import PropTypes from "prop-types";
+import React from "react";
+import { Form, Header } from "semantic-ui-react";
+import { TextField, RemoteSelectField } from "react-invenio-forms";
+import { i18next } from "@translations/invenio_rdm_records/i18next";
+import _isEmpty from "lodash/isEmpty";
+
+function CustomAwardForm({ deserializeFunder, selectedFunding }) {
+ function deserializeFunderToDropdown(funderItem) {
+ let funderName = null;
+ let funderPID = null;
+
+ if (funderItem.name) {
+ funderName = funderItem.name;
+ }
+
+ if (funderItem.pid) {
+ funderPID = funderItem.pid;
+ }
+
+ if (!funderName && !funderPID) {
+ return {};
+ }
+
+ return {
+ text: funderName || funderPID,
+ value: funderItem.id,
+ key: funderItem.id,
+ ...(funderName && { name: funderName }),
+ ...(funderPID && { pid: funderPID }),
+ };
+ }
+
+ function serializeFunderFromDropdown(funderDropObject) {
+ return {
+ id: funderDropObject.key,
+ ...(funderDropObject.name && { name: funderDropObject.name }),
+ ...(funderDropObject.pid && { pid: funderDropObject.pid }),
+ };
+ }
+
+ return (
+
+ );
+}
+
+CustomAwardForm.propTypes = {
+ deserializeFunder: PropTypes.func.isRequired,
+ selectedFunding: PropTypes.object,
+};
+
+CustomAwardForm.defaultProps = {
+ selectedFunding: undefined,
+};
+
+export default CustomAwardForm;
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FunderDropdown.js b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FunderDropdown.js
new file mode 100644
index 00000000..0b26678f
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FunderDropdown.js
@@ -0,0 +1,87 @@
+// This file is part of InvenioVocabularies
+// Copyright (C) 2021-2023 CERN.
+// Copyright (C) 2021 Northwestern University.
+//
+// Invenio is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+import React from "react";
+
+import { Dropdown } from "semantic-ui-react";
+import { withState } from "react-searchkit";
+import { i18next } from "@translations/invenio_rdm_records/i18next";
+
+export const FunderDropdown = withState(
+ ({ currentResultsState: awardsList, updateQueryState, currentQueryState }) => {
+ const [fundersFromFacets] = useFundersFromFacets(awardsList);
+
+ /**
+ * Trigger on funder selection.
+ * Updated the query state to filter by the selected funder.
+ *
+ * @param {*} event
+ * @param {*} data
+ */
+ function onFunderSelect(event, data) {
+ let newFilter = [];
+
+ if (data && data.value !== "") {
+ newFilter = ["funders", data.value];
+ }
+ updateQueryState({ ...currentQueryState, filters: newFilter, page: 1 });
+ }
+
+ /**
+ * Custom hook, triggered when the awards list changes.
+ * It retrieves funders from new award's facets.
+ *
+ * @param {object} awards
+ *
+ * @returns {object[]} an array of objects, each representing a facetted funder.
+ */
+ function useFundersFromFacets(awards) {
+ const [result, setResult] = React.useState([]);
+ React.useEffect(() => {
+ /**
+ * Retrieves funders from awards facets and sets the result in state 'result'.
+ */
+ function getFundersFromAwardsFacet() {
+ if (awards.loading) {
+ setResult([]);
+ return;
+ }
+
+ const funders = awards.data.aggregations?.funders?.buckets.map((agg) => {
+ return {
+ key: agg.key,
+ value: agg.key,
+ text: agg.label,
+ };
+ });
+ setResult(funders);
+ }
+
+ getFundersFromAwardsFacet();
+ }, [awards]);
+
+ return [result];
+ }
+
+ return (
+
+ );
+ }
+);
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FundingField.js b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FundingField.js
new file mode 100644
index 00000000..830863a0
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FundingField.js
@@ -0,0 +1,215 @@
+// This file is part of InvenioVocabularies
+// Copyright (C) 2021-2023 CERN.
+// Copyright (C) 2021 Northwestern University.
+//
+// Invenio is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+import React from "react";
+import PropTypes from "prop-types";
+import { FieldArray, getIn } from "formik";
+import { HTML5Backend } from "react-dnd-html5-backend";
+import { DndProvider } from "react-dnd";
+import { Button, Form, Icon, List } from "semantic-ui-react";
+import { FieldLabel } from "react-invenio-forms";
+
+import { FundingFieldItem } from "./FundingFieldItem";
+import FundingModal from "./FundingModal";
+
+import { i18next } from "@translations/invenio_rdm_records/i18next";
+
+function FundingFieldForm(props) {
+ const {
+ label,
+ labelIcon,
+ fieldPath,
+ form: { values },
+ move: formikArrayMove,
+ push: formikArrayPush,
+ remove: formikArrayRemove,
+ replace: formikArrayReplace,
+ required,
+ deserializeAward: deserializeAwardFunc,
+ deserializeFunder: deserializeFunderFunc,
+ computeFundingContents: computeFundingContentsFunc,
+ searchConfig,
+ } = props;
+
+ const deserializeAward = deserializeAwardFunc
+ ? deserializeAwardFunc
+ : (award) => ({
+ title: award?.title_l10n,
+ number: award.number,
+ funder: award.funder ?? "",
+ id: award.id,
+ ...(award.identifiers && { identifiers: award.identifiers }),
+ ...(award.acronym && { acronym: award.acronym }),
+ });
+
+ const deserializeFunder = deserializeFunderFunc
+ ? deserializeFunderFunc
+ : (funder) => ({
+ id: funder.id,
+ name: funder.name,
+ ...(funder.pid && { pid: funder.pid }),
+ ...(funder.country && { country: funder.country }),
+ ...(funder.identifiers && { identifiers: funder.identifiers }),
+ });
+
+ const computeFundingContents = computeFundingContentsFunc
+ ? computeFundingContentsFunc
+ : (funding) => {
+ let headerContent,
+ descriptionContent = "";
+ let awardOrFunder = "award";
+ if (funding.award) {
+ headerContent = funding.award.title;
+ }
+
+ if (funding.funder) {
+ const funderName =
+ funding?.funder?.name ?? funding.funder?.title ?? funding?.funder?.id ?? "";
+ descriptionContent = funderName;
+ if (!headerContent) {
+ awardOrFunder = "funder";
+ headerContent = funderName;
+ descriptionContent = "";
+ }
+ }
+
+ return { headerContent, descriptionContent, awardOrFunder };
+ };
+ return (
+
+
+
+
+ {getIn(values, fieldPath, []).map((value, index) => {
+ const key = `${fieldPath}.${index}`;
+ // if award does not exist or has no id, it's a custom one
+ const awardType = value?.award?.id ? "standard" : "custom";
+ return (
+
+ );
+ })}
+
+
+ {i18next.t("Add award")}
+
+ }
+ onAwardChange={(selectedFunding) => {
+ formikArrayPush(selectedFunding);
+ }}
+ mode="standard"
+ action="add"
+ deserializeAward={deserializeAward}
+ deserializeFunder={deserializeFunder}
+ computeFundingContents={computeFundingContents}
+ />
+
+
+ {i18next.t("Add custom")}
+
+ }
+ onAwardChange={(selectedFunding) => {
+ formikArrayPush(selectedFunding);
+ }}
+ mode="custom"
+ action="add"
+ deserializeAward={deserializeAward}
+ deserializeFunder={deserializeFunder}
+ computeFundingContents={computeFundingContents}
+ />
+
+
+
+ );
+}
+
+FundingFieldForm.propTypes = {
+ label: PropTypes.node,
+ labelIcon: PropTypes.node,
+ fieldPath: PropTypes.string.isRequired,
+ form: PropTypes.object,
+ move: PropTypes.func,
+ push: PropTypes.func,
+ remove: PropTypes.func,
+ replace: PropTypes.func,
+ required: PropTypes.bool,
+ deserializeAward: PropTypes.func,
+ deserializeFunder: PropTypes.func,
+ computeFundingContents: PropTypes.func,
+ searchConfig: PropTypes.object,
+};
+
+FundingFieldForm.defaultProps = {
+ label: undefined,
+ labelIcon: undefined,
+ form: undefined,
+ move: undefined,
+ push: undefined,
+ remove: undefined,
+ replace: undefined,
+ required: undefined,
+ deserializeAward: undefined,
+ deserializeFunder: undefined,
+ computeFundingContents: undefined,
+ searchConfig: undefined,
+};
+
+export function FundingField(props) {
+ const { fieldPath } = props;
+ return (
+ }
+ />
+ );
+}
+
+FundingField.propTypes = {
+ fieldPath: PropTypes.string.isRequired,
+ label: PropTypes.string,
+ labelIcon: PropTypes.string,
+ searchConfig: PropTypes.object.isRequired,
+ required: PropTypes.bool,
+ deserializeAward: PropTypes.func,
+ deserializeFunder: PropTypes.func,
+ computeFundingContents: PropTypes.func,
+};
+
+FundingField.defaultProps = {
+ label: "Awards",
+ labelIcon: "money bill alternate outline",
+ required: false,
+ deserializeAward: undefined,
+ deserializeFunder: undefined,
+ computeFundingContents: undefined,
+};
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FundingField.test.js b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FundingField.test.js
new file mode 100644
index 00000000..2729edae
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FundingField.test.js
@@ -0,0 +1 @@
+it("can contain tests", () => {});
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FundingFieldItem.js b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FundingFieldItem.js
new file mode 100644
index 00000000..e5c6e312
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FundingFieldItem.js
@@ -0,0 +1,152 @@
+// This file is part of InvenioVocabularies
+// Copyright (C) 2021-2023 CERN.
+// Copyright (C) 2021 Northwestern University.
+// Copyright (C) 2021 Graz University of Technology.
+//
+// Invenio is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+import { i18next } from "@translations/invenio_rdm_records/i18next";
+import React from "react";
+import { useDrag, useDrop } from "react-dnd";
+import { Button, Icon, Label, List, Ref } from "semantic-ui-react";
+
+import FundingModal from "./FundingModal";
+import PropTypes from "prop-types";
+
+export const FundingFieldItem = ({
+ compKey,
+ index,
+ fundingItem,
+ awardType,
+ moveFunding,
+ replaceFunding,
+ removeFunding,
+ searchConfig,
+ deserializeAward,
+ deserializeFunder,
+ computeFundingContents,
+}) => {
+ const dropRef = React.useRef(null);
+ // eslint-disable-next-line no-unused-vars
+ const [_, drag, preview] = useDrag({
+ item: { index, type: "award" },
+ });
+ const [{ hidden }, drop] = useDrop({
+ accept: "award",
+ hover(item, monitor) {
+ if (!dropRef.current) {
+ return;
+ }
+ const dragIndex = item.index;
+ const hoverIndex = index;
+
+ // Don't replace items with themselves
+ if (dragIndex === hoverIndex) {
+ return;
+ }
+
+ if (monitor.isOver({ shallow: true })) {
+ moveFunding(dragIndex, hoverIndex);
+ item.index = hoverIndex;
+ }
+ },
+ collect: (monitor) => ({
+ hidden: monitor.isOver({ shallow: true }),
+ }),
+ });
+
+ let { headerContent, descriptionContent, awardOrFunder } =
+ computeFundingContents(fundingItem);
+
+ // Initialize the ref explicitely
+ drop(dropRef);
+ return (
+ [
+
+
+ {
+ replaceFunding(index, selectedFunding);
+ }}
+ mode={awardType}
+ action="edit"
+ trigger={
+
+ }
+ deserializeAward={deserializeAward}
+ deserializeFunder={deserializeFunder}
+ computeFundingContents={computeFundingContents}
+ initialFunding={fundingItem}
+ />
+
+
+
+ ][
+
+ ]
+ [
+
+
+ <>
+ {headerContent}
+
+ {awardOrFunder === "award"
+ ? fundingItem?.award?.number && (
+
+ )
+ : ""}
+ {awardOrFunder === "award"
+ ? fundingItem?.award?.url && (
+
+
+
+ )
+ : ""}
+ >
+
+
+ {descriptionContent ? descriptionContent : ]
}
+
+
+
+
+
+ );
+};
+
+FundingFieldItem.propTypes = {
+ compKey: PropTypes.any,
+ index: PropTypes.number,
+ fundingItem: PropTypes.object,
+ awardType: PropTypes.string,
+ moveFunding: PropTypes.func.isRequired,
+ replaceFunding: PropTypes.func.isRequired,
+ removeFunding: PropTypes.func.isRequired,
+ searchConfig: PropTypes.object.isRequired,
+ deserializeAward: PropTypes.func.isRequired,
+ deserializeFunder: PropTypes.func.isRequired,
+ computeFundingContents: PropTypes.func.isRequired,
+};
+
+FundingFieldItem.defaultProps = {
+ compKey: undefined,
+ index: undefined,
+ fundingItem: undefined,
+ awardType: undefined,
+};
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FundingModal.js b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FundingModal.js
new file mode 100644
index 00000000..0baf00dc
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/FundingModal.js
@@ -0,0 +1,272 @@
+// This file is part of InvenioVocabularies
+// Copyright (C) 2021-2023 CERN.
+// Copyright (C) 2021 Northwestern University.
+//
+// Invenio is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+import { i18next } from "@translations/invenio_rdm_records/i18next";
+import { Formik } from "formik";
+import PropTypes from "prop-types";
+import React, { useState } from "react";
+import {
+ EmptyResults,
+ Error,
+ InvenioSearchApi,
+ Pagination,
+ ReactSearchKit,
+ ResultsLoader,
+ SearchBar,
+} from "react-searchkit";
+import { Grid, Modal, Container, Button } from "semantic-ui-react";
+import * as Yup from "yup";
+import { AwardResults } from "./AwardResults";
+import CustomAwardForm from "./CustomAwardForm";
+import { FunderDropdown } from "./FunderDropdown";
+import { NoAwardResults } from "./NoAwardResults";
+
+const ModalTypes = {
+ STANDARD: "standard",
+ CUSTOM: "custom",
+};
+
+const ModalActions = {
+ ADD: "add",
+ EDIT: "edit",
+};
+
+const StandardSchema = Yup.object().shape({
+ selectedFunding: Yup.object().shape({
+ funder: Yup.object().shape({
+ id: Yup.string().required(),
+ }),
+ award: Yup.object().shape({
+ id: Yup.string().required(),
+ }),
+ }),
+});
+
+const CustomFundingSchema = Yup.object().shape({
+ selectedFunding: Yup.object().shape({
+ funder: Yup.object().shape({
+ id: Yup.string().required(i18next.t("Funder is required.")),
+ }),
+ award: Yup.object().shape({
+ title: Yup.string().test({
+ name: "testTitle",
+ message: i18next.t("Title must be set alongside number."),
+ test: function testTitle(value) {
+ const { number } = this.parent;
+
+ if (number && !value) {
+ return false;
+ }
+
+ return true;
+ },
+ }),
+ number: Yup.string().test({
+ name: "testNumber",
+ message: i18next.t("Number must be set alongside title."),
+ test: function testNumber(value) {
+ const { title } = this.parent;
+
+ if (title && !value) {
+ return false;
+ }
+
+ return true;
+ },
+ }),
+ url: Yup.string()
+ .url(i18next.t("URL must be valid."))
+ .test({
+ name: "validateUrlDependencies",
+ message: i18next.t("URL must be set alongside title and number."),
+ test: function testUrl(value) {
+ const { title, number } = this.parent;
+
+ if (value && value !== "" && !title && !number) {
+ return false;
+ }
+
+ return true;
+ },
+ }),
+ }),
+ }),
+});
+
+function FundingModal({
+ action,
+ mode: initialMode,
+ trigger,
+ onAwardChange,
+ searchConfig,
+ deserializeAward,
+ deserializeFunder,
+ computeFundingContents,
+ ...props
+}) {
+ const [open, setOpen] = useState(false);
+ const [mode, setMode] = useState(initialMode);
+ const openModal = () => setOpen(true);
+ const closeModal = () => {
+ setMode(initialMode);
+ setOpen(false);
+ };
+ const onSubmit = (values, formikBag) => {
+ formikBag.setSubmitting(false);
+ formikBag.resetForm();
+ setMode(initialMode);
+ closeModal();
+ onAwardChange(values.selectedFunding);
+ };
+
+ const searchApi = new InvenioSearchApi(searchConfig.searchApi);
+ const customObject = mode === ModalTypes.CUSTOM ? props.initialFunding : {};
+ const initialFunding = {
+ selectedFunding: action === ModalActions.EDIT ? customObject : {},
+ };
+
+ const FundingSchema =
+ mode === ModalTypes.CUSTOM ? CustomFundingSchema : StandardSchema;
+
+ return (
+
+ {({ values, resetForm, handleSubmit }) => (
+
+
+ {mode === "standard"
+ ? i18next.t("Add standard award")
+ : i18next.t("Add custom award")}
+
+
+ {mode === ModalTypes.STANDARD && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ resetForm();
+ setMode(ModalTypes.CUSTOM);
+ }}
+ />
+
+
+
+ )}
+ {mode === ModalTypes.CUSTOM && (
+
+ )}
+
+
+
+
+ )}
+
+ );
+}
+
+FundingModal.propTypes = {
+ mode: PropTypes.oneOf(["standard", "custom"]).isRequired,
+ action: PropTypes.oneOf(["add", "edit"]).isRequired,
+ trigger: PropTypes.object.isRequired,
+ onAwardChange: PropTypes.func.isRequired,
+ searchConfig: PropTypes.shape({
+ searchApi: PropTypes.shape({
+ axios: PropTypes.shape({
+ headers: PropTypes.object,
+ }),
+ }).isRequired,
+ initialQueryState: PropTypes.object.isRequired,
+ }).isRequired,
+ deserializeAward: PropTypes.func.isRequired,
+ deserializeFunder: PropTypes.func.isRequired,
+ computeFundingContents: PropTypes.func.isRequired,
+ initialFunding: PropTypes.object,
+};
+
+FundingModal.defaultProps = {
+ initialFunding: undefined,
+};
+
+export default FundingModal;
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/NoAwardResults.js b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/NoAwardResults.js
new file mode 100644
index 00000000..2c2adb50
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/NoAwardResults.js
@@ -0,0 +1,37 @@
+// This file is part of InvenioVocabularies
+// Copyright (C) 2021-2023 CERN.
+// Copyright (C) 2021 Northwestern University.
+//
+// Invenio is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+import PropTypes from "prop-types";
+import React from "react";
+import { Segment } from "semantic-ui-react";
+import { i18next } from "@translations/invenio_rdm_records/i18next";
+
+export function NoAwardResults({ switchToCustom }) {
+ return (
+
+ {i18next.t("Did not find your award? ")}
+ {
+ e.preventDefault();
+ switchToCustom();
+ }}
+ >
+ {i18next.t("Add a custom award.")}
+
+
+ }
+ />
+ );
+}
+
+NoAwardResults.propTypes = {
+ switchToCustom: PropTypes.func.isRequired,
+};
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/index.js b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/index.js
new file mode 100644
index 00000000..8bda4f26
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/Funding/index.js
@@ -0,0 +1,8 @@
+// This file is part of InvenioVocabularies
+// Copyright (C) 2021-2023 CERN.
+// Copyright (C) 2021 Northwestern University.
+//
+// Invenio is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+export { FundingField } from "./FundingField";
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/index.js b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/index.js
new file mode 100644
index 00000000..4e8a7812
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/forms/index.js
@@ -0,0 +1,7 @@
+// This file is part of InvenioVocabularies
+// Copyright (C) 2023 CERN.
+//
+// Invenio is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+export { FundingField } from "./Funding";
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/index.js b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/index.js
new file mode 100644
index 00000000..a2c44a39
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/contrib/index.js
@@ -0,0 +1,7 @@
+// This file is part of InvenioVocabularies
+// Copyright (C) 2023 CERN.
+//
+// Invenio is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+export * from "./forms";
diff --git a/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/index.js b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/index.js
new file mode 100644
index 00000000..4eb1c430
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/src/index.js
@@ -0,0 +1,7 @@
+// This file is part of InvenioVocabularies
+// Copyright (C) 2023 CERN.
+//
+// Invenio is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+export * from "./contrib";
diff --git a/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/i18next-scanner.config.js b/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/i18next-scanner.config.js
new file mode 100644
index 00000000..042d279f
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/i18next-scanner.config.js
@@ -0,0 +1,63 @@
+// This file is part of React-Invenio-Deposit
+//
+// Invenio-administration is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+// list of func used to
+// mark the strings for translation
+const { languages } = require("./package.json").config;
+
+const funcList = ["i18next.t"];
+const extensions = [".js", ".jsx"];
+
+module.exports = {
+ options: {
+ debug: true,
+ removeUnusedKeys: true,
+ browserLanguageDetection: true,
+ func: {
+ list: funcList,
+ extensions: extensions,
+ },
+ //using Trans component
+ trans: {
+ component: "Trans",
+ extensions: extensions,
+ fallbackKey: function (ns, value) {
+ return value;
+ },
+ },
+ lngs: languages,
+ ns: [
+ // file name (.json)
+ "translations",
+ ],
+ defaultLng: "en",
+ defaultNs: "translations",
+ // @param {string} lng The language currently used.
+ // @param {string} ns The namespace currently used.
+ // @param {string} key The translation key.
+ // @return {string} Returns a default value for the translation key.
+ defaultValue: function (lng, ns, key) {
+ if (lng === "en") {
+ // Return key as the default value for English language
+ return key;
+ }
+ return "";
+ },
+ resource: {
+ // The path where resources get loaded from. Relative to current working directory.
+ loadPath: "messages/{{lng}}/{{ns}}.json",
+
+ // The path to store resources.
+ savePath: "messages/{{lng}}/{{ns}}.json",
+ jsonIndent: 2,
+ lineEnding: "\n",
+ },
+ nsSeparator: false, // namespace separator
+
+ //Set to false to disable key separator
+ // if you prefer having keys as the fallback for translation (e.g. gettext).
+ keySeparator: false,
+ },
+};
diff --git a/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/i18next.js b/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/i18next.js
new file mode 100644
index 00000000..76dd5d71
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/i18next.js
@@ -0,0 +1,36 @@
+// This file is part of React-Invenio-Deposit
+//
+// Invenio-administration is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+import i18n from "i18next";
+
+import LanguageDetector from "i18next-browser-languagedetector";
+import { translations } from "./messages";
+import { initReactI18next } from "react-i18next";
+
+const options = {
+ fallbackLng: "en", // fallback keys
+ returnEmptyString: false,
+ debug: process.env.NODE_ENV === "development",
+ resources: translations,
+ keySeparator: false,
+ nsSeparator: false,
+ // specify language detection order
+ detection: {
+ order: ["htmlTag"],
+ // cache user language off
+ caches: [],
+ },
+ react: {
+ // Set empty - to allow html tags convert to trans tags
+ // HTML TAG | Trans TAG
+ // | <1>
+ transKeepBasicHtmlNodesFor: [],
+ },
+};
+
+const i18next = i18n.createInstance();
+i18next.use(LanguageDetector).use(initReactI18next).init(options);
+
+export { i18next };
diff --git a/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/messages/index.js b/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/messages/index.js
new file mode 100644
index 00000000..2d2ef0c8
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/messages/index.js
@@ -0,0 +1 @@
+export const translations = {};
diff --git a/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/package.json b/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/package.json
new file mode 100644
index 00000000..1427a20c
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "invenio-vocabularies-ui",
+ "config": {
+ "languages": [
+ "af",
+ "ar",
+ "bg",
+ "ca",
+ "cs",
+ "da",
+ "de",
+ "el",
+ "en",
+ "es",
+ "et",
+ "et_EE",
+ "fa",
+ "fr",
+ "gl",
+ "hr",
+ "hu",
+ "it",
+ "ja",
+ "ka",
+ "lt",
+ "no",
+ "pl",
+ "pt",
+ "ro",
+ "ru",
+ "rw",
+ "sk",
+ "sv",
+ "tr",
+ "uk",
+ "zh_CN",
+ "zh_TW"
+ ]
+ },
+ "devDependencies": {
+ "i18next-conv": "^10.2.0",
+ "i18next-scanner": "^3.0.0",
+ "react-i18next": "^11.11.3",
+ "i18next": "^20.3.0",
+ "i18next-browser-languagedetector": "^6.1.0"
+ },
+ "scripts": {
+ "extract_messages": "i18next-scanner --config i18next-scanner.config.js '../../js/**/*.{js,jsx}'",
+ "postextract_messages": "i18next-conv -l en -s ./messages/en/translations.json -t ./translations.pot",
+ "compile_catalog": "node ./scripts/compileCatalog.js",
+ "init_catalog": "node ./scripts/initCatalog"
+ }
+}
diff --git a/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/scripts/compileCatalog.js b/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/scripts/compileCatalog.js
new file mode 100644
index 00000000..95ffcabc
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/scripts/compileCatalog.js
@@ -0,0 +1,39 @@
+// This file is part of React-Invenio-Deposit
+//
+// Invenio-administration is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+const { readFileSync, writeFileSync } = require("fs");
+const { gettextToI18next } = require("i18next-conv");
+
+const PACKAGE_JSON_BASE_PATH = "./";
+const { languages } = require(`../package`).config;
+
+// it accepts the same options as the cli.
+// https://github.com/i18next/i18next-gettext-converter#options
+const options = {
+ /* you options here */
+};
+
+function save(target) {
+ return (result) => {
+ writeFileSync(target, result);
+ };
+}
+
+if ("lang" === process.argv[2]) {
+ const lang = process.argv[3];
+ gettextToI18next(
+ lang,
+ readFileSync(`${PACKAGE_JSON_BASE_PATH}messages/${lang}/messages.po`),
+ options
+ ).then(save(`${PACKAGE_JSON_BASE_PATH}messages/${lang}/translations.json`));
+} else {
+ for (const lang of languages) {
+ gettextToI18next(
+ lang,
+ readFileSync(`${PACKAGE_JSON_BASE_PATH}messages/${lang}/messages.po`),
+ options
+ ).then(save(`${PACKAGE_JSON_BASE_PATH}messages/${lang}/translations.json`));
+ }
+}
diff --git a/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/scripts/initCatalog.js b/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/scripts/initCatalog.js
new file mode 100644
index 00000000..753e43db
--- /dev/null
+++ b/invenio_vocabularies/assets/semantic-ui/translations/invenio_vocabularies/scripts/initCatalog.js
@@ -0,0 +1,19 @@
+// This file is part of React-Invenio-Deposit
+//
+// Invenio-administration is free software; you can redistribute it and/or modify it
+// under the terms of the MIT License; see LICENSE file for more details.
+
+const { writeFileSync } = require("fs");
+const packageJson = require("../package");
+
+const { languages } = packageJson.config;
+if ("lang" === process.argv[2]) {
+ const addedLang = process.argv[3];
+ languages.push(addedLang);
+ packageJson.config.languages = [...new Set(languages)];
+ writeFileSync(`package.json`, JSON.stringify(packageJson, null, 2));
+} else {
+ console.error(
+ "Error:Please provide a language by running `npm run init_catalog lang `"
+ );
+}
diff --git a/invenio_vocabularies/webpack.py b/invenio_vocabularies/webpack.py
new file mode 100644
index 00000000..cc4a9074
--- /dev/null
+++ b/invenio_vocabularies/webpack.py
@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2019-2022 CERN.
+# Copyright (C) 2019-2022 Northwestern University.
+# Copyright (C) 2022 TU Wien.
+# Copyright (C) 2022 Graz University of Technology.
+#
+# Invenio RDM Records is free software; you can redistribute it and/or modify it
+# under the terms of the MIT License; see LICENSE file for more details.
+
+"""JS/CSS Webpack bundles for theme."""
+
+from invenio_assets.webpack import WebpackThemeBundle
+
+theme = WebpackThemeBundle(
+ __name__,
+ "assets",
+ default="semantic-ui",
+ themes={
+ "semantic-ui": dict(
+ entry={},
+ dependencies={
+ "@babel/runtime": "^7.9.0",
+ "@ckeditor/ckeditor5-build-classic": "^16.0.0",
+ "@ckeditor/ckeditor5-react": "^2.1.0",
+ "formik": "^2.1.0",
+ "i18next": "^20.3.0",
+ "i18next-browser-languagedetector": "^6.1.0",
+ "luxon": "^1.23.0",
+ "path": "^0.12.7",
+ "prop-types": "^15.7.2",
+ "react-copy-to-clipboard": "^5.0.0",
+ "react-dnd": "^11.1.0",
+ "react-dnd-html5-backend": "^11.1.0",
+ "react-dropzone": "^11.0.0",
+ "react-i18next": "^11.11.0",
+ "react-invenio-forms": "^2.0.0",
+ "react-searchkit": "^2.0.0",
+ "yup": "^0.32.0",
+ },
+ aliases={
+ # Define Semantic-UI theme configuration needed by
+ # Invenio-Theme in order to build Semantic UI (in theme.js
+ # entry point). theme.config itself is provided by
+ # cookiecutter-invenio-rdm.
+ "@js/invenio_vocabularies": "js/invenio_vocabularies",
+ "@translations/invenio_vocabularies": "translations/invenio_vocabularies",
+ },
+ ),
+ },
+)
diff --git a/run-js-linter.sh b/run-js-linter.sh
new file mode 100755
index 00000000..a7cf28b7
--- /dev/null
+++ b/run-js-linter.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2022 CERN.
+#
+# Invenio App RDM is free software; you can redistribute it and/or modify it
+# under the terms of the MIT License; see LICENSE file for more details.
+
+# Usage:
+# ./run-js-linter.sh [args]
+
+# Arguments
+# -i|--install: installs eslint-config-invenio
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+NC='\033[0m' # No Color
+
+for arg in $@; do
+ case ${arg} in
+ -i|--install)
+ npm install --no-save --no-package-lock @inveniosoftware/eslint-config-invenio@^2.0.0;;
+ -f|--fix)
+ printf "${GREEN}Run eslint${NC}\n";
+ npx eslint -c invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/.eslintrc.yml invenio_vocabularies/**/*.js --fix;;
+ *)
+ printf "Argument ${RED}$arg${NC} not supported\n"
+ exit;;
+ esac
+done
+
+printf "${GREEN}Run eslint${NC}\n"
+npx eslint -c invenio_vocabularies/assets/semantic-ui/js/invenio_vocabularies/.eslintrc.yml invenio_vocabularies/**/*.js
diff --git a/setup.cfg b/setup.cfg
index cc832b56..88319d4f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -90,7 +90,8 @@ invenio_search.mappings =
names = invenio_vocabularies.contrib.names.mappings
subjects = invenio_vocabularies.contrib.subjects.mappings
vocabularies = invenio_vocabularies.records.mappings
-
+invenio_assets.webpack =
+ invenio_vocabularies = invenio_vocabularies.webpack:theme
invenio_i18n.translations =
invenio_vocabularies = invenio_vocabularies