Compare commits
2 Commits
pubsub-buf
...
v2.23.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a68676b84 | ||
|
|
d3b6863ae9 |
@@ -1594,12 +1594,14 @@ func writeCoderConnectNetInfo(ctx context.Context, networkInfoDir string) error
|
||||
// Converts workspace name input to owner/workspace.agent format
|
||||
// Possible valid input formats:
|
||||
// workspace
|
||||
// workspace.agent
|
||||
// owner/workspace
|
||||
// owner--workspace
|
||||
// owner/workspace--agent
|
||||
// owner/workspace.agent
|
||||
// owner--workspace--agent
|
||||
// owner--workspace.agent
|
||||
// agent.workspace.owner - for parity with Coder Connect
|
||||
func normalizeWorkspaceInput(input string) string {
|
||||
// Split on "/", "--", and "."
|
||||
parts := workspaceNameRe.Split(input, -1)
|
||||
@@ -1608,8 +1610,15 @@ func normalizeWorkspaceInput(input string) string {
|
||||
case 1:
|
||||
return input // "workspace"
|
||||
case 2:
|
||||
if strings.Contains(input, ".") {
|
||||
return fmt.Sprintf("%s.%s", parts[0], parts[1]) // "workspace.agent"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", parts[0], parts[1]) // "owner/workspace"
|
||||
case 3:
|
||||
// If the only separator is a dot, it's the Coder Connect format
|
||||
if !strings.Contains(input, "/") && !strings.Contains(input, "--") {
|
||||
return fmt.Sprintf("%s/%s.%s", parts[2], parts[1], parts[0]) // "owner/workspace.agent"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s.%s", parts[0], parts[1], parts[2]) // "owner/workspace.agent"
|
||||
default:
|
||||
return input // Fallback
|
||||
|
||||
@@ -107,12 +107,14 @@ func TestSSH(t *testing.T) {
|
||||
|
||||
cases := []string{
|
||||
"myworkspace",
|
||||
"myworkspace.dev",
|
||||
"myuser/myworkspace",
|
||||
"myuser--myworkspace",
|
||||
"myuser/myworkspace--dev",
|
||||
"myuser/myworkspace.dev",
|
||||
"myuser--myworkspace--dev",
|
||||
"myuser--myworkspace.dev",
|
||||
"dev.myworkspace.myuser",
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -860,7 +860,7 @@ func New(options *Options) *API {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
},
|
||||
// httpmw.CSRF(options.DeploymentValues.HTTPCookies),
|
||||
httpmw.CSRF(options.DeploymentValues.HTTPCookies),
|
||||
)
|
||||
|
||||
// This incurs a performance hit from the middleware, but is required to make sure
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
@@ -211,6 +212,86 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
|
||||
require.Zero(t, setup.api.FileCache.Count())
|
||||
})
|
||||
|
||||
t.Run("RebuildParameters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/modules/main.tf")
|
||||
require.NoError(t, err)
|
||||
|
||||
modulesArchive, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules"))
|
||||
require.NoError(t, err)
|
||||
|
||||
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
|
||||
provisionerDaemonVersion: provProto.CurrentVersion.String(),
|
||||
mainTF: dynamicParametersTerraformSource,
|
||||
modulesArchive: modulesArchive,
|
||||
plan: nil,
|
||||
static: nil,
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
stream := setup.stream
|
||||
previews := stream.Chan()
|
||||
|
||||
// Should see the output of the module represented
|
||||
preview := testutil.RequireReceive(ctx, t, previews)
|
||||
require.Equal(t, -1, preview.ID)
|
||||
require.Empty(t, preview.Diagnostics)
|
||||
|
||||
require.Len(t, preview.Parameters, 1)
|
||||
require.Equal(t, "jetbrains_ide", preview.Parameters[0].Name)
|
||||
require.True(t, preview.Parameters[0].Value.Valid)
|
||||
require.Equal(t, "CL", preview.Parameters[0].Value.Value)
|
||||
_ = stream.Close(websocket.StatusGoingAway)
|
||||
|
||||
wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID, func(request *codersdk.CreateWorkspaceRequest) {
|
||||
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
|
||||
{
|
||||
Name: preview.Parameters[0].Name,
|
||||
Value: "GO",
|
||||
},
|
||||
}
|
||||
})
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID)
|
||||
|
||||
params, err := setup.client.WorkspaceBuildParameters(ctx, wrk.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, params, 1)
|
||||
require.Equal(t, "jetbrains_ide", params[0].Name)
|
||||
require.Equal(t, "GO", params[0].Value)
|
||||
|
||||
// A helper function to assert params
|
||||
doTransition := func(t *testing.T, trans codersdk.WorkspaceTransition) {
|
||||
t.Helper()
|
||||
|
||||
fooVal := coderdtest.RandomUsername(t)
|
||||
bld, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
TemplateVersionID: setup.template.ActiveVersionID,
|
||||
Transition: trans,
|
||||
RichParameterValues: []codersdk.WorkspaceBuildParameter{
|
||||
// No validation, so this should work as is.
|
||||
// Overwrite the value on each transition
|
||||
{Name: "foo", Value: fooVal},
|
||||
},
|
||||
EnableDynamicParameters: ptr.Ref(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID)
|
||||
|
||||
latestParams, err := setup.client.WorkspaceBuildParameters(ctx, bld.ID)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, latestParams, []codersdk.WorkspaceBuildParameter{
|
||||
{Name: "jetbrains_ide", Value: "GO"},
|
||||
{Name: "foo", Value: fooVal},
|
||||
})
|
||||
}
|
||||
|
||||
// Restart the workspace, then delete. Asserting params on all builds.
|
||||
doTransition(t, codersdk.WorkspaceTransitionStop)
|
||||
doTransition(t, codersdk.WorkspaceTransitionStart)
|
||||
doTransition(t, codersdk.WorkspaceTransitionDelete)
|
||||
})
|
||||
|
||||
t.Run("BadOwner", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -266,9 +347,10 @@ type setupDynamicParamsTestParams struct {
|
||||
}
|
||||
|
||||
type dynamicParamsTest struct {
|
||||
client *codersdk.Client
|
||||
api *coderd.API
|
||||
stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest]
|
||||
client *codersdk.Client
|
||||
api *coderd.API
|
||||
stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest]
|
||||
template codersdk.Template
|
||||
}
|
||||
|
||||
func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dynamicParamsTest {
|
||||
@@ -300,7 +382,7 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, files)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID)
|
||||
_ = coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
|
||||
tpl := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, version.ID)
|
||||
@@ -321,9 +403,10 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
|
||||
})
|
||||
|
||||
return dynamicParamsTest{
|
||||
client: ownerClient,
|
||||
stream: stream,
|
||||
api: api,
|
||||
client: ownerClient,
|
||||
api: api,
|
||||
stream: stream,
|
||||
template: tpl,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -623,6 +623,11 @@ func (b *Builder) getParameters() (names, values []string, err error) {
|
||||
return nil, nil, BuildError{http.StatusBadRequest, "Unable to build workspace with unsupported parameters", err}
|
||||
}
|
||||
|
||||
lastBuildParameterValues := db2sdk.WorkspaceBuildParameters(lastBuildParameters)
|
||||
resolver := codersdk.ParameterResolver{
|
||||
Rich: lastBuildParameterValues,
|
||||
}
|
||||
|
||||
// Dynamic parameters skip all parameter validation.
|
||||
// Deleting a workspace also should skip parameter validation.
|
||||
// Pass the user's input as is.
|
||||
@@ -632,19 +637,34 @@ func (b *Builder) getParameters() (names, values []string, err error) {
|
||||
// conditional parameter existence, the static frame of reference
|
||||
// is not sufficient. So assume the user is correct, or pull in the
|
||||
// dynamic param code to find the actual parameters.
|
||||
latestValues := make(map[string]string, len(b.richParameterValues))
|
||||
for _, latest := range b.richParameterValues {
|
||||
latestValues[latest.Name] = latest.Value
|
||||
}
|
||||
|
||||
// Merge the inputs with values from the previous build.
|
||||
for _, last := range lastBuildParameterValues {
|
||||
// TODO: Ideally we use the resolver here and look at parameter
|
||||
// fields such as 'ephemeral'. This requires loading the terraform
|
||||
// files. For now, just send the previous inputs as is.
|
||||
if _, exists := latestValues[last.Name]; exists {
|
||||
// latestValues take priority, so skip this previous value.
|
||||
continue
|
||||
}
|
||||
names = append(names, last.Name)
|
||||
values = append(values, last.Value)
|
||||
}
|
||||
|
||||
for _, value := range b.richParameterValues {
|
||||
names = append(names, value.Name)
|
||||
values = append(values, value.Value)
|
||||
}
|
||||
|
||||
b.parameterNames = &names
|
||||
b.parameterValues = &values
|
||||
return names, values, nil
|
||||
}
|
||||
|
||||
resolver := codersdk.ParameterResolver{
|
||||
Rich: db2sdk.WorkspaceBuildParameters(lastBuildParameters),
|
||||
}
|
||||
|
||||
for _, templateVersionParameter := range templateVersionParameters {
|
||||
tvp, err := db2sdk.TemplateVersionParameter(templateVersionParameter)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,27 +12,30 @@ const meta: Meta<typeof FeatureStageBadge> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FeatureStageBadge>;
|
||||
|
||||
export const MediumBeta: Story = {
|
||||
args: {
|
||||
size: "md",
|
||||
},
|
||||
};
|
||||
|
||||
export const SmallBeta: Story = {
|
||||
args: {
|
||||
size: "sm",
|
||||
contentType: "beta",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeBeta: Story = {
|
||||
args: {
|
||||
size: "lg",
|
||||
},
|
||||
};
|
||||
|
||||
export const MediumExperimental: Story = {
|
||||
export const MediumBeta: Story = {
|
||||
args: {
|
||||
size: "md",
|
||||
contentType: "experimental",
|
||||
contentType: "beta",
|
||||
},
|
||||
};
|
||||
|
||||
export const SmallEarlyAccess: Story = {
|
||||
args: {
|
||||
size: "sm",
|
||||
contentType: "early_access",
|
||||
},
|
||||
};
|
||||
|
||||
export const MediumEarlyAccess: Story = {
|
||||
args: {
|
||||
size: "md",
|
||||
contentType: "early_access",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import Link from "@mui/material/Link";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip";
|
||||
import { Popover, PopoverTrigger } from "components/deprecated/Popover/Popover";
|
||||
import { Link } from "components/Link/Link";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import type { FC, HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { docs } from "utils/docs";
|
||||
|
||||
/**
|
||||
@@ -11,132 +14,73 @@ import { docs } from "utils/docs";
|
||||
* ensure that we can't accidentally make typos when writing the badge text.
|
||||
*/
|
||||
export const featureStageBadgeTypes = {
|
||||
early_access: "early access",
|
||||
beta: "beta",
|
||||
experimental: "experimental",
|
||||
} as const satisfies Record<string, ReactNode>;
|
||||
|
||||
type FeatureStageBadgeProps = Readonly<
|
||||
Omit<HTMLAttributes<HTMLSpanElement>, "children"> & {
|
||||
contentType: keyof typeof featureStageBadgeTypes;
|
||||
labelText?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
showTooltip?: boolean;
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
>;
|
||||
|
||||
const badgeColorClasses = {
|
||||
early_access: "bg-surface-orange text-content-warning",
|
||||
beta: "bg-surface-sky text-highlight-sky",
|
||||
} as const;
|
||||
|
||||
const badgeSizeClasses = {
|
||||
sm: "text-xs font-medium px-2 py-1",
|
||||
md: "text-base px-2 py-1",
|
||||
} as const;
|
||||
|
||||
export const FeatureStageBadge: FC<FeatureStageBadgeProps> = ({
|
||||
contentType,
|
||||
labelText = "",
|
||||
size = "md",
|
||||
showTooltip = true, // This is a temporary until the deprecated popover is removed
|
||||
className,
|
||||
...delegatedProps
|
||||
}) => {
|
||||
const colorClasses = badgeColorClasses[contentType];
|
||||
const sizeClasses = badgeSizeClasses[size];
|
||||
|
||||
return (
|
||||
<Popover mode="hover">
|
||||
<PopoverTrigger>
|
||||
{({ isOpen }) => (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
css={[
|
||||
styles.badge,
|
||||
size === "sm" && styles.badgeSmallText,
|
||||
size === "lg" && styles.badgeLargeText,
|
||||
isOpen && styles.badgeHover,
|
||||
]}
|
||||
className={cn(
|
||||
"block max-w-fit cursor-default flex-shrink-0 leading-none whitespace-nowrap border rounded-md transition-colors duration-200 ease-in-out bg-transparent border-solid border-transparent",
|
||||
sizeClasses,
|
||||
colorClasses,
|
||||
className,
|
||||
)}
|
||||
{...delegatedProps}
|
||||
>
|
||||
<span style={visuallyHidden}> (This is a</span>
|
||||
<span className="sr-only"> (This is a</span>
|
||||
<span className="first-letter:uppercase">
|
||||
{labelText && `${labelText} `}
|
||||
{featureStageBadgeTypes[contentType]}
|
||||
</span>
|
||||
<span style={visuallyHidden}> feature)</span>
|
||||
<span className="sr-only"> feature)</span>
|
||||
</span>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
|
||||
{showTooltip && (
|
||||
<HelpTooltipContent
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "center" }}
|
||||
>
|
||||
<p css={styles.tooltipDescription}>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" className="max-w-xs text-sm">
|
||||
<p className="m-0">
|
||||
This feature has not yet reached general availability (GA).
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={docs("/install/releases/feature-stages")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
css={styles.tooltipLink}
|
||||
className="font-semibold"
|
||||
>
|
||||
Learn about feature stages
|
||||
<span style={visuallyHidden}> (link opens in new tab)</span>
|
||||
<span className="sr-only"> (link opens in new tab)</span>
|
||||
</Link>
|
||||
</HelpTooltipContent>
|
||||
)}
|
||||
</Popover>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
badge: (theme) => ({
|
||||
// Base type is based on a span so that the element can be placed inside
|
||||
// more types of HTML elements without creating invalid markdown, but we
|
||||
// still want the default display behavior to be div-like
|
||||
display: "block",
|
||||
maxWidth: "fit-content",
|
||||
|
||||
// Base style assumes that medium badges will be the default
|
||||
fontSize: "0.75rem",
|
||||
|
||||
cursor: "default",
|
||||
flexShrink: 0,
|
||||
padding: "4px 8px",
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
border: `1px solid ${theme.branding.featureStage.border}`,
|
||||
color: theme.branding.featureStage.text,
|
||||
backgroundColor: theme.branding.featureStage.background,
|
||||
borderRadius: "6px",
|
||||
transition:
|
||||
"color 0.2s ease-in-out, border-color 0.2s ease-in-out, background-color 0.2s ease-in-out",
|
||||
}),
|
||||
|
||||
badgeHover: (theme) => ({
|
||||
color: theme.branding.featureStage.hover.text,
|
||||
borderColor: theme.branding.featureStage.hover.border,
|
||||
backgroundColor: theme.branding.featureStage.hover.background,
|
||||
}),
|
||||
|
||||
badgeLargeText: {
|
||||
fontSize: "1rem",
|
||||
},
|
||||
|
||||
badgeSmallText: {
|
||||
// Have to beef up font weight so that the letters still maintain the
|
||||
// same relative thickness as all our other main UI text
|
||||
fontWeight: 500,
|
||||
fontSize: "0.625rem",
|
||||
},
|
||||
|
||||
tooltipTitle: (theme) => ({
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 600,
|
||||
fontFamily: "inherit",
|
||||
fontSize: 18,
|
||||
margin: 0,
|
||||
lineHeight: 1,
|
||||
paddingBottom: "8px",
|
||||
}),
|
||||
|
||||
tooltipDescription: {
|
||||
margin: 0,
|
||||
lineHeight: 1.4,
|
||||
paddingBottom: "8px",
|
||||
},
|
||||
|
||||
tooltipLink: {
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
} as const satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -73,6 +73,9 @@ export const AgentSSHButton: FC<AgentSSHButtonProps> = ({
|
||||
>
|
||||
Connect via JetBrains Gateway
|
||||
</HelpTooltipLink>
|
||||
<HelpTooltipLink href={docs("/user-guides/desktop")}>
|
||||
Connect via Coder Desktop
|
||||
</HelpTooltipLink>
|
||||
<HelpTooltipLink href={docs("/user-guides/workspace-access#ssh")}>
|
||||
SSH configuration
|
||||
</HelpTooltipLink>
|
||||
|
||||
@@ -84,6 +84,7 @@ export const DynamicParameter: FC<DynamicParameterProps> = ({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
isPreset={isPreset}
|
||||
/>
|
||||
) : (
|
||||
<ParameterField
|
||||
@@ -231,6 +232,7 @@ interface DebouncedParameterFieldProps {
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
id: string;
|
||||
isPreset?: boolean;
|
||||
}
|
||||
|
||||
const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
|
||||
@@ -239,6 +241,7 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
|
||||
onChange,
|
||||
disabled,
|
||||
id,
|
||||
isPreset,
|
||||
}) => {
|
||||
const [localValue, setLocalValue] = useState(
|
||||
value !== undefined ? value : validValue(parameter.value),
|
||||
@@ -251,19 +254,26 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
|
||||
|
||||
// This is necessary in the case of fields being set by preset parameters
|
||||
useEffect(() => {
|
||||
if (value !== undefined && value !== prevValueRef.current) {
|
||||
if (isPreset && value !== undefined && value !== prevValueRef.current) {
|
||||
setLocalValue(value);
|
||||
prevValueRef.current = value;
|
||||
}
|
||||
}, [value]);
|
||||
}, [value, isPreset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevDebouncedValueRef.current !== undefined) {
|
||||
// Only call onChangeEvent if debouncedLocalValue is different from the previously committed value
|
||||
// and it's not the initial undefined state.
|
||||
if (
|
||||
prevDebouncedValueRef.current !== undefined &&
|
||||
prevDebouncedValueRef.current !== debouncedLocalValue
|
||||
) {
|
||||
onChangeEvent(debouncedLocalValue);
|
||||
}
|
||||
|
||||
// Update the ref to the current debounced value for the next comparison
|
||||
prevDebouncedValueRef.current = debouncedLocalValue;
|
||||
}, [debouncedLocalValue, onChangeEvent]);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const resizeTextarea = useEffectEvent(() => {
|
||||
@@ -513,7 +523,9 @@ const ParameterField: FC<ParameterFieldProps> = ({
|
||||
max={parameter.validations[0]?.validation_max ?? 100}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="w-4 font-medium">{parameter.value.value}</span>
|
||||
<span className="w-4 font-medium">
|
||||
{Number.isFinite(Number(value)) ? value : "0"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case "error":
|
||||
|
||||
@@ -3,12 +3,12 @@ import type { FriendlyDiagnostic, PreviewParameter } from "api/typesGenerated";
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
|
||||
import { Input } from "components/Input/Input";
|
||||
import { Label } from "components/Label/Label";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -353,21 +353,39 @@ export const CreateWorkspacePageViewExperimental: FC<
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 max-w-screen-md mx-auto">
|
||||
<header className="flex flex-col items-start gap-3 mt-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
variant="icon"
|
||||
size="md"
|
||||
src={template.icon}
|
||||
fallback={template.name}
|
||||
/>
|
||||
<p className="text-base font-medium m-0">
|
||||
{template.display_name.length > 0
|
||||
? template.display_name
|
||||
: template.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 justify-between w-full">
|
||||
<span className="flex items-center gap-2">
|
||||
<Avatar
|
||||
variant="icon"
|
||||
size="md"
|
||||
src={template.icon}
|
||||
fallback={template.name}
|
||||
/>
|
||||
<p className="text-base font-medium m-0">
|
||||
{template.display_name.length > 0
|
||||
? template.display_name
|
||||
: template.name}
|
||||
</p>
|
||||
{template.deprecated && (
|
||||
<Badge variant="warning" size="sm">
|
||||
Deprecated
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
{experimentalFormContext && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={experimentalFormContext.toggleOptedOut}
|
||||
>
|
||||
<Undo2 />
|
||||
Classic workspace creation
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex flex-row items-center gap-2">
|
||||
<h1 className="text-3xl font-semibold m-0">New workspace</h1>
|
||||
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -389,19 +407,11 @@ export const CreateWorkspacePageViewExperimental: FC<
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
|
||||
{template.deprecated && <Pill type="warning">Deprecated</Pill>}
|
||||
|
||||
{experimentalFormContext && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={experimentalFormContext.toggleOptedOut}
|
||||
>
|
||||
<Undo2 />
|
||||
Use the classic workspace creation flow
|
||||
</Button>
|
||||
)}
|
||||
<FeatureStageBadge
|
||||
contentType={"early_access"}
|
||||
size="sm"
|
||||
labelText="Dynamic parameters"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<form
|
||||
@@ -555,7 +565,7 @@ export const CreateWorkspacePageViewExperimental: FC<
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Label className="text-sm">Preset</Label>
|
||||
<FeatureStageBadge contentType={"beta"} size="md" />
|
||||
<FeatureStageBadge contentType={"beta"} size="sm" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="max-w-lg">
|
||||
|
||||
@@ -53,7 +53,7 @@ export const Section: FC<SectionProps> = ({
|
||||
{featureStage && (
|
||||
<FeatureStageBadge
|
||||
contentType={featureStage}
|
||||
size="lg"
|
||||
size="md"
|
||||
css={{ marginBottom: "5px" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -117,18 +117,18 @@ export const WorkspaceParametersPageView: FC<
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<header className="flex flex-col items-start gap-2">
|
||||
<span className="flex flex-row justify-between items-center gap-2">
|
||||
<span className="flex flex-row justify-between w-full items-center gap-2">
|
||||
<h1 className="text-3xl m-0">Workspace parameters</h1>
|
||||
{experimentalFormContext && (
|
||||
<ShadcnButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={experimentalFormContext.toggleOptedOut}
|
||||
>
|
||||
Try out the new workspace parameters ✨
|
||||
</ShadcnButton>
|
||||
)}
|
||||
</span>
|
||||
{experimentalFormContext && (
|
||||
<ShadcnButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={experimentalFormContext.toggleOptedOut}
|
||||
>
|
||||
Try out the new workspace parameters ✨
|
||||
</ShadcnButton>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{submitError && !isApiValidationError(submitError) ? (
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import { useMutation, useQuery } from "react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { docs } from "utils/docs";
|
||||
import { pageTitle } from "utils/page";
|
||||
import type { AutofillBuildParameter } from "utils/richParameters";
|
||||
import {
|
||||
type WorkspacePermissions,
|
||||
workspaceChecks,
|
||||
@@ -39,11 +41,27 @@ const WorkspaceParametersPageExperimental: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const experimentalFormContext = useContext(ExperimentalFormContext);
|
||||
|
||||
// autofill the form with the workspace build parameters from the latest build
|
||||
const {
|
||||
data: latestBuildParameters,
|
||||
isLoading: latestBuildParametersLoading,
|
||||
} = useQuery({
|
||||
queryKey: ["workspaceBuilds", workspace.latest_build.id, "parameters"],
|
||||
queryFn: () => API.getWorkspaceBuildParameters(workspace.latest_build.id),
|
||||
});
|
||||
|
||||
const [latestResponse, setLatestResponse] =
|
||||
useState<DynamicParametersResponse | null>(null);
|
||||
const wsResponseId = useRef<number>(-1);
|
||||
const ws = useRef<WebSocket | null>(null);
|
||||
const [wsError, setWsError] = useState<Error | null>(null);
|
||||
const initialParamsSentRef = useRef(false);
|
||||
|
||||
const autofillParameters: AutofillBuildParameter[] =
|
||||
latestBuildParameters?.map((p) => ({
|
||||
...p,
|
||||
source: "active_build",
|
||||
})) ?? [];
|
||||
|
||||
const sendMessage = useEffectEvent((formValues: Record<string, string>) => {
|
||||
const request: DynamicParametersRequest = {
|
||||
@@ -57,11 +75,34 @@ const WorkspaceParametersPageExperimental: FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// On page load, sends initial workspace build parameters to the websocket.
|
||||
// This ensures the backend has the form's complete initial state,
|
||||
// vital for rendering dynamic UI elements dependent on initial parameter values.
|
||||
const sendInitialParameters = useEffectEvent(() => {
|
||||
if (initialParamsSentRef.current) return;
|
||||
if (autofillParameters.length === 0) return;
|
||||
|
||||
const initialParamsToSend: Record<string, string> = {};
|
||||
for (const param of autofillParameters) {
|
||||
if (param.name && param.value) {
|
||||
initialParamsToSend[param.name] = param.value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(initialParamsToSend).length === 0) return;
|
||||
|
||||
sendMessage(initialParamsToSend);
|
||||
initialParamsSentRef.current = true;
|
||||
});
|
||||
|
||||
const onMessage = useEffectEvent((response: DynamicParametersResponse) => {
|
||||
if (latestResponse && latestResponse?.id >= response.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initialParamsSentRef.current && response.parameters?.length > 0) {
|
||||
sendInitialParameters();
|
||||
}
|
||||
|
||||
setLatestResponse(response);
|
||||
});
|
||||
|
||||
@@ -149,6 +190,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
|
||||
const error = wsError || updateParameters.error;
|
||||
|
||||
if (
|
||||
latestBuildParametersLoading ||
|
||||
!latestResponse ||
|
||||
(ws.current && ws.current.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
@@ -162,39 +204,46 @@ const WorkspaceParametersPageExperimental: FC = () => {
|
||||
</Helmet>
|
||||
|
||||
<header className="flex flex-col items-start gap-2">
|
||||
<span className="flex flex-row items-center gap-2">
|
||||
<h1 className="text-3xl m-0">Workspace parameters</h1>
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleHelp className="size-icon-xs text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm">
|
||||
Dynamic Parameters enhances Coder's existing parameter system
|
||||
with real-time validation, conditional parameter behavior, and
|
||||
richer input types.
|
||||
<br />
|
||||
<Link
|
||||
href={docs(
|
||||
"/admin/templates/extending-templates/parameters#enable-dynamic-parameters-early-access",
|
||||
)}
|
||||
>
|
||||
View docs
|
||||
</Link>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<span className="flex flex-row items-center gap-2 justify-between w-full">
|
||||
<span className="flex flex-row items-center gap-2">
|
||||
<h1 className="text-3xl m-0">Workspace parameters</h1>
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleHelp className="size-icon-xs text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm">
|
||||
Dynamic Parameters enhances Coder's existing parameter system
|
||||
with real-time validation, conditional parameter behavior, and
|
||||
richer input types.
|
||||
<br />
|
||||
<Link
|
||||
href={docs(
|
||||
"/admin/templates/extending-templates/parameters#enable-dynamic-parameters-early-access",
|
||||
)}
|
||||
>
|
||||
View docs
|
||||
</Link>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
{experimentalFormContext && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={experimentalFormContext.toggleOptedOut}
|
||||
>
|
||||
<Undo2 />
|
||||
Classic workspace parameters
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
{experimentalFormContext && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={experimentalFormContext.toggleOptedOut}
|
||||
>
|
||||
<Undo2 />
|
||||
Use the classic workspace parameters
|
||||
</Button>
|
||||
)}
|
||||
<FeatureStageBadge
|
||||
contentType={"early_access"}
|
||||
size="sm"
|
||||
labelText="Dynamic parameters"
|
||||
/>
|
||||
</header>
|
||||
|
||||
{Boolean(error) && <ErrorAlert error={error} />}
|
||||
@@ -202,6 +251,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
|
||||
{sortedParams.length > 0 ? (
|
||||
<WorkspaceParametersPageViewExperimental
|
||||
workspace={workspace}
|
||||
autofillParameters={autofillParameters}
|
||||
canChangeVersions={canChangeVersions}
|
||||
parameters={sortedParams}
|
||||
diagnostics={latestResponse.diagnostics}
|
||||
|
||||
@@ -16,9 +16,11 @@ import {
|
||||
} from "modules/workspaces/DynamicParameter/DynamicParameter";
|
||||
import type { FC } from "react";
|
||||
import { docs } from "utils/docs";
|
||||
import type { AutofillBuildParameter } from "utils/richParameters";
|
||||
|
||||
type WorkspaceParametersPageViewExperimentalProps = {
|
||||
workspace: Workspace;
|
||||
autofillParameters: AutofillBuildParameter[];
|
||||
parameters: PreviewParameter[];
|
||||
diagnostics: PreviewParameter["diagnostics"];
|
||||
canChangeVersions: boolean;
|
||||
@@ -34,6 +36,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
WorkspaceParametersPageViewExperimentalProps
|
||||
> = ({
|
||||
workspace,
|
||||
autofillParameters,
|
||||
parameters,
|
||||
diagnostics,
|
||||
canChangeVersions,
|
||||
@@ -42,17 +45,32 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
sendMessage,
|
||||
onCancel,
|
||||
}) => {
|
||||
const autofillByName = Object.fromEntries(
|
||||
autofillParameters.map((param) => [param.name, param]),
|
||||
);
|
||||
const initialTouched = parameters.reduce(
|
||||
(touched, parameter) => {
|
||||
if (autofillByName[parameter.name] !== undefined) {
|
||||
touched[parameter.name] = true;
|
||||
}
|
||||
return touched;
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
);
|
||||
const form = useFormik({
|
||||
onSubmit,
|
||||
initialValues: {
|
||||
rich_parameter_values: getInitialParameterValues(parameters),
|
||||
rich_parameter_values: getInitialParameterValues(
|
||||
parameters,
|
||||
autofillParameters,
|
||||
),
|
||||
},
|
||||
initialTouched,
|
||||
validationSchema: useValidationSchemaForDynamicParameters(parameters),
|
||||
enableReinitialize: false,
|
||||
validateOnChange: true,
|
||||
validateOnBlur: true,
|
||||
});
|
||||
|
||||
// Group parameters by ephemeral status
|
||||
const ephemeralParameters = parameters.filter((p) => p.ephemeral);
|
||||
const standardParameters = parameters.filter((p) => !p.ephemeral);
|
||||
|
||||
Reference in New Issue
Block a user