diff --git a/app/client/src/IDE/Components/FileTab/FileTab.test.tsx b/app/client/src/IDE/Components/FileTab/FileTab.test.tsx index 38ff125fe0f7..dc1cd307c776 100644 --- a/app/client/src/IDE/Components/FileTab/FileTab.test.tsx +++ b/app/client/src/IDE/Components/FileTab/FileTab.test.tsx @@ -37,6 +37,7 @@ describe("FileTab", () => { editorConfig={mockEditorConfig} icon={} isActive + isChangePermitted isLoading={isLoading} onClick={mockOnClick} onClose={mockOnClose} diff --git a/app/client/src/IDE/Components/FileTab/FileTab.tsx b/app/client/src/IDE/Components/FileTab/FileTab.tsx index 5de086408157..8198e27050d0 100644 --- a/app/client/src/IDE/Components/FileTab/FileTab.tsx +++ b/app/client/src/IDE/Components/FileTab/FileTab.tsx @@ -13,6 +13,7 @@ import { DATA_TEST_ID } from "./constants"; export interface FileTabProps { isActive: boolean; + isChangePermitted?: boolean; isLoading?: boolean; title: string; onClick: () => void; @@ -32,6 +33,7 @@ export const FileTab = ({ editorConfig, icon, isActive, + isChangePermitted = false, isLoading = false, onClick, onClose, @@ -89,7 +91,8 @@ export const FileTab = ({ enterEditMode(); }); - const handleDoubleClick = editorConfig ? handleEnterEditMode : noop; + const handleDoubleClick = + editorConfig && isChangePermitted ? handleEnterEditMode : noop; const inputProps = useMemo( () => ({ diff --git a/app/client/src/PluginActionEditor/components/PluginActionNameEditor.tsx b/app/client/src/PluginActionEditor/components/PluginActionNameEditor.tsx index 55f32a5e4cd1..7958facc607b 100644 --- a/app/client/src/PluginActionEditor/components/PluginActionNameEditor.tsx +++ b/app/client/src/PluginActionEditor/components/PluginActionNameEditor.tsx @@ -1,16 +1,19 @@ -import React from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useSelector } from "react-redux"; -import ActionNameEditor from "components/editorComponents/ActionNameEditor"; import { usePluginActionContext } from "../PluginActionContext"; import { useFeatureFlag } from "utils/hooks/useFeatureFlag"; import { getHasManageActionPermission } from "ee/utils/BusinessFeatures/permissionPageHelpers"; import { FEATURE_FLAG } from "ee/entities/FeatureFlag"; -import { PluginType } from "entities/Action"; import type { ReduxAction } from "ee/constants/ReduxActionConstants"; -import styled from "styled-components"; import { getSavingStatusForActionName } from "selectors/actionSelectors"; import { getAssetUrl } from "ee/utils/airgapHelpers"; import { ActionUrlIcon } from "pages/Editor/Explorer/ExplorerIcons"; +import { Spinner, Text as ADSText, Tooltip, Flex } from "@appsmith/ads"; +import { usePrevious } from "@mantine/hooks"; +import styled from "styled-components"; +import { useNameEditor } from "utils/hooks/useNameEditor"; +import { useBoolean, useEventCallback, useEventListener } from "usehooks-ts"; +import { noop } from "lodash"; export interface SaveActionNameParams { id: string; @@ -23,58 +26,177 @@ export interface PluginActionNameEditorProps { ) => ReduxAction; } -const ActionNameEditorWrapper = styled.div` - & .ads-v2-box { - gap: var(--ads-v2-spaces-2); - } - - && .t--action-name-edit-field { - font-size: 12px; - - .bp3-editable-text-content { - height: unset !important; - line-height: unset !important; - } - } +export const NameWrapper = styled(Flex)` + height: 100%; + position: relative; + font-size: 12px; + color: var(--ads-v2-colors-text-default); + cursor: pointer; + gap: var(--ads-v2-spaces-2); + align-items: center; + justify-content: center; + padding: var(--ads-v2-spaces-3); +`; - & .t--plugin-icon-box { - height: 12px; +export const IconContainer = styled.div` + height: 12px; + width: 12px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + img { width: 12px; - - img { - width: 12px; - height: auto; - } } `; +export const Text = styled(ADSText)` + min-width: 3ch; + padding: 0 var(--ads-v2-spaces-1); + font-weight: 500; +`; + const PluginActionNameEditor = (props: PluginActionNameEditorProps) => { const { action, plugin } = usePluginActionContext(); + const title = action.name; + const previousTitle = usePrevious(title); + const [editableTitle, setEditableTitle] = useState(title); + const [validationError, setValidationError] = useState(null); + const inputRef = useRef(null); + const isLoading = useSelector( + (state) => getSavingStatusForActionName(state, action?.id || "").isSaving, + ); + + const { handleNameSave, normalizeName, validateName } = useNameEditor({ + entityId: action.id, + entityName: title, + nameSaveAction: props.saveActionName, + }); + + const { + setFalse: exitEditMode, + setTrue: enterEditMode, + value: isEditing, + } = useBoolean(false); + const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled); const isChangePermitted = getHasManageActionPermission( isFeatureEnabled, action?.userPermissions, ); - const saveStatus = useSelector((state) => - getSavingStatusForActionName(state, action?.id || ""), - ); - + const currentTitle = + isEditing || isLoading || title !== editableTitle ? editableTitle : title; const iconUrl = getAssetUrl(plugin?.iconLocation) || ""; const icon = ActionUrlIcon(iconUrl); + const handleKeyUp = useEventCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + const nameError = validateName(editableTitle); + + if (nameError === null) { + exitEditMode(); + handleNameSave(editableTitle); + } else { + setValidationError(nameError); + } + } else if (e.key === "Escape") { + exitEditMode(); + setEditableTitle(title); + setValidationError(null); + } else { + setValidationError(null); + } + }, + ); + + const handleTitleChange = useEventCallback( + (e: React.ChangeEvent) => { + setEditableTitle(normalizeName(e.target.value)); + }, + ); + + const handleEnterEditMode = useEventCallback(() => { + setEditableTitle(title); + enterEditMode(); + }); + + const handleDoubleClick = isChangePermitted ? handleEnterEditMode : noop; + + const inputProps = useMemo( + () => ({ + onKeyUp: handleKeyUp, + onChange: handleTitleChange, + autoFocus: true, + style: { + paddingTop: 0, + paddingBottom: 0, + left: -1, + top: -1, + }, + }), + [handleKeyUp, handleTitleChange], + ); + + useEventListener( + "focusout", + function handleFocusOut() { + if (isEditing) { + const nameError = validateName(editableTitle); + + exitEditMode(); + + if (nameError === null) { + handleNameSave(editableTitle); + } else { + setEditableTitle(title); + setValidationError(null); + } + } + }, + inputRef, + ); + + useEffect( + function syncEditableTitle() { + if (!isEditing && previousTitle !== title) { + setEditableTitle(title); + } + }, + [title, previousTitle, isEditing], + ); + + useEffect( + function recaptureFocusInEventOfFocusRetention() { + const input = inputRef.current; + + if (isEditing && input) { + setTimeout(() => { + input.focus(); + }, 200); + } + }, + [isEditing], + ); + return ( - - - + + {icon && !isLoading ? {icon} : null} + {isLoading && } + + + + {currentTitle} + + + ); }; diff --git a/app/client/src/ce/entities/IDE/constants.ts b/app/client/src/ce/entities/IDE/constants.ts index 3f1425dce203..0b19c02f6255 100644 --- a/app/client/src/ce/entities/IDE/constants.ts +++ b/app/client/src/ce/entities/IDE/constants.ts @@ -122,6 +122,7 @@ export interface EntityItem { key: string; icon?: ReactNode; group?: string; + userPermissions?: string[]; } export type UseRoutes = Array<{ diff --git a/app/client/src/ce/entities/IDE/utils.ts b/app/client/src/ce/entities/IDE/utils.ts index 9beb96cdf2c2..d119cac82369 100644 --- a/app/client/src/ce/entities/IDE/utils.ts +++ b/app/client/src/ce/entities/IDE/utils.ts @@ -7,6 +7,19 @@ import { BUILDER_PATH, BUILDER_PATH_DEPRECATED, } from "ee/constants/routes/appRoutes"; +import { saveActionName } from "actions/pluginActionActions"; +import { saveJSObjectName } from "actions/jsActionActions"; +import { EditorEntityTab, type EntityItem } from "ee/entities/IDE/constants"; +import { getHasManageActionPermission } from "ee/utils/BusinessFeatures/permissionPageHelpers"; + +export interface SaveEntityName { + params: { + name: string; + id: string; + }; + segment: EditorEntityTab; + entity?: EntityItem; +} export const EDITOR_PATHS = [ BUILDER_CUSTOM_PATH, @@ -35,3 +48,28 @@ export function getIDETypeByUrl(path: string): IDEType { export function getBaseUrlsForIDEType(type: IDEType): string[] { return IDEBasePaths[type]; } + +export const saveEntityName = ({ params, segment }: SaveEntityName) => { + let saveNameAction = saveActionName(params); + + if (EditorEntityTab.JS === segment) { + saveNameAction = saveJSObjectName(params); + } + + return saveNameAction; +}; + +export interface EditableTabPermissions { + isFeatureEnabled: boolean; + entity?: EntityItem; +} + +export const getEditableTabPermissions = ({ + entity, + isFeatureEnabled, +}: EditableTabPermissions) => { + return getHasManageActionPermission( + isFeatureEnabled, + entity?.userPermissions || [], + ); +}; diff --git a/app/client/src/ce/selectors/entitiesSelector.ts b/app/client/src/ce/selectors/entitiesSelector.ts index 4f50d471cb0b..03262d1b6489 100644 --- a/app/client/src/ce/selectors/entitiesSelector.ts +++ b/app/client/src/ce/selectors/entitiesSelector.ts @@ -59,12 +59,16 @@ import { import { MAX_DATASOURCE_SUGGESTIONS } from "constants/DatasourceEditorConstants"; import type { CreateNewActionKeyInterface } from "ee/entities/Engine/actionHelpers"; import { getNextEntityName } from "utils/AppsmithUtils"; -import type { EntityItem } from "ee/entities/IDE/constants"; +import { EditorEntityTab, type EntityItem } from "ee/entities/IDE/constants"; import { ActionUrlIcon, JsFileIconV2, } from "pages/Editor/Explorer/ExplorerIcons"; import { getAssetUrl } from "ee/utils/airgapHelpers"; +import { + getIsSavingForApiName, + getIsSavingForJSObjectName, +} from "selectors/ui"; export enum GROUP_TYPES { API = "APIs", @@ -1650,6 +1654,7 @@ export const getQuerySegmentItems = createSelector( key: action.config.baseId, type: action.config.pluginType, group, + userPermissions: action.config.userPermissions, }; }); @@ -1664,6 +1669,7 @@ export const getJSSegmentItems = createSelector( title: js.config.name, key: js.config.baseId, type: PluginType.JS, + userPermissions: js.config.userPermissions, })); return items; @@ -1691,3 +1697,22 @@ export const getDatasourceUsageCountForApp = createSelector( return actionDsMap; }, ); + +export interface IsSavingEntityNameParams { + id: string; + segment: EditorEntityTab; + entity?: EntityItem; +} + +export const getIsSavingEntityName = ( + state: AppState, + { id, segment }: IsSavingEntityNameParams, +) => { + let isSavingEntityName = getIsSavingForApiName(state, id); + + if (EditorEntityTab.JS === segment) { + isSavingEntityName = getIsSavingForJSObjectName(state, id); + } + + return isSavingEntityName; +}; diff --git a/app/client/src/components/editorComponents/ActionNameEditor.tsx b/app/client/src/components/editorComponents/ActionNameEditor.tsx index 1d9bf01aa7cb..7db94c26666d 100644 --- a/app/client/src/components/editorComponents/ActionNameEditor.tsx +++ b/app/client/src/components/editorComponents/ActionNameEditor.tsx @@ -17,8 +17,6 @@ import { } from "ee/constants/messages"; import type { ReduxAction } from "ee/constants/ReduxActionConstants"; import type { SaveActionNameParams } from "PluginActionEditor"; -import { useFeatureFlag } from "utils/hooks/useFeatureFlag"; -import { FEATURE_FLAG } from "ee/entities/FeatureFlag"; import type { Action } from "entities/Action"; import type { ModuleInstance } from "ee/constants/ModuleInstanceConstants"; @@ -49,10 +47,6 @@ function ActionNameEditor(props: ActionNameEditorProps) { saveStatus, } = props; - const isActionRedesignEnabled = useFeatureFlag( - FEATURE_FLAG.release_actions_redesign_enabled, - ); - return ( { id: string; onClose: (id: string) => void; + entity?: EntityItem; } export function EditableTab(props: EditableTabProps) { - const { icon, id, isActive, onClick, onClose, title } = props; + const { entity, icon, id, isActive, onClick, onClose, title } = props; const { segment } = useCurrentEditorState(); + const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled); + const isChangePermitted = getEditableTabPermissions({ + isFeatureEnabled, + entity, + }); + const { handleNameSave, normalizeName, validateName } = useNameEditor({ entityId: id, entityName: title, - nameSaveAction: - EditorEntityTab.JS === segment ? saveJSObjectName : saveActionName, + nameSaveAction: (params) => saveEntityName({ params, segment, entity }), }); const isLoading = useSelector((state) => - EditorEntityTab.JS === segment - ? getIsSavingForJSObjectName(state, id) - : getIsSavingForApiName(state, id), + getIsSavingEntityName(state, { id, segment, entity }), ); const editorConfig = useMemo( @@ -55,6 +60,7 @@ export function EditableTab(props: EditableTabProps) { editorConfig={editorConfig} icon={icon} isActive={isActive} + isChangePermitted={isChangePermitted} isLoading={isLoading} onClick={onClick} onClose={handleClose} diff --git a/app/client/src/pages/Editor/IDE/EditorTabs/index.tsx b/app/client/src/pages/Editor/IDE/EditorTabs/index.tsx index 09a923da0704..eeea9f8e7eb6 100644 --- a/app/client/src/pages/Editor/IDE/EditorTabs/index.tsx +++ b/app/client/src/pages/Editor/IDE/EditorTabs/index.tsx @@ -35,6 +35,7 @@ const EditorTabs = () => { const { segment, segmentMode } = useCurrentEditorState(); const { closeClickHandler, tabClickHandler } = useIDETabClickHandlers(); const tabsConfig = TabSelectors[segment]; + const entities = useSelector(tabsConfig.listSelector, shallowEqual); const files = useSelector(tabsConfig.tabsSelector, shallowEqual); const isListViewActive = useSelector(getListViewActiveState); @@ -122,21 +123,26 @@ const EditorTabs = () => { gap="spaces-2" height="100%" > - {files.map((tab) => ( - - ))} + {files.map((tab) => { + const entity = entities.find((entity) => entity.key === tab.key); + + return ( + + ); + })}