diff --git a/CHANGES.md b/CHANGES.md
index 27afc522..d4d0e812 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,9 @@
# Changes
+Version 3.0.0 (released 2024-01-30)
+
+- add discoverable custom fields components
+
Version 2.8.4 (released 2023-12-12)
- Replace CKEditor with TinyMCE
diff --git a/package-lock.json b/package-lock.json
index fc35fdbd..5d932791 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,12 @@
{
"name": "react-invenio-forms",
- "version": "2.8.4",
+ "version": "3.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
- "version": "2.8.2",
+ "name": "react-invenio-forms",
+ "version": "2.8.4",
"license": "MIT",
"devDependencies": {
"@babel/cli": "^7.5.0",
diff --git a/package.json b/package.json
index ee41c2dc..4c6dfd71 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "react-invenio-forms",
- "version": "2.8.4",
+ "version": "3.0.0",
"description": "React components to build forms in Invenio",
"main": "dist/cjs/index.js",
"browser": "dist/cjs/index.js",
diff --git a/src/lib/forms/widgets/custom_fields/ComposeFields.js b/src/lib/forms/widgets/custom_fields/ComposeFields.js
new file mode 100644
index 00000000..b789969d
--- /dev/null
+++ b/src/lib/forms/widgets/custom_fields/ComposeFields.js
@@ -0,0 +1,122 @@
+import _isEmpty from "lodash/isEmpty";
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { Divider } from "semantic-ui-react";
+import { AccordionField } from "../../AccordionField";
+import { FieldLabel } from "../../FieldLabel";
+import { Extensions } from "./Extensions";
+
+export class ComposeFields extends Component {
+ constructor(props) {
+ super(props);
+ const { composeSections, record } = props;
+ const filled = Object.keys(record.custom_fields).map(
+ (key) => `custom_fields.${key}`
+ );
+ this.state = { sections: composeSections, tempFields: [], recordFields: filled };
+ this.fieldsCfg = this.getFieldsConfig(composeSections);
+ this.sectionsList = composeSections.map((section) => section.section);
+ }
+
+ getFieldsConfig = (sectionCfg) => {
+ const cfg = {};
+ for (const section of sectionCfg) {
+ for (const fieldCfg of section.fieldsConfig) {
+ const { field, props, ui_widget, ...otherCfg } = fieldCfg;
+ cfg[field] = { ui_widget: ui_widget, section: section, ...props, ...otherCfg };
+ }
+ }
+
+ return cfg;
+ };
+
+ getFieldsWithValues = (sectionFields) => {
+ const { record } = this.props;
+ const { tempFields, recordFields } = this.state;
+ const filledFields = [];
+ if (!record.custom_fields) {
+ return [];
+ }
+ for (const field of sectionFields) {
+ if (recordFields.includes(field.key) || tempFields.includes(field)) {
+ filledFields.push(field);
+ }
+ }
+ return filledFields;
+ };
+
+ getSectionOfField = (field) => {
+ const { sections } = this.state;
+ for (const section of sections) {
+ if (section.fields.map((field) => field.key).includes(field.key)) {
+ return section.section;
+ }
+ }
+ };
+
+ addFieldCallback = (fields) => {
+ const { sections: prevSections, tempFields: prevTempFields } = this.state;
+
+ const sections = [...prevSections];
+ for (const field of fields) {
+ const sectionToUpdate = this.getSectionOfField(field);
+ for (const section of sections) {
+ if (section.section === sectionToUpdate) {
+ section["fields"] = [...section.fields, field].sort((a, b) =>
+ a.key.localeCompare(b.key)
+ );
+ }
+ }
+ }
+ this.setState({
+ sections: [...sections],
+ tempFields: [...prevTempFields, ...fields],
+ });
+ };
+
+ render() {
+ const { templateLoaders, record } = this.props;
+ const { sections, tempFields, recordFields } = this.state;
+ const existingFields = [
+ ...Object.entries(tempFields).map(([key, value]) => value.key),
+ ...recordFields,
+ ];
+
+ return (
+
+ {sections.map(({ fields, paths, ...sectionConfig }) => {
+ const recordCustomFields = this.getFieldsWithValues(fields);
+ if (_isEmpty(recordCustomFields)) {
+ return undefined;
+ }
+ return (
+
+
+
+
{recordCustomFields}
+
+ );
+ })}
+
+
+ );
+ }
+}
+
+ComposeFields.propTypes = {
+ templateLoaders: PropTypes.array.isRequired,
+ composeSections: PropTypes.array.isRequired,
+ record: PropTypes.object.isRequired,
+};
diff --git a/src/lib/forms/widgets/custom_fields/CustomFields.js b/src/lib/forms/widgets/custom_fields/CustomFields.js
index d410d470..84afd6e9 100644
--- a/src/lib/forms/widgets/custom_fields/CustomFields.js
+++ b/src/lib/forms/widgets/custom_fields/CustomFields.js
@@ -7,54 +7,90 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
+import { ComposeFields } from "./ComposeFields";
import { AccordionField } from "../../AccordionField";
import { loadWidgetsFromConfig } from "../loader";
export class CustomFields extends Component {
- state = { sections: [] };
+ constructor(props) {
+ super(props);
+ this.state = { sections: undefined, composeSections: undefined };
+ }
componentDidMount() {
+ this.populateConfig();
+ }
+
+ populateConfig = async () => {
const { includesPaths, fieldPathPrefix } = this.props;
- // use of `Promise.then()` as eslint is giving an error when calling setState() directly
- // in the componentDidMount() method
- this.loadCustomFieldsWidgets()
- .then((sections) => {
- sections = sections.map((sectionCfg) => {
- const paths = includesPaths(sectionCfg.fields, fieldPathPrefix);
- return { ...sectionCfg, paths };
- });
- this.setState({ sections });
- })
- .catch((error) => {
- console.error("Couldn't load custom fields widgets.", error);
+ try {
+ const { sectionsConfig, composeSectionConfig } =
+ await this.loadCustomFieldsWidgets();
+ const sections = sectionsConfig.map((sectionCfg) => {
+ const paths = includesPaths(sectionCfg.fields, fieldPathPrefix);
+ return { ...sectionCfg, paths };
});
- }
+
+ const composeSections = composeSectionConfig.map((sectionCfg) => {
+ const paths = includesPaths(sectionCfg.fields, fieldPathPrefix);
+ return { ...sectionCfg, paths };
+ });
+
+ this.setState({ sections: sections, composeSections: composeSections });
+ } catch (error) {
+ console.error("Couldn't load custom fields widgets.", error);
+ }
+ };
async loadCustomFieldsWidgets() {
- const { config, fieldPathPrefix, templateLoaders } = this.props;
+ const { config, fieldPathPrefix, templateLoaders, record } = this.props;
const sections = [];
+ const composeFieldSections = [];
for (const sectionCfg of config) {
// Path to end user's folder defining custom fields ui widgets
const fields = await loadWidgetsFromConfig({
templateLoaders: templateLoaders,
fieldPathPrefix: fieldPathPrefix,
fields: sectionCfg.fields,
+ record: record,
});
- sections.push({ ...sectionCfg, fields });
+ if (sectionCfg.compose_fields) {
+ composeFieldSections.push({
+ ...sectionCfg,
+ fields: fields,
+ fieldsConfig: sectionCfg.fields,
+ });
+ } else {
+ sections.push({ ...sectionCfg, fields });
+ }
}
- return sections;
+ return { sectionsConfig: sections, composeSectionConfig: composeFieldSections };
}
render() {
- const { sections } = this.state;
+ const { sections, composeSections } = this.state;
+ const { templateLoaders, record } = this.props;
return (
<>
- {sections.map(({ section, fields, paths }) => (
-
- {fields}
-
- ))}
+ {sections &&
+ sections.map(({ fields, paths, ...sectionConfig }) => (
+
+ {fields}
+
+ ))}
+ {composeSections && composeSections && (
+
+ )}
>
);
}
@@ -76,6 +112,7 @@ CustomFields.propTypes = {
templateLoaders: PropTypes.array.isRequired,
fieldPathPrefix: PropTypes.string.isRequired,
includesPaths: PropTypes.func,
+ record: PropTypes.object.isRequired,
};
CustomFields.defaultProps = {
diff --git a/src/lib/forms/widgets/custom_fields/Extensions.js b/src/lib/forms/widgets/custom_fields/Extensions.js
new file mode 100644
index 00000000..7e745095
--- /dev/null
+++ b/src/lib/forms/widgets/custom_fields/Extensions.js
@@ -0,0 +1,178 @@
+// This file is part of Invenio-RDM-Records
+// Copyright (C) 2020-2023 CERN.
+// Copyright (C) 2020-2022 Northwestern University.
+//
+// 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.
+
+import React, { Component } from "react";
+import { ListAndFilterCustomFields } from "./ListAndFilterCustomFields";
+import { importWidget } from "../loader";
+
+import { Button, Icon, Modal, Divider } from "semantic-ui-react";
+
+import PropTypes from "prop-types";
+
+export class Extensions extends Component {
+ constructor(props) {
+ super(props);
+ const { existingFields } = props;
+ this.state = {
+ modalOpen: false,
+ selectedField: undefined,
+ selectedFieldTarget: undefined,
+ addFields: [],
+ existingFields: [...existingFields],
+ loading: false,
+ };
+ }
+
+ handleModalOpen = () => {
+ this.setState({ modalOpen: true });
+ };
+
+ handleModalClosed = () => {
+ this.setState({ modalOpen: false });
+ };
+
+ handleSelectField = (e, fieldName, field) => {
+ const { selectedFieldTarget: previousSelected } = this.state;
+ if (previousSelected) {
+ previousSelected.classList.toggle("selected-background");
+ }
+ e.currentTarget.classList.toggle("selected-background");
+ const newField = {
+ field: fieldName,
+ props: { ...field },
+ ui_widget: field.ui_widget,
+ };
+ this.setState({
+ selectedField: { ...newField },
+ selectedFieldTarget: e.currentTarget,
+ });
+ };
+
+ handleAddField = async (withClose = false) => {
+ const {
+ selectedField,
+ addFields: prevFields,
+ selectedFieldTarget,
+ existingFields: prevExisting,
+ } = this.state;
+ const { fieldPath, templateLoaders, addFieldCallback } = this.props;
+ this.setState({ loading: true });
+ const field = await importWidget(templateLoaders, {
+ ...selectedField,
+ fieldPath: `${fieldPath}.${selectedField.field}`,
+ });
+
+ const performCallback = (selectedFieldTarget) => {
+ const { addFields } = this.state;
+
+ if (withClose) {
+ addFieldCallback(addFields);
+ this.setState({ addFields: [], existingFields: [] });
+ this.handleModalClosed();
+ }
+ };
+ selectedFieldTarget.classList.toggle("selected-background");
+ this.setState(
+ {
+ addFields: [...prevFields, field],
+ existingFields: [...prevExisting, field.key],
+ selectedField: undefined,
+ selectedFieldTarget: undefined,
+ loading: false,
+ },
+ () => performCallback(selectedFieldTarget)
+ );
+ };
+
+ handleCancel = () => {
+ const { addFields } = this.state;
+ const { addFieldCallback } = this.props;
+ addFieldCallback(addFields);
+ this.setState({ addFields: [] });
+ this.handleModalClosed();
+ };
+
+ render() {
+ const {
+ fieldPath, // injected by the custom field loader via the `field` config property
+ icon,
+ label,
+ record,
+ templateLoaders,
+ addFieldCallback,
+ sections,
+ existingFields: selected,
+ ...fieldsList
+ } = this.props;
+ const { modalOpen, existingFields, loading } = this.state;
+ return (
+ <>
+
+
+
+ Add domain specific fields
+
+
+
+
+
+
+
+ >
+ );
+ }
+}
+
+Extensions.propTypes = {
+ fieldPath: PropTypes.string.isRequired,
+ record: PropTypes.object.isRequired,
+ icon: PropTypes.string,
+ label: PropTypes.string,
+ templateLoaders: PropTypes.array.isRequired,
+ addFieldCallback: PropTypes.func.isRequired,
+ sections: PropTypes.array,
+ existingFields: PropTypes.array.isRequired,
+};
+
+Extensions.defaultProps = {
+ icon: undefined,
+ label: undefined,
+ sections: undefined,
+};
diff --git a/src/lib/forms/widgets/custom_fields/ListAndFilterCustomFields.js b/src/lib/forms/widgets/custom_fields/ListAndFilterCustomFields.js
new file mode 100644
index 00000000..2c03a3a9
--- /dev/null
+++ b/src/lib/forms/widgets/custom_fields/ListAndFilterCustomFields.js
@@ -0,0 +1,168 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import {
+ Dropdown,
+ Grid,
+ Icon,
+ Input,
+ Item,
+ Label,
+ Modal,
+ Segment,
+} from "semantic-ui-react";
+
+export class ListAndFilterCustomFields extends Component {
+ constructor(props) {
+ super(props);
+ const { fieldsList } = props;
+ this.state = {
+ filteredFieldsList: fieldsList,
+ searchPhrase: undefined,
+ filter: undefined,
+ };
+ }
+
+ resetFilter = () => {
+ const { fieldsList } = this.props;
+ this.setState({ filteredFieldsList: fieldsList });
+ };
+
+ filter = () => {
+ const { fieldsList } = this.props;
+ const { searchPhrase, filter } = this.state;
+ if (!searchPhrase && !filter) {
+ this.resetFilter();
+ }
+ const filteredResults = Object.fromEntries(
+ Object.entries(fieldsList).filter(([key, val]) => {
+ if (filter && searchPhrase) {
+ return (
+ val.section.section === filter &&
+ val.label.toLowerCase().includes(searchPhrase.toLowerCase())
+ );
+ } else if (filter) {
+ return val.section.section === filter;
+ } else if (searchPhrase) {
+ return val.label.toLowerCase().includes(searchPhrase.toLowerCase());
+ }
+ })
+ );
+ this.setState({ filteredFieldsList: filteredResults });
+ };
+
+ handleSearch = (e, { value }) => {
+ this.setState({ searchPhrase: value }, () => this.filter());
+ };
+
+ handleDomainFilter = (e, { value }) => {
+ this.setState({ filter: value }, () => this.filter());
+ };
+
+ render() {
+ const { filteredFieldsList } = this.state;
+ const { alreadyAddedFields, fieldPath, handleSelectField, sections } = this.props;
+ const dropdownOptions = sections.map((section) => ({
+ key: section,
+ text: section,
+ value: section,
+ }));
+ return (
+ <>
+
+
+
+
+
+
+
+ in:{" "}
+
+
+
+
+
+
+
+ {Object.entries(filteredFieldsList).map(([key, value]) => {
+ const names = key.split(":");
+
+ const isDisabled = alreadyAddedFields.includes(`${fieldPath}.${key}`);
+
+ return (
+ - (!isDisabled ? handleSelectField(e, key, value) : {})}
+ >
+
+
+ <>
+ {value.label}{" "}
+ {isDisabled && (
+
+ Added
+
+ )}
+ >
+
+
+
+ {value.note}
+
+
+
+
+ {value.multiple_values === true && (
+
+ )}
+ {value.type === "text" && (
+
+ )}
+
+
+
+ );
+ })}{" "}
+
+
+ >
+ );
+ }
+}
+
+ListAndFilterCustomFields.propTypes = {
+ alreadyAddedFields: PropTypes.array.isRequired,
+ fieldsList: PropTypes.array.isRequired,
+ fieldPath: PropTypes.string.isRequired,
+ handleSelectField: PropTypes.func.isRequired,
+ sections: PropTypes.array,
+};
+
+ListAndFilterCustomFields.defaultProps = {
+ sections: undefined,
+};
diff --git a/src/lib/forms/widgets/custom_fields/index.js b/src/lib/forms/widgets/custom_fields/index.js
index b57a438d..a8c85b78 100644
--- a/src/lib/forms/widgets/custom_fields/index.js
+++ b/src/lib/forms/widgets/custom_fields/index.js
@@ -1 +1,2 @@
export { CustomFields } from "./CustomFields";
+export { Extensions } from "./Extensions";
diff --git a/src/lib/forms/widgets/loader.js b/src/lib/forms/widgets/loader.js
index 5081d6e4..84a135b6 100644
--- a/src/lib/forms/widgets/loader.js
+++ b/src/lib/forms/widgets/loader.js
@@ -9,7 +9,7 @@ import React from "react";
*/
export async function importWidget(
templateLoaders,
- { ui_widget: UIWidget, fieldPath, props }
+ { ui_widget: UIWidget, fieldPath, record, props }
) {
let component = undefined;
@@ -36,6 +36,7 @@ export async function importWidget(
return React.createElement(component, {
...props,
+ record: record,
key: fieldPath,
fieldPath: fieldPath,
});
@@ -72,6 +73,7 @@ export async function loadWidgetsFromConfig({
templateLoaders,
fieldPathPrefix,
fields,
+ record,
}) {
const importWidgetsFromFolder = (templateFolder, fieldPathPrefix, fieldsConfig) => {
const tplPromises = [];
@@ -82,6 +84,7 @@ export async function loadWidgetsFromConfig({
fieldPath: fieldPathPrefix
? `${fieldPathPrefix}.${fieldCfg.field}`
: fieldCfg.field,
+ record,
})
);
});