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
47 changes: 47 additions & 0 deletions src/main/frontend/app/components/context-editor-footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type ReactNode } from 'react'
import Button from '~/components/inputs/button'

type ContextEditorFooterProperties = {
onSave: () => void
onDelete: () => void
saveDisabled?: boolean
deleteDisabled?: boolean
saveLabel?: string
deleteLabel?: string
errorMessage?: string
leadingActions?: ReactNode
}

export default function ContextEditorFooter({
onSave,
onDelete,
saveDisabled = false,
deleteDisabled = false,
saveLabel = 'Save & Close',
deleteLabel = 'Delete',
errorMessage,
leadingActions,
}: Readonly<ContextEditorFooterProperties>) {
return (
<div className="border-t-border bg-background border-t p-4">
<div className="flex w-full items-center justify-between">
<Button
onClick={onSave}
disabled={saveDisabled}
className="disabled:text-foreground-muted w-auto disabled:cursor-not-allowed disabled:opacity-50"
>
{saveLabel}
</Button>

<div className="flex items-center gap-2">
{leadingActions}
<Button className="w-auto" onClick={onDelete} disabled={deleteDisabled}>
{deleteLabel}
</Button>
</div>
</div>

{errorMessage && <p className="text-error mt-2 text-sm">{errorMessage}</p>}
</div>
)
}
4 changes: 2 additions & 2 deletions src/main/frontend/app/components/inputs/button.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React from 'react'
import clsx from 'clsx'

type ButtonVariant = 'default' | 'ghost' | 'primary' | 'destructive'
type ButtonVariant = 'default' | 'ghost' | 'primary' | 'destructive' | 'unstyled'

