Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Accordion: add error labels in title & add FeedbackLabel #276

Merged
merged 7 commits into from
Mar 18, 2025
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/lib/elements/accessibility/InvenioPopup.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,24 @@ import { Popup } from "semantic-ui-react";

export class InvenioPopup extends Component {
render() {
const { popupId, size, trigger, content, position, inverted, ariaLabel } =
this.props;
const {
popupId,
size,
trigger,
content,
position,
inverted,
ariaLabel,
hoverable,
} = this.props;

return (
<Popup
id={popupId}
size={size}
position={position}
inverted={inverted}
hoverable={hoverable}
on={["hover", "focus"]}
trigger={React.cloneElement(trigger, {
"role": "button",
Expand All @@ -41,6 +50,7 @@ InvenioPopup.propTypes = {
content: PropTypes.string.isRequired,
popupId: PropTypes.string.isRequired,
inverted: PropTypes.bool,
hoverable: PropTypes.bool,
position: PropTypes.string,
size: PropTypes.string,
};
Expand All @@ -49,4 +59,5 @@ InvenioPopup.defaultProps = {
inverted: false,
position: "top left",
size: "small",
hoverable: false,
};
140 changes: 84 additions & 56 deletions src/lib/forms/AccordionField.js
Original file line number Diff line number Diff line change
@@ -1,57 +1,77 @@
// This file is part of React-Invenio-Forms
// Copyright (C) 2020 CERN.
// Copyright (C) 2020 Northwestern University.
//
// React-Invenio-Forms 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, useState } from "react";
import PropTypes from "prop-types";
import { Field, FastField } from "formik";
import { Accordion, Container, Icon } from "semantic-ui-react";
import { Accordion, Container, Icon, Label } from "semantic-ui-react";
import _omit from "lodash/omit";
import _get from "lodash/get";
import { flattenAndCategorizeErrors } from "../utils";

export class AccordionField extends Component {
hasError(errors, initialValues = undefined, values = undefined) {
const { includesPaths } = this.props;
for (const errorPath in errors) {
for (const subPath in errors[errorPath]) {
const path = `${errorPath}.${subPath}`;
if (
_get(initialValues, path, "") === _get(values, path, "") &&
includesPaths.includes(`${errorPath}.${subPath}`)
)
return true;
hasError(errors, includesPaths) {
return Object.keys(errors).some((errorPath) =>
includesPaths.some((path) => errorPath.startsWith(path))
);
}

getErrorSummary = (errors, includePaths, severityChecks) => {
const count = {};

// Count generic errors
for (const path in errors.flattenedErrors) {
if (includePaths.some((includePath) => path.startsWith(includePath))) {
count["error"] = (count["error"] || 0) + 1;
}
}

// Count severity-based errors
for (const key in errors.severityChecks) {
const severity = errors.severityChecks[key]?.severity;
const path = key;

if (
severity &&
includePaths.some((includePath) => path.startsWith(includePath))
) {
count[severity] = (count[severity] || 0) + 1;
}
}
return false;
}

// Format output to display labels
const formattedCount = {};
for (const [severity, num] of Object.entries(count)) {
const label =
severityChecks?.[severity]?.label ||
severity.charAt(0).toUpperCase() + severity.slice(1);
formattedCount[severity] = `${num} ${label}${num === 1 ? "" : "s"}`;
}

return formattedCount;
};

renderAccordion = (props) => {
const {
form: { errors, status, initialErrors, initialValues, values },
form: { errors, initialErrors },
} = props;
const { includesPaths, label, children, active, severityChecks } = this.props;

const uiProps = _omit(this.props, ["optimized", "includesPaths"]);
const currentErrors = { ...initialErrors, ...errors };
const categorizedErrors = flattenAndCategorizeErrors(currentErrors);

const errorClass = this.hasError(categorizedErrors.flattenedErrors, includesPaths)
? "error secondary"
: "";
const errorSummary = this.getErrorSummary(
categorizedErrors,
includesPaths,
severityChecks
);

// eslint-disable-next-line no-unused-vars
const { label, children, active, ...ui } = this.props;
const uiProps = _omit({ ...ui }, ["optimized", "includesPaths"]);
const hasError = status
? this.hasError(status)
: this.hasError(errors) || this.hasError(initialErrors, initialValues, values);
const panels = [
{
key: `panel-${label}`,
title: {
content: label,
},
content: {
content: <Container>{children}</Container>,
},
},
];

const errorClass = hasError ? "error secondary" : "";
const [activeIndex, setActiveIndex] = useState(active ? 0 : -1);

const handleTitleClick = (e, { index }) => {
Expand All @@ -64,34 +84,40 @@ export class AccordionField extends Component {
className={`invenio-accordion-field ${errorClass}`}
{...uiProps}
>
{panels.map((panel, index) => (
<React.Fragment key={panel.key}>
<Accordion.Title
active={activeIndex === index}
index={index}
onClick={handleTitleClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleTitleClick(e, { index });
}
}}
tabIndex={0}
<Accordion.Title
active={activeIndex === 0}
index={0}
onClick={handleTitleClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleTitleClick(e, { index: 0 });
}
}}
tabIndex={0}
>
{label}
{Object.entries(errorSummary).map(([severity, text]) => (
<Label
key={severity}
size="tiny"
circular
className={`accordion-label ${severity}`}
>
{panel.title.content}
<Icon name="angle right" />
</Accordion.Title>
<Accordion.Content active={activeIndex === index}>
{panel.content.content}
</Accordion.Content>
</React.Fragment>
))}
{text}
</Label>
))}
<Icon name={activeIndex === 0 ? "angle down" : "angle right"} />
</Accordion.Title>

<Accordion.Content active={activeIndex === 0}>
<Container>{children}</Container>
</Accordion.Content>
</Accordion>
);
};

render() {
const { optimized } = this.props;

const FormikField = optimized ? FastField : Field;
return <FormikField name="" component={this.renderAccordion} />;
}
Expand All @@ -104,6 +130,7 @@ AccordionField.propTypes = {
optimized: PropTypes.bool,
children: PropTypes.node,
ui: PropTypes.object,
severityChecks: PropTypes.object,
};

AccordionField.defaultProps = {
Expand All @@ -113,4 +140,5 @@ AccordionField.defaultProps = {
optimized: false,
children: null,
ui: null,
severityChecks: null,
};
66 changes: 66 additions & 0 deletions src/lib/forms/FeedbackLabel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React, { Component } from "react";
import { Label, Icon } from "semantic-ui-react";
import { flattenAndCategorizeErrors } from "../utils";
import { InvenioPopup } from "../elements/accessibility";
import PropTypes from "prop-types";

export class FeedbackLabel extends Component {
render() {
const { errorMessage } = this.props;
if (!errorMessage) return null;

const { flattenedErrors, severityChecks } =
flattenAndCategorizeErrors(errorMessage);

let errorText = "";
let severityLevel = "";
let severityMessage = "";

if (flattenedErrors) {
const errorKey = Object.keys(flattenedErrors)[0];
errorText = flattenedErrors[errorKey];
}

if (severityChecks) {
const severityKey = Object.keys(severityChecks)[0];
const severityData = severityChecks[severityKey];
if (severityData) {
severityLevel = severityData.severity;
severityMessage = severityData.message;
}
}

if (errorMessage.message && errorMessage.severity) {
severityLevel = errorMessage.severity;
severityMessage = errorMessage.message;
}

const prompt = !severityLevel && !!errorText;

return (
<Label pointing="left" className={severityLevel} prompt={prompt}>
{severityMessage && (
<InvenioPopup
trigger={<Icon name="info circle" />}
content={
<a target="_blank" href="_">
{severityMessage}
</a>
}
position="top center"
hoverable
/>
)}
{errorText || severityMessage}
</Label>
);
}
}

FeedbackLabel.propTypes = {
errorMessage: PropTypes.array,
};

FeedbackLabel.defaultProps = {
errorMessage: undefined,
};
1 change: 1 addition & 0 deletions src/lib/forms/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export { BaseForm } from "./BaseForm";
export { BooleanField } from "./BooleanField";
export { ErrorLabel } from "./ErrorLabel";
export { FieldLabel } from "./FieldLabel";
export { FeedbackLabel } from "./FeedbackLabel";
export { GroupField } from "./GroupField";
export { SelectField } from "./SelectField";
export { RemoteSelectField } from "./RemoteSelectField";
Expand Down
2 changes: 2 additions & 0 deletions src/lib/forms/widgets/custom_fields/CustomFields.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,15 @@ export class CustomFields extends Component {
paths,
displaySection = true,
section: sectionName,
id: sectionId,
} = section;
return displaySection ? (
<AccordionField
key={`section-${sectionName}`}
includesPaths={paths}
label={sectionName}
active
id={sectionId}
>
{fields}
</AccordionField>
Expand Down
9 changes: 8 additions & 1 deletion src/lib/forms/widgets/custom_fields/DiscoverFieldsSection.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,16 @@ export class DiscoverFieldsSection extends Component {
...Object.entries(tempFields).map(([key, value]) => value.key),
...recordFields,
];
const tempFieldsPaths = tempFields.map((item) => item.key);

return (
<AccordionField key="discover-fields" label="Domain specific fields" active>
<AccordionField
key="discover-fields"
includesPaths={tempFieldsPaths}
label="Domain specific fields"
active
id="domain-specific-fields-section"
>
{sections.map(({ fields, paths, ...sectionConfig }) => {
const recordCustomFields = this.getFieldsWithValues(fields);
if (_isEmpty(recordCustomFields)) {
Expand Down
42 changes: 42 additions & 0 deletions src/lib/utils/flattenAndCategorizeErrors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// This file is part of React-Invenio-Forms
// Copyright (C) 2025 CERN.
//
// React-Invenio-Forms 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 function flattenAndCategorizeErrors(obj, prefix = "") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is prefix?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the prefix param is used to track the hierarchy of keys when recursively flattening nested objects

let flattenedErrors = {}; // To store flattened errors
let severityChecks = {}; // To store severity-based errors

for (let key in obj) {
let newKey = prefix ? `${prefix}.${key}` : key;
let value = obj[key];

if (value && typeof value === "object") {
if ("message" in value && "severity" in value) {
severityChecks[newKey] = value;
} else if (Array.isArray(value)) {
value.forEach((item, index) => {
let arrayKey = `${newKey}[${index}]`;

// Fix: If item is a string, store it directly instead of iterating over characters
if (typeof item === "string") {
flattenedErrors[arrayKey] = item;
} else {
let nested = flattenAndCategorizeErrors(item, arrayKey);
Object.assign(flattenedErrors, nested.flattenedErrors);
Object.assign(severityChecks, nested.severityChecks);
}
});
} else {
let nested = flattenAndCategorizeErrors(value, newKey);
Object.assign(flattenedErrors, nested.flattenedErrors);
Object.assign(severityChecks, nested.severityChecks);
}
} else {
flattenedErrors[newKey] = value;
}
}

return { flattenedErrors, severityChecks };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put a deprecation warning on flattenedErrors i.e. old error format

}
1 change: 1 addition & 0 deletions src/lib/utils/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { humanReadableBytes } from "./humanReadableBytes";
export { dropdownOptionsGenerator } from "./dropdownOptionsGenerator";
export { flattenAndCategorizeErrors } from "./flattenAndCategorizeErrors";
Loading