From 92ae9bfddcb4ae1730bdb14c767154250c841017 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 14 Jul 2026 15:01:14 +0200 Subject: [PATCH 01/11] feat: ClickHouse warehouse connection types in frontend API layer --- frontend/common/types/requests.ts | 6 ++++-- frontend/common/types/responses.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 9da78ca7e198..ea4d74fe89f4 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -1028,7 +1028,8 @@ export type Req = { environmentId: string warehouse_type: string name?: string - config?: Record + config?: Record + credentials?: { password: string } } deleteWarehouseConnection: { environmentId: string; id: number } testWarehouseConnection: { environmentId: string; id: number } @@ -1036,7 +1037,8 @@ export type Req = { environmentId: string id: number name?: string - config?: Record + config?: Record + credentials?: { password: string } } getExperiments: PagedRequest<{ environmentId: string diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index adfcad97f578..536084bf5afe 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -1259,12 +1259,20 @@ export type SnowflakeConfig = { user: string } +export type ClickHouseConfig = { + host: string + port: number + database: string + username: string + secure: boolean +} + export type WarehouseConnection = { id: number warehouse_type: WarehouseType status: WarehouseConnectionStatus name: string - config: SnowflakeConfig | Record + config: SnowflakeConfig | ClickHouseConfig | Record created_at: string total_events_received: number | null unique_events_count: number | null From 323e01e4c88d3e7d840e457173f44aba9a81cd8e Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 14 Jul 2026 15:02:38 +0200 Subject: [PATCH 02/11] feat: ClickHouse warehouse config form --- .../warehouse-tab/ClickHouseConfigForm.tsx | 190 ++++++++++++++++++ .../__tests__/clickhouseConfig.test.ts | 75 +++++++ .../tabs/warehouse-tab/clickhouseConfig.ts | 60 ++++++ 3 files changed, 325 insertions(+) create mode 100644 frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx create mode 100644 frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/clickhouseConfig.test.ts create mode 100644 frontend/web/components/pages/environment-settings/tabs/warehouse-tab/clickhouseConfig.ts diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx new file mode 100644 index 000000000000..6ecced0fb0f5 --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx @@ -0,0 +1,190 @@ +import React, { FC, useState } from 'react' +import Button from 'components/base/forms/Button' +import Input from 'components/base/forms/Input' +import Switch from 'components/Switch' +import ErrorMessage from 'components/ErrorMessage' +import { ClickHouseConfig } from 'common/types/responses' +import { + buildClickHousePayload, + CLICKHOUSE_DEFAULTS, + ClickHouseFormData, + ClickHouseFormState, + isClickHouseFormValid, +} from './clickhouseConfig' +import './ConfigForm.scss' + +type ClickHouseConfigFormProps = { + onSave: (data: ClickHouseFormData) => Promise + onCancel: () => void + isEdit?: boolean + initialConfig?: ClickHouseConfig + initialName?: string +} + +const getButtonLabel = (isEdit: boolean, isSaving: boolean): string => { + if (isSaving) return isEdit ? 'Saving...' : 'Creating...' + return isEdit ? 'Save changes' : 'Save and continue' +} + +const ClickHouseConfigForm: FC = ({ + initialConfig, + initialName = '', + isEdit = false, + onCancel, + onSave, +}) => { + const defaults = { ...CLICKHOUSE_DEFAULTS, ...initialConfig } + const [name, setName] = useState(initialName) + const [host, setHost] = useState(defaults.host) + const [port, setPort] = useState(String(defaults.port)) + const [database, setDatabase] = useState(defaults.database) + const [username, setUsername] = useState(defaults.username) + const [password, setPassword] = useState('') + const [secure, setSecure] = useState(defaults.secure) + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(false) + + const form: ClickHouseFormState = { + database, + host, + name, + password, + port, + secure, + username, + } + const isValid = isClickHouseFormValid(form, isEdit) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!isValid) return + + setIsSaving(true) + setError(false) + try { + await onSave(buildClickHousePayload(form)) + } catch { + setError(true) + setIsSaving(false) + } + } + + return ( +
+
+
+ + ) => + setHost(e.target.value) + } + placeholder='your-instance.clickhouse.cloud' + disabled={isEdit} + /> + + {isEdit + ? "Host can't be changed. To use a different ClickHouse instance, disconnect and create a new connection." + : 'The hostname of your ClickHouse instance, without protocol or port.'} + +
+ +
+ + ) => + setName(e.target.value) + } + placeholder='e.g. Production ClickHouse' + /> +
+ +
+
+ + ) => + setPort(e.target.value) + } + placeholder='9440' + /> +
+
+ + ) => + setDatabase(e.target.value) + } + placeholder='flagsmith' + /> +
+
+ +
+ + ) => + setUsername(e.target.value) + } + placeholder='default' + /> +
+ +
+ + ) => + setPassword(e.target.value) + } + type='password' + placeholder={isEdit ? '••••••••' : 'Password'} + /> + {isEdit && ( + + Leave blank to keep the current password. + + )} +
+ +
+
+ + +
+
+ + {error && ( + + )} + +
+ + +
+
+
+ ) +} + +ClickHouseConfigForm.displayName = 'ClickHouseConfigForm' +export default ClickHouseConfigForm diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/clickhouseConfig.test.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/clickhouseConfig.test.ts new file mode 100644 index 000000000000..b3e2418a8251 --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/clickhouseConfig.test.ts @@ -0,0 +1,75 @@ +import { + buildClickHousePayload, + ClickHouseFormState, + isClickHouseFormValid, + isValidPort, +} from 'components/pages/environment-settings/tabs/warehouse-tab/clickhouseConfig' + +const validForm: ClickHouseFormState = { + database: 'flagsmith', + host: 'ch.example.com', + name: 'Production ClickHouse', + password: 'hunter2', + port: '9440', + secure: true, + username: 'default', +} + +describe('isValidPort', () => { + it('accepts ports within 1-65535', () => { + expect(isValidPort('1')).toBe(true) + expect(isValidPort('9440')).toBe(true) + expect(isValidPort('65535')).toBe(true) + }) + + it('rejects out-of-range or non-numeric ports', () => { + expect(isValidPort('0')).toBe(false) + expect(isValidPort('65536')).toBe(false) + expect(isValidPort('abc')).toBe(false) + expect(isValidPort('94.4')).toBe(false) + expect(isValidPort('')).toBe(false) + }) +}) + +describe('isClickHouseFormValid', () => { + it('accepts a complete form on create', () => { + expect(isClickHouseFormValid(validForm, false)).toBe(true) + }) + + it.each(['name', 'host', 'database', 'username', 'password'] as const)( + 'rejects a form with empty %s on create', + (field) => { + expect(isClickHouseFormValid({ ...validForm, [field]: '' }, false)).toBe( + false, + ) + }, + ) + + it('allows an empty password on edit (keeps the stored one)', () => { + expect(isClickHouseFormValid({ ...validForm, password: '' }, true)).toBe( + true, + ) + }) +}) + +describe('buildClickHousePayload', () => { + it('builds config with a numeric port and separates credentials', () => { + expect(buildClickHousePayload(validForm)).toEqual({ + config: { + database: 'flagsmith', + host: 'ch.example.com', + port: 9440, + secure: true, + username: 'default', + }, + credentials: { password: 'hunter2' }, + name: 'Production ClickHouse', + }) + }) + + it('omits credentials when the password is blank (edit keeps stored)', () => { + expect( + buildClickHousePayload({ ...validForm, password: '' }).credentials, + ).toBeUndefined() + }) +}) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/clickhouseConfig.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/clickhouseConfig.ts new file mode 100644 index 000000000000..6c08a06bdcce --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/clickhouseConfig.ts @@ -0,0 +1,60 @@ +import { ClickHouseConfig } from 'common/types/responses' + +export const CLICKHOUSE_DEFAULTS: ClickHouseConfig = { + database: 'flagsmith', + host: '', + port: 9440, + secure: true, + username: 'default', +} + +export type ClickHouseFormState = { + name: string + host: string + port: string + database: string + username: string + password: string + secure: boolean +} + +export type ClickHouseFormData = { + name: string + config: ClickHouseConfig + credentials?: { password: string } +} + +export const isValidPort = (port: string): boolean => { + const value = Number(port) + return ( + /^\d+$/.test(port) && + Number.isInteger(value) && + value >= 1 && + value <= 65535 + ) +} + +export const isClickHouseFormValid = ( + form: ClickHouseFormState, + isEdit: boolean, +): boolean => + !!form.name && + !!form.host && + isValidPort(form.port) && + !!form.database && + !!form.username && + (isEdit || !!form.password) + +export const buildClickHousePayload = ( + form: ClickHouseFormState, +): ClickHouseFormData => ({ + config: { + database: form.database, + host: form.host, + port: Number(form.port), + secure: form.secure, + username: form.username, + }, + credentials: form.password ? { password: form.password } : undefined, + name: form.name, +}) From 592773cbc5179603890c8178e9935e243c420256 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 14 Jul 2026 15:05:10 +0200 Subject: [PATCH 03/11] feat: ClickHouse warehouse setup flow behind clickhouse_warehouse flag --- .../warehouse-tab/WarehouseConnectionCard.tsx | 11 ++- .../tabs/warehouse-tab/WarehouseSetup.tsx | 33 ++++++++- .../tabs/warehouse-tab/index.tsx | 71 ++++++++++++++++++- 3 files changed, 108 insertions(+), 7 deletions(-) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx index 225ae8791711..8b32adc3d26a 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx @@ -16,6 +16,7 @@ type WarehouseConnectionCardProps = { onDelete: () => void onEdit?: () => void onSendTestEvent: () => void + onTestConnection?: () => void isSendingTestEvent: boolean isLoadingStats?: boolean } @@ -46,6 +47,7 @@ const WarehouseConnectionCard: FC = ({ onDelete, onEdit, onSendTestEvent, + onTestConnection, }) => { const typeLabel = connection.warehouse_type !== 'flagsmith' @@ -90,6 +92,10 @@ const WarehouseConnectionCard: FC = ({ 'account_identifier' in connection.config && connection.config.account_identifier && `: ${connection.config.account_identifier}`} + {connection.config && + 'host' in connection.config && + connection.config.host && + `: ${connection.config.host}`} )} @@ -139,9 +145,10 @@ const WarehouseConnectionCard: FC = ({ id='warehouse-connection-test' theme='outline' size='small' - disabled + onClick={onTestConnection} + disabled={!onTestConnection || isSendingTestEvent} > - Test connection + {isSendingTestEvent ? 'Testing...' : 'Test connection'} )} {isFlagsmith && !isPending && !isConnected && ( diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseSetup.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseSetup.tsx index 3c0946b5601b..bbaaf64d06df 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseSetup.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseSetup.tsx @@ -5,25 +5,32 @@ import { WarehouseType } from 'common/types/responses' import { ConfigFormData } from './ConfigForm' import SelectableCard from 'components/base/SelectableCard' import ConfigForm from './ConfigForm' +import Utils from 'common/utils/utils' +import ClickHouseConfigForm from './ClickHouseConfigForm' +import { ClickHouseFormData } from './clickhouseConfig' import './WarehouseSetup.scss' type WarehouseSetupProps = { onEnableFlagsmith: () => void onCreateSnowflake: (data: ConfigFormData) => Promise + onCreateClickHouse: (data: ClickHouseFormData) => Promise isCreating: boolean } type WarehouseTypeOption = WarehouseType | 'bigquery' | 'databricks' -const CONFIGURABLE_TYPES: WarehouseTypeOption[] = ['flagsmith'] - const WarehouseSetup: FC = ({ isCreating, + onCreateClickHouse, onCreateSnowflake, onEnableFlagsmith, }) => { const [selectedType, setSelectedType] = useState('flagsmith') + const clickhouseEnabled = Utils.getFlagsmithHasFeature('clickhouse_warehouse') + const configurableTypes: WarehouseTypeOption[] = clickhouseEnabled + ? ['flagsmith', 'clickhouse'] + : ['flagsmith'] return (
@@ -46,6 +53,19 @@ const WarehouseSetup: FC = ({ onClick={() => setSelectedType('flagsmith')} />
+
+ } + title='ClickHouse' + description='Open-source OLAP database' + selected={selectedType === 'clickhouse'} + onClick={() => clickhouseEnabled && setSelectedType('clickhouse')} + disabled={!clickhouseEnabled} + /> + {!clickhouseEnabled && ( + Coming Soon + )} +
} @@ -109,7 +129,14 @@ const WarehouseSetup: FC = ({ /> )} - {!CONFIGURABLE_TYPES.includes(selectedType) && ( + {selectedType === 'clickhouse' && ( + setSelectedType('flagsmith')} + /> + )} + + {!configurableTypes.includes(selectedType) && (

Coming soon. This warehouse type is not yet available. diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx index 7eac70be8241..6aac16d5588d 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx @@ -6,11 +6,13 @@ import { useTestWarehouseConnectionMutation, useUpdateWarehouseConnectionMutation, } from 'common/services/useWarehouseConnection' -import { SnowflakeConfig } from 'common/types/responses' +import { ClickHouseConfig, SnowflakeConfig } from 'common/types/responses' import WarehouseConnectionCard from './WarehouseConnectionCard' import WarehouseSetup from './WarehouseSetup' import WarehouseSetupSkeleton from './WarehouseSetupSkeleton' import ConfigForm from './ConfigForm' +import ClickHouseConfigForm from './ClickHouseConfigForm' +import { ClickHouseFormData } from './clickhouseConfig' import sendWarehouseTestEvent from './sendWarehouseTestEvent' import { getWarehousePollingInterval } from './warehousePolling' @@ -56,6 +58,7 @@ const WarehouseTab: FC = ({ environmentId }) => { }, [baseConnection, connectionsWithStats]) const connectionId = connection?.id const connectionStatus = connection?.status + const connectionType = connection?.warehouse_type const [testConnection, { isLoading: isSendingTestEvent }] = useTestWarehouseConnectionMutation() @@ -65,6 +68,7 @@ const WarehouseTab: FC = ({ environmentId }) => { }, [connection]) useEffect(() => { + if (connectionType !== 'flagsmith') return const interval = getWarehousePollingInterval(connectionStatus) if (!interval || connectionId === undefined) return testConnection({ environmentId, id: connectionId }) @@ -72,7 +76,13 @@ const WarehouseTab: FC = ({ environmentId }) => { testConnection({ environmentId, id: connectionId }) }, interval) return () => clearInterval(timer) - }, [connectionStatus, connectionId, environmentId, testConnection]) + }, [ + connectionStatus, + connectionId, + connectionType, + environmentId, + testConnection, + ]) const handleEnableFlagsmith = () => { openConfirm({ @@ -122,6 +132,43 @@ const WarehouseTab: FC = ({ environmentId }) => { }) } + const handleCreateClickHouse = (data: ClickHouseFormData) => + createConnection({ + environmentId, + warehouse_type: 'clickhouse', + ...data, + }) + .unwrap() + .then(() => toast('Warehouse connection created')) + + const handleUpdateClickHouse = (data: ClickHouseFormData) => { + if (!connection) return Promise.reject() + return updateConnection({ + environmentId, + id: connection.id, + ...data, + }) + .unwrap() + .then(() => { + setEditing(false) + toast('Warehouse connection updated') + }) + } + + const handleTestConnection = () => { + if (!connection) return + testConnection({ environmentId, id: connection.id }) + .unwrap() + .then((result) => { + if (result.status === 'connected') { + toast('Connection verified') + } else { + toast('Connection failed — check your credentials', 'danger') + } + }) + .catch(() => toast('Failed to test connection', 'danger')) + } + const handleDelete = () => { if (!connection) return deleteConnection({ environmentId, id: connection.id }) @@ -169,6 +216,7 @@ const WarehouseTab: FC = ({ environmentId }) => {

@@ -189,6 +237,20 @@ const WarehouseTab: FC = ({ environmentId }) => { ) } + if (editing && connection.warehouse_type === 'clickhouse') { + return ( +
+ setEditing(false)} + /> +
+ ) + } + return (
= ({ environmentId }) => { : undefined } onSendTestEvent={handleSendTestEvent} + onTestConnection={ + connection.warehouse_type === 'clickhouse' + ? handleTestConnection + : undefined + } isSendingTestEvent={isSendingTestEvent} isLoadingStats={isFetchingStats} /> From 438e82e85ce5345182a18803bdaed3d80e5b2492 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 16 Jul 2026 12:38:27 +0200 Subject: [PATCH 04/11] feat: surface warehouse connection status detail in the frontend --- frontend/common/types/responses.ts | 1 + .../tabs/warehouse-tab/WarehouseConnectionCard.tsx | 6 +++++- .../pages/environment-settings/tabs/warehouse-tab/index.tsx | 6 +++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 536084bf5afe..0e79f4fae6a3 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -1271,6 +1271,7 @@ export type WarehouseConnection = { id: number warehouse_type: WarehouseType status: WarehouseConnectionStatus + status_detail: string | null name: string config: SnowflakeConfig | ClickHouseConfig | Record created_at: string diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx index 8b32adc3d26a..d80e6946b8a1 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx @@ -82,7 +82,11 @@ const WarehouseConnectionCard: FC = ({ } place='top' > - {STATUS_LABEL[connection.status]} + {connection.status === 'errored' && connection.status_detail + ? `${STATUS_LABEL[connection.status]}: ${ + connection.status_detail + }` + : STATUS_LABEL[connection.status]}
{typeLabel && ( diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx index 6aac16d5588d..47ca1e466cda 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx @@ -163,7 +163,11 @@ const WarehouseTab: FC = ({ environmentId }) => { if (result.status === 'connected') { toast('Connection verified') } else { - toast('Connection failed — check your credentials', 'danger') + toast( + result.status_detail || + 'Connection failed — check your credentials', + 'danger', + ) } }) .catch(() => toast('Failed to test connection', 'danger')) From 3153113cbf5a1ddbe93c7ea7008cfff39d4f8178 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 20 Jul 2026 11:48:04 +0200 Subject: [PATCH 05/11] fix: address review feedback on warehouse frontend - Only render Test connection button when the handler is provided (backend only supports ClickHouse, not Snowflake) - Add missing id attributes on ClickHouse form buttons for E2E - Extract shared getButtonLabel helper from both config forms - Extract WarehouseConfigValue named type in requests.ts - Extract WarehouseConfigResponse named type in responses.ts --- frontend/common/types/requests.ts | 6 ++++-- frontend/common/types/responses.ts | 7 ++++++- .../tabs/warehouse-tab/ClickHouseConfigForm.tsx | 15 +++++++++------ .../tabs/warehouse-tab/ConfigForm.tsx | 6 +----- .../warehouse-tab/WarehouseConnectionCard.tsx | 4 ++-- .../tabs/warehouse-tab/warehouseFormUtils.ts | 4 ++++ 6 files changed, 26 insertions(+), 16 deletions(-) create mode 100644 frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehouseFormUtils.ts diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index ea4d74fe89f4..ee5b8fedac5c 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -147,6 +147,8 @@ export interface PipelineStageRequest { actions: StageActionRequest[] } +type WarehouseConfigValue = string | number | boolean + export type Req = { getFeatureCodeReferences: { projectId: number @@ -1028,7 +1030,7 @@ export type Req = { environmentId: string warehouse_type: string name?: string - config?: Record + config?: Record credentials?: { password: string } } deleteWarehouseConnection: { environmentId: string; id: number } @@ -1037,7 +1039,7 @@ export type Req = { environmentId: string id: number name?: string - config?: Record + config?: Record credentials?: { password: string } } getExperiments: PagedRequest<{ diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 0e79f4fae6a3..cc31e3d92490 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -1267,13 +1267,18 @@ export type ClickHouseConfig = { secure: boolean } +export type WarehouseConfigResponse = + | SnowflakeConfig + | ClickHouseConfig + | Record + export type WarehouseConnection = { id: number warehouse_type: WarehouseType status: WarehouseConnectionStatus status_detail: string | null name: string - config: SnowflakeConfig | ClickHouseConfig | Record + config: WarehouseConfigResponse created_at: string total_events_received: number | null unique_events_count: number | null diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx index 6ecced0fb0f5..66692d2a74e9 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx @@ -11,6 +11,7 @@ import { ClickHouseFormState, isClickHouseFormValid, } from './clickhouseConfig' +import { getButtonLabel } from './warehouseFormUtils' import './ConfigForm.scss' type ClickHouseConfigFormProps = { @@ -21,11 +22,6 @@ type ClickHouseConfigFormProps = { initialName?: string } -const getButtonLabel = (isEdit: boolean, isSaving: boolean): string => { - if (isSaving) return isEdit ? 'Saving...' : 'Creating...' - return isEdit ? 'Save changes' : 'Save and continue' -} - const ClickHouseConfigForm: FC = ({ initialConfig, initialName = '', @@ -169,10 +165,17 @@ const ClickHouseConfigForm: FC = ({ )}
- diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehouseFormUtils.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehouseFormUtils.ts new file mode 100644 index 000000000000..79d787eea6be --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehouseFormUtils.ts @@ -0,0 +1,4 @@ +export const getButtonLabel = (isEdit: boolean, isSaving: boolean): string => { + if (isSaving) return isEdit ? 'Saving...' : 'Creating...' + return isEdit ? 'Save changes' : 'Save and continue' +} From 1f9edaa87f8bb13c1b8fe2651cd47af63ceea386 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 21 Jul 2026 18:06:26 +0200 Subject: [PATCH 06/11] feat: test ClickHouse connection before saving warehouse config --- .../common/services/useWarehouseConnection.ts | 11 ++ frontend/common/types/requests.ts | 7 ++ frontend/common/types/responses.ts | 6 + .../warehouse-tab/ClickHouseConfigForm.tsx | 109 +++++++++++++++--- .../tabs/warehouse-tab/WarehouseSetup.tsx | 4 +- .../__tests__/clickhouseConfig.test.ts | 54 +++++++++ .../tabs/warehouse-tab/clickhouseConfig.ts | 24 ++++ .../tabs/warehouse-tab/index.tsx | 2 + 8 files changed, 197 insertions(+), 20 deletions(-) diff --git a/frontend/common/services/useWarehouseConnection.ts b/frontend/common/services/useWarehouseConnection.ts index c3af3511ab3f..18eb6028bb44 100644 --- a/frontend/common/services/useWarehouseConnection.ts +++ b/frontend/common/services/useWarehouseConnection.ts @@ -48,6 +48,16 @@ export const warehouseConnectionService = service url: `environments/${environmentId}/warehouse-connections/${id}/test-warehouse-connection/`, }), }), + testWarehouseConnectionConfig: builder.mutation< + Res['warehouseConnectionTestResult'], + Req['testWarehouseConnectionConfig'] + >({ + query: ({ environmentId, ...body }) => ({ + body, + method: 'POST', + url: `environments/${environmentId}/warehouse-connections/test-warehouse-connection/`, + }), + }), updateWarehouseConnection: builder.mutation< Res['warehouseConnections'][number], Req['updateWarehouseConnection'] @@ -66,6 +76,7 @@ export const { useCreateWarehouseConnectionMutation, useDeleteWarehouseConnectionMutation, useGetWarehouseConnectionsQuery, + useTestWarehouseConnectionConfigMutation, useTestWarehouseConnectionMutation, useUpdateWarehouseConnectionMutation, } = warehouseConnectionService diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index ee5b8fedac5c..d6e7d96e4087 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -1035,6 +1035,13 @@ export type Req = { } deleteWarehouseConnection: { environmentId: string; id: number } testWarehouseConnection: { environmentId: string; id: number } + testWarehouseConnectionConfig: { + environmentId: string + warehouse_type: string + name?: string + config?: Record + credentials?: { password: string } + } updateWarehouseConnection: { environmentId: string id: number diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index cc31e3d92490..76a6d722ebb9 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -1272,6 +1272,11 @@ export type WarehouseConfigResponse = | ClickHouseConfig | Record +export type WarehouseConnectionTestResult = { + status: WarehouseConnectionStatus + status_detail: string | null +} + export type WarehouseConnection = { id: number warehouse_type: WarehouseType @@ -1510,6 +1515,7 @@ export type Res = { gitlabIssues: PagedResponse gitlabMergeRequests: PagedResponse warehouseConnections: WarehouseConnection[] + warehouseConnectionTestResult: WarehouseConnectionTestResult experiments: PagedResponse & { currentPage: number pageSize: number diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx index 66692d2a74e9..ecf89632865a 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx @@ -4,25 +4,32 @@ import Input from 'components/base/forms/Input' import Switch from 'components/Switch' import ErrorMessage from 'components/ErrorMessage' import { ClickHouseConfig } from 'common/types/responses' +import { useTestWarehouseConnectionConfigMutation } from 'common/services/useWarehouseConnection' import { buildClickHousePayload, + canTestClickHouseConnection, CLICKHOUSE_DEFAULTS, ClickHouseFormData, ClickHouseFormState, + isClickHouseConfigDirty, isClickHouseFormValid, } from './clickhouseConfig' import { getButtonLabel } from './warehouseFormUtils' import './ConfigForm.scss' type ClickHouseConfigFormProps = { + environmentId: string onSave: (data: ClickHouseFormData) => Promise - onCancel: () => void + onCancel?: () => void isEdit?: boolean initialConfig?: ClickHouseConfig initialName?: string } +type TestState = 'idle' | 'connected' | 'errored' + const ClickHouseConfigForm: FC = ({ + environmentId, initialConfig, initialName = '', isEdit = false, @@ -39,6 +46,11 @@ const ClickHouseConfigForm: FC = ({ const [secure, setSecure] = useState(defaults.secure) const [isSaving, setIsSaving] = useState(false) const [error, setError] = useState(false) + const [testState, setTestState] = useState('idle') + const [testDetail, setTestDetail] = useState(null) + + const [testConnectionConfig, { isLoading: isTesting }] = + useTestWarehouseConnectionConfigMutation() const form: ClickHouseFormState = { database, @@ -50,10 +62,40 @@ const ClickHouseConfigForm: FC = ({ username, } const isValid = isClickHouseFormValid(form, isEdit) + const requiresTest = !isEdit || isClickHouseConfigDirty(form, initialConfig) + const canTest = canTestClickHouseConnection(form) + const canSave = isValid && (!requiresTest || testState === 'connected') + + const setField = + (setter: (value: T) => void) => + (value: T) => { + setter(value) + setTestState('idle') + setTestDetail(null) + } + + const handleTest = async () => { + if (!canTest || isTesting) return + setError(false) + try { + const { config, credentials } = buildClickHousePayload(form) + const result = await testConnectionConfig({ + config, + credentials, + environmentId, + warehouse_type: 'clickhouse', + }).unwrap() + setTestState(result.status === 'connected' ? 'connected' : 'errored') + setTestDetail(result.status_detail) + } catch { + setTestState('errored') + setTestDetail(null) + } + } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!isValid) return + if (!canSave || isSaving) return setIsSaving(true) setError(false) @@ -73,7 +115,7 @@ const ClickHouseConfigForm: FC = ({ ) => - setHost(e.target.value) + setField(setHost)(e.target.value) } placeholder='your-instance.clickhouse.cloud' disabled={isEdit} @@ -102,7 +144,7 @@ const ClickHouseConfigForm: FC = ({ ) => - setPort(e.target.value) + setField(setPort)(e.target.value) } placeholder='9440' /> @@ -112,7 +154,7 @@ const ClickHouseConfigForm: FC = ({ ) => - setDatabase(e.target.value) + setField(setDatabase)(e.target.value) } placeholder='flagsmith' /> @@ -124,7 +166,7 @@ const ClickHouseConfigForm: FC = ({ ) => - setUsername(e.target.value) + setField(setUsername)(e.target.value) } placeholder='default' /> @@ -135,27 +177,42 @@ const ClickHouseConfigForm: FC = ({ ) => - setPassword(e.target.value) + setField(setPassword)(e.target.value) } type='password' placeholder={isEdit ? '••••••••' : 'Password'} /> {isEdit && ( - Leave blank to keep the current password. + {requiresTest && !password + ? 'Enter your password to test the connection before saving.' + : 'Leave blank to keep the current password.'} )}
- +
+ {testState === 'errored' && ( + + )} + {testState === 'connected' && ( + + Connection verified. You can now save. + + )} + {error && ( = ({ )}
- + {isEdit && onCancel && ( + + )} + {requiresTest && ( + + )} diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseSetup.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseSetup.tsx index bbaaf64d06df..2acd69cdc328 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseSetup.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseSetup.tsx @@ -11,6 +11,7 @@ import { ClickHouseFormData } from './clickhouseConfig' import './WarehouseSetup.scss' type WarehouseSetupProps = { + environmentId: string onEnableFlagsmith: () => void onCreateSnowflake: (data: ConfigFormData) => Promise onCreateClickHouse: (data: ClickHouseFormData) => Promise @@ -20,6 +21,7 @@ type WarehouseSetupProps = { type WarehouseTypeOption = WarehouseType | 'bigquery' | 'databricks' const WarehouseSetup: FC = ({ + environmentId, isCreating, onCreateClickHouse, onCreateSnowflake, @@ -131,8 +133,8 @@ const WarehouseSetup: FC = ({ {selectedType === 'clickhouse' && ( setSelectedType('flagsmith')} /> )} diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/clickhouseConfig.test.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/clickhouseConfig.test.ts index b3e2418a8251..37ab573f86fb 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/clickhouseConfig.test.ts +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/clickhouseConfig.test.ts @@ -1,6 +1,8 @@ import { buildClickHousePayload, + canTestClickHouseConnection, ClickHouseFormState, + isClickHouseConfigDirty, isClickHouseFormValid, isValidPort, } from 'components/pages/environment-settings/tabs/warehouse-tab/clickhouseConfig' @@ -52,6 +54,58 @@ describe('isClickHouseFormValid', () => { }) }) +describe('canTestClickHouseConnection', () => { + it('accepts a complete form regardless of name', () => { + expect(canTestClickHouseConnection({ ...validForm, name: '' })).toBe(true) + }) + + it.each(['host', 'port', 'database', 'username', 'password'] as const)( + 'rejects a form with empty %s', + (field) => { + expect(canTestClickHouseConnection({ ...validForm, [field]: '' })).toBe( + false, + ) + }, + ) +}) + +describe('isClickHouseConfigDirty', () => { + const initialConfig = { + database: 'flagsmith', + host: 'ch.example.com', + port: 9440, + secure: true, + username: 'default', + } + + it('is clean when the form matches the stored config with a blank password', () => { + expect( + isClickHouseConfigDirty({ ...validForm, password: '' }, initialConfig), + ).toBe(false) + }) + + it.each([ + ['port', { port: '9000' }], + ['database', { database: 'analytics' }], + ['username', { username: 'svc' }], + ['secure', { secure: false }], + ['password', { password: 'hunter2' }], + ] as const)('is dirty when %s changes', (_, change) => { + expect( + isClickHouseConfigDirty( + { ...validForm, password: '', ...change }, + initialConfig, + ), + ).toBe(true) + }) + + it('compares against defaults when there is no stored config', () => { + expect( + isClickHouseConfigDirty({ ...validForm, password: '' }, undefined), + ).toBe(true) + }) +}) + describe('buildClickHousePayload', () => { it('builds config with a numeric port and separates credentials', () => { expect(buildClickHousePayload(validForm)).toEqual({ diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/clickhouseConfig.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/clickhouseConfig.ts index 6c08a06bdcce..3adfe12686e3 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/clickhouseConfig.ts +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/clickhouseConfig.ts @@ -58,3 +58,27 @@ export const buildClickHousePayload = ( credentials: form.password ? { password: form.password } : undefined, name: form.name, }) + +export const canTestClickHouseConnection = ( + form: ClickHouseFormState, +): boolean => + !!form.host && + isValidPort(form.port) && + !!form.database && + !!form.username && + !!form.password + +export const isClickHouseConfigDirty = ( + form: ClickHouseFormState, + initialConfig: ClickHouseConfig | undefined, +): boolean => { + const initial = { ...CLICKHOUSE_DEFAULTS, ...initialConfig } + return ( + form.host !== initial.host || + form.port !== String(initial.port) || + form.database !== initial.database || + form.username !== initial.username || + form.secure !== initial.secure || + !!form.password + ) +} diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx index 47ca1e466cda..2c3f77ba1dbd 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx @@ -218,6 +218,7 @@ const WarehouseTab: FC = ({ environmentId }) => { return (
= ({ environmentId }) => {
Date: Wed, 22 Jul 2026 10:15:00 +0200 Subject: [PATCH 07/11] feat: allow saving warehouse config after failed connection test --- .../warehouse-tab/ClickHouseConfigForm.tsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx index ecf89632865a..aef6f50ce1ca 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx @@ -3,6 +3,7 @@ import Button from 'components/base/forms/Button' import Input from 'components/base/forms/Input' import Switch from 'components/Switch' import ErrorMessage from 'components/ErrorMessage' +import WarningMessage from 'components/WarningMessage' import { ClickHouseConfig } from 'common/types/responses' import { useTestWarehouseConnectionConfigMutation } from 'common/services/useWarehouseConnection' import { @@ -64,7 +65,7 @@ const ClickHouseConfigForm: FC = ({ const isValid = isClickHouseFormValid(form, isEdit) const requiresTest = !isEdit || isClickHouseConfigDirty(form, initialConfig) const canTest = canTestClickHouseConnection(form) - const canSave = isValid && (!requiresTest || testState === 'connected') + const canSave = isValid && (!requiresTest || testState !== 'idle') const setField = (setter: (value: T) => void) => @@ -200,19 +201,6 @@ const ClickHouseConfigForm: FC = ({
- {testState === 'errored' && ( - - )} - {testState === 'connected' && ( - - Connection verified. You can now save. - - )} - {error && ( = ({ {getButtonLabel(isEdit, isSaving)}
+ + {testState === 'connected' && ( + + Connection verified. You can now save. + + )} + {testState === 'errored' && ( + + )}
) From 8f27c089af869f031cd667332d30ed007e170022 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Jul 2026 14:03:31 +0200 Subject: [PATCH 08/11] fix: address warehouse form review feedback --- .../warehouse-tab/ClickHouseConfigForm.tsx | 20 +++++++------------ .../tabs/warehouse-tab/warehouseFormUtils.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx index aef6f50ce1ca..465cedac81cd 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx @@ -15,7 +15,11 @@ import { isClickHouseConfigDirty, isClickHouseFormValid, } from './clickhouseConfig' -import { getButtonLabel } from './warehouseFormUtils' +import { + getButtonLabel, + getTestFailureWarning, + getWarehouseErrorMessage, +} from './warehouseFormUtils' import './ConfigForm.scss' type ClickHouseConfigFormProps = { @@ -201,13 +205,7 @@ const ClickHouseConfigForm: FC = ({ - {error && ( - - )} + {error && }
{isEdit && onCancel && ( @@ -250,11 +248,7 @@ const ClickHouseConfigForm: FC = ({ )} {testState === 'errored' && ( - + )}
diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehouseFormUtils.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehouseFormUtils.ts index 79d787eea6be..95c8e5ba95f0 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehouseFormUtils.ts +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehouseFormUtils.ts @@ -2,3 +2,13 @@ export const getButtonLabel = (isEdit: boolean, isSaving: boolean): string => { if (isSaving) return isEdit ? 'Saving...' : 'Creating...' return isEdit ? 'Save changes' : 'Save and continue' } + +export const getWarehouseErrorMessage = (isEdit: boolean): string => + `Failed to ${ + isEdit ? 'update' : 'create' + } warehouse connection. Please try again.` + +export const getTestFailureWarning = (detail: string | null): string => { + const reason = detail ? `: ${detail}${detail.endsWith('.') ? '' : '.'}` : '.' + return `We couldn't establish a connection${reason} You can save anyway and test again later, but events won't be delivered until the connection succeeds.` +} From 0a8a2d3296fb5095c6a66dec3c3d8b5221e2884a Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Jul 2026 14:23:11 +0200 Subject: [PATCH 09/11] fix: toast warehouse test results and align inline messages under buttons --- .../warehouse-tab/ClickHouseConfigForm.tsx | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx index 465cedac81cd..4f28fee06880 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx @@ -90,11 +90,22 @@ const ClickHouseConfigForm: FC = ({ environmentId, warehouse_type: 'clickhouse', }).unwrap() - setTestState(result.status === 'connected' ? 'connected' : 'errored') - setTestDetail(result.status_detail) + if (result.status === 'connected') { + setTestState('connected') + setTestDetail(null) + toast('Connection verified') + } else { + setTestState('errored') + setTestDetail(result.status_detail) + toast( + result.status_detail || 'Connection failed — check your credentials', + 'danger', + ) + } } catch { setTestState('errored') setTestDetail(null) + toast('Failed to test connection', 'danger') } } @@ -243,12 +254,19 @@ const ClickHouseConfigForm: FC = ({ {testState === 'connected' && ( - - Connection verified. You can now save. - +
+ + Connection verified. You can now save. + +
)} {testState === 'errored' && ( - +
+ +
)} From 78816ff13d681d99b31016443e0addbcd8589763 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Jul 2026 14:24:23 +0200 Subject: [PATCH 10/11] fix: always show test connection button on warehouse form --- .../warehouse-tab/ClickHouseConfigForm.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx index 4f28fee06880..79e02aa8e6eb 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx @@ -230,18 +230,16 @@ const ClickHouseConfigForm: FC = ({ Cancel )} - {requiresTest && ( - - )} +