chore: entity tabs replacement (#38989)

## Description
Replacing entity tab bar and components with ADS templates.


Fixes #37647
Fixes #37775

## Automation

/ok-to-test tags="@tag.All"

### 🔍 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/13172995565>
> Commit: f3db2d920ded4aec290af6bc48278a59a04b8db8
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=13172995565&attempt=2"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.All`
> Spec:
> <hr>Thu, 06 Feb 2025 07:56:59 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

## Summary by CodeRabbit

- **New Features**
- Enhanced tab interaction: The add button now supports dynamic
visibility and a loading indicator when new tabs are being created.
- Improved editing: Tab labels now feature refined controls for entering
and exiting edit mode, offering a more flexible user experience.
	- New `EntityTabsHeader` component added to enhance tab management.
- Updated tab structure to utilize `DismissibleTab` for improved
functionality.
- Introduced new props for `DismissibleTabBar` to allow for additional
styling and control over add button visibility.
- New properties added to `DismissibleTab` for greater interactivity and
customization.
- Additional properties introduced to `EditableDismissibleTab` for
enhanced editing capabilities.

- **Refactor**
- Streamlined the tab interface by replacing legacy components with
modern, responsive alternatives, simplifying the overall design and
interaction.
- Enhanced event handling and state management for better performance
and user experience.
- Updated import paths and component structures for clarity and
maintainability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Alex 2025-02-06 11:33:54 +03:00 committed by GitHub
parent 41160a26f6
commit 858ca47d3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 299 additions and 498 deletions

View File

@ -110,7 +110,7 @@ describe("Focus Retention of Inputs", { tags: ["@tag.IDE"] }, function () {
cy.setQueryTimeout(10000);
EditorNavigation.SelectEntityByName("SQL_Query", EntityType.Query);
cy.get(locators._queryName).should("contain.text", "SQL_Query");
cy.get(locators._activeEntityTab).should("contain.text", "SQL_Query");
pluginActionForm.toolbar.toggleSettings();
agHelper.GetElement(dataSources._usePreparedStatement).should("be.focused");
EditorNavigation.SelectEntityByName("S3_Query", EntityType.Query);

View File

@ -53,13 +53,13 @@ describe("Tabs Navigation", { tags: ["@tag.IDE"] }, () => {
agHelper.GetNClick(editorTabSelector("page1_query1"));
agHelper
.GetElement(locators._queryName)
.GetElement(locators._activeEntityTab)
.should("have.text", "Page1_Query1");
agHelper.GetNClick(editorTabSelector("page1_query2"));
agHelper
.GetElement(locators._queryName)
.GetElement(locators._activeEntityTab)
.should("have.text", "Page1_Query2");
});
@ -93,13 +93,13 @@ describe("Tabs Navigation", { tags: ["@tag.IDE"] }, () => {
agHelper.GetNClick(editorTabSelector("page2_query1"));
agHelper
.GetElement(locators._queryName)
.GetElement(locators._activeEntityTab)
.should("have.text", "Page2_Query1");
agHelper.GetNClick(editorTabSelector("page2_query2"));
agHelper
.GetElement(locators._queryName)
.GetElement(locators._activeEntityTab)
.should("have.text", "Page2_Query2");
});
@ -108,13 +108,13 @@ describe("Tabs Navigation", { tags: ["@tag.IDE"] }, () => {
agHelper.GetNClick(editorTabSelector("page1_query1"));
agHelper
.GetElement(locators._queryName)
.GetElement(locators._activeEntityTab)
.should("have.text", "Page1_Query1");
agHelper.GetNClick(editorTabSelector("page1_query2"));
agHelper
.GetElement(locators._queryName)
.GetElement(locators._activeEntityTab)
.should("have.text", "Page1_Query2");
PageLeftPane.switchSegment(PagePaneSegment.JS);

View File

@ -12,7 +12,7 @@ describe(
jsEditor.RenameJSObjectFromContextMenu("ChangedName1");
// Validate the new name of the JS Object
cy.get(jsEditor.listOfJsObjects).eq(0).contains("ChangedName1");
cy.get(jsEditor.listOfJsDismissibleTabs).eq(0).contains("ChangedName1");
// Create second JS file
jsEditor.CreateJSObject("", { prettify: false, toRun: false });
@ -24,7 +24,7 @@ describe(
jsEditor.RenameJSObjectFromContextMenu("ChangedName3");
// Validate the new name of the 3rd JS Objcte
cy.get(jsEditor.listOfJsObjects).eq(2).contains("ChangedName3");
cy.get(jsEditor.listOfJsDismissibleTabs).eq(2).contains("ChangedName3");
});
},
);

View File

@ -446,7 +446,7 @@ describe(
subAction: "Page1",
toastToValidate: "copied to page",
});
agHelper.GetNAssertContains(locators._queryName, "Query1Copy");
agHelper.GetNAssertContains(locators._activeEntityTab, "Query1Copy");
dataSources.runQueryAndVerifyResponseViews({ count: 2 });
PageList.AddNewPage();
EditorNavigation.SelectEntityByName("Page1", EntityType.Page);
@ -456,7 +456,7 @@ describe(
toastToValidate: "moved to page",
});
agHelper.WaitUntilAllToastsDisappear();
agHelper.GetNAssertContains(locators._queryName, "Query1Copy");
agHelper.GetNAssertContains(locators._activeEntityTab, "Query1Copy");
dataSources.runQueryAndVerifyResponseViews({ count: 2 });
agHelper.ActionContextMenuWithInPane({
action: "Delete",

View File

@ -181,9 +181,9 @@ Cypress.Commands.add("createAndFillApi", (url, parameters) => {
dataSources.NavigateToDSCreateNew();
cy.testCreateApiButton();
cy.get("@createNewApi").then((response) => {
cy.get(locator._queryName).should("be.visible");
cy.get(locator._activeEntityTab).should("be.visible");
expect(response.response.body.responseMeta.success).to.eq(true);
cy.get(locator._queryName)
cy.get(locator._activeEntityTab)
.invoke("text")
.then((text) => {
const someText = text;

View File

@ -10,8 +10,8 @@ export class CommonLocators {
_link = ".ads-v2-link";
_btnSpinner = ".ads-v2-spinner";
_sidebar = ".t--sidebar";
_queryName = ".editor-tab.active > .ads-v2-text";
_queryNameTxt = ".editor-tab.active > .ads-v2-text input";
_activeEntityTab = ".editor-tab.active .ads-v2-text";
_activeEntityTabInput = ".editor-tab.active .ads-v2-text input";
_editIcon = ".t--action-name-edit-icon";
_emptyCanvasCta = "[data-testid='canvas-ctas']";
_dsName = ".t--edit-datasource-name span";

View File

@ -270,8 +270,8 @@ export class AggregateHelper {
public RenameQuery(renameVal: string, willFailError?: string) {
this.rename({
nameLocator: this.locator._queryName,
textInputLocator: this.locator._queryNameTxt,
nameLocator: this.locator._activeEntityTab,
textInputLocator: this.locator._activeEntityTabInput,
renameVal,
dblClick: true,
willFailError,
@ -1162,7 +1162,7 @@ export class AggregateHelper {
public GetObjectName() {
//cy.get(this.locator._queryName).invoke("text").then((text) => cy.wrap(text).as("queryName")); or below syntax
return cy.get(this.locator._queryName).invoke("text");
return cy.get(this.locator._activeEntityTab).invoke("text");
}
public GetElementLength(selector: string) {

View File

@ -105,7 +105,7 @@ export class ApiPage {
public settingsTriggerLocator = "[data-testid='t--js-settings-trigger']";
public splitPaneContextMenuTrigger = ".entity-context-menu";
public moreActionsTrigger = "[data-testid='t--more-action-trigger']";
private apiNameInput = ".editor-tab.active > .ads-v2-text input";
private apiNameInput = this.locator._activeEntityTabInput;
public pageList = ".ads-v2-sub-menu > .ads-v2-menu__menu-item";
CreateApi(

View File

@ -36,7 +36,8 @@ export class JSEditor {
public settingsTriggerLocator = "[data-testid='t--js-settings-trigger']";
public contextMenuTriggerLocator = "[data-testid='t--more-action-trigger']";
public runFunctionSelectLocator = "[data-testid='t--js-function-run']";
public listOfJsObjects = "[data-testid='t--tabs-container']>div>span";
public listOfJsDismissibleTabs =
"[data-testid='t--tabs-container'] .editor-tab";
public toolbar = new PluginEditorToolbar(
this.runButtonLocator,
@ -55,8 +56,8 @@ export class JSEditor {
private _onPageLoadSwitchStatus = (functionName: string) =>
`//div[contains(@class, '${functionName}-on-page-load-setting')]//label/input`;
private _jsObjName = ".editor-tab.active > .ads-v2-text";
public _jsObjTxt = ".editor-tab.active > .ads-v2-text input";
private _jsObjName = this.locator._activeEntityTab;
public _jsObjTxt = this.locator._activeEntityTabInput;
public _newJSobj = "span:contains('New JS object')";
private _bindingsClose = ".t--entity-property-close";
public _propertyList = ".binding";

View File

@ -1,7 +1,7 @@
/* eslint-disable no-console */
import type { Meta, StoryObj } from "@storybook/react";
import { DismissibleTab } from ".";
import { DismissibleTab } from "./DismissibleTab";
const meta: Meta<typeof DismissibleTab> = {
title: "ADS/Components/Dismissible Tab",

View File

@ -1,6 +1,6 @@
import styled from "styled-components";
import { Button as ADSButton } from "..";
import { Button as ADSButton } from "../Button";
export const Tab = styled.div`
position: relative;
@ -9,7 +9,8 @@ export const Tab = styled.div`
justify-content: center;
flex-shrink: 0;
gap: var(--ads-v2-spaces-2);
height: 100%;
min-height: 32px;
max-height: 32px;
font-size: 12px;
color: var(--ads-v2-color-fg);
cursor: pointer;
@ -20,8 +21,7 @@ export const Tab = styled.div`
border-right: 1px solid transparent;
border-top: 3px solid transparent;
padding: var(--ads-v2-spaces-3);
padding-top: 6px;
padding: 0 var(--ads-v2-spaces-3) 2px var(--ads-v2-spaces-3);
&.active {
background: var(--ads-v2-colors-control-field-default-bg);

View File

@ -2,7 +2,7 @@ import React, { useCallback } from "react";
import clsx from "classnames";
import { Icon } from "..";
import { Icon } from "../Icon";
import * as Styled from "./DismissibleTab.styles";
import { DATA_TEST_ID } from "./constants";

View File

@ -1,10 +1,18 @@
import type React from "react";
export interface DismissibleTabProps {
/** The content of the tab. */
children: React.ReactNode;
/** Used for custom styling, necessary for styled-components. */
className?: string;
/** Used for passing data-testid. */
dataTestId?: string;
/** Applies active styling. */
isActive?: boolean;
/** Callback when the tab is clicked. */
onClick: () => void;
/** Callback when tab is closed. */
onClose: (e: React.MouseEvent) => void;
/** Callback when tab is double clicked. */
onDoubleClick?: () => void;
}

View File

@ -2,11 +2,9 @@
import React, { useRef, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react";
import {
DismissibleTab,
DismissibleTabBar,
type DismissibleTabBarProps,
} from ".";
import { DismissibleTab } from "./DismissibleTab";
import { DismissibleTabBar } from "./DismissibleTabBar";
import { type DismissibleTabBarProps } from "./DismissibleTabBar.types";
const meta: Meta<typeof DismissibleTabBar> = {
title: "ADS/Components/Dismissible Tab Bar",

View File

@ -1,6 +1,6 @@
import styled, { css } from "styled-components";
import { Button } from "..";
import { Button } from "../Button";
export const animatedLeftBorder = (showLeftBorder: boolean) => css`
transition: border-color 0.5s ease;
@ -16,10 +16,11 @@ export const Root = styled.div<{
}>`
display: flex;
align-items: center;
overflow: hidden;
overflow-x: hidden;
white-space: nowrap;
position: relative;
height: 32px;
max-height: 32px;
min-height: 32px;
${({ $showLeftBorder }) => animatedLeftBorder($showLeftBorder ?? false)};
`;
@ -28,7 +29,6 @@ export const TabsContainer = styled.div`
display: flex;
flex: 1 0 auto;
align-items: center;
gap: var(--ads-v2-spaces-2);
height: 100%;
`;
@ -41,11 +41,11 @@ export const PlusButtonContainer = styled.div<{ $showLeftBorder?: boolean }>`
position: sticky;
right: 0;
border: none;
min-width: 32px;
height: 100%;
min-width: 28px;
min-height: 32px;
display: flex;
align-items: center;
justify-content: center;
justify-content: end;
${({ $showLeftBorder }) => animatedLeftBorder($showLeftBorder ?? false)};
`;

View File

@ -1,6 +1,8 @@
import React, { useEffect, useRef, useState } from "react";
import { noop } from "lodash";
import clsx from "clsx";
import { Spinner } from "../Spinner";
import { ScrollArea } from "../ScrollArea";
import * as Styled from "./DismissibleTabBar.styles";
@ -14,13 +16,15 @@ export const SCROLL_AREA_OPTIONS = {
} as const;
const SCROLL_AREA_STYLE = {
height: 34,
top: 1,
height: 32,
};
export const DismissibleTabBar = ({
children,
className,
disableAdd = false,
hideAdd = false,
isAddingNewTab,
onTabAdd,
}: DismissibleTabBarProps) => {
const [isLeftIntersecting, setIsLeftIntersecting] = useState(false);
@ -85,7 +89,10 @@ export const DismissibleTabBar = ({
);
return (
<Styled.Root $showLeftBorder={isLeftIntersecting}>
<Styled.Root
$showLeftBorder={isLeftIntersecting}
className={clsx(className)}
>
<ScrollArea
data-testid="t--editor-tabs"
options={SCROLL_AREA_OPTIONS}
@ -99,16 +106,23 @@ export const DismissibleTabBar = ({
<Styled.StickySentinel ref={sentinelRightRef} />
</Styled.TabsContainer>
</ScrollArea>
<Styled.PlusButtonContainer $showLeftBorder={isRightIntersecting}>
<Styled.PlusButton
isDisabled={disableAdd}
isIconButton
kind="tertiary"
onClick={handleAdd}
startIcon="add-line"
title="Add new tab"
/>
</Styled.PlusButtonContainer>
{!hideAdd && (
<Styled.PlusButtonContainer $showLeftBorder={isRightIntersecting}>
{isAddingNewTab ? (
<Spinner size="md" />
) : (
<Styled.PlusButton
data-testid="t--ide-tabs-add-button"
isDisabled={disableAdd}
isIconButton
kind="tertiary"
onClick={handleAdd}
startIcon="add-line"
title="Add new tab"
/>
)}
</Styled.PlusButtonContainer>
)}
</Styled.Root>
);
};

View File

@ -2,9 +2,19 @@ import type React from "react";
import type { DismissibleTabProps } from "./DismissibleTab.types";
export interface DismissibleTabBarProps {
/** The content of the tab bar. */
children:
| React.ReactElement<DismissibleTabProps>
| React.ReactElement<DismissibleTabProps>[];
onTabAdd: () => void;
| React.ReactElement<DismissibleTabProps>[]
| React.ReactNode;
/** Used for custom styling, necessary for styled-components. */
className?: string;
/** Button is visible, but disabled & not clickable. */
disableAdd?: boolean;
/** Hides add button completely. */
hideAdd?: boolean;
/** Will display a loader in place of add button. */
isAddingNewTab?: boolean;
/** Callback tab is added. */
onTabAdd: () => void;
}

View File

@ -2,8 +2,8 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { EditableDismissibleTab } from ".";
import { Icon } from "../..";
import { EditableDismissibleTab } from "./EditableDismissibleTab";
import { Icon } from "../../Icon";
const meta: Meta<typeof EditableDismissibleTab> = {
title: "ADS/Templates/Editable Dismissible Tab",
@ -24,7 +24,7 @@ export const Basic: Story = {
dataTestId: "t--dismissible-tab",
icon: JSIcon(),
name: "Hello",
canEdit: true,
isEditable: true,
onNameSave: console.log,
validateName: (name: string) =>

View File

@ -2,33 +2,38 @@ import React from "react";
import { noop } from "lodash";
import { useBoolean } from "usehooks-ts";
import { DismissibleTab } from "../..";
import { EditableEntityName } from "..";
import { DismissibleTab } from "../../DismissibleTab";
import { EditableEntityName } from "../EditableEntityName";
import type { EditableDismissibleTabProps } from "./EditableDismissibleTab.types";
export const EditableDismissibleTab = (props: EditableDismissibleTabProps) => {
const {
canEdit,
dataTestId,
icon,
isActive,
isEditable = true,
isEditing: propIsEditing,
isLoading,
name,
onClick,
onClose,
onEnterEditMode: propOnEnterEditMode,
onExitEditMode: propOnExitEditMode,
onNameSave,
validateName,
} = props;
const {
setFalse: exitEditMode,
setTrue: enterEditMode,
value: isEditing,
setFalse: localOnExitEditMode,
setTrue: localOnEnterEditMode,
value: localIsEditing,
} = useBoolean(false);
const handleDoubleClick = isEditable ? enterEditMode : noop;
const isEditing = propIsEditing ?? localIsEditing;
const handleEnterEditMode = propOnEnterEditMode ?? localOnEnterEditMode;
const handleExitEditMode = propOnExitEditMode ?? localOnExitEditMode;
const handleDoubleClick = isEditable ? handleEnterEditMode : noop;
return (
<DismissibleTab
@ -39,12 +44,12 @@ export const EditableDismissibleTab = (props: EditableDismissibleTabProps) => {
onDoubleClick={handleDoubleClick}
>
<EditableEntityName
canEdit={canEdit}
canEdit={isEditable}
icon={icon}
isEditing={isEditing}
isLoading={isLoading}
name={name}
onExitEditing={exitEditMode}
onExitEditing={handleExitEditMode}
onNameSave={onNameSave}
validateName={validateName}
/>

View File

@ -1,15 +1,31 @@
import type React from "react";
export interface EditableDismissibleTabProps {
/** Used for passing data-testid. */
dataTestId?: string;
/** Icon component to be displayed in the tab. */
icon: React.ReactNode;
/** Passed to tab component, applies active state/styles. */
isActive: boolean;
/** Controls if tab can be edited. */
isEditable?: boolean;
/** Can be passed to control editing state externally. */
isEditing?: boolean;
/** Shows loading indicator in place of an icon. */
isLoading: boolean;
/** The name of the tab. */
name: string;
/** Callback when the tab is clicked. */
onClick: () => void;
/** Callback when tab is closed. */
onClose: () => void;
/** Callback when tab enters edit mode. */
onEnterEditMode?: () => void;
/** Callback when tab exits edit mode. */
onExitEditMode?: () => void;
/** Callback when tab name is saved. */
onNameSave: (name: string) => void;
/** Function to validate the name. */
validateName: (name: string) => string | null;
canEdit: boolean;
}

View File

@ -45,7 +45,7 @@ export const EditableEntityName = (props: EditableEntityNameProps) => {
}
return icon;
}, [isLoading, icon]);
}, [isLoading, icon, size]);
const inputProps = useMemo(
() => ({

View File

@ -1,15 +1,26 @@
import type React from "react";
export interface EditableEntityNameProps {
icon: React.ReactNode;
inputTestId?: string;
isEditing: boolean;
isLoading?: boolean;
name: string;
onExitEditing: () => void;
onNameSave: (name: string) => void;
validateName: (name: string) => string | null;
/** Controls if name can be edited. */
canEdit: boolean;
/** Icon component. */
icon: React.ReactNode;
/** Used for passing data-testid to input. */
inputTestId?: string;
/** Toggles editing mode. */
isEditing: boolean;
/** Controls if name is fixed width. */
isFixedWidth?: boolean;
/** Shows loading indicator in place of an icon. */
isLoading?: boolean;
/** The name of the entity. */
name: string;
/** Size of the icon & input. */
size?: "small" | "medium";
/** Callback when editing is exited. */
onExitEditing: () => void;
/** Callback when name is saved. */
onNameSave: (name: string) => void;
/** Function to validate the name. */
validateName: (name: string) => string | null;
}

View File

@ -3,9 +3,14 @@ import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { useBoolean } from "usehooks-ts";
import { DismissibleTab, DismissibleTabBar } from "../..";
import { DismissibleTab } from "../../DismissibleTab";
import { EntityTabsHeader, EntityListButton, ToggleScreenModeButton } from ".";
import {
EntityTabBar,
EntityTabsHeader,
EntityListButton,
ToggleScreenModeButton,
} from "./EntityTabsHeader";
const meta: Meta<typeof EntityTabsHeader> = {
title: "ADS/Templates/Entity Tabs Header",
@ -25,7 +30,7 @@ const Template = ({ width }: Args) => {
<div style={{ width }}>
<EntityTabsHeader>
<EntityListButton onClick={console.log} />
<DismissibleTabBar onTabAdd={console.log}>
<EntityTabBar onTabAdd={console.log}>
<DismissibleTab onClick={console.log} onClose={console.log}>
One
</DismissibleTab>
@ -41,7 +46,7 @@ const Template = ({ width }: Args) => {
<DismissibleTab onClick={console.log} onClose={console.log}>
Five
</DismissibleTab>
</DismissibleTabBar>
</EntityTabBar>
<ToggleScreenModeButton
isInSplitScreenMode={isInSplitScreenMode}
onClick={toggle}

View File

@ -1,5 +1,6 @@
import styled from "styled-components";
import { Button } from "../..";
import { Button } from "../../Button";
import { DismissibleTabBar } from "../../DismissibleTab";
export const Root = styled.div`
display: flex;
@ -11,7 +12,6 @@ export const Root = styled.div`
min-height: 32px;
padding: 0 var(--ads-v2-spaces-2);
width: 100%;
overflow: hidden;
`;
export const IconButton = styled(Button)`
@ -20,3 +20,7 @@ export const IconButton = styled(Button)`
min-width: 24px;
}
`;
export const TabBar = styled(DismissibleTabBar)`
margin-bottom: -1px;
`;

View File

@ -8,7 +8,8 @@ import type {
ToggleScreenModeButtonProps,
} from "./EntityTabsHeader.types";
import { ToggleButton } from "../..";
import { ToggleButton } from "../../ToggleButton";
import { type DismissibleTabBarProps } from "../../DismissibleTab/DismissibleTabBar.types";
export const EntityListButton = (props: EntityListButtonProps) => {
return <ToggleButton {...props} icon="hamburger" size="md" />;
@ -28,6 +29,10 @@ export const ToggleScreenModeButton = (props: ToggleScreenModeButtonProps) => {
);
};
export const EntityTabBar = (props: DismissibleTabBarProps) => {
return <Styled.TabBar {...props} />;
};
export function EntityTabsHeader({ children }: EntityTabsHeaderProps) {
return <Styled.Root>{children}</Styled.Root>;
}

View File

@ -1,9 +1,8 @@
import type { ReactElement, ReactNode } from "react";
import type {
ToggleButtonProps,
ButtonProps,
DismissibleTabBarProps,
} from "../..";
import type { DismissibleTabBarProps } from "../../DismissibleTab";
import type { ToggleButtonProps } from "../../ToggleButton";
import type { ButtonProps } from "../../Button";
export type EntityListButtonProps = Omit<ToggleButtonProps, "icon" | "size">;
@ -17,11 +16,15 @@ export type ToggleScreenModeButtonProps = Omit<
type DismissibleTabBarType = ReactElement<DismissibleTabBarProps>;
type EntityListButtonType = ReactElement<EntityListButtonProps>;
/** Required for optional/conditional children. */
type OptionalChild<T> = T | null | false;
export interface EntityTabsHeaderProps {
children:
| DismissibleTabBarType
| [DismissibleTabBarType]
| [EntityListButtonType, DismissibleTabBarType]
| [DismissibleTabBarType, ReactNode]
| [EntityListButtonType, DismissibleTabBarType, ReactNode];
| [
OptionalChild<EntityListButtonType>,
DismissibleTabBarType,
OptionalChild<ReactNode>,
];
}

View File

@ -1,5 +1,6 @@
export {
EntityTabsHeader,
EntityListButton,
EntityTabBar,
ToggleScreenModeButton,
} from "./EntityTabsHeader";

View File

@ -3,3 +3,4 @@ export * from "./EntityExplorer";
export * from "./Sidebar";
export * from "./EditableEntityName";
export * from "./EditableDismissibleTab";
export * from "./EntityTabsHeader";

View File

@ -1,64 +0,0 @@
/* eslint-disable react-perf/jsx-no-new-object-as-prop */
/* eslint-disable react-perf/jsx-no-jsx-as-prop */
import "@testing-library/jest-dom";
import React from "react";
import { render, fireEvent } from "@testing-library/react";
import { Icon } from "@appsmith/ads";
import { FileTab } from "./FileTab";
import { DATA_TEST_ID } from "./constants";
describe("FileTab", () => {
const mockOnClick = jest.fn();
const mockOnClose = jest.fn();
const mockOnDoubleClick = jest.fn();
const TITLE = "test_file";
const TabIcon = () => <Icon name="js" />;
const setup = () => {
const utils = render(
<FileTab
isActive
onClick={mockOnClick}
onClose={mockOnClose}
onDoubleClick={mockOnDoubleClick}
title={TITLE}
>
<TabIcon />
{TITLE}
</FileTab>,
);
const tabElement = utils.getByText(TITLE);
return {
tabElement,
...utils,
};
};
test("renders component", () => {
const { getByTestId, tabElement } = setup();
fireEvent.click(tabElement);
expect(mockOnClick).toHaveBeenCalled();
const closeButton = getByTestId(DATA_TEST_ID.CLOSE_BUTTON);
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
test("double click event is fired", () => {
const { getByTestId, tabElement } = setup();
fireEvent.doubleClick(tabElement);
expect(mockOnDoubleClick).toHaveBeenCalled();
const closeButton = getByTestId(DATA_TEST_ID.CLOSE_BUTTON);
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
});

View File

@ -1,46 +0,0 @@
import React from "react";
import clsx from "classnames";
import { Icon } from "@appsmith/ads";
import { sanitizeString } from "utils/URLUtils";
import * as Styled from "./styles";
import { DATA_TEST_ID } from "./constants";
export interface FileTabProps {
isActive: boolean;
title: string;
onClick: () => void;
onClose: (e: React.MouseEvent) => void;
children: React.ReactNode;
onDoubleClick?: () => void;
}
export const FileTab = ({
children,
isActive,
onClick,
onClose,
onDoubleClick,
title,
}: FileTabProps) => {
return (
<Styled.Tab
className={clsx("editor-tab", isActive && "active")}
data-testid={`t--ide-tab-${sanitizeString(title)}`}
onClick={onClick}
onDoubleClick={onDoubleClick}
>
{children}
<Styled.CloseButton
aria-label="Close tab"
className="tab-close"
data-testid={DATA_TEST_ID.CLOSE_BUTTON}
onClick={onClose}
>
<Icon name="close-line" />
</Styled.CloseButton>
</Styled.Tab>
);
};

View File

@ -1,5 +0,0 @@
export const DATA_TEST_ID = {
INPUT: "t--ide-tab-editable-input",
CLOSE_BUTTON: "t--tab-close-btn",
SPINNER: "t--ide-tab-spinner",
};

View File

@ -1,2 +0,0 @@
export { FileTab } from "./FileTab";
export type { FileTabProps } from "./FileTab";

View File

@ -1,73 +0,0 @@
import styled from "styled-components";
import { Text as ADSText } from "@appsmith/ads";
export const Tab = styled.div`
display: flex;
height: 100%;
position: relative;
font-size: 12px;
color: var(--ads-v2-colors-text-default);
cursor: pointer;
gap: var(--ads-v2-spaces-2);
border-top-left-radius: var(--ads-v2-border-radius);
border-top-right-radius: var(--ads-v2-border-radius);
align-items: center;
justify-content: center;
padding: var(--ads-v2-spaces-3);
padding-top: 6px; // to accommodate border and make icons align correctly
border-left: 1px solid transparent;
border-right: 1px solid transparent;
border-top: 3px solid transparent;
flex-shrink: 0;
&.active {
background: var(--ads-v2-colors-control-field-default-bg);
border-top-color: var(--ads-v2-color-bg-brand);
border-left-color: var(--ads-v2-color-border-muted);
border-right-color: var(--ads-v2-color-border-muted);
span {
font-weight: var(--ads-v2-font-weight-bold);
}
}
& > .tab-close {
position: relative;
right: -2px;
visibility: hidden;
}
&:hover > .tab-close,
&.active > .tab-close {
visibility: visible;
}
`;
export const IconContainer = styled.div`
height: 12px;
width: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 12px;
}
`;
export const Text = styled(ADSText)`
min-width: 3ch;
padding: 0 var(--ads-v2-spaces-1);
`;
export const CloseButton = styled.button`
border-radius: var(--ads-v2-border-radius);
cursor: pointer;
padding: var(--ads-v2-spaces-1);
&:hover {
background: var(--ads-v2-colors-action-tertiary-surface-hover-bg);
}
`;

View File

@ -1,38 +0,0 @@
import React from "react";
import { Flex, Spinner, Button } from "@appsmith/ads";
import { useCurrentEditorState, useIDETabClickHandlers } from "../hooks";
import { useIsJSAddLoading } from "ee/pages/Editor/IDE/EditorPane/JS/hooks";
import { EditorEntityTabState } from "ee/entities/IDE/constants";
const AddButton = () => {
const { addClickHandler } = useIDETabClickHandlers();
const isJSLoading = useIsJSAddLoading();
const { segmentMode } = useCurrentEditorState();
if (segmentMode === EditorEntityTabState.Add) {
return null;
}
if (isJSLoading) {
return (
<Flex px="spaces-2">
<Spinner size="md" />
</Flex>
);
}
return (
<Button
className="!min-w-[24px]"
data-testid="t--ide-tabs-add-button"
id="tabs-add-toggle"
isIconButton
kind="tertiary"
onClick={addClickHandler}
size="sm"
startIcon="add-line"
/>
);
};
export { AddButton };

View File

@ -1,12 +1,14 @@
import React from "react";
import { useEventCallback } from "usehooks-ts";
import { DismissibleTab, Text } from "@appsmith/ads";
import { FileTab } from "IDE/Components/FileTab";
import { useCurrentEditorState } from "../hooks";
import {
EditorEntityTab,
EditorEntityTabState,
} from "ee/entities/IDE/constants";
import { Text } from "@appsmith/ads";
import { useCurrentEditorState } from "../hooks";
const AddTab = ({
isListActive,
@ -19,24 +21,26 @@ const AddTab = ({
}) => {
const { segment, segmentMode } = useCurrentEditorState();
if (segmentMode !== EditorEntityTabState.Add) return null;
const onCloseClick = (e: React.MouseEvent) => {
const onCloseClick = useEventCallback((e: React.MouseEvent) => {
e.stopPropagation();
onClose();
};
});
const content = `New ${segment === EditorEntityTab.JS ? "JS" : "Query"}`;
if (segmentMode !== EditorEntityTabState.Add) return null;
const segmentName = segment === EditorEntityTab.JS ? "JS" : "Query";
const content = `New ${segmentName}`;
const dataTestId = `t--ide-tab-new_${segmentName.toLowerCase()}`;
return (
<FileTab
<DismissibleTab
dataTestId={dataTestId}
isActive={segmentMode === EditorEntityTabState.Add && !isListActive}
onClick={newTabClickCallback}
onClose={(e) => onCloseClick(e)}
title={content}
onClose={onCloseClick}
>
<Text kind="body-s">{content}</Text>
</FileTab>
</DismissibleTab>
);
};

View File

@ -1,24 +0,0 @@
import type { ReactNode } from "react";
import React from "react";
import { Flex } from "@appsmith/ads";
import { EDITOR_TABS_HEIGHT } from "../EditorPane/constants";
const Container = (props: { children: ReactNode }) => {
return (
<Flex
alignItems="center"
backgroundColor="#FFFFFF"
borderBottom="1px solid var(--ads-v2-color-border-muted)"
gap="spaces-2"
id="ide-tabs-container"
maxHeight={EDITOR_TABS_HEIGHT}
minHeight={EDITOR_TABS_HEIGHT}
px="spaces-2"
width="100%"
>
{props.children}
</Flex>
);
};
export default Container;

View File

@ -1,21 +1,23 @@
import React, { useCallback } from "react";
import { FileTab } from "IDE/Components/FileTab";
import { type EntityItem } from "ee/entities/IDE/constants";
import { useCurrentEditorState } from "../hooks";
import { useDispatch, useSelector } from "react-redux";
import { useEventCallback } from "usehooks-ts";
import { getIsSavingEntityName } from "ee/selectors/entitiesSelector";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { EditableDismissibleTab } from "@appsmith/ads";
import { type EntityItem } from "ee/entities/IDE/constants";
import { FEATURE_FLAG } from "ee/entities/FeatureFlag";
import { getIsSavingEntityName } from "ee/selectors/entitiesSelector";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { sanitizeString } from "utils/URLUtils";
import {
getEditableTabPermissions,
saveEntityName,
} from "ee/entities/IDE/utils";
import { noop } from "lodash";
import { EditableName, useIsRenaming } from "IDE";
import { IconContainer } from "IDE/Components/FileTab/styles";
import { useIsRenaming, useValidateEntityName } from "IDE";
import { useCurrentEditorState } from "../hooks";
interface EditableTabProps {
id: string;
@ -30,6 +32,7 @@ interface EditableTabProps {
export function EditableTab(props: EditableTabProps) {
const { entity, icon, id, isActive, onClick, onClose, title } = props;
const { segment } = useCurrentEditorState();
const dispatch = useDispatch();
const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled);
const isChangePermitted = getEditableTabPermissions({
@ -43,39 +46,36 @@ export function EditableTab(props: EditableTabProps) {
getIsSavingEntityName(state, { id, segment, entity }),
);
const handleClose = useEventCallback((e: React.MouseEvent) => {
e.stopPropagation();
onClose(id);
const validateName = useValidateEntityName({
entityName: title,
});
const handleDoubleClick = isChangePermitted ? enterEditMode : noop;
const dispatch = useDispatch();
const handleClose = useEventCallback(() => {
onClose(id);
});
const handleNameSave = useCallback(
(name: string) => {
dispatch(saveEntityName({ params: { id, name }, segment, entity }));
exitEditMode();
},
[dispatch, entity, exitEditMode, id, segment],
[dispatch, entity, id, segment],
);
return (
<FileTab
<EditableDismissibleTab
dataTestId={`t--ide-tab-${sanitizeString(title)}`}
icon={icon}
isActive={isActive}
isEditable={isChangePermitted}
isEditing={isEditing}
isLoading={isLoading}
name={title}
onClick={onClick}
onClose={handleClose}
onDoubleClick={handleDoubleClick}
title={title}
>
<EditableName
exitEditing={exitEditMode}
icon={<IconContainer>{icon}</IconContainer>}
isEditing={isEditing}
isLoading={isLoading}
name={title}
onNameSave={handleNameSave}
/>
</FileTab>
onEnterEditMode={enterEditMode}
onExitEditMode={exitEditMode}
onNameSave={handleNameSave}
validateName={validateName}
/>
);
}

View File

@ -37,14 +37,13 @@ describe("EditorTabs render checks", () => {
it("Renders correctly in split view", () => {
const state = getIDETestState({ ideView: EditorViewMode.SplitScreen });
const { getByTestId, queryByTestId } = renderComponent(
const { getByTestId, queryAllByRole, queryByTestId } = renderComponent(
`/app/applicationSlug/pageSlug-${page.basePageId}/edit/queries`,
state,
);
// check tabs is empty
const tabsContainer = getByTestId("t--tabs-container");
expect(tabsContainer.firstChild).toBeNull();
// check that tabs are empty
expect(queryAllByRole("tab").length).toBe(0);
//check add button is not present
expect(queryByTestId("t--ide-tabs-add-button")).toBeNull();
@ -58,7 +57,7 @@ describe("EditorTabs render checks", () => {
it("Renders correctly in fullscreen view", () => {
const state = getIDETestState({ ideView: EditorViewMode.FullScreen });
const { getByTestId, queryByTestId } = renderComponent(
const { getByTestId, queryAllByRole, queryByTestId } = renderComponent(
`/app/applicationSlug/pageSlug-${page.basePageId}/edit/queries`,
state,
);
@ -66,10 +65,8 @@ describe("EditorTabs render checks", () => {
// check toggle
expect(queryByTestId("t--list-toggle")).toBeNull();
// check tabs is empty
const tabsContainer = getByTestId("t--tabs-container");
expect(tabsContainer.firstChild).toBeNull();
// check that tabs are empty
expect(queryAllByRole("tab").length).toBe(0);
//check add button is not present
expect(queryByTestId("t--ide-tabs-add-button")).toBeNull();

View File

@ -37,10 +37,3 @@ export const TabSelectors: Record<
itemUrlSelector: () => "",
},
};
export const SCROLL_AREA_OPTIONS = {
overflow: {
x: "scroll",
y: "hidden",
},
} as const;

View File

@ -1,6 +1,14 @@
import React, { useCallback, useEffect } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import { Flex, ScrollArea, ToggleButton } from "@appsmith/ads";
import { useEventCallback } from "usehooks-ts";
import { useLocation } from "react-router";
import {
EntityTabsHeader,
EntityListButton,
EntityTabBar,
} from "@appsmith/ads";
import { getIDEViewMode, getListViewActiveState } from "selectors/ideSelectors";
import type { EntityItem } from "ee/entities/IDE/constants";
import {
@ -8,27 +16,25 @@ import {
EditorEntityTabState,
EditorViewMode,
} from "ee/entities/IDE/constants";
import { useIsJSAddLoading } from "ee/pages/Editor/IDE/EditorPane/JS/hooks";
import { identifyEntityFromPath } from "navigation/FocusEntity";
import { setListViewActiveState } from "actions/ideActions";
import Container from "./Container";
import {
useCurrentEditorState,
useIDETabClickHandlers,
useShowSideBySideNudge,
} from "../hooks";
import { SCROLL_AREA_OPTIONS, TabSelectors } from "./constants";
import { AddButton } from "./AddButton";
import { useLocation } from "react-router";
import { identifyEntityFromPath } from "navigation/FocusEntity";
import { List } from "./List";
import { ScreenModeToggle } from "./ScreenModeToggle";
import { AddTab } from "./AddTab";
import { setListViewActiveState } from "actions/ideActions";
import { useEventCallback } from "usehooks-ts";
import { EditableTab } from "./EditableTab";
import { TabSelectors } from "./constants";
import { AddTab } from "./AddTab";
const EditorTabs = () => {
const location = useLocation();
const dispatch = useDispatch();
const ideViewMode = useSelector(getIDEViewMode);
const { segment, segmentMode } = useCurrentEditorState();
const { closeClickHandler, tabClickHandler } = useIDETabClickHandlers();
@ -37,46 +43,29 @@ const EditorTabs = () => {
const files = useSelector(tabsConfig.tabsSelector, shallowEqual);
const isListViewActive = useSelector(getListViewActiveState);
const [showNudge, dismissNudge] = useShowSideBySideNudge();
const { addClickHandler } = useIDETabClickHandlers();
const isJSLoading = useIsJSAddLoading();
const hideAdd = segmentMode === EditorEntityTabState.Add || !files.length;
const location = useLocation();
const dispatch = useDispatch();
const currentEntity = identifyEntityFromPath(location.pathname);
const showEntityListButton =
ideViewMode === EditorViewMode.SplitScreen && files.length > 0;
// Turn off list view while changing segment, files
useEffect(() => {
dispatch(setListViewActiveState(false));
}, [currentEntity.id, currentEntity.entity, files, segmentMode, dispatch]);
useEffect(
function turnOffListViewWhileChangingSegmentFiles() {
dispatch(setListViewActiveState(false));
},
[currentEntity.id, currentEntity.entity, files, segmentMode, dispatch],
);
// Show list view if all tabs is closed
useEffect(() => {
if (files.length === 0 && segmentMode !== EditorEntityTabState.Add) {
dispatch(setListViewActiveState(true));
}
}, [files, segmentMode, currentEntity.entity, dispatch]);
// scroll to the active tab
useEffect(() => {
const activeTab = document.querySelector(".editor-tab.active");
if (activeTab) {
activeTab.scrollIntoView({
inline: "nearest",
});
}
}, [files, segmentMode]);
// show border if add button is sticky
useEffect(() => {
const ele = document.querySelector<HTMLElement>(
'[data-testid="t--editor-tabs"] > [data-overlayscrollbars-viewport]',
);
if (ele && ele.scrollWidth > ele.clientWidth) {
ele.style.borderRight = "1px solid var(--ads-v2-color-border)";
} else if (ele) {
ele.style.borderRight = "unset";
}
}, [files]);
useEffect(
function showListViewIfAllTabsAreClosed() {
if (files.length === 0 && segmentMode !== EditorEntityTabState.Add) {
dispatch(setListViewActiveState(true));
}
},
[files, segmentMode, currentEntity.entity, dispatch],
);
const handleHamburgerClick = useEventCallback(() => {
if (files.length === 0 && segmentMode !== EditorEntityTabState.Add) return;
@ -101,61 +90,49 @@ const EditorTabs = () => {
return (
<>
<Container>
{ideViewMode === EditorViewMode.SplitScreen && files.length > 0 ? (
<ToggleButton
<EntityTabsHeader>
{showEntityListButton && (
<EntityListButton
data-testid="t--list-toggle"
icon="hamburger"
isSelected={isListViewActive}
onClick={handleHamburgerClick}
size="md"
/>
) : null}
<ScrollArea
className="h-[32px] top-[0.5px]"
data-testid="t--editor-tabs"
options={SCROLL_AREA_OPTIONS}
size="sm"
)}
<EntityTabBar
hideAdd={hideAdd}
isAddingNewTab={isJSLoading}
onTabAdd={addClickHandler}
>
<Flex
className="items-center"
data-testid="t--tabs-container"
gap="spaces-2"
height="100%"
>
{files.map((tab) => {
const entity = entities.find((entity) => entity.key === tab.key);
{files.map((tab) => {
const entity = entities.find((entity) => entity.key === tab.key);
return (
<EditableTab
entity={entity}
icon={tab.icon}
id={tab.key}
isActive={
currentEntity.id === tab.key &&
segmentMode !== EditorEntityTabState.Add &&
!isListViewActive
}
key={tab.key}
onClick={handleTabClick(tab)}
onClose={closeClickHandler}
title={tab.title}
/>
);
})}
<AddTab
isListActive={isListViewActive}
newTabClickCallback={handleNewTabClick}
onClose={closeClickHandler}
/>
</Flex>
</ScrollArea>
{files.length > 0 ? <AddButton /> : null}
{/* Switch screen mode button */}
return (
<EditableTab
entity={entity}
icon={tab.icon}
id={tab.key}
isActive={
currentEntity.id === tab.key &&
segmentMode !== EditorEntityTabState.Add &&
!isListViewActive
}
key={tab.key}
onClick={handleTabClick(tab)}
onClose={closeClickHandler}
title={tab.title}
/>
);
})}
<AddTab
isListActive={isListViewActive}
newTabClickCallback={handleNewTabClick}
onClose={closeClickHandler}
/>
</EntityTabBar>
<ScreenModeToggle dismissNudge={dismissNudge} showNudge={showNudge} />
</Container>
</EntityTabsHeader>
{/* Overflow list */}
{isListViewActive && ideViewMode === EditorViewMode.SplitScreen && (
<List />
)}