Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions site/src/components/FormField/FormField.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import { type FC, type ReactNode, useId } from "react";
import { type FC, type ReactNode, useId, useRef } from "react";
import { Input } from "#/components/Input/Input";
import { Label } from "#/components/Label/Label";
import { useHasBeenScrolledPast } from "#/hooks/useHasBeenScrolledPast";
import { useHasReachedBottom } from "#/hooks/useHasReachedBottom";
import { cn } from "#/utils/cn";
import type { FormHelpers } from "#/utils/formUtils";

type FormFieldProps = React.ComponentPropsWithRef<"input"> & {
field: FormHelpers;
label: ReactNode;
description?: ReactNode;
/**
* When true and the field is `required` with an empty value, the input
* flips to `aria-invalid` (destructive red border) once the user scrolls
* past it. The cue clears as soon as they type a value.
*/
markInvalidWhenScrolledPastEmpty?: boolean;
};

export const FormField: FC<FormFieldProps> = ({
field,
label,
description,
className,
markInvalidWhenScrolledPastEmpty,
...inputProps
}) => {
const generatedId = useId();
Expand All @@ -30,8 +39,20 @@ export const FormField: FC<FormFieldProps> = ({
.join(" ");
const required = inputProps.required ?? false;

const wrapperRef = useRef<HTMLDivElement>(null);
const scrolledPast = useHasBeenScrolledPast(wrapperRef);
const hasReachedBottom = useHasReachedBottom();
const isEmpty = field.value == null || field.value === "";
const showRequiredMiss = Boolean(
markInvalidWhenScrolledPastEmpty &&
required &&
isEmpty &&
(scrolledPast || hasReachedBottom),
);
const isInvalid = Boolean(field.error) || showRequiredMiss;

return (
<div className="flex flex-col gap-2">
<div ref={wrapperRef} className="flex flex-col gap-2">
<Label htmlFor={id}>
{label}
{required && (
Expand All @@ -55,9 +76,9 @@ export const FormField: FC<FormFieldProps> = ({
onBlur={field.onBlur}
{...inputProps}
id={id}
aria-invalid={field.error}
aria-invalid={isInvalid}
aria-describedby={describedBy || undefined}
className={cn(field.error && "border-border-destructive", className)}
className={cn(isInvalid && "border-border-destructive", className)}
/>
{field.error ? (
<span id={errorId} className="text-xs text-content-destructive">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ import {
PopoverContent,
PopoverTrigger,
} from "#/components/Popover/Popover";
import { cn } from "#/utils/cn";

type OrganizationAutocompleteProps = {
value: Organization | null;
onChange: (organization: Organization | null) => void;
options: readonly Organization[];
id?: string;
required?: boolean;
"aria-invalid"?: boolean;
};

export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
Expand All @@ -32,6 +34,7 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
options,
id,
required,
"aria-invalid": ariaInvalid,
}) => {
const [open, setOpen] = useState(false);

Expand All @@ -43,8 +46,12 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
variant="outline"
aria-expanded={open}
aria-required={required}
aria-invalid={ariaInvalid}
data-testid="organization-autocomplete"
className="w-full justify-start gap-2 font-normal"
className={cn(
"w-full justify-start gap-2 font-normal",
ariaInvalid && "border-border-destructive",
)}
>
{value ? (
<>
Expand Down
42 changes: 42 additions & 0 deletions site/src/hooks/useHasBeenScrolledPast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { type RefObject, useEffect, useRef, useState } from "react";

/**
* Returns true once the element referenced by `ref` has been visible in the
* viewport at least once and then scrolled above the top of the viewport.
*
* The state is sticky: once true, it stays true until the ref changes. Callers
* typically gate a visual "you missed a required field" cue on the return
* value combined with an emptiness check, so the cue disappears as soon as
* the user fills the field in.
*/
export const useHasBeenScrolledPast = (
ref: RefObject<HTMLElement | null>,
): boolean => {
const [scrolledPast, setScrolledPast] = useState(false);
const hasBeenSeen = useRef(false);

useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") {
return;
}

const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
hasBeenSeen.current = true;
} else if (hasBeenSeen.current && entry.boundingClientRect.top < 0) {
setScrolledPast(true);
}
}
},
{ threshold: 0 },
);

observer.observe(el);
return () => observer.disconnect();
}, [ref]);

return scrolledPast;
};
56 changes: 56 additions & 0 deletions site/src/hooks/useHasReachedBottom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
createContext,
type FC,
type PropsWithChildren,
useContext,
useEffect,
useState,
} from "react";

/**
* Tracks whether the user has scrolled to the bottom of the window at least
* once. Consumed by required-field wrappers so we can flip empty required
* fields to the destructive red outline once the user has reached the end of
* the page, catching required fields that were visible but never scrolled
* past. Sticky: stays true once triggered.
*/
const HasReachedBottomContext = createContext<{ hasReachedBottom: boolean }>({
hasReachedBottom: false,
});

export const useHasReachedBottom = (): boolean =>
useContext(HasReachedBottomContext).hasReachedBottom;

const BOTTOM_TOLERANCE_PX = 4;

export const HasReachedBottomProvider: FC<PropsWithChildren> = ({
children,
}) => {
const [hasReachedBottom, setHasReachedBottom] = useState(false);

useEffect(() => {
if (hasReachedBottom) {
return;
}
const check = () => {
const scrolled = window.scrollY + window.innerHeight;
const total = document.documentElement.scrollHeight;
if (scrolled >= total - BOTTOM_TOLERANCE_PX) {
setHasReachedBottom(true);
}
};
check();
window.addEventListener("scroll", check, { passive: true });
window.addEventListener("resize", check);
return () => {
window.removeEventListener("scroll", check);
window.removeEventListener("resize", check);
};
}, [hasReachedBottom]);

return (
<HasReachedBottomContext.Provider value={{ hasReachedBottom }}>
{children}
</HasReachedBottomContext.Provider>
);
};
5 changes: 3 additions & 2 deletions site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ export const BaseInfraSelectStep: FC<BaseInfraSelectStepProps> = ({
Select your infrastructure foundation.
</TemplateBuilderSubtitle>

{/* 420px accounts for navbar, page header, card padding, tab bar, and nav controls */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 max-h-[calc(100vh-420px)] overflow-y-auto">
{/* Show ~3 rows of cards before scrolling; cards keep their natural
height, and descriptions clamp to two lines to stay uniform. */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 max-h-[42rem] overflow-y-auto">
{bases.map((base) => (
<TemplateCard
key={base.id}
Expand Down
5 changes: 2 additions & 3 deletions site/src/pages/TemplateBuilder/BaseTemplateParametersStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ function variableToField(
required: variable.required,
placeholder:
defaultPlaceholder(variable.default) ??
(variable.required ? "Required" : "Optional"),
(variable.required ? "Required" : ""),
field: {
name: variable.name,
id,
Expand Down Expand Up @@ -123,8 +123,7 @@ export const BaseTemplateParametersStep: FC<
Your base template requires customizations.
</TemplateBuilderSubtitle>

{/* 340px accounts for navbar, page header, card padding, and nav controls */}
<div className="max-h-[calc(100vh-340px)] overflow-y-auto">
<div>
<TemplateConfiguration
name={base?.name ?? "Base Template"}
description={base?.description ?? ""}
Expand Down
37 changes: 13 additions & 24 deletions site/src/pages/TemplateBuilder/ConfigurationField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ const TextField: FC<TextFieldDefinition> = ({
description={description}
required={required}
placeholder={placeholder}
// Once the user scrolls past a required field, flip it to the
// destructive red outline so easy-to-miss empty required fields
// stand out. Clears as soon as the field has a value.
markInvalidWhenScrolledPastEmpty
// Placeholder text is instructional (a hint of what to enter),
// so it uses the dimmer content-disabled tone instead of the
// default content-secondary. The important flag beats the base
// Input class.
className="placeholder:!text-content-disabled"
/>
);

Expand All @@ -123,15 +132,13 @@ const SelectField: FC<SelectFieldDefinition> = ({
<div className="!col-end-1 flex flex-col gap-2">
<Label htmlFor={id}>
{label}
{required ? (
{required && (
<>
{" "}
<span className="text-sm font-bold text-content-destructive">
*
</span>
</>
) : (
<OptionalIndicator />
)}
</Label>
{description && (
Expand Down Expand Up @@ -173,15 +180,13 @@ const RadioField: FC<RadioFieldDefinition> = ({
<div className="flex flex-col gap-2">
<Label id={labelId}>
{label}
{required ? (
{required && (
<>
{" "}
<span className="text-sm font-bold text-content-destructive">
*
</span>
</>
) : (
<OptionalIndicator />
)}
</Label>
{description && (
Expand Down Expand Up @@ -298,15 +303,13 @@ const SwitchGroupField: FC<SwitchGroupFieldDefinition> = ({
<div className="flex flex-col gap-2">
<Label id={labelId}>
{label}
{required ? (
{required && (
<>
{" "}
<span className="text-sm font-bold text-content-destructive">
*
</span>
</>
) : (
<OptionalIndicator />
)}
</Label>
{description && (
Expand Down Expand Up @@ -343,22 +346,8 @@ export const ConfigurationFieldContainer: FC<PropsWithChildren> = ({
);
};

const OptionalIndicator: FC = () => {
return (
<>
{" "}
<span className="text-content-secondary">(optional)</span>
</>
);
};

export const ConfigurationFieldLabel: FC<{
variable: TemplateBuilderModuleVariable;
}> = ({ variable }) => {
return (
<>
{variable.name}
{!variable.required && <OptionalIndicator />}
</>
);
return <>{variable.name}</>;
};
8 changes: 4 additions & 4 deletions site/src/pages/TemplateBuilder/ModuleCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,14 @@ export const ModuleCard: React.FC<ModuleCardProps> = ({
<div>
<h3
id={nameId}
className="flex items-center gap-1.5 text-md font-semibold text-content-primary"
className="flex items-start gap-1.5 text-md font-semibold text-content-primary"
>
{name}
<span className="line-clamp-2">{name}</span>
{official && (
<BadgeCheckIcon className="size-4 text-highlight-sky shrink-0" />
<BadgeCheckIcon className="size-4 text-highlight-sky shrink-0 mt-1" />
)}
</h3>
<p className="text-sm font-normal text-content-secondary">
<p className="text-sm font-normal text-content-secondary line-clamp-2">
{description}
</p>

Expand Down
47 changes: 47 additions & 0 deletions site/src/pages/TemplateBuilder/ModuleConfiguration.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,50 @@ export const WithoutIcon: Story = {
],
},
};

export const WithSensitiveVariables: Story = {
args: {
name: "Claude Code",
description: "Install and configure the Claude Code CLI in your workspace.",
iconUrl: "/icon/claude.svg",
detailsUrl: "https://registry.coder.com/modules/claude-code",
optionalFields: [
{
type: "select",
id: "model",
label: "Model",
options: [
{ value: "sonnet", label: "Sonnet" },
{ value: "opus", label: "Opus" },
],
},
],
sensitiveVariables: [
{
name: "claude_code_oauth_token",
type: "string",
description: "OAuth token used by Claude Code",
required: true,
sensitive: true,
},
],
},
};

export const NoConfigWithSensitiveVariables: Story = {
args: {
name: "OpenAI Codex",
description: "Install the OpenAI Codex CLI in your workspace.",
iconUrl: "/icon/openai.svg",
detailsUrl: "https://registry.coder.com/modules/codex",
sensitiveVariables: [
{
name: "openai_api_key",
type: "string",
description: "OpenAI API key",
required: true,
sensitive: true,
},
],
},
};
Loading
Loading