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

#2870 Add ability to pass in an array variable or template for dropdown field options #2915

Merged
merged 18 commits into from
Mar 15, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion src/components/fields/schemaFields/widgets/ArrayWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const ArrayWidget: React.VFC<ArrayWidgetProps> = ({
<FieldArray name={name}>
{({ push }) => (
<>
<ul className="list-group">
<ul className="list-group mb-2">
{(field.value ?? []).map((item: unknown, index: number) => (
<li className="list-group-item py-1" key={index}>
<SchemaField
Expand Down
167 changes: 167 additions & 0 deletions src/components/formBuilder/FormBuilder.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
* Copyright (C) 2022 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { IBlock } from "@/core";
import { getExampleBlockConfig } from "@/pageEditor/tabs/editTab/exampleBlockConfigs";
import {
createFormikTemplate,
fireTextInput,
selectSchemaFieldType,
} from "@/tests/formHelpers";
import { waitForEffect } from "@/tests/testHelpers";
import { validateRegistryId } from "@/types/helpers";
import { render, RenderResult, screen } from "@testing-library/react";
import React from "react";
import { act } from "react-dom/test-utils";
import selectEvent from "react-select-event";
import registerDefaultWidgets from "@/components/fields/schemaFields/widgets/registerDefaultWidgets";
import FormBuilder from "./FormBuilder";
import { RJSFSchema } from "./formBuilderTypes";

let exampleFormSchema: RJSFSchema;
let defaultFieldName: string;

beforeAll(() => {
registerDefaultWidgets();
const { schema, uiSchema } = getExampleBlockConfig({
id: validateRegistryId("@pixiebrix/form"),
} as IBlock);
exampleFormSchema = {
schema,
uiSchema,
};
defaultFieldName = "notes";
});

function renderFormBuilder(
formBuilderSchema: RJSFSchema = exampleFormSchema,
activeField = defaultFieldName
) {
const initialValues = {
form: formBuilderSchema,
};
const FormikTemplate = createFormikTemplate(initialValues);
return render(
<FormikTemplate>
<FormBuilder name="form" initialActiveField={activeField} />
</FormikTemplate>
);
}

async function selectUiType(uiType: string) {
await act(async () =>
selectEvent.select(screen.getByLabelText("Input Type"), uiType)
);
}

describe("Dropdown field", () => {
let rendered: RenderResult;
beforeEach(async () => {
rendered = renderFormBuilder();

// Switch to Dropdown widget
await selectUiType("Dropdown");
});

test("doesn't fail when field type changed to Dropdown", async () => {
// Expect the dropdown rendered in the preview
expect(
rendered.container.querySelector(`select#root_${defaultFieldName}`)
).not.toBeNull();
});

test("can add an option", async () => {
// Add a text option
screen.getByText("Add Item").click();
const firstOption = rendered.container.querySelector(
`[name="form.schema.properties.${defaultFieldName}.enum.0"]`
);
fireTextInput(firstOption, "Test option");
await waitForEffect();

// Expect the dropdown option rendered in the preview
expect(
screen.queryByRole("option", { name: "Test option" })
).not.toBeNull();
});

test("can use @var", async () => {
// Switch to @var and inset "@data"
await selectSchemaFieldType(
`form.schema.properties.${defaultFieldName}.enum`,
"var"
);
fireTextInput(screen.getByLabelText("Options"), "@data");
await waitForEffect();

// Expect the dropdown option rendered in the preview
expect(screen.queryByRole("option", { name: "@data" })).not.toBeNull();
});
});

describe("Dropdown with labels field", () => {
let rendered: RenderResult;
beforeEach(async () => {
rendered = renderFormBuilder();

// Switch to Dropdown widget
await selectUiType("Dropdown with labels");
});
test("doesn't fail when field type changed to Dropdown with labels", async () => {
// Expect the dropdown rendered in the preview
expect(
rendered.container.querySelector(`select#root_${defaultFieldName}`)
).not.toBeNull();
});

test("can add an option", async () => {
// Add a text option
screen.getByText("Add Item").click();

// Set option value
const firstOptionValueInput = rendered.container.querySelector(
`[name="form.schema.properties.${defaultFieldName}.oneOf.0.const"]`
);
fireTextInput(firstOptionValueInput, "1");
await waitForEffect();

// Set option label
const firstOptionLabelInput = rendered.container.querySelector(
`[name="form.schema.properties.${defaultFieldName}.oneOf.0.title"]`
);
fireTextInput(firstOptionLabelInput, "Test option");
await waitForEffect();

// Validate the rendered option
const optionElement = screen.queryByRole("option", { name: "Test option" });
expect(optionElement).not.toBeNull();
expect(optionElement).toHaveValue("1");
});

test("can use @var in Dropdown", async () => {
// Switch to @var and inset "@data"
await selectSchemaFieldType(
`form.schema.properties.${defaultFieldName}.oneOf`,
"var"
);
fireTextInput(screen.getByLabelText("Options"), "@data");
await waitForEffect();

// Expect the dropdown option rendered in the preview
expect(screen.queryByRole("option", { name: "@data" })).not.toBeNull();
});
});
9 changes: 5 additions & 4 deletions src/components/formBuilder/FormBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@
import styles from "./FormBuilder.module.scss";

import React, { useState } from "react";
import FormEditor from "./FormEditor";
import FormPreview from "./FormPreview";
import FormEditor from "./edit/FormEditor";
import FormPreview from "./preview/FormPreview";
import { useField } from "formik";
import { RJSFSchema } from "@/components/formBuilder/formBuilderTypes";

const FormBuilder: React.FC<{
name: string;
}> = ({ name }) => {
const [activeField, setActiveField] = useState<string>();
initialActiveField?: string;
}> = ({ name, initialActiveField }) => {
const [activeField, setActiveField] = useState<string>(initialActiveField);
const [{ value: rjsfSchema }] = useField<RJSFSchema>(name);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import {
RJSFSchema,
SelectStringOption,
SetActiveField,
} from "./formBuilderTypes";
import { UI_WIDGET } from "./schemaFieldNames";
} from "@/components/formBuilder/formBuilderTypes";
import { UI_WIDGET } from "@/components/formBuilder/schemaFieldNames";
import {
FIELD_TYPES_WITHOUT_DEFAULT,
FIELD_TYPE_OPTIONS,
Expand All @@ -33,16 +33,15 @@ import {
replaceStringInArray,
stringifyUiType,
UiType,
UiTypeExtra,
validateNextPropertyName,
} from "./formBuilderHelpers";
} from "@/components/formBuilder/formBuilderHelpers";
import { Schema, SchemaPropertyType } from "@/core";
import ConnectedFieldTemplate from "@/components/form/ConnectedFieldTemplate";
import FieldTemplate from "@/components/form/FieldTemplate";
import { produce } from "immer";
import SelectWidget, {
SelectWidgetOnChange,
} from "@/components/form/widgets/SelectWidget";
import OptionsWidget from "@/components/form/widgets/OptionsWidget";
import SwitchButtonWidget, {
CheckBoxLike,
} from "@/components/form/widgets/switchButton/SwitchButtonWidget";
Expand Down Expand Up @@ -136,11 +135,16 @@ const FieldEditor: React.FC<{
// eslint-disable-next-line security/detect-object-injection
const uiWidget = uiSchema?.[propertyName]?.[UI_WIDGET];
const propertyFormat = propertySchema.format;
const extra: UiTypeExtra =
uiWidget === "select" && typeof propertySchema.oneOf !== "undefined"
? "selectWithLabels"
: undefined;

const uiType = stringifyUiType({
propertyType,
uiWidget,
propertyFormat,
extra,
});

const selected = fieldTypes.find((option) => option.value === uiType);
Expand Down Expand Up @@ -202,6 +206,7 @@ const FieldEditor: React.FC<{
propertyType: "null",
uiWidget: undefined,
propertyFormat: undefined,
extra: undefined,
}
: parseUiType(selectedUiTypeOption.value);

Expand All @@ -214,6 +219,8 @@ const FieldEditor: React.FC<{
type: uiType.propertyType,
},
label: "Default value",
// RJSF Form throws when Dropdown with labels selected, no options set and default is empty
isRequired: uiType.extra === "selectWithLabels",
};

return (
Expand Down Expand Up @@ -255,10 +262,37 @@ const FieldEditor: React.FC<{
)}

{propertySchema.enum && (
<ConnectedFieldTemplate
name={getFullFieldName("enum")}
<>
<SchemaField
label="Options"
name={getFullFieldName("enum")}
schema={{
type: "array",
items: {
type: "string",
},
}}
isRequired
/>
</>
)}

{propertySchema.oneOf && (
<SchemaField
label="Options"
as={OptionsWidget}
name={getFullFieldName("oneOf")}
schema={{
type: "array",
items: {
type: "object",
properties: {
const: { type: "string" },
title: { type: "string" },
},
required: ["const"],
},
}}
isRequired
/>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,23 @@
import { Schema, UiSchema } from "@/core";
import { waitForEffect } from "@/tests/testHelpers";
import testItRenders, { ItRendersOptions } from "@/tests/testItRenders";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { Except } from "type-fest";
import {
createFormikTemplate,
fireTextInput,
fireFormSubmit,
selectSchemaFieldType,
} from "@/tests/formHelpers";
import { RJSFSchema } from "./formBuilderTypes";
import { RJSFSchema } from "@/components/formBuilder/formBuilderTypes";
import FormEditor, { FormEditorProps } from "./FormEditor";
import {
initAddingFieldCases,
initOneFieldSchemaCase,
initRenamingCases,
} from "./formEditor.testCases";
} from "@/components/formBuilder/formEditor.testCases";
import selectEvent from "react-select-event";
import userEvent from "@testing-library/user-event";
import registerDefaultWidgets from "@/components/fields/schemaFields/widgets/registerDefaultWidgets";

const RJSF_SCHEMA_PROPERTY_NAME = "rjsfSchema";
Expand Down Expand Up @@ -347,20 +347,10 @@ describe("FormEditor", () => {
</FormikTemplate>
);

await waitForEffect();

const fieldToggleButton = screen
.getByTestId(
`toggle-${RJSF_SCHEMA_PROPERTY_NAME}.schema.properties.${fieldName}.default`
)
.querySelector("button");
expect(fieldToggleButton).not.toBeNull();
userEvent.click(fieldToggleButton);
const textOption = screen.getByTestId("string");
expect(textOption).not.toBeNull();
await waitFor(() => {
userEvent.click(textOption);
});
await selectSchemaFieldType(
`${RJSF_SCHEMA_PROPERTY_NAME}.schema.properties.${fieldName}.default`,
Copy link
Contributor

@twschiller twschiller Mar 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be be using joinName to correctly handle spaces/special characters in field names (unless we block them on the entry side)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this is a test file, do you think it can be a problem in the test?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, didn't notice the file name! This is perfectly ok here

"string"
);

const defaultValue = "Initial default value";
const defaultValueInput = screen.getByLabelText("Default value");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
RJSFSchema,
SelectStringOption,
SetActiveField,
} from "./formBuilderTypes";
} from "@/components/formBuilder/formBuilderTypes";
import { Button, Col, Row } from "react-bootstrap";
import FieldEditor from "./FieldEditor";
import {
Expand All @@ -33,8 +33,8 @@ import {
normalizeUiOrder,
replaceStringInArray,
updateRjsfSchemaWithDefaultsIfNeeded,
} from "./formBuilderHelpers";
import { UI_ORDER } from "./schemaFieldNames";
} from "@/components/formBuilder/formBuilderHelpers";
import { UI_ORDER } from "@/components/formBuilder/schemaFieldNames";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { Schema } from "@/core";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@ exports[`FormEditor renders simple schema 1`] = `
<input
name="rjsfSchema.schema.properties.firstName.uiType"
type="hidden"
value="string::"
value="string:::"
/>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/formBuilder/formBuilderHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ describe("validateNextPropertyName", () => {
});
});

describe("ormalizeUiOrder", () => {
describe("normalizeUiOrder", () => {
test("init uiOrder", () => {
const actual = normalizeUiOrder(["propA", "propB"], []);
expect(actual).toEqual(["propA", "propB", "*"]);
Expand Down
Loading