Skip to content

Commit 099acd1

Browse files
forms: add error count label in accordion and add FeedbackLabel
Co-authored-by: Carlin MacKenzie <[email protected]> Co-authored-by: Fatimah Zulfiqar <[email protected]>
1 parent 3dfeb43 commit 099acd1

File tree

8 files changed

+242
-60
lines changed

8 files changed

+242
-60
lines changed

src/lib/elements/accessibility/InvenioPopup.js

+13-2
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,24 @@ import { Popup } from "semantic-ui-react";
1010

1111
export class InvenioPopup extends Component {
1212
render() {
13-
const { popupId, size, trigger, content, position, inverted, ariaLabel } =
14-
this.props;
13+
const {
14+
popupId,
15+
size,
16+
trigger,
17+
content,
18+
position,
19+
inverted,
20+
ariaLabel,
21+
hoverable,
22+
} = this.props;
1523

1624
return (
1725
<Popup
1826
id={popupId}
1927
size={size}
2028
position={position}
2129
inverted={inverted}
30+
hoverable={hoverable}
2231
on={["hover", "focus"]}
2332
trigger={React.cloneElement(trigger, {
2433
"role": "button",
@@ -41,6 +50,7 @@ InvenioPopup.propTypes = {
4150
content: PropTypes.string.isRequired,
4251
popupId: PropTypes.string.isRequired,
4352
inverted: PropTypes.bool,
53+
hoverable: PropTypes.bool,
4454
position: PropTypes.string,
4555
size: PropTypes.string,
4656
};
@@ -49,4 +59,5 @@ InvenioPopup.defaultProps = {
4959
inverted: false,
5060
position: "top left",
5161
size: "small",
62+
hoverable: true,
5263
};

src/lib/forms/AccordionField.js

+98-57
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,86 @@
11
// This file is part of React-Invenio-Forms
22
// Copyright (C) 2020 CERN.
33
// Copyright (C) 2020 Northwestern University.
4-
//
54
// React-Invenio-Forms is free software; you can redistribute it and/or modify it
65
// under the terms of the MIT License; see LICENSE file for more details.
76

87
import React, { Component, useState } from "react";
98
import PropTypes from "prop-types";
109
import { Field, FastField } from "formik";
11-
import { Accordion, Container, Icon } from "semantic-ui-react";
10+
import { Accordion, Container, Icon, Label } from "semantic-ui-react";
1211
import _omit from "lodash/omit";
13-
import _get from "lodash/get";
12+
import { flattenAndCategorizeErrors } from "../utils";
1413

1514
export class AccordionField extends Component {
16-
hasError(errors, initialValues = undefined, values = undefined) {
17-
const { includesPaths } = this.props;
18-
for (const errorPath in errors) {
19-
for (const subPath in errors[errorPath]) {
20-
const path = `${errorPath}.${subPath}`;
21-
if (
22-
_get(initialValues, path, "") === _get(values, path, "") &&
23-
includesPaths.includes(`${errorPath}.${subPath}`)
24-
)
25-
return true;
15+
// Checks if there are any errors that match the given paths.
16+
17+
hasError(errors, includesPaths) {
18+
return Object.keys(errors).some((errorPath) =>
19+
includesPaths.some((path) => errorPath.startsWith(path))
20+
);
21+
}
22+
23+
// Generates a summary of errors categorized by severity.
24+
getErrorSummary = (errors, includePaths, severityChecks) => {
25+
const count = {};
26+
27+
// Count generic errors
28+
for (const path in errors.flattenedErrors) {
29+
if (includePaths.some((includePath) => path.startsWith(includePath))) {
30+
count["error"] = (count["error"] || 0) + 1;
2631
}
2732
}
28-
return false;
29-
}
33+
34+
// Count severity-based errors
35+
for (const key in errors.severityChecks) {
36+
const severity = errors.severityChecks[key]?.severity;
37+
const path = key;
38+
39+
if (
40+
severity &&
41+
includePaths.some((includePath) => path.startsWith(includePath))
42+
) {
43+
count[severity] = (count[severity] || 0) + 1;
44+
}
45+
}
46+
47+
// Format output to display labels
48+
// e.g., { error: "1 Error", warning: "2 Warnings" }
49+
const formattedCount = {};
50+
for (const [severity, num] of Object.entries(count)) {
51+
const label =
52+
severityChecks?.[severity]?.label ||
53+
severity.charAt(0).toUpperCase() + severity.slice(1);
54+
formattedCount[severity] = `${num} ${label}${num === 1 ? "" : "s"}`;
55+
}
56+
57+
return formattedCount;
58+
};
3059

3160
renderAccordion = (props) => {
3261
const {
33-
form: { errors, status, initialErrors, initialValues, values },
62+
form: { errors, initialErrors },
3463
} = props;
64+
const { includesPaths, label, children, active, severityChecks } = this.props;
65+
66+
const uiProps = _omit(this.props, ["optimized", "includesPaths"]);
67+
68+
// Merge initial and current errors for accurate validation
69+
const persistentErrors = { ...initialErrors, ...errors };
70+
const categorizedErrors = flattenAndCategorizeErrors(persistentErrors);
71+
72+
// Determine if the accordion should show an "error" state
73+
const errorClass = this.hasError(categorizedErrors.flattenedErrors, includesPaths)
74+
? "error"
75+
: "";
76+
77+
// Generate summary of errors for display
78+
const errorSummary = this.getErrorSummary(
79+
categorizedErrors,
80+
includesPaths,
81+
severityChecks
82+
);
3583

36-
// eslint-disable-next-line no-unused-vars
37-
const { label, children, active, ...ui } = this.props;
38-
const uiProps = _omit({ ...ui }, ["optimized", "includesPaths"]);
39-
const hasError = status
40-
? this.hasError(status)
41-
: this.hasError(errors) || this.hasError(initialErrors, initialValues, values);
42-
const panels = [
43-
{
44-
key: `panel-${label}`,
45-
title: {
46-
content: label,
47-
},
48-
content: {
49-
content: <Container>{children}</Container>,
50-
},
51-
},
52-
];
53-
54-
const errorClass = hasError ? "error secondary" : "";
5584
const [activeIndex, setActiveIndex] = useState(active ? 0 : -1);
5685

5786
const handleTitleClick = (e, { index }) => {
@@ -61,37 +90,47 @@ export class AccordionField extends Component {
6190
return (
6291
<Accordion
6392
inverted
64-
className={`invenio-accordion-field ${errorClass}`}
93+
className={`invenio-accordion-field ${errorClass} secondary`}
6594
{...uiProps}
6695
>
67-
{panels.map((panel, index) => (
68-
<React.Fragment key={panel.key}>
69-
<Accordion.Title
70-
active={activeIndex === index}
71-
index={index}
72-
onClick={handleTitleClick}
73-
onKeyDown={(e) => {
74-
if (e.key === "Enter" || e.key === " ") {
75-
handleTitleClick(e, { index });
76-
}
77-
}}
78-
tabIndex={0}
96+
{/* Accordion Title with Error Summary */}
97+
<Accordion.Title
98+
active={activeIndex === 0}
99+
index={0}
100+
onClick={handleTitleClick}
101+
onKeyDown={(e) => {
102+
if (e.key === "Enter" || e.key === " ") {
103+
handleTitleClick(e, { index: 0 });
104+
}
105+
}}
106+
tabIndex={0}
107+
>
108+
{label}
109+
{/* Display error labels */}
110+
{Object.entries(errorSummary).map(([severity, text]) => (
111+
<Label
112+
key={severity}
113+
size="tiny"
114+
circular
115+
className={`accordion-label ${severity}`}
79116
>
80-
{panel.title.content}
81-
<Icon name="angle right" />
82-
</Accordion.Title>
83-
<Accordion.Content active={activeIndex === index}>
84-
{panel.content.content}
85-
</Accordion.Content>
86-
</React.Fragment>
87-
))}
117+
{text}
118+
</Label>
119+
))}
120+
{/* Toggle Icon */}
121+
<Icon name={activeIndex === 0 ? "angle down" : "angle right"} />
122+
</Accordion.Title>
123+
124+
{/* Accordion Content */}
125+
<Accordion.Content active={activeIndex === 0}>
126+
<Container>{children}</Container>
127+
</Accordion.Content>
88128
</Accordion>
89129
);
90130
};
91131

92132
render() {
93133
const { optimized } = this.props;
94-
95134
const FormikField = optimized ? FastField : Field;
96135
return <FormikField name="" component={this.renderAccordion} />;
97136
}
@@ -104,6 +143,7 @@ AccordionField.propTypes = {
104143
optimized: PropTypes.bool,
105144
children: PropTypes.node,
106145
ui: PropTypes.object,
146+
severityChecks: PropTypes.object,
107147
};
108148

109149
AccordionField.defaultProps = {
@@ -113,4 +153,5 @@ AccordionField.defaultProps = {
113153
optimized: false,
114154
children: null,
115155
ui: null,
156+
severityChecks: null,
116157
};

src/lib/forms/FeedbackLabel.js

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React, { Component } from "react";
2+
import { Label, Icon } from "semantic-ui-react";
3+
import { flattenAndCategorizeErrors } from "../utils";
4+
import { InvenioPopup } from "../elements/accessibility";
5+
import PropTypes from "prop-types";
6+
7+
export class FeedbackLabel extends Component {
8+
constructor(props) {
9+
super(props);
10+
11+
const { errorMessage } = props;
12+
const { flattenedErrors = {}, severityChecks = {} } =
13+
flattenAndCategorizeErrors(errorMessage);
14+
15+
// Get the first error and severity data, defaulting to empty values if not present
16+
const errorText = Object.values(flattenedErrors)[0] || "";
17+
const severityData = Object.values(severityChecks)[0] || {};
18+
19+
// Destructure severityData, ensuring default values for missing keys
20+
const severityLevel = severityData?.severity || "";
21+
const severityMessage = severityData?.message || "";
22+
const severityDescription = severityData?.description || "";
23+
24+
this.state = {
25+
errorText,
26+
severityInfo: { severityLevel, severityMessage, severityDescription },
27+
prompt: !severityLevel && !!errorText,
28+
};
29+
}
30+
31+
render() {
32+
const { errorText, severityInfo, prompt } = this.state;
33+
34+
// Return null if neither errorText nor severityMessage exists
35+
if (!errorText && !severityInfo.severityMessage) {
36+
return null;
37+
}
38+
const hasSeverity =
39+
severityInfo.severityMessage && severityInfo.severityDescription;
40+
return (
41+
<Label pointing="left" className={severityInfo.severityLevel} prompt={prompt}>
42+
{/* Display severity message with a popup if it exists */}
43+
{hasSeverity && (
44+
<InvenioPopup
45+
trigger={<Icon name="info circle" />}
46+
content={severityInfo.severityDescription}
47+
position="top center"
48+
hoverable
49+
/>
50+
)}
51+
{/* Display either the error text or the severity message */}
52+
{errorText || severityInfo.severityMessage}
53+
</Label>
54+
);
55+
}
56+
}
57+
58+
FeedbackLabel.propTypes = {
59+
errorMessage: PropTypes.object,
60+
};
61+
62+
FeedbackLabel.defaultProps = {
63+
errorMessage: undefined,
64+
};

src/lib/forms/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export { BaseForm } from "./BaseForm";
1515
export { BooleanField } from "./BooleanField";
1616
export { ErrorLabel } from "./ErrorLabel";
1717
export { FieldLabel } from "./FieldLabel";
18+
export { FeedbackLabel } from "./FeedbackLabel";
1819
export { GroupField } from "./GroupField";
1920
export { SelectField } from "./SelectField";
2021
export { RemoteSelectField } from "./RemoteSelectField";

src/lib/forms/widgets/custom_fields/CustomFields.js

+2
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,15 @@ export class CustomFields extends Component {
8484
paths,
8585
displaySection = true,
8686
section: sectionName,
87+
id: sectionId,
8788
} = section;
8889
return displaySection ? (
8990
<AccordionField
9091
key={`section-${sectionName}`}
9192
includesPaths={paths}
9293
label={sectionName}
9394
active
95+
id={sectionId}
9496
>
9597
{fields}
9698
</AccordionField>

src/lib/forms/widgets/custom_fields/DiscoverFieldsSection.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,16 @@ export class DiscoverFieldsSection extends Component {
9898
...Object.entries(tempFields).map(([key, value]) => value.key),
9999
...recordFields,
100100
];
101+
const tempFieldsPaths = tempFields.map((item) => item.key);
101102

102103
return (
103-
<AccordionField key="discover-fields" label="Domain specific fields" active>
104+
<AccordionField
105+
key="discover-fields"
106+
includesPaths={tempFieldsPaths}
107+
label="Domain specific fields"
108+
active
109+
id="domain-specific-fields-section"
110+
>
104111
{sections.map(({ fields, paths, ...sectionConfig }) => {
105112
const recordCustomFields = this.getFieldsWithValues(fields);
106113
if (_isEmpty(recordCustomFields)) {

0 commit comments

Comments
 (0)