diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 218bb79d..ae653c39 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -2870,6 +2870,9 @@ class CreateTool(Resource): "config": fields.Raw( required=True, description="Configuration of the tool" ), + "customName": fields.String( + required=False, description="Custom name for the tool" + ), "actions": fields.List( fields.Raw, required=True, @@ -2916,6 +2919,7 @@ class CreateTool(Resource): "name": data["name"], "displayName": data["displayName"], "description": data["description"], + "customName": data.get("customName", ""), "actions": transformed_actions, "config": data["config"], "status": data["status"], @@ -2937,6 +2941,7 @@ class UpdateTool(Resource): "id": fields.String(required=True, description="Tool ID"), "name": fields.String(description="Name of the tool"), "displayName": fields.String(description="Display name for the tool"), + "customName": fields.String(description="Custom name for the tool"), "description": fields.String(description="Tool description"), "config": fields.Raw(description="Configuration of the tool"), "actions": fields.List( @@ -2963,6 +2968,8 @@ class UpdateTool(Resource): update_data["name"] = data["name"] if "displayName" in data: update_data["displayName"] = data["displayName"] + if "customName" in data: + update_data["customName"] = data["customName"] if "description" in data: update_data["description"] = data["description"] if "actions" in data: diff --git a/frontend/src/components/Input.tsx b/frontend/src/components/Input.tsx index 1278b813..a968511c 100644 --- a/frontend/src/components/Input.tsx +++ b/frontend/src/components/Input.tsx @@ -36,6 +36,8 @@ const Input = ({ const inputRef = useRef(null); + const hasValue = value !== undefined && value !== null && value !== ''; + return (
{placeholder} {required && ( diff --git a/frontend/src/components/ToolsPopup.tsx b/frontend/src/components/ToolsPopup.tsx index 5fdd6c23..4948bad6 100644 --- a/frontend/src/components/ToolsPopup.tsx +++ b/frontend/src/components/ToolsPopup.tsx @@ -201,7 +201,7 @@ export default function ToolsPopup({ />

- {tool.displayName} + {tool.customName || tool.displayName}

diff --git a/frontend/src/index.css b/frontend/src/index.css index 4c1bb30f..ee9aa0c2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -46,6 +46,31 @@ body.dark { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ } + + /* Thin scrollbar utility */ + .scrollbar-thin::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + .scrollbar-thin::-webkit-scrollbar-track { + background: transparent; + } + + .scrollbar-thin::-webkit-scrollbar-thumb { + background: rgba(156, 163, 175, 0.5); + border-radius: 3px; + } + + .scrollbar-thin::-webkit-scrollbar-thumb:hover { + background: rgba(156, 163, 175, 0.7); + } + + /* For Firefox */ + .scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.5) transparent; + } } @layer components { diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 6c1559b7..352de717 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -117,9 +117,15 @@ "noToolsFound": "No tools found", "selectToolSetup": "Select a tool to set up", "settingsIconAlt": "Settings icon", - "configureToolAria": "Configure {toolName}", - "toggleToolAria": "Toggle {toolName}", - "manageTools": "Go to Tools" + "configureToolAria": "Configure {{toolName}}", + "toggleToolAria": "Toggle {{toolName}}", + "manageTools": "Go to Tools", + "edit": "Edit", + "delete": "Delete", + "deleteWarning": "Are you sure you want to delete the tool \"{{toolName}}\" ?", + "unsavedChanges": "You have unsaved changes that will be lost if you leave without saving.", + "leaveWithoutSaving": "Leave without Saving", + "saveAndLeave": "Save and Leave" } }, "modals": { diff --git a/frontend/src/modals/AddActionModal.tsx b/frontend/src/modals/AddActionModal.tsx index 3cb35849..b7aa8bba 100644 --- a/frontend/src/modals/AddActionModal.tsx +++ b/frontend/src/modals/AddActionModal.tsx @@ -47,9 +47,6 @@ export default function AddActionModal({ New Action
- - Action Name -

(''); + const [customName, setCustomName] = React.useState(''); const handleAddTool = (tool: AvailableToolType) => { userService @@ -34,6 +35,7 @@ export default function ConfigToolModal({ displayName: tool.displayName, description: tool.description, config: { token: authKey }, + customName: customName, actions: tool.actions, status: true, }, @@ -58,6 +60,16 @@ export default function ConfigToolModal({ {t('modals.configTool.type')}:{' '} {tool?.name}

+
+ setCustomName(e.target.value)} + borderVariant="thin" + placeholder="Enter custom name (optional)" + labelBgClassName="bg-white dark:bg-charleston-green-2" + /> +
{modalState === 'ACTIVE' && ( - { - setModalState('INACTIVE'); - handleCancel && handleCancel(); - }} - > + setModalState('INACTIVE')}>

diff --git a/frontend/src/settings/ToolConfig.tsx b/frontend/src/settings/ToolConfig.tsx index b2e26507..de99fabf 100644 --- a/frontend/src/settings/ToolConfig.tsx +++ b/frontend/src/settings/ToolConfig.tsx @@ -10,10 +10,12 @@ import Dropdown from '../components/Dropdown'; import Input from '../components/Input'; import ToggleSwitch from '../components/ToggleSwitch'; import AddActionModal from '../modals/AddActionModal'; +import ConfirmationModal from '../modals/ConfirmationModal'; import { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; import { APIActionType, APIToolType, UserToolType } from './types'; import { useTranslation } from 'react-i18next'; +import { areObjectsEqual } from '../utils/objectUtils'; export default function ToolConfig({ tool, @@ -28,9 +30,40 @@ export default function ToolConfig({ const [authKey, setAuthKey] = React.useState( 'token' in tool.config ? tool.config.token : '', ); + const [customName, setCustomName] = React.useState( + tool.customName || '', + ); const [actionModalState, setActionModalState] = React.useState('INACTIVE'); + const [initialState, setInitialState] = React.useState({ + customName: tool.customName || '', + authKey: 'token' in tool.config ? tool.config.token : '', + config: tool.config, + actions: 'actions' in tool ? tool.actions : [], + }); + const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false); + const [showUnsavedModal, setShowUnsavedModal] = React.useState(false); const { t } = useTranslation(); + + const handleBackClick = () => { + if (hasUnsavedChanges) { + setShowUnsavedModal(true); + } else { + handleGoBack(); + } + }; + + React.useEffect(() => { + const currentState = { + customName, + authKey, + config: tool.config, + actions: 'actions' in tool ? tool.actions : [], + }; + + setHasUnsavedChanges(!areObjectsEqual(initialState, currentState)); + }, [customName, authKey, tool]); + const handleCheckboxChange = (actionIndex: number, property: string) => { setTool({ ...tool, @@ -66,6 +99,7 @@ export default function ToolConfig({ id: tool.id, name: tool.name, displayName: tool.displayName, + customName: customName, description: tool.description, config: tool.name === 'api_tool' ? tool.config : { token: authKey }, actions: 'actions' in tool ? tool.actions : [], @@ -74,6 +108,14 @@ export default function ToolConfig({ token, ) .then(() => { + // Update initialState to match current state + setInitialState({ + customName, + authKey, + config: tool.config, + actions: 'actions' in tool ? tool.actions : [], + }); + setHasUnsavedChanges(false); handleGoBack(); }); }; @@ -114,23 +156,38 @@ export default function ToolConfig({ }); }; return ( -

-
+
+
+
+ +

Back to all tools

+
-

Back to all tools

-
+ {/* Custom name section */} +

- Type -

-

- {tool.name} + Custom Name

+
+ setCustomName(e.target.value)} + borderVariant="thin" + placeholder="Enter a custom name (optional)" + /> +
{Object.keys(tool?.config).length !== 0 && tool.name !== 'api_tool' && ( @@ -141,7 +198,7 @@ export default function ToolConfig({
{Object.keys(tool?.config).length !== 0 && tool.name !== 'api_tool' && ( -
+
)} -
- - -
@@ -173,17 +216,19 @@ export default function ToolConfig({

Actions

-
{tool.name === 'api_tool' ? ( - + <> + +
+ +
+ ) : (
{'actions' in tool && @@ -214,10 +259,10 @@ export default function ToolConfig({ id={`actionToggle-${actionIndex}`} />
-
+
{ @@ -368,6 +413,49 @@ export default function ToolConfig({ setModalState={setActionModalState} handleSubmit={handleAddNewAction} /> + {showUnsavedModal && ( + setShowUnsavedModal(state === 'ACTIVE')} + submitLabel={t('settings.tools.saveAndLeave', { + defaultValue: 'Save and Leave', + })} + handleSubmit={() => { + userService + .updateTool( + { + id: tool.id, + name: tool.name, + displayName: tool.displayName, + customName: customName, + description: tool.description, + config: + tool.name === 'api_tool' + ? tool.config + : { token: authKey }, + actions: 'actions' in tool ? tool.actions : [], + status: tool.status, + }, + token, + ) + .then(() => { + setShowUnsavedModal(false); + handleGoBack(); + }); + }} + cancelLabel={t('settings.tools.leaveWithoutSaving', { + defaultValue: 'Leave without Saving', + })} + handleCancel={() => { + setShowUnsavedModal(false); + handleGoBack(); + }} + /> + )}
); @@ -381,6 +469,34 @@ function APIToolConfig({ setTool: (tool: APIToolType) => void; }) { const [apiTool, setApiTool] = React.useState(tool); + const { t } = useTranslation(); + const [actionToDelete, setActionToDelete] = React.useState( + null, + ); + const [deleteModalState, setDeleteModalState] = + React.useState('INACTIVE'); + + const handleDeleteActionClick = (actionName: string) => { + setActionToDelete(actionName); + setDeleteModalState('ACTIVE'); + }; + const handleConfirmedDelete = () => { + if (actionToDelete) { + setApiTool((prevApiTool) => { + const { [actionToDelete]: deletedAction, ...remainingActions } = + prevApiTool.config.actions; + return { + ...prevApiTool, + config: { + ...prevApiTool.config, + actions: remainingActions, + }, + }; + }); + setActionToDelete(null); + setDeleteModalState('INACTIVE'); + } + }; const handleActionChange = ( actionName: string, @@ -417,19 +533,31 @@ function APIToolConfig({ setTool(apiTool); }, [apiTool]); return ( -
+
+ {/* Actions list */} {apiTool.config.actions && Object.entries(apiTool.config.actions).map( - ([actionName, action], actionIndex) => { - return ( -
-
-

- {action.name} -

+ ([actionName, action], actionIndex) => ( +
+
+

+ {action.name} +

+
+ handleActionToggle(actionName)} @@ -437,117 +565,136 @@ function APIToolConfig({ id={`actionToggle-${actionIndex}`} />
-
-
- - URL - - { - setApiTool((prevApiTool) => { - const updatedActions = { - ...prevApiTool.config.actions, - }; - const updatedAction = { - ...updatedActions[actionName], - }; - updatedAction.url = e.target.value; - updatedActions[actionName] = updatedAction; - return { - ...prevApiTool, - config: { - ...prevApiTool.config, - actions: updatedActions, - }, - }; - }); - }} - borderVariant="thin" - placeholder="Enter url" - > -
+
+
+
+ + URL + + { + setApiTool((prevApiTool) => { + const updatedActions = { + ...prevApiTool.config.actions, + }; + const updatedAction = { + ...updatedActions[actionName], + }; + updatedAction.url = e.target.value; + updatedActions[actionName] = updatedAction; + return { + ...prevApiTool, + config: { + ...prevApiTool.config, + actions: updatedActions, + }, + }; + }); + }} + borderVariant="thin" + placeholder="Enter url" + >
-
-
- - Method - - { - setApiTool((prevApiTool) => { - const updatedActions = { - ...prevApiTool.config.actions, - }; - const updatedAction = { - ...updatedActions[actionName], - }; - updatedAction.method = value as - | 'GET' - | 'POST' - | 'PUT' - | 'DELETE'; - updatedActions[actionName] = updatedAction; - return { - ...prevApiTool, - config: { - ...prevApiTool.config, - actions: updatedActions, - }, - }; - }); - }} - size="w-56" - rounded="3xl" - border="border" - /> -
-
-
-
- - Description - - { - setApiTool((prevApiTool) => { - const updatedActions = { - ...prevApiTool.config.actions, - }; - const updatedAction = { - ...updatedActions[actionName], - }; - updatedAction.description = e.target.value; - updatedActions[actionName] = updatedAction; - return { - ...prevApiTool, - config: { - ...prevApiTool.config, - actions: updatedActions, - }, - }; - }); - }} - borderVariant="thin" - placeholder="Enter description" - > -
-
-
- +
+
+ + Method + + { + setApiTool((prevApiTool) => { + const updatedActions = { + ...prevApiTool.config.actions, + }; + const updatedAction = { + ...updatedActions[actionName], + }; + updatedAction.method = value as + | 'GET' + | 'POST' + | 'PUT' + | 'DELETE'; + updatedActions[actionName] = updatedAction; + return { + ...prevApiTool, + config: { + ...prevApiTool.config, + actions: updatedActions, + }, + }; + }); + }} + size="w-56" + rounded="3xl" + border="border" />
- ); - }, +
+
+ + Description + + { + setApiTool((prevApiTool) => { + const updatedActions = { + ...prevApiTool.config.actions, + }; + const updatedAction = { + ...updatedActions[actionName], + }; + updatedAction.description = e.target.value; + updatedActions[actionName] = updatedAction; + return { + ...prevApiTool, + config: { + ...prevApiTool.config, + actions: updatedActions, + }, + }; + }); + }} + borderVariant="thin" + placeholder="Enter description" + > +
+
+
+ +
+
+ ), )} + + {/* Confirmation Modal */} + {deleteModalState === 'ACTIVE' && actionToDelete && ( + { + setDeleteModalState('INACTIVE'); + setActionToDelete(null); + }} + submitLabel={t('convTile.delete')} + variant="danger" + /> + )}
); } @@ -876,7 +1023,7 @@ function APIActionTable({ ); }; return ( -
+

Headers diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx index 9c7b6906..05350e03 100644 --- a/frontend/src/settings/Tools.tsx +++ b/frontend/src/settings/Tools.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import userService from '../api/services/userService'; -import CogwheelIcon from '../assets/cogwheel.svg'; +import ThreeDotsIcon from '../assets/three-dots.svg'; import NoFilesIcon from '../assets/no-files.svg'; import NoFilesDarkIcon from '../assets/no-files-dark.svg'; import Input from '../components/Input'; @@ -15,6 +15,10 @@ import { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; import ToolConfig from './ToolConfig'; import { APIToolType, UserToolType } from './types'; +import ContextMenu, { MenuOption } from '../components/ContextMenu'; +import Edit from '../assets/edit.svg'; +import Trash from '../assets/red-trash.svg'; +import ConfirmationModal from '../modals/ConfirmationModal'; export default function Tools() { const { t } = useTranslation(); @@ -29,6 +33,57 @@ export default function Tools() { UserToolType | APIToolType | null >(null); const [loading, setLoading] = React.useState(false); + const [activeMenuId, setActiveMenuId] = React.useState(null); + const menuRefs = React.useRef<{ + [key: string]: React.RefObject; + }>({}); + const [deleteModalState, setDeleteModalState] = + React.useState('INACTIVE'); + const [toolToDelete, setToolToDelete] = React.useState( + null, + ); + + React.useEffect(() => { + userTools.forEach((tool) => { + if (!menuRefs.current[tool.id]) { + menuRefs.current[tool.id] = React.createRef(); + } + }); + }, [userTools]); + + const handleDeleteTool = (tool: UserToolType) => { + setToolToDelete(tool); + setDeleteModalState('ACTIVE'); + }; + + const confirmDeleteTool = () => { + if (toolToDelete) { + userService.deleteTool({ id: toolToDelete.id }, token).then(() => { + getUserTools(); + setDeleteModalState('INACTIVE'); + setToolToDelete(null); + }); + } + }; + + const getMenuOptions = (tool: UserToolType): MenuOption[] => [ + { + icon: Edit, + label: t('settings.tools.edit'), + onClick: () => handleSettingsClick(tool), + variant: 'primary', + iconWidth: 14, + iconHeight: 14, + }, + { + icon: Trash, + label: t('settings.tools.delete'), + onClick: () => handleDeleteTool(tool), + variant: 'danger', + iconWidth: 12, + iconHeight: 12, + }, + ]; const getUserTools = () => { setLoading(true); @@ -150,28 +205,41 @@ export default function Tools() { ) : ( userTools .filter((tool) => - tool.displayName + (tool.customName || tool.displayName) .toLowerCase() .includes(searchTerm.toLowerCase()), ) .map((tool, index) => (
- + { + setActiveMenuId(isOpen ? tool.id : null); + }} + options={getMenuOptions(tool)} + anchorRef={menuRefs.current[tool.id]} + position="top-right" + offset={{ x: 0, y: 0 }} + /> +

- {tool.displayName} + {tool.customName || tool.displayName}

{tool.description} @@ -201,7 +269,7 @@ export default function Tools() { size="small" id={`toolToggle-${index}`} ariaLabel={t('settings.tools.toggleToolAria', { - toolName: tool.displayName, + toolName: tool.customName || tool.displayName, })} />

@@ -218,6 +286,17 @@ export default function Tools() { getUserTools={getUserTools} onToolAdded={handleToolAdded} /> +
)}
diff --git a/frontend/src/settings/types/index.ts b/frontend/src/settings/types/index.ts index 0795fbb0..5ce3733f 100644 --- a/frontend/src/settings/types/index.ts +++ b/frontend/src/settings/types/index.ts @@ -41,6 +41,7 @@ export type UserToolType = { id: string; name: string; displayName: string; + customName?: string; description: string; status: boolean; config: { @@ -81,6 +82,7 @@ export type APIToolType = { id: string; name: string; displayName: string; + customName?: string; description: string; status: boolean; config: { actions: { [key: string]: APIActionType } }; diff --git a/frontend/src/utils/objectUtils.ts b/frontend/src/utils/objectUtils.ts new file mode 100644 index 00000000..45d5313d --- /dev/null +++ b/frontend/src/utils/objectUtils.ts @@ -0,0 +1,29 @@ +/** + * Deeply compares two objects for equality + * @param obj1 First object to compare + * @param obj2 Second object to compare + * @returns boolean indicating if objects are equal + */ +export function areObjectsEqual(obj1: any, obj2: any): boolean { + if (obj1 === obj2) return true; + if (obj1 == null || obj2 == null) return false; + if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return false; + + if (Array.isArray(obj1) && Array.isArray(obj2)) { + if (obj1.length !== obj2.length) return false; + return obj1.every((val, idx) => areObjectsEqual(val, obj2[idx])); + } + + if (obj1 instanceof Date && obj2 instanceof Date) { + return obj1.getTime() === obj2.getTime(); + } + + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) return false; + + return keys1.every((key) => { + return keys2.includes(key) && areObjectsEqual(obj1[key], obj2[key]); + }); +}