Skip to content

Commit

Permalink
fix: improved validation feedback (#365)
Browse files Browse the repository at this point in the history
  • Loading branch information
valeriocomo authored Dec 17, 2024
1 parent 197bb19 commit b8d0f27
Show file tree
Hide file tree
Showing 10 changed files with 200 additions and 35 deletions.
50 changes: 42 additions & 8 deletions src/app/components/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FieldErrors, FieldPathByValue, FormProvider, Resolver, useForm } from "react-hook-form";
import PubliccodeYmlLanguages from "./PubliccodeYmlLanguages";

import { Col, Container, notify, Row } from "design-react-kit";
import { Col, Container, Icon, notify, Row } from "design-react-kit";
import { set } from "lodash";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
Expand Down Expand Up @@ -44,6 +44,9 @@ import { resetPubliccodeYmlLanguages, setPubliccodeYmlLanguages } from "../store
import yamlSerializer from "../yaml-serializer";
import { removeDuplicate } from "../yaml-upload";
import EditorUsedBy from "./EditorUsedBy";
import { WarningModal } from "./WarningModal";

const PUBLIC_CODE_EDITOR_WARNINGS = 'PUBLIC_CODE_EDITOR_WARNINGS'

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

Expand Down Expand Up @@ -105,6 +108,20 @@ export default function Editor() {
const [currentPublicodeYmlVersion, setCurrentPubliccodeYmlVersion] = useState('');
const [isYamlModalVisible, setYamlModalVisibility] = useState(false);
const [isPublicCodeImported, setPublicCodeImported] = useState(false);
const [isWarningModalVisible, setWarningModalVisibility] = useState(false);
const [warnings, setWarnings] = useState<{ key: string; message: string; }[]>([]);

useEffect(() => {
const warnings = localStorage.getItem(PUBLIC_CODE_EDITOR_WARNINGS);

if (warnings) {
setWarnings(JSON.parse(warnings))
}
}, [])

useEffect(() => {
localStorage.setItem(PUBLIC_CODE_EDITOR_WARNINGS, JSON.stringify(warnings))
}, [warnings])

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

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

const res = await checkWarnings(values)

if (res.warnings.size) {
const body = Array
.from(res.warnings)
.reduce((p, [key, { message }]) => p + `${key}: ${message}`, '')
setWarnings(Array.from(res.warnings).map(([key, { message }]) => ({ key, message })));

const numberOfWarnings = res.warnings.size;

const _1_MINUTE = 60 * 1 * 1000
if (numberOfWarnings) {
const body = `ci sono ${numberOfWarnings} warnings`

const _5_SECONDS = 5 * 1 * 1000

notify("Warnings", body, {
dismissable: true,
state: 'warning',
duration: _1_MINUTE
duration: _5_SECONDS
})
}
}
Expand All @@ -281,7 +301,16 @@ export default function Editor() {
<Container>
<Head />
<div className="p-4">
<PubliccodeYmlLanguages />
<div className="d-flex flex-row">
<div className="p-2 bd-highlight">
<PubliccodeYmlLanguages />
</div>
{!!warnings.length &&
<div className="p-2 bd-highlight" >
<Icon icon="it-warning-circle" color="warning" title={t("editor.warnings")} onClick={() => setWarningModalVisibility(true)} />&nbsp;
</div>
}
</div>
<div className='mt-3'></div>
<FormProvider {...methods}>
<form>
Expand Down Expand Up @@ -496,6 +525,11 @@ export default function Editor() {
display={isYamlModalVisible}
toggle={() => setYamlModalVisibility(!isYamlModalVisible)}
/>
<WarningModal
display={isWarningModalVisible}
toggle={() => setWarningModalVisibility(!isWarningModalVisible)}
warnings={warnings}
/>
</div>
</Container >
);
Expand Down
48 changes: 34 additions & 14 deletions src/app/components/EditorFeatures.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { Button, Icon, Input, InputGroup } from "design-react-kit";
import { get } from "lodash";
import { useEffect, useRef, useState } from "react";
import { useController, useFormContext } from "react-hook-form";
import PublicCode from "../contents/publiccode";
import { useTranslation } from "react-i18next";
import { get } from "lodash";
import { useState } from "react";
import { Button, Icon, Input, InputGroup } from "design-react-kit";
import PublicCode from "../contents/publiccode";
import flattenObject from "../flatten-object-to-record";
import { removeDuplicate } from "../yaml-upload";

interface Props {
lang: string;
}

export default function EditorFeatures({ lang }: Props): JSX.Element {
const formFieldName = `description.${lang}.features` as keyof PublicCode;

const { control } = useFormContext<PublicCode>();
const {
field,
field: { onChange, value },
formState: { errors },
} = useController<PublicCode>({
Expand All @@ -22,21 +27,34 @@ export default function EditorFeatures({ lang }: Props): JSX.Element {
const { t } = useTranslation();

const features: string[] = value ? (value as string[]) : [];
const [currFeat, setCurrFeat] = useState<string>("");
const [current, setCurrent] = useState<string>("");

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

const addFeature = () => {
onChange([...features, currFeat.trim()]);
setCurrFeat("");
const add = () => {
onChange(removeDuplicate([...features, current.trim()]));
setCurrent("");
};

const removeFeature = (feat: string) => {
const remove = (feat: string) => {
onChange(features.filter((elem) => elem !== feat));
};

const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
const errorsRecord = flattenObject(errors as Record<string, { type: string; message: string }>);
const formFieldKeys = Object.keys(errorsRecord);
const isFirstError = formFieldKeys && formFieldKeys.length && formFieldKeys[0] === formFieldName

if (isFirstError) {
inputRef.current?.focus()
}

}, [errors, formFieldName, inputRef])


return (
<div className="form-group">
Expand All @@ -54,7 +72,7 @@ export default function EditorFeatures({ lang }: Props): JSX.Element {
<Button
color="link"
icon
onClick={() => removeFeature(feat)}
onClick={() => remove(feat)}
size="xs"
>
<Icon icon="it-delete" size="sm" title="Remove feature" />
Expand All @@ -64,14 +82,16 @@ export default function EditorFeatures({ lang }: Props): JSX.Element {
</ul>
<InputGroup>
<Input
value={currFeat}
onChange={({ target }) => setCurrFeat(target.value)}
{...field}
value={current}
onChange={({ target }) => setCurrent(target.value)}
innerRef={inputRef}
/>
<div className="input-group-append">
<Button
color="primary"
disabled={currFeat.trim() === ""}
onClick={addFeature}
disabled={current.trim() === ""}
onClick={add}
>
Add feature
</Button>
Expand Down
22 changes: 19 additions & 3 deletions src/app/components/EditorMultiselect.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { get } from "lodash";
import { useEffect, useRef } from "react";
import {
FieldPathByValue,
useController,
useFormContext,
} from "react-hook-form";
import { RequiredDeep } from "type-fest";
import PublicCode, { PublicCodeWithDeprecatedFields } from "../contents/publiccode";
import { useTranslation } from "react-i18next";
import { Multiselect } from "react-widgets";
import { Filter } from "react-widgets/Filter";
import { get } from "lodash";
import { RequiredDeep } from "type-fest";
import PublicCode, { PublicCodeWithDeprecatedFields } from "../contents/publiccode";
import flattenObject from "../flatten-object-to-record";

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

const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
const errorsRecord = flattenObject(errors as Record<string, { type: string; message: string }>);
const formFieldKeys = Object.keys(errorsRecord);
const isFirstError = formFieldKeys && formFieldKeys.length && formFieldKeys[0] === fieldName

if (isFirstError) {
inputRef.current?.focus()
}

}, [errors, fieldName, inputRef])

return (
<div className="form-group">
<label className="active" htmlFor={fieldName}>
Expand All @@ -51,6 +66,7 @@ export default function EditorMultiselect<
dataKey="value"
textField="text"
filter={filter}
ref={inputRef}
/>

<small className="form-text">{description}</small>
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/Head.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import {
documentationUrl,
} from "../contents/constants";

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

export const Head = (): JSX.Element => {
Expand Down
43 changes: 43 additions & 0 deletions src/app/components/WarningModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Icon, Modal, ModalBody } from "design-react-kit";
import { useTranslation } from "react-i18next";

interface Props {
display: boolean;
toggle: () => void;
warnings: { key: string, message: string }[]
}

export const WarningModal = ({ display, toggle, warnings = [] }: Props): JSX.Element => {
const { t } = useTranslation();

return (
<Modal
isOpen={display}
toggle={toggle}>
<ModalBody>
<h3><Icon icon="it-warning-circle" color="warning" title={t("editor.warnings")} />&nbsp;{t("editor.warnings")}</h3>
<div className="it-list-wrapper">
{warnings.length
?
<ul className="it-list">
<li>
{warnings.map(({ key, message }) =>
<li key={key}>
<div className="list-item">
<div className="it-right-zone">
<div>
<h4 className="text m-0">{key}</h4>
<p className="small m-0">{message}</p>
</div>
</div>
</div>
</li>
)}
</li>
</ul>
: <p>Non ci sono warning</p>}

</div>
</ModalBody>
</Modal>)
}
27 changes: 27 additions & 0 deletions src/app/flatten-object-to-record.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import flattenObjectToRecord from "./flatten-object-to-record";

describe('Flatten object to record', () => {
it('should return a record given a object with a object-ish property', () => {
//arrage
const mockData = { en: { type: "error", message: "this is an error message" } };
//act
const actual = flattenObjectToRecord(mockData);
//assert
expect(actual).toBeDefined();
expect(actual.en).toBeDefined()
expect(actual.en.type).toBe(mockData.en.type)
expect(actual.en.message).toBe(mockData.en.message)
})

it('should return a record given a object with a object-ish property - deep', () => {
//arrage
const mockData = { description: { en: { type: "error", message: "this is an error message" } } };
//act
const actual = flattenObjectToRecord(mockData);
//assert
expect(actual).toBeDefined();
expect(actual['description.en']).toBeDefined();
expect(actual['description.en'].type).toBe("error")
expect(actual['description.en'].message).toBe("this is an error message")
})
})
28 changes: 28 additions & 0 deletions src/app/flatten-object-to-record.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
function flattenObject(
obj: object,
parentKey = '',
separator = '.'
): Record<string, { type: string; message: string }> {
return Object.entries(obj).reduce((acc, [key, value]) => {
const newKey = parentKey ? `${parentKey}${separator}${key}` : key;

// Controlla se il valore è un oggetto e ha proprietà
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
const isLeaf = Object.keys(value).every(
(k) => typeof value[k] !== 'object' || value[k] === null
);

if (isLeaf) {
// Se è una foglia, aggiungilo direttamente
acc[newKey] = value as { type: string; message: string };
} else {
// Altrimenti continua la ricorsione
Object.assign(acc, flattenObject(value, newKey, separator));
}
}

return acc;
}, {} as Record<string, { type: string; message: string }>);
}

export default flattenObject
6 changes: 2 additions & 4 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"load": "Load",
"notvalidurl": "Not a valid url",
"filenotsupported": "File type not supported",
"warnings": "Warnings",
"errors": {
"yamlloading": "Error loading yaml"
},
Expand Down Expand Up @@ -300,7 +301,6 @@
"label": "Landing Page URL",
"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."
},

"isBasedOn": {
"label": "Is Based On",
"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."
Expand Down Expand Up @@ -333,12 +333,10 @@
"label": "Platforms",
"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."
},

"categories": {
"label": "Category",
"description": "The list of categories this software falls under."
},

"usedBy": {
"label": "Used By",
"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."
Expand All @@ -352,4 +350,4 @@
"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."
}
}
}
}
Loading

0 comments on commit b8d0f27

Please sign in to comment.