Merge pull request #12914 from ToolJet/gh-12722-resize-container

Add resizable handle on header and footer in Container and ModalV2
This commit is contained in:
Johnson Cherian 2025-06-25 13:01:12 +05:30 committed by GitHub
commit 76029ca5d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 181 additions and 127 deletions

View file

@ -47,11 +47,6 @@ export const containerConfig = {
defaultValue: true,
},
},
headerHeight: {
type: 'numberInput',
displayName: 'Header height',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
},
defaultChildren: [
{
@ -65,7 +60,6 @@ export const containerConfig = {
},
displayName: 'ContainerText',
properties: ['text'],
slotName: 'header',
accessorKey: 'text',
styles: ['fontWeight', 'textSize', 'textColor'],
defaultValue: {

View file

@ -92,18 +92,6 @@ export const modalV2Config = {
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 400 },
},
headerHeight: {
type: 'numberInput',
displayName: 'Header height',
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
footerHeight: {
type: 'numberInput',
displayName: 'Footer height',
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
hideOnEsc: { type: 'toggle', displayName: 'Close on escape key', section: 'additionalActions' },
closeOnClickingOutside: { type: 'toggle', displayName: 'Close on clicking outside', section: 'additionalActions' },
hideCloseButton: { type: 'toggle', displayName: 'Hide close button', section: 'additionalActions' },

View file

@ -9,6 +9,8 @@ import {
} from '@/AppBuilder/AppCanvas/appCanvasConstants';
import useStore from '@/AppBuilder/_stores/store';
import './container.scss';
import { useActiveSlot } from '@/AppBuilder/_hooks/useActiveSlot';
import { HorizontalSlot } from '@/AppBuilder/Widgets/Form/Components/HorizontalSlot';
export const Container = ({
id,
@ -33,8 +35,13 @@ export const Container = ({
shallow
);
const isEditing = useStore((state) => state.currentMode === 'edit');
const setComponentProperty = useStore((state) => state.setComponentProperty, shallow);
const activeSlot = useActiveSlot(isEditing ? id : null); // Track the active slot for this widget
const { borderRadius, borderColor, boxShadow } = styles;
const { headerHeight = 80 } = properties;
const headerMaxHeight = parseInt(height, 10) - 100 - 10;
const contentBgColor = useMemo(() => {
return {
backgroundColor:
@ -65,6 +72,7 @@ export const Container = ({
const containerHeaderStyles = {
flexShrink: 0,
padding: `${CONTAINER_FORM_CANVAS_PADDING}px ${CONTAINER_FORM_CANVAS_PADDING}px 3px ${CONTAINER_FORM_CANVAS_PADDING}px`,
maxHeight: `${headerMaxHeight}px`,
...headerBgColor,
};
const containerContentStyles = {
@ -74,6 +82,11 @@ export const Container = ({
padding: `${CONTAINER_FORM_CANVAS_PADDING}px`,
};
const updateHeaderSizeInStore = ({ newHeight }) => {
const _height = parseInt(newHeight, 10);
setComponentProperty(id, `headerHeight`, _height, 'properties', 'value', false);
};
return (
<div
className={`jet-container ${isLoading ? 'jet-container-loading' : ''}`}
@ -86,17 +99,19 @@ export const Container = ({
) : (
<>
{properties.showHeader && (
<div style={containerHeaderStyles} className="wj-container-header">
<ContainerComponent
id={`${id}-header`}
styles={{ ...headerBgColor, height: `${headerHeight}px` }}
canvasHeight={headerHeight / 10}
canvasWidth={width}
allowContainerSelect={true}
darkMode={darkMode}
componentType="Container"
/>
</div>
<HorizontalSlot
slotName={'header'}
slotStyle={containerHeaderStyles}
isEditing={isEditing}
id={`${id}-header`}
height={headerHeight}
width={width}
darkMode={darkMode}
isDisabled={isDisabled}
isActive={activeSlot === `${id}-header`}
onResize={updateHeaderSizeInStore}
componentType="Container"
/>
)}
<div style={containerContentStyles}>
<ContainerComponent

View file

@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { Container as SubContainer } from '@/AppBuilder/AppCanvas/Container';
import { showGridLinesOnSlot, hideGridLinesOnSlot } from '@/AppBuilder/AppCanvas/Grid/gridUtils';
import { useResizable } from '@/AppBuilder/_hooks/useMoveable';
import { showGridLines, hideGridLines } from '@/AppBuilder/AppCanvas/Grid/gridUtils';
import { useSubContainerResizable } from '@/AppBuilder/_hooks/useSubContainerResizable';
export const HorizontalSlot = React.memo(
({
@ -16,10 +16,10 @@ export const HorizontalSlot = React.memo(
onResize,
isEditing,
maxHeight,
componentType,
}) => {
const parsedHeight = parseInt(height, 10);
const { getRootProps, getHandleProps, getResizeState } = useResizable({
const { getRootProps, getHandleProps, getResizeState } = useSubContainerResizable({
initialHeight: parsedHeight,
initialWidth: '100%', // Now respects parent's width
minHeight: 10,
@ -34,12 +34,11 @@ export const HorizontalSlot = React.memo(
});
const { height: resizedHeight, isDragging } = getResizeState();
useEffect(() => {
if (isDragging) {
showGridLinesOnSlot(id);
showGridLines();
} else {
hideGridLinesOnSlot(id);
hideGridLines();
}
}, [isDragging, id]);
@ -50,7 +49,10 @@ export const HorizontalSlot = React.memo(
};
return (
<div className={`jet-form-${slotName} wj-form-${slotName}`} style={slotStyle}>
<div
className={`jet-${componentType?.toLowerCase()}-${slotName} wj-${componentType?.toLowerCase()}-${slotName}`}
style={slotStyle}
>
<div
className={`resizable-slot only-${slotName} ${isActive ? 'active' : ''} ${isEditing && 'is-editing'} ${
isDragging ? 'dragging' : ''
@ -68,7 +70,7 @@ export const HorizontalSlot = React.memo(
backgroundColor: 'transparent',
overflow: 'hidden',
}}
componentType="Form"
componentType={componentType}
/>
{isEditing && <div className="resize-handle" {...getHandleProps()} style={resizeStyle} />}
</div>

View file

@ -340,6 +340,7 @@ export const Form = function Form(props) {
isDisabled={isDisabled}
isActive={activeSlot === `${id}-header`}
onResize={updateHeaderSizeInStore}
componentType="Form"
/>
)}
@ -415,6 +416,7 @@ export const Form = function Form(props) {
isDisabled={isDisabled}
onResize={updateFooterSizeInStore}
isActive={activeSlot === `${id}-footer`}
componentType="Form"
/>
)}
</form>

View file

@ -1,35 +1,55 @@
import React from 'react';
import { default as BootstrapModal } from 'react-bootstrap/Modal';
import { Container as SubContainer } from '@/AppBuilder/AppCanvas/Container';
import { getCanvasHeight } from '@/AppBuilder/Widgets/ModalV2/helpers/utils';
import { HorizontalSlot } from '@/AppBuilder/Widgets/Form/Components/HorizontalSlot';
import { MODAL_CANVAS_PADDING } from '@/AppBuilder/AppCanvas/appCanvasConstants';
export const ModalFooter = React.memo(({ id, isDisabled, customStyles, darkMode, width, footerHeight, onClick }) => {
const canvasFooterHeight = getCanvasHeight(footerHeight);
return (
<BootstrapModal.Footer style={{ ...customStyles.modalFooter }} data-cy={`modal-footer`} onClick={onClick}>
<SubContainer
id={`${id}-footer`}
canvasHeight={canvasFooterHeight}
canvasWidth={width}
allowContainerSelect={false}
darkMode={darkMode}
styles={{
margin: 0,
backgroundColor: 'transparent',
overflowX: 'hidden',
overflowY: isDisabled ? 'hidden' : 'auto',
}}
componentType="ModalV2"
/>
{isDisabled && (
<div
id={`${id}-footer-disabled`}
className="tj-modal-disabled-overlay"
style={{ height: footerHeight || '100%' }}
onClick={onClick}
onDrop={(e) => e.stopPropagation()}
export const ModalFooter = React.memo(
({
id,
isDisabled,
customStyles,
darkMode,
width,
footerHeight,
onClick,
isEditing,
updateFooterSizeInStore,
activeSlot,
footerMaxHeight,
isFullScreen,
}) => {
const canvasFooterHeight = getCanvasHeight(footerHeight);
return (
<BootstrapModal.Footer style={{ ...customStyles.modalFooter }} data-cy={`modal-footer`} onClick={onClick}>
<HorizontalSlot
slotName={'footer'}
slotStyle={{
width: `100%`,
padding: `${4.5}px ${MODAL_CANVAS_PADDING}px`,
margin: '0px',
}}
isEditing={isEditing}
id={`${id}-footer`}
height={canvasFooterHeight}
width={width}
darkMode={darkMode}
isDisabled={isDisabled}
isActive={activeSlot === `${id}-footer`}
onResize={updateFooterSizeInStore}
componentType="ModalV2"
maxHeight={isFullScreen ? undefined : footerMaxHeight}
/>
)}
</BootstrapModal.Footer>
);
});
{isDisabled && (
<div
id={`${id}-footer-disabled`}
className="tj-modal-disabled-overlay"
style={{ height: footerHeight || '100%' }}
onClick={onClick}
onDrop={(e) => e.stopPropagation()}
/>
)}
</BootstrapModal.Footer>
);
}
);

View file

@ -1,27 +1,49 @@
import React from 'react';
import { default as BootstrapModal } from 'react-bootstrap/Modal';
import { Container as SubContainer } from '@/AppBuilder/AppCanvas/Container';
import { getCanvasHeight } from '@/AppBuilder/Widgets/ModalV2/helpers/utils';
import { HorizontalSlot } from '@/AppBuilder/Widgets/Form/Components/HorizontalSlot';
import { MODAL_CANVAS_PADDING } from '@/AppBuilder/AppCanvas/appCanvasConstants';
export const ModalHeader = React.memo(
({ id, isDisabled, customStyles, hideCloseButton, darkMode, width, onHideModal, headerHeight, onClick }) => {
({
id,
isDisabled,
customStyles,
hideCloseButton,
darkMode,
width,
onHideModal,
headerHeight,
onClick,
isEditing,
updateHeaderSizeInStore,
activeSlot,
headerMaxHeight,
isFullScreen,
}) => {
const canvasHeaderHeight = getCanvasHeight(headerHeight);
// console.log(headerMaxHeight, 'headerMaxHeight');
return (
<BootstrapModal.Header style={{ ...customStyles.modalHeader }} data-cy={`modal-header`} onClick={onClick}>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<SubContainer
id={`${id}-header`}
canvasHeight={canvasHeaderHeight}
canvasWidth={width}
allowContainerSelect={false}
darkMode={darkMode}
styles={{
backgroundColor: 'transparent',
overflowX: 'hidden',
overflowY: isDisabled ? 'hidden' : 'auto',
<HorizontalSlot
slotName={'header'}
slotStyle={{
height: `100%`,
padding: `${4.5}px ${MODAL_CANVAS_PADDING}px`,
maxHeight: isFullScreen ? `${headerMaxHeight}` : `${headerMaxHeight}px`,
minHeight: '10px',
}}
isEditing={isEditing}
id={`${id}-header`}
height={canvasHeaderHeight}
width={width}
darkMode={darkMode}
isDisabled={isDisabled}
isActive={activeSlot === `${id}-header`}
onResize={updateHeaderSizeInStore}
componentType="ModalV2"
maxHeight={isFullScreen ? undefined : headerMaxHeight}
/>
</div>
{isDisabled && (

View file

@ -4,7 +4,8 @@ import { Container as SubContainer } from '@/AppBuilder/AppCanvas/Container';
import { ConfigHandle } from '@/AppBuilder/AppCanvas/ConfigHandle/ConfigHandle';
import { ModalHeader } from '@/AppBuilder/Widgets/ModalV2/Components/Header';
import { ModalFooter } from '@/AppBuilder/Widgets/ModalV2/Components/Footer';
import useStore from '@/AppBuilder/_stores/store';
import { useActiveSlot } from '@/AppBuilder/_hooks/useActiveSlot';
export const ModalWidget = ({ ...restProps }) => {
const {
customStyles,
@ -24,8 +25,31 @@ export const ModalWidget = ({ ...restProps }) => {
headerHeight,
footerHeight,
onSelectModal,
modalHeight,
isFullScreen,
} = restProps['modalProps'];
const isEditing = useStore((state) => state.currentMode === 'edit');
const setComponentProperty = useStore((state) => state.setComponentProperty);
const activeSlot = useActiveSlot(isEditing ? id : null); // Track the active slot for this widget
const _modalHeight = isFullScreen ? '100vh' : `${modalHeight}px`;
const headerMaxHeight = isFullScreen
? `calc(${_modalHeight} - ${footerHeight} - 100px - 10px)`
: parseInt(_modalHeight, 10) - parseInt(footerHeight, 10) - 100 - 10;
const footerMaxHeight = isFullScreen
? `calc(${_modalHeight} - ${headerHeight} - 100px - 10px)`
: parseInt(_modalHeight, 10) - parseInt(headerHeight, 10) - 100 - 10;
const updateHeaderSizeInStore = ({ newHeight }) => {
const _height = parseInt(newHeight, 10);
setComponentProperty(id, `headerHeight`, _height, 'properties', 'value', false);
};
const updateFooterSizeInStore = ({ newHeight }) => {
const _height = parseInt(newHeight, 10);
setComponentProperty(id, `footerHeight`, _height, 'properties', 'value', false);
};
// When the modal body is clicked capture it and use the callback to set the selected component as modal
const handleModalSlotClick = (event) => {
const clickedComponentId = event.target.getAttribute('component-id');
@ -53,10 +77,21 @@ export const ModalWidget = ({ ...restProps }) => {
};
}, []);
useEffect(() => {
setTimeout(() => {
const modalContent = document.querySelector(`.tj-modal-content-${id}`);
if (restProps.show && modalContent) {
modalContent.style.setProperty('height', _modalHeight, 'important');
modalContent.style.setProperty('max-height', isFullScreen ? '100%' : modalHeight, 'important');
}
}, 100);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modalHeight, restProps.show, isFullScreen]);
return (
<BootstrapModal
{...restProps}
contentClassName="modal-component tj-modal--container tj-modal-widget-content"
contentClassName={`modal-component tj-modal--container tj-modal-widget-content tj-modal-content-${id}`}
animation={true}
onEscapeKeyDown={(e) => {
e.preventDefault();
@ -87,6 +122,11 @@ export const ModalWidget = ({ ...restProps }) => {
onHideModal={onHideModal}
headerHeight={headerHeight}
onClick={handleModalSlotClick}
isEditing={isEditing}
updateHeaderSizeInStore={updateHeaderSizeInStore}
activeSlot={activeSlot}
headerMaxHeight={headerMaxHeight}
isFullScreen={isFullScreen}
/>
)}
<BootstrapModal.Body style={{ ...customStyles.modalBody }} ref={parentRef} id={id} data-cy={`modal-body`}>
@ -128,6 +168,11 @@ export const ModalWidget = ({ ...restProps }) => {
width={modalWidth}
footerHeight={footerHeight}
onClick={handleModalSlotClick}
isEditing={isEditing}
updateFooterSizeInStore={updateFooterSizeInStore}
activeSlot={activeSlot}
footerMaxHeight={footerMaxHeight}
isFullScreen={isFullScreen}
/>
)}
</BootstrapModal>

View file

@ -14,7 +14,6 @@ import {
} from '@/AppBuilder/Widgets/ModalV2/helpers/utils';
import { createModalStyles } from '@/AppBuilder/Widgets/ModalV2/helpers/stylesFactory';
import { onShowSideEffects, onHideSideEffects } from '@/AppBuilder/Widgets/ModalV2/helpers/sideEffects';
import '@/AppBuilder/Widgets/ModalV2/style.scss';
import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext';
@ -238,6 +237,7 @@ export const ModalV2 = function Modal({
modalBodyHeight: computedCanvasHeight,
modalWidth,
onSelectModal: setSelectedComponentAsModal,
isFullScreen,
}}
/>
</div>

View file

@ -29,16 +29,12 @@ export function createModalStyles({
modalHeader: {
backgroundColor:
['#fff', '#ffffffff'].includes(headerBackgroundColor) && darkMode ? '#1F2837' : headerBackgroundColor,
height: headerHeightPx,
overflowY: isDisabledModal ? 'hidden' : 'auto',
padding: `${4.5}px ${MODAL_CANVAS_PADDING}px`,
},
modalFooter: {
backgroundColor:
['#fff', '#ffffffff'].includes(footerBackgroundColor) && darkMode ? '#1F2837' : footerBackgroundColor,
height: footerHeightPx,
overflowY: isDisabledModal ? 'hidden' : 'auto',
padding: `${4.5}px ${MODAL_CANVAS_PADDING}px`,
},
buttonStyles: {
backgroundColor: triggerButtonBackgroundColor,

View file

@ -49,6 +49,7 @@
.modal-header {
padding: 0;
position: relative;
min-height: 40px;
}
.modal-body {
@ -62,5 +63,11 @@
border-top: 1px solid var(--border-weak);
overflow-x: hidden;
width: 100%;
}
}
.jet-modalv2-footer .resize-handle {
top: -4px;
bottom: unset;
}

View file

@ -15,7 +15,7 @@ const defaultProps = {
isReverseVerticalDrag: false,
};
export const useResizable = (options = {}) => {
export const useSubContainerResizable = (options = {}) => {
const props = { ...defaultProps, ...options };
const parentRef = useRef(null);
const [isDragging, setIsDragging] = useState(false); // ✅ Track dragging state
@ -115,7 +115,6 @@ export const useResizable = (options = {}) => {
// Get the updated height and width from the DOM instead of relying on state
const finalHeight = parentRef.current ? parseInt(parentRef.current.clientHeight) : parseInt(height);
const finalWidth = parentRef.current ? parseInt(parentRef.current.clientWidth) : parseInt(width);
props.onDragEnd({ newHeight: finalHeight, newWidth: finalWidth });
}
};
@ -132,4 +131,4 @@ export const useResizable = (options = {}) => {
return { rootRef: parentRef, getRootProps, getHandleProps, getResizeState };
};
export default useResizable;
export default useSubContainerResizable;

View file

@ -47,11 +47,6 @@ export const containerConfig = {
defaultValue: true,
},
},
headerHeight: {
type: 'numberInput',
displayName: 'Header height',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
},
defaultChildren: [
{
@ -65,7 +60,6 @@ export const containerConfig = {
},
displayName: 'ContainerText',
properties: ['text'],
slotName: 'header',
accessorKey: 'text',
styles: ['fontWeight', 'textSize', 'textColor'],
defaultValue: {

View file

@ -92,18 +92,6 @@ export const modalV2Config = {
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 400 },
},
headerHeight: {
type: 'numberInput',
displayName: 'Header height',
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
footerHeight: {
type: 'numberInput',
displayName: 'Footer height',
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
hideOnEsc: { type: 'toggle', displayName: 'Close on escape key', section: 'additionalActions' },
closeOnClickingOutside: { type: 'toggle', displayName: 'Close on clicking outside', section: 'additionalActions' },
hideCloseButton: { type: 'toggle', displayName: 'Hide close button', section: 'additionalActions' },

View file

@ -47,11 +47,6 @@ export const containerConfig = {
defaultValue: true,
},
},
headerHeight: {
type: 'numberInput',
displayName: 'Header height',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
},
defaultChildren: [
{
@ -65,7 +60,6 @@ export const containerConfig = {
},
displayName: 'ContainerText',
properties: ['text'],
slotName: 'header',
accessorKey: 'text',
styles: ['fontWeight', 'textSize', 'textColor'],
defaultValue: {

View file

@ -92,18 +92,6 @@ export const modalV2Config = {
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 400 },
},
headerHeight: {
type: 'numberInput',
displayName: 'Header height',
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
footerHeight: {
type: 'numberInput',
displayName: 'Footer height',
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
hideOnEsc: { type: 'toggle', displayName: 'Close on escape key', section: 'additionalActions' },
closeOnClickingOutside: { type: 'toggle', displayName: 'Close on clicking outside', section: 'additionalActions' },
hideCloseButton: { type: 'toggle', displayName: 'Hide close button', section: 'additionalActions' },