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
2 changes: 1 addition & 1 deletion console/src/components/project-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function ProjectSwitcher({
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger className="w-full">
<DropdownMenuTrigger asChild className="w-full">
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground cursor-pointer"
Expand Down
71 changes: 67 additions & 4 deletions console/src/components/schema-fields.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect, useMemo } from "react"
import type { UseFormReturn } from "react-hook-form"
import { snakeToTitle } from "@/utils"

import { Input } from "@/components/ui/input"
Expand Down Expand Up @@ -84,6 +83,8 @@ export interface SchemaFieldsProps {
onChange: (v: Record<string, unknown>) => void
/** Optional variable groups for TemplateInput / CodeEditor pickers. */
variables?: VariableGroup[]
/** Optional field-level error messages keyed by property name. */
errors?: Record<string, string | undefined>
}

export function SchemaFields({
Expand All @@ -93,6 +94,7 @@ export function SchemaFields({
value,
onChange,
variables,
errors,
}: SchemaFieldsProps) {
const entries = normalizeProperties(schema?.properties)

Expand All @@ -113,6 +115,21 @@ export function SchemaFields({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entryKeys])

// Initialize missing boolean fields to false.
useEffect(() => {
if (!entries.length) return
let changed = false
const next = { ...value }
for (const [key, item] of entries) {
if (item.type === "boolean" && next[key] === undefined) {
next[key] = false
changed = true
}
}
if (changed) onChange(next)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entryKeys])

if (!entries.length) return null

const sorted = [...entries].sort((a, b) => (a[1].order ?? 0) - (b[1].order ?? 0))
Expand Down Expand Up @@ -151,9 +168,12 @@ export function SchemaFields({
}
}}
/>
{value[key] && (
{Boolean(value[key]) && (
<p className="text-xs text-muted-foreground">File configured</p>
)}
{errors?.[key] && (
<p className="text-xs text-destructive">{errors[key]}</p>
)}
</div>
)
}
Expand All @@ -176,6 +196,9 @@ export function SchemaFields({
maxHeight={300}
variables={variables}
/>
{errors?.[key] && (
<p className="text-xs text-destructive">{errors[key]}</p>
)}
</div>
)
}
Expand All @@ -196,6 +219,9 @@ export function SchemaFields({
onChange={(val) => set(key, val)}
variables={variables}
/>
{errors?.[key] && (
<p className="text-xs text-destructive">{errors[key]}</p>
)}
</div>
)
}
Expand Down Expand Up @@ -226,6 +252,9 @@ export function SchemaFields({
))}
</SelectContent>
</Select>
{errors?.[key] && (
<p className="text-xs text-destructive">{errors[key]}</p>
)}
</div>
)
}
Expand Down Expand Up @@ -277,6 +306,9 @@ export function SchemaFields({
placeholder={item.preview}
/>
)}
{errors?.[key] && (
<p className="text-xs text-destructive">{errors[key]}</p>
)}
</div>
)
}
Expand All @@ -300,6 +332,9 @@ export function SchemaFields({
checked={(value[key] as boolean) ?? false}
onCheckedChange={(checked) => set(key, checked)}
/>
{errors?.[key] && (
<p className="text-xs text-destructive">{errors[key]}</p>
)}
</div>
)
}
Expand All @@ -317,7 +352,11 @@ export function SchemaFields({
value={(value[key] as Record<string, unknown>) ?? {}}
onChange={(nested) => set(key, nested)}
variables={variables}
errors={errors}
/>
{errors?.[key] && (
<p className="text-xs text-destructive">{errors[key]}</p>
)}
</div>
)
}
Expand All @@ -328,6 +367,13 @@ export function SchemaFields({
)
}

interface FormAdapter {
watch(name: string): Record<string, unknown> | undefined
setValue(name: string, value: unknown, options?: { shouldDirty?: boolean }): void
clearErrors(name?: string | string[] | readonly string[]): void
formState: { errors: Record<string, unknown> }
}

