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

fix(web): option and tag field validation enhancement #1306

Merged
merged 6 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
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()}
status={errorIndexes?.has(key) ? "error" : undefined}
/>
</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
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 @@ -475,25 +473,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 +529,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 +557,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
Loading