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:
Rahul Barwal 2024-11-12 10:56:26 +05:30 committed by GitHub
parent d8d0d1a61c
commit 8cbf8a5504
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 257 additions and 5 deletions

View File

@ -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,
]);
}
}
}

View File

@ -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,
],
);