mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-27 22:54:07 +00:00
Merge pull request #6970 from usebruno/feature/environment-color-extended
feat(#304) Environments color 🎨 (#1053)
This commit is contained in:
24
packages/bruno-app/src/components/ColorBadge/index.js
Normal file
24
packages/bruno-app/src/components/ColorBadge/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const ColorBadge = ({ color, size = 10, showEmptyBorder = true }) => {
|
||||
const sizeValue = typeof size === 'string' ? size : `${size}px`;
|
||||
const { theme } = useTheme();
|
||||
|
||||
const showBorder = !color && showEmptyBorder;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
width: sizeValue,
|
||||
height: sizeValue,
|
||||
backgroundColor: color || 'transparent',
|
||||
border: showBorder ? '1px solid' : 'none',
|
||||
borderColor: showBorder ? theme.background.surface1 : 'transparent'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorBadge;
|
||||
@@ -0,0 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
164
packages/bruno-app/src/components/ColorPicker/index.js
Normal file
164
packages/bruno-app/src/components/ColorPicker/index.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { IconBan, IconBrush } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import ColorBadge from 'components/ColorBadge';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { parseToRgb, toColorString } from 'polished';
|
||||
import ColorRangePicker from 'components/ColorRange/index';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#CE4F3B',
|
||||
'#2E8A54',
|
||||
'#346AB2',
|
||||
'#C77A0F',
|
||||
'#B83D7F',
|
||||
'#8D44B2'
|
||||
];
|
||||
|
||||
const COLOR_RANGE_SEQUENCE = ['#D85D43', '#F4BB74', '#61DCB1', '#7EBDF2', '#D48ADE', '#B491E5'];
|
||||
|
||||
/**
|
||||
* @param {string} hex
|
||||
* @returns {red:string,green:string,blue:string}
|
||||
*/
|
||||
const hexToRgb = (hex) => {
|
||||
try {
|
||||
return parseToRgb(hex);
|
||||
} catch (err) {
|
||||
return { red: 0, green: 0, blue: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
const rgbToHex = (r, g, b) => {
|
||||
return toColorString({ red: Math.round(r), green: Math.round(g), blue: Math.round(b) });
|
||||
};
|
||||
|
||||
const interpolateColor = (position) => {
|
||||
const numColors = COLOR_RANGE_SEQUENCE.length;
|
||||
const scaledPos = (position / 100) * (numColors - 1);
|
||||
const index = Math.floor(scaledPos);
|
||||
const fraction = scaledPos - index;
|
||||
|
||||
if (index >= numColors - 1) {
|
||||
return COLOR_RANGE_SEQUENCE[numColors - 1];
|
||||
}
|
||||
|
||||
const color1 = hexToRgb(COLOR_RANGE_SEQUENCE[index]);
|
||||
const color2 = hexToRgb(COLOR_RANGE_SEQUENCE[index + 1]);
|
||||
|
||||
const r = color1.red + (color2.red - color1.red) * fraction;
|
||||
const g = color1.green + (color2.green - color1.green) * fraction;
|
||||
const b = color1.blue + (color2.blue - color1.blue) * fraction;
|
||||
|
||||
return rgbToHex(r, g, b);
|
||||
};
|
||||
|
||||
const findClosestPosition = (hex) => {
|
||||
if (!hex) return 0;
|
||||
const target = hexToRgb(hex);
|
||||
let closestPos = 0;
|
||||
let minDistance = Infinity;
|
||||
|
||||
for (let pos = 0; pos <= 100; pos++) {
|
||||
const color = hexToRgb(interpolateColor(pos));
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(target.red - color.red, 2) + Math.pow(target.green - color.green, 2) + Math.pow(target.blue - color.blue, 2)
|
||||
);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestPos = pos;
|
||||
}
|
||||
}
|
||||
return closestPos;
|
||||
};
|
||||
|
||||
const ColorPickerIcon = ({ color }) => {
|
||||
if (color) {
|
||||
return <ColorBadge color={color} size={8} />;
|
||||
}
|
||||
return <IconBrush size={14} strokeWidth={1.5} className="opacity-70" />;
|
||||
};
|
||||
|
||||
const ColorPicker = ({ color, onChange, icon }) => {
|
||||
const [sliderPosition, setSliderPosition] = useState(() =>
|
||||
color && !PRESET_COLORS.includes(color) ? findClosestPosition(color) : 0
|
||||
);
|
||||
const [customColor, setCustomColor] = useState(() =>
|
||||
color && !PRESET_COLORS.includes(color) ? color : COLOR_RANGE_SEQUENCE[0]
|
||||
);
|
||||
const pendingColorRef = useRef(customColor);
|
||||
|
||||
const handleColorSelect = (selectedColor) => {
|
||||
onChange(selectedColor);
|
||||
};
|
||||
|
||||
const handleSliderChange = (e) => {
|
||||
const newPosition = parseInt(e.target.value, 10);
|
||||
setSliderPosition(newPosition);
|
||||
const newColor = interpolateColor(newPosition);
|
||||
setCustomColor(newColor);
|
||||
pendingColorRef.current = newColor;
|
||||
};
|
||||
|
||||
const handleSliderEnd = () => {
|
||||
onChange(pendingColorRef.current);
|
||||
};
|
||||
|
||||
const defaultIcon = (
|
||||
<div className="cursor-pointer flex items-center" title="Change color">
|
||||
<ColorPickerIcon color={color} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const colorPickerContent = (
|
||||
<StyledWrapper>
|
||||
<div className="p-2">
|
||||
<div className="flex flex-wrap gap-1.5 justify-between items-center">
|
||||
<div
|
||||
className="w-5 h-5 cursor-pointer flex items-center justify-center transition-transform duration-100 hover:scale-110"
|
||||
onClick={() => handleColorSelect(null)}
|
||||
title="No color"
|
||||
>
|
||||
<IconBan size={20} strokeWidth={1.5} />
|
||||
</div>
|
||||
{PRESET_COLORS.map((presetColor, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-5 h-5 rounded cursor-pointer flex items-center justify-center transition-transform duration-100 hover:scale-110 border-2 border-transparent
|
||||
${color === presetColor ? 'border-solid !border-current' : ''}
|
||||
`}
|
||||
style={{ backgroundColor: presetColor }}
|
||||
onClick={() => handleColorSelect(presetColor)}
|
||||
title={presetColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2 pt-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex-shrink-0 cursor-pointer"
|
||||
style={{ backgroundColor: customColor }}
|
||||
onClick={() => handleColorSelect(customColor)}
|
||||
title="Custom color"
|
||||
/>
|
||||
<ColorRangePicker
|
||||
className="flex-1"
|
||||
value={sliderPosition}
|
||||
onChange={handleSliderChange}
|
||||
onMouseUp={handleSliderEnd}
|
||||
selectedColor={customColor}
|
||||
colorRange={COLOR_RANGE_SEQUENCE}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown icon={icon || defaultIcon} placement="bottom-start">
|
||||
{colorPickerContent}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -0,0 +1,45 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.hue-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hue-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: ${(props) => props.color ?? props.theme.bg};
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.hue-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.hue-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: ${(props) => props.color ?? props.theme.bg};
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.hue-slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
23
packages/bruno-app/src/components/ColorRange/index.js
Normal file
23
packages/bruno-app/src/components/ColorRange/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ColorRangePicker = ({ selectedColor, className, value, onChange, colorRange, ...props }) => {
|
||||
return (
|
||||
<StyledWrapper color={selectedColor}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={`hue-slider ${className}`}
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${colorRange.join(',')})`
|
||||
}}
|
||||
title="Adjust color"
|
||||
{...props}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorRangePicker;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import ColorBadge from 'components/ColorBadge';
|
||||
|
||||
const EnvironmentListContent = ({
|
||||
environments,
|
||||
@@ -38,6 +39,7 @@ const EnvironmentListContent = ({
|
||||
data-tooltip-content={env.name}
|
||||
data-tooltip-hidden={env.name?.length < 90}
|
||||
>
|
||||
<ColorBadge color={env.color} size={8} showEmptyBorder={false} />
|
||||
<span className="max-w-100% truncate no-wrap">{env.name}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -33,8 +33,7 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
.env-separator {
|
||||
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
|
||||
margin: 0 0.35rem;
|
||||
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
|
||||
}
|
||||
|
||||
.env-text-inactive {
|
||||
|
||||
@@ -13,6 +13,166 @@ import ImportEnvironmentModal from 'components/Environments/Common/ImportEnviron
|
||||
import CreateGlobalEnvironment from 'components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { transparentize, toColorString, parseToRgb } from 'polished';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
|
||||
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
|
||||
];
|
||||
|
||||
const EMPTY_STATE_DESCRIPTIONS = {
|
||||
collection: 'Create your first environment to begin working with your collection.',
|
||||
global: 'Create your first global environment to begin working across collections.'
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates background color with transparency for environment badges
|
||||
*/
|
||||
const getEnvBackgroundColor = (color) => (color ? transparentize(1 - 0.12, color) : 'transparent');
|
||||
|
||||
/**
|
||||
* Calculates the style for an environment badge section
|
||||
*/
|
||||
const getEnvBadgeStyle = (environment, position, hasOtherEnv) => {
|
||||
const color = environment?.color;
|
||||
const isLeft = position === 'left';
|
||||
|
||||
// Determine border radius based on position and whether other env exists
|
||||
let borderRadius = '0.3rem';
|
||||
if (hasOtherEnv) {
|
||||
borderRadius = isLeft ? '0.3rem 0 0 0.3rem' : '0 0.3rem 0.3rem 0';
|
||||
}
|
||||
|
||||
// Determine padding based on position
|
||||
const padding = isLeft
|
||||
? hasOtherEnv
|
||||
? '0.25rem 0.5rem 0.25rem 0.5rem'
|
||||
: '0.25rem 0.3rem 0.25rem 0.5rem'
|
||||
: '0.25rem 0.3rem 0.25rem 0.5rem';
|
||||
|
||||
return {
|
||||
backgroundColor: getEnvBackgroundColor(color),
|
||||
padding,
|
||||
borderRadius
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates dropdown width based on longest environment name
|
||||
*/
|
||||
const calculateDropdownWidth = (environments, globalEnvironments) => {
|
||||
const allEnvironments = [...environments, ...globalEnvironments];
|
||||
if (allEnvironments.length === 0) return 0;
|
||||
|
||||
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
|
||||
// 8 pixels per character (rough estimate for average character width)
|
||||
return maxCharLength * 8;
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays a single environment with icon, name, and optional color styling
|
||||
*/
|
||||
const EnvironmentBadge = ({ environment, icon: Icon }) => {
|
||||
if (!environment) return null;
|
||||
|
||||
const colorStyle = environment.color ? { color: environment.color } : {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Icon size={14} strokeWidth={1.5} className="env-icon" style={colorStyle} />
|
||||
<ToolHint
|
||||
text={environment.name}
|
||||
toolhintId={`env-${environment.uid}`}
|
||||
place="bottom-start"
|
||||
delayShow={1000}
|
||||
hidden={environment.name?.length < 7}
|
||||
>
|
||||
<span className="env-text max-w-24 truncate overflow-hidden" style={colorStyle}>
|
||||
{environment.name}
|
||||
</span>
|
||||
</ToolHint>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dropdown trigger component showing active environments
|
||||
*/
|
||||
const DropdownTrigger = forwardRef(({ collectionEnv, globalEnv }, ref) => {
|
||||
const hasAnyEnv = collectionEnv || globalEnv;
|
||||
|
||||
// Empty state - no environments selected
|
||||
if (!hasAnyEnv) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="current-environment flex align-center justify-center cursor-pointer bg-transparent no-environments"
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only collection env selected - caret goes with collection env
|
||||
if (collectionEnv && !globalEnv) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
|
||||
style={{ padding: 0 }}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
<div className="flex items-center" style={getEnvBadgeStyle(collectionEnv, 'left', false)}>
|
||||
<EnvironmentBadge environment={collectionEnv} icon={IconDatabase} />
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only global env selected - caret goes with global env
|
||||
if (!collectionEnv && globalEnv) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
|
||||
style={{ padding: 0 }}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
<div className="flex items-center" style={getEnvBadgeStyle(globalEnv, 'right', false)}>
|
||||
<EnvironmentBadge environment={globalEnv} icon={IconWorld} />
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Both environments selected
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
|
||||
style={{ padding: 0 }}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
{/* Collection Environment Section */}
|
||||
<div className="flex items-center" style={getEnvBadgeStyle(collectionEnv, 'left', true)}>
|
||||
<EnvironmentBadge environment={collectionEnv} icon={IconDatabase} />
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="env-separator" style={{ width: '1px', alignSelf: 'stretch' }} />
|
||||
|
||||
{/* Global Environment Section + Caret */}
|
||||
<div className="flex items-center" style={getEnvBadgeStyle(globalEnv, 'right', true)}>
|
||||
<EnvironmentBadge environment={globalEnv} icon={IconWorld} />
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const EnvironmentSelector = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -35,159 +195,82 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
? find(environments, (e) => e.uid === activeEnvironmentUid)
|
||||
: null;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
|
||||
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
|
||||
];
|
||||
const dropdownWidth = useMemo(
|
||||
() => calculateDropdownWidth(environments, globalEnvironments),
|
||||
[environments, globalEnvironments]
|
||||
);
|
||||
|
||||
const onDropdownCreate = (ref) => {
|
||||
dropdownTippyRef.current = ref;
|
||||
};
|
||||
const description = EMPTY_STATE_DESCRIPTIONS[activeTab];
|
||||
|
||||
// Get description based on active tab
|
||||
const description
|
||||
= activeTab === 'collection'
|
||||
? 'Create your first environment to begin working with your collection.'
|
||||
: 'Create your first global environment to begin working across collections.';
|
||||
const hideDropdown = () => dropdownTippyRef.current?.hide();
|
||||
|
||||
// Environment selection handler
|
||||
const handleEnvironmentSelect = (environment) => {
|
||||
const action
|
||||
= activeTab === 'collection'
|
||||
? selectEnvironment(environment ? environment.uid : null, collection.uid)
|
||||
: selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null });
|
||||
? selectEnvironment(environment?.uid || null, collection.uid)
|
||||
: selectGlobalEnvironment({ environmentUid: environment?.uid || null });
|
||||
|
||||
dispatch(action)
|
||||
.then(() => {
|
||||
if (environment) {
|
||||
toast.success(`Environment changed to ${environment.name}`);
|
||||
} else {
|
||||
toast.success('No Environments are active now');
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
toast.success(environment ? `Environment changed to ${environment.name}` : 'No Environments are active now');
|
||||
hideDropdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while selecting the environment');
|
||||
});
|
||||
};
|
||||
|
||||
// Settings handler - opens environment settings tab
|
||||
const handleSettingsClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'environment-settings'
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-global-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'global-environment-settings'
|
||||
})
|
||||
);
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
const isCollection = activeTab === 'collection';
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-${isCollection ? 'environment' : 'global-environment'}-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: isCollection ? 'environment-settings' : 'global-environment-settings'
|
||||
})
|
||||
);
|
||||
hideDropdown();
|
||||
};
|
||||
|
||||
// Create handler
|
||||
const handleCreateClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
setShowCreateCollectionModal(true);
|
||||
} else {
|
||||
setShowCreateGlobalModal(true);
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
hideDropdown();
|
||||
};
|
||||
|
||||
// Import handler
|
||||
const handleImportClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
setShowImportCollectionModal(true);
|
||||
} else {
|
||||
setShowImportGlobalModal(true);
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
hideDropdown();
|
||||
};
|
||||
|
||||
// Calculate dropdown width based on the longest environment name.
|
||||
// To prevent resizing while switching between collection and global environments.
|
||||
const dropdownWidth = useMemo(() => {
|
||||
const allEnvironments = [...environments, ...globalEnvironments];
|
||||
if (allEnvironments.length === 0) return 0;
|
||||
|
||||
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
|
||||
// 8 pixels per character: This is a rough estimate for the average character width in most fonts
|
||||
// (monospace fonts are typically 8-10px, proportional fonts vary but 8px is a safe average)
|
||||
return maxCharLength * 8;
|
||||
}, [environments, globalEnvironments]);
|
||||
|
||||
// Create icon component for dropdown trigger
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
const hasAnyEnv = activeGlobalEnvironment || activeCollectionEnvironment;
|
||||
|
||||
const displayContent = hasAnyEnv ? (
|
||||
<>
|
||||
{activeCollectionEnvironment && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<IconDatabase size={14} strokeWidth={1.5} className="env-icon" />
|
||||
<ToolHint
|
||||
text={activeCollectionEnvironment.name}
|
||||
toolhintId={`collection-env-${activeCollectionEnvironment.uid}`}
|
||||
place="bottom-start"
|
||||
delayShow={1000}
|
||||
hidden={activeCollectionEnvironment.name?.length < 7}
|
||||
>
|
||||
<span className="env-text max-w-24 truncate overflow-hidden">{activeCollectionEnvironment.name}</span>
|
||||
</ToolHint>
|
||||
</div>
|
||||
{activeGlobalEnvironment && <span className="env-separator">|</span>}
|
||||
</>
|
||||
)}
|
||||
{activeGlobalEnvironment && (
|
||||
<div className="flex items-center">
|
||||
<IconWorld size={14} strokeWidth={1.5} className="env-icon" />
|
||||
<ToolHint
|
||||
text={activeGlobalEnvironment.name}
|
||||
toolhintId={`global-env-${activeGlobalEnvironment.uid}`}
|
||||
place="bottom-start"
|
||||
delayShow={1000}
|
||||
hidden={activeGlobalEnvironment.name?.length < 7}
|
||||
>
|
||||
<span className="env-text max-w-24 truncate overflow-hidden">{activeGlobalEnvironment.name}</span>
|
||||
</ToolHint>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
|
||||
const openEnvironmentSettingsTab = (type) => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-${type}-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: `${type}-settings`
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`current-environment flex align-center justify-center cursor-pointer bg-transparent ${
|
||||
!hasAnyEnv ? 'no-environments' : ''
|
||||
}`}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
{displayContent}
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper width={dropdownWidth}>
|
||||
<div className="environment-selector flex align-center cursor-pointer">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
|
||||
<Dropdown
|
||||
onCreate={(ref) => (dropdownTippyRef.current = ref)}
|
||||
icon={<DropdownTrigger collectionEnv={activeCollectionEnvironment} globalEnv={activeGlobalEnvironment} />}
|
||||
placement="bottom-end"
|
||||
>
|
||||
{/* Tab Headers */}
|
||||
<div className="tab-header flex pt-3 pb-2 px-3">
|
||||
{tabs.map((tab) => (
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-button whitespace-nowrap pb-[0.375rem] border-b-[0.125rem] bg-transparent flex align-center cursor-pointer transition-all duration-200 mr-[1.25rem] ${
|
||||
@@ -222,15 +305,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
{showCreateGlobalModal && (
|
||||
<CreateGlobalEnvironment
|
||||
onClose={() => setShowCreateGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-global-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'global-environment-settings'
|
||||
})
|
||||
);
|
||||
}}
|
||||
onEnvironmentCreated={() => openEnvironmentSettingsTab('global-environment')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -238,15 +313,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
<ImportEnvironmentModal
|
||||
type="global"
|
||||
onClose={() => setShowImportGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-global-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'global-environment-settings'
|
||||
})
|
||||
);
|
||||
}}
|
||||
onEnvironmentCreated={() => openEnvironmentSettingsTab('global-environment')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -254,15 +321,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
<CreateEnvironment
|
||||
collection={collection}
|
||||
onClose={() => setShowCreateCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'environment-settings'
|
||||
})
|
||||
);
|
||||
}}
|
||||
onEnvironmentCreated={() => openEnvironmentSettingsTab('environment')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -271,15 +330,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
type="collection"
|
||||
collection={collection}
|
||||
onClose={() => setShowImportCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'environment-settings'
|
||||
})
|
||||
);
|
||||
}}
|
||||
onEnvironmentCreated={() => openEnvironmentSettingsTab('environment')}
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import CopyEnvironment from 'components/Environments/EnvironmentSettings/CopyEnvironment';
|
||||
import DeleteEnvironment from 'components/Environments/EnvironmentSettings/DeleteEnvironment';
|
||||
import EnvironmentVariables from './EnvironmentVariables';
|
||||
import ColorPicker from 'components/ColorPicker';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
@@ -111,6 +112,16 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid))
|
||||
.then(() => {
|
||||
toast.success('Environment color updated!');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while updating the environment color');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openDeleteModal && (
|
||||
@@ -157,7 +168,10 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<h2 className="title">{environment.name}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="title">{environment.name}</h2>
|
||||
<ColorPicker color={environment.color} onChange={handleColorChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
|
||||
|
||||
@@ -110,6 +110,7 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 1px;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ConfirmSwitchEnv from 'components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import ColorBadge from 'components/ColorBadge';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { addEnvironment, renameEnvironment, selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -367,6 +368,7 @@ const EnvironmentList = ({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ColorBadge color={env.color} size={8} />
|
||||
<span className="environment-name">{env.name}</span>
|
||||
<div className="environment-actions">
|
||||
{activeEnvironmentUid === env.uid ? (
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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 { renameGlobalEnvironment, updateGlobalEnvironmentColor } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import CopyEnvironment from '../../CopyEnvironment';
|
||||
import DeleteEnvironment from '../../DeleteEnvironment';
|
||||
import EnvironmentVariables from './EnvironmentVariables';
|
||||
import ColorPicker from 'components/ColorPicker';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
@@ -110,6 +111,16 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
dispatch(updateGlobalEnvironmentColor(environment.uid, color))
|
||||
.then(() => {
|
||||
toast.success('Environment color updated!');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while updating the environment color');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openDeleteModal && (
|
||||
@@ -159,7 +170,10 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<h2 className="title">{environment.name}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="title">{environment.name}</h2>
|
||||
<ColorPicker color={environment.color} onChange={handleColorChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
|
||||
|
||||
@@ -110,6 +110,7 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 1px;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import ColorBadge from 'components/ColorBadge';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
@@ -357,6 +358,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ColorBadge color={env.color} size={8} />
|
||||
<span className="environment-name">{env.name}</span>
|
||||
<div className="environment-actions">
|
||||
{activeEnvironmentUid === env.uid ? (
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
updateActiveConnections,
|
||||
saveRequest as _saveRequest,
|
||||
saveEnvironment as _saveEnvironment,
|
||||
updateEnvironmentColor as _updateEnvironmentColor,
|
||||
saveCollectionDraft,
|
||||
saveFolderDraft,
|
||||
addVar,
|
||||
@@ -1930,6 +1931,31 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
|
||||
});
|
||||
};
|
||||
|
||||
export const updateEnvironmentColor = (environmentUid, color, collectionUid) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
const collectionCopy = cloneDeep(collection);
|
||||
const environment = findEnvironmentInCollection(collectionCopy, environmentUid);
|
||||
if (!environment) {
|
||||
return reject(new Error('Environment not found'));
|
||||
}
|
||||
|
||||
environment.color = color;
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:update-environment-color', collection.pathname, environment.name, color)
|
||||
.then(() => {
|
||||
dispatch(_updateEnvironmentColor({ environmentUid, color, collectionUid }));
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a variable value directly in the file without affecting draft state
|
||||
* @param {string} pathname - File path
|
||||
|
||||
@@ -277,6 +277,18 @@ export const collectionsSlice = createSlice({
|
||||
}
|
||||
}
|
||||
},
|
||||
updateEnvironmentColor: (state, action) => {
|
||||
const { environmentUid, color, collectionUid } = action.payload;
|
||||
const collection = findCollectionByUid(state.collections, collectionUid);
|
||||
|
||||
if (collection) {
|
||||
const environment = findEnvironmentInCollection(collection, environmentUid);
|
||||
|
||||
if (environment) {
|
||||
environment.color = color;
|
||||
}
|
||||
}
|
||||
},
|
||||
newItem: (state, action) => {
|
||||
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
|
||||
|
||||
@@ -3465,6 +3477,7 @@ export const {
|
||||
collectionUnlinkEnvFileEvent,
|
||||
saveEnvironment,
|
||||
selectEnvironment,
|
||||
updateEnvironmentColor,
|
||||
newItem,
|
||||
deleteItem,
|
||||
renameItem,
|
||||
|
||||
@@ -81,6 +81,12 @@ export const globalEnvironmentsSlice = createSlice({
|
||||
},
|
||||
clearGlobalEnvironmentDraft: (state) => {
|
||||
state.globalEnvironmentDraft = null;
|
||||
},
|
||||
_updateGlobalEnvironmentColor: (state, action) => {
|
||||
const { environmentUid, color } = action.payload;
|
||||
if (environmentUid) {
|
||||
state.globalEnvironments = state.globalEnvironments.map((env) => env?.uid == environmentUid ? { ...env, color } : env);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -93,6 +99,7 @@ export const {
|
||||
_copyGlobalEnvironment,
|
||||
_selectGlobalEnvironment,
|
||||
_deleteGlobalEnvironment,
|
||||
_updateGlobalEnvironmentColor,
|
||||
setGlobalEnvironmentDraft,
|
||||
clearGlobalEnvironmentDraft
|
||||
} = globalEnvironmentsSlice.actions;
|
||||
@@ -303,4 +310,16 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) =>
|
||||
});
|
||||
};
|
||||
|
||||
export const updateGlobalEnvironmentColor = (environmentUid, color) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
const { workspaceUid, workspacePath } = getWorkspaceContext(state);
|
||||
ipcRenderer.invoke('renderer:update-global-environment-color', { environmentUid, color, workspaceUid, workspacePath })
|
||||
.then(() => dispatch(_updateGlobalEnvironmentColor({ environmentUid, color })))
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export default globalEnvironmentsSlice.reducer;
|
||||
|
||||
@@ -620,6 +620,28 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
}
|
||||
});
|
||||
|
||||
// update environment color
|
||||
ipcMain.handle('renderer:update-environment-color', async (event, collectionPathname, environmentName, color) => {
|
||||
try {
|
||||
const format = getCollectionFormat(collectionPathname);
|
||||
const envDirPath = path.join(collectionPathname, 'environments');
|
||||
const envFilePath = path.join(envDirPath, `${environmentName}.${format}`);
|
||||
|
||||
if (!fs.existsSync(envFilePath)) {
|
||||
throw new Error(`environment: ${envFilePath} does not exist`);
|
||||
}
|
||||
|
||||
// Read, update color, and write back to file
|
||||
const fileContent = fs.readFileSync(envFilePath, 'utf8');
|
||||
const environment = parseEnvironment(fileContent, { format });
|
||||
environment.color = color;
|
||||
const updatedContent = stringifyEnvironment(environment, { format });
|
||||
fs.writeFileSync(envFilePath, updatedContent, 'utf8');
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Generic environment export handler
|
||||
ipcMain.handle('renderer:export-environment', async (event, { environments, environmentType, filePath, exportFormat = 'folder' }) => {
|
||||
try {
|
||||
|
||||
@@ -99,6 +99,19 @@ const registerGlobalEnvironmentsIpc = (mainWindow, workspaceEnvironmentsManager)
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:update-global-environment-color', async (event, { environmentUid, color, workspacePath }) => {
|
||||
try {
|
||||
if (workspacePath && workspaceEnvironmentsManager) {
|
||||
return await workspaceEnvironmentsManager.updateGlobalEnvironmentColorByPath(workspacePath, { environmentUid, color });
|
||||
}
|
||||
|
||||
globalEnvironmentsStore.updateGlobalEnvironmentColor({ environmentUid, color });
|
||||
} catch (error) {
|
||||
console.error('Error in renderer:update-global-environment-color:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = registerGlobalEnvironmentsIpc;
|
||||
|
||||
@@ -162,6 +162,15 @@ class GlobalEnvironmentsStore {
|
||||
}
|
||||
this.setGlobalEnvironments(globalEnvironments);
|
||||
}
|
||||
|
||||
updateGlobalEnvironmentColor({ environmentUid, color }) {
|
||||
let globalEnvironments = this.getGlobalEnvironments();
|
||||
const environment = globalEnvironments.find((env) => env?.uid == environmentUid);
|
||||
if (environment) {
|
||||
environment.color = color;
|
||||
}
|
||||
this.setGlobalEnvironments(globalEnvironments);
|
||||
}
|
||||
}
|
||||
|
||||
const globalEnvironmentsStore = new GlobalEnvironmentsStore();
|
||||
|
||||
@@ -325,6 +325,30 @@ class GlobalEnvironmentsManager {
|
||||
}
|
||||
}
|
||||
|
||||
async updateGlobalEnvironmentColor(workspacePath, environmentUid, color) {
|
||||
try {
|
||||
if (!workspacePath) {
|
||||
throw new Error('Workspace path is required');
|
||||
}
|
||||
|
||||
const envFile = this.findEnvironmentFileByUid(workspacePath, environmentUid);
|
||||
|
||||
if (!envFile) {
|
||||
throw new Error(`Environment file not found for uid: ${environmentUid}`);
|
||||
}
|
||||
|
||||
const environment = await this.parseEnvironmentFile(envFile.filePath, workspacePath);
|
||||
environment.color = color;
|
||||
|
||||
const content = stringifyEnvironment(environment, { format: 'yml' });
|
||||
await writeFile(envFile.filePath, content);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getGlobalEnvironmentsByPath(workspacePath) {
|
||||
return this.getGlobalEnvironments(workspacePath);
|
||||
}
|
||||
@@ -348,6 +372,10 @@ class GlobalEnvironmentsManager {
|
||||
async selectGlobalEnvironmentByPath(workspacePath, params) {
|
||||
return this.selectGlobalEnvironment(workspacePath, params);
|
||||
}
|
||||
|
||||
async updateGlobalEnvironmentColorByPath(workspacePath, { environmentUid, color }) {
|
||||
return this.updateGlobalEnvironmentColor(workspacePath, environmentUid, color);
|
||||
}
|
||||
}
|
||||
|
||||
const globalEnvironmentsManager = new GlobalEnvironmentsManager();
|
||||
|
||||
@@ -43,7 +43,8 @@ const parseEnvironment = (ymlString: string): BrunoEnvironment => {
|
||||
const brunoEnvironment: BrunoEnvironment = {
|
||||
uid: uuid(),
|
||||
name: ensureString(ocEnvironment.name, 'Untitled Environment'),
|
||||
variables: toBrunoEnvironmentVariables(ocEnvironment.variables)
|
||||
variables: toBrunoEnvironmentVariables(ocEnvironment.variables),
|
||||
color: ocEnvironment.color || null
|
||||
};
|
||||
|
||||
return brunoEnvironment;
|
||||
|
||||
@@ -47,7 +47,10 @@ const stringifyEnvironment = (environment: BrunoEnvironment): string => {
|
||||
name: environment.name
|
||||
};
|
||||
|
||||
// Convert variables if they exist
|
||||
if (environment.color) {
|
||||
ocEnvironment.color = environment.color;
|
||||
}
|
||||
|
||||
if (environment.variables?.length) {
|
||||
const ocVariables = toOpenCollectionEnvironmentVariables(environment.variables);
|
||||
if (ocVariables) {
|
||||
|
||||
4
packages/bruno-filestore/test-results/.last-run.json
Normal file
4
packages/bruno-filestore/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -12,7 +12,7 @@ const _ = require('lodash');
|
||||
// }
|
||||
const indentLevel = 4;
|
||||
const grammar = ohm.grammar(`Bru {
|
||||
BruEnvFile = (vars | secretvars)*
|
||||
BruEnvFile = (vars | secretvars | color)*
|
||||
|
||||
nl = "\\r"? "\\n"
|
||||
st = " " | "\\t"
|
||||
@@ -43,6 +43,7 @@ const grammar = ohm.grammar(`Bru {
|
||||
|
||||
secretvars = "vars:secret" array
|
||||
vars = "vars" dictionary
|
||||
color = "color:" any*
|
||||
}`);
|
||||
|
||||
const mapPairListToKeyValPairs = (pairList = []) => {
|
||||
@@ -190,6 +191,11 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
return {
|
||||
variables: vars
|
||||
};
|
||||
},
|
||||
color: (_1, anystring) => {
|
||||
return {
|
||||
color: anystring.sourceString.trim()
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ const { getValueString, indentString } = require('./utils');
|
||||
|
||||
const envToJson = (json) => {
|
||||
const variables = _.get(json, 'variables', []);
|
||||
const color = _.get(json, 'color', null);
|
||||
|
||||
const vars = variables
|
||||
.filter((variable) => !variable.secret)
|
||||
.map((variable) => {
|
||||
@@ -20,13 +22,14 @@ const envToJson = (json) => {
|
||||
return indentString(`${prefix}${name}`);
|
||||
});
|
||||
|
||||
let output = '';
|
||||
|
||||
if (!variables || !variables.length) {
|
||||
return `vars {
|
||||
output += `vars {
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
let output = '';
|
||||
if (vars.length) {
|
||||
output += `vars {
|
||||
${vars.join('\n')}
|
||||
@@ -38,6 +41,10 @@ ${vars.join('\n')}
|
||||
output += `vars:secret [
|
||||
${secretVars.join(',\n')}
|
||||
]
|
||||
`;
|
||||
}
|
||||
if (color) {
|
||||
output += `color: ${color}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface Environment {
|
||||
uid: UID;
|
||||
name: string;
|
||||
variables: EnvironmentVariable[];
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export type Environments = Environment[];
|
||||
|
||||
@@ -16,7 +16,8 @@ const environmentVariablesSchema = Yup.object({
|
||||
const environmentSchema = Yup.object({
|
||||
uid: uidSchema,
|
||||
name: Yup.string().min(1).required('name is required'),
|
||||
variables: Yup.array().of(environmentVariablesSchema).required('variables are required')
|
||||
variables: Yup.array().of(environmentVariablesSchema).required('variables are required'),
|
||||
color: Yup.string().nullable().optional()
|
||||
})
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
|
||||
5
tests/environments/color-picker/collection/bruno.json
Normal file
5
tests/environments/color-picker/collection/bruno.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "global-env-config-selection",
|
||||
"type": "collection"
|
||||
}
|
||||
17
tests/environments/color-picker/collection/test-request.bru
Normal file
17
tests/environments/color-picker/collection/test-request.bru
Normal file
@@ -0,0 +1,17 @@
|
||||
meta {
|
||||
name: test-request
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/api/echo
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("should get 200 response", function() {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
}
|
||||
156
tests/environments/color-picker/color-picker.spec.ts
Normal file
156
tests/environments/color-picker/color-picker.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections } from '../../utils/page/actions';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#CE4F3B',
|
||||
'#2E8A54',
|
||||
'#346AB2',
|
||||
'#C77A0F',
|
||||
'#B83D7F',
|
||||
'#8D44B2'
|
||||
];
|
||||
|
||||
// Convert hex color to RGB format used by CSS
|
||||
const hexToRgb = (hex: string): string => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return '';
|
||||
const r = parseInt(result[1], 16);
|
||||
const g = parseInt(result[2], 16);
|
||||
const b = parseInt(result[3], 16);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
};
|
||||
|
||||
test.describe('Color Picker Tests', () => {
|
||||
test.afterAll(async ({ pageWithUserData: page }) => {
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('should select a preset color for global environment', async ({ pageWithUserData: page }) => {
|
||||
// Open the collection from sidebar
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'global-env-config-selection' }).click();
|
||||
|
||||
// Open global environment configuration
|
||||
await page.getByTestId('environment-selector-trigger').click();
|
||||
await page.getByTestId('env-tab-global').click();
|
||||
await page.getByText('Configure', { exact: true }).click();
|
||||
|
||||
// Wait for the environments tab to be visible
|
||||
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
|
||||
await expect(envTab).toBeVisible();
|
||||
|
||||
// Click on the color picker icon (brush icon) next to the environment name
|
||||
const colorPickerTrigger = page.locator('[title="Change color"]').first();
|
||||
await colorPickerTrigger.click();
|
||||
|
||||
// Wait for the color picker dropdown to appear
|
||||
const colorPickerDropdown = page.locator('.tippy-box');
|
||||
await expect(colorPickerDropdown).toBeVisible();
|
||||
|
||||
// Select the first preset color (red) using title attribute
|
||||
const presetColor = PRESET_COLORS[0];
|
||||
const colorOption = colorPickerDropdown.locator(`[title="${presetColor}"]`);
|
||||
await colorOption.click();
|
||||
|
||||
// Verify the color badge in the environment list shows the selected color
|
||||
const activeEnvItem = page.locator('.environment-item.active');
|
||||
const colorBadge = activeEnvItem.locator('.rounded-full').first();
|
||||
await expect(colorBadge).toHaveCSS('background-color', hexToRgb(presetColor));
|
||||
});
|
||||
|
||||
test('should remove color from environment', async ({ pageWithUserData: page }) => {
|
||||
// Open global environment configuration
|
||||
await page.getByTestId('environment-selector-trigger').click();
|
||||
await page.getByTestId('env-tab-global').click();
|
||||
await page.getByText('Configure', { exact: true }).click();
|
||||
|
||||
// Wait for the environments tab to be visible
|
||||
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
|
||||
await expect(envTab).toBeVisible();
|
||||
|
||||
// Click on the color picker icon
|
||||
const colorPickerTrigger = page.locator('[title="Change color"]').first();
|
||||
await colorPickerTrigger.click();
|
||||
|
||||
// Wait for the color picker dropdown to appear
|
||||
const colorPickerDropdown = page.locator('.tippy-box');
|
||||
await expect(colorPickerDropdown).toBeVisible();
|
||||
|
||||
// Click the "No color" option (ban icon)
|
||||
const noColorOption = colorPickerDropdown.locator('[title="No color"]');
|
||||
await noColorOption.click();
|
||||
|
||||
// Verify the color badge becomes transparent (no color)
|
||||
const activeEnvItem = page.locator('.environment-item.active');
|
||||
const colorBadge = activeEnvItem.locator('.rounded-full').first();
|
||||
await expect(colorBadge).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
|
||||
});
|
||||
|
||||
test('should select custom color using slider', async ({ pageWithUserData: page }) => {
|
||||
// Open global environment configuration
|
||||
await page.getByTestId('environment-selector-trigger').click();
|
||||
await page.getByTestId('env-tab-global').click();
|
||||
await page.getByText('Configure', { exact: true }).click();
|
||||
|
||||
// Wait for the environments tab to be visible
|
||||
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
|
||||
await expect(envTab).toBeVisible();
|
||||
|
||||
// Click on the color picker icon
|
||||
const colorPickerTrigger = page.locator('[title="Change color"]').first();
|
||||
await colorPickerTrigger.click();
|
||||
|
||||
// Wait for the color picker dropdown to appear
|
||||
const colorPickerDropdown = page.locator('.tippy-box');
|
||||
await expect(colorPickerDropdown).toBeVisible();
|
||||
|
||||
// Find the slider and change its value
|
||||
const slider = colorPickerDropdown.locator('input[type="range"]');
|
||||
await expect(slider).toBeVisible();
|
||||
|
||||
// Move slider to middle position (50%)
|
||||
await slider.fill('50');
|
||||
|
||||
// Click the custom color preview to apply it
|
||||
const customColorPreview = colorPickerDropdown.locator('[title="Custom color"]');
|
||||
await customColorPreview.click();
|
||||
|
||||
// Verify the color badge has a color applied (not transparent)
|
||||
const activeEnvItem = page.locator('.environment-item.active');
|
||||
const colorBadge = activeEnvItem.locator('.rounded-full').first();
|
||||
const bgColor = await colorBadge.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(bgColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||
expect(bgColor).toMatch(/^rgb\(\d+, \d+, \d+\)$/);
|
||||
});
|
||||
|
||||
test('should display color badge in environment list after selecting color', async ({ pageWithUserData: page }) => {
|
||||
// Open global environment configuration
|
||||
await page.getByTestId('environment-selector-trigger').click();
|
||||
await page.getByTestId('env-tab-global').click();
|
||||
await page.getByText('Configure', { exact: true }).click();
|
||||
|
||||
// Wait for the environments tab to be visible
|
||||
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
|
||||
await expect(envTab).toBeVisible();
|
||||
|
||||
// Get the currently selected environment name
|
||||
const activeEnvItem = page.locator('.environment-item.active');
|
||||
const envName = await activeEnvItem.locator('.environment-name').textContent();
|
||||
|
||||
// Click on the color picker icon
|
||||
const colorPickerTrigger = page.locator('[title="Change color"]').first();
|
||||
await colorPickerTrigger.click();
|
||||
|
||||
// Wait for the color picker dropdown to appear and select a color
|
||||
const colorPickerDropdown = page.locator('.tippy-box');
|
||||
await expect(colorPickerDropdown).toBeVisible();
|
||||
|
||||
const presetColor = PRESET_COLORS[1]; // green
|
||||
const colorOption = colorPickerDropdown.locator(`[title="${presetColor}"]`);
|
||||
await colorOption.click();
|
||||
|
||||
// Verify the color badge in the environment list shows the selected color
|
||||
const envListItem = page.locator('.environment-item').filter({ hasText: envName as string });
|
||||
const colorBadge = envListItem.locator('.rounded-full').first();
|
||||
await expect(colorBadge).toHaveCSS('background-color', hexToRgb(presetColor));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"path": "{{projectRoot}}/tests/environments/global-env-config-selection/collection",
|
||||
"securityConfig": {
|
||||
"jsSandboxMode": "safe"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"environments": [
|
||||
{
|
||||
"uid": "FlaexlO7lcH7UtEpWsVyz",
|
||||
"name": "Development Environment",
|
||||
"variables": [
|
||||
{
|
||||
"uid": "lflBDSYBdHkUedYhBF4Ty",
|
||||
"name": "env_type",
|
||||
"value": "development",
|
||||
"type": "text",
|
||||
"secret": false,
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "MsHcnAIonZ3455OfvpTUT",
|
||||
"name": "Production Environment",
|
||||
"variables": [
|
||||
{
|
||||
"uid": "TZljXLErzW1nUWoozntZE",
|
||||
"name": "env_type",
|
||||
"value": "production",
|
||||
"type": "text",
|
||||
"secret": false,
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "VdUAdMPcfapMCqjKAeUiI",
|
||||
"name": "Staging Environment",
|
||||
"variables": [
|
||||
{
|
||||
"uid": "FwoWhHvu9eLhA8H4brG6f",
|
||||
"name": "env_type",
|
||||
"value": "staging",
|
||||
"type": "text",
|
||||
"secret": false,
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"activeGlobalEnvironmentUid": "MsHcnAIonZ3455OfvpTUT"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"maximized": true,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/tests/environments/global-env-config-selection/collection"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user