export function buttonClasses(variant: ButtonVariant = 'default', disabled?: boolean, className?: string) {
return clsx(
'rounded-md px-4 py-2',
variant !== 'unstyled' && 'rounded-md px-4 py-2',
variant === 'default' && 'text-foreground border-border bg-backdrop border',
variant === 'ghost' && 'text-foreground border border-transparent bg-transparent',
variant === 'primary' && 'bg-brand font-medium text-white',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ type SidebarLayoutProperties = {
name: string
defaultVisible?: VisibilityState
windowResizeOnChange?: boolean
hideLeft?: boolean
}

export default function SidebarLayout({
children,
name,
defaultVisible,
windowResizeOnChange,
hideLeft,
}: Readonly<SidebarLayoutProperties>) {
const initializeInstance = useSidebarStore((state) => state.initializeInstance)
const setSizes = useSidebarStore((state) => state.setSizes)
Expand All @@ -39,8 +41,7 @@ export default function SidebarLayout({
if (!allotmentReady || !allotmentRef.current) return
if (sizes.length === 0) return

const target = sizes.map((size, i) => (visible[i] ? size : 0))

const target = sizes.map((size, index) => (visible[index] ? size : 0))
allotmentRef.current.resize(target)
}, [sizes, visible, allotmentReady])

Expand All @@ -50,7 +51,7 @@ export default function SidebarLayout({

const saveSizes = (newSizes: number[]) => {
const previous = useSidebarStore.getState().getSizes(name) ?? []
const merged = newSizes.map((size, i) => (size === 0 ? (previous[i] ?? 0) : size))
const merged = newSizes.map((size, index) => (size === 0 ? (previous[index] ?? 0) : size))
setSizes(name, merged)
if (windowResizeOnChange) {
globalThis.dispatchEvent(new Event('resize'))
Expand All @@ -76,7 +77,7 @@ export default function SidebarLayout({
minSize={200}
maxSize={500}
preferredSize={300}
visible={visible[SidebarSide.LEFT]}
visible={!hideLeft && visible[SidebarSide.LEFT]}
className="bg-background flex h-full flex-col"
>
{childrenArray[SidebarSide.LEFT]}
Expand Down
93 changes: 93 additions & 0 deletions src/main/frontend/app/routes/configurations/adapter-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react'
import Input from '~/components/inputs/input'
import ContextEditorFooter from '~/components/context-editor-footer'
import { deleteAdapter, renameAdapter } from '~/services/adapter-service'

export type AdapterEditorState = {
configPath: string
adapterName: string
adapterPosition: number
}

type AdapterContextProperties = {
projectName: string
editor: AdapterEditorState
onSaved: () => void
onDeleted: () => void
onNameChange?: (name: string) => void
}

export default function AdapterContext({
projectName,
editor,
onSaved,
onDeleted,
onNameChange,
}: Readonly<AdapterContextProperties>) {
const [name, setName] = useState(editor.adapterName)
const [isSaving, setIsSaving] = useState(false)
const [errorMessage, setErrorMessage] = useState('')

useEffect(() => {
onNameChange?.(name)
}, [name, onNameChange])

const trimmedName = name.trim()
const canSave = trimmedName !== '' && trimmedName !== editor.adapterName && !isSaving

const handleSave = async () => {
if (!canSave) return
setIsSaving(true)
setErrorMessage('')
try {
await renameAdapter(projectName, editor.adapterName, trimmedName, editor.configPath)
onSaved()
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : `Failed to rename ${editor.adapterName}`)
setIsSaving(false)
}
}

const handleDelete = async () => {
setIsSaving(true)
setErrorMessage('')
try {
await deleteAdapter(projectName, editor.adapterName, editor.configPath)
onDeleted()
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : `Failed to delete ${editor.adapterName}`)
setIsSaving(false)
}
}

return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 overflow-y-auto px-4">
<div className="text-foreground-muted mt-2 text-xs font-semibold tracking-wider uppercase">{name}</div>

<div className="bg-background w-full space-y-4 rounded-md p-6">
<div className="space-y-1">
<label htmlFor="adapter-name" className="text-foreground text-sm">
name
</label>
<Input
id="adapter-name"
value={name}
disabled={isSaving}
onChange={(event) => setName(event.target.value)}
/>
</div>
</div>
</div>

<ContextEditorFooter
onSave={handleSave}
saveDisabled={!canSave}
saveLabel={isSaving ? 'Saving...' : 'Save & Close'}
onDelete={handleDelete}
deleteDisabled={isSaving}
errorMessage={errorMessage}
/>
</div>
)
}
32 changes: 32 additions & 0 deletions src/main/frontend/app/routes/configurations/adapter-list-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import RulerCrossPenIcon from '/icons/solar/Ruler Cross Pen.svg?react'
import IconLabelButton from '~/components/inputs/icon-label-button'
import ComponentRow from './component-row'

type AdapterListItemProperties = {
adapterName: string
adapterPosition: number
onConfigure: () => void
onOpenInStudio: (adapterName: string, adapterPosition: number) => void
}

export default function AdapterListItem({
adapterName,
adapterPosition,
onConfigure,
onOpenInStudio,
}: Readonly<AdapterListItemProperties>) {
return (
<ComponentRow
typeLabel="Adapter"
primaryLabel={adapterName}
onConfigure={onConfigure}
action={
<IconLabelButton
icon={<RulerCrossPenIcon className="h-4 w-4 fill-current" />}
label="Open in Studio"
onClick={() => onOpenInStudio(adapterName, adapterPosition)}
/>
}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React, { useMemo, useState, type ChangeEvent } from 'react'
import { createPortal } from 'react-dom'
import { useFFDoc } from '@frankframework/doc-library-react'
import Button from '~/components/inputs/button'
import Search from '~/components/search/search'
import { useFrankConfigXsd } from '~/providers/frankconfig-xsd-provider'
import { getAddableNonCanvasComponentNames } from '~/services/non-canvas-component-service'

type AddNonCanvasComponentMenuProperties = {
isOpen: boolean
onClose: () => void
onSelect: (tagName: string) => void
}

export default function AddNonCanvasComponentMenu({
isOpen,
onClose,
onSelect,
}: Readonly<AddNonCanvasComponentMenuProperties>) {
const { elements } = useFFDoc()
const { xsdContent } = useFrankConfigXsd()
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<string | null>(null)

const addableNames = useMemo(() => getAddableNonCanvasComponentNames(xsdContent, elements), [xsdContent, elements])

const filteredNames = useMemo(
() => addableNames.filter((name) => name.toLowerCase().includes(search.toLowerCase())),
[addableNames, search],
)

if (!isOpen) return null

const clearAndClose = () => {
setSearch('')
setSelected(null)
onClose()
}

const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) clearAndClose()
}

const handleSearchChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setSearch(value)
const filtered = addableNames.find((name) => name.toLowerCase().includes(value.toLowerCase()))
setSelected(filtered ?? null)
}

const handleConfirm = (name?: string) => {
const tagName = name ?? selected
if (!tagName) return
onSelect(tagName)
clearAndClose()
}

return createPortal(
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40" onClick={handleBackdropClick}>
<div className="bg-background border-border relative flex h-1/2 w-1/3 min-w-[400px] flex-col rounded-lg border p-6 shadow-lg">
<Button
onClick={clearAndClose}
className="text-foreground-muted hover:text-foreground absolute top-3 right-3 cursor-pointer text-lg leading-none"
>
&times;
</Button>

<h2 className="mb-4 text-lg font-bold">Add non-canvas component</h2>

<Search placeholder="Search components..." value={search} onChange={handleSearchChange} />
<div className="border-border bg-background my-3 w-full flex-1 overflow-hidden rounded border">
<ul className="h-full overflow-y-auto">
{filteredNames.length > 0 ? (
filteredNames.map((name) => {
const isSelected = selected === name
return (
<li
key={name}
onClick={() => setSelected(name)}
onDoubleClick={() => handleConfirm(name)}
className={`cursor-pointer px-3 py-2 ${
isSelected ? 'bg-foreground-active text-background' : 'hover:bg-hover'
}`}
>
{name}
</li>
)
})
) : (
<li className="text-foreground-muted px-3 py-2">
{addableNames.length === 0 ? 'No addable components found.' : 'No results found.'}
</li>
)}
</ul>
</div>

<Button onClick={() => handleConfirm()} disabled={!selected}>
Add component
</Button>
</div>
</div>,
document.body,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { type NonCanvasComponent } from '~/services/non-canvas-component-service'
import ComponentRow from './component-row'

type ComponentListItemProperties = {
component: NonCanvasComponent
onConfigure: () => void
}

function getComponentLabels(component: NonCanvasComponent): { typeLabel: string | null; primaryLabel: string } {
const primary = component.tagName === 'Include' ? component.attributes.ref : component.name
if (primary && primary.trim()) {
return { typeLabel: component.tagName, primaryLabel: primary }
}
return { typeLabel: null, primaryLabel: component.tagName }
}

export default function ComponentListItem({ component, onConfigure }: Readonly<ComponentListItemProperties>) {
const { typeLabel, primaryLabel } = getComponentLabels(component)
return <ComponentRow typeLabel={typeLabel} primaryLabel={primaryLabel} onConfigure={onConfigure} />
}
36 changes: 36 additions & 0 deletions src/main/frontend/app/routes/configurations/component-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { type ReactNode } from 'react'
import Button from '~/components/inputs/button'

type ComponentRowProperties = {
typeLabel: string | null
primaryLabel: string
onConfigure: () => void
action?: ReactNode
}

export default function ComponentRow({
typeLabel,
primaryLabel,
onConfigure,
action,
}: Readonly<ComponentRowProperties>) {
return (
<li className="border-border bg-background hover:bg-hover flex items-stretch justify-between gap-2 rounded border shadow-md transition-colors">
<Button
variant="unstyled"
type="button"
onClick={onConfigure}
className="flex min-w-0 flex-1 cursor-pointer flex-col justify-center gap-0.5 px-4 py-3 text-left"
title={`Configure ${primaryLabel}`}
>
{typeLabel && (
<span className="text-foreground-muted truncate text-xs font-bold tracking-wider uppercase">{typeLabel}</span>
)}
<span className="text-foreground truncate text-base" title={primaryLabel}>
{primaryLabel}
</span>
</Button>
{action && <div className="flex shrink-0 items-center pr-2">{action}</div>}
</li>
)
}
Loading
Loading