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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/web/src/components/common/ClearFieldButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { XIcon } from "lucide-react"
import { cn } from "@lib/utils"
import _ from "@lib/translate"

/** Clear (×) for pickers — a SIBLING of the trigger (button-in-button is invalid markup),
absolutely positioned where the chevron sits; pair with pr-7 on the trigger. */
const ClearFieldButton = ({ onClick, ariaLabel, className }: {
onClick: () => void
ariaLabel?: string
className?: string
}) => (
<button
type="button"
onClick={onClick}
aria-label={ariaLabel ?? _("Clear")}
className={cn("absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center rounded text-ink-gray-5 hover:bg-surface-gray-4 hover:text-ink-gray-8", className)}
>
<XIcon className="size-3.5" />
</button>
)

export default ClearFieldButton
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover"
import { DrawerClose, DrawerContent, DrawerDescription, DrawerNested, DrawerTitle, DrawerTrigger } from "@components/ui/drawer";
import { FormControl } from "@components/ui/form"
import { ChevronDownIcon, ExternalLink } from "lucide-react";
import ClearFieldButton from "@components/common/ClearFieldButton";
import { Button } from "@components/ui/button";
import { cn } from "@lib/utils";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
Expand Down Expand Up @@ -140,6 +141,10 @@ export interface LinkFieldComboboxProps {
useInForm?: boolean,
/** Button Class name */
buttonClassName?: string
/** Extra classes for the dropdown popover (e.g. a max-w cap). */
dropdownClassName?: string;
/** Show a clear (×) button in place of the chevron while a value is picked. */
clearable?: boolean;
}
const LinkFieldCombobox = ({
doctype,
Expand All @@ -151,13 +156,15 @@ const LinkFieldCombobox = ({
disabled,
filterFn,
suggestedItems,
placeholder = `Select ${doctype}`,
placeholder = _("Select {0}", [doctype]),
customQuery,
searchfield,
searchAPIPath = "frappe.desk.search.search_link",
limit,
useInForm,
buttonClassName
buttonClassName,
dropdownClassName,
clearable
}: LinkFieldComboboxProps) => {

const pageLimit = useMemo(() => limit || getSystemDefault('link_field_results_limit') || 20, [limit])
Expand Down Expand Up @@ -282,6 +289,9 @@ const LinkFieldCombobox = ({
</FilterComboboxItem>
)

const showClear = Boolean(clearable && value && !disabled && !readOnly)
const clearButton = showClear ? <ClearFieldButton onClick={() => onChange("")} /> : null

// The trigger is identical for both shells (popover on desktop, drawer on
// mobile), so it's built once and handed to whichever *Trigger wraps it.
const trigger = useInForm ? (
Expand All @@ -293,13 +303,10 @@ const LinkFieldCombobox = ({
tabIndex={0}
disabled={disabled}
aria-expanded={open}
// FILTER_TRIGGER_STYLES sizes the trigger to fit its row (w-fit) — this
// one is a form field instead, so w-full overrides just the width half
// of that pairing. `group` scopes the hover-revealed external link
// below. subtle's own bg is already gray-2, so read-only dims the label
// rather than swapping in a background that would look identical to the
// normal state.
className={cn(FILTER_TRIGGER_STYLES, "group w-full", readOnly && "text-ink-gray-5", buttonClassName)}>
// w-full overrides FILTER_TRIGGER_STYLES' w-fit — this one is a form field.
// read-only dims the label; subtle's bg is already gray-2.
// disabled keeps pointer-events so cursor-not-allowed shows; pr-7 = room for the clear ×.
className={cn(FILTER_TRIGGER_STYLES, "group w-full disabled:pointer-events-auto", showClear && "pr-7", readOnly && "text-ink-gray-5", buttonClassName)}>
<span className={cn("min-w-0 flex-1 truncate text-left", !linkTitle && "text-ink-gray-4")}>
{linkTitle || placeholder}
</span>
Expand All @@ -313,7 +320,7 @@ const LinkFieldCombobox = ({
<ExternalLink className="size-4 shrink-0 text-ink-gray-5" />
</a>
)}
<ChevronDownIcon className="size-4 shrink-0 text-ink-gray-4" />
{!showClear && <ChevronDownIcon className="size-4 shrink-0 text-ink-gray-4" />}
</div>
</Button>
</FormControl>
Expand All @@ -324,12 +331,12 @@ const LinkFieldCombobox = ({
role="combobox"
disabled={disabled}
aria-expanded={open}
className={cn(FILTER_TRIGGER_STYLES, "w-full", readOnly && "text-ink-gray-5", buttonClassName)}>
className={cn(FILTER_TRIGGER_STYLES, "w-full disabled:pointer-events-auto", showClear && "pr-7", readOnly && "text-ink-gray-5", buttonClassName)}>
<span className={cn("min-w-0 flex-1 truncate text-left", !value && "text-ink-gray-4")}>
{value || placeholder}
</span>

<ChevronDownIcon className="size-4 shrink-0 text-ink-gray-4" />
{!showClear && <ChevronDownIcon className="size-4 shrink-0 text-ink-gray-4" />}
</Button>
)

Expand All @@ -346,7 +353,7 @@ const LinkFieldCombobox = ({
text-base keeps the taller box on mobile for touch. See FilterCombobox. */}
<CommandInput
variant="plain"
placeholder={placeholder}
placeholder={_("Search")}
onValueChange={setSearchInput}
className="text-base"
/>
Expand Down Expand Up @@ -400,7 +407,10 @@ const LinkFieldCombobox = ({
{/* No disabled/readOnly handling here — the trigger button carries
`disabled` itself, and readOnly is enforced in onOpenChange (same
as the popover), keeping the read-only look distinct from disabled. */}
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<div className="relative w-full">
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
{clearButton}
</div>
<DrawerContent
className="h-[85dvh]"
// Radix focuses the search field on open — the keyboard would cover
Expand All @@ -424,9 +434,12 @@ const LinkFieldCombobox = ({

return (
<Popover open={open} onOpenChange={onOpenChange} modal={true}>
<PopoverTrigger asChild>
{trigger}
</PopoverTrigger>
<div className="relative w-full">
<PopoverTrigger asChild>
{trigger}
</PopoverTrigger>
{clearButton}
</div>
<PopoverContent
side="bottom"
align="start"
Expand All @@ -448,6 +461,7 @@ const LinkFieldCombobox = ({
className={cn(
"flex min-w-(--radix-popover-trigger-width) flex-col p-0 shadow-2xl",
"max-h-[min(18rem,var(--radix-popover-content-available-height))]",
dropdownClassName,
)}
>
{/* max-h-none hands height control to the popover's cap above — cmdk's own
Expand Down
157 changes: 157 additions & 0 deletions apps/web/src/components/common/UploadDocDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { useState, type ReactNode } from "react"
import { useFrappeCreateDoc, useFrappeFileUpload } from "frappe-react-sdk"
import { useForm, type DefaultValues, type FieldValues, type UseFormProps, type UseFormReturn } from "react-hook-form"
import { toast } from "sonner"
import { Button } from "@components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@components/ui/dialog"
import ErrorBanner from "@components/ui/error-banner"
import { FileDropzone } from "@components/ui/file-dropzone"
import { Form } from "@components/ui/form"
import { Spinner } from "@components/ui/spinner"
import _ from "@lib/translate"

type UploadDocDialogProps<T extends FieldValues> = {
doctype: string
/** Upload fieldname; the created doc gets the file URL under this key. */
fileField: string
/** Defaults to private — an upload with no stated intent should not be world-readable. */
isPrivate?: boolean
accept?: Record<string, string[]>
/** Oversized picks are rejected with a toast. */
maxBytes?: number
title: string
description: string
submitLabel?: string
submitBusyLabel?: string
defaults: DefaultValues<T>
/** react-hook-form validation mode. */
mode?: UseFormProps<T>["mode"]
/** Form fields, rendered inside the dialog's FormProvider. */
children: ReactNode
/** Helper text under the dropzone. */
hint?: ReactNode
/** Seed form fields from the picked file (e.g. a name); cleared fields are the caller's job on `null`. */
onFilePicked?: (file: File | null, form: UseFormReturn<T>) => void
/** Pre-upload gate (e.g. duplicate check). Return false to abort — set form errors yourself. */
beforeUpload?: (data: T, form: UseFormReturn<T>) => Promise<boolean>
/** Docname the file is uploaded against. Defaults to a timestamped placeholder. */
docname?: (data: T) => string
onCreated: (doc: T & { name: string }) => void | Promise<void>
/** Uncontrolled shell: custom trigger (defaults to a primary button with the title). */
trigger?: ReactNode
/** Controlled shell: pass both to own the open state; `trigger` is ignored. */
open?: boolean
onOpenChange?: (open: boolean) => void
}

/** Dialog that uploads one file and creates a doc pointing at it: dropzone + caller fields + Cancel/submit. */
const UploadDocDialog = <T extends FieldValues>({
doctype, fileField, isPrivate = true, accept, maxBytes, title, description,
submitLabel, submitBusyLabel, defaults, mode, children, hint,
onFilePicked, beforeUpload, docname, onCreated, trigger, open, onOpenChange,
}: UploadDocDialogProps<T>) => {
const controlled = open !== undefined
const [internalOpen, setInternalOpen] = useState(false)
const isOpen = controlled ? open : internalOpen

const form = useForm<T>({ defaultValues: defaults, mode })
const [files, setFiles] = useState<File[]>([])
const { upload, loading: uploading, error: uploadError, reset: resetUpload } = useFrappeFileUpload()
const { createDoc, loading: creating, error: createError, reset: resetCreate } = useFrappeCreateDoc<T>()
const busy = uploading || creating

const resetState = () => {
form.reset()
setFiles([])
resetUpload()
resetCreate()
}

const setOpen = (next: boolean) => {
if (!next) resetState()
if (!controlled) setInternalOpen(next)
onOpenChange?.(next)
}

const handleSetFiles: React.Dispatch<React.SetStateAction<File[]>> = (action) => {
const next = typeof action === "function" ? action(files) : action
const file = next[0]
if (file && maxBytes && file.size > maxBytes) {
toast.error(_("File size should not exceed {0}MB", [String(Math.round(maxBytes / (1024 * 1024)))]))
setFiles([])
onFilePicked?.(null, form)
return
}
setFiles(next)
onFilePicked?.(file ?? null, form)
}

const onSubmit = async (data: T) => {
const file = files[0]
if (!file) return
try {
if (beforeUpload && !(await beforeUpload(data, form))) return
const res = await upload(file, {
doctype,
docname: docname ? docname(data) : `new-${doctype.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}`,
fieldname: fileField,
isPrivate,
})
const doc = await createDoc(doctype, { ...data, [fileField]: res.file_url })
await onCreated(doc)
// Controlled owners close via onCreated; avoid a second onOpenChange(false).
resetState()
if (!controlled) setInternalOpen(false)
} catch {
// Surfaced by the upload/create error banners.
}
}

return (
<Dialog open={isOpen} onOpenChange={setOpen}>
{!controlled && (
<DialogTrigger asChild>
{trigger ?? <Button type="button" size="sm">{title}</Button>}
</DialogTrigger>
)}
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
{uploadError && <ErrorBanner error={uploadError} />}
{createError && <ErrorBanner error={createError} />}
<FileDropzone files={files} setFiles={handleSetFiles} multiple={false} accept={accept} />
{(hint || accept || maxBytes) ? <div className="-mt-2 flex flex-col gap-0.5 text-p-sm text-ink-gray-5">
{hint && <p>{hint}</p>}
{accept && <p>{_("Supported formats: {0}", [Object.values(accept).flat().join(", ")])}</p>}
{maxBytes && <p>{_("Maximum file size: {0}MB", [String(Math.round(maxBytes / (1024 * 1024)))])}</p>}
</div> : null}
{children}
<DialogFooter className="flex-row justify-end gap-2 pt-4">
<Button type="button" variant="outline" size="md" onClick={() => setOpen(false)} disabled={busy}>
{_("Cancel")}
</Button>
<Button type="submit" size="md" disabled={busy || files.length === 0}>
{busy && <Spinner />}
{busy ? (submitBusyLabel ?? _("Saving...")) : (submitLabel ?? _("Upload"))}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

export default UploadDocDialog
19 changes: 3 additions & 16 deletions apps/web/src/components/common/filters/FilterCombobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { useState, type ReactNode } from "react"
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover"
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@components/ui/command"
import { Button } from "@components/ui/button"
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"
import { CheckIcon, ChevronDownIcon } from "lucide-react"
import ClearFieldButton from "@components/common/ClearFieldButton"
import { cn } from "@lib/utils"
import { useIsMobile } from "@hooks/use-mobile"
import _ from "@lib/translate"
Expand Down Expand Up @@ -194,21 +195,7 @@ export function FilterCombobox({
{!onClear && <ChevronDownIcon className="size-4 shrink-0 text-ink-gray-4" />}
</Button>
</PopoverTrigger>
{/* A sibling of the trigger rather than a child of it: a button inside a
button is invalid markup, and a span with a click handler would leave
keyboard users no way to clear now that the list has no Clear row.
Absolutely positioned so it takes the chevron's place without the trigger
having to give up any width. */}
{onClear && (
<button
type="button"
onClick={onClear}
aria-label={_("Clear filter")}
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center rounded text-ink-gray-5 hover:bg-surface-gray-4 hover:text-ink-gray-8"
>
<XIcon className="size-3.5" />
</button>
)}
{onClear && <ClearFieldButton onClick={onClear} ariaLabel={_("Clear filter")} />}
<PopoverContent
side="bottom"
align="start"
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/components/features/settings/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ const DocumentPreviewsPanel = lazy(() => import('./panels/DocumentPreviews').the
const MessageActionsPanel = lazy(() => import('./panels/MessageActions').then((m) => ({ default: m.MessageActions })));
const ScheduledMessagesPanel = lazy(() => import('./panels/ScheduledMessages').then((m) => ({ default: m.ScheduledMessages })));
const WebhooksPanel = lazy(() => import('./panels/Webhooks').then((m) => ({ default: m.Webhooks })));
const AgentsPanel = lazy(() => import('./panels/Agents').then((m) => ({ default: m.Agents })));
const FunctionsPanel = lazy(() => import('./panels/Functions').then((m) => ({ default: m.Functions })));
const FileSourcesPanel = lazy(() => import('./panels/FileSources').then((m) => ({ default: m.FileSources })));
const InstructionsPanel = lazy(() => import('./panels/Instructions').then((m) => ({ default: m.Instructions })));
const DocumentProcessorsPanel = lazy(() => import('./panels/DocumentProcessors').then((m) => ({ default: m.DocumentProcessors })));
const CommandsPanel = lazy(() => import('./panels/Commands').then((m) => ({ default: m.Commands })));
const AgentsPanel = lazy(() => import('./panels/Agents/Agents').then((m) => ({ default: m.Agents })));
const FunctionsPanel = lazy(() => import('./panels/Functions/Functions').then((m) => ({ default: m.Functions })));
const FileSourcesPanel = lazy(() => import('./panels/FileSources/FileSources').then((m) => ({ default: m.FileSources })));
const InstructionsPanel = lazy(() => import('./panels/Instructions/Instructions').then((m) => ({ default: m.Instructions })));
const DocumentProcessorsPanel = lazy(() => import('./panels/DocumentProcessors/DocumentProcessors').then((m) => ({ default: m.DocumentProcessors })));
const CommandsPanel = lazy(() => import('./panels/Commands/Commands').then((m) => ({ default: m.Commands })));
const KeyboardShortcutsPanel = lazy(() => import('./panels/KeyboardShortcuts').then((m) => ({ default: m.KeyboardShortcuts })));

const SETTINGS_TAB_GROUPS: { id: string, label: string }[] = [
Expand Down
Loading
Loading