diff --git a/apps/webapp/app/components/Feedback.tsx b/apps/webapp/app/components/Feedback.tsx index f7ad1b50f9..50af57e0e7 100644 --- a/apps/webapp/app/components/Feedback.tsx +++ b/apps/webapp/app/components/Feedback.tsx @@ -1,10 +1,14 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; +import { BookOpenIcon } from "@heroicons/react/20/solid"; import { ChevronRightIcon } from "@heroicons/react/24/solid"; import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react"; import { DiscordIcon, GitHubLightIcon } from "@trigger.dev/companyicons"; +import { ActivityIcon } from "lucide-react"; import { ReactNode, useState } from "react"; import { FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback"; +import { cn } from "~/utils/cn"; +import { docsPath } from "~/utils/pathBuilder"; import { Button, LinkButton } from "./primitives/Buttons"; import { Fieldset } from "./primitives/Fieldset"; import { FormButtons } from "./primitives/FormButtons"; @@ -13,20 +17,9 @@ import { Header1, Header2 } from "./primitives/Headers"; import { InputGroup } from "./primitives/InputGroup"; import { Label } from "./primitives/Label"; import { Paragraph } from "./primitives/Paragraph"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "./primitives/Select"; +import { Select, SelectItem } from "./primitives/Select"; import { Sheet, SheetBody, SheetContent, SheetTrigger } from "./primitives/Sheet"; import { TextArea } from "./primitives/TextArea"; -import { cn } from "~/utils/cn"; -import { BookOpenIcon } from "@heroicons/react/20/solid"; -import { ActivityIcon, HeartPulseIcon } from "lucide-react"; -import { docsPath } from "~/utils/pathBuilder"; type FeedbackProps = { button: ReactNode; @@ -78,20 +71,20 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
- - - + {feedbackType.error} diff --git a/apps/webapp/app/components/events/EventsFilters.tsx b/apps/webapp/app/components/events/EventsFilters.tsx index 13711d804f..c210119349 100644 --- a/apps/webapp/app/components/events/EventsFilters.tsx +++ b/apps/webapp/app/components/events/EventsFilters.tsx @@ -9,7 +9,7 @@ import { SelectItem, SelectTrigger, SelectValue, -} from "../primitives/Select"; +} from "../primitives/SimpleSelect"; import { EventListSearchSchema } from "./EventStatuses"; import { environmentKeys, FilterableEnvironment } from "~/components/runs/RunStatuses"; import { TimeFrameFilter } from "../runs/TimeFrameFilter"; diff --git a/apps/webapp/app/components/layout/AppLayout.tsx b/apps/webapp/app/components/layout/AppLayout.tsx index 73e7d79208..1748337eb0 100644 --- a/apps/webapp/app/components/layout/AppLayout.tsx +++ b/apps/webapp/app/components/layout/AppLayout.tsx @@ -41,7 +41,7 @@ export function PageBody({
void; + variant?: Variant; + className?: string; +}; + +export function AppliedFilter({ + label, + value, + removable = true, + onRemove, + variant = "tertiary/small", + className, +}: AppliedFilterProps) { + const variantClassName = variants[variant]; + return ( +
+
+
+ {label}: +
+
+
{value}
+
+
+ {removable && ( + + )} +
+ ); +} diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 61ea34133f..6e9d73aee7 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -1,143 +1,624 @@ -"use client"; - -import * as SelectPrimitive from "@radix-ui/react-select"; -import { Check, ChevronDown } from "lucide-react"; +import * as Ariakit from "@ariakit/react"; +import { SelectProps as AriaSelectProps } from "@ariakit/react"; +import { SelectValue } from "@ariakit/react-core/select/select-value"; +import { Link } from "@remix-run/react"; import * as React from "react"; +import { Fragment, useMemo, useState } from "react"; +import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys"; import { cn } from "~/utils/cn"; +import { ShortcutKey } from "./ShortcutKey"; +import { ChevronDown } from "lucide-react"; const sizes = { - "secondary/small": - "text-xs h-6 bg-tertiary border border-tertiary group-hover:text-text-bright hover:border-charcoal-600 pr-2 pl-1.5", - medium: "text-sm h-8 bg-tertiary border border-tertiary hover:border-charcoal-600 px-2.5", - minimal: "text-xs h-6 bg-transparent hover:bg-tertiary pl-1.5 pr-2", + small: { + button: "h-6 rounded text-xs px-2 ", + }, + medium: { + button: "h-8 rounded text-xs px-3 text-sm", + }, +}; + +const style = { + tertiary: { + button: + "bg-tertiary focus-within:ring-charcoal-500 border border-tertiary hover:text-text-bright hover:border-charcoal-600", + }, + minimal: { + button: + "bg-transparent focus-within:ring-charcoal-500 hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none", + }, }; -export type SelectProps = { - size?: keyof typeof sizes; - width?: "content" | "full"; +const variants = { + "tertiary/small": { + button: cn(sizes.small.button, style.tertiary.button), + }, + "tertiary/medium": { + button: cn(sizes.medium.button, style.tertiary.button), + }, + "minimal/small": { + button: cn(sizes.small.button, style.minimal.button), + }, + "minimal/medium": { + button: cn(sizes.medium.button, style.minimal.button), + }, }; -const Select = SelectPrimitive.Root; -const SelectGroup = SelectPrimitive.Group; -const SelectValue = SelectPrimitive.Value; -const SelectTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & SelectProps ->(({ className, children, width = "content", size = "secondary/small", ...props }, ref) => { - const sizeClassName = sizes[size]; +type Variant = keyof typeof variants; + +type Section = { + type: "section"; + title?: string; + items: TItem[]; +}; + +function isSection(data: TItem[] | Section[]): data is Section[] { + const firstItem = data[0]; return ( - ).type === "section" && + (firstItem as Section).items !== undefined && + Array.isArray((firstItem as Section).items) + ); +} + +type ItemFromSection = TItemOrSection extends Section ? U : TItemOrSection; +export interface SelectProps + extends Omit { + icon?: React.ReactNode; + text?: React.ReactNode | ((value: TValue) => React.ReactNode); + placeholder?: React.ReactNode; + value?: Ariakit.SelectProviderProps["value"]; + setValue?: Ariakit.SelectProviderProps["setValue"]; + defaultValue?: Ariakit.SelectProviderProps["defaultValue"]; + label?: string | Ariakit.SelectLabelProps["render"]; + heading?: string; + showHeading?: boolean; + items?: TItem[] | Section[]; + empty?: React.ReactNode; + filter?: (item: ItemFromSection, search: string, title?: string) => boolean; + children: + | React.ReactNode + | (( + items: ItemFromSection[], + meta: { + shortcutsEnabled?: boolean; + section?: { + title?: string; + startIndex: number; + count: number; + }; + } + ) => React.ReactNode); + variant?: Variant; + open?: boolean; + setOpen?: (open: boolean) => void; + shortcut?: ShortcutDefinition; + allowItemShortcuts?: boolean; + clearSearchOnSelection?: boolean; + dropdownIcon?: boolean | React.ReactNode; +} + +export function Select({ + children, + icon, + text, + placeholder, + value, + setValue, + defaultValue, + label, + heading, + showHeading = false, + items, + filter, + empty = null, + variant = "tertiary/small", + open, + setOpen, + shortcut, + allowItemShortcuts = true, + disabled, + clearSearchOnSelection = true, + dropdownIcon, + ...props +}: SelectProps) { + const [searchValue, setSearchValue] = useState(""); + const searchable = items !== undefined && filter !== undefined; + + const matches = useMemo(() => { + if (!items) return []; + if (!searchValue || !filter) return items; + + if (isSection(items)) { + return items + .map((section) => ({ + ...section, + items: section.items.filter((item) => + filter(item as ItemFromSection, searchValue, section.title) + ), + })) + .filter((section) => section.items.length > 0); + } + + return items.filter((item) => filter(item as ItemFromSection, searchValue)); + }, [searchValue, items]); + + const enableItemShortcuts = allowItemShortcuts && matches.length === items?.length; + + const select = ( + { + if (clearSearchOnSelection) { + setSearchValue(""); + } + + if (setValue) { + setValue(v as any); + } + }} + defaultValue={defaultValue} > - {children} - - {label}
: label} />} + + + {!searchable && showHeading && heading && {heading}} />} + {searchable && } + + + {typeof children === "function" ? ( + matches.length > 0 ? ( + isSection(matches) ? ( + + ) : ( + children(matches as ItemFromSection[], { + shortcutsEnabled: enableItemShortcuts, + }) + ) + ) : ( + empty + ) + ) : ( + children )} - /> - - + + + ); -}); -SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; - -const SelectContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, position = "popper", ...props }, ref) => ( - - { + React.startTransition(() => { + setSearchValue(value); + }); + }} + > + {select} + + ); + } + + return select; +} + +export interface SelectTriggerProps extends AriaSelectProps { + icon?: React.ReactNode; + text?: React.ReactNode | ((value: TValue) => React.ReactNode); + placeholder?: React.ReactNode; + variant?: Variant; + shortcut?: ShortcutDefinition; + tooltipTitle?: string; + dropdownIcon?: boolean | React.ReactNode; +} + +export function SelectTrigger({ + icon, + variant = "tertiary/small", + text, + shortcut, + tooltipTitle, + disabled, + placeholder, + dropdownIcon = false, + children, + className, + ...props +}: SelectTriggerProps) { + const ref = React.useRef(null); + useShortcutKeys({ + shortcut: shortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + if (ref.current) { + ref.current.click(); + } + }, + disabled, + }); + + const showTooltip = tooltipTitle || shortcut; + const variantClasses = variants[variant]; + + let content: React.ReactNode = ""; + if (children) { + content = children; + } else if (text !== undefined) { + if (typeof text === "function") { + content = {(value) => <>{text(value) ?? placeholder}}; + } else { + content = text; + } + } else { + content = ( + + {(value) => ( + <> + {typeof value === "string" + ? value ?? placeholder + : value.length === 0 + ? placeholder + : value.join(", ")} + + )} + + ); + } + + return ( + + + } + > +
+ {icon &&
{icon}
} +
{content}
+
+ {dropdownIcon === true ? ( + + ) : !dropdownIcon ? null : ( + dropdownIcon + )} +
+ {showTooltip && ( + +
+ {tooltipTitle ?? "Open menu"} + {shortcut && ( + + )} +
+
+ )} +
+ ); +} + +export interface SelectProviderProps + extends Ariakit.SelectProviderProps {} +export function SelectProvider( + props: SelectProviderProps +) { + return ; +} + +export interface ComboboxProviderProps extends Ariakit.ComboboxProviderProps {} +export function ComboboxProvider(props: ComboboxProviderProps) { + return ; +} + +function SelectGroupedRenderer({ + items, + children, + enableItemShortcuts, +}: { + items: Section[]; + children: ( + items: ItemFromSection[], + meta: { + shortcutsEnabled?: boolean; + section?: { title?: string; startIndex: number; count: number }; + } + ) => React.ReactNode; + enableItemShortcuts: boolean; +}) { + let count = 0; + return ( + <> + {items.map((section, index) => { + const previousItem = items.at(index - 1); + count += previousItem ? previousItem.items.length : 0; + return ( + + {children(section.items as ItemFromSection[], { + shortcutsEnabled: enableItemShortcuts, + section: { + title: section.title, + startIndex: count - 1, + count: section.items.length, + }, + })} + + ); + })} + + ); +} + +export interface SelectListProps extends Omit {} +export function SelectList(props: SelectListProps) { + const combobox = Ariakit.useComboboxContext(); + const Component = combobox ? Ariakit.ComboboxList : Ariakit.SelectList; + + return ( + + ); +} + +export interface SelectItemProps extends Ariakit.SelectItemProps { + icon?: React.ReactNode; + checkIcon?: React.ReactNode; + shortcut?: ShortcutDefinition; +} + +const selectItemClasses = + "group cursor-pointer px-1 pt-1 text-xs text-text-dimmed outline-none last:pb-1"; + +export function SelectItem({ + icon, + checkIcon = , + shortcut, + ...props +}: SelectItemProps) { + const combobox = Ariakit.useComboboxContext(); + const render = combobox ? : undefined; + const ref = React.useRef(null); + + useShortcutKeys({ + shortcut: shortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + if (ref.current) { + ref.current.click(); + } + }, + disabled: props.disabled, + enabledOnInputElements: true, + }); + + return ( + - + {icon} +
{props.children || props.value}
+ {checkIcon} + {shortcut && ( + )} - > - {children} -
-
-
-)); -SelectContent.displayName = SelectPrimitive.Content.displayName; - -const SelectLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -SelectLabel.displayName = SelectPrimitive.Label.displayName; - -type SelectItemProps = React.ComponentPropsWithoutRef & { - contentClassName?: string; -}; + + + ); +} -const SelectItem = React.forwardRef, SelectItemProps>( - ({ className, children, contentClassName, ...props }, ref) => ( - , + to, + ...props +}: SelectLinkItemProps) { + const render = ; + + return ( + + ); +} + +export interface SelectButtonItemProps extends Omit { + icon?: React.ReactNode; + checkIcon?: React.ReactNode; + shortcut?: ShortcutDefinition; + onClick: React.ComponentProps<"button">["onClick"]; +} + +export function SelectButtonItem({ + checkIcon = , + onClick, + ...props +}: SelectButtonItemProps) { + const render = ( + + + )} + ); +} - const handleFilterChange = useCallback((filterType: string, value: string | undefined) => { - if (value) { - searchParams.set(filterType, value); - } else { - searchParams.delete(filterType); - } - searchParams.delete("cursor"); - searchParams.delete("direction"); - navigate(`${location.pathname}?${searchParams.toString()}`); - }, []); - - const handleStatusChange = useCallback((value: TaskRunAttemptStatus | typeof All) => { - handleFilterChange("statuses", value === "ALL" ? undefined : value); - }, []); - - const handleTaskChange = useCallback((value: string | typeof All) => { - handleFilterChange("tasks", value === "ALL" ? undefined : value); - }, []); - - const handleEnvironmentChange = useCallback((value: string | typeof All) => { - handleFilterChange("environments", value === "ALL" ? undefined : value); - }, []); - - const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => { - if (range.from) { - searchParams.set("from", range.from.toString()); - } else { - searchParams.delete("from"); - } +const filterTypes = [ + { + name: "statuses", + title: "Status", + icon: ( +
+
+
+ ), + }, + { name: "environments", title: "Environment", icon: }, + { name: "tasks", title: "Tasks", icon: }, + { name: "created", title: "Created", icon: }, +]; - if (range.to) { - searchParams.set("to", range.to.toString()); - } else { - searchParams.delete("to"); - } +type FilterType = (typeof filterTypes)[number]["name"]; - searchParams.delete("cursor"); - searchParams.delete("direction"); - navigate(`${location.pathname}?${searchParams.toString()}`); - }, []); +const shortcut = { key: "f" }; - const clearFilters = useCallback(() => { - searchParams.delete("statuses"); - searchParams.delete("environments"); - searchParams.delete("tasks"); - searchParams.delete("from"); - searchParams.delete("to"); - navigate(`${location.pathname}?${searchParams.toString()}`); - }, []); +function FilterMenu(props: RunFiltersProps) { + const [filterType, setFilterType] = useState(); + + const filterTrigger = ( + + +
+ } + variant={"minimal/small"} + shortcut={shortcut} + tooltipTitle={"Filter runs"} + > + Filter +
+ ); return ( -
- - - - - - - - - - - - - - -
+ ))} + + + + ); +} + +function AppliedStatusFilter() { + const { values, del } = useSearchParams(); + const statuses = values("statuses"); + + if (statuses.length === 0) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + runStatusTitle(v as TaskRunStatus)))} + onRemove={() => del(["statuses", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + ); } + +function EnvironmentsDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, + possibleEnvironments, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; + possibleEnvironments: DisplayableEnvironment[]; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ environments: values, cursor: undefined, direction: undefined }); + }; + + const filtered = useMemo(() => { + return possibleEnvironments.filter((item) => { + const title = environmentTitle(item, item.userName); + return title.toLowerCase().includes(searchValue.toLowerCase()); + }); + }, [searchValue, possibleEnvironments]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + + + {filtered.map((item, index) => ( + + + + ))} + + + + ); +} + +function AppliedEnvironmentFilter({ + possibleEnvironments, +}: Pick) { + const { values, del } = useSearchParams(); + + if (values("environments").length === 0) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + { + const environment = possibleEnvironments.find((env) => env.id === v); + return environment ? environmentTitle(environment, environment.userName) : v; + }) + )} + onRemove={() => del(["environments", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + possibleEnvironments={possibleEnvironments} + /> + )} + + ); +} + +function TasksDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, + possibleTasks, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; + possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[]; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ tasks: values, cursor: undefined, direction: undefined }); + }; + + const filtered = useMemo(() => { + return possibleTasks.filter((item) => { + return item.slug.toLowerCase().includes(searchValue.toLowerCase()); + }); + }, [searchValue, possibleTasks]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + + + {filtered.map((item, index) => ( + } + > + {item.slug} + + ))} + + + + ); +} + +function AppliedTaskFilter({ possibleTasks }: Pick) { + const { values, del } = useSearchParams(); + + if (values("tasks").length === 0) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + { + const task = possibleTasks.find((task) => task.slug === v); + return task ? task.slug : v; + }) + )} + onRemove={() => del(["tasks", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + possibleTasks={possibleTasks} + /> + )} + + ); +} + +const timePeriods = [ + { + label: "All periods", + value: "all", + }, + { + label: "5 mins ago", + value: "5m", + }, + { + label: "15 mins ago", + value: "15m", + }, + { + label: "30 mins ago", + value: "30m", + }, + { + label: "1 hour ago", + value: "1h", + }, + { + label: "3 hours ago", + value: "3h", + }, + { + label: "6 hours ago", + value: "6h", + }, + { + label: "1 day ago", + value: "1d", + }, + { + label: "3 days ago", + value: "3d", + }, + { + label: "7 days ago", + value: "7d", + }, + { + label: "10 days ago", + value: "10d", + }, + { + label: "14 days ago", + value: "14d", + }, + { + label: "30 days ago", + value: "30d", + }, +]; + +function CreatedDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const { value, replace } = useSearchParams(); + + const handleChange = (newValue: string) => { + clearSearchValue(); + if (newValue === "all") { + if (!value) return; + } + + replace({ period: newValue, cursor: undefined, direction: undefined }); + }; + + const filtered = useMemo(() => { + return timePeriods.filter((item) => + item.label.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + + + {filtered.map((item) => ( + + {item.label} + + ))} + + + + ); +} + +function AppliedPeriodFilter() { + const { value, del } = useSearchParams(); + + if (value("period") === undefined || value("period") === "all") { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + t.value === value("period"))?.label ?? value("period") + } + onRemove={() => del(["period", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function appliedSummary(values: string[], maxValues = 3) { + if (values.length === 0) { + return null; + } + + if (values.length > maxValues) { + return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`; + } + + return values.join(", "); +} diff --git a/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx b/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx index 6a042b7bc1..6bd5618e55 100644 --- a/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx +++ b/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx @@ -1,9 +1,11 @@ -import { TrashIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { XMarkIcon } from "@heroicons/react/20/solid"; import { useNavigate } from "@remix-run/react"; import { RuntimeEnvironment } from "@trigger.dev/database"; import { useCallback } from "react"; import { z } from "zod"; +import { Input } from "~/components/primitives/Input"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useThrottle } from "~/hooks/useThrottle"; import { EnvironmentLabel } from "../../environments/EnvironmentLabel"; import { Button } from "../../primitives/Buttons"; import { Paragraph } from "../../primitives/Paragraph"; @@ -14,10 +16,7 @@ import { SelectItem, SelectTrigger, SelectValue, -} from "../../primitives/Select"; -import { Input } from "~/components/primitives/Input"; -import { useDebounce } from "~/hooks/useDebounce"; -import { useThrottle } from "~/hooks/useThrottle"; +} from "../../primitives/SimpleSelect"; export const ScheduleListFilters = z.object({ page: z.coerce.number().default(1), @@ -53,6 +52,9 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul Object.fromEntries(searchParams.entries()) ); + const hasFilters = + searchParams.has("tasks") || searchParams.has("environments") || searchParams.has("search"); + const handleFilterChange = useCallback((filterType: string, value: string | undefined) => { if (value) { searchParams.set(filterType, value); @@ -146,7 +148,7 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul {task} @@ -156,7 +158,11 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul - + )} ); } diff --git a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx index 17b4a317b6..a0ae7788d6 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx @@ -15,6 +15,20 @@ import { SnowflakeIcon } from "lucide-react"; import { Spinner } from "~/components/primitives/Spinner"; import { cn } from "~/utils/cn"; +export const allTaskRunStatuses = [ + "WAITING_FOR_DEPLOY", + "PENDING", + "EXECUTING", + "RETRYING_AFTER_FAILURE", + "WAITING_TO_RESUME", + "COMPLETED_SUCCESSFULLY", + "CANCELED", + "COMPLETED_WITH_ERRORS", + "CRASHED", + "INTERRUPTED", + "SYSTEM_FAILURE", +] as TaskRunStatus[]; + const taskRunStatusDescriptions: Record = { PENDING: "Task is waiting to be executed", WAITING_FOR_DEPLOY: "Task needs to be deployed first to start executing", diff --git a/apps/webapp/app/hooks/useFilterTasks.ts b/apps/webapp/app/hooks/useFilterTasks.ts new file mode 100644 index 0000000000..6bb64a9d8b --- /dev/null +++ b/apps/webapp/app/hooks/useFilterTasks.ts @@ -0,0 +1,43 @@ +import { useTextFilter } from "./useTextFilter"; + +type Task = { + id: string; + friendlyId: string; + taskIdentifier: string; + exportName: string; + filePath: string; + triggerSource: string; +}; + +export function useFilterTasks({ tasks }: { tasks: T[] }) { + return useTextFilter({ + items: tasks, + filter: (task, text) => { + if (task.taskIdentifier.toLowerCase().includes(text.toLowerCase())) { + return true; + } + + if (task.exportName.toLowerCase().includes(text.toLowerCase())) { + return true; + } + + if (task.filePath.toLowerCase().includes(text.toLowerCase())) { + return true; + } + + if (task.id.toLowerCase().includes(text.toLowerCase())) { + return true; + } + + if (task.friendlyId.toLowerCase().includes(text.toLowerCase())) { + return true; + } + + if (task.triggerSource === "SCHEDULED" && "scheduled".includes(text.toLowerCase())) { + return true; + } + + return false; + }, + }); +} diff --git a/apps/webapp/app/hooks/useSearchParam.ts b/apps/webapp/app/hooks/useSearchParam.ts new file mode 100644 index 0000000000..982b51ce64 --- /dev/null +++ b/apps/webapp/app/hooks/useSearchParam.ts @@ -0,0 +1,76 @@ +import { useNavigate } from "@remix-run/react"; +import { useOptimisticLocation } from "./useOptimisticLocation"; +import { useCallback } from "react"; + +type Values = Record; + +export function useSearchParams() { + const navigate = useNavigate(); + const location = useOptimisticLocation(); + const search = new URLSearchParams(location.search); + + const set = useCallback( + (values: Values) => { + for (const [param, value] of Object.entries(values)) { + if (value === undefined) { + search.delete(param); + continue; + } + + if (typeof value === "string") { + search.set(param, value); + continue; + } + + search.delete(param); + for (const v of value) { + search.append(param, v); + } + } + }, + [location, search] + ); + + const replace = useCallback( + (values: Values) => { + set(values); + navigate(`${location.pathname}?${search.toString()}`, { replace: true }); + }, + [location, search] + ); + + const del = useCallback( + (keys: string | string[]) => { + if (!Array.isArray(keys)) { + keys = [keys]; + } + for (const key of keys) { + search.delete(key); + } + navigate(`${location.pathname}?${search.toString()}`, { replace: true }); + }, + [location, search] + ); + + const value = useCallback( + (param: string) => { + return search.get(param) ?? undefined; + }, + [location, search] + ); + + const values = useCallback( + (param: string) => { + return search.getAll(param); + }, + [location, search] + ); + + return { + value, + values, + set, + replace, + del, + }; +} diff --git a/apps/webapp/app/hooks/useShortcutKeys.tsx b/apps/webapp/app/hooks/useShortcutKeys.tsx index 092e163967..721dbeea18 100644 --- a/apps/webapp/app/hooks/useShortcutKeys.tsx +++ b/apps/webapp/app/hooks/useShortcutKeys.tsx @@ -17,16 +17,22 @@ export type ShortcutDefinition = | Shortcut; type useShortcutKeysProps = { - shortcut: ShortcutDefinition; + shortcut: ShortcutDefinition | undefined; action: (event: KeyboardEvent) => void; disabled?: boolean; enabledOnInputElements?: boolean; }; -export function useShortcutKeys({ shortcut, action, disabled = false }: useShortcutKeysProps) { +export function useShortcutKeys({ + shortcut, + action, + disabled = false, + enabledOnInputElements, +}: useShortcutKeysProps) { const { platform } = useOperatingSystem(); const isMac = platform === "mac"; - const relevantShortcut = "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut; + const relevantShortcut = + shortcut && "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut; const keys = createKeysFromShortcut(relevantShortcut); useHotkeys( @@ -36,13 +42,17 @@ export function useShortcutKeys({ shortcut, action, disabled = false }: useShort }, { enabled: !disabled, - enableOnFormTags: relevantShortcut.enabledOnInputElements, - enableOnContentEditable: relevantShortcut.enabledOnInputElements, + enableOnFormTags: enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements, + enableOnContentEditable: enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements, } ); } -function createKeysFromShortcut(shortcut: Shortcut) { +function createKeysFromShortcut(shortcut: Shortcut | undefined) { + if (!shortcut) { + return []; + } + const modifiers = shortcut.modifiers; const character = shortcut.key; diff --git a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts index 019f48f0c8..d78b0c7083 100644 --- a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts @@ -1,4 +1,5 @@ import { Prisma, TaskRunStatus } from "@trigger.dev/database"; +import parse from "parse-duration"; import { Direction } from "~/components/runs/RunStatuses"; import { FINISHED_STATUSES } from "~/components/runs/v3/TaskRunStatus"; import { sqlDatabaseSchema } from "~/db.server"; @@ -15,6 +16,7 @@ type RunListOptions = { statuses?: TaskRunStatus[]; environments?: string[]; scheduleId?: string; + period?: string; from?: number; to?: number; //pagination @@ -38,6 +40,7 @@ export class RunListPresenter extends BasePresenter { statuses, environments, scheduleId, + period, from, to, direction = "forward", @@ -47,10 +50,11 @@ export class RunListPresenter extends BasePresenter { const hasStatusFilters = statuses && statuses.length > 0; const hasFilters = - tasks !== undefined || - versions !== undefined || + (tasks !== undefined && tasks.length > 0) || + (versions !== undefined && versions.length > 0) || hasStatusFilters || - environments !== undefined || + (environments !== undefined && environments.length > 0) || + (period !== undefined && period !== "all") || from !== undefined || to !== undefined; @@ -90,6 +94,8 @@ export class RunListPresenter extends BasePresenter { }, }); + const periodMs = period ? parse(period) : undefined; + //get the runs let runs = await this._replica.$queryRaw< { @@ -152,6 +158,11 @@ export class RunListPresenter extends BasePresenter { : Prisma.empty } ${scheduleId ? Prisma.sql`AND tr."scheduleId" = ${scheduleId}` : Prisma.empty} + ${ + periodMs + ? Prisma.sql`AND tr."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}` + : Prisma.empty + } ${ from ? Prisma.sql`AND tr."createdAt" >= ${new Date(from).toISOString()}::timestamp` @@ -224,7 +235,11 @@ export class RunListPresenter extends BasePresenter { next, previous, }, - possibleTasks: possibleTasks.map((task) => task.slug), + possibleTasks: possibleTasks + .map((task) => ({ slug: task.slug, triggerSource: task.triggerSource })) + .sort((a, b) => { + return a.slug.localeCompare(b.slug); + }), filters: { tasks: tasks || [], versions: versions || [], diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.environments/FirstEndpointSheet.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.environments/FirstEndpointSheet.tsx index 1b741e399c..45dc829097 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.environments/FirstEndpointSheet.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.environments/FirstEndpointSheet.tsx @@ -27,7 +27,7 @@ import { SelectItem, SelectTrigger, SelectValue, -} from "~/components/primitives/Select"; +} from "~/components/primitives/SimpleSelect"; import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; type FirstEndpointSheetProps = { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.test/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.test/route.tsx index b2cff83f72..a4e2902f80 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.test/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.test/route.tsx @@ -25,7 +25,7 @@ import { SelectItem, SelectTrigger, SelectValue, -} from "~/components/primitives/Select"; +} from "~/components/primitives/SimpleSelect"; import { TextLink } from "~/components/primitives/TextLink"; import { runStatusClassNameColor, runStatusTitle } from "~/components/runs/RunStatuses"; import { redirectBackWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx index 57107654c4..2fe2d23fcc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx @@ -30,8 +30,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { projectParam, organizationSlug } = ProjectParamSchema.parse(params); const url = new URL(request.url); - const s = Object.fromEntries(url.searchParams.entries()); - const { tasks, versions, statuses, environments, from, to, cursor, direction } = + const s = { + cursor: url.searchParams.get("cursor") ?? undefined, + direction: url.searchParams.get("direction") ?? undefined, + statuses: url.searchParams.getAll("statuses"), + environments: url.searchParams.getAll("environments"), + tasks: url.searchParams.getAll("tasks"), + period: url.searchParams.get("period") ?? undefined, + }; + const { tasks, versions, statuses, environments, period, from, to, cursor, direction } = TaskRunListSearchFilters.parse(s); const presenter = new RunListPresenter(); @@ -42,6 +49,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { versions, statuses, environments, + period, from, to, direction: direction, @@ -88,10 +96,11 @@ export default function Page() { ) : (
-
+
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route.tsx index 3db6aaf271..8f96fd3d3d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route.tsx @@ -30,11 +30,11 @@ import { } from "~/components/primitives/Table"; import { TaskFunctionName } from "~/components/runs/v3/TaskPath"; import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource"; +import { useFilterTasks } from "~/hooks/useFilterTasks"; import { useLinkStatus } from "~/hooks/useLinkStatus"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { useTextFilter } from "~/hooks/useTextFilter"; import { SelectedEnvironment, TaskListItem, @@ -153,36 +153,7 @@ function TaskSelector({ tasks: TaskListItem[]; environmentSlug: string; }) { - const { filterText, setFilterText, filteredItems } = useTextFilter({ - items: tasks, - filter: (task, text) => { - if (task.taskIdentifier.toLowerCase().includes(text.toLowerCase())) { - return true; - } - - if (task.exportName.toLowerCase().includes(text.toLowerCase())) { - return true; - } - - if (task.filePath.toLowerCase().includes(text.toLowerCase())) { - return true; - } - - if (task.id.toLowerCase().includes(text.toLowerCase())) { - return true; - } - - if (task.friendlyId.toLowerCase().includes(text.toLowerCase())) { - return true; - } - - if (task.triggerSource === "SCHEDULED" && "scheduled".includes(text.toLowerCase())) { - return true; - } - - return false; - }, - }); + const { filterText, setFilterText, filteredItems } = useFilterTasks({ tasks }); return (
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx index eb7bad6add..16c0a5c587 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx @@ -16,14 +16,7 @@ import { FormTitle } from "~/components/primitives/FormTitle"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/primitives/Select"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { prisma } from "~/db.server"; import { useFeatures } from "~/hooks/useFeatures"; import { redirectWithSuccessMessage } from "~/models/message.server"; @@ -148,20 +141,24 @@ export default function NewOrganizationPage() { {canCreateV3Projects ? ( - - - + {projectVersion.error} ) : ( diff --git a/apps/webapp/app/routes/_app.orgs.new/route.tsx b/apps/webapp/app/routes/_app.orgs.new/route.tsx index 563215901e..eaadf34202 100644 --- a/apps/webapp/app/routes/_app.orgs.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.new/route.tsx @@ -17,14 +17,7 @@ import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { RadioGroupItem } from "~/components/primitives/RadioButton"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/primitives/Select"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { featuresForRequest } from "~/features.server"; import { useFeatures } from "~/hooks/useFeatures"; import { createOrganization } from "~/models/organization.server"; @@ -143,17 +136,24 @@ export default function NewOrganizationPage() { {v3Enabled ? ( - - - + {projectVersion.error} ) : ( diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route.tsx index 692a34050c..6ddf72c53b 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route.tsx @@ -21,14 +21,7 @@ import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/primitives/Select"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { Table, TableBody, @@ -189,28 +182,25 @@ export function UpsertScheduleForm({
- - task.toLowerCase().includes(search.toLowerCase())} + dropdownIcon + > + {(matches) => ( + <> + {matches?.map((task) => ( - - {task} - + {task} ))} - - - + + )} + {taskIdentifier.error} {showGenerateField && } diff --git a/apps/webapp/app/routes/storybook.filter/route.tsx b/apps/webapp/app/routes/storybook.filter/route.tsx new file mode 100644 index 0000000000..78323201b7 --- /dev/null +++ b/apps/webapp/app/routes/storybook.filter/route.tsx @@ -0,0 +1,246 @@ +import { CpuChipIcon } from "@heroicons/react/20/solid"; +import { CircleStackIcon } from "@heroicons/react/24/outline"; +import { Form } from "@remix-run/react"; +import { startTransition, useCallback, useMemo, useState } from "react"; +import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { + ComboBox, + ComboboxProvider, + SelectButtonItem, + SelectItem, + SelectList, + SelectPopover, + SelectProvider, + SelectTrigger, + shortcutFromIndex, +} from "~/components/primitives/Select"; +import { + TaskRunStatusCombo, + allTaskRunStatuses, + runStatusTitle, +} from "~/components/runs/v3/TaskRunStatus"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import { ShortcutDefinition } from "~/hooks/useShortcutKeys"; + +export default function Story() { + return ( +
+
+
+ +
+
+
+ ); +} + +const filterTypes = [ + { + name: "status", + title: "Status", + icon: , + }, + { name: "environment", title: "Environment", icon: }, +]; + +type FilterType = (typeof filterTypes)[number]["name"]; + +//todo what if we had shortcut keys that would deeplink you to the appropriate filter? +function Filter() { + const [filterType, setFilterType] = useState(); + const [searchValue, setSearchValue] = useState(""); + const shortcut = { key: "f" }; + + const clearSearchValue = useCallback(() => { + setSearchValue(""); + }, [setSearchValue]); + + const filterTrigger = ( + + Filter + + ); + + return ( + { + startTransition(() => { + setSearchValue(value); + }); + }} + setOpen={(open) => { + if (!open) { + setFilterType(undefined); + } + }} + > + + + ); +} + +type MenuProps = { + searchValue: string; + clearSearchValue: () => void; + shortcut: ShortcutDefinition; + trigger: React.ReactNode; + filterType: FilterType | undefined; + setFilterType: (filterType: FilterType | undefined) => void; +}; + +function Menu(props: MenuProps) { + switch (props.filterType) { + case undefined: + return ; + case "status": + return ; + case "environment": + return ; + } + return <>; +} + +function MainMenu({ searchValue, clearSearchValue, setFilterType, trigger, shortcut }: MenuProps) { + const filtered = useMemo(() => { + return filterTypes.filter((item) => + item.title.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue]); + + return ( + + {trigger} + + + + {filtered.map((type, index) => ( + { + clearSearchValue(); + setFilterType(type.name); + }} + icon={type.icon} + shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })} + > + {type.title} + + ))} + + + + ); +} + +const statuses = allTaskRunStatuses.map((status) => ({ + title: runStatusTitle(status), + value: status, +})); + +function Statuses({ trigger, clearSearchValue, shortcut, searchValue, setFilterType }: MenuProps) { + const { values, replace } = useSearchParams(); + + const handleChange = useCallback((values: string[]) => { + clearSearchValue(); + replace({ status: values }); + }, []); + + const filtered = useMemo(() => { + return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase())); + }, [searchValue]); + + return ( + + {trigger} + { + setFilterType(undefined); + return false; + }} + > + + + {filtered.map((item, index) => ( + + + + ))} + + + + ); +} + +const environments = [ + { + type: "DEVELOPMENT" as const, + }, + { + type: "STAGING" as const, + }, + { + type: "PRODUCTION" as const, + }, +]; + +function Environments({ + trigger, + clearSearchValue, + shortcut, + searchValue, + setFilterType, +}: MenuProps) { + const { values, replace } = useSearchParams(); + + const handleChange = useCallback((values: string[]) => { + clearSearchValue(); + replace({ environment: values }); + }, []); + + const filtered = useMemo(() => { + return environments.filter((item) => + item.type.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue]); + + return ( + + {trigger} + { + setFilterType(undefined); + return false; + }} + > + + + {filtered.map((item, index) => ( + + + + ))} + + + + ); +} diff --git a/apps/webapp/app/routes/storybook.select/route.tsx b/apps/webapp/app/routes/storybook.select/route.tsx index f9fc2806a8..b91704c143 100644 --- a/apps/webapp/app/routes/storybook.select/route.tsx +++ b/apps/webapp/app/routes/storybook.select/route.tsx @@ -1,91 +1,272 @@ -import { Header1, Header2 } from "~/components/primitives/Headers"; +import { CircleStackIcon } from "@heroicons/react/20/solid"; +import { Form, useNavigate } from "@remix-run/react"; +import { useCallback, useState } from "react"; +import { LogoIcon } from "~/components/LogoIcon"; +import { Button } from "~/components/primitives/Buttons"; import { Select, - SelectContent, SelectGroup, + SelectGroupLabel, SelectItem, - SelectLabel, - SelectSeparator, - SelectTrigger, - SelectValue, + SelectLinkItem, + shortcutFromIndex, } from "~/components/primitives/Select"; +import { + TaskRunStatusCombo, + allTaskRunStatuses, + runStatusTitle, +} from "~/components/runs/v3/TaskRunStatus"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; + +const branches = [ + "main", + "0.10-stable", + "0.11-stable", + "0.12-stable", + "0.13-stable", + "0.14-stable", + "15-stable", + "15.6-dev", + "16.3-dev", + "16.4.2-dev", + "16.8.3", + "16.8.4", + "16.8.5", + "16.8.6", + "17.0.0-dev", + "builds/facebook-www", + "devtools-v4-merge", + "fabric-cleanup", + "fabric-focus-blur", + "gh-pages", + "leg", + "nativefb-enable-cache", + "nov-main-trigger", + "rsckeys", +]; export default function Story() { return ( -
-
- Variants - size=small width=content - - + + Item 1 + + + Item 2 + - - size=small width=full - - + + Item 1 + + + Item 2 + - - size=medium width=content - - + + Item 1 + + + Item 2 + - - size=medium width=full - - + + Item 1 + + + Item 2 + - -
+ + + + + + +
+
); } + +const statuses = allTaskRunStatuses.map((status) => ({ + title: runStatusTitle(status), + value: status, +})); + +function Statuses() { + const navigate = useNavigate(); + const location = useOptimisticLocation(); + const search = new URLSearchParams(location.search); + + const handleChange = useCallback((values: string[]) => { + search.delete("status"); + for (const value of values) { + search.append("status", value); + } + navigate(`${location.pathname}?${search.toString()}`, { replace: true }); + }, []); + + return ( + + ); +} + +export const projects = [ + { + type: "section" as const, + title: "Apple", + items: [ + { + title: "iTunes", + value: "itunes", + }, + { + title: "App Store", + value: "appstore", + }, + ], + }, + { + type: "section" as const, + title: "Google", + items: [ + { + title: "Maps", + value: "maps", + }, + { + title: "Gmail", + value: "gmail", + }, + { + title: "Waymo", + value: "waymo", + }, + { + title: "Android", + value: "android", + }, + ], + }, + { + type: "section" as const, + title: "Uber", + items: [ + { + title: "Planner", + value: "planner", + }, + ], + }, +]; + +function ProjectSelector() { + const location = useOptimisticLocation(); + const search = new URLSearchParams(location.search); + + const selected = projects + .find((p) => p.items.some((i) => i.value === search.get("project"))) + ?.items.find((i) => i.value === search.get("project")); + + const searchParams = new URLSearchParams(location.search); + searchParams.delete("project"); + + return ( + + ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index f8ed181ab6..ad8e61b0b4 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -134,6 +134,10 @@ const stories: Story[] = [ name: "Select", slug: "select", }, + { + name: "Filter", + slug: "filter", + }, { name: "Popover", slug: "popover", diff --git a/apps/webapp/package.json b/apps/webapp/package.json index c9c2df2923..ab06ac89d2 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -30,6 +30,8 @@ "/public/build" ], "dependencies": { + "@ariakit/react": "^0.4.6", + "@ariakit/react-core": "^0.4.6", "@aws-sdk/client-sqs": "^3.445.0", "@codemirror/autocomplete": "^6.3.1", "@codemirror/commands": "^6.1.2", @@ -124,11 +126,13 @@ "lodash.omit": "^4.5.0", "lucide-react": "^0.229.0", "marked": "^4.0.18", + "match-sorter": "^6.3.4", "morgan": "^1.10.0", "nanoid": "^3.3.4", "non.geist": "^1.0.2", "ohash": "^1.1.3", "openai": "^4.33.1", + "parse-duration": "^1.1.0", "posthog-js": "^1.93.3", "posthog-node": "^3.1.3", "prism-react-renderer": "^1.3.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a78f649d0..25c73dba4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,6 +201,12 @@ importers: apps/webapp: dependencies: + '@ariakit/react': + specifier: ^0.4.6 + version: 0.4.6(react-dom@18.2.0)(react@18.2.0) + '@ariakit/react-core': + specifier: ^0.4.6 + version: 0.4.6(react-dom@18.2.0)(react@18.2.0) '@aws-sdk/client-sqs': specifier: ^3.445.0 version: 3.454.0 @@ -483,6 +489,9 @@ importers: marked: specifier: ^4.0.18 version: 4.2.5 + match-sorter: + specifier: ^6.3.4 + version: 6.3.4 morgan: specifier: ^1.10.0 version: 1.10.0 @@ -498,6 +507,9 @@ importers: openai: specifier: ^4.33.1 version: 4.33.1 + parse-duration: + specifier: ^1.1.0 + version: 1.1.0 posthog-js: specifier: ^1.93.3 version: 1.93.3 @@ -3309,6 +3321,34 @@ packages: - chokidar dev: true + /@ariakit/core@0.4.6: + resolution: {integrity: sha512-L2WIZZlxDs611m3YLSv2xvJyQrkkVQJlxn8Y4DlI1G65VLTEH7hysw3RYUNdXsl0gP6S20id3zBMJCHT9BCRcg==} + dev: false + + /@ariakit/react-core@0.4.6(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-2Ca327IzSOxQEd3gEr59JJj0y8fXDMLYd+948wyOzIsk2/yoTnA4+R7Vhs361w3KzOjBQM44KmnNL7ckBMtT0w==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + dependencies: + '@ariakit/core': 0.4.6 + '@floating-ui/dom': 1.6.5 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + use-sync-external-store: 1.2.2(react@18.2.0) + dev: false + + /@ariakit/react@0.4.6(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-7lZQew9n+nxswgJ5aL87xo42I+t3A8gWzaxIIZY+c58SLCASh1IdDkGLhGPcyhmXWRIjs/8L1h6cMbRMEBrtJQ==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + dependencies: + '@ariakit/react-core': 0.4.6(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@astrojs/compiler@2.1.0: resolution: {integrity: sha512-Mp+qrNhly+27bL/Zq8lGeUY+YrdoU0eDfIlAeGIPrzt0PnI/jGpvPUdCaugv4zbCrDkOUScFfcbeEiYumrdJnw==} @@ -5236,6 +5276,13 @@ packages: dependencies: regenerator-runtime: 0.13.11 + /@babel/runtime@7.24.5: + resolution: {integrity: sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + dev: false + /@babel/template@7.22.15: resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==} engines: {node: '>=6.9.0'} @@ -7535,12 +7582,25 @@ packages: resolution: {integrity: sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg==} dev: false + /@floating-ui/core@1.6.1: + resolution: {integrity: sha512-42UH54oPZHPdRHdw6BgoBD6cg/eVTmVrFcgeRDM3jbO7uxSoipVcmcIGFcA5jmOHO5apcyvBhkSKES3fQJnu7A==} + dependencies: + '@floating-ui/utils': 0.2.2 + dev: false + /@floating-ui/dom@0.5.4: resolution: {integrity: sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg==} dependencies: '@floating-ui/core': 0.7.3 dev: false + /@floating-ui/dom@1.6.5: + resolution: {integrity: sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==} + dependencies: + '@floating-ui/core': 1.6.1 + '@floating-ui/utils': 0.2.2 + dev: false + /@floating-ui/react-dom@0.7.2(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg==} peerDependencies: @@ -7555,6 +7615,10 @@ packages: - '@types/react' dev: false + /@floating-ui/utils@0.2.2: + resolution: {integrity: sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==} + dev: false + /@formatjs/ecma402-abstract@1.18.0: resolution: {integrity: sha512-PEVLoa3zBevWSCZzPIM/lvPCi8P5l4G+NXQMc/CjEiaCWgyHieUoo0nM7Bs0n/NbuQ6JpXEolivQ9pKSBHaDlA==} dependencies: @@ -24693,11 +24757,11 @@ packages: hasBin: true dev: false - /match-sorter@6.3.1: - resolution: {integrity: sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==} + /match-sorter@6.3.4: + resolution: {integrity: sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==} dependencies: - '@babel/runtime': 7.22.5 - remove-accents: 0.4.2 + '@babel/runtime': 7.24.5 + remove-accents: 0.5.0 dev: false /md-to-react-email@5.0.2(react@18.2.0): @@ -27136,6 +27200,10 @@ packages: dependencies: callsites: 3.1.0 + /parse-duration@1.1.0: + resolution: {integrity: sha512-z6t9dvSJYaPoQq7quMzdEagSFtpGu+utzHqqxmpVWNNZRIXnvqyCvn9XsTdh7c/w0Bqmdz3RB3YnRaKtpRtEXQ==} + dev: false + /parse-entities@4.0.0: resolution: {integrity: sha512-5nk9Fn03x3rEhGaX1FU6IDwG/k+GxLXlFAkgrbM1asuAFl3BhdQWvASaIsmwWypRNcZKHPYnIuOSfIWEyEQnPQ==} dependencies: @@ -28631,7 +28699,7 @@ packages: dependencies: '@babel/runtime': 7.22.5 broadcast-channel: 3.7.0 - match-sorter: 6.3.1 + match-sorter: 6.3.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: false @@ -29046,6 +29114,10 @@ packages: /regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + /regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + dev: false + /regenerator-transform@0.15.1: resolution: {integrity: sha512-knzmNAcuyxV+gQCufkYcvOqX/qIIfHLv0u5x79kRxuGojfYVky1f15TzZEu2Avte8QGepvUNTnLskf8E6X6Vyg==} dependencies: @@ -29350,8 +29422,8 @@ packages: engines: {node: '>=8'} dev: false - /remove-accents@0.4.2: - resolution: {integrity: sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==} + /remove-accents@0.5.0: + resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==} dev: false /repeat-element@1.1.4: @@ -32967,6 +33039,14 @@ packages: tslib: 2.6.2 dev: false + /use-sync-external-store@1.2.2(react@18.2.0): + resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /use@3.1.1: resolution: {integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==} engines: {node: '>=0.10.0'}