diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/WebSessionProjectImpl.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/WebSessionProjectImpl.java index 5e89a602413..ae8ed9d3c90 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/WebSessionProjectImpl.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/WebSessionProjectImpl.java @@ -357,6 +357,13 @@ public DataSourceDescriptor getDataSourceContainerFromInput(@NotNull WebConnecti return getInputConfigHandler(configInput).createDataSourceContainer(); } + public void updateDataSourceContainerFromInput( + @NotNull WebConnectionConfig configInput, + @NotNull DataSourceDescriptor dataSource + ) throws DBWebException { + getInputConfigHandler(configInput).updateDataSource(dataSource); + } + @NotNull public WebConnectionConfig getConnectionConfigInput(@Nullable Map configMap) { return new WebConnectionConfig(configMap == null ? Map.of() : configMap); diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/impl/WebServiceCore.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/impl/WebServiceCore.java index b92adc97347..be9090ef304 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/impl/WebServiceCore.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/impl/WebServiceCore.java @@ -601,11 +601,7 @@ private DataSourceDescriptor getDataSourceDescriptor( } testDataSource = (DataSourceDescriptor) dataSource.createCopy(dataSource.getRegistry()); - WebDataSourceUtils.setConnectionConfiguration( - testDataSource.getDriver(), - testDataSource.getConnectionConfiguration(), - configInput - ); + project.updateDataSourceContainerFromInput(configInput, testDataSource); if (configInput.getSelectedSecretId() != null) { try { dataSource.listSharedCredentials() diff --git a/webapp/common-react/@dbeaver/ui-kit/src/Select/Select.css b/webapp/common-react/@dbeaver/ui-kit/src/Select/Select.css index 3bed93e9941..30f191505e8 100644 --- a/webapp/common-react/@dbeaver/ui-kit/src/Select/Select.css +++ b/webapp/common-react/@dbeaver/ui-kit/src/Select/Select.css @@ -56,7 +56,9 @@ z-index: 1000; min-width: 100%; max-height: var(--dbv-kit-select-popover-height); - overflow-y: auto; + overflow-y: hidden; + display: flex; + flex-direction: column; background-color: var(--dbv-kit-select-popover-background); color: var(--dbv-kit-select-popover-foreground); border-color: var(--dbv-kit-select-border-color); @@ -83,8 +85,29 @@ } } + .dbv-kit-select__popover-items { + overflow-y: auto; + flex: 1; + min-height: 0; + } + + .dbv-kit-select__popover-header { + border-bottom: 1px solid var(--dbv-kit-select-border-color); + flex-shrink: 0; + } + + .dbv-kit-select__popover-footer { + border-top: 1px solid var(--dbv-kit-select-border-color); + flex-shrink: 0; + } + + .dbv-kit-select__group { + border-top: 1px solid var(--dbv-kit-select-border-color); + } + .dbv-kit-select__item { - display: block; + display: flex; + align-items: center; width: 100%; font-weight: var(--dbv-kit-select-font-weight); font-size: var(--dbv-kit-select-font-size); diff --git a/webapp/common-react/@dbeaver/ui-kit/src/Select/Select.tsx b/webapp/common-react/@dbeaver/ui-kit/src/Select/Select.tsx index 4e29cdc682d..79872ecc802 100644 --- a/webapp/common-react/@dbeaver/ui-kit/src/Select/Select.tsx +++ b/webapp/common-react/@dbeaver/ui-kit/src/Select/Select.tsx @@ -14,6 +14,7 @@ import { SelectLabel as AriaSelectLabel, SelectValue as AriaSelectValue, SelectArrow as AriaSelectArrow, + SelectGroup as AriaSelectGroup, type SelectProviderProps, type SelectProps, type SelectLabelProps, @@ -21,6 +22,7 @@ import { type SelectItemProps, type SelectValueProps, type SelectArrowProps, + type SelectGroupProps, useSelectContext, useSelectStore, } from '@ariakit/react'; @@ -70,6 +72,14 @@ export function SelectLabel({ children, className, ...props }: SelectLabelProps) ); } +export function SelectGroup({ children, className, ...props }: SelectGroupProps) { + return ( + + {children} + + ); +} + export { useSelectContext, useSelectStore, @@ -80,11 +90,13 @@ export { type SelectItemProps, type SelectValueProps, type SelectArrowProps, + type SelectGroupProps, }; Select.Provider = SelectProvider; Select.Popover = SelectPopover; Select.Item = SelectItem; Select.Label = SelectLabel; +Select.Group = SelectGroup; Select.Value = AriaSelectValue; Select.Arrow = AriaSelectArrow; diff --git a/webapp/common-react/@dbeaver/ui-kit/src/Select/SelectField.tsx b/webapp/common-react/@dbeaver/ui-kit/src/Select/SelectField.tsx index 40a12bda79a..1f2f09c64c7 100644 --- a/webapp/common-react/@dbeaver/ui-kit/src/Select/SelectField.tsx +++ b/webapp/common-react/@dbeaver/ui-kit/src/Select/SelectField.tsx @@ -8,10 +8,10 @@ import { useState } from 'react'; import clsx from 'clsx'; -import { SelectProvider, Select, SelectPopover, SelectItem, SelectLabel, type SelectProviderProps } from './Select.js'; +import { SelectProvider, Select, SelectPopover, SelectItem, SelectLabel, SelectGroup, type SelectProviderProps } from './Select.js'; import './SelectField.css'; -export interface SelectItem { +export interface ISelectItem { value: T; label: string; disabled?: boolean; @@ -19,7 +19,7 @@ export interface SelectItem { type PropertyGetter = (item: ItemType) => ValueType; -export interface SelectFieldProps> { +export interface ISelectFieldProps> { /** Options array - can be SelectOption objects or arbitrary objects */ items: ItemType[]; @@ -47,6 +47,12 @@ export interface SelectFieldProps> { */ itemDisabled?: PropertyGetter; + /** + * When true for an item, it acts as a group boundary — items are split into + * SelectGroup chunks separated by a CSS border. Separator items are not rendered. + */ + isSeparator?: PropertyGetter; + value?: T; onChange?: (value: T) => void; @@ -65,6 +71,18 @@ export interface SelectFieldProps> { noItemsPlaceholder?: React.ReactNode; + /** + * Items pinned above the scrollable list, always visible at the top of the popover. + * Uses the same item API (itemValue, itemRender, itemDisabled) as the main items list. + */ + headerItems?: ItemType[]; + + /** + * Items pinned below the scrollable list, always visible at the bottom of the popover. + * Uses the same item API (itemValue, itemRender, itemDisabled) as the main items list. + */ + footerItems?: ItemType[]; + /** * Custom renderer for the selected value, overrides itemRenderer for the selected state * Only needed for special formatting of the selected value different from list items @@ -94,7 +112,19 @@ function getValueByPath(item: Item, getter: PropertyGetter>({ +function splitIntoGroups(items: ItemType[], isSeparator: PropertyGetter): ItemType[][] { + const groups: ItemType[][] = [[]]; + for (const item of items) { + if (isSeparator(item)) { + groups.push([]); + } else { + groups[groups.length - 1]!.push(item); + } + } + return groups.filter(g => g.length > 0); +} + +export function SelectField>({ items, value, onChange, @@ -102,8 +132,11 @@ export function SelectField>({ itemValueSerialized, itemRender, itemDisabled, + isSeparator, label, noItemsPlaceholder = 'No items', + headerItems, + footerItems, description, disabled, required, @@ -115,18 +148,18 @@ export function SelectField>({ autoFocusItemsOnShow, name, id, -}: SelectFieldProps) { +}: ISelectFieldProps) { const getItemValue = (item: ItemType): T => - getValueByPath(item, itemValue, i => ('value' in i ? (i as unknown as SelectItem).value : (i as unknown as T))); + getValueByPath(item, itemValue, i => ('value' in i ? (i as unknown as ISelectItem).value : (i as unknown as T))); const getItemValueSerialized = (item: ItemType): string => getValueByPath(getItemValue(item), itemValueSerialized, key => JSON.stringify(key)); const renderItem = (item: ItemType): React.ReactNode => - getValueByPath(item, itemRender, i => ('label' in i ? (i as unknown as SelectItem).label : String(i))); + getValueByPath(item, itemRender, i => ('label' in i ? (i as unknown as ISelectItem).label : String(i))); const isItemDisabled = (item: ItemType): boolean => - getValueByPath(item, itemDisabled, i => ('disabled' in i ? Boolean((i as unknown as SelectItem).disabled) : false)); + getValueByPath(item, itemDisabled, i => ('disabled' in i ? Boolean((i as unknown as ISelectItem).disabled) : false)); const [selectedValue, setSelectedValue] = useState(() => { if (value !== undefined) { @@ -140,7 +173,8 @@ export function SelectField>({ const handleChange = (newValue: string | readonly string[]) => { // TODO: add support for multi-select - const newItem = items.find(item => getItemValueSerialized(item) === newValue); + const allItems = [...(headerItems ?? []), ...items, ...(footerItems ?? [])]; + const newItem = allItems.find(item => getItemValueSerialized(item) === newValue); if (!newItem) { return; } @@ -159,6 +193,28 @@ export function SelectField>({ const selectedItem = currentValue !== undefined ? items.find(item => getItemValueSerialized(item) === currentValueSerialized) : undefined; const displayValue = selectedRender ? selectedRender(currentValue, selectedItem) : selectedItem ? renderItem(selectedItem) : ''; + function renderSelectItem(item: ItemType) { + return ( + + {renderItem(item)} + + ); + } + + function renderItems(): React.ReactNode { + if (items.length === 0) { + return
{noItemsPlaceholder}
; + } + + if (isSeparator) { + return splitIntoGroups(items, isSeparator).map(group => ( + {group.map(renderSelectItem)} + )); + } + + return items.map(renderSelectItem); + } + return (
handleChange(val)} store={store}> @@ -171,14 +227,12 @@ export function SelectField>({ {description && {description}} - {items.length === 0 ? ( -
{noItemsPlaceholder}
- ) : ( - items.map(item => ( - - {renderItem(item)} - - )) + {headerItems && headerItems.length > 0 && ( + {headerItems.map(renderSelectItem)} + )} +
{renderItems()}
+ {footerItems && footerItems.length > 0 && ( + {footerItems.map(renderSelectItem)} )}
diff --git a/webapp/common-react/@dbeaver/ui-kit/src/index.ts b/webapp/common-react/@dbeaver/ui-kit/src/index.ts index 3e79b447b75..9af7a55de20 100644 --- a/webapp/common-react/@dbeaver/ui-kit/src/index.ts +++ b/webapp/common-react/@dbeaver/ui-kit/src/index.ts @@ -48,7 +48,7 @@ export { export * from './Combobox/Combobox.js'; export * from './Command/Command.js'; export { Popover, usePopoverStore, type PopoverStore } from './Popover/Popover.js'; -export { SelectField, type SelectFieldProps, type SelectItem } from './Select/SelectField.js'; +export { SelectField, type ISelectFieldProps, type ISelectItem } from './Select/SelectField.js'; export { Spinner, type SpinnerProps } from './Spinner/Spinner.js'; export { Switch, useSwitchContext, type SwitchProps } from './Switch/Switch.js'; export { SwitchProvider, type SwitchProviderProps } from './Switch/SwitchProvider.js'; diff --git a/webapp/packages/core-app/public/icons/preload/icons.svg b/webapp/packages/core-app/public/icons/preload/icons.svg index e51b3db417b..23efb21e38f 100644 --- a/webapp/packages/core-app/public/icons/preload/icons.svg +++ b/webapp/packages/core-app/public/icons/preload/icons.svg @@ -576,4 +576,9 @@ - \ No newline at end of file + + + + + + diff --git a/webapp/packages/core-blocks/src/FormControls/Select.css b/webapp/packages/core-blocks/src/FormControls/Select.css index 72368e0d6d7..206848d865f 100644 --- a/webapp/packages/core-blocks/src/FormControls/Select.css +++ b/webapp/packages/core-blocks/src/FormControls/Select.css @@ -78,10 +78,11 @@ } .select__item { - display: inline-flex; + display: flex; gap: 8px; letter-spacing: normal; align-items: center; + min-width: 0; } .select__item--placeholder { @@ -94,6 +95,7 @@ } .select__input-icon { + flex-shrink: 0; width: 16px; & .select__icon { diff --git a/webapp/packages/core-blocks/src/FormControls/Select.tsx b/webapp/packages/core-blocks/src/FormControls/Select.tsx index 867b3dcfd04..d0dff201cdf 100644 --- a/webapp/packages/core-blocks/src/FormControls/Select.tsx +++ b/webapp/packages/core-blocks/src/FormControls/Select.tsx @@ -36,10 +36,13 @@ export type SelectBaseProps = Omit< titleSelector?: (item: TValue) => string | undefined; iconSelector?: (item: TValue) => string | React.ReactElement | undefined; isDisabled?: (item: TValue) => boolean; + isSeparator?: (item: TValue) => boolean; onSwitch?: (state: boolean) => void; inline?: boolean; children?: string; portal?: boolean; + headerItems?: TValue[]; + footerItems?: TValue[]; }; type ControlledProps = SelectBaseProps & { @@ -56,12 +59,12 @@ type ObjectProps = SelectBaseProps(props: ControlledProps): React.JSX.Element; (props: ObjectProps): React.JSX.Element; } -export const Select: SelectType = observer(function Select({ +export const Select: ISelectType = observer(function Select({ value: controlledValue, defaultValue, name, @@ -77,6 +80,8 @@ export const Select: SelectType = observer(function Select({ inline, description, placeholder, + headerItems, + footerItems, id, keySelector = v => v, valueSelector = v => v, @@ -84,6 +89,7 @@ export const Select: SelectType = observer(function Select({ iconSelector, titleSelector, isDisabled, + isSeparator, onSelect, onSwitch, ...rest @@ -150,9 +156,9 @@ export const Select: SelectType = observer(function Select({ function itemRender(item: (typeof items)[number]): React.ReactNode { return ( -
+
{renderIcon(item)} - {valueSelector(item)} + {valueSelector(item)}
); } @@ -191,6 +197,9 @@ export const Select: SelectType = observer(function Select({ itemValueSerialized={serializedKeySelector} itemRender={itemRender} itemDisabled={itemDisabled} + isSeparator={isSeparator} + headerItems={headerItems} + footerItems={footerItems} name={name} disabled={disabled || readOnly} noItemsPlaceholder={translate('combobox_no_results_placeholder')} diff --git a/webapp/packages/core-ui/src/Form/FormPart.ts b/webapp/packages/core-ui/src/Form/FormPart.ts index 2169687af28..e3223f9360e 100644 --- a/webapp/packages/core-ui/src/Form/FormPart.ts +++ b/webapp/packages/core-ui/src/Form/FormPart.ts @@ -19,6 +19,7 @@ export abstract class FormPart impl state: TPartState; initialState: TPartState; isSaving: boolean; + isReadOnly: boolean; exception: Error | null; promise: Promise | null; @@ -35,6 +36,7 @@ export abstract class FormPart impl this.initialState = initialState; this.state = toJS(this.initialState); this.isSaving = false; + this.isReadOnly = false; this.exception = null; this.promise = null; @@ -54,6 +56,7 @@ export abstract class FormPart impl exception: observable.ref, promise: observable.ref, isSaving: observable.ref, + isReadOnly: observable.ref, loaded: observable, loading: observable, setInitialState: action, diff --git a/webapp/packages/core-ui/src/Form/IFormPart.ts b/webapp/packages/core-ui/src/Form/IFormPart.ts index dc70be0b760..af2cf62a7aa 100644 --- a/webapp/packages/core-ui/src/Form/IFormPart.ts +++ b/webapp/packages/core-ui/src/Form/IFormPart.ts @@ -12,6 +12,7 @@ export interface IFormPart extends ILoadableState { readonly initialState: TState; isSaving: boolean; readonly isDisabled: boolean; + isReadOnly: boolean; readonly isChanged: boolean; diff --git a/webapp/packages/plugin-connection-network-handlers/src/SSH/ConnectionFormSSHPart.ts b/webapp/packages/plugin-connection-network-handlers/src/SSH/ConnectionFormSSHPart.ts index 3272ab2b8a1..3722edf2cf9 100644 --- a/webapp/packages/plugin-connection-network-handlers/src/SSH/ConnectionFormSSHPart.ts +++ b/webapp/packages/plugin-connection-network-handlers/src/SSH/ConnectionFormSSHPart.ts @@ -105,7 +105,7 @@ export class ConnectionFormSSHPart extends FormPart { const validation = contexts.getContext(formValidationContext); - if (!this.isChanged || !this.state.enabled) { + if (!this.isChanged || !this.state.enabled || this.isReadOnly) { return; } diff --git a/webapp/packages/plugin-connection-network-handlers/src/SSH/SSH.tsx b/webapp/packages/plugin-connection-network-handlers/src/SSH/SSH.tsx index 1b243b4dade..cd3aab85c68 100644 --- a/webapp/packages/plugin-connection-network-handlers/src/SSH/SSH.tsx +++ b/webapp/packages/plugin-connection-network-handlers/src/SSH/SSH.tsx @@ -37,7 +37,7 @@ export const SSH: TabContainerPanelComponent = observer(function SSH({ fo state={handlerState} initialState={sshPart.initialState} disabled={formState.isDisabled || formState.isReadOnly} - readonly={formState.isReadOnly} + readonly={formState.isReadOnly || sshPart.isReadOnly} sharedCredentials={optionsPart.state.sharedCredentials} projectId={formState.state.projectId} connectionId={formState.state.connectionId} diff --git a/webapp/packages/plugin-connection-network-handlers/src/index.ts b/webapp/packages/plugin-connection-network-handlers/src/index.ts index 36eb47b0b19..88522d20b62 100644 --- a/webapp/packages/plugin-connection-network-handlers/src/index.ts +++ b/webapp/packages/plugin-connection-network-handlers/src/index.ts @@ -7,3 +7,4 @@ */ import './module.js'; +export * from './SSH/getConnectionFormSSHPart.js';