export interface FormSchemaFieldsProps {
/** Optional heading rendered above the fields. */
title?: string
Expand All @@ -338,8 +384,7 @@ export interface FormSchemaFieldsProps {
/** The JSON schema describing the fields. */
schema: Schema
/** The react-hook-form instance. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: UseFormReturn<any>
form: FormAdapter
}

/**
Expand All @@ -356,26 +401,44 @@ export function FormSchemaFields({
schema,
}: FormSchemaFieldsProps) {
const watched = form.watch(parent) ?? {}
const formErrors = form.formState.errors

// Derive valid keys from the current schema so we can clean up stale ones
const schemaKeys = useMemo(() => {
const entries = normalizeProperties(schema?.properties)
return new Set(entries.map(([k]) => k))
}, [schema])

// Extract field-level errors from react-hook-form for the parent path
const errors = useMemo(() => {
const parentErrors = formErrors[parent]
if (!parentErrors || typeof parentErrors !== "object") return undefined
const result: Record<string, string | undefined> = {}
for (const [key, err] of Object.entries(
parentErrors as Record<string, { message?: string }>,
)) {
if (err?.message) {
result[key] = err.message
}
}
return Object.keys(result).length > 0 ? result : undefined
}, [formErrors, parent])
Comment on lines +412 to +425

return (
<SchemaFields
title={title}
description={description}
schema={schema}
value={watched}
errors={errors}
onChange={(next) => {
// Diff and set only the keys that changed so we don't
// unnecessarily mark untouched fields as dirty.
for (const key of Object.keys(next)) {
const fieldName = `${parent}.${key}`
if (next[key] !== watched[key]) {
form.setValue(fieldName, next[key], { shouldDirty: true })
form.clearErrors(fieldName)
}
}
// Remove stale keys that no longer exist in the current schema
Expand Down
28 changes: 16 additions & 12 deletions console/src/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@ import * as React from "react"

import { cn } from "@/utils"

function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
)
}
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
)
},
)
Textarea.displayName = "Textarea"

export { Textarea }
54 changes: 35 additions & 19 deletions console/src/views/settings/IntegrationSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ type IntegrationFormValues = {
data?: Record<string, unknown>
config?: Record<string, unknown>
link_wrap?: boolean
rate_limit?: number | null
rate_interval?: string | null
rate_limit?: {
limit: number
interval: string
}
}

/** Map human-friendly interval labels to Go duration strings */
Expand Down Expand Up @@ -270,24 +272,48 @@ export default function IntegrationSetup() {
data: provider.data,
module: effectiveModule ?? "",
link_wrap: provider?.link_wrap ?? false,
rate_limit: provider?.rate_limit ?? null,
rate_interval: provider?.rate_interval ?? "1s",
rate_limit: provider?.rate_limit,
}
: {
kind: "provider",
name: "",
data: {},
module: effectiveModule ?? "",
link_wrap: true,
rate_limit: null,
rate_interval: "1s",
rate_limit: {
limit: manifestRateLimit?.limit ?? 0,
interval: manifestRateLimit?.interval ?? "1s",
},
},
})

