From 9a315eabdc7d94a5518e24697d38d1a25706c054 Mon Sep 17 00:00:00 2001 From: Ankita Kinger Date: Fri, 18 Oct 2024 19:07:50 +0530 Subject: [PATCH] chore: Updating plugin action name editor component to use ADS text component (#36960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Updating plugin action name editor component to use ADS text component instead of EditableText from blueprint. Also, adding permission checks for editable tabs to not allow the name edits when the user doesn't have the permission. Fixes [#36793](https://github.com/appsmithorg/appsmith/issues/36793) ## Automation /ok-to-test tags="@tag.All" ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: 0b695e8837a4c510df51ff3e7fcb799ed50a3666 > Cypress dashboard. > Tags: `@tag.All` > Spec: >
Fri, 18 Oct 2024 12:36:26 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit - **New Features** - Introduced conditional editing functionality in the FileTab component based on user permissions. - Enhanced the PluginActionNameEditor with real-time validation and improved user interaction. - Added user permissions information to entity items and selectors for better access control. - Integrated new functionality for saving entity names and managing editable tab permissions. - Expanded the EditorTabs component to include contextual entity information for each tab. - **Bug Fixes** - Simplified the ActionNameEditor by removing unnecessary feature flag checks. - **Documentation** - Updated interfaces and components to improve clarity and functionality. --- .../IDE/Components/FileTab/FileTab.test.tsx | 1 + .../src/IDE/Components/FileTab/FileTab.tsx | 5 +- .../components/PluginActionNameEditor.tsx | 198 ++++++++++++++---- app/client/src/ce/entities/IDE/constants.ts | 1 + app/client/src/ce/entities/IDE/utils.ts | 38 ++++ .../src/ce/selectors/entitiesSelector.ts | 27 ++- .../editorComponents/ActionNameEditor.tsx | 8 +- .../Editor/IDE/EditorTabs/EditableTab.tsx | 32 +-- .../src/pages/Editor/IDE/EditorTabs/index.tsx | 36 ++-- 9 files changed, 271 insertions(+), 75 deletions(-) diff --git a/app/client/src/IDE/Components/FileTab/FileTab.test.tsx b/app/client/src/IDE/Components/FileTab/FileTab.test.tsx index 38ff125fe0..dc1cd307c7 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 5de0864081..8198e27050 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 55f32a5e4c..7958facc60 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); - } +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--action-name-edit-field { - font-size: 12px; - - .bp3-editable-text-content { - height: unset !important; - line-height: unset !important; - } - } - - & .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 3f1425dce2..0b19c02f62 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 9beb96cdf2..d119cac823 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 4f50d471cb..03262d1b64 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 1d9bf01aa7..7db94c2666 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 09a923da07..eeea9f8e7e 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 ( + + ); + })}