diff --git a/frontend/common/services/useWarehouseConnection.ts b/frontend/common/services/useWarehouseConnection.ts index 4c98437a6ad3..2ff3fe3f7527 100644 --- a/frontend/common/services/useWarehouseConnection.ts +++ b/frontend/common/services/useWarehouseConnection.ts @@ -36,6 +36,17 @@ export const warehouseConnectionService = service url: `environments/${environmentId}/warehouse-connections/`, }), }), + updateWarehouseConnection: builder.mutation< + Res['warehouseConnections'][number], + Req['updateWarehouseConnection'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'WarehouseConnection' }], + query: ({ environmentId, id, ...body }) => ({ + body, + method: 'PATCH', + url: `environments/${environmentId}/warehouse-connections/${id}/`, + }), + }), }), }) @@ -43,4 +54,5 @@ export const { useCreateWarehouseConnectionMutation, useDeleteWarehouseConnectionMutation, useGetWarehouseConnectionsQuery, + useUpdateWarehouseConnectionMutation, } = warehouseConnectionService diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 85042872f0f4..7dd713129e41 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -971,7 +971,18 @@ export type Req = { gitlab_project_id: number }> getWarehouseConnections: { environmentId: string } - createWarehouseConnection: { environmentId: string; warehouse_type: string } + createWarehouseConnection: { + environmentId: string + warehouse_type: string + name?: string + config?: Record + } deleteWarehouseConnection: { environmentId: string; id: number } + updateWarehouseConnection: { + environmentId: string + id: number + name?: string + config?: Record + } // END OF TYPES } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 6474a37289ee..b3edece5bd24 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -955,11 +955,6 @@ export type IdentityTrait = { trait_value: FlagsmithValue } -enum PipelineStatus { - DRAFT = 'DRAFT', - ACTIVE = 'ACTIVE', -} - export interface ReleasePipeline { id: number name: string @@ -1098,17 +1093,28 @@ export type ExperimentResults = { } export type WarehouseConnectionStatus = + | 'created' | 'pending_connection' | 'connected' | 'errored' export type WarehouseType = 'flagsmith' | 'snowflake' | 'clickhouse' +export type SnowflakeConfig = { + account_identifier: string + warehouse: string + database: string + schema: string + role: string + user: string +} + export type WarehouseConnection = { id: number warehouse_type: WarehouseType status: WarehouseConnectionStatus name: string + config: SnowflakeConfig | Record created_at: string } diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/CreateWarehouseConnectionModal.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/CreateWarehouseConnectionModal.tsx new file mode 100644 index 000000000000..bc036821daed --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/CreateWarehouseConnectionModal.tsx @@ -0,0 +1,222 @@ +import { FC, FormEvent, useMemo, useState } from 'react' +import { Req } from 'common/types/requests' +import { WarehouseConnection } from 'common/types/responses' +import InputGroup from 'components/base/forms/InputGroup' +import Button from 'components/base/forms/Button' +import ErrorMessage from 'components/ErrorMessage' +import SearchableSelect from 'components/base/select/SearchableSelect' + +type CreateWarehouseConnectionModalProps = { + connection?: WarehouseConnection + save: ( + data: Omit, + ) => Promise +} + +const warehouseTypeOptions = [{ label: 'Snowflake', value: 'snowflake' }] + +const getButtonLabel = (isEdit: boolean, isSaving: boolean): string => { + if (isSaving) return isEdit ? 'Saving...' : 'Creating...' + return isEdit ? 'Save Changes' : 'Create Connection' +} + +const CreateWarehouseConnectionModal: FC< + CreateWarehouseConnectionModalProps +> = ({ connection, save }) => { + const isEdit = !!connection + const initialConfig = connection?.config as Record | null + + const [name, setName] = useState(connection?.name ?? '') + const [warehouseType] = useState(connection?.warehouse_type ?? 'snowflake') + const [accountIdentifier, setAccountIdentifier] = useState( + initialConfig?.account_identifier ?? '', + ) + const [warehouse, setWarehouse] = useState( + initialConfig?.warehouse ?? 'COMPUTE_WH', + ) + const [database, setDatabase] = useState( + initialConfig?.database ?? 'FLAGSMITH', + ) + const [schema, setSchema] = useState(initialConfig?.schema ?? 'ANALYTICS') + const [role, setRole] = useState(initialConfig?.role ?? 'FLAGSMITH_LOADER') + const [user, setUser] = useState(initialConfig?.user ?? 'FLAGSMITH_SERVICE') + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(false) + + const isValid = !!name && !!accountIdentifier + + const hasChanges = useMemo(() => { + if (!isEdit) return true + return ( + name !== (connection?.name ?? '') || + accountIdentifier !== (initialConfig?.account_identifier ?? '') || + warehouse !== (initialConfig?.warehouse ?? '') || + database !== (initialConfig?.database ?? '') || + schema !== (initialConfig?.schema ?? '') || + role !== (initialConfig?.role ?? '') || + user !== (initialConfig?.user ?? '') + ) + }, [ + isEdit, + connection, + initialConfig, + name, + accountIdentifier, + warehouse, + database, + schema, + role, + user, + ]) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + if (!isValid || !hasChanges) return + + setIsSaving(true) + setError(false) + try { + await save({ + config: { + account_identifier: accountIdentifier, + database, + role, + schema, + user, + warehouse, + }, + name, + warehouse_type: warehouseType, + }) + closeModal() + toast( + isEdit + ? 'Warehouse connection updated' + : 'Warehouse connection created', + ) + } catch { + setError(true) + setIsSaving(false) + } + } + + return ( +
+ o.value === warehouseType)?.label + } + isSearchable={false} + onChange={() => {}} + /> + } + /> + setName(Utils.safeParseEventValue(e))} + isValid={!!name} + placeholder='e.g. Production Snowflake' + /> + + setAccountIdentifier(Utils.safeParseEventValue(e)) + } + isValid={!!accountIdentifier} + placeholder='e.g. xy12345.us-east-1' + /> + setWarehouse(Utils.safeParseEventValue(e))} + placeholder='COMPUTE_WH' + /> + setDatabase(Utils.safeParseEventValue(e))} + placeholder='FLAGSMITH' + /> + setSchema(Utils.safeParseEventValue(e))} + placeholder='ANALYTICS' + /> + setRole(Utils.safeParseEventValue(e))} + placeholder='FLAGSMITH_LOADER' + /> + setUser(Utils.safeParseEventValue(e))} + placeholder='FLAGSMITH_SERVICE' + /> + {error && ( + + )} +
+ +
+ + ) +} + +CreateWarehouseConnectionModal.displayName = 'CreateWarehouseConnectionModal' +export default CreateWarehouseConnectionModal 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 fbfaa2eb91c0..1413d91b21bd 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 @@ -1,7 +1,8 @@ -import React, { FC, useState } from 'react' +import React, { FC, useRef, useState } from 'react' import { WarehouseConnection, WarehouseConnectionStatus, + WarehouseType, } from 'common/types/responses' import ColorSwatch from 'components/ColorSwatch' import Tooltip from 'components/Tooltip' @@ -10,33 +11,52 @@ import Button from 'components/base/forms/Button' import WarehouseEventCodeHelp from './WarehouseEventCodeHelp' import WarehouseStats from './WarehouseStats' import useCollapsibleHeight from 'common/hooks/useCollapsibleHeight' +import useOutsideClick from 'common/useOutsideClick' type WarehouseConnectionCardProps = { connection: WarehouseConnection onDelete: () => void + onEdit?: () => void } const STATUS_COLOUR: Record = { connected: '#27AE60', + created: '#F2C94C', errored: '#EB5757', pending_connection: '#F2C94C', } const STATUS_LABEL: Record = { connected: 'Connected', + created: 'Created', errored: 'Errored', pending_connection: 'Pending Connection', } +const TYPE_LABEL: Partial> = { + clickhouse: 'ClickHouse', + snowflake: 'Snowflake', +} + +const isSetupStatus = (status: WarehouseConnectionStatus) => + status === 'created' || status === 'pending_connection' + const WarehouseConnectionCard: FC = ({ connection, onDelete, + onEdit, }) => { const [open, setOpen] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + const menuRef = useRef(null) const { contentRef, style: collapsibleStyle } = useCollapsibleHeight(open) - const handleDelete = (e: React.MouseEvent) => { - e.stopPropagation() + useOutsideClick(menuRef as React.RefObject, () => + setMenuOpen(false), + ) + + const handleDelete = () => { + setMenuOpen(false) openConfirm({ body: 'Are you sure you want to remove this warehouse connection?', onYes: onDelete, @@ -44,16 +64,63 @@ const WarehouseConnectionCard: FC = ({ }) } + const handleEdit = () => { + setMenuOpen(false) + onEdit?.() + } + + const displayName = + connection.warehouse_type === 'flagsmith' + ? connection.name + : `${connection.name} (${ + TYPE_LABEL[connection.warehouse_type] ?? connection.warehouse_type + })` + + const renderBody = () => { + if (isSetupStatus(connection.status)) { + if (connection.warehouse_type === 'flagsmith') { + return ( + <> + + + + ) + } + return ( +
+          {'SELECT * FROM users'}
+        
+ ) + } + return ( + + ) + } + return ( -
- + {menuOpen && ( +
+ {onEdit && ( +
{ + e.stopPropagation() + handleEdit() + }} + > + + Edit +
+ )} +
{ + e.stopPropagation() + handleDelete() + }} + > + + Delete +
+
+ )} +
- +
-
- {connection.status === 'pending_connection' ? ( - <> - - - - ) : ( - - )} -
+
{renderBody()}
) 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 37e1505021ac..eaf7acbdbdb7 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 @@ -1,12 +1,16 @@ -import React, { FC } from 'react' +import { FC } from 'react' import { useCreateWarehouseConnectionMutation, useDeleteWarehouseConnectionMutation, useGetWarehouseConnectionsQuery, + useUpdateWarehouseConnectionMutation, } from 'common/services/useWarehouseConnection' +import { WarehouseConnection } from 'common/types/responses' +import Button from 'components/base/forms/Button' import Loader from 'components/Loader' import Setting from 'components/Setting' import WarehouseConnectionCard from './WarehouseConnectionCard' +import CreateWarehouseConnectionModal from './CreateWarehouseConnectionModal' type WarehouseTabProps = { environmentId: string @@ -24,6 +28,64 @@ const WarehouseTab: FC = ({ environmentId }) => { const [createConnection, { isLoading: isCreating }] = useCreateWarehouseConnectionMutation() const [deleteConnection] = useDeleteWarehouseConnectionMutation() + const [updateConnection] = useUpdateWarehouseConnectionMutation() + + const hasFlagsmithConnection = connections?.some( + (c) => c.warehouse_type === 'flagsmith', + ) + const hasConnections = !!connections?.length + + const handleCreateFlagsmith = () => { + createConnection({ + environmentId, + warehouse_type: 'flagsmith', + }) + .unwrap() + .then(() => toast('Warehouse connection created')) + .catch(() => toast('Failed to create warehouse connection', 'danger')) + } + + const handleConfirmFlagsmith = () => { + openConfirm({ + body: 'This will enable a Flagsmith Warehouse connection for this environment. Are you sure you want to proceed?', + onYes: handleCreateFlagsmith, + title: 'Connect Flagsmith Warehouse', + }) + } + + const handleOpenCreateDrawer = () => { + openModal( + 'Connect Warehouse', + createConnection({ environmentId, ...data }).unwrap()} + />, + 'side-modal side-modal--narrow', + ) + } + + const handleOpenEditDrawer = (connection: WarehouseConnection) => { + openModal( + 'Edit Warehouse', + + updateConnection({ + environmentId, + id: connection.id, + ...data, + }).unwrap() + } + />, + 'side-modal side-modal--narrow', + ) + } + + const handleDelete = (connection: WarehouseConnection) => { + deleteConnection({ environmentId, id: connection.id }) + .unwrap() + .then(() => toast('Warehouse connection removed')) + .catch(() => toast('Failed to remove warehouse connection', 'danger')) + } if (isLoading) { return ( @@ -43,53 +105,53 @@ const WarehouseTab: FC = ({ environmentId }) => { ) } - const hasNoConnection = !connections || connections.length === 0 - - if (hasNoConnection) { - return ( -
- - openConfirm({ - body: 'This will enable a Flagsmith Warehouse connection for this environment. Are you sure you want to proceed?', - onYes: () => - createConnection({ - environmentId, - warehouse_type: 'flagsmith', - }) - .unwrap() - .then(() => toast('Warehouse connection created')) - .catch(() => - toast('Failed to create warehouse connection', 'danger'), - ), - title: 'Connect Flagsmith Warehouse', - }) - } - /> -
- ) - } - return ( -
- {connections.map((connection) => ( - - deleteConnection({ environmentId, id: connection.id }) - .unwrap() - .then(() => toast('Warehouse connection removed')) - .catch(() => - toast('Failed to remove warehouse connection', 'danger'), - ) - } - /> - ))} +
+ {!hasFlagsmithConnection && ( +
+ +
+ )} +
+ {hasConnections ? ( + <> +
+ +
+ {connections.map((connection) => ( + handleDelete(connection)} + onEdit={ + connection.warehouse_type !== 'flagsmith' + ? () => handleOpenEditDrawer(connection) + : undefined + } + /> + ))} + + ) : ( +
+
Connect an external Warehouse
+

+ Flagsmith lets you connect your own data warehouse to collect + experimentation data. +

+ +
+ )} +
) } diff --git a/frontend/web/styles/project/_modals.scss b/frontend/web/styles/project/_modals.scss index afcd8d0e1cf3..bc3dc69e3f71 100644 --- a/frontend/web/styles/project/_modals.scss +++ b/frontend/web/styles/project/_modals.scss @@ -253,6 +253,12 @@ $side-width: 800px; display: none !important; } +@include media-breakpoint-up(md) { + .side-modal--narrow .modal-dialog { + width: 640px !important; + } +} + @media (max-width: 600px) { /* portrait tablets, portrait iPad, e-readers (Nook/Kindle), landscape 800x480 phones (Android) */ .side-modal__footer {