Better environment color picker (#282)

This commit is contained in:
Gregory Schier
2025-10-26 12:05:03 -07:00
committed by GitHub
parent 923b1ac830
commit 3f5b5a397c
10 changed files with 233 additions and 64 deletions

View File

@@ -1,8 +1,9 @@
import { createWorkspaceModel, type Environment } from '@yaakapp-internal/models';
import { type Environment } from '@yaakapp-internal/models';
import { CreateEnvironmentDialog } from '../components/CreateEnvironmentDialog';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { showDialog } from '../lib/dialog';
import { jotaiStore } from '../lib/jotai';
import { showPrompt } from '../lib/prompt';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
export const createSubEnvironmentAndActivate = createFastMutation<
@@ -21,24 +22,23 @@ export const createSubEnvironmentAndActivate = createFastMutation<
throw new Error('Cannot create environment when no active workspace');
}
const name = await showPrompt({
id: 'new-environment',
title: 'New Environment',
description: 'Create multiple environments with different sets of variables',
label: 'Name',
placeholder: 'My Environment',
defaultValue: 'My Environment',
confirmText: 'Create',
});
if (name == null) return null;
return createWorkspaceModel({
model: 'environment',
name,
variables: [],
workspaceId,
parentId: baseEnvironment.id,
parentModel: 'environment',
return new Promise<string | null>((resolve) => {
showDialog({
id: 'new-environment',
title: 'New Environment',
description: 'Create multiple environments with different sets of variables',
size: 'sm',
onClose: () => resolve(null),
render: ({ hide }) => (
<CreateEnvironmentDialog
workspaceId={workspaceId}
hide={hide}
onCreate={(id: string) => {
resolve(id);
}}
/>
),
});
});
},
onSuccess: async (environmentId) => {

View File

@@ -0,0 +1,25 @@
import classNames from 'classnames';
import type { CSSProperties } from 'react';
interface Props {
color: string | null;
onClick?: () => void;
}
export function ColorIndicator({ color, onClick }: Props) {
const style: CSSProperties = { backgroundColor: color ?? undefined };
const className =
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent';
if (onClick) {
return (
<button
onClick={onClick}
style={style}
className={classNames(className, 'hover:border-text')}
/>
);
} else {
return <span style={style} className={className} />;
}
}

View File

@@ -0,0 +1,68 @@
import { createWorkspaceModel } from '@yaakapp-internal/models';
import { useState } from 'react';
import { useToggle } from '../hooks/useToggle';
import { ColorIndicator } from './ColorIndicator';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { ColorPickerWithThemeColors } from './core/ColorPicker';
import { Label } from './core/Label';
import { PlainInput } from './core/PlainInput';
interface Props {
onCreate: (id: string) => void;
hide: () => void;
workspaceId: string;
}
export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) {
const [name, setName] = useState<string>('');
const [color, setColor] = useState<string | null>(null);
const [sharable, toggleSharable] = useToggle(false);
return (
<form
className="pb-3 flex flex-col gap-3"
onSubmit={async (e) => {
e.preventDefault();
const id = await createWorkspaceModel({
model: 'environment',
name,
color,
variables: [],
public: sharable,
workspaceId,
parentModel: 'environment',
});
hide();
onCreate(id);
}}
>
<PlainInput
label="Name"
required
defaultValue={name}
onChange={setName}
placeholder="Production"
/>
<Checkbox
checked={sharable}
title="Share this environment"
help="Sharable environments are included in data export and directory sync."
onChange={toggleSharable}
/>
<div>
<Label
htmlFor="color"
className="mb-1.5"
help="Select a color to be displayed when this environment is active, to help identify it."
>
Color
</Label>
<ColorPickerWithThemeColors onChange={setColor} color={color} />
</div>
<Button type="submit" color="secondary" className="mt-3">
{color != null && <ColorIndicator color={color} />}
Create Environment
</Button>
</form>
);
}

View File

@@ -1,6 +1,6 @@
import type { Environment } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { showColorPicker } from '../lib/showColorPicker';
import { ColorIndicator } from './ColorIndicator';
export function EnvironmentColorIndicator({
environment,
@@ -11,19 +11,10 @@ export function EnvironmentColorIndicator({
}) {
if (environment?.color == null) return null;
const style = { backgroundColor: environment.color };
const className =
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent';
if (clickToEdit) {
return (
<button
onClick={() => showColorPicker(environment)}
style={style}
className={classNames(className, 'hover:border-text')}
/>
);
} else {
return <span style={style} className={className} />;
}
return (
<ColorIndicator
color={environment?.color ?? null}
onClick={clickToEdit ? () => showColorPicker(environment) : undefined}
/>
);
}

View File

@@ -1,6 +1,8 @@
import { useState } from 'react';
import { ColorIndicator } from './ColorIndicator';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { ColorPicker } from './core/ColorPicker';
import { ColorPickerWithThemeColors } from './core/ColorPicker';
export function EnvironmentColorPicker({
color: defaultColor,
@@ -12,21 +14,20 @@ export function EnvironmentColorPicker({
const [color, setColor] = useState<string | null>(defaultColor);
return (
<form
className="flex flex-col items-stretch gap-3 pb-2 w-full"
className="flex flex-col items-stretch gap-5 pb-2 w-full"
onSubmit={(e) => {
e.preventDefault();
onChange(color);
}}
>
<ColorPicker color={color} onChange={setColor} />
<div className="grid grid-cols-[1fr_1fr] gap-1.5">
<Button variant="border" color="secondary" onClick={() => onChange(null)}>
Clear
</Button>
<Button type="submit" color="primary">
Save
</Button>
</div>
<Banner color="secondary">
This color will be used to color the interface when this environment is active
</Banner>
<ColorPickerWithThemeColors color={color} onChange={setColor} />
<Button type="submit" color="secondary">
{color != null && <ColorIndicator color={color} />}
Save
</Button>
</form>
);
}

View File

@@ -232,17 +232,14 @@ function EnvironmentDialogSidebarButton({
await patchModel(environment, { name });
},
},
...((duplicateEnvironment
? [
{
label: 'Duplicate',
leftSlot: <Icon icon="copy" />,
onSelect: () => {
duplicateEnvironment?.(environment);
},
},
]
: []) as DropdownItem[]),
{
label: 'Duplicate',
leftSlot: <Icon icon="copy" />,
hidden: isBaseEnvironment(environment),
onSelect: () => {
duplicateEnvironment?.(environment);
},
},
{
label: environment.color ? 'Change Color' : 'Assign Color',
leftSlot: <Icon icon="palette" />,

View File

@@ -1,16 +1,20 @@
import classNames from 'classnames';
import { useState } from 'react';
import { HexColorPicker } from 'react-colorful';
import { useRandomKey } from '../../hooks/useRandomKey';
import { Icon } from './Icon';
import { PlainInput } from './PlainInput';
interface Props {
onChange: (value: string | null) => void;
color: string | null;
className?: string;
}
export function ColorPicker({ onChange, color }: Props) {
export function ColorPicker({ onChange, color, className }: Props) {
const [updateKey, regenerateKey] = useRandomKey();
return (
<div>
<div className={className}>
<HexColorPicker
color={color ?? undefined}
className="!w-full"
@@ -30,3 +34,84 @@ export function ColorPicker({ onChange, color }: Props) {
</div>
);
}
const colors = [
null,
'danger',
'warning',
'notice',
'success',
'primary',
'info',
'secondary',
'custom',
] as const;
export function ColorPickerWithThemeColors({ onChange, color, className }: Props) {
const [updateKey, regenerateKey] = useRandomKey();
const [selectedColor, setSelectedColor] = useState<string | null>(() => {
if (color == null) return null;
const c = color?.match(/var\(--([a-z]+)\)/)?.[1];
return c ?? 'custom';
});
return (
<div className={classNames(className, 'flex flex-col gap-3')}>
<div className="flex items-center gap-2.5">
{colors.map((color) => (
<button
type="button"
key={color}
onClick={() => {
setSelectedColor(color);
if (color == null) {
onChange(null);
} else if (color === 'custom') {
onChange('#ffffff');
} else {
onChange(`var(--${color})`);
}
}}
className={classNames(
'flex items-center justify-center',
'w-8 h-8 rounded-full transition-all',
selectedColor === color && 'scale-[1.15]',
selectedColor === color ? 'opacity-100' : 'opacity-60',
color === null && 'border border-text-subtle',
color === 'primary' && 'bg-primary',
color === 'secondary' && 'bg-secondary',
color === 'success' && 'bg-success',
color === 'notice' && 'bg-notice',
color === 'warning' && 'bg-warning',
color === 'danger' && 'bg-danger',
color === 'info' && 'bg-info',
color === 'custom' &&
'bg-[conic-gradient(var(--danger),var(--warning),var(--notice),var(--success),var(--info),var(--primary),var(--danger))]',
)}
>
{color == null && <Icon icon="minus" className="text-text-subtle" size="md" />}
</button>
))}
</div>
{selectedColor === 'custom' && (
<>
<HexColorPicker
color={color ?? undefined}
className="!w-full"
onChange={(color) => {
onChange(color);
regenerateKey(); // To force input to change
}}
/>
<PlainInput
hideLabel
label="Plain Color"
forceUpdateKey={updateKey}
defaultValue={color ?? ''}
onChange={onChange}
validate={(color) => color.match(/#[0-9a-fA-F]{6}$/) !== null}
/>
</>
)}
</div>
);
}

View File

@@ -30,6 +30,7 @@ import {
CircleDollarSignIcon,
CircleFadingArrowUpIcon,
CircleHelpIcon,
CircleOffIcon,
ClipboardPasteIcon,
ClockIcon,
CodeIcon,
@@ -191,6 +192,7 @@ const icons = {
git_fork: GitForkIcon,
git_pull_request: GitPullRequestIcon,
grip_vertical: GripVerticalIcon,
circle_off: CircleOffIcon,
hand: HandIcon,
help: CircleHelpIcon,
history: HistoryIcon,

View File

@@ -49,7 +49,7 @@ export function getCharsetFromContentType(headers: HttpResponseHeader[]): string
}
export function isBaseEnvironment(environment: Environment): boolean {
return environment.parentId == null;
return environment.parentModel == 'workspace';
}
export function isSubEnvironment(environment: Environment): boolean {

View File

@@ -1,17 +1,17 @@
import type { Environment } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import { showDialog } from './dialog';
import { EnvironmentColorPicker } from '../components/EnvironmentColorPicker';
import { showDialog } from './dialog';
export function showColorPicker(environment: Environment) {
showDialog({
title: 'Environment Color',
id: 'color-picker',
size: 'dynamic',
size: 'sm',
render: ({ hide }) => {
return (
<EnvironmentColorPicker
color={environment.color ?? '#54dc44'}
color={environment.color}
onChange={async (color) => {
await patchModel(environment, { color });
hide();