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 { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation'; import { createFastMutation } from '../hooks/useFastMutation';
import { showDialog } from '../lib/dialog';
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from '../lib/jotai';
import { showPrompt } from '../lib/prompt';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams'; import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
export const createSubEnvironmentAndActivate = createFastMutation< export const createSubEnvironmentAndActivate = createFastMutation<
@@ -21,24 +22,23 @@ export const createSubEnvironmentAndActivate = createFastMutation<
throw new Error('Cannot create environment when no active workspace'); throw new Error('Cannot create environment when no active workspace');
} }
const name = await showPrompt({ return new Promise<string | null>((resolve) => {
id: 'new-environment', showDialog({
title: 'New Environment', id: 'new-environment',
description: 'Create multiple environments with different sets of variables', title: 'New Environment',
label: 'Name', description: 'Create multiple environments with different sets of variables',
placeholder: 'My Environment', size: 'sm',
defaultValue: 'My Environment', onClose: () => resolve(null),
confirmText: 'Create', render: ({ hide }) => (
}); <CreateEnvironmentDialog
if (name == null) return null; workspaceId={workspaceId}
hide={hide}
return createWorkspaceModel({ onCreate={(id: string) => {
model: 'environment', resolve(id);
name, }}
variables: [], />
workspaceId, ),
parentId: baseEnvironment.id, });
parentModel: 'environment',
}); });
}, },
onSuccess: async (environmentId) => { 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 type { Environment } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { showColorPicker } from '../lib/showColorPicker'; import { showColorPicker } from '../lib/showColorPicker';
import { ColorIndicator } from './ColorIndicator';
export function EnvironmentColorIndicator({ export function EnvironmentColorIndicator({
environment, environment,
@@ -11,19 +11,10 @@ export function EnvironmentColorIndicator({
}) { }) {
if (environment?.color == null) return null; if (environment?.color == null) return null;
const style = { backgroundColor: environment.color }; return (
const className = <ColorIndicator
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent'; color={environment?.color ?? null}
onClick={clickToEdit ? () => showColorPicker(environment) : undefined}
if (clickToEdit) { />
return ( );
<button
onClick={() => showColorPicker(environment)}
style={style}
className={classNames(className, 'hover:border-text')}
/>
);
} else {
return <span style={style} className={className} />;
}
} }

View File

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

View File

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

View File

@@ -1,16 +1,20 @@
import classNames from 'classnames';
import { useState } from 'react';
import { HexColorPicker } from 'react-colorful'; import { HexColorPicker } from 'react-colorful';
import { useRandomKey } from '../../hooks/useRandomKey'; import { useRandomKey } from '../../hooks/useRandomKey';
import { Icon } from './Icon';
import { PlainInput } from './PlainInput'; import { PlainInput } from './PlainInput';
interface Props { interface Props {
onChange: (value: string | null) => void; onChange: (value: string | null) => void;
color: string | null; color: string | null;
className?: string;
} }
export function ColorPicker({ onChange, color }: Props) { export function ColorPicker({ onChange, color, className }: Props) {
const [updateKey, regenerateKey] = useRandomKey(); const [updateKey, regenerateKey] = useRandomKey();
return ( return (
<div> <div className={className}>
<HexColorPicker <HexColorPicker
color={color ?? undefined} color={color ?? undefined}
className="!w-full" className="!w-full"
@@ -30,3 +34,84 @@ export function ColorPicker({ onChange, color }: Props) {
</div> </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, CircleDollarSignIcon,
CircleFadingArrowUpIcon, CircleFadingArrowUpIcon,
CircleHelpIcon, CircleHelpIcon,
CircleOffIcon,
ClipboardPasteIcon, ClipboardPasteIcon,
ClockIcon, ClockIcon,
CodeIcon, CodeIcon,
@@ -191,6 +192,7 @@ const icons = {
git_fork: GitForkIcon, git_fork: GitForkIcon,
git_pull_request: GitPullRequestIcon, git_pull_request: GitPullRequestIcon,
grip_vertical: GripVerticalIcon, grip_vertical: GripVerticalIcon,
circle_off: CircleOffIcon,
hand: HandIcon, hand: HandIcon,
help: CircleHelpIcon, help: CircleHelpIcon,
history: HistoryIcon, history: HistoryIcon,

View File

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

View File

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