From 85e89b620691b5d689b764b4b7bd69fb7056fc25 Mon Sep 17 00:00:00 2001 From: Govinda Vashishtha <57435703+govindavashishtha@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:48:55 +0530 Subject: [PATCH 1/9] Enhance scheduling features in trueforge-ui --- .changeset/schedules-side-drawer.md | 5 + .gitignore | 1 + .../src/atoms/SchedulesButton.tsx | 56 +++ .../agent-details/AgentSessionsFilters.tsx | 118 +++--- .../src/atoms/lib/selectClasses.ts | 27 ++ .../src/atoms/primitives/DropdownMenu.tsx | 93 +++-- .../src/atoms/primitives/PopoverSelect.tsx | 173 +++++++++ .../src/atoms/primitives/SideDrawer.tsx | 133 +++++++ .../src/atoms/primitives/Table.tsx | 146 ++++++++ .../atoms/schedules/ScheduleFormDrawer.tsx | 166 +++++++++ .../atoms/schedules/ScheduleFormFields.tsx | 220 +++++++++++ .../atoms/schedules/ScheduleStatusBadge.tsx | 21 ++ .../src/atoms/schedules/SchedulesPage.tsx | 352 ++++++++++++++++++ .../src/atoms/schedules/cadence.ts | 245 ++++++++++++ .../src/containers/ThreadListContainer.tsx | 4 + .../trueforge-ui/src/icons/IconRegistry.tsx | 6 + packages/trueforge-ui/src/index.ts | 34 ++ .../trueforge-ui/src/layouts/DrawerLayout.tsx | 23 +- .../src/layouts/SidebarLayout.tsx | 25 +- .../src/layouts/StackChatPanel.tsx | 22 ++ .../trueforge-agent-server-adapter/index.ts | 3 + .../schedules/scheduleServer.ts | 111 ++++++ .../src/routing/ShellRouteSync.tsx | 11 + .../trueforge-ui/src/routing/derivePlace.ts | 6 +- packages/trueforge-ui/src/routing/paths.ts | 7 + packages/trueforge-ui/src/routing/types.ts | 7 +- .../trueforge-ui/src/server/ServerContext.tsx | 20 +- .../src/server/ShellModeContext.tsx | 57 ++- .../src/server/createTrueFoundryServer.ts | 36 +- packages/trueforge-ui/src/server/types.ts | 6 + .../trueforge-ui/src/theme/defaultSlots.ts | 2 + .../test/atoms/AgentSessionsFilters.test.tsx | 4 +- .../test/atoms/SessionsPage.test.tsx | 6 +- .../atoms/primitives/DropdownMenu.test.tsx | 19 + .../atoms/primitives/PopoverSelect.test.tsx | 55 +++ .../test/atoms/primitives/SideDrawer.test.tsx | 106 ++++++ .../test/atoms/primitives/Table.test.tsx | 93 +++++ .../schedules/ScheduleFormDrawer.test.tsx | 109 ++++++ .../atoms/schedules/SchedulesPage.test.tsx | 114 ++++++ .../ThreadListContainer.pagination.test.tsx | 5 +- .../trueforge-ui/test/publicUiExports.test.ts | 18 + .../test/routing/derivePlace.test.ts | 9 + .../trueforge-ui/test/routing/paths.test.ts | 4 + 43 files changed, 2582 insertions(+), 96 deletions(-) create mode 100644 .changeset/schedules-side-drawer.md create mode 100644 packages/trueforge-ui/src/atoms/SchedulesButton.tsx create mode 100644 packages/trueforge-ui/src/atoms/lib/selectClasses.ts create mode 100644 packages/trueforge-ui/src/atoms/primitives/PopoverSelect.tsx create mode 100644 packages/trueforge-ui/src/atoms/primitives/SideDrawer.tsx create mode 100644 packages/trueforge-ui/src/atoms/primitives/Table.tsx create mode 100644 packages/trueforge-ui/src/atoms/schedules/ScheduleFormDrawer.tsx create mode 100644 packages/trueforge-ui/src/atoms/schedules/ScheduleFormFields.tsx create mode 100644 packages/trueforge-ui/src/atoms/schedules/ScheduleStatusBadge.tsx create mode 100644 packages/trueforge-ui/src/atoms/schedules/SchedulesPage.tsx create mode 100644 packages/trueforge-ui/src/atoms/schedules/cadence.ts create mode 100644 packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/schedules/scheduleServer.ts create mode 100644 packages/trueforge-ui/test/atoms/primitives/PopoverSelect.test.tsx create mode 100644 packages/trueforge-ui/test/atoms/primitives/SideDrawer.test.tsx create mode 100644 packages/trueforge-ui/test/atoms/primitives/Table.test.tsx create mode 100644 packages/trueforge-ui/test/atoms/schedules/ScheduleFormDrawer.test.tsx create mode 100644 packages/trueforge-ui/test/atoms/schedules/SchedulesPage.test.tsx diff --git a/.changeset/schedules-side-drawer.md b/.changeset/schedules-side-drawer.md new file mode 100644 index 000000000..a0bc76cde --- /dev/null +++ b/.changeset/schedules-side-drawer.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge-ui": minor +--- + +Add global Schedules page at `/schedules` with listing, popover-based filters, and create/edit drawer wired to the schedule API. Add Table primitives with client-side pagination and portal DropdownMenu so row actions are not clipped by overflow. Export a reusable popover select with single- and multi-select modes. Remove per-agent schedules from Agents Library. diff --git a/.gitignore b/.gitignore index 6ba4f9250..a90e1c82d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ *.tgz +!vendor/*.tgz .DS_Store *.log coverage/ diff --git a/packages/trueforge-ui/src/atoms/SchedulesButton.tsx b/packages/trueforge-ui/src/atoms/SchedulesButton.tsx new file mode 100644 index 000000000..01bd9b734 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/SchedulesButton.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { Icon } from '../icons/Icon.js'; +import { useOptionalScheduleServer } from '../server/ServerContext.js'; +import { useOptionalShellMode } from '../server/ShellModeContext.js'; +import { auiButtonClass } from './lib/buttonClasses.js'; +import { cn } from './lib/cn.js'; + +export type SchedulesButtonProps = { + className?: string; + compact?: boolean; +}; + +export function SchedulesButton({ className, compact = false }: SchedulesButtonProps) { + const shell = useOptionalShellMode(); + const scheduleServer = useOptionalScheduleServer(); + + const enabled = scheduleServer != null && shell != null; + const open = shell?.schedulesOpen === true; + + if (!enabled) return null; + + return ( +
+ +
+ ); +} + +declare module '../theme/SlotsProvider.js' { + interface AtomSlots { + SchedulesButton: typeof SchedulesButton; + } +} diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionsFilters.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionsFilters.tsx index cfddab0e8..acbe76203 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionsFilters.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionsFilters.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; +import { Icon } from '../../icons/Icon.js'; import { useOptionalServer } from '../../server/ServerContext.js'; import { useOptionalShellMode } from '../../server/ShellModeContext.js'; import type { AgentBuilderServer, AgentLibraryEntry } from '../../server/types.js'; @@ -13,8 +14,12 @@ import { SESSION_TIME_PRESETS, toDateTimeLocalValue, } from '../../utils/sessionTimePresets.js'; +import { auiButtonClass } from '../lib/buttonClasses.js'; import { cn } from '../lib/cn.js'; +import { auiInputClass } from '../lib/inputClasses.js'; +import { auiSelectMenuClass, auiSelectOptionClass, auiSelectTriggerClass } from '../lib/selectClasses.js'; import { SEARCH_AGENTS_PAGE_SIZE } from '../lib/useSearchAgentsList.js'; +import { PopoverSelect } from '../primitives/PopoverSelect.js'; async function searchAllAgents( server: Pick, @@ -106,31 +111,25 @@ export function AgentSessionsFilters({ return (
- + options={[ + { value: '', label: 'All' }, + ...agents.map(agent => ({ value: agent.agentId ?? agent.name, label: agent.name })), + ]} + onValueChange={value => onAgentChange(value.length === 0 ? null : value)} + /> +
{menuOpen ? ( -
+
{customPickerOpen ? (
Select Time Range
@@ -151,7 +151,7 @@ export function AgentSessionsFilters({ setFromValue(event.target.value)} /> @@ -161,7 +161,7 @@ export function AgentSessionsFilters({ setToValue(event.target.value)} /> @@ -169,52 +169,56 @@ export function AgentSessionsFilters({

Timezone: {formatTimezoneOffsetLabel()}

-
) : null} -
+
- {SESSION_TIME_PRESETS.map(preset => ( - - ))} + /> + + {SESSION_TIME_PRESETS.map(preset => { + const selected = timeRange.timeWindowMs === preset.windowMs; + return ( + + ); + })}
) : null} diff --git a/packages/trueforge-ui/src/atoms/lib/selectClasses.ts b/packages/trueforge-ui/src/atoms/lib/selectClasses.ts new file mode 100644 index 000000000..65ae5f88c --- /dev/null +++ b/packages/trueforge-ui/src/atoms/lib/selectClasses.ts @@ -0,0 +1,27 @@ +import { cn } from './cn.js'; +import { auiInputClass } from './inputClasses.js'; + +/** + * Shared dropdown chrome. Every select-like surface (PopoverSelect, the session + * time-range popover) composes these so triggers, menus, and option rows stay + * visually identical; callers pass `className` for width and placement only. + */ +export function auiSelectTriggerClass(className?: string): string { + return auiInputClass(cn('flex h-9 cursor-pointer items-center justify-between gap-2 pr-2 text-left', className)); +} + +export function auiSelectMenuClass(className?: string): string { + return cn( + 'bg-card-bg text-text-primary absolute top-full z-50 mt-1 max-h-64 overflow-y-auto rounded-md border border-border p-1 shadow-md', + className, + ); +} + +export function auiSelectOptionClass(className?: string): string { + return cn( + 'text-text-primary flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors', + 'hover:bg-ghost-button-hover focus:bg-dropdown-selected-item-bg focus:text-dropdown-selected-item-text', + 'disabled:pointer-events-none disabled:opacity-50', + className, + ); +} diff --git a/packages/trueforge-ui/src/atoms/primitives/DropdownMenu.tsx b/packages/trueforge-ui/src/atoms/primitives/DropdownMenu.tsx index 5a77fb39f..c0fe1e97d 100644 --- a/packages/trueforge-ui/src/atoms/primitives/DropdownMenu.tsx +++ b/packages/trueforge-ui/src/atoms/primitives/DropdownMenu.tsx @@ -1,7 +1,20 @@ -import React, { useEffect, useId, useRef, useState } from 'react'; +'use client'; + +import React, { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { cn } from '../lib/cn.js'; +/** Keep portaled chrome under ThemeProvider so preset/custom CSS vars still apply. */ +function themePortalRoot(from: HTMLElement | null): HTMLElement { + // A native opened with showModal() renders in the top layer, above any + // z-index. When the trigger lives inside one, portal into the dialog so the + // menu joins the top layer instead of rendering behind the modal. + const dialog = from?.closest('dialog'); + if (dialog instanceof HTMLElement) return dialog; + return from?.closest('.aui-theme-root') ?? document.body; +} + export type DropdownMenuProps = { trigger: React.ReactNode; children: React.ReactNode; @@ -11,16 +24,44 @@ export type DropdownMenuProps = { export function DropdownMenu({ trigger, children, align = 'end', className }: DropdownMenuProps) { const [open, setOpen] = useState(false); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const containerRef = useRef(null); const menuRef = useRef(null); const menuId = useId(); + useLayoutEffect(() => { + if (!open) { + setPos(null); + return; + } + + const update = () => { + const el = containerRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setPos({ + top: rect.bottom + 4, + left: align === 'end' ? rect.right : rect.left, + }); + }; + + update(); + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + return () => { + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + }; + }, [open, align]); + useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false); - } + const target = e.target; + if (!(target instanceof Node)) return; + if (containerRef.current?.contains(target)) return; + if (menuRef.current?.contains(target)) return; + setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); @@ -30,7 +71,7 @@ export function DropdownMenu({ trigger, children, align = 'end', className }: Dr if (!open) return; const first = menuRef.current?.querySelector('[role="menuitem"]:not([disabled])'); first?.focus(); - }, [open]); + }, [open, pos]); useEffect(() => { if (!open) return; @@ -82,25 +123,35 @@ export function DropdownMenu({ trigger, children, align = 'end', className }: Dr }) : trigger; + const menu = + open && pos != null + ? createPortal( + , + themePortalRoot(containerRef.current), + ) + : null; + return (
setOpen(v => !v)}>{triggerEl}
- {open && ( - - )} + {menu}
); } diff --git a/packages/trueforge-ui/src/atoms/primitives/PopoverSelect.tsx b/packages/trueforge-ui/src/atoms/primitives/PopoverSelect.tsx new file mode 100644 index 000000000..6d58b82d6 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/primitives/PopoverSelect.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useEffect, useId, useRef, useState } from 'react'; + +import { Icon } from '../../icons/Icon.js'; +import { cn } from '../lib/cn.js'; +import { auiSelectMenuClass, auiSelectOptionClass, auiSelectTriggerClass } from '../lib/selectClasses.js'; + +export type PopoverSelectOption = { + value: T; + label: string; + disabled?: boolean; +}; + +type CommonPopoverSelectProps = { + options: readonly PopoverSelectOption[]; + placeholder?: string; + disabled?: boolean; + className?: string; + 'aria-label': string; +}; + +export type PopoverSelectProps = CommonPopoverSelectProps & + ( + | { + multiple?: false; + value: T; + onValueChange: (value: T) => void; + } + | { + multiple: true; + value: readonly T[]; + onValueChange: (value: T[]) => void; + } + ); + +export function PopoverSelect(props: PopoverSelectProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const listboxRef = useRef(null); + const listboxId = useId(); + + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: MouseEvent) => { + if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false); + }; + document.addEventListener('mousedown', handlePointerDown); + return () => document.removeEventListener('mousedown', handlePointerDown); + }, [open]); + + useEffect(() => { + if (!open) return; + const selected = listboxRef.current?.querySelector('[role="option"][aria-selected="true"]'); + const first = listboxRef.current?.querySelector('[role="option"]:not([aria-disabled="true"])'); + (selected ?? first)?.focus(); + }, [open]); + + useEffect(() => { + if (!open) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setOpen(false); + triggerRef.current?.focus(); + return; + } + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + + const items = Array.from( + listboxRef.current?.querySelectorAll('[role="option"]:not([aria-disabled="true"])') ?? [], + ); + if (items.length === 0) return; + + event.preventDefault(); + const currentIndex = items.findIndex(item => item === document.activeElement); + const nextIndex = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? items.length - 1 + : event.key === 'ArrowDown' + ? (currentIndex + 1) % items.length + : (currentIndex - 1 + items.length) % items.length; + items[nextIndex]?.focus(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [open]); + + const isSelected = (value: T) => (props.multiple ? props.value.includes(value) : props.value === value); + + const selectedLabels = props.options.filter(option => isSelected(option.value)).map(option => option.label); + const triggerLabel = + selectedLabels.length === 0 + ? (props.placeholder ?? 'Select') + : props.multiple && selectedLabels.length > 1 + ? `${selectedLabels.length} selected` + : selectedLabels[0]; + + const select = (option: PopoverSelectOption) => { + if (option.disabled) return; + if (props.multiple) { + props.onValueChange( + props.value.includes(option.value) + ? props.value.filter(value => value !== option.value) + : [...props.value, option.value], + ); + return; + } + props.onValueChange(option.value); + setOpen(false); + triggerRef.current?.focus(); + }; + + return ( +
+ + + {open ? ( +
+ {props.options.map(option => { + const selected = isSelected(option.value); + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/packages/trueforge-ui/src/atoms/primitives/SideDrawer.tsx b/packages/trueforge-ui/src/atoms/primitives/SideDrawer.tsx new file mode 100644 index 000000000..21d885629 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/primitives/SideDrawer.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, useId, useRef, type ReactNode } from 'react'; + +import { Icon } from '../../icons/Icon.js'; +import { auiButtonClass } from '../lib/buttonClasses.js'; +import { cn } from '../lib/cn.js'; +import { useCompactLayout } from '../lib/CompactLayoutContext.js'; +import { useCompactOverlayStyle } from '../lib/useCompactOverlayStyle.js'; + +export type SideDrawerAnchor = 'left' | 'right'; +export type SideDrawerSize = 'sm' | 'md' | 'lg' | 'xl'; + +export type SideDrawerProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + headerIcon?: ReactNode; + children: ReactNode; + footer?: ReactNode; + /** Desktop side; ignored when compact / below `md` (falls back to bottom sheet). */ + anchor?: SideDrawerAnchor; + /** Desktop width. Defaults to `md`. */ + size?: SideDrawerSize; + className?: string; + 'aria-label'?: string; +}; + +const SIZE_WIDTH: Record = { + sm: 'md:w-80', + md: 'md:w-[28rem]', + lg: 'md:w-[36rem]', + xl: 'md:w-[42rem]', +}; + +/** + * Responsive overlay chrome: side drawer on `md+`, bottom sheet below `md` + * (and always when compact dock/widget layout is active). + */ +export function SideDrawer({ + open, + onOpenChange, + title, + description, + headerIcon, + children, + footer, + anchor = 'right', + size = 'md', + className, + 'aria-label': ariaLabel, +}: SideDrawerProps) { + const ref = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + const compact = useCompactLayout(); + const compactStyle = useCompactOverlayStyle(ref, compact); + + useEffect(() => { + const el = ref.current; + if (!el) return; + if (open && !el.open) el.showModal(); + else if (!open && el.open) el.close(); + }, [open]); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const handler = () => onOpenChange(false); + el.addEventListener('close', handler); + return () => el.removeEventListener('close', handler); + }, [onOpenChange]); + + return ( + { + event.preventDefault(); + event.stopPropagation(); + onOpenChange(false); + }} + style={compactStyle} + className={cn( + 'bg-card-bg text-text-primary border-border open:flex open:flex-col overflow-hidden rounded-none p-0 shadow-xl', + 'backdrop:bg-black/50 backdrop:backdrop-blur-[2px] dark:backdrop:bg-black/70', + compact + ? 'm-0 mt-auto h-[min(85dvh,40rem)] w-full max-w-none border-t pb-[env(safe-area-inset-bottom)]' + : cn( + // Mobile / narrow: bottom sheet + 'm-0 mt-auto h-[min(85dvh,40rem)] w-full max-w-none border-t pb-[env(safe-area-inset-bottom)]', + // Desktop: side drawer + 'md:mt-0 md:h-dvh md:max-h-none md:border md:pb-0', + SIZE_WIDTH[size], + anchor === 'right' + ? 'md:ml-auto md:mr-0 md:border-y-0 md:border-r-0' + : 'md:mr-auto md:ml-0 md:border-y-0 md:border-l-0', + ), + className, + )} + > +
+ {headerIcon} +
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+ +
+
{children}
+ {footer ?
{footer}
: null} +
+ ); +} diff --git a/packages/trueforge-ui/src/atoms/primitives/Table.tsx b/packages/trueforge-ui/src/atoms/primitives/Table.tsx new file mode 100644 index 000000000..7e20508ce --- /dev/null +++ b/packages/trueforge-ui/src/atoms/primitives/Table.tsx @@ -0,0 +1,146 @@ +'use client'; + +import React from 'react'; + +import { Icon } from '../../icons/Icon.js'; +import { auiButtonClass } from '../lib/buttonClasses.js'; +import { cn } from '../lib/cn.js'; +import { PopoverSelect } from './PopoverSelect.js'; + +export const DEFAULT_TABLE_PAGE_SIZE = 10; +export const TABLE_PAGE_SIZE_OPTIONS = [10, 25, 50] as const; + +export type TableProps = React.HTMLAttributes & { + /** Extra classes on the horizontal scroll wrapper. */ + containerClassName?: string; +}; + +export function Table({ className, containerClassName, ...props }: TableProps) { + return ( +
+ + + ); +} + +export type TableHeaderProps = React.HTMLAttributes; + +export function TableHeader({ className, ...props }: TableHeaderProps) { + return ; +} + +export type TableBodyProps = React.HTMLAttributes; + +export function TableBody({ className, ...props }: TableBodyProps) { + return ; +} + +export type TableRowProps = React.HTMLAttributes; + +export function TableRow({ className, ...props }: TableRowProps) { + return ( + + ); +} + +export type TableHeadProps = React.ThHTMLAttributes; + +export function TableHead({ className, ...props }: TableHeadProps) { + return ( +
+ ); +} + +export type TableCellProps = React.TdHTMLAttributes; + +export function TableCell({ className, ...props }: TableCellProps) { + return ; +} + +export type TablePaginationProps = { + page: number; + pageSize: number; + total: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; + pageSizeOptions?: readonly number[]; + className?: string; +}; + +export function TablePagination({ + page, + pageSize, + total, + onPageChange, + onPageSizeChange, + pageSizeOptions = TABLE_PAGE_SIZE_OPTIONS, + className, +}: TablePaginationProps) { + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const safePage = Math.min(page, pageCount - 1); + const from = total === 0 ? 0 : safePage * pageSize + 1; + const to = Math.min(total, (safePage + 1) * pageSize); + const canPrev = safePage > 0; + const canNext = safePage < pageCount - 1; + + const sizeOptions = pageSizeOptions.map(size => ({ + value: String(size), + label: String(size), + })); + + return ( +
+

+ {total === 0 ? 'Showing 0 of 0' : `Showing ${String(from)}–${String(to)} of ${String(total)}`} +

+
+ +
+ + + {String(safePage + 1)} / {String(pageCount)} + + +
+
+
+ ); +} diff --git a/packages/trueforge-ui/src/atoms/schedules/ScheduleFormDrawer.tsx b/packages/trueforge-ui/src/atoms/schedules/ScheduleFormDrawer.tsx new file mode 100644 index 000000000..25d37b674 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/schedules/ScheduleFormDrawer.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useEffect, useMemo, useState, type FormEvent } from 'react'; + +import { Icon } from '../../icons/Icon.js'; +import { useScheduleServer, useServer } from '../../server/ServerContext.js'; +import { libraryAgentId } from '../../server/ShellModeContext.js'; +import type { Schedule } from '../../server/types.js'; +import { SEARCH_AGENTS_PAGE_SIZE } from '../lib/useSearchAgentsList.js'; +import { Button } from '../primitives/Button.js'; +import { SideDrawer } from '../primitives/SideDrawer.js'; +import { cronToFormValues, defaultScheduleFormValues, valuesToCron, type ScheduleFormValues } from './cadence.js'; +import { ScheduleFormFields } from './ScheduleFormFields.js'; + +export type ScheduleFormDrawerProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: 'create' | 'edit'; + schedule?: Schedule; + initialAgentId?: string; + onSaved?: () => void; +}; + +export function ScheduleFormDrawer({ + open, + onOpenChange, + mode, + schedule, + initialAgentId = '', + onSaved, +}: ScheduleFormDrawerProps) { + const scheduleServer = useScheduleServer(); + const server = useServer(); + const [form, setForm] = useState(defaultScheduleFormValues); + const [agentId, setAgentId] = useState(initialAgentId); + const [agentOptions, setAgentOptions] = useState>([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const isEdit = mode === 'edit'; + + useEffect(() => { + if (!open) return; + let cancelled = false; + void server + .searchAgents({ limit: SEARCH_AGENTS_PAGE_SIZE }) + .then(rows => { + if (cancelled) return; + setAgentOptions(rows.map(agent => ({ agentId: libraryAgentId(agent), name: agent.name }))); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [open, server]); + + useEffect(() => { + if (!open) { + setForm(defaultScheduleFormValues()); + setAgentId(initialAgentId); + setError(null); + return; + } + if (isEdit && schedule != null) { + setForm( + cronToFormValues({ + name: schedule.name, + task: schedule.task, + cron: schedule.cron, + timezone: schedule.timezone, + }), + ); + setAgentId(schedule.agentId); + return; + } + setForm(defaultScheduleFormValues()); + setAgentId(initialAgentId); + }, [open, isEdit, schedule, initialAgentId]); + + const title = isEdit ? 'Edit Schedule' : 'New Schedule'; + const description = isEdit + ? 'Update cadence and task for this schedule.' + : 'Create a recurring unattended run for an agent.'; + + const canSubmit = useMemo(() => { + const cron = valuesToCron(form); + return form.name.trim().length > 0 && form.task.trim().length > 0 && cron.length > 0 && agentId.length > 0; + }, [form, agentId]); + + const handleSave = async (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + const cron = valuesToCron(form); + setSaving(true); + setError(null); + try { + if (isEdit && schedule != null) { + await scheduleServer.updateSchedule({ + id: schedule.id, + name: form.name.trim(), + task: form.task.trim(), + cron, + timezone: form.timezone, + status: schedule.status, + }); + } else { + await scheduleServer.createSchedule({ + agentId, + name: form.name.trim(), + task: form.task.trim(), + cron, + timezone: form.timezone, + }); + } + onSaved?.(); + onOpenChange(false); + } catch (caught) { + const message = caught instanceof Error ? caught.message : 'Failed to save schedule'; + setError(message); + } finally { + setSaving(false); + } + }; + + const footer = ( +
+ {error != null ?

{error}

: null} +
+ + +
+
+ ); + + return ( + + + + } + footer={footer} + > +
+ + +
+ ); +} diff --git a/packages/trueforge-ui/src/atoms/schedules/ScheduleFormFields.tsx b/packages/trueforge-ui/src/atoms/schedules/ScheduleFormFields.tsx new file mode 100644 index 000000000..a6a13f6d8 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/schedules/ScheduleFormFields.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { cn } from '../lib/cn.js'; +import { auiInputClass } from '../lib/inputClasses.js'; +import { PopoverSelect } from '../primitives/PopoverSelect.js'; +import { + TIMEZONE_OPTIONS, + WEEKDAY_OPTIONS, + formatCadenceSummary, + valuesToCron, + type RecurrenceKind, + type ScheduleFormValues, +} from './cadence.js'; + +export const RECURRENCE_OPTIONS: Array<{ value: RecurrenceKind; label: string }> = [ + { value: 'hourly', label: 'Hourly' }, + { value: 'daily', label: 'Daily' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'custom', label: 'Custom' }, +]; + +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, hour) => ({ + value: String(hour), + label: hour.toString().padStart(2, '0'), +})); +const MINUTE_OPTIONS = [0, 15, 30, 45].map(minute => ({ + value: String(minute), + label: `:${minute.toString().padStart(2, '0')}`, +})); + +export type ScheduleFormFieldsProps = { + values: ScheduleFormValues; + onChange: (next: ScheduleFormValues) => void; + agentId: string; + onAgentIdChange?: (agentId: string) => void; + agentOptions: Array<{ agentId: string; name: string }>; + agentPickerDisabled?: boolean; +}; + +export function ScheduleFormFields({ + values, + onChange, + agentId, + onAgentIdChange, + agentOptions, + agentPickerDisabled = false, +}: ScheduleFormFieldsProps) { + const cron = valuesToCron(values); + const cadence = formatCadenceSummary({ cron, timezone: values.timezone }); + + const set = (key: K, value: ScheduleFormValues[K]) => { + onChange({ ...values, [key]: value }); + }; + + const toggleWeekday = (day: number) => { + const has = values.weekdays.includes(day); + const weekdays = has ? values.weekdays.filter(d => d !== day) : [...values.weekdays, day]; + onChange({ ...values, weekdays: weekdays.length > 0 ? weekdays : [day] }); + }; + + return ( +
+
+ Agent + ({ value: agent.agentId, label: agent.name }))} + onValueChange={value => onAgentIdChange?.(value)} + disabled={agentPickerDisabled || onAgentIdChange == null} + /> +
+ + + +