Skip to content

Commit a09038b

Browse files
Feature(webapp): new User and Project onboarding questions (#3109)
- New User onboarding questions added and stored in a new `onboardingData` col - Keeps the same Org creation screen and stores the data in the same format in same DB column - New Org onboarding questions addded and stored in a new `onboardingData` col https://github.com/user-attachments/assets/244e4bae-f74d-4ed4-a545-92c9b927e98b --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 2135dc5 commit a09038b

File tree

17 files changed

+1216
-199
lines changed

17 files changed

+1216
-199
lines changed
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
import * as Ariakit from "@ariakit/react";
2+
import {
3+
XMarkIcon,
4+
PlusIcon,
5+
CubeIcon,
6+
MagnifyingGlassIcon,
7+
ChevronDownIcon,
8+
} from "@heroicons/react/20/solid";
9+
import { useCallback, useMemo, useRef, useState } from "react";
10+
import { CheckboxIndicator } from "~/components/primitives/CheckboxIndicator";
11+
import { cn } from "~/utils/cn";
12+
import { matchSorter } from "match-sorter";
13+
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
14+
15+
const pillColors = [
16+
"bg-green-800/40 border-green-600/50",
17+
"bg-teal-800/40 border-teal-600/50",
18+
"bg-blue-800/40 border-blue-600/50",
19+
"bg-indigo-800/40 border-indigo-600/50",
20+
"bg-violet-800/40 border-violet-600/50",
21+
"bg-purple-800/40 border-purple-600/50",
22+
"bg-fuchsia-800/40 border-fuchsia-600/50",
23+
"bg-pink-800/40 border-pink-600/50",
24+
"bg-rose-800/40 border-rose-600/50",
25+
"bg-orange-800/40 border-orange-600/50",
26+
"bg-amber-800/40 border-amber-600/50",
27+
"bg-yellow-800/40 border-yellow-600/50",
28+
"bg-lime-800/40 border-lime-600/50",
29+
"bg-emerald-800/40 border-emerald-600/50",
30+
"bg-cyan-800/40 border-cyan-600/50",
31+
"bg-sky-800/40 border-sky-600/50",
32+
];
33+
34+
function getPillColor(value: string): string {
35+
let hash = 0;
36+
for (let i = 0; i < value.length; i++) {
37+
hash = (hash << 5) - hash + value.charCodeAt(i);
38+
hash |= 0;
39+
}
40+
return pillColors[Math.abs(hash) % pillColors.length];
41+
}
42+
43+
export const TECHNOLOGY_OPTIONS = [
44+
"Airflow",
45+
"Angular",
46+
"Anthropic",
47+
"Astro",
48+
"Auth0",
49+
"AWS",
50+
"AWS SQS",
51+
"Azure",
52+
"BigQuery",
53+
"BullMQ",
54+
"Bun",
55+
"Cassandra",
56+
"Celery",
57+
"ClickHouse",
58+
"Clerk",
59+
"Cloudflare",
60+
"CockroachDB",
61+
"Cohere",
62+
"Convex",
63+
"Databricks",
64+
"Datadog",
65+
"DeepSeek",
66+
"Deno",
67+
"DigitalOcean",
68+
"Django",
69+
"Docker",
70+
"Drizzle",
71+
"DynamoDB",
72+
"Elasticsearch",
73+
"Electron",
74+
"Elevenlabs",
75+
"Expo",
76+
"Express",
77+
"FastAPI",
78+
"Fastify",
79+
"Firebase",
80+
"Flask",
81+
"Fly.io",
82+
"Gatsby",
83+
"GCP",
84+
"Go",
85+
"Google Cloud Tasks",
86+
"Google Gemini",
87+
"GraphQL",
88+
"Groq",
89+
"Heroku",
90+
"Hono",
91+
"htmx",
92+
"Hugging Face",
93+
"Inngest",
94+
"Kafka",
95+
"Kubernetes",
96+
"LangChain",
97+
"Laravel",
98+
"LlamaIndex",
99+
"MariaDB",
100+
"Midjourney",
101+
"Mistral",
102+
"MongoDB",
103+
"Mongoose",
104+
"MySQL",
105+
"Neo4j",
106+
"Neon",
107+
"Nest.js",
108+
"Netlify",
109+
"Next.js",
110+
"Node.js",
111+
"Nuxt",
112+
"Ollama",
113+
"OpenAI",
114+
"Perplexity",
115+
"PHP",
116+
"Pinecone",
117+
"PlanetScale",
118+
"Python",
119+
"PostHog",
120+
"PostgreSQL",
121+
"Prisma",
122+
"Pulumi",
123+
"RabbitMQ",
124+
"Railway",
125+
"React",
126+
"React Native",
127+
"Redis",
128+
"Redshift",
129+
"Remix",
130+
"Render",
131+
"Replicate",
132+
"Resend",
133+
"Ruby on Rails",
134+
"Rust",
135+
"SendGrid",
136+
"Sentry",
137+
"Sidekiq",
138+
"Snowflake",
139+
"Solid.js",
140+
"Spring Boot",
141+
"SQLite",
142+
"Stability AI",
143+
"Stripe",
144+
"Supabase",
145+
"Svelte",
146+
"SvelteKit",
147+
"Tailwind CSS",
148+
"Temporal",
149+
"Terraform",
150+
"Together AI",
151+
"tRPC",
152+
"Turso",
153+
"Twilio",
154+
"TypeORM",
155+
"Upstash",
156+
"Vercel",
157+
"Vercel AI SDK",
158+
"Vite",
159+
"Vue",
160+
"Weaviate",
161+
] as const;
162+
163+
type TechnologyPickerProps = {
164+
value: string[];
165+
onChange: (value: string[]) => void;
166+
customValues: string[];
167+
onCustomValuesChange: (values: string[]) => void;
168+
};
169+
170+
export function TechnologyPicker({
171+
value,
172+
onChange,
173+
customValues,
174+
onCustomValuesChange,
175+
}: TechnologyPickerProps) {
176+
const [open, setOpen] = useState(false);
177+
const [searchValue, setSearchValue] = useState("");
178+
const [otherInputValue, setOtherInputValue] = useState("");
179+
const [showOtherInput, setShowOtherInput] = useState(false);
180+
const otherInputRef = useRef<HTMLInputElement>(null);
181+
182+
const allSelected = useMemo(() => [...value, ...customValues], [value, customValues]);
183+
184+
const filteredOptions = useMemo(() => {
185+
if (!searchValue) return TECHNOLOGY_OPTIONS;
186+
return matchSorter([...TECHNOLOGY_OPTIONS], searchValue);
187+
}, [searchValue]);
188+
189+
const toggleOption = useCallback(
190+
(option: string) => {
191+
if (value.includes(option)) {
192+
onChange(value.filter((v) => v !== option));
193+
} else {
194+
onChange([...value, option]);
195+
}
196+
},
197+
[value, onChange]
198+
);
199+
200+
const removeItem = useCallback(
201+
(item: string) => {
202+
if (value.includes(item)) {
203+
onChange(value.filter((v) => v !== item));
204+
} else {
205+
onCustomValuesChange(customValues.filter((v) => v !== item));
206+
}
207+
},
208+
[value, onChange, customValues, onCustomValuesChange]
209+
);
210+
211+
const addCustomValue = useCallback(() => {
212+
const trimmed = otherInputValue.trim();
213+
if (trimmed && !customValues.includes(trimmed) && !value.includes(trimmed)) {
214+
onCustomValuesChange([...customValues, trimmed]);
215+
setOtherInputValue("");
216+
}
217+
}, [otherInputValue, customValues, onCustomValuesChange, value]);
218+
219+
const handleOtherKeyDown = useCallback(
220+
(e: React.KeyboardEvent) => {
221+
if (e.key === "Enter") {
222+
e.preventDefault();
223+
e.stopPropagation();
224+
addCustomValue();
225+
}
226+
},
227+
[addCustomValue]
228+
);
229+
230+
return (
231+
<div className="flex flex-col gap-2">
232+
{allSelected.length > 0 && (
233+
<div className="flex flex-wrap gap-1.5">
234+
{allSelected.map((item) => (
235+
<span
236+
key={item}
237+
className={cn(
238+
"flex items-center gap-1 rounded-sm border py-0.5 pl-1.5 pr-1 text-xs font-medium text-text-bright",
239+
getPillColor(item)
240+
)}
241+
>
242+
{item}
243+
<button
244+
type="button"
245+
onClick={() => removeItem(item)}
246+
aria-label={`Remove ${item}`}
247+
className="ml-0.5 flex items-center transition hover:text-text-bright/70"
248+
>
249+
<XMarkIcon className="size-3.5" />
250+
</button>
251+
</span>
252+
))}
253+
</div>
254+
)}
255+
256+
<Ariakit.ComboboxProvider
257+
resetValueOnHide
258+
setValue={(val) => {
259+
setSearchValue(val);
260+
}}
261+
>
262+
<Ariakit.SelectProvider
263+
open={open}
264+
setOpen={setOpen}
265+
value={value}
266+
setValue={(v) => {
267+
if (Array.isArray(v)) {
268+
onChange(v);
269+
}
270+
}}
271+
virtualFocus
272+
>
273+
<Ariakit.Select className="group flex h-8 w-full items-center rounded bg-charcoal-750 pl-2 pr-2.5 text-sm text-text-dimmed ring-charcoal-600 transition focus-custom hover:bg-charcoal-650 hover:ring-1">
274+
<div className="flex grow items-center">
275+
<CubeIcon className="mr-1.5 size-4 flex-none text-text-dimmed" />
276+
<span>Select your technologies…</span>
277+
</div>
278+
<ChevronDownIcon className="size-4 flex-none text-text-dimmed transition group-hover:text-text-bright" />
279+
</Ariakit.Select>
280+
281+
<Ariakit.SelectPopover
282+
gutter={5}
283+
unmountOnHide
284+
className={cn(
285+
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
286+
"min-w-[max(180px,var(--popover-anchor-width))]",
287+
"max-w-[min(480px,var(--popover-available-width))]",
288+
"max-h-[min(400px,var(--popover-available-height))]"
289+
)}
290+
>
291+
<div className="flex h-9 w-full flex-none items-center gap-2 border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-none">
292+
<MagnifyingGlassIcon className="size-3.5 flex-none text-text-dimmed" />
293+
<Ariakit.Combobox
294+
autoSelect
295+
placeholder="Search technologies…"
296+
className="flex-1 bg-transparent text-xs text-text-dimmed outline-none"
297+
/>
298+
</div>
299+
300+
<Ariakit.ComboboxList className="overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 focus-custom">
301+
{filteredOptions.map((option) => (
302+
<Ariakit.ComboboxItem
303+
key={option}
304+
className="group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1"
305+
onClick={(e) => {
306+
e.preventDefault();
307+
toggleOption(option);
308+
}}
309+
>
310+
<div className="flex h-8 w-full items-center gap-2 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary">
311+
<CheckboxIndicator checked={value.includes(option)} />
312+
<span className="grow truncate text-text-bright">{option}</span>
313+
</div>
314+
</Ariakit.ComboboxItem>
315+
))}
316+
317+
{filteredOptions.length === 0 && searchValue && (
318+
<div className="px-3 py-2 text-xs text-text-dimmed">
319+
No matches for &ldquo;{searchValue}&rdquo;
320+
</div>
321+
)}
322+
</Ariakit.ComboboxList>
323+
324+
<div className="sticky bottom-0 border-t border-charcoal-700 bg-background-bright px-1 py-1">
325+
{showOtherInput ? (
326+
<div className="flex h-8 w-full items-center rounded-sm bg-tertiary pl-0 pr-2 ring-1 ring-charcoal-650">
327+
<input
328+
ref={otherInputRef}
329+
type="text"
330+
value={otherInputValue}
331+
onChange={(e) => setOtherInputValue(e.target.value)}
332+
onKeyDown={handleOtherKeyDown}
333+
placeholder="Type and press Enter to add"
334+
className="flex-1 border-none bg-transparent pl-2 text-2sm text-text-bright shadow-none outline-none ring-0 placeholder:text-text-dimmed focus:border-none focus:outline-none focus:ring-0"
335+
autoFocus
336+
/>
337+
<ShortcutKey
338+
shortcut={{ key: "Enter" }}
339+
variant="small"
340+
className={cn(
341+
"mr-1.5 transition-opacity duration-150",
342+
otherInputValue.length > 0 ? "opacity-100" : "opacity-0"
343+
)}
344+
/>
345+
<button
346+
type="button"
347+
onClick={() => {
348+
setOtherInputValue("");
349+
setShowOtherInput(false);
350+
}}
351+
className="flex items-center text-text-dimmed hover:text-text-bright"
352+
>
353+
<XMarkIcon className="size-4" />
354+
</button>
355+
</div>
356+
) : (
357+
<button
358+
type="button"
359+
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-sm px-2 text-2sm text-text-dimmed hover:bg-tertiary"
360+
onClick={() => {
361+
setShowOtherInput(true);
362+
setTimeout(() => otherInputRef.current?.focus(), 0);
363+
}}
364+
>
365+
<PlusIcon className="size-4 flex-none" />
366+
<span>Other (not listed)</span>
367+
</button>
368+
)}
369+
</div>
370+
</Ariakit.SelectPopover>
371+
</Ariakit.SelectProvider>
372+
</Ariakit.ComboboxProvider>
373+
</div>
374+
);
375+
}

0 commit comments

Comments
 (0)