Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions frontend/src/AppBuilder/AppCanvas/WidgetWrapper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,11 @@ const WidgetWrapper = memo(
const height = calculateMoveableBoxHeightWithId(id, currentLayout, stylesDefinition, moduleId);

// Calculate the final height based on visibility and temporary layouts.
// Hidden widgets in edit mode keep a small placeholder height so designers
// can still see/select them; in view mode they collapse to 0 (and
// display:none is set below).
const finalHeight = visibility ? temporaryLayouts?.height ?? height : mode === 'edit' ? HIDDEN_COMPONENT_HEIGHT : 0;
// Hidden widgets collapse to 0 in both edit and view modes — in edit mode
// a 1px dashed top border (set below) marks the widget's authored position
// so designers can still locate it; the floating ConfigHandle stays
// clickable above the collapsed slot. In view mode display:none is set.
const finalHeight = visibility ? temporaryLayouts?.height ?? height : HIDDEN_COMPONENT_HEIGHT;
const layoutContext = indices ?? subContainerIndex;
const serializedLayoutContext = serializeLayoutContext(layoutContext);

Expand All @@ -151,7 +152,7 @@ const WidgetWrapper = memo(
: finalHeight + 'px',
transform: `translate(${newLayoutData.left * gridWidth}px, ${temporaryLayouts?.top ?? newLayoutData.top}px)`,
WebkitFontSmoothing: 'antialiased',
border: !visibility && mode === 'edit' ? `1px solid var(--border-default)` : 'none',
borderTop: !visibility && mode === 'edit' ? `1px dashed var(--border-accent-strong)` : 'none',
boxSizing: 'content-box',
display: !visibility && mode === 'view' ? 'none' : 'block',
};
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/AppBuilder/AppCanvas/appCanvasConstants.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export const NO_OF_GRIDS = 43;

export const GRID_HEIGHT = 10;

export const HIDDEN_COMPONENT_HEIGHT = 10;
export const HIDDEN_COMPONENT_HEIGHT = 0;

export const CANVAS_WIDTHS = Object.freeze({
deviceWindowWidth: 450,
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/AppBuilder/_stores/slices/gridSlice.js
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ export const createGridSlice = (set, get) => ({
isContainer,
visibility,
containerHeight,
calculateMoveableBoxHeightWithId: boundCalculateMoveableBoxHeightWithId,
});

// ModalV2 bodies aren't siblings in the grid — stash height on a synthetic
Expand Down Expand Up @@ -372,7 +373,8 @@ export const createGridSlice = (set, get) => ({
// in which case hidden widgets drop out of flow and downstream siblings
// collapse up. When `collapseWhenHidden` is false (default), a hidden
// widget still holds its authored slot for reflow anchor math, so
// siblings stay where they are.
// siblings stay where they are (visible gap remains where the widget
// used to be — intentional design).
const inFlowMap = siblingIds.reduce((accumulator, siblingId) => {
if (visibleMap[siblingId]) {
accumulator[siblingId] = true;
Expand Down
49 changes: 39 additions & 10 deletions frontend/src/AppBuilder/_stores/utils/dynamicHeightReflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,17 @@ export const sortByCanonicalPosition = (componentIds, currentLayout, currentPage
return firstLeft - secondLeft;
}

// updatedAt tiebreaker — for widgets sharing the exact same (top, left)
// (e.g. multiple collapseWhenHidden widgets dropped on top of each other),
// the most recently positioned widget renders at the bottom of the stack.
// Server's createComponentWithLayout response exposes layout.updatedAt.
const firstUpdatedAt = firstLayout?.updatedAt ? new Date(firstLayout.updatedAt).getTime() : null;
const secondUpdatedAt = secondLayout?.updatedAt ? new Date(secondLayout.updatedAt).getTime() : null;

if (firstUpdatedAt !== null && secondUpdatedAt !== null && firstUpdatedAt !== secondUpdatedAt) {
return firstUpdatedAt - secondUpdatedAt;
}

return firstId.localeCompare(secondId);
});
};
Expand Down Expand Up @@ -660,6 +671,7 @@ export const resolveWidgetMeasuredHeight = ({
isContainer,
visibility,
containerHeight,
calculateMoveableBoxHeightWithId,
}) => {
if (isContainer && (componentType !== 'Listview' || normalizeLayoutContext(contextIndices))) {
return containerHeight;
Expand All @@ -674,8 +686,30 @@ export const resolveWidgetMeasuredHeight = ({
contextIndices
)?.height;

// Fallback when the DOM can't be measured (invisible widget, hidden ancestor
// subtree, or missing element). Prefer the calc-bumped canonical so top-
// aligned input widgets don't get pinned at their raw authored height (40)
// — WidgetWrapper renders them at the bumped height (60) once visible, and
// a temp.height written here becomes the finalHeight on visibility flip.
const fallbackHeight = () => {
if (typeof calculateMoveableBoxHeightWithId === 'function') {
const definition = currentPageComponents?.[componentId];
const stylesDefinition = definition?.component?.definition?.styles;
const calc = calculateMoveableBoxHeightWithId(componentId, currentLayout, stylesDefinition);
if (typeof calc === 'number') return calc;
}
return getCanonicalLayout(componentId, currentLayout, currentPageComponents)?.height ?? 0;
};

// Invisible widget: we can't measure it, so return what WidgetWrapper would
// render it at when visible — calc-bumped canonical. Floor any prior temp at
// this value too: a stale temp written before the bump (or under a previous
// alignment) must not pin the widget below its rendered visible height,
// because temp.height becomes finalHeight on the next visibility flip.
if (!visibility) {
return existingHeight ?? getCanonicalLayout(componentId, currentLayout, currentPageComponents)?.height ?? 0;
const bumped = fallbackHeight();
if (existingHeight != null) return Math.max(existingHeight, bumped);
return bumped;
}

// Hidden ancestor subtree (inactive tab pane, collapsed accordion, closed
Expand All @@ -684,18 +718,13 @@ export const resolveWidgetMeasuredHeight = ({
// poison every subsequent reflow: gridSlice's resolvedHeights treats 0 as
// a valid existing value (0 != null) and replays it for siblings, which
// collapse to height:0 in WidgetWrapper. Fall through to the last known /
// canonical height so the widget keeps its slot until it's actually
// measurable.
// calc-bumped canonical height so the widget keeps its slot until it's
// actually measurable.
if (element && element.offsetParent === null) {
return existingHeight ?? getCanonicalLayout(componentId, currentLayout, currentPageComponents)?.height ?? 0;
return existingHeight ?? fallbackHeight();
}

return (
element?.offsetHeight ??
existingHeight ??
getCanonicalLayout(componentId, currentLayout, currentPageComponents)?.height ??
0
);
return element?.offsetHeight ?? existingHeight ?? fallbackHeight();
};

// Blocker enumeration — returns every widget canonically above `targetId`
Expand Down
11 changes: 9 additions & 2 deletions server/src/modules/apps/services/component.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,19 +270,26 @@ export class ComponentsService implements IComponentsService {
const { id, name, properties, styles, generalStyles, validation, parent, displayPreferences, general } =
componentData;

const layouts: Record<string, { top: number; left: number; width: number; height: number }> = {};
const layouts: Record<
string,
{ top: number; left: number; width: number; height: number; updatedAt: Date | null }
> = {};

layoutData.forEach((layout) => {
if (layout && layout.type) {
const { type, top, left, width, height } = layout;
const { type, top, left, width, height, updatedAt } = layout;

// Note: adjustedLeftValue logic will be handled BEFORE calling this function
// so 'left' here is already the final desired value for the output.
// `updatedAt` is exposed so the frontend can use it as a stack-order
// tiebreaker for widgets sharing the same (top, left) — most recently
// positioned widget renders at the bottom of the stack.
layouts[type] = {
top: top ?? 0,
left: left ?? 0, // Use the already adjusted 'left' value
width: width ?? 0,
height: height ?? 0,
updatedAt: updatedAt ?? null,
};
}
});
Expand Down
Loading