mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-08 22:45:25 +00:00
134
packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js
Normal file
134
packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js
Normal file
@@ -0,0 +1,134 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.workspace-header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workspace-rename-container {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
gap: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.workspace-name-input {
|
||||
padding: 0 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text.primary};
|
||||
outline: none;
|
||||
min-width: 200px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inline-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.save {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.bg.green};
|
||||
}
|
||||
}
|
||||
|
||||
&.cancel {
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.bg.red};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-error {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 16px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
}
|
||||
|
||||
.workspace-menu-dropdown {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
color: ${(props) => props.theme.text.primary};
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
border-bottom: 1px solid ${(props) => props.theme.workspace.border};
|
||||
background: ${(props) => props.theme.bg.primary};
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
color: var(--color-tab-inactive);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text.primary};
|
||||
border-bottom-color: ${(props) => props.theme.colors.border};
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-bottom-color: ${(props) => props.theme.colors.text.yellow};
|
||||
color: ${(props) => props.theme.tabs.active.color};
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-action-buttons {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.workspace-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
color: ${(props) => props.theme.text.primary};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,154 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.collections-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
|
||||
.collections-header {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: ${(props) => props.theme.workspace.collection.header.indentBorder};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
|
||||
&:has(.header-git) {
|
||||
grid-template-columns: 1fr 3fr 1fr 1.5fr;
|
||||
}
|
||||
|
||||
&:not(:has(.header-git)) {
|
||||
grid-template-columns: 1fr 3fr 1.5fr;
|
||||
}
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
font-weight: 600;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collections-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.collection-row {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: ${(props) => props.theme.workspace.collection.item.indentBorder};
|
||||
transition: background-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
grid-template-columns: 1fr 3fr 1.5fr;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.sidebar.bg};
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.row-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cell-name {
|
||||
.collection-icon {
|
||||
color: ${(props) => props.theme.workspace.accent};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collection-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.collection-name {
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.collection-subtitle {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.text.muted};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-location {
|
||||
.location-text {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text.muted};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
justify-content: flex-end;
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
color: ${(props) => props.theme.text.muted};
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${(props) => props.theme.listItem.hoverBg};
|
||||
|
||||
&.action-edit {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.action-share {
|
||||
color: #3B82F6;
|
||||
}
|
||||
|
||||
&.action-delete {
|
||||
color: #EF4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,343 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconBox, IconTrash, IconEdit, IconShare } from '@tabler/icons';
|
||||
import { removeCollectionFromWorkspaceAction, importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import RenameCollection from 'components/Sidebar/Collections/Collection/RenameCollection';
|
||||
import ShareCollection from 'components/ShareCollection';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
const WorkspaceCollections = ({ workspace, onImportCollection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { collections } = useSelector((state) => state.collections);
|
||||
const [collectionToRemove, setCollectionToRemove] = useState(null);
|
||||
const [renameCollectionModalOpen, setRenameCollectionModalOpen] = useState(false);
|
||||
const [shareCollectionModalOpen, setShareCollectionModalOpen] = useState(false);
|
||||
const [selectedCollectionUid, setSelectedCollectionUid] = useState(null);
|
||||
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
|
||||
const handleImportCollection = ({ rawData, type }) => {
|
||||
if (onImportCollection) {
|
||||
onImportCollection();
|
||||
return;
|
||||
}
|
||||
|
||||
setImportCollectionModalOpen(false);
|
||||
dispatch(importCollectionInWorkspace(rawData, workspace.uid, undefined, type))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
};
|
||||
|
||||
const workspaceCollections = React.useMemo(() => {
|
||||
if (!workspace.collections || workspace.collections.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
workspace.collections.forEach((wc) => {
|
||||
const loadedCollection = collections.find((c) => c.pathname === wc.path);
|
||||
|
||||
if (loadedCollection) {
|
||||
result.push({
|
||||
...loadedCollection,
|
||||
isGitBacked: !!wc.remote,
|
||||
gitRemoteUrl: wc.remote
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
uid: `unloaded-${wc.path}`,
|
||||
name: wc.name,
|
||||
pathname: wc.path,
|
||||
items: [],
|
||||
environments: [],
|
||||
isGitBacked: !!wc.remote,
|
||||
isLoaded: false,
|
||||
gitRemoteUrl: wc.remote,
|
||||
git: { gitRootPath: null },
|
||||
brunoConfig: {},
|
||||
root: {
|
||||
request: {
|
||||
headers: [],
|
||||
auth: { mode: 'none' },
|
||||
vars: { req: [], res: [] },
|
||||
script: { req: '', res: '' },
|
||||
tests: ''
|
||||
},
|
||||
docs: ''
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [workspace.collections, collections, workspace.pathname]);
|
||||
|
||||
const handleOpenCollectionClick = (collection, event) => {
|
||||
if (event.target.closest('.action-buttons')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (collection.isLoaded === false) {
|
||||
if (collection.isGitBacked) {
|
||||
toast.error(`Collection "${collection.name}" needs to be cloned first`);
|
||||
} else {
|
||||
toast.error(`Collection "${collection.name}" does not exist on disk`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(mountCollection({
|
||||
collectionUid: collection.uid,
|
||||
collectionPathname: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig
|
||||
}));
|
||||
|
||||
dispatch(hideHomePage());
|
||||
|
||||
dispatch(addTab({
|
||||
uid: collection.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'collection-settings'
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRenameCollection = (collection) => {
|
||||
if (collection.isLoaded === false) {
|
||||
toast.error('Cannot rename collections that are not cloned yet');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCollectionUid(collection.uid);
|
||||
setRenameCollectionModalOpen(true);
|
||||
};
|
||||
|
||||
const handleShareCollection = (collection) => {
|
||||
if (collection.isLoaded === false) {
|
||||
toast.error('Please clone this collection first before sharing it');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(mountCollection({
|
||||
collectionUid: collection.uid,
|
||||
collectionPathname: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig
|
||||
}));
|
||||
|
||||
setSelectedCollectionUid(collection.uid);
|
||||
setShareCollectionModalOpen(true);
|
||||
};
|
||||
|
||||
const handleRemoveCollection = (collection) => {
|
||||
setCollectionToRemove(collection);
|
||||
};
|
||||
|
||||
const confirmRemoveCollection = async () => {
|
||||
if (!collectionToRemove) return;
|
||||
|
||||
try {
|
||||
await dispatch(removeCollectionFromWorkspaceAction(workspace.uid, collectionToRemove.pathname));
|
||||
|
||||
const collectionInfo = getCollectionWorkspaceInfo(collectionToRemove);
|
||||
|
||||
if (collectionInfo.isLoaded && !collectionInfo.isGitBacked) {
|
||||
toast.success(`Deleted "${collectionToRemove.name}" collection`);
|
||||
} else if (collectionInfo.isGitBacked) {
|
||||
toast.success(`Removed git-backed collection "${collectionToRemove.name}" from workspace`);
|
||||
} else {
|
||||
toast.success(`Removed "${collectionToRemove.name}" from workspace`);
|
||||
}
|
||||
|
||||
setCollectionToRemove(null);
|
||||
} catch (error) {
|
||||
console.error('Error removing collection:', error);
|
||||
toast.error(error.message || 'Failed to remove collection from workspace');
|
||||
}
|
||||
};
|
||||
|
||||
const getCollectionWorkspaceInfo = (collection) => {
|
||||
if (collection.hasOwnProperty('isGitBacked')) {
|
||||
return {
|
||||
isGitBacked: collection.isGitBacked,
|
||||
gitRemoteUrl: collection.gitRemoteUrl,
|
||||
isLoaded: collection.isLoaded !== false
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceCollection = workspace.collections?.find((wc) => {
|
||||
return collection.pathname === wc.path;
|
||||
});
|
||||
|
||||
return {
|
||||
isGitBacked: !!workspaceCollection?.remote,
|
||||
gitRemoteUrl: workspaceCollection?.remote,
|
||||
isLoaded: true
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full">
|
||||
<div className="w-full h-full">
|
||||
|
||||
{createCollectionModalOpen && (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{importCollectionModalOpen && (
|
||||
<ImportCollection
|
||||
onClose={() => setImportCollectionModalOpen(false)}
|
||||
handleSubmit={handleImportCollection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{renameCollectionModalOpen && selectedCollectionUid && (
|
||||
<RenameCollection
|
||||
collectionUid={selectedCollectionUid}
|
||||
onClose={() => {
|
||||
setRenameCollectionModalOpen(false);
|
||||
setSelectedCollectionUid(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shareCollectionModalOpen && selectedCollectionUid && (
|
||||
<ShareCollection
|
||||
collectionUid={selectedCollectionUid}
|
||||
onClose={() => {
|
||||
setShareCollectionModalOpen(false);
|
||||
setSelectedCollectionUid(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{collectionToRemove && (
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Delete Collection"
|
||||
handleCancel={() => setCollectionToRemove(null)}
|
||||
handleConfirm={confirmRemoveCollection}
|
||||
confirmText="Delete Collection"
|
||||
cancelText="Cancel"
|
||||
style="new"
|
||||
>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Are you sure you want to delete <strong>"{collectionToRemove.name}"</strong>?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-3">
|
||||
{(() => {
|
||||
const collectionInfo = getCollectionWorkspaceInfo(collectionToRemove);
|
||||
|
||||
if (collectionInfo.isGitBacked) {
|
||||
return 'This will remove the git-backed collection reference from workspace.yml. Local files (if any) will not be deleted.';
|
||||
} else {
|
||||
return 'This will permanently delete the collection files from the workspace collections folder.';
|
||||
}
|
||||
})()}
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<div className="h-full overflow-auto">
|
||||
{workspaceCollections.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-full mb-4">
|
||||
<IconBox size={32} stroke={1.5} className="text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No collections yet</h3>
|
||||
<p className="text-muted mb-4">
|
||||
Create your first collection or open an existing one to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="collections-table">
|
||||
<div className="collections-header">
|
||||
<div className="header-cell header-name">Collection</div>
|
||||
<div className="header-cell header-location">Location</div>
|
||||
<div className="header-cell flex justify-end">Actions</div>
|
||||
</div>
|
||||
|
||||
<div className="collections-body">
|
||||
{workspaceCollections.map((collection, index) => {
|
||||
return (
|
||||
<div
|
||||
key={collection.uid || index}
|
||||
className="collection-row"
|
||||
onClick={(e) => handleOpenCollectionClick(collection, e)}
|
||||
>
|
||||
<div className="row-cell cell-name">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconBox size={16} stroke={1.5} className="collection-icon" />
|
||||
<div className="collection-info">
|
||||
<div className="collection-name">{collection.name}</div>
|
||||
{collection.brunoConfig?.name && collection.brunoConfig.name !== collection.name && (
|
||||
<div className="collection-subtitle">{collection.brunoConfig.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row-cell cell-location">
|
||||
<div className="location-text" title={collection.pathname}>
|
||||
{collection.pathname}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row-cell cell-actions">
|
||||
<div className="action-buttons">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRenameCollection(collection);
|
||||
}}
|
||||
className="action-btn action-edit"
|
||||
title="Rename collection"
|
||||
>
|
||||
<IconEdit size={16} stroke={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleShareCollection(collection);
|
||||
}}
|
||||
className="action-btn action-share"
|
||||
title="Share collection"
|
||||
>
|
||||
<IconShare size={16} stroke={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveCollection(collection);
|
||||
}}
|
||||
className="action-btn action-delete"
|
||||
title="Remove from workspace"
|
||||
>
|
||||
<IconTrash size={16} stroke={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceCollections;
|
||||
@@ -0,0 +1,9 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.editing-mode {
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'github-markdown-css/github-markdown.css';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { saveWorkspaceDocs } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import Markdown from 'components/MarkDown';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const WorkspaceDocs = ({ workspace }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localDocs, setLocalDocs] = useState(workspace?.docs || '');
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalDocs(workspace?.docs || '');
|
||||
setIsEditing(false);
|
||||
}, [workspace?.uid, workspace?.docs]);
|
||||
|
||||
const toggleViewMode = () => {
|
||||
setIsEditing((prev) => !prev);
|
||||
};
|
||||
|
||||
const onEdit = (value) => {
|
||||
setLocalDocs(value);
|
||||
};
|
||||
|
||||
const handleDiscardChanges = () => {
|
||||
setLocalDocs(workspace?.docs || '');
|
||||
toggleViewMode();
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
if (!workspace) {
|
||||
toast.error('Workspace not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(saveWorkspaceDocs(workspace.uid, localDocs));
|
||||
toast.success('Documentation saved successfully');
|
||||
toggleViewMode();
|
||||
} catch (error) {
|
||||
console.error('Error saving workspace docs:', error);
|
||||
toast.error('Failed to save documentation');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full w-full relative flex flex-col p-4">
|
||||
<div className="flex flex-row w-full justify-between items-center mb-4">
|
||||
<div className="text-lg font-medium flex items-center gap-2">
|
||||
<IconFileText size={20} strokeWidth={1.5} />
|
||||
Workspace Documentation
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 items-center justify-center">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div className="editing-mode" role="tab" onClick={handleDiscardChanges}>
|
||||
<IconX className="cursor-pointer" size={20} strokeWidth={1.5} />
|
||||
</div>
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={onSave}>
|
||||
Save
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
|
||||
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={localDocs}
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
mode="markdown"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-auto pl-1">
|
||||
<div className="h-[1px] min-h-[500px]">
|
||||
{
|
||||
localDocs?.length > 0
|
||||
? <Markdown onDoubleClick={toggleViewMode} content={localDocs} />
|
||||
: <Markdown onDoubleClick={toggleViewMode} content={workspaceDocumentationPlaceholder} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceDocs;
|
||||
|
||||
const workspaceDocumentationPlaceholder = `
|
||||
# Welcome to your Workspace Documentation
|
||||
|
||||
This is your workspace documentation area where you can document your entire project, team guidelines, and shared resources.
|
||||
|
||||
## What to Document Here
|
||||
|
||||
### Project Overview
|
||||
- Project goals and objectives
|
||||
- Architecture overview
|
||||
- Key stakeholders and team members
|
||||
- Project timeline and milestones
|
||||
|
||||
### Development Guidelines
|
||||
- Coding standards and conventions
|
||||
- Git workflow and branching strategy
|
||||
- Code review process
|
||||
- Testing guidelines
|
||||
|
||||
### API Documentation
|
||||
- Authentication methods
|
||||
- Base URLs and environments
|
||||
- Common headers and parameters
|
||||
- Error handling standards
|
||||
|
||||
### Team Resources
|
||||
- Useful links and references
|
||||
- Development environment setup
|
||||
- Deployment procedures
|
||||
- Troubleshooting guides
|
||||
|
||||
## Markdown Support
|
||||
|
||||
This documentation supports full Markdown formatting:
|
||||
|
||||
- **Bold** and *italic* text
|
||||
- \`inline code\` and code blocks
|
||||
- Lists and tables
|
||||
- [Links](https://usebruno.com) and images
|
||||
- Headers and sections
|
||||
|
||||
**Tip:** Double-click anywhere in this area to start editing!
|
||||
`;
|
||||
@@ -0,0 +1,78 @@
|
||||
import Modal from 'components/Modal/index';
|
||||
import Portal from 'components/Portal/index';
|
||||
import { useFormik } from 'formik';
|
||||
import { copyGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const CopyEnvironment = ({ environment, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const inputRef = useRef();
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
name: environment.name + ' - Copy'
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string()
|
||||
.min(1, 'must be at least 1 character')
|
||||
.max(50, 'must be 50 characters or less')
|
||||
.required('name is required')
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
dispatch(copyGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
|
||||
.then(() => {
|
||||
toast.success('Environment created!');
|
||||
onClose();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error occurred while creating the environment');
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const onSubmit = () => {
|
||||
formik.handleSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="sm" title="Copy Environment" confirmText="Copy" handleConfirm={onSubmit} handleCancel={onClose}>
|
||||
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<label htmlFor="environment-name" className="block font-semibold">
|
||||
New Environment Name
|
||||
</label>
|
||||
<input
|
||||
id="environment-name"
|
||||
type="text"
|
||||
name="name"
|
||||
ref={inputRef}
|
||||
className="block textbox mt-2 w-full"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.name || ''}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name ? (
|
||||
<div className="text-red-500">{formik.errors.name}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyEnvironment;
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
|
||||
const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => {
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
|
||||
const validateEnvironmentName = (name) => {
|
||||
const trimmedName = name?.toLowerCase().trim();
|
||||
return (globalEnvs || []).every((env) => env?.name?.toLowerCase().trim() !== trimmedName);
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const inputRef = useRef();
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
name: ''
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string()
|
||||
.min(1, 'Must be at least 1 character')
|
||||
.max(255, 'Must be 255 characters or less')
|
||||
.test('is-valid-filename', function (value) {
|
||||
const isValid = validateName(value);
|
||||
return isValid ? true : this.createError({ message: validateNameError(value) });
|
||||
})
|
||||
.required('Name is required')
|
||||
.test('duplicate-name', 'Environment already exists', validateEnvironmentName)
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
dispatch(addGlobalEnvironment({ name: values.name }))
|
||||
.then(() => {
|
||||
toast.success('Environment created!');
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while creating the environment'));
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const onSubmit = () => {
|
||||
formik.handleSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Create Environment"
|
||||
confirmText="Create"
|
||||
handleConfirm={onSubmit}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block font-semibold">
|
||||
Environment Name
|
||||
</label>
|
||||
<div className="flex items-center mt-2">
|
||||
<input
|
||||
id="environment-name"
|
||||
type="text"
|
||||
name="name"
|
||||
ref={inputRef}
|
||||
className="block textbox w-full"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.name || ''}
|
||||
/>
|
||||
</div>
|
||||
{formik.touched.name && formik.errors.name ? (
|
||||
<div className="text-red-500">{formik.errors.name}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateEnvironment;
|
||||
@@ -0,0 +1,15 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
button.submit {
|
||||
color: white;
|
||||
background-color: var(--color-background-danger) !important;
|
||||
border: inherit !important;
|
||||
|
||||
&:hover {
|
||||
border: inherit !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal/index';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal/index';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { deleteGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
|
||||
const DeleteEnvironment = ({ onClose, environment }) => {
|
||||
const dispatch = useDispatch();
|
||||
const onConfirm = () => {
|
||||
dispatch(deleteGlobalEnvironment({ environmentUid: environment.uid }))
|
||||
.then(() => {
|
||||
toast.success('Environment deleted successfully');
|
||||
onClose();
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while deleting the environment'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Delete Environment"
|
||||
confirmText="Delete"
|
||||
handleConfirm={onConfirm}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
Are you sure you want to delete <span className="font-semibold">{environment.name}</span> ?
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteEnvironment;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { IconAlertTriangle } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const ConfirmSwitchEnv = ({ onCancel }) => {
|
||||
const modalContent = (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Unsaved changes"
|
||||
disableEscapeKey={true}
|
||||
disableCloseOnOutsideClick={true}
|
||||
closeModalFadeTimeout={150}
|
||||
handleCancel={onCancel}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
hideFooter={true}
|
||||
>
|
||||
<div className="flex items-center font-normal">
|
||||
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<h1 className="ml-2 text-lg font-semibold">Hold on..</h1>
|
||||
</div>
|
||||
<div className="font-normal mt-4">You have unsaved changes in this environment.</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<div>
|
||||
<button className="btn btn-sm btn-danger" onClick={onCancel}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
};
|
||||
|
||||
export default ConfirmSwitchEnv;
|
||||
@@ -0,0 +1,195 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.table-container {
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
border: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
|
||||
thead,
|
||||
td {
|
||||
padding: 4px 12px;
|
||||
|
||||
&:nth-child(1),
|
||||
&:nth-child(4) {
|
||||
width: 80px;
|
||||
}
|
||||
&:nth-child(5) {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
user-select: none;
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
font-weight: 600;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
td {
|
||||
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-add-param {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.textLink};
|
||||
font-weight: 500;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 6px;
|
||||
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
background: transparent;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.listItem.hoverBg};
|
||||
border-color: ${(props) => props.theme.textLink};
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-mod {
|
||||
font-size: 11px !important;
|
||||
max-width: 200px !important;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
outline: none !important;
|
||||
background-color: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: ${(props) => props.theme.workspace.accent};
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
padding: 12px 0;
|
||||
background: ${(props) => props.theme.bg};
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.submit {
|
||||
padding: 7px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: ${(props) => props.theme.workspace.accent};
|
||||
color: ${(props) => props.theme.bg};
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.reset {
|
||||
background: transparent;
|
||||
padding: 6px 16px;
|
||||
border: 1px solid ${(props) => props.theme.workspace.accent};
|
||||
color: ${(props) => props.theme.workspace.accent};
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.discard {
|
||||
padding: 7px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,344 @@
|
||||
import React from 'react';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections';
|
||||
|
||||
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
let _collection = collection ? cloneDeep(collection) : {};
|
||||
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
if (_collection) {
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
}
|
||||
|
||||
const initialValues = React.useMemo(() => {
|
||||
const vars = environment.variables || [];
|
||||
return [
|
||||
...vars,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
}, [environment.uid, environment.variables]);
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: initialValues,
|
||||
validationSchema: Yup.array().of(Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
name: Yup.string()
|
||||
.when('$isLastRow', {
|
||||
is: true,
|
||||
then: (schema) => schema.optional(),
|
||||
otherwise: (schema) => schema
|
||||
.required('Name cannot be empty')
|
||||
.matches(variableNameRegex,
|
||||
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.')
|
||||
.trim()
|
||||
}),
|
||||
secret: Yup.boolean(),
|
||||
type: Yup.string(),
|
||||
uid: Yup.string(),
|
||||
value: Yup.mixed().nullable()
|
||||
})),
|
||||
validate: (values) => {
|
||||
const errors = {};
|
||||
values.forEach((variable, index) => {
|
||||
const isLastRow = index === values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
|
||||
// Skip validation for the last empty row
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate name for non-empty rows
|
||||
if (!variable.name || variable.name.trim() === '') {
|
||||
if (!errors[index]) errors[index] = {};
|
||||
errors[index].name = 'Name cannot be empty';
|
||||
} else if (!variableNameRegex.test(variable.name)) {
|
||||
if (!errors[index]) errors[index] = {};
|
||||
errors[index].name = 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
|
||||
}
|
||||
});
|
||||
return Object.keys(errors).length > 0 ? errors : {};
|
||||
},
|
||||
onSubmit: () => {}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const savedValues = environment.variables || [];
|
||||
|
||||
const hasActualChanges = JSON.stringify(currentValues) !== JSON.stringify(savedValues);
|
||||
|
||||
setIsModified(hasActualChanges);
|
||||
}, [formik.values, environment.variables, setIsModified]);
|
||||
|
||||
const ErrorMessage = ({ name, index }) => {
|
||||
const meta = formik.getFieldMeta(name);
|
||||
const id = `error-${name}-${index}`;
|
||||
|
||||
// Don't show error for the last empty row
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const variable = formik.values[index];
|
||||
const isEmptyRow = !variable?.name || variable.name.trim() === '';
|
||||
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!meta.error || !meta.touched) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
|
||||
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveVar = (id) => {
|
||||
const filteredValues = formik.values.filter((variable) => variable.uid !== id);
|
||||
|
||||
const lastRow = formik.values[formik.values.length - 1];
|
||||
const isLastEmptyRow = lastRow.uid === id && (!lastRow.name || lastRow.name.trim() === '');
|
||||
|
||||
if (isLastEmptyRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasEmptyLastRow = filteredValues.length > 0
|
||||
&& (!filteredValues[filteredValues.length - 1].name
|
||||
|| filteredValues[filteredValues.length - 1].name.trim() === '');
|
||||
|
||||
if (!hasEmptyLastRow) {
|
||||
filteredValues.push({
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
});
|
||||
}
|
||||
|
||||
formik.setValues(filteredValues);
|
||||
};
|
||||
|
||||
const handleNameChange = (index, e) => {
|
||||
formik.handleChange(e);
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
|
||||
// If typing in the last row, add a new empty row
|
||||
if (isLastRow) {
|
||||
const newVariable = {
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
};
|
||||
// Use setTimeout to ensure the change is processed first
|
||||
setTimeout(() => {
|
||||
formik.setFieldValue(formik.values.length, newVariable, false);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
|
||||
const hasValidationErrors = variablesToSave.some((variable) => {
|
||||
if (!variable.name || variable.name.trim() === '') {
|
||||
return true;
|
||||
}
|
||||
if (!variableNameRegex.test(variable.name)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasValidationErrors) {
|
||||
toast.error('Please fix validation errors before saving');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(variablesToSave) }))
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
const newValues = [
|
||||
...variablesToSave,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
formik.resetForm({ values: newValues });
|
||||
setIsModified(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const originalVars = environment.variables || [];
|
||||
const resetValues = [
|
||||
...originalVars,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
formik.resetForm({ values: resetValues });
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td className="text-center">Enabled</td>
|
||||
<td>Name</td>
|
||||
<td>Value</td>
|
||||
<td className="text-center">Secret</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formik.values.map((variable, index) => {
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
|
||||
return (
|
||||
<tr key={variable.uid}>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${index}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="mousetrap"
|
||||
id={`${index}.name`}
|
||||
name={`${index}.name`}
|
||||
value={variable.name}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
onChange={(e) => handleNameChange(index, e)}
|
||||
/>
|
||||
<ErrorMessage name={`${index}.name`} index={index} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap items-center">
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${index}.value`}
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
|
||||
/>
|
||||
</div>
|
||||
{typeof variable.value !== 'string' && (
|
||||
<span className="ml-2 flex items-center">
|
||||
<IconInfoCircle
|
||||
id={`${variable.uid}-disabled-info-icon`}
|
||||
className="text-muted"
|
||||
size={16}
|
||||
/>
|
||||
<Tooltip
|
||||
anchorId={`${variable.uid}-disabled-info-icon`}
|
||||
content="Non-string values set via scripts are read-only and can only be updated through scripts."
|
||||
place="top"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${index}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={() => handleRemoveVar(variable.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="button-container">
|
||||
<div className="flex items-center">
|
||||
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="submit reset ml-2" onClick={handleReset} data-testid="reset-env">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
export default EnvironmentVariables;
|
||||
@@ -0,0 +1,316 @@
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import styled from 'styled-components';
|
||||
import CopyEnvironment from '../../CopyEnvironment';
|
||||
import DeleteEnvironment from '../../DeleteEnvironment';
|
||||
import EnvironmentVariables from './EnvironmentVariables';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: ${(props) => props.theme.bg};
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 12px 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
|
||||
&.renaming {
|
||||
.title-input {
|
||||
flex: 1;
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
outline: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.inline-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&.save {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
&.cancel {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title-error {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => `${props.theme.colors.text.danger}15`};
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:last-child:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
|
||||
const [openDeleteModal, setOpenDeleteModal] = useState(false);
|
||||
const [openCopyModal, setOpenCopyModal] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const validateEnvironmentName = (name) => {
|
||||
if (!name || name.trim() === '') {
|
||||
return 'Name is required';
|
||||
}
|
||||
|
||||
if (name.length < 1) {
|
||||
return 'Must be at least 1 character';
|
||||
}
|
||||
|
||||
if (name.length > 255) {
|
||||
return 'Must be 255 characters or less';
|
||||
}
|
||||
|
||||
if (!validateName(name)) {
|
||||
return validateNameError(name);
|
||||
}
|
||||
|
||||
const trimmedName = name.toLowerCase().trim();
|
||||
const isDuplicate = (globalEnvs || []).some((env) =>
|
||||
env?.uid !== environment.uid && env?.name?.toLowerCase().trim() === trimmedName);
|
||||
if (isDuplicate) {
|
||||
return 'Environment already exists';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleRenameClick = () => {
|
||||
setIsRenaming(true);
|
||||
setNewName(environment.name);
|
||||
setNameError('');
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleSaveRename = () => {
|
||||
const error = validateEnvironmentName(newName);
|
||||
if (error) {
|
||||
setNameError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(renameGlobalEnvironment({ name: newName, environmentUid: environment.uid }))
|
||||
.then(() => {
|
||||
toast.success('Environment renamed!');
|
||||
setIsRenaming(false);
|
||||
setNewName('');
|
||||
setNameError('');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while renaming the environment');
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
setIsRenaming(false);
|
||||
setNewName('');
|
||||
setNameError('');
|
||||
};
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
setNewName(e.target.value);
|
||||
if (nameError) {
|
||||
setNameError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameBlur = () => {
|
||||
if (newName.trim() === '') {
|
||||
handleCancelRename();
|
||||
} else {
|
||||
const error = validateEnvironmentName(newName);
|
||||
if (error) {
|
||||
setNameError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSaveRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelRename();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openDeleteModal && (
|
||||
<DeleteEnvironment
|
||||
onClose={() => setOpenDeleteModal(false)}
|
||||
environment={environment}
|
||||
/>
|
||||
)}
|
||||
{openCopyModal && (
|
||||
<CopyEnvironment onClose={() => setOpenCopyModal(false)} environment={environment} />
|
||||
)}
|
||||
|
||||
<div className="header">
|
||||
<div className={`title-container ${isRenaming ? 'renaming' : ''}`}>
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="title-input"
|
||||
value={newName}
|
||||
onChange={handleNameChange}
|
||||
onBlur={handleNameBlur}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<h2 className="title">{environment.name}</h2>
|
||||
)}
|
||||
</div>
|
||||
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
|
||||
<div className="actions">
|
||||
<button onClick={handleRenameClick} title="Rename">
|
||||
<IconEdit size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button onClick={() => setOpenCopyModal(true)} title="Copy">
|
||||
<IconCopy size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button onClick={() => setOpenDeleteModal(true)} title="Delete">
|
||||
<IconTrash size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvironmentDetails;
|
||||
@@ -0,0 +1,280 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background-color: ${(props) => props.theme.bg};
|
||||
position: relative;
|
||||
|
||||
.environments-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.confirm-switch-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
}
|
||||
|
||||
/* Left Sidebar */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 12px 16px;
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
padding: 0 12px 12px 12px;
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-100%);
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px 6px 28px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
border-radius: 5px;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.environments-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.environment-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 1px;
|
||||
font-size: 13px;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
.environment-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.environment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
.activate-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.text.muted};
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.workspace.button.bg};
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
}
|
||||
|
||||
.activated-checkmark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .environment-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.activated .environment-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.workspace.button.bg};
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.workspace.environments.activeBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.renaming,
|
||||
&.creating {
|
||||
cursor: default;
|
||||
padding: 4px 4px 4px 8px;
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.workspace.button.bg};
|
||||
}
|
||||
}
|
||||
|
||||
.rename-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
|
||||
.environment-name-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 13px;
|
||||
padding: 2px 4px;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&.creating {
|
||||
.environment-name-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 13px;
|
||||
padding: 2px 4px;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.inline-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&.save {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.listItem.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
&.cancel {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.listItem.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.env-error {
|
||||
padding: 4px 12px;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => `${props.theme.colors.text.danger}15`};
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,426 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import usePrevious from 'hooks/usePrevious';
|
||||
import EnvironmentDetails from './EnvironmentDetails';
|
||||
import CreateEnvironment from '../CreateEnvironment';
|
||||
import { IconDownload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
|
||||
import ImportEnvironment from '../ImportEnvironment';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isCreatingInline, setIsCreatingInline] = useState(false);
|
||||
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
|
||||
const [newEnvName, setNewEnvName] = useState('');
|
||||
const [envNameError, setEnvNameError] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
const renameContainerRef = useRef(null);
|
||||
const createContainerRef = useRef(null);
|
||||
|
||||
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
|
||||
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
|
||||
|
||||
const envUids = environments ? environments.map((env) => env.uid) : [];
|
||||
const prevEnvUids = usePrevious(envUids);
|
||||
|
||||
useEffect(() => {
|
||||
if (!environments?.length) {
|
||||
setSelectedEnvironment(null);
|
||||
setOriginalEnvironmentVariables([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEnvironment) {
|
||||
let _selectedEnvironment = environments?.find((env) => env?.uid === selectedEnvironment?.uid);
|
||||
|
||||
if (!_selectedEnvironment) {
|
||||
_selectedEnvironment = environments?.find((env) => env?.name === selectedEnvironment?.name);
|
||||
}
|
||||
|
||||
if (!_selectedEnvironment) {
|
||||
_selectedEnvironment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0];
|
||||
}
|
||||
|
||||
const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment);
|
||||
if (hasSelectedEnvironmentChanged || selectedEnvironment.uid !== _selectedEnvironment?.uid) {
|
||||
setSelectedEnvironment(_selectedEnvironment);
|
||||
}
|
||||
setOriginalEnvironmentVariables(_selectedEnvironment?.variables || []);
|
||||
return;
|
||||
}
|
||||
|
||||
const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0];
|
||||
|
||||
setSelectedEnvironment(environment);
|
||||
setOriginalEnvironmentVariables(environment?.variables || []);
|
||||
}, [environments, activeEnvironmentUid, selectedEnvironment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) {
|
||||
const newEnv = environments.find((env) => !prevEnvUids.includes(env.uid));
|
||||
if (newEnv) {
|
||||
setSelectedEnvironment(newEnv);
|
||||
}
|
||||
}
|
||||
|
||||
if (prevEnvUids && prevEnvUids.length && envUids.length < prevEnvUids.length) {
|
||||
setSelectedEnvironment(environments && environments.length ? environments[0] : null);
|
||||
}
|
||||
}, [envUids, environments, prevEnvUids]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingEnvUid) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (renameContainerRef.current && !renameContainerRef.current.contains(event.target)) {
|
||||
handleCancelRename();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [renamingEnvUid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreatingInline) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (createContainerRef.current && !createContainerRef.current.contains(event.target)) {
|
||||
handleCancelCreate();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isCreatingInline]);
|
||||
|
||||
const handleEnvironmentClick = (env) => {
|
||||
if (!isModified) {
|
||||
setSelectedEnvironment(env);
|
||||
} else {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnvironmentDoubleClick = (env) => {
|
||||
setRenamingEnvUid(env.uid);
|
||||
setNewEnvName(env.name);
|
||||
setEnvNameError('');
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleActivateEnvironment = (e, env) => {
|
||||
e.stopPropagation();
|
||||
dispatch(selectGlobalEnvironment({ environmentUid: env.uid }))
|
||||
.then(() => {
|
||||
toast.success(`Environment "${env.name}" activated`);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to activate environment');
|
||||
});
|
||||
};
|
||||
|
||||
if (!selectedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validateEnvironmentName = (name, excludeUid = null) => {
|
||||
if (!name || name.trim() === '') {
|
||||
return 'Name is required';
|
||||
}
|
||||
|
||||
if (!validateName(name)) {
|
||||
return validateNameError(name);
|
||||
}
|
||||
|
||||
const trimmedName = name.toLowerCase().trim();
|
||||
const isDuplicate = globalEnvs.some((env) =>
|
||||
env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName);
|
||||
if (isDuplicate) {
|
||||
return 'Environment already exists';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCreateEnvClick = () => {
|
||||
if (!isModified) {
|
||||
setIsCreatingInline(true);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
} else {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelCreate = () => {
|
||||
setIsCreatingInline(false);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
};
|
||||
|
||||
const handleSaveNewEnv = () => {
|
||||
const error = validateEnvironmentName(newEnvName);
|
||||
if (error) {
|
||||
setEnvNameError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(addGlobalEnvironment({ name: newEnvName }))
|
||||
.then(() => {
|
||||
toast.success('Environment created!');
|
||||
setIsCreatingInline(false);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while creating the environment');
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnvNameChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setNewEnvName(value);
|
||||
|
||||
if (envNameError) {
|
||||
setEnvNameError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnvNameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (renamingEnvUid) {
|
||||
handleSaveRename();
|
||||
} else {
|
||||
handleSaveNewEnv();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (renamingEnvUid) {
|
||||
handleCancelRename();
|
||||
} else {
|
||||
handleCancelCreate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRename = () => {
|
||||
const error = validateEnvironmentName(newEnvName, renamingEnvUid);
|
||||
if (error) {
|
||||
setEnvNameError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(renameGlobalEnvironment({ name: newEnvName, environmentUid: renamingEnvUid }))
|
||||
.then(() => {
|
||||
toast.success('Environment renamed!');
|
||||
setRenamingEnvUid(null);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while renaming the environment');
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
setRenamingEnvUid(null);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
if (!isModified) {
|
||||
setOpenImportModal(true);
|
||||
} else {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSwitch = (saveChanges) => {
|
||||
if (!saveChanges) {
|
||||
setSwitchEnvConfirmClose(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEnvironments = environments?.filter((env) =>
|
||||
env.name.toLowerCase().includes(searchText.toLowerCase())) || [];
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
|
||||
{openImportModal && <ImportEnvironment onClose={() => setOpenImportModal(false)} />}
|
||||
|
||||
<div className="environments-container">
|
||||
{switchEnvConfirmClose && (
|
||||
<div className="confirm-switch-overlay">
|
||||
<ConfirmSwitchEnv onCancel={() => handleConfirmSwitch(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left Sidebar */}
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2 className="title">Environments</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button className="btn-action" onClick={() => handleImportClick()} title="Import environment">
|
||||
<IconDownload size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-container">
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
key={env.uid}
|
||||
id={env.uid}
|
||||
className={`environment-item ${selectedEnvironment.uid === env.uid ? 'active' : ''} ${renamingEnvUid === env.uid ? 'renaming' : ''} ${activeEnvironmentUid === env.uid ? 'activated' : ''}`}
|
||||
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
|
||||
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
|
||||
>
|
||||
{renamingEnvUid === env.uid ? (
|
||||
<div className="rename-container" ref={renameContainerRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="environment-name">{env.name}</span>
|
||||
<div className="environment-actions">
|
||||
{activeEnvironmentUid === env.uid ? (
|
||||
<div className="activated-checkmark" title="Active environment">
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="activate-btn"
|
||||
onClick={(e) => handleActivateEnvironment(e, env)}
|
||||
title="Activate environment"
|
||||
>
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isCreatingInline && (
|
||||
<div className="environment-item creating" ref={createContainerRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
placeholder="Environment name..."
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveNewEnv}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelCreate}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{envNameError && (isCreatingInline || renamingEnvUid) && (
|
||||
<div className="env-error">{envNameError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Content */}
|
||||
<EnvironmentDetails
|
||||
environment={selectedEnvironment}
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvironmentList;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import importPostmanEnvironment from 'utils/importers/postman-environment';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { IconDatabaseImport } from '@tabler/icons';
|
||||
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
|
||||
const ImportEnvironment = ({ onClose, onEnvironmentCreated }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleImportPostmanEnvironment = () => {
|
||||
importPostmanEnvironment()
|
||||
.then((environments) => {
|
||||
const importPromises = environments
|
||||
.filter((env) =>
|
||||
env.name && env.name !== 'undefined')
|
||||
.map((environment) =>
|
||||
dispatch(addGlobalEnvironment({ name: environment.name, variables: environment.variables }))
|
||||
.then(() => {
|
||||
toast.success('Environment imported successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error occurred while importing the environment');
|
||||
console.error(error);
|
||||
}));
|
||||
return Promise.all(importPromises);
|
||||
})
|
||||
.then(() => {
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch((err) => toastError(err, 'Postman Import environment failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-environment-modal">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportPostmanEnvironment}
|
||||
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
|
||||
data-testid="import-postman-environment"
|
||||
>
|
||||
<IconDatabaseImport size={64} />
|
||||
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
|
||||
</button>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportEnvironment;
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Portal from 'components/Portal/index';
|
||||
import Modal from 'components/Modal/index';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
const RenameEnvironment = ({ onClose, environment }) => {
|
||||
const dispatch = useDispatch();
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
const inputRef = useRef();
|
||||
|
||||
const validateEnvironmentName = (name) => {
|
||||
const trimmedName = name?.toLowerCase().trim();
|
||||
return (globalEnvs || []).every((env) =>
|
||||
env.uid === environment.uid || env?.name?.toLowerCase().trim() !== trimmedName);
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
name: environment.name
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string()
|
||||
.min(1, 'must be at least 1 character')
|
||||
.max(255, 'Must be 255 characters or less')
|
||||
.test('is-valid-filename', function (value) {
|
||||
const isValid = validateName(value);
|
||||
return isValid ? true : this.createError({ message: validateNameError(value) });
|
||||
})
|
||||
.required('name is required')
|
||||
.test('duplicate-name', 'Environment already exists', validateEnvironmentName)
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
if (values.name === environment.name) {
|
||||
return;
|
||||
}
|
||||
dispatch(renameGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
|
||||
.then(() => {
|
||||
toast.success('Environment renamed successfully');
|
||||
onClose();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error occurred while renaming the environment');
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const onSubmit = () => {
|
||||
formik.handleSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Rename Environment"
|
||||
confirmText="Rename"
|
||||
handleConfirm={onSubmit}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block font-semibold">
|
||||
Environment Name
|
||||
</label>
|
||||
<input
|
||||
id="environment-name"
|
||||
type="text"
|
||||
name="name"
|
||||
ref={inputRef}
|
||||
className="block textbox mt-2 w-full"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.name || ''}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name ? (
|
||||
<div className="text-red-500">{formik.errors.name}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenameEnvironment;
|
||||
@@ -0,0 +1,52 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: ${(props) => props.theme.bg};
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
svg {
|
||||
opacity: 0.3;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.shared-button {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.listItem.hoverBg};
|
||||
border-color: ${(props) => props.theme.textLink};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import CreateEnvironment from './CreateEnvironment';
|
||||
import EnvironmentList from './EnvironmentList';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconFileAlert } from '@tabler/icons';
|
||||
import ImportEnvironment from './ImportEnvironment';
|
||||
|
||||
export const SharedButton = ({ children, className, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded bg-transparent px-2.5 py-2 w-fit text-xs font-semibold text-zinc-900 dark:text-zinc-50 shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-500 hover:bg-gray-50 dark:hover:bg-zinc-700
|
||||
${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultTab = ({ setTab }) => {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<IconFileAlert size={48} strokeWidth={1.5} />
|
||||
<div className="title">No Environments</div>
|
||||
<div className="actions">
|
||||
<button className="shared-button" onClick={() => setTab('create')}>
|
||||
Create Environment
|
||||
</button>
|
||||
<button className="shared-button" onClick={() => setTab('import')}>
|
||||
Import Environment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WorkspaceEnvironments = ({ workspace }) => {
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
|
||||
const [tab, setTab] = useState('default');
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
|
||||
|
||||
if (!globalEnvironments || !globalEnvironments.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{tab === 'create' ? (
|
||||
<CreateEnvironment onClose={() => setTab('default')} />
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironment onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<EnvironmentList
|
||||
environments={globalEnvironments}
|
||||
activeEnvironmentUid={activeGlobalEnvironmentUid}
|
||||
selectedEnvironment={selectedEnvironment}
|
||||
setSelectedEnvironment={setSelectedEnvironment}
|
||||
isModified={isModified}
|
||||
setIsModified={setIsModified}
|
||||
collection={null}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceEnvironments;
|
||||
351
packages/bruno-app/src/components/WorkspaceHome/index.js
Normal file
351
packages/bruno-app/src/components/WorkspaceHome/index.js
Normal file
@@ -0,0 +1,351 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconCategory, IconPlus, IconFolders, IconFileImport, IconDots, IconEdit, IconX, IconCheck, IconFolder } from '@tabler/icons';
|
||||
import { importCollectionInWorkspace, renameWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { showInFolder, openCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import CloseWorkspace from 'components/Sidebar/TitleBar/CloseWorkspace';
|
||||
import WorkspaceCollections from './WorkspaceCollections';
|
||||
import WorkspaceDocs from './WorkspaceDocs';
|
||||
import WorkspaceEnvironments from './WorkspaceEnvironments';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
|
||||
const WorkspaceHome = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const [activeTab, setActiveTab] = useState('collections');
|
||||
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
|
||||
const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
|
||||
const [workspaceNameInput, setWorkspaceNameInput] = useState('');
|
||||
const [workspaceNameError, setWorkspaceNameError] = useState('');
|
||||
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
|
||||
const workspaceNameInputRef = useRef(null);
|
||||
const workspaceRenameContainerRef = useRef(null);
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRenamingWorkspace) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) {
|
||||
handleCancelWorkspaceRename();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isRenamingWorkspace]);
|
||||
|
||||
if (!activeWorkspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCreateCollection = async () => {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
await ipcRenderer.invoke('renderer:ensure-collections-folder', activeWorkspace.pathname);
|
||||
setCreateCollectionModalOpen(true);
|
||||
} catch (error) {
|
||||
console.error('Error ensuring collections folder exists:', error);
|
||||
toast.error('Error preparing workspace for collection creation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCollection = () => {
|
||||
dispatch(openCollection())
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while opening the collection');
|
||||
});
|
||||
};
|
||||
|
||||
const handleImportCollection = () => {
|
||||
setImportCollectionModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImportCollectionSubmit = ({ rawData, type, environment, repositoryUrl }) => {
|
||||
setImportCollectionModalOpen(false);
|
||||
dispatch(importCollectionInWorkspace(rawData, activeWorkspace.uid, undefined, type))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
};
|
||||
|
||||
// Workspace menu handlers
|
||||
const handleRenameWorkspaceClick = () => {
|
||||
setIsRenamingWorkspace(true);
|
||||
setWorkspaceNameInput(activeWorkspace.name);
|
||||
setWorkspaceNameError('');
|
||||
setTimeout(() => {
|
||||
workspaceNameInputRef.current?.focus();
|
||||
workspaceNameInputRef.current?.select();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleCloseWorkspaceClick = () => {
|
||||
dropdownTippyRef.current?.hide();
|
||||
if (activeWorkspace.type === 'default') {
|
||||
toast.error('Cannot close the default workspace');
|
||||
return;
|
||||
}
|
||||
setCloseWorkspaceModalOpen(true);
|
||||
};
|
||||
|
||||
const handleShowInFolder = () => {
|
||||
dropdownTippyRef.current?.hide();
|
||||
if (activeWorkspace.pathname) {
|
||||
dispatch(showInFolder(activeWorkspace.pathname))
|
||||
.catch((error) => {
|
||||
console.error('Error opening the folder', error);
|
||||
toast.error('Error opening the folder');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateWorkspaceName = (name) => {
|
||||
if (!name || name.trim() === '') {
|
||||
return 'Name is required';
|
||||
}
|
||||
|
||||
if (name.length < 1) {
|
||||
return 'Must be at least 1 character';
|
||||
}
|
||||
|
||||
if (name.length > 255) {
|
||||
return 'Must be 255 characters or less';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSaveWorkspaceRename = () => {
|
||||
const error = validateWorkspaceName(workspaceNameInput);
|
||||
if (error) {
|
||||
setWorkspaceNameError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(renameWorkspaceAction(activeWorkspace.uid, workspaceNameInput))
|
||||
.then(() => {
|
||||
toast.success('Workspace renamed!');
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err?.message || 'An error occurred while renaming the workspace');
|
||||
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelWorkspaceRename = () => {
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
};
|
||||
|
||||
const handleWorkspaceNameChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setWorkspaceNameInput(value);
|
||||
|
||||
if (workspaceNameError) {
|
||||
setWorkspaceNameError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkspaceNameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSaveWorkspaceRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelWorkspaceRename();
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeWorkspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'collections',
|
||||
label: 'Collections',
|
||||
component: (
|
||||
<WorkspaceCollections
|
||||
workspace={activeWorkspace}
|
||||
onImportCollection={handleImportCollection}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'environments',
|
||||
label: 'Environments',
|
||||
component: <WorkspaceEnvironments workspace={activeWorkspace} />
|
||||
},
|
||||
{
|
||||
id: 'documentation',
|
||||
label: 'Documentation',
|
||||
component: <WorkspaceDocs workspace={activeWorkspace} />
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full">
|
||||
<div className="h-full flex flex-col">
|
||||
{createCollectionModalOpen && (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{importCollectionModalOpen && (
|
||||
<ImportCollection
|
||||
onClose={() => setImportCollectionModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-5 p-4 pb-2 workspace-header">
|
||||
<div className="text-xl font-semibold flex items-center gap-2">
|
||||
<IconCategory size={24} stroke={2} />
|
||||
{isRenamingWorkspace ? (
|
||||
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
|
||||
<input
|
||||
ref={workspaceNameInputRef}
|
||||
type="text"
|
||||
className="workspace-name-input"
|
||||
value={workspaceNameInput}
|
||||
onChange={handleWorkspaceNameChange}
|
||||
onKeyDown={handleWorkspaceNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveWorkspaceRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelWorkspaceRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span>{activeWorkspace.name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isRenamingWorkspace && activeWorkspace.type !== 'default' && (
|
||||
<Dropdown
|
||||
style="new"
|
||||
placement="bottom-end"
|
||||
onCreate={onDropdownCreate}
|
||||
icon={<IconDots size={20} strokeWidth={1.5} className="cursor-pointer" />}
|
||||
>
|
||||
<div className="workspace-menu-dropdown">
|
||||
<div className="dropdown-item" onClick={handleRenameWorkspaceClick}>
|
||||
<IconEdit size={16} strokeWidth={1.5} />
|
||||
<span>Rename</span>
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={handleShowInFolder}>
|
||||
<IconFolder size={16} strokeWidth={1.5} />
|
||||
<span>Show in Folder</span>
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={handleCloseWorkspaceClick}>
|
||||
<IconX size={16} strokeWidth={1.5} />
|
||||
<span>Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
{workspaceNameError && isRenamingWorkspace && (
|
||||
<div className="workspace-error">{workspaceNameError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{closeWorkspaceModalOpen && (
|
||||
<CloseWorkspace
|
||||
workspaceUid={activeWorkspace.uid}
|
||||
onClose={() => setCloseWorkspaceModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-4 tabs-container">
|
||||
<div className="flex gap-5">
|
||||
{tabs.map((tab) => {
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 py-2 text-sm border-b-2 transition-colors tab-item ${activeTab === tab.id ? 'active' : ''}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeTab === 'collections' && (
|
||||
<div className="flex items-center gap-1 workspace-action-buttons">
|
||||
<button
|
||||
onClick={handleCreateCollection}
|
||||
className="workspace-button"
|
||||
title="Create Collection"
|
||||
>
|
||||
<IconPlus size={16} stroke={1.5} />
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenCollection}
|
||||
className="workspace-button"
|
||||
title="Add Collection"
|
||||
>
|
||||
<IconFolders size={16} stroke={1.5} />
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportCollection}
|
||||
className="workspace-button"
|
||||
title="Import Collection"
|
||||
>
|
||||
<IconFileImport size={16} stroke={1.5} />
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{tabs.find((tab) => tab.id === activeTab)?.component}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceHome;
|
||||
Reference in New Issue
Block a user