Skip to content

Commit bee59de

Browse files
matt-aitkensamejr
andauthored
Concurrency self serve (#2681)
* Don't use the organization max concurrency anymore * Early draft of the concurrency page * WIP adding a new stepper input component * Move stepper to be alphabetical * When max value is reached, disabled the + button * Show placeholder if you delete all numbers * Make all the html input values available to the component * Adds size variants * Move stepper into its own component * Work on showing the extra concurrency * The purchase form styling and functionality (minus actually purchasing) * New style for outline input fields * Concurrency purchasing working * Purchasing concurrency and quota emails working * Improvements to the modal * Show cost breakdown in the modal * Fix for allocated concurrency including DEV * Improved types * Allocating concurrency is working * Live updates total env concurrency * Implemented reset * Fix for concurrency allocation editing across multiple projects * Tabular numbers * Added an error from allocating concurrency * Fixes for allocating concurrency where it didn't calculate correctly * "Increase limit" link to concurrency page * Indent environments * Added Preview limit when updating concurrency for an org * Show error when changing plan fails * Added maximumProjectCount column to Org * Limit project count and display a rich error toast (with title and button now) * Added title and button to toasts. Use it for new project error * @trigger.dev/platform 1.0.20 * Allow submitting zero concurrency so you can downgrade back to nothing * Use the server as the truth for omitted environments * Updated the pricing panels --------- Co-authored-by: James Ritchie <[email protected]>
1 parent 3af7303 commit bee59de

File tree

33 files changed

+1947
-177
lines changed

33 files changed

+1947
-177
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export function ConcurrencyIcon({ className }: { className?: string }) {
2+
return (
3+
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
4+
<circle cx="3.75" cy="3.75" r="2.25" fill="currentColor" />
5+
<circle cx="9" cy="3.75" r="2.25" fill="currentColor" />
6+
<circle cx="14.25" cy="3.75" r="2.25" fill="currentColor" />
7+
<circle cx="3.75" cy="9" r="2.25" fill="currentColor" />
8+
<circle cx="9" cy="9" r="2.25" fill="currentColor" />
9+
<circle cx="9" cy="14.25" r="1.75" stroke="currentColor" />
10+
<circle cx="14.25" cy="9" r="2.25" fill="currentColor" />
11+
</svg>
12+
);
13+
}

apps/webapp/app/components/Feedback.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { conform, useForm } from "@conform-to/react";
22
import { parse } from "@conform-to/zod";
33
import { InformationCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid";
44
import { EnvelopeIcon } from "@heroicons/react/24/solid";
5-
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
5+
import { Form, useActionData, useLocation, useNavigation, useSearchParams } from "@remix-run/react";
66
import { type ReactNode, useEffect, useState } from "react";
77
import { type FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
88
import { Button } from "./primitives/Buttons";
@@ -23,10 +23,12 @@ import { DialogClose } from "@radix-ui/react-dialog";
2323
type FeedbackProps = {
2424
button: ReactNode;
2525
defaultValue?: FeedbackType;
26+
onOpenChange?: (open: boolean) => void;
2627
};
2728

28-
export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
29+
export function Feedback({ button, defaultValue = "bug", onOpenChange }: FeedbackProps) {
2930
const [open, setOpen] = useState(false);
31+
const [searchParams, setSearchParams] = useSearchParams();
3032
const location = useLocation();
3133
const lastSubmission = useActionData();
3234
const navigation = useNavigation();
@@ -52,8 +54,26 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
5254
}
5355
}, [navigation, form]);
5456

57+
// Handle URL param functionality
58+
useEffect(() => {
59+
const open = searchParams.get("feedbackPanel");
60+
if (open) {
61+
setType(open as FeedbackType);
62+
setOpen(true);
63+
// Clone instead of mutating in place
64+
const next = new URLSearchParams(searchParams);
65+
next.delete("feedbackPanel");
66+
setSearchParams(next);
67+
}
68+
}, [searchParams]);
69+
70+
const handleOpenChange = (value: boolean) => {
71+
setOpen(value);
72+
onOpenChange?.(value);
73+
};
74+
5575
return (
56-
<Dialog open={open} onOpenChange={setOpen}>
76+
<Dialog open={open} onOpenChange={handleOpenChange}>
5777
<DialogTrigger asChild>{button}</DialogTrigger>
5878
<DialogContent>
5979
<DialogHeader>Contact us</DialogHeader>

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { Link, useNavigation } from "@remix-run/react";
2424
import { useEffect, useRef, useState, type ReactNode } from "react";
2525
import simplur from "simplur";
2626
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
27+
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
2728
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
2829
import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon";
2930
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
@@ -43,6 +44,7 @@ import {
4344
accountPath,
4445
adminPath,
4546
branchesPath,
47+
concurrencyPath,
4648
logoutPath,
4749
newOrganizationPath,
4850
newProjectPath,
@@ -122,6 +124,7 @@ export function SideMenu({
122124
const { isConnected } = useDevPresence();
123125
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
124126
const isAdmin = useHasAdminAccess();
127+
const { isManagedCloud } = useFeatures();
125128

126129
useEffect(() => {
127130
const handleScroll = () => {
@@ -313,6 +316,15 @@ export function SideMenu({
313316
data-action="preview-branches"
314317
badge={<V4Badge />}
315318
/>
319+
{isManagedCloud && (
320+
<SideMenuItem
321+
name="Concurrency"
322+
icon={ConcurrencyIcon}
323+
activeIconColor="text-amber-500"
324+
to={concurrencyPath(organization, project, environment)}
325+
data-action="concurrency"
326+
/>
327+
)}
316328
<SideMenuItem
317329
name="Regions"
318330
icon={GlobeAmericasIcon}

apps/webapp/app/components/primitives/Input.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,24 @@ const variants = {
4444
iconSize: "size-3 ml-0.5",
4545
accessory: "pr-0.5",
4646
},
47+
"outline/large": {
48+
container: "px-1 h-10 w-full rounded border border-grid-bright hover:border-charcoal-550",
49+
input: "px-2 rounded text-sm",
50+
iconSize: "size-4 ml-1",
51+
accessory: "pr-1",
52+
},
53+
"outline/medium": {
54+
container: "px-1 h-8 w-full rounded border border-grid-bright hover:border-charcoal-550",
55+
input: "px-1 rounded text-sm",
56+
iconSize: "size-4 ml-0.5",
57+
accessory: "pr-1",
58+
},
59+
"outline/small": {
60+
container: "px-1 h-6 w-full rounded border border-grid-bright hover:border-charcoal-550",
61+
input: "px-1 rounded text-xs",
62+
iconSize: "size-3 ml-0.5",
63+
accessory: "pr-0.5",
64+
},
4765
};
4866

4967
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
2+
import { type ChangeEvent, useRef } from "react";
3+
import { cn } from "~/utils/cn";
4+
5+
type InputNumberStepperProps = Omit<JSX.IntrinsicElements["input"], "min" | "max" | "step"> & {
6+
step?: number;
7+
min?: number;
8+
max?: number;
9+
round?: boolean;
10+
controlSize?: "base" | "large";
11+
};
12+
13+
export function InputNumberStepper({
14+
value,
15+
onChange,
16+
step = 50,
17+
min,
18+
max,
19+
round = true,
20+
controlSize = "base",
21+
name,
22+
id,
23+
disabled = false,
24+
readOnly = false,
25+
className,
26+
placeholder = "Type a number",
27+
...props
28+
}: InputNumberStepperProps) {
29+
const inputRef = useRef<HTMLInputElement>(null);
30+
31+
const handleStepUp = () => {
32+
if (!inputRef.current || disabled) return;
33+
34+
// If rounding is enabled, ensure we start from a rounded base before stepping
35+
if (round) {
36+
// If field is empty, treat as 0 (or min if provided) before stepping up
37+
if (inputRef.current.value === "") {
38+
inputRef.current.value = String(min ?? 0);
39+
} else {
40+
commitRoundedFromInput();
41+
}
42+
}
43+
inputRef.current.stepUp();
44+
const event = new Event("change", { bubbles: true });
45+
inputRef.current.dispatchEvent(event);
46+
};
47+
48+
const handleStepDown = () => {
49+
if (!inputRef.current || disabled) return;
50+
51+
// If rounding is enabled, ensure we start from a rounded base before stepping
52+
if (round) {
53+
// If field is empty, treat as 0 (or min if provided) before stepping down
54+
if (inputRef.current.value === "") {
55+
inputRef.current.value = String(min ?? 0);
56+
} else {
57+
commitRoundedFromInput();
58+
}
59+
}
60+
inputRef.current.stepDown();
61+
const event = new Event("change", { bubbles: true });
62+
inputRef.current.dispatchEvent(event);
63+
};
64+
65+
const numericValue = value === "" ? NaN : (value as number);
66+
const isMinDisabled = min !== undefined && !Number.isNaN(numericValue) && numericValue <= min;
67+
const isMaxDisabled = max !== undefined && !Number.isNaN(numericValue) && numericValue >= max;
68+
69+
function clamp(val: number): number {
70+
if (Number.isNaN(val)) return typeof value === "number" ? value : min ?? 0;
71+
let next = val;
72+
if (min !== undefined) next = Math.max(min, next);
73+
if (max !== undefined) next = Math.min(max, next);
74+
return next;
75+
}
76+
77+
function roundToStep(val: number): number {
78+
if (step <= 0) return val;
79+
const base = min ?? 0;
80+
const shifted = val - base;
81+
const quotient = shifted / step;
82+
const floored = Math.floor(quotient);
83+
const ceiled = Math.ceil(quotient);
84+
const down = base + floored * step;
85+
const up = base + ceiled * step;
86+
const distDown = Math.abs(val - down);
87+
const distUp = Math.abs(up - val);
88+
return distUp < distDown ? up : down;
89+
}
90+
91+
function commitRoundedFromInput() {
92+
if (!inputRef.current || disabled || readOnly) return;
93+
const el = inputRef.current;
94+
const raw = el.value;
95+
if (raw === "") return; // do not coerce empty to 0; keep placeholder visible
96+
const numeric = Number(raw);
97+
if (Number.isNaN(numeric)) return; // ignore non-numeric
98+
const rounded = clamp(roundToStep(numeric));
99+
if (String(rounded) === String(value)) return;
100+
// Update the real input's value for immediate UI feedback
101+
el.value = String(rounded);
102+
// Invoke consumer onChange with the real element as target/currentTarget
103+
onChange?.({
104+
target: el,
105+
currentTarget: el,
106+
} as unknown as ChangeEvent<HTMLInputElement>);
107+
}
108+
109+
const sizeStyles = {
110+
base: {
111+
container: "h-9",
112+
input: "text-sm px-3",
113+
button: "size-6",
114+
icon: "size-3.5",
115+
gap: "gap-1 pr-1.5",
116+
},
117+
large: {
118+
container: "h-11 rounded-md",
119+
input: "text-base px-3.5",
120+
button: "size-8",
121+
icon: "size-5",
122+
gap: "gap-[0.3125rem] pr-[0.3125rem]",
123+
},
124+
} as const;
125+
126+
const size = sizeStyles[controlSize];
127+
128+
return (
129+
<div
130+
className={cn(
131+
"flex items-center rounded border border-charcoal-600 bg-tertiary transition hover:border-charcoal-550/80 hover:bg-charcoal-600/80",
132+
size.container,
133+
"has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-0 has-[:focus-visible]:outline-text-link",
134+
disabled && "cursor-not-allowed opacity-50",
135+
className
136+
)}
137+
>
138+
<input
139+
ref={inputRef}
140+
type="number"
141+
id={id}
142+
name={name}
143+
value={value}
144+
placeholder={placeholder}
145+
onChange={(e) => {
146+
// Allow empty string to pass through so user can clear the field
147+
if (e.currentTarget.value === "") {
148+
// reflect emptiness in the input and notify consumer as empty
149+
if (inputRef.current) inputRef.current.value = "";
150+
onChange?.({
151+
target: e.currentTarget,
152+
currentTarget: e.currentTarget,
153+
} as ChangeEvent<HTMLInputElement>);
154+
return;
155+
}
156+
onChange?.(e);
157+
}}
158+
onBlur={(e) => {
159+
// If blur is caused by clicking our step buttons, we prevent pointerdown
160+
// so blur shouldn't fire. This is for safety in case of keyboard focus move.
161+
if (round) commitRoundedFromInput();
162+
}}
163+
onKeyDown={(e) => {
164+
if (e.key === "Enter" && round) {
165+
e.preventDefault();
166+
commitRoundedFromInput();
167+
}
168+
}}
169+
step={step}
170+
min={min}
171+
max={max}
172+
disabled={disabled}
173+
readOnly={readOnly}
174+
className={cn(
175+
"placeholder:text-muted-foreground h-full grow border-0 bg-transparent text-left text-text-bright outline-none ring-0 focus:border-0 focus:outline-none focus:ring-0 disabled:cursor-not-allowed",
176+
size.input,
177+
// Hide number input arrows
178+
"[type=number]:border-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
179+
)}
180+
{...props}
181+
/>
182+
183+
<div className={cn("flex items-center", size.gap)}>
184+
<button
185+
type="button"
186+
onClick={handleStepDown}
187+
onPointerDown={(e) => e.preventDefault()}
188+
disabled={disabled || isMinDisabled}
189+
aria-label={`Decrease by ${step}`}
190+
className={cn(
191+
"flex items-center justify-center rounded border border-error/30 bg-error/20 transition",
192+
size.button,
193+
"hover:border-error/50 hover:bg-error/30",
194+
"disabled:cursor-not-allowed disabled:opacity-40",
195+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
196+
)}
197+
>
198+
<MinusIcon className={cn("text-error", size.icon)} />
199+
</button>
200+
201+
<button
202+
type="button"
203+
onClick={handleStepUp}
204+
onPointerDown={(e) => e.preventDefault()}
205+
disabled={disabled || isMaxDisabled}
206+
aria-label={`Increase by ${step}`}
207+
className={cn(
208+
"flex items-center justify-center rounded border border-success/30 bg-success/10 transition",
209+
size.button,
210+
"hover:border-success/40 hover:bg-success/20",
211+
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
212+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
213+
)}
214+
>
215+
<PlusIcon className={cn("text-success", size.icon)} />
216+
</button>
217+
</div>
218+
</div>
219+
);
220+
}

0 commit comments

Comments
 (0)