From 8cbf8a550449729ecc3f8e8a669ffb316af420f3 Mon Sep 17 00:00:00 2001 From: Rahul Barwal Date: Tue, 12 Nov 2024 10:56:26 +0530 Subject: [PATCH] fix: Implement dynamic dropdown width in SelectField in JSONForm (#37289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Problem The SelectField inside JSONForm widget's dropdown width was not dynamically adjusted, resulting in inconsistent responsiveness. Root cause The SelectField component's dropdown width was not being updated dynamically, causing the component to become too wide or too narrow, affecting its usability. Solution 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" ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: 1438c99fb6760f87879363ed1ad82bc0f3ddea54 > Cypress dashboard. > Tags: `@tag.JSONForm` > Spec: >
Tue, 12 Nov 2024 04:59:15 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## 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. --- .../fields/SelectField.test.tsx | 224 +++++++++++++++++- .../JSONFormWidget/fields/SelectField.tsx | 38 ++- 2 files changed, 257 insertions(+), 5 deletions(-) diff --git a/app/client/src/widgets/JSONFormWidget/fields/SelectField.test.tsx b/app/client/src/widgets/JSONFormWidget/fields/SelectField.test.tsx index 7f87b5030d..6d49c89022 100644 --- a/app/client/src/widgets/JSONFormWidget/fields/SelectField.test.tsx +++ b/app/client/src/widgets/JSONFormWidget/fields/SelectField.test.tsx @@ -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 ( + + + {children} + + + ); + }; + 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( + + + , + ); + + expect(mockObserver).toHaveBeenCalled(); + }); + + it("should cleanup ResizeObserver on unmount", () => { + const { unmount } = render( + + + , + ); + + 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( + + + , + ); + 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( + + + , + ); + 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; + + 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, + ]); + } + } +} diff --git a/app/client/src/widgets/JSONFormWidget/fields/SelectField.tsx b/app/client/src/widgets/JSONFormWidget/fields/SelectField.tsx index 63bb33ae54..d2ac95ea5c 100644 --- a/app/client/src/widgets/JSONFormWidget/fields/SelectField.tsx +++ b/app/client/src/widgets/JSONFormWidget/fields/SelectField.tsx @@ -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( () => ( @@ -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, ], );