Skip to content

Commit

Permalink
Merge branch 'main' into dev/guard-against-empty-frame-aa
Browse files Browse the repository at this point in the history
  • Loading branch information
Ben Loe committed May 17, 2024
2 parents d771d0f + 19e49c1 commit 858f14c
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 54 deletions.
184 changes: 141 additions & 43 deletions src/components/form/widgets/SelectWidget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,54 +19,152 @@ import { render, screen } from "@testing-library/react";
import React from "react";
import selectEvent from "react-select-event";
import SelectWidget, { type Option } from "./SelectWidget";
import { type GroupBase } from "react-select";
import { waitForEffect } from "@/testUtils/testHelpers";

const options: Option[] = [
{
label: "Test label 1",
value: "value1",
},
{
label: "Test label 2",
value: "value2",
},
];
describe("options", () => {
const options: Option[] = [
{
label: "Test label 1",
value: "value1",
},
{
label: "Test label 2",
value: "value2",
},
];

test("renders value", () => {
const name = "Name for Test";
const { asFragment } = render(
<SelectWidget
id="idForTest"
name={name}
options={options}
value={options[1]!.value}
onChange={jest.fn()}
/>,
);
expect(asFragment()).toMatchSnapshot();
});
test("renders value", () => {
const name = "Name for Test";
const selectedOption = options[1]!;
const { asFragment } = render(
<SelectWidget
id="idForTest"
name={name}
options={options}
value={selectedOption.value}
onChange={jest.fn()}
/>,
);
expect(asFragment()).toMatchSnapshot();
expect(screen.getByText(selectedOption.label)).toBeInTheDocument();
// eslint-disable-next-line testing-library/no-node-access -- react-select works via hidden input element that can't be accessed via getByRole
const hiddenInput = document.querySelector("input[type=hidden]");
expect(hiddenInput).toHaveAttribute("name", name);
expect(hiddenInput).toHaveAttribute("value", selectedOption.value);
});

test("calls onChange", async () => {
const id = "idForTest";
const name = "Name for Test";
const onChangeMock = jest.fn();
render(
<SelectWidget
id={id}
name={name}
options={options}
value={options[1]!.value}
onChange={onChangeMock}
/>,
);
test("calls onChange", async () => {
const id = "idForTest";
const name = "Name for Test";
const onChangeMock = jest.fn();
const selectedOption = options[1]!;
render(
<SelectWidget
id={id}
name={name}
options={options}
value={selectedOption.value}
onChange={onChangeMock}
/>,
);

const optionToSelect = options[0];
await selectEvent.select(screen.getByRole("combobox"), optionToSelect!.label);
const optionToSelect = options[0]!;
await selectEvent.select(
screen.getByRole("combobox"),
optionToSelect.label,
);

expect(onChangeMock).toHaveBeenCalledWith({
target: {
options,
value: optionToSelect!.value,
name,
expect(onChangeMock).toHaveBeenCalledWith({
target: {
options,
value: optionToSelect.value,
name,
},
});
});
});

describe("grouped options", () => {
const groupedOptions: Array<GroupBase<Option>> = [
{
label: "Group 1",
options: [
{
label: "Group 1 label 1",
value: "group1value1",
},
{
label: "Group1 label 2",
value: "group1value2",
},
],
},
{
label: "Group 2",
options: [
{
label: "Group 2 label 1",
value: "group2value1",
},
{
label: "Group 2 label 2",
value: "group2value2",
},
],
},
];

test("renders grouped options", async () => {
const name = "Test field";
const selectedOption = groupedOptions[1]!.options[0]!;
render(
<SelectWidget
id="testField"
name={name}
options={groupedOptions}
value={selectedOption.value}
onChange={jest.fn()}
/>,
);

await waitForEffect();
expect(screen.getByText(selectedOption.label)).toBeInTheDocument();

// eslint-disable-next-line testing-library/no-node-access -- react-select works via hidden input element that can't be accessed via getByRole
const hiddenInput = document.querySelector("input[type=hidden]");
expect(hiddenInput).toHaveAttribute("name", name);
expect(hiddenInput).toHaveAttribute("value", selectedOption.value);
});

test("calls onChange", async () => {
const id = "idForTest";
const name = "Name for Test";
const onChangeMock = jest.fn();
const selectedOption = groupedOptions[1]!.options[0]!;

render(
<SelectWidget
id={id}
name={name}
options={groupedOptions}
value={selectedOption.value}
onChange={onChangeMock}
/>,
);

const optionToSelect = groupedOptions[0]!.options[1]!;
await selectEvent.select(
screen.getByRole("combobox"),
optionToSelect.label,
);

expect(onChangeMock).toHaveBeenCalledWith({
target: {
options: groupedOptions,
value: optionToSelect.value,
name,
},
});
});
});
37 changes: 30 additions & 7 deletions src/components/form/widgets/SelectWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React, { type ChangeEvent, useState } from "react";
import React, { type ChangeEvent, useState, useMemo } from "react";
import { type CustomFieldWidgetProps } from "@/components/form/FieldTemplate";
import Select, {
type GroupBase,
Expand All @@ -27,14 +27,20 @@ import Creatable from "react-select/creatable";
import useAddCreatablePlaceholder from "@/components/form/widgets/useAddCreatablePlaceholder";

// Type of the Select options
export type Option<TValue = string> = {
export type Option<TValue = string | null> = {
label: string;
value: TValue | null;
value: TValue;
};

function isGroupedOption<TValue = string | null>(
option: Option<TValue> | GroupBase<Option<TValue>>,
): option is GroupBase<Option<TValue>> {
return "options" in option;
}

// Type passed as target in onChange event
export type SelectLike<TOption extends Option<TOption["value"]> = Option> = {
value?: TOption["value"];
value: TOption["value"];
name: string;
options: TOption[];
};
Expand All @@ -57,7 +63,7 @@ export type SelectWidgetOnChange<
export type SelectWidgetProps<TOption extends Option<TOption["value"]>> =
CustomFieldWidgetProps<TOption["value"], SelectLike<TOption>> & {
isClearable?: boolean;
options?: TOption[];
options?: TOption[] | Array<GroupBase<TOption>>;
isLoading?: boolean;
loadingMessage?: string;
disabled?: boolean;
Expand All @@ -69,6 +75,7 @@ export type SelectWidgetProps<TOption extends Option<TOption["value"]>> =
* True if the user can create new options. Default is false.
*/
creatable?: boolean;
placeholder?: string;
};

const SelectWidget = <TOption extends Option<TOption["value"]>>({
Expand All @@ -86,10 +93,13 @@ const SelectWidget = <TOption extends Option<TOption["value"]>>({
styles,
creatable = false,
isSearchable = true,
placeholder,
}: SelectWidgetProps<TOption>) => {
const [textInputValue, setTextInputValue] = useState("");

const optionsWithPlaceholder = useAddCreatablePlaceholder({
const optionsWithPlaceholder = useAddCreatablePlaceholder<
NonNullable<SelectWidgetProps<TOption>["options"]>[number]
>({
creatable,
options,
textInputValue,
Expand All @@ -102,9 +112,21 @@ const SelectWidget = <TOption extends Option<TOption["value"]>>({
} as ChangeEvent<SelectLike<TOption>>);
};

const flatOptions = useMemo(
() =>
options?.flatMap<TOption>((option) => {
if (isGroupedOption(option)) {
return option.options;
}

return option;
}),
[options],
);

// Pass null instead of undefined if options is not defined
const selectedOption =
options?.find((option: TOption) => value === option.value) ?? null;
flatOptions?.find((option: TOption) => value === option.value) ?? null;

const Component = creatable ? Creatable : Select;

Expand All @@ -129,6 +151,7 @@ const SelectWidget = <TOption extends Option<TOption["value"]>>({
}
styles={styles}
isSearchable={isSearchable}
placeholder={placeholder}
/>
);
};
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 13 additions & 2 deletions src/integrations/util/getUnconfiguredComponentIntegrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe("getUnconfiguredComponentIntegrations", () => {
]);
});

it("dedupes integrations", () => {
it("dedupes integrations by integrationId and outputKey", () => {
const serviceId1 = validateRegistryId("@pixiebrix/test-service1");
const serviceId2 = validateRegistryId("@pixiebrix/test-service2");
const modComponentDefinition1 = modComponentDefinitionFactory({
Expand All @@ -98,11 +98,16 @@ describe("getUnconfiguredComponentIntegrations", () => {
const modComponentDefinition2 = modComponentDefinitionFactory({
services: {
properties: {
// Same outputKey as above for service1
service1: {
$ref: `${SERVICES_BASE_SCHEMA_URL}${serviceId1}`,
},
// Unique outputKey for service2
service3: {
$ref: `${SERVICES_BASE_SCHEMA_URL}${serviceId2}`,
},
},
required: ["service1"],
required: ["service1", "service3"],
},
});

Expand All @@ -124,6 +129,12 @@ describe("getUnconfiguredComponentIntegrations", () => {
isOptional: false,
apiVersion: "v2",
},
{
integrationId: serviceId2,
outputKey: validateOutputKey("service3"),
isOptional: false,
apiVersion: "v2",
},
]),
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ export default function getUnconfiguredComponentIntegrations({

const dedupedIntegrationDependencies: IntegrationDependency[] = [];
for (const group of Object.values(
groupBy(integrationDependencies, "integrationId"),
groupBy(
integrationDependencies,
({ integrationId, outputKey }) => `${integrationId}:${outputKey}`,
),
)) {
const notOptional = group.find(({ isOptional }) => !isOptional);
if (notOptional) {
Expand Down

0 comments on commit 858f14c

Please sign in to comment.