Skip to content

Commit b8d0f27

Browse files
authored
fix: improved validation feedback (#365)
1 parent 197bb19 commit b8d0f27

File tree

10 files changed

+200
-35
lines changed

10 files changed

+200
-35
lines changed

src/app/components/Editor.tsx

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { FieldErrors, FieldPathByValue, FormProvider, Resolver, useForm } from "react-hook-form";
22
import PubliccodeYmlLanguages from "./PubliccodeYmlLanguages";
33

4-
import { Col, Container, notify, Row } from "design-react-kit";
4+
import { Col, Container, Icon, notify, Row } from "design-react-kit";
55
import { set } from "lodash";
66
import { useCallback, useEffect, useState } from "react";
77
import { useTranslation } from "react-i18next";
@@ -44,6 +44,9 @@ import { resetPubliccodeYmlLanguages, setPubliccodeYmlLanguages } from "../store
4444
import yamlSerializer from "../yaml-serializer";
4545
import { removeDuplicate } from "../yaml-upload";
4646
import EditorUsedBy from "./EditorUsedBy";
47+
import { WarningModal } from "./WarningModal";
48+
49+
const PUBLIC_CODE_EDITOR_WARNINGS = 'PUBLIC_CODE_EDITOR_WARNINGS'
4750

4851
const validatorFn = async (values: PublicCode) => await validator({ publiccode: JSON.stringify(values), baseURL: values.url });
4952

@@ -105,6 +108,20 @@ export default function Editor() {
105108
const [currentPublicodeYmlVersion, setCurrentPubliccodeYmlVersion] = useState('');
106109
const [isYamlModalVisible, setYamlModalVisibility] = useState(false);
107110
const [isPublicCodeImported, setPublicCodeImported] = useState(false);
111+
const [isWarningModalVisible, setWarningModalVisibility] = useState(false);
112+
const [warnings, setWarnings] = useState<{ key: string; message: string; }[]>([]);
113+
114+
useEffect(() => {
115+
const warnings = localStorage.getItem(PUBLIC_CODE_EDITOR_WARNINGS);
116+
117+
if (warnings) {
118+
setWarnings(JSON.parse(warnings))
119+
}
120+
}, [])
121+
122+
useEffect(() => {
123+
localStorage.setItem(PUBLIC_CODE_EDITOR_WARNINGS, JSON.stringify(warnings))
124+
}, [warnings])
108125

109126
const getNestedValue = (obj: PublicCodeWithDeprecatedFields, path: string) => {
110127
return path.split('.').reduce((acc, key) => (acc as never)?.[key], obj);
@@ -222,6 +239,7 @@ export default function Editor() {
222239
reset({ ...defaultValues });
223240
checkPubliccodeYmlVersion(getValues() as PublicCode);
224241
setPublicCodeImported(false);
242+
setWarnings([])
225243
};
226244

227245
const setFormDataAfterImport = async (
@@ -245,17 +263,19 @@ export default function Editor() {
245263

246264
const res = await checkWarnings(values)
247265

248-
if (res.warnings.size) {
249-
const body = Array
250-
.from(res.warnings)
251-
.reduce((p, [key, { message }]) => p + `${key}: ${message}`, '')
266+
setWarnings(Array.from(res.warnings).map(([key, { message }]) => ({ key, message })));
267+
268+
const numberOfWarnings = res.warnings.size;
252269

253-
const _1_MINUTE = 60 * 1 * 1000
270+
if (numberOfWarnings) {
271+
const body = `ci sono ${numberOfWarnings} warnings`
272+
273+
const _5_SECONDS = 5 * 1 * 1000
254274

255275
notify("Warnings", body, {
256276
dismissable: true,
257277
state: 'warning',
258-
duration: _1_MINUTE
278+
duration: _5_SECONDS
259279
})
260280
}
261281
}
@@ -281,7 +301,16 @@ export default function Editor() {
281301
<Container>
282302
<Head />
283303
<div className="p-4">
284-
<PubliccodeYmlLanguages />
304+
<div className="d-flex flex-row">
305+
<div className="p-2 bd-highlight">
306+
<PubliccodeYmlLanguages />
307+
</div>
308+
{!!warnings.length &&
309+
<div className="p-2 bd-highlight" >
310+
<Icon icon="it-warning-circle" color="warning" title={t("editor.warnings")} onClick={() => setWarningModalVisibility(true)} />&nbsp;
311+
</div>
312+
}
313+
</div>
285314
<div className='mt-3'></div>
286315
<FormProvider {...methods}>
287316
<form>
@@ -496,6 +525,11 @@ export default function Editor() {
496525
display={isYamlModalVisible}
497526
toggle={() => setYamlModalVisibility(!isYamlModalVisible)}
498527
/>
528+
<WarningModal
529+
display={isWarningModalVisible}
530+
toggle={() => setWarningModalVisibility(!isWarningModalVisible)}
531+
warnings={warnings}
532+
/>
499533
</div>
500534
</Container >
501535
);

src/app/components/EditorFeatures.tsx

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
1+
import { Button, Icon, Input, InputGroup } from "design-react-kit";
2+
import { get } from "lodash";
3+
import { useEffect, useRef, useState } from "react";
14
import { useController, useFormContext } from "react-hook-form";
2-
import PublicCode from "../contents/publiccode";
35
import { useTranslation } from "react-i18next";
4-
import { get } from "lodash";
5-
import { useState } from "react";
6-
import { Button, Icon, Input, InputGroup } from "design-react-kit";
6+
import PublicCode from "../contents/publiccode";
7+
import flattenObject from "../flatten-object-to-record";
8+
import { removeDuplicate } from "../yaml-upload";
79

810
interface Props {
911
lang: string;
1012
}
1113

1214
export default function EditorFeatures({ lang }: Props): JSX.Element {
15+
const formFieldName = `description.${lang}.features` as keyof PublicCode;
16+
1317
const { control } = useFormContext<PublicCode>();
1418
const {
19+
field,
1520
field: { onChange, value },
1621
formState: { errors },
1722
} = useController<PublicCode>({
@@ -22,21 +27,34 @@ export default function EditorFeatures({ lang }: Props): JSX.Element {
2227
const { t } = useTranslation();
2328

2429
const features: string[] = value ? (value as string[]) : [];
25-
const [currFeat, setCurrFeat] = useState<string>("");
30+
const [current, setCurrent] = useState<string>("");
2631

2732
const label = t(`publiccodeyml.description.features.label`);
2833
const description = t(`publiccodeyml.description.features.description`);
2934
const errorMessage = get(errors, `description.${lang}.features.message`);
3035

31-
const addFeature = () => {
32-
onChange([...features, currFeat.trim()]);
33-
setCurrFeat("");
36+
const add = () => {
37+
onChange(removeDuplicate([...features, current.trim()]));
38+
setCurrent("");
3439
};
3540

36-
const removeFeature = (feat: string) => {
41+
const remove = (feat: string) => {
3742
onChange(features.filter((elem) => elem !== feat));
3843
};
3944

45+
const inputRef = useRef<HTMLInputElement>(null);
46+
47+
useEffect(() => {
48+
const errorsRecord = flattenObject(errors as Record<string, { type: string; message: string }>);
49+
const formFieldKeys = Object.keys(errorsRecord);
50+
const isFirstError = formFieldKeys && formFieldKeys.length && formFieldKeys[0] === formFieldName
51+
52+
if (isFirstError) {
53+
inputRef.current?.focus()
54+
}
55+
56+
}, [errors, formFieldName, inputRef])
57+
4058

4159
return (
4260
<div className="form-group">
@@ -54,7 +72,7 @@ export default function EditorFeatures({ lang }: Props): JSX.Element {
5472
<Button
5573
color="link"
5674
icon
57-
onClick={() => removeFeature(feat)}
75+
onClick={() => remove(feat)}
5876
size="xs"
5977
>
6078
<Icon icon="it-delete" size="sm" title="Remove feature" />
@@ -64,14 +82,16 @@ export default function EditorFeatures({ lang }: Props): JSX.Element {
6482
</ul>
6583
<InputGroup>
6684
<Input
67-
value={currFeat}
68-
onChange={({ target }) => setCurrFeat(target.value)}
85+
{...field}
86+
value={current}
87+
onChange={({ target }) => setCurrent(target.value)}
88+
innerRef={inputRef}
6989
/>
7090
<div className="input-group-append">
7191
<Button
7292
color="primary"
73-
disabled={currFeat.trim() === ""}
74-
onClick={addFeature}
93+
disabled={current.trim() === ""}
94+
onClick={add}
7595
>
7696
Add feature
7797
</Button>

src/app/components/EditorMultiselect.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1+
import { get } from "lodash";
2+
import { useEffect, useRef } from "react";
13
import {
24
FieldPathByValue,
35
useController,
46
useFormContext,
57
} from "react-hook-form";
6-
import { RequiredDeep } from "type-fest";
7-
import PublicCode, { PublicCodeWithDeprecatedFields } from "../contents/publiccode";
88
import { useTranslation } from "react-i18next";
99
import { Multiselect } from "react-widgets";
1010
import { Filter } from "react-widgets/Filter";
11-
import { get } from "lodash";
11+
import { RequiredDeep } from "type-fest";
12+
import PublicCode, { PublicCodeWithDeprecatedFields } from "../contents/publiccode";
13+
import flattenObject from "../flatten-object-to-record";
1214

1315
type Props<T> = {
1416
fieldName: T;
@@ -37,6 +39,19 @@ export default function EditorMultiselect<
3739
const description = t(`publiccodeyml.${fieldName}.description`);
3840
const errorMessage = get(errors, `${fieldName}.message`);
3941

42+
const inputRef = useRef<HTMLInputElement>(null);
43+
44+
useEffect(() => {
45+
const errorsRecord = flattenObject(errors as Record<string, { type: string; message: string }>);
46+
const formFieldKeys = Object.keys(errorsRecord);
47+
const isFirstError = formFieldKeys && formFieldKeys.length && formFieldKeys[0] === fieldName
48+
49+
if (isFirstError) {
50+
inputRef.current?.focus()
51+
}
52+
53+
}, [errors, fieldName, inputRef])
54+
4055
return (
4156
<div className="form-group">
4257
<label className="active" htmlFor={fieldName}>
@@ -51,6 +66,7 @@ export default function EditorMultiselect<
5166
dataKey="value"
5267
textField="text"
5368
filter={filter}
69+
ref={inputRef}
5470
/>
5571

5672
<small className="form-text">{description}</small>

src/app/components/Head.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import {
22
documentationUrl,
33
} from "../contents/constants";
44

5-
import { useTranslation } from "react-i18next";
65
import { Dropdown, DropdownMenu, DropdownToggle, Icon, LinkList, LinkListItem } from "design-react-kit";
6+
import { useTranslation } from "react-i18next";
77
import { formatLanguageLabel, getSupportedLanguages } from "../../i18n";
88

99
export const Head = (): JSX.Element => {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Icon, Modal, ModalBody } from "design-react-kit";
2+
import { useTranslation } from "react-i18next";
3+
4+
interface Props {
5+
display: boolean;
6+
toggle: () => void;
7+
warnings: { key: string, message: string }[]
8+
}
9+
10+
export const WarningModal = ({ display, toggle, warnings = [] }: Props): JSX.Element => {
11+
const { t } = useTranslation();
12+
13+
return (
14+
<Modal
15+
isOpen={display}
16+
toggle={toggle}>
17+
<ModalBody>
18+
<h3><Icon icon="it-warning-circle" color="warning" title={t("editor.warnings")} />&nbsp;{t("editor.warnings")}</h3>
19+
<div className="it-list-wrapper">
20+
{warnings.length
21+
?
22+
<ul className="it-list">
23+
<li>
24+
{warnings.map(({ key, message }) =>
25+
<li key={key}>
26+
<div className="list-item">
27+
<div className="it-right-zone">
28+
<div>
29+
<h4 className="text m-0">{key}</h4>
30+
<p className="small m-0">{message}</p>
31+
</div>
32+
</div>
33+
</div>
34+
</li>
35+
)}
36+
</li>
37+
</ul>
38+
: <p>Non ci sono warning</p>}
39+
40+
</div>
41+
</ModalBody>
42+
</Modal>)
43+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import flattenObjectToRecord from "./flatten-object-to-record";
2+
3+
describe('Flatten object to record', () => {
4+
it('should return a record given a object with a object-ish property', () => {
5+
//arrage
6+
const mockData = { en: { type: "error", message: "this is an error message" } };
7+
//act
8+
const actual = flattenObjectToRecord(mockData);
9+
//assert
10+
expect(actual).toBeDefined();
11+
expect(actual.en).toBeDefined()
12+
expect(actual.en.type).toBe(mockData.en.type)
13+
expect(actual.en.message).toBe(mockData.en.message)
14+
})
15+
16+
it('should return a record given a object with a object-ish property - deep', () => {
17+
//arrage
18+
const mockData = { description: { en: { type: "error", message: "this is an error message" } } };
19+
//act
20+
const actual = flattenObjectToRecord(mockData);
21+
//assert
22+
expect(actual).toBeDefined();
23+
expect(actual['description.en']).toBeDefined();
24+
expect(actual['description.en'].type).toBe("error")
25+
expect(actual['description.en'].message).toBe("this is an error message")
26+
})
27+
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
function flattenObject(
2+
obj: object,
3+
parentKey = '',
4+
separator = '.'
5+
): Record<string, { type: string; message: string }> {
6+
return Object.entries(obj).reduce((acc, [key, value]) => {
7+
const newKey = parentKey ? `${parentKey}${separator}${key}` : key;
8+
9+
// Controlla se il valore è un oggetto e ha proprietà
10+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
11+
const isLeaf = Object.keys(value).every(
12+
(k) => typeof value[k] !== 'object' || value[k] === null
13+
);
14+
15+
if (isLeaf) {
16+
// Se è una foglia, aggiungilo direttamente
17+
acc[newKey] = value as { type: string; message: string };
18+
} else {
19+
// Altrimenti continua la ricorsione
20+
Object.assign(acc, flattenObject(value, newKey, separator));
21+
}
22+
}
23+
24+
return acc;
25+
}, {} as Record<string, { type: string; message: string }>);
26+
}
27+
28+
export default flattenObject

src/i18n/locales/en.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"load": "Load",
2020
"notvalidurl": "Not a valid url",
2121
"filenotsupported": "File type not supported",
22+
"warnings": "Warnings",
2223
"errors": {
2324
"yamlloading": "Error loading yaml"
2425
},
@@ -300,7 +301,6 @@
300301
"label": "Landing Page URL",
301302
"description": "If the URL parameter does not serve a human readable or browsable page, but only serves source code to a source control client, with this key you have an option to specify a landing page. This page, ideally, is where your users will land when they will click a button labeled something like “Go to the application source code”. In case the product provides an automated graphical installer, this URL can point to a page which contains a reference to the source code but also offers the download of such an installer."
302303
},
303-
304304
"isBasedOn": {
305305
"label": "Is Based On",
306306
"description": "The URL of the original project, if this software is a variant or a fork of another software. If present, it identifies the fork as a software variant, descending from the specified repositories."
@@ -333,12 +333,10 @@
333333
"label": "Platforms",
334334
"description": "List of platforms the software runs under. It describes the platforms that users will use to access and operate the software, rather than the platform the software itself runs on. Use the predefined values if possible. If the software runs on a platform for which a predefined value is not available, a different value can be used. Values: web, windows, mac, linux, ios, android. Human readable values outside this list are allowed."
335335
},
336-
337336
"categories": {
338337
"label": "Category",
339338
"description": "The list of categories this software falls under."
340339
},
341-
342340
"usedBy": {
343341
"label": "Used By",
344342
"description": "The list of the names of prominent public administrations (that will serve as testimonials) that are currently known to the software maintainer to be using this software. Parsers are encouraged to enhance this list also with other information that can obtain independently; for instance, a fork of a software, owned by an administration, could be used as a signal of usage of the software."
@@ -352,4 +350,4 @@
352350
"description": "The list of Media Types (MIME Types) as mandated in RFC 6838 which the application can handle as output. In case the software does not support any output, you can skip this field or use application/x.empty."
353351
}
354352
}
355-
}
353+
}

0 commit comments

Comments
 (0)