Skip to content

Commit

Permalink
fix(web): option and tag field validation enhancement (#1306)
Browse files Browse the repository at this point in the history
* improve validation of option and tag

* fix: input status

* fix: wrap t

* fix: input status

* small fixes
  • Loading branch information
caichi-t authored Nov 13, 2024
1 parent 6a889ca commit f7f15b6
Show file tree
Hide file tree
Showing 13 changed files with 158 additions and 109 deletions.
5 changes: 5 additions & 0 deletions web/e2e/project/item/fields/option.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ test("Option field creating and updating has succeeded", async ({ page }) => {
await page.locator("#values").nth(0).click();
await page.locator("#values").nth(0).fill("first");
await page.getByRole("button", { name: "plus New" }).click();
await expect(page.getByText("Empty values are not allowed")).toBeVisible();
await expect(page.getByRole("button", { name: "OK" })).toBeDisabled();
await page.locator("#values").nth(1).click();
await page.locator("#values").nth(1).fill("first");
await expect(page.getByText("Option must be unique")).toBeVisible();
await expect(page.getByRole("button", { name: "OK" })).toBeDisabled();
await page.locator("#values").nth(1).fill("second");
await page.getByRole("button", { name: "OK" }).click();
await closeNotification(page);
Expand Down
6 changes: 6 additions & 0 deletions web/e2e/project/item/metadata/tag.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ test("Tag metadata creating and updating has succeeded", async ({ page }) => {
await page.getByLabel("Set Tags").fill("Tag1");
await page.getByRole("button", { name: "plus New" }).click();
await page.locator("div").filter({ hasText: /^Tag$/ }).click();
await page.locator("#tags").nth(1).fill("");
await expect(page.getByText("Empty values are not allowed")).toBeVisible();
await expect(page.getByRole("button", { name: "OK" })).toBeDisabled();
await page.locator("#tags").nth(1).fill("Tag1");
await expect(page.getByText("Labels must be unique")).toBeVisible();
await expect(page.getByRole("button", { name: "OK" })).toBeDisabled();
await page.locator("#tags").nth(1).fill("Tag2");
await page.getByRole("button", { name: "OK" }).click();
await closeNotification(page);
Expand Down
7 changes: 4 additions & 3 deletions web/src/components/atoms/Input/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ export type { SearchProps } from "antd/lib/input";

type Props = {
value?: string;
isError?: boolean;
} & InputProps;

const Input = forwardRef<InputRef, Props>(({ value, maxLength, ...props }, ref) => {
const Input = forwardRef<InputRef, Props>(({ value, isError, maxLength, ...props }, ref) => {
const status = useMemo(() => {
if (maxLength && value && runes(value).length > maxLength) {
if (isError || (maxLength && value && runes(value).length > maxLength)) {
return "error";
}
}, [maxLength, value]);
}, [isError, maxLength, value]);

return (
<AntDInput
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,16 @@ type TagColor = (typeof colors)[number];
type Props = {
value?: { id?: string; name: string; color: TagColor }[];
onChange?: (value: { id?: string; name: string; color: TagColor }[]) => void;
errorIndexes: Set<number>;
} & TextAreaProps &
InputProps;

const MultiValueColoredTag: React.FC<Props> = ({ value = [], onChange, ...props }) => {
const MultiValueColoredTag: React.FC<Props> = ({
value = [],
onChange,
errorIndexes,
...props
}) => {
const t = useT();
const [lastColorIndex, setLastColorIndex] = useState(0);
const [focusedTagIndex, setFocusedTagIndex] = useState<number | null>(null); // New State to hold the focused tag index
Expand Down Expand Up @@ -140,11 +146,13 @@ const MultiValueColoredTag: React.FC<Props> = ({ value = [], onChange, ...props
onChange={(e: ChangeEvent<HTMLInputElement>) => handleInput(e, key)}
value={valueItem.name}
onBlur={() => handleInputBlur()}
isError={errorIndexes?.has(key)}
/>
</StyledDiv>
<StyledTagContainer
hidden={focusedTagIndex === key} // Hide tag when it is focused
onClick={() => handleTagClick(key)}>
onClick={() => handleTagClick(key)}
isError={errorIndexes?.has(key)}>
<StyledTag color={valueItem.color.toLowerCase()}>{valueItem.name}</StyledTag>
</StyledTagContainer>
<Dropdown menu={{ items: generateMenuItems(key) }} trigger={["click"]}>
Expand Down Expand Up @@ -188,9 +196,9 @@ const StyledInput = styled(Input)`
flex: 1;
`;

const StyledTagContainer = styled.div`
const StyledTagContainer = styled.div<{ isError?: boolean }>`
cursor: pointer;
border: 1px solid #d9d9d9;
border: 1px solid ${({ isError }) => (isError ? "#ff4d4f" : "#d9d9d9")};
padding: 4px 11px;
overflow: auto;
height: 100%;
Expand Down
3 changes: 3 additions & 0 deletions web/src/components/molecules/Common/MultiValueField/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Props = {
onBlur?: () => Promise<void>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
FieldInput: React.FunctionComponent<any>;
errorIndexes?: Set<number>;
} & TextAreaProps &
InputProps;

Expand All @@ -24,6 +25,7 @@ const MultiValueField: React.FC<Props> = ({
onChange,
onBlur,
FieldInput,
errorIndexes,
...props
}) => {
const t = useT();
Expand Down Expand Up @@ -91,6 +93,7 @@ const MultiValueField: React.FC<Props> = ({
onChange={(e: ChangeEvent<HTMLInputElement>) => handleInput(e, key)}
onBlur={() => onBlur?.()}
value={valueItem}
isError={errorIndexes?.has(key)}
/>
{!props.disabled && (
<FieldButton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,11 @@ import Steps from "@reearth-cms/components/atoms/Step";
import Tabs from "@reearth-cms/components/atoms/Tabs";
import TextArea from "@reearth-cms/components/atoms/TextArea";
import { keyAutoFill, keyReplace } from "@reearth-cms/components/molecules/Common/Form/utils";
import MultiValueField from "@reearth-cms/components/molecules/Common/MultiValueField";
import { Model } from "@reearth-cms/components/molecules/Model/types";
import { fieldTypes } from "@reearth-cms/components/molecules/Schema/fieldTypes";
import {
Field,
FieldModalTabs,
FieldType,
FormValues,
CorrespondingField,
} from "@reearth-cms/components/molecules/Schema/types";
Expand All @@ -32,7 +30,7 @@ const { TabPane } = Tabs;

type Props = {
models?: Model[];
selectedType: FieldType;
selectedType: "Reference";
selectedField: Field | null;
open: boolean;
isLoading: boolean;
Expand Down Expand Up @@ -208,11 +206,10 @@ const FieldCreationModalWithSteps: React.FC<Props> = ({
setField1FormValues(initialValues);
}, [initialValues, setCurrentStep]);

const handleCancel = useCallback(() => {
onClose();
const handleAfterClose = useCallback(() => {
formReset();
modalReset();
}, [formReset, modalReset, onClose]);
}, [formReset, modalReset]);

const prevStep = useCallback(() => {
if (currentStep > 0) setCurrentStep(currentStep - 1);
Expand Down Expand Up @@ -342,8 +339,8 @@ const FieldCreationModalWithSteps: React.FC<Props> = ({
</FieldThumbnail>
) : null
}
onCancel={handleCancel}
afterClose={modalReset}
onCancel={onClose}
afterClose={handleAfterClose}
width={572}
open={open}
footer={
Expand Down Expand Up @@ -475,25 +472,6 @@ const FieldCreationModalWithSteps: React.FC<Props> = ({
<Form.Item name="description" label={t("Description")}>
<TextArea rows={3} showCount maxLength={1000} />
</Form.Item>
{selectedType === "Select" && (
<Form.Item
name="values"
label={t("Set Options")}
rules={[
{
validator: async (_, values) => {
if (!values || values.length < 1) {
return Promise.reject(new Error("At least 1 option"));
}
if (values.some((value: string) => value.length === 0)) {
return Promise.reject(new Error("Empty values are not allowed"));
}
},
},
]}>
<MultiValueField FieldInput={Input} />
</Form.Item>
)}
<Form.Item
name="multiple"
valuePropName="checked"
Expand Down Expand Up @@ -550,7 +528,7 @@ const FieldCreationModalWithSteps: React.FC<Props> = ({
</Form.Item>
<Form.Item
name="key"
label="Field Key"
label={t("Field Key")}
extra={t(
"Field key must be unique and at least 1 character long. It can only contain letters, numbers, underscores and dashes.",
)}
Expand Down Expand Up @@ -578,25 +556,6 @@ const FieldCreationModalWithSteps: React.FC<Props> = ({
<Form.Item name="description" label={t("Description")}>
<TextArea rows={3} showCount maxLength={1000} />
</Form.Item>
{selectedType === "Select" && (
<Form.Item
name="values"
label={t("Set Options")}
rules={[
{
validator: async (_, values) => {
if (!values || values.length < 1) {
return Promise.reject(new Error("At least 1 option"));
}
if (values.some((value: string) => value.length === 0)) {
return Promise.reject(new Error("Empty values are not allowed"));
}
},
},
]}>
<MultiValueField FieldInput={Input} />
</Form.Item>
)}
</TabPane>
<TabPane tab={t("Validation")} key="validation" forceRender>
<Form.Item
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const URLField: React.FC<Props> = ({ multiple }) => {
return (
<Form.Item
name="defaultValue"
label="Set default value"
label={t("Set default value")}
extra={t("Default value must be a valid URL and start with 'http://' or 'https://'.")}
rules={[
{
Expand Down
90 changes: 66 additions & 24 deletions web/src/components/molecules/Schema/FieldModal/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default (
open: boolean,
onClose: () => void,
onSubmit: (values: FormValues) => Promise<void>,
handleFieldKeyUnique: (key: string, fieldId?: string) => boolean,
handleFieldKeyUnique: (key: string) => boolean,
) => {
const [form] = Form.useForm<FormTypes>();
const [buttonDisabled, setButtonDisabled] = useState(true);
Expand Down Expand Up @@ -235,7 +235,11 @@ export default (
const values = Form.useWatch([], form);
useEffect(() => {
if (form.getFieldValue("title") && form.getFieldValue("key")) {
if (form.getFieldValue("supportedTypes")?.length === 0) {
if (
form.getFieldValue("values")?.length === 0 ||
form.getFieldValue("supportedTypes")?.length === 0 ||
form.getFieldValue("tags")?.length === 0
) {
setButtonDisabled(true);
} else {
form
Expand All @@ -249,26 +253,22 @@ export default (
}, [form, values]);

const handleValuesChange = useCallback(async (changedValues: Record<string, unknown>) => {
const [key, value] = Object.entries(changedValues)[0];
let changedValue = value;
let defaultValue = defaultValueRef.current?.[key as keyof FormTypes];
if (Array.isArray(value)) {
changedValue = [...value].sort();
}
if (Array.isArray(defaultValue)) {
defaultValue = [...defaultValue].sort();
}
const [key, value] = Object.entries(changedValues)[0];
let changedValue = value;
let defaultValue = defaultValueRef.current?.[key as keyof FormTypes];
if (Array.isArray(value)) {
changedValue = [...value].sort();
}
if (Array.isArray(defaultValue)) {
defaultValue = [...defaultValue].sort();
}

if (
JSON.stringify(emptyConvert(changedValue)) === JSON.stringify(emptyConvert(defaultValue))
) {
changedKeys.current.delete(key);
} else {
changedKeys.current.add(key);
}
},
[],
);
if (JSON.stringify(emptyConvert(changedValue)) === JSON.stringify(emptyConvert(defaultValue))) {
changedKeys.current.delete(key);
} else {
changedKeys.current.add(key);
}
}, []);

const handleNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -323,15 +323,15 @@ export default (
(value: string) => {
if (prevKey.current?.key === value) {
return prevKey.current?.isSuccess ? Promise.resolve() : Promise.reject();
} else if (validateKey(value) && handleFieldKeyUnique(value, selectedField?.id)) {
} else if (validateKey(value) && handleFieldKeyUnique(value)) {
prevKey.current = { key: value, isSuccess: true };
return Promise.resolve();
} else {
prevKey.current = { key: value, isSuccess: false };
return Promise.reject();
}
},
[handleFieldKeyUnique, selectedField?.id],
[handleFieldKeyUnique],
);

const isTitleDisabled = useMemo(
Expand Down Expand Up @@ -368,14 +368,53 @@ export default (

useEffect(() => {
if (open && !selectedField) {
if (selectedType === "GeometryObject") {
if (selectedType === "Select") {
form.setFieldValue("values", []);
} else if (selectedType === "GeometryObject") {
form.setFieldValue("supportedTypes", []);
} else if (selectedType === "GeometryEditor") {
form.setFieldValue("supportedTypes", EditorSupportType[0].value);
} else if (selectedType === "Tag") {
form.setFieldValue("tags", []);
}
}
}, [EditorSupportType, form, open, selectedField, selectedType]);

const [emptyIndexes, setEmptyIndexes] = useState<number[]>([]);
const emptyValidator = useCallback(async (values?: string[]) => {
if (values) {
const indexes = values
.map((value: string, index: number) => value.length === 0 && index)
.filter(value => typeof value === "number");
setEmptyIndexes(indexes);
if (indexes.length) {
return Promise.reject();
}
}
}, []);

const [duplicatedIndexes, setDuplicatedIndexes] = useState<number[]>([]);
const duplicatedValidator = useCallback(async (values?: string[]) => {
if (values) {
const indexes = values
.map((value: string, selfIndex: number) => {
if (!value) return;
const index = values.findIndex(v => v === value);
return index < selfIndex && selfIndex;
})
.filter(value => typeof value === "number");
setDuplicatedIndexes(indexes);
if (indexes.length) {
return Promise.reject();
}
}
}, []);

const errorIndexes = useMemo(
() => new Set([...emptyIndexes, ...duplicatedIndexes]),
[duplicatedIndexes, emptyIndexes],
);

return {
form,
buttonDisabled,
Expand All @@ -400,5 +439,8 @@ export default (
isTitleDisabled,
ObjectSupportType,
EditorSupportType,
emptyValidator,
duplicatedValidator,
errorIndexes,
};
};
Loading

0 comments on commit f7f15b6

Please sign in to comment.