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); cy.setQueryTimeout(10000);
EditorNavigation.SelectEntityByName("SQL_Query", EntityType.Query); 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(); pluginActionForm.toolbar.toggleSettings();
agHelper.GetElement(dataSources._usePreparedStatement).should("be.focused"); agHelper.GetElement(dataSources._usePreparedStatement).should("be.focused");
EditorNavigation.SelectEntityByName("S3_Query", EntityType.Query); EditorNavigation.SelectEntityByName("S3_Query", EntityType.Query);

View File

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

View File

@ -12,7 +12,7 @@ describe(
jsEditor.RenameJSObjectFromContextMenu("ChangedName1"); jsEditor.RenameJSObjectFromContextMenu("ChangedName1");
// Validate the new name of the JS Object // 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 // Create second JS file
jsEditor.CreateJSObject("", { prettify: false, toRun: false }); jsEditor.CreateJSObject("", { prettify: false, toRun: false });
@ -24,7 +24,7 @@ describe(
jsEditor.RenameJSObjectFromContextMenu("ChangedName3"); jsEditor.RenameJSObjectFromContextMenu("ChangedName3");
// Validate the new name of the 3rd JS Objcte // 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", subAction: "Page1",
toastToValidate: "copied to page", toastToValidate: "copied to page",
}); });
agHelper.GetNAssertContains(locators._queryName, "Query1Copy"); agHelper.GetNAssertContains(locators._activeEntityTab, "Query1Copy");
dataSources.runQueryAndVerifyResponseViews({ count: 2 }); dataSources.runQueryAndVerifyResponseViews({ count: 2 });
PageList.AddNewPage(); PageList.AddNewPage();
EditorNavigation.SelectEntityByName("Page1", EntityType.Page); EditorNavigation.SelectEntityByName("Page1", EntityType.Page);
@ -456,7 +456,7 @@ describe(
toastToValidate: "moved to page", toastToValidate: "moved to page",
}); });
agHelper.WaitUntilAllToastsDisappear(); agHelper.WaitUntilAllToastsDisappear();
agHelper.GetNAssertContains(locators._queryName, "Query1Copy"); agHelper.GetNAssertContains(locators._activeEntityTab, "Query1Copy");
dataSources.runQueryAndVerifyResponseViews({ count: 2 }); dataSources.runQueryAndVerifyResponseViews({ count: 2 });
agHelper.ActionContextMenuWithInPane({ agHelper.ActionContextMenuWithInPane({
action: "Delete", action: "Delete",

View File

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

View File

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

View File

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

View File

@ -105,7 +105,7 @@ export class ApiPage {
public settingsTriggerLocator = "[data-testid='t--js-settings-trigger']"; public settingsTriggerLocator = "[data-testid='t--js-settings-trigger']";
public splitPaneContextMenuTrigger = ".entity-context-menu"; public splitPaneContextMenuTrigger = ".entity-context-menu";
public moreActionsTrigger = "[data-testid='t--more-action-trigger']"; 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"; public pageList = ".ads-v2-sub-menu > .ads-v2-menu__menu-item";
CreateApi( CreateApi(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,9 +2,19 @@ import type React from "react";
import type { DismissibleTabProps } from "./DismissibleTab.types"; import type { DismissibleTabProps } from "./DismissibleTab.types";
export interface DismissibleTabBarProps { export interface DismissibleTabBarProps {
/** The content of the tab bar. */
children: children:
| React.ReactElement<DismissibleTabProps> | React.ReactElement<DismissibleTabProps>
| React.ReactElement<DismissibleTabProps>[]; | React.ReactElement<DismissibleTabProps>[]
onTabAdd: () => void; | React.ReactNode;
/** Used for custom styling, necessary for styled-components. */
className?: string;
/** Button is visible, but disabled & not clickable. */
disableAdd?: boolean; 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 React from "react";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { EditableDismissibleTab } from "."; import { EditableDismissibleTab } from "./EditableDismissibleTab";
import { Icon } from "../.."; import { Icon } from "../../Icon";
const meta: Meta<typeof EditableDismissibleTab> = { const meta: Meta<typeof EditableDismissibleTab> = {
title: "ADS/Templates/Editable Dismissible Tab", title: "ADS/Templates/Editable Dismissible Tab",
@ -24,7 +24,7 @@ export const Basic: Story = {
dataTestId: "t--dismissible-tab", dataTestId: "t--dismissible-tab",
icon: JSIcon(), icon: JSIcon(),
name: "Hello", name: "Hello",
canEdit: true, isEditable: true,
onNameSave: console.log, onNameSave: console.log,
validateName: (name: string) => validateName: (name: string) =>

View File

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

View File

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

View File

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

View File

@ -1,15 +1,26 @@
import type React from "react"; import type React from "react";
export interface EditableEntityNameProps { export interface EditableEntityNameProps {
icon: React.ReactNode; /** Controls if name can be edited. */
inputTestId?: string;
isEditing: boolean;
isLoading?: boolean;
name: string;
onExitEditing: () => void;
onNameSave: (name: string) => void;
validateName: (name: string) => string | null;
canEdit: boolean; 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; 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"; 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 type { Meta, StoryObj } from "@storybook/react";
import { useBoolean } from "usehooks-ts"; 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> = { const meta: Meta<typeof EntityTabsHeader> = {
title: "ADS/Templates/Entity Tabs Header", title: "ADS/Templates/Entity Tabs Header",
@ -25,7 +30,7 @@ const Template = ({ width }: Args) => {
<div style={{ width }}> <div style={{ width }}>
<EntityTabsHeader> <EntityTabsHeader>
<EntityListButton onClick={console.log} /> <EntityListButton onClick={console.log} />
<DismissibleTabBar onTabAdd={console.log}> <EntityTabBar onTabAdd={console.log}>
<DismissibleTab onClick={console.log} onClose={console.log}> <DismissibleTab onClick={console.log} onClose={console.log}>
One One
</DismissibleTab> </DismissibleTab>
@ -41,7 +46,7 @@ const Template = ({ width }: Args) => {
<DismissibleTab onClick={console.log} onClose={console.log}> <DismissibleTab onClick={console.log} onClose={console.log}>
Five Five
</DismissibleTab> </DismissibleTab>
</DismissibleTabBar> </EntityTabBar>
<ToggleScreenModeButton <ToggleScreenModeButton
isInSplitScreenMode={isInSplitScreenMode} isInSplitScreenMode={isInSplitScreenMode}
onClick={toggle} onClick={toggle}

View File

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

View File

@ -8,7 +8,8 @@ import type {
ToggleScreenModeButtonProps, ToggleScreenModeButtonProps,
} from "./EntityTabsHeader.types"; } from "./EntityTabsHeader.types";
import { ToggleButton } from "../.."; import { ToggleButton } from "../../ToggleButton";
import { type DismissibleTabBarProps } from "../../DismissibleTab/DismissibleTabBar.types";
export const EntityListButton = (props: EntityListButtonProps) => { export const EntityListButton = (props: EntityListButtonProps) => {
return <ToggleButton {...props} icon="hamburger" size="md" />; 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) { export function EntityTabsHeader({ children }: EntityTabsHeaderProps) {
return <Styled.Root>{children}</Styled.Root>; return <Styled.Root>{children}</Styled.Root>;
} }

View File

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

View File

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

View File

@ -3,3 +3,4 @@ export * from "./EntityExplorer";
export * from "./Sidebar"; export * from "./Sidebar";
export * from "./EditableEntityName"; export * from "./EditableEntityName";
export * from "./EditableDismissibleTab"; 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 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 { import {
EditorEntityTab, EditorEntityTab,
EditorEntityTabState, EditorEntityTabState,
} from "ee/entities/IDE/constants"; } from "ee/entities/IDE/constants";
import { Text } from "@appsmith/ads";
import { useCurrentEditorState } from "../hooks";
const AddTab = ({ const AddTab = ({
isListActive, isListActive,
@ -19,24 +21,26 @@ const AddTab = ({
}) => { }) => {
const { segment, segmentMode } = useCurrentEditorState(); const { segment, segmentMode } = useCurrentEditorState();
if (segmentMode !== EditorEntityTabState.Add) return null; const onCloseClick = useEventCallback((e: React.MouseEvent) => {
const onCloseClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
onClose(); 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 ( return (
<FileTab <DismissibleTab
dataTestId={dataTestId}
isActive={segmentMode === EditorEntityTabState.Add && !isListActive} isActive={segmentMode === EditorEntityTabState.Add && !isListActive}
onClick={newTabClickCallback} onClick={newTabClickCallback}
onClose={(e) => onCloseClick(e)} onClose={onCloseClick}
title={content}
> >
<Text kind="body-s">{content}</Text> <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 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 { useDispatch, useSelector } from "react-redux";
import { useEventCallback } from "usehooks-ts"; 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 { FEATURE_FLAG } from "ee/entities/FeatureFlag";
import { getIsSavingEntityName } from "ee/selectors/entitiesSelector";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { sanitizeString } from "utils/URLUtils";
import { import {
getEditableTabPermissions, getEditableTabPermissions,
saveEntityName, saveEntityName,
} from "ee/entities/IDE/utils"; } from "ee/entities/IDE/utils";
import { noop } from "lodash"; import { useIsRenaming, useValidateEntityName } from "IDE";
import { EditableName, useIsRenaming } from "IDE";
import { IconContainer } from "IDE/Components/FileTab/styles"; import { useCurrentEditorState } from "../hooks";
interface EditableTabProps { interface EditableTabProps {
id: string; id: string;
@ -30,6 +32,7 @@ interface EditableTabProps {
export function EditableTab(props: EditableTabProps) { export function EditableTab(props: EditableTabProps) {
const { entity, icon, id, isActive, onClick, onClose, title } = props; const { entity, icon, id, isActive, onClick, onClose, title } = props;
const { segment } = useCurrentEditorState(); const { segment } = useCurrentEditorState();
const dispatch = useDispatch();
const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled); const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled);
const isChangePermitted = getEditableTabPermissions({ const isChangePermitted = getEditableTabPermissions({
@ -43,39 +46,36 @@ export function EditableTab(props: EditableTabProps) {
getIsSavingEntityName(state, { id, segment, entity }), getIsSavingEntityName(state, { id, segment, entity }),
); );
const handleClose = useEventCallback((e: React.MouseEvent) => { const validateName = useValidateEntityName({
e.stopPropagation(); entityName: title,
onClose(id);
}); });
const handleDoubleClick = isChangePermitted ? enterEditMode : noop; const handleClose = useEventCallback(() => {
onClose(id);
const dispatch = useDispatch(); });
const handleNameSave = useCallback( const handleNameSave = useCallback(
(name: string) => { (name: string) => {
dispatch(saveEntityName({ params: { id, name }, segment, entity })); dispatch(saveEntityName({ params: { id, name }, segment, entity }));
exitEditMode();
}, },
[dispatch, entity, exitEditMode, id, segment], [dispatch, entity, id, segment],
); );
return ( return (
<FileTab <EditableDismissibleTab
dataTestId={`t--ide-tab-${sanitizeString(title)}`}
icon={icon}
isActive={isActive} isActive={isActive}
onClick={onClick} isEditable={isChangePermitted}
onClose={handleClose}
onDoubleClick={handleDoubleClick}
title={title}
>
<EditableName
exitEditing={exitEditMode}
icon={<IconContainer>{icon}</IconContainer>}
isEditing={isEditing} isEditing={isEditing}
isLoading={isLoading} isLoading={isLoading}
name={title} name={title}
onClick={onClick}
onClose={handleClose}
onEnterEditMode={enterEditMode}
onExitEditMode={exitEditMode}
onNameSave={handleNameSave} onNameSave={handleNameSave}
validateName={validateName}
/> />
</FileTab>
); );
} }

View File

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

View File

@ -37,10 +37,3 @@ export const TabSelectors: Record<
itemUrlSelector: () => "", 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 React, { useCallback, useEffect } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux"; 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 { getIDEViewMode, getListViewActiveState } from "selectors/ideSelectors";
import type { EntityItem } from "ee/entities/IDE/constants"; import type { EntityItem } from "ee/entities/IDE/constants";
import { import {
@ -8,27 +16,25 @@ import {
EditorEntityTabState, EditorEntityTabState,
EditorViewMode, EditorViewMode,
} from "ee/entities/IDE/constants"; } 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 { import {
useCurrentEditorState, useCurrentEditorState,
useIDETabClickHandlers, useIDETabClickHandlers,
useShowSideBySideNudge, useShowSideBySideNudge,
} from "../hooks"; } 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 { List } from "./List";
import { ScreenModeToggle } from "./ScreenModeToggle"; import { ScreenModeToggle } from "./ScreenModeToggle";
import { AddTab } from "./AddTab";
import { setListViewActiveState } from "actions/ideActions";
import { useEventCallback } from "usehooks-ts";
import { EditableTab } from "./EditableTab"; import { EditableTab } from "./EditableTab";
import { TabSelectors } from "./constants";
import { AddTab } from "./AddTab";
const EditorTabs = () => { const EditorTabs = () => {
const location = useLocation();
const dispatch = useDispatch();
const ideViewMode = useSelector(getIDEViewMode); const ideViewMode = useSelector(getIDEViewMode);
const { segment, segmentMode } = useCurrentEditorState(); const { segment, segmentMode } = useCurrentEditorState();
const { closeClickHandler, tabClickHandler } = useIDETabClickHandlers(); const { closeClickHandler, tabClickHandler } = useIDETabClickHandlers();
@ -37,47 +43,30 @@ const EditorTabs = () => {
const files = useSelector(tabsConfig.tabsSelector, shallowEqual); const files = useSelector(tabsConfig.tabsSelector, shallowEqual);
const isListViewActive = useSelector(getListViewActiveState); const isListViewActive = useSelector(getListViewActiveState);
const [showNudge, dismissNudge] = useShowSideBySideNudge(); 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 currentEntity = identifyEntityFromPath(location.pathname);
const showEntityListButton =
ideViewMode === EditorViewMode.SplitScreen && files.length > 0;
// Turn off list view while changing segment, files useEffect(
useEffect(() => { function turnOffListViewWhileChangingSegmentFiles() {
dispatch(setListViewActiveState(false)); dispatch(setListViewActiveState(false));
}, [currentEntity.id, currentEntity.entity, files, segmentMode, dispatch]); },
[currentEntity.id, currentEntity.entity, files, segmentMode, dispatch],
);
// Show list view if all tabs is closed useEffect(
useEffect(() => { function showListViewIfAllTabsAreClosed() {
if (files.length === 0 && segmentMode !== EditorEntityTabState.Add) { if (files.length === 0 && segmentMode !== EditorEntityTabState.Add) {
dispatch(setListViewActiveState(true)); dispatch(setListViewActiveState(true));
} }
}, [files, segmentMode, currentEntity.entity, dispatch]); },
[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]);
const handleHamburgerClick = useEventCallback(() => { const handleHamburgerClick = useEventCallback(() => {
if (files.length === 0 && segmentMode !== EditorEntityTabState.Add) return; if (files.length === 0 && segmentMode !== EditorEntityTabState.Add) return;
@ -101,27 +90,19 @@ const EditorTabs = () => {
return ( return (
<> <>
<Container> <EntityTabsHeader>
{ideViewMode === EditorViewMode.SplitScreen && files.length > 0 ? ( {showEntityListButton && (
<ToggleButton <EntityListButton
data-testid="t--list-toggle" data-testid="t--list-toggle"
icon="hamburger"
isSelected={isListViewActive} isSelected={isListViewActive}
onClick={handleHamburgerClick} onClick={handleHamburgerClick}
size="md"
/> />
) : null} )}
<ScrollArea
className="h-[32px] top-[0.5px]" <EntityTabBar
data-testid="t--editor-tabs" hideAdd={hideAdd}
options={SCROLL_AREA_OPTIONS} isAddingNewTab={isJSLoading}
size="sm" onTabAdd={addClickHandler}
>
<Flex
className="items-center"
data-testid="t--tabs-container"
gap="spaces-2"
height="100%"
> >
{files.map((tab) => { {files.map((tab) => {
const entity = entities.find((entity) => entity.key === tab.key); const entity = entities.find((entity) => entity.key === tab.key);
@ -148,14 +129,10 @@ const EditorTabs = () => {
newTabClickCallback={handleNewTabClick} newTabClickCallback={handleNewTabClick}
onClose={closeClickHandler} onClose={closeClickHandler}
/> />
</Flex> </EntityTabBar>
</ScrollArea>
{files.length > 0 ? <AddButton /> : null}
{/* Switch screen mode button */}
<ScreenModeToggle dismissNudge={dismissNudge} showNudge={showNudge} /> <ScreenModeToggle dismissNudge={dismissNudge} showNudge={showNudge} />
</Container> </EntityTabsHeader>
{/* Overflow list */}
{isListViewActive && ideViewMode === EditorViewMode.SplitScreen && ( {isListViewActive && ideViewMode === EditorViewMode.SplitScreen && (
<List /> <List />
)} )}