fix: Implement dynamic dropdown width in SelectField in JSONForm (#37289)
## Description <ins>Problem</ins> The SelectField inside JSONForm widget's dropdown width was not dynamically adjusted, resulting in inconsistent responsiveness. <ins>Root cause</ins> The SelectField component's dropdown width was not being updated dynamically, causing the component to become too wide or too narrow, affecting its usability. <ins>Solution</ins> This PR enhances the SelectField component to adjust its dropdown width dynamically for improved responsiveness. This PR handles... - Dynamically adjusting the dropdown width based on the available screen space, ensuring a responsive user experience. - Properly setting up and tearing down the ResizeObserver to ensure accurate width detection. Fixes #37279 _or_ Fixes `Issue URL` > [!WARNING] > _If no issue exists, please create an issue first, and check with the maintainers if the issue is valid._ ## Automation /ok-to-test tags="@tag.JSONForm" ### 🔍 Cypress test results <!-- This is an auto-generated comment: Cypress test results --> > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: <https://github.com/appsmithorg/appsmith/actions/runs/11790765022> > Commit: 1438c99fb6760f87879363ed1ad82bc0f3ddea54 > <a href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=11790765022&attempt=1" target="_blank">Cypress dashboard</a>. > Tags: `@tag.JSONForm` > Spec: > <hr>Tue, 12 Nov 2024 04:59:15 UTC <!-- end of auto-generated comment: Cypress test results --> ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Enhanced the `SelectField` component for dynamic dropdown width adjustment based on its wrapper size. - Improved filter update handling within the `SelectField`. - **Bug Fixes** - Expanded tests for validation logic to ensure accurate behavior based on the `isRequired` property. - **Tests** - Added comprehensive tests for the `SelectField`, including mock implementations for `ResizeObserver` to validate resizing behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
d8d0d1a61c
commit
8cbf8a5504
|
|
@ -1,5 +1,11 @@
|
|||
import { act, render } from "@testing-library/react";
|
||||
import { RenderModes } from "constants/WidgetConstants";
|
||||
import React from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { DataType, FieldType, type Schema } from "../constants";
|
||||
import FormContext from "../FormContext";
|
||||
import type { SelectFieldProps } from "./SelectField";
|
||||
import { isValid } from "./SelectField";
|
||||
import SelectField, { isValid } from "./SelectField";
|
||||
|
||||
describe(".isValid", () => {
|
||||
it("returns true when isRequired is false", () => {
|
||||
|
|
@ -70,3 +76,219 @@ describe(".isValid", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResizeObserver", () => {
|
||||
const MockFormWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const methods = useForm();
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<FormContext.Provider
|
||||
value={{
|
||||
executeAction: jest.fn(),
|
||||
renderMode: RenderModes.CANVAS,
|
||||
setMetaInternalFieldState: jest.fn(),
|
||||
updateWidgetMetaProperty: jest.fn(),
|
||||
updateWidgetProperty: jest.fn(),
|
||||
updateFormData: jest.fn(),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FormContext.Provider>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
const defaultProps: SelectFieldProps = {
|
||||
name: "testSelect",
|
||||
fieldClassName: "test-select",
|
||||
propertyPath: "testSelect",
|
||||
schemaItem: {
|
||||
fieldType: FieldType.SELECT,
|
||||
isRequired: false,
|
||||
isVisible: true,
|
||||
isDisabled: false,
|
||||
accessor: "testSelect",
|
||||
identifier: "testSelect",
|
||||
originalIdentifier: "testSelect",
|
||||
position: 0,
|
||||
label: "Test Select",
|
||||
options: [
|
||||
{ label: "Option 1", value: "1" },
|
||||
{ label: "Option 2", value: "2" },
|
||||
],
|
||||
children: {} as Schema, // Assuming an empty Schema object or placeholder
|
||||
dataType: DataType.STRING,
|
||||
isCustomField: false,
|
||||
sourceData: null, // Assuming sourceData as null or other default
|
||||
isFilterable: false,
|
||||
filterText: "",
|
||||
serverSideFiltering: false,
|
||||
},
|
||||
};
|
||||
let resizeObserver: ResizeObserverMock;
|
||||
|
||||
beforeAll(() => {
|
||||
(
|
||||
global as unknown as { ResizeObserver: typeof ResizeObserverMock }
|
||||
).ResizeObserver = ResizeObserverMock;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete (global as unknown as { ResizeObserver?: typeof ResizeObserverMock })
|
||||
.ResizeObserver;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Capture the ResizeObserver instance
|
||||
resizeObserver = null!;
|
||||
(
|
||||
global as unknown as { ResizeObserver: typeof ResizeObserverMock }
|
||||
).ResizeObserver = class extends ResizeObserverMock {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
super(callback);
|
||||
resizeObserver = this as ResizeObserverMock;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
it("should setup ResizeObserver on mount", () => {
|
||||
const mockObserver = jest.fn();
|
||||
|
||||
window.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
observe: mockObserver,
|
||||
disconnect: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
render(
|
||||
<MockFormWrapper>
|
||||
<SelectField {...defaultProps} />
|
||||
</MockFormWrapper>,
|
||||
);
|
||||
|
||||
expect(mockObserver).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should cleanup ResizeObserver on unmount", () => {
|
||||
const { unmount } = render(
|
||||
<MockFormWrapper>
|
||||
<SelectField {...defaultProps} />
|
||||
</MockFormWrapper>,
|
||||
);
|
||||
|
||||
const disconnectSpy = jest.spyOn(resizeObserver, "disconnect");
|
||||
|
||||
// Unmount component
|
||||
unmount();
|
||||
|
||||
// Verify cleanup
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("initializes with correct width", () => {
|
||||
// Mock offsetWidth
|
||||
const mockOffsetWidth = 200;
|
||||
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, "offsetWidth", "get")
|
||||
.mockImplementation(() => mockOffsetWidth);
|
||||
|
||||
const { getByTestId } = render(
|
||||
<MockFormWrapper>
|
||||
<SelectField {...defaultProps} />
|
||||
</MockFormWrapper>,
|
||||
);
|
||||
const content = getByTestId("select-container");
|
||||
|
||||
expect(content.offsetWidth).toBe(mockOffsetWidth);
|
||||
});
|
||||
|
||||
it("updates width when select component is resized", async () => {
|
||||
const widths = [200, 300, 400, 250];
|
||||
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, "offsetWidth", "get")
|
||||
.mockImplementation(() => widths[0]);
|
||||
|
||||
const { getByTestId } = render(
|
||||
<MockFormWrapper>
|
||||
<SelectField {...defaultProps} />
|
||||
</MockFormWrapper>,
|
||||
);
|
||||
let triggerElement = getByTestId("select-container");
|
||||
|
||||
widths.forEach((width, index) => {
|
||||
let newWidth = widths[index + 1];
|
||||
|
||||
if (index === widths.length - 1) {
|
||||
newWidth = widths[0];
|
||||
}
|
||||
|
||||
// Verify initial width
|
||||
expect(triggerElement.offsetWidth).toBe(width);
|
||||
|
||||
// Update mock width
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, "offsetWidth", "get")
|
||||
.mockImplementation(() => newWidth);
|
||||
|
||||
// Trigger resize
|
||||
act(() => {
|
||||
resizeObserver.triggerResize(triggerElement, newWidth);
|
||||
});
|
||||
|
||||
// Verify updated width
|
||||
triggerElement = getByTestId("select-container");
|
||||
|
||||
expect(triggerElement.offsetWidth).toBe(newWidth);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type ResizeObserverCallback = (entries: ResizeObserverEntry[]) => void;
|
||||
|
||||
class ResizeObserverMock implements ResizeObserver {
|
||||
private callback: ResizeObserverCallback;
|
||||
private elements: Set<Element>;
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
this.elements = new Set();
|
||||
}
|
||||
|
||||
observe(element: Element): void {
|
||||
this.elements.add(element);
|
||||
}
|
||||
|
||||
unobserve(element: Element): void {
|
||||
this.elements.delete(element);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.elements.clear();
|
||||
}
|
||||
|
||||
// Utility method to trigger resize
|
||||
triggerResize(element: Element, width: number): void {
|
||||
if (this.elements.has(element)) {
|
||||
this.callback([
|
||||
{
|
||||
target: element,
|
||||
contentRect: {
|
||||
width,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: jest.fn(),
|
||||
},
|
||||
borderBoxSize: [{ inlineSize: width, blockSize: 0 }],
|
||||
contentBoxSize: [{ inlineSize: width, blockSize: 0 }],
|
||||
devicePixelContentBoxSize: [{ inlineSize: width, blockSize: 0 }],
|
||||
} as ResizeObserverEntry,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import React, { useCallback, useContext, useMemo, useRef } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import styled from "styled-components";
|
||||
import { useController } from "react-hook-form";
|
||||
|
||||
|
|
@ -101,6 +108,7 @@ function SelectField({
|
|||
schemaItem.defaultValue,
|
||||
passedDefaultValue as DefaultValue,
|
||||
);
|
||||
const [dropDownWidth, setDropDownWidth] = useState(10);
|
||||
|
||||
useRegisterFieldValidity({
|
||||
isValid: isValueValid,
|
||||
|
|
@ -108,6 +116,29 @@ function SelectField({
|
|||
fieldType: schemaItem.fieldType,
|
||||
});
|
||||
useUnmountFieldValidation({ fieldName: name });
|
||||
useEffect(() => {
|
||||
const updateWidth = () => {
|
||||
if (wrapperRef.current) {
|
||||
setDropDownWidth(wrapperRef.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial width
|
||||
updateWidth();
|
||||
|
||||
// Create ResizeObserver instance
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
|
||||
// Start observing the trigger element
|
||||
if (wrapperRef.current) {
|
||||
resizeObserver.observe(wrapperRef.current);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [wrapperRef]);
|
||||
|
||||
const [updateFilterText] = useUpdateInternalMetaState({
|
||||
propertyName: `${name}.filterText`,
|
||||
|
|
@ -158,7 +189,6 @@ function SelectField({
|
|||
[onChange, schemaItem.onOptionChange, executeAction],
|
||||
);
|
||||
|
||||
const dropdownWidth = wrapperRef.current?.clientWidth;
|
||||
const fieldComponent = useMemo(
|
||||
() => (
|
||||
<StyledSelectWrapper ref={wrapperRef}>
|
||||
|
|
@ -168,7 +198,7 @@ function SelectField({
|
|||
boxShadow={schemaItem.boxShadow}
|
||||
compactMode={false}
|
||||
disabled={schemaItem.isDisabled}
|
||||
dropDownWidth={dropdownWidth || 100}
|
||||
dropDownWidth={dropDownWidth || 100}
|
||||
hasError={isDirtyRef.current ? !isValueValid : false}
|
||||
height={10}
|
||||
isFilterable={schemaItem.isFilterable}
|
||||
|
|
@ -203,7 +233,7 @@ function SelectField({
|
|||
isValueValid,
|
||||
onOptionSelected,
|
||||
selectedIndex,
|
||||
dropdownWidth,
|
||||
dropDownWidth,
|
||||
fieldClassName,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user