const handleSubmit = async (values: IntegrationFormValues) => {
if (isExternal) return
if (!effectiveModule) return

// Validate required schema fields before submitting
if (dataSchema?.required && dataSchema.required.length > 0) {
const prefix = kind === "action" ? "config" : "data"
form.clearErrors(prefix)
let hasErrors = false
for (const key of dataSchema.required) {
const value = values[prefix]?.[key]
if (
value === undefined ||
value === null ||
value === "" ||
(typeof value === "string" && !value.trim())
) {
form.setError(`${prefix}.${key}`, {
type: "required",
message: t("field_required", "This field is required"),
})
hasErrors = true
}
}
if (hasErrors) return
}

setIsSaving(true)
try {
if (kind === "action") {
Expand Down Expand Up @@ -353,17 +379,7 @@ export default function IntegrationSetup() {
}

if (rateLimitOverride) {
const hasCustomRateLimit =
typeof values.rate_limit === "number" &&
Number.isFinite(values.rate_limit) &&
values.rate_limit > 0

if (hasCustomRateLimit || isEdit) {
body.rate_limit = {
limit: hasCustomRateLimit ? values.rate_limit! : 0,
interval: hasCustomRateLimit ? (values.rate_interval ?? "1s") : "1s",
}
}
body.rate_limit = values.rate_limit
}

if (isEdit && provider?.id) {
Expand Down Expand Up @@ -655,15 +671,15 @@ export default function IntegrationSetup() {
manifestRateLimit.limit,
)}
className="w-28"
{...form.register("rate_limit", {
{...form.register("rate_limit.limit", {
valueAsNumber: true,
min: 0,
max: maxRateLimit ?? undefined,
})}
/>
<Controller
control={form.control}
name="rate_interval"
name="rate_limit.interval"
render={({ field }) => (
<Select
value={field.value ?? "1s"}
Expand Down
4 changes: 3 additions & 1 deletion console/src/views/settings/Integrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export default function Integrations() {

const [result, , reload] = useResolver(
useCallback(async () => {
const query = debouncedQuery ? { search: debouncedQuery } : {}

const [{ data: providers }, { data: actions }] = await Promise.all([
oapiClient.GET("/api/admin/projects/{projectID}/providers", {
params: {
Expand All @@ -83,7 +85,7 @@ export default function Integrations() {
oapiClient.GET("/api/admin/projects/{projectID}/actions", {
params: {
path: { projectID: project.id },
query: { limit: 50, offset: 0 },
query: { limit: 50, offset: 0, ...query },
},
}),
])
Expand Down
4 changes: 2 additions & 2 deletions console/src/views/users/ListCreateForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ export function ListCreateForm({ onCreated }: ListCreateFormProps) {
<Label className="text-sm font-medium">{t("type")}</Label>
<Tabs value={type} onValueChange={(v) => setType(v as "dynamic" | "static")}>
<TabsList className="w-full">
<TabsTrigger value="dynamic" className="flex-1">
<TabsTrigger value="dynamic" className="flex-1 cursor-pointer">
{t("dynamic")}
</TabsTrigger>
<TabsTrigger value="static" className="flex-1">
<TabsTrigger value="static" className="flex-1 cursor-pointer">
{t("static")}
</TabsTrigger>
</TabsList>
Expand Down
16 changes: 12 additions & 4 deletions internal/http/controllers/v1/management/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,12 @@ func (srv *ProvidersController) CreateProvider(w http.ResponseWriter, r *http.Re
}

if body.RateLimit != nil {
provider.RateLimit = body.RateLimit.Limit
provider.RateInterval = body.RateLimit.Interval
if body.RateLimit.Limit > 0 {
provider.RateLimit = body.RateLimit.Limit
}
if body.RateLimit.Interval != "" {
provider.RateInterval = body.RateLimit.Interval
}
}

providerID, err := srv.store.ProvidersStore.CreateProvider(ctx, provider)
Expand Down Expand Up @@ -390,8 +394,12 @@ func (srv *ProvidersController) UpdateProvider(w http.ResponseWriter, r *http.Re
}

if body.RateLimit != nil {
update.RateLimit = &body.RateLimit.Limit
update.RateInterval = &body.RateLimit.Interval
if body.RateLimit.Limit > 0 {
update.RateLimit = &body.RateLimit.Limit
}
Comment on lines +397 to +399
if body.RateLimit.Interval != "" {
update.RateInterval = &body.RateLimit.Interval
}
Comment on lines +397 to +402
}

err = srv.store.ProvidersStore.UpdateProvider(ctx, projectID, providerID, update)
Expand Down
Loading