From dc765e1dd88b4a727dad907d2a8de16338485401 Mon Sep 17 00:00:00 2001 From: wicky <130177258+wicky-zipstack@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:23:31 +0530 Subject: [PATCH 01/12] feat: redesign Connections page with rich table and stepped Drawer List page: - Rich table: DB icon in square tile, name + type tag + description, status (Healthy/Stale/Error), Used by (env/project counts with tooltip), Last modified, Actions (test/edit/delete inline) - Filter bar: search, database type, status dropdowns with clear + count ConnectionDrawer (new component): - Step 1: DB picker grid with real logos from API - Step 2: Name + description with validation - Step 3: Credentials with DB-specific fields via RJSF - Segmented toggle for Individual fields / Connection URL - Grid layout: host+port, user+password, database+schema side-by-side - Custom ObjectFieldTemplate with HALF_WIDTH_FIELDS set - Inline test result: success + error with collapsible View details - Friendly validation messages (Please enter Host, not raw Ajv) - Hidden ErrorList summary (inline errors only) - Drawer scoped below topbar, ESC/mask-click disabled - Reveal credentials button on URL toggle row --- .../connection/ConnectionDrawer.jsx | 915 ++++++++++++++++++ .../components/connection/ConnectionList.css | 157 +++ .../components/connection/ConnectionList.jsx | 762 ++++++++++----- 3 files changed, 1588 insertions(+), 246 deletions(-) create mode 100644 frontend/src/base/components/connection/ConnectionDrawer.jsx create mode 100644 frontend/src/base/components/connection/ConnectionList.css diff --git a/frontend/src/base/components/connection/ConnectionDrawer.jsx b/frontend/src/base/components/connection/ConnectionDrawer.jsx new file mode 100644 index 00000000..a206acd5 --- /dev/null +++ b/frontend/src/base/components/connection/ConnectionDrawer.jsx @@ -0,0 +1,915 @@ +/* eslint-disable react/prop-types */ +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import Cookies from "js-cookie"; +import { + Drawer, + Form, + Input, + Button, + Space, + Typography, + Alert, + Divider, + Tag, + Row, + Col, + Card, + Tooltip, + Segmented, + Empty, +} from "antd"; +import { + LinkOutlined, + DatabaseOutlined, + SafetyCertificateOutlined, + ThunderboltOutlined, + CheckCircleFilled, + LockOutlined, + EyeOutlined, +} from "@ant-design/icons"; +import RjsfForm from "@rjsf/antd"; +import validator from "@rjsf/validator-ajv8"; + +import { useAxiosPrivate } from "../../../service/axios-service"; +import { orgStore } from "../../../store/org-store"; +import encryptionService from "../../../service/encryption-service"; +import { + fetchDataSources, + fetchDataSourceFields, + createConnectionApi, + updateConnectionApi, + fetchSingleConnection, + revealConnectionCredentials, + testConnectionApi, +} from "../environment/environment-api-service"; +import { useNotificationService } from "../../../service/notification-service"; +import { + validateFormFieldName, + validateFormFieldDescription, + collapseSpaces, +} from "../environment/helper"; +import { SpinnerLoader } from "../../../widgets/spinner_loader"; + +const { Text } = Typography; +const { TextArea } = Input; + +/* ── Fields that should render half-width (side-by-side) ── */ +const HALF_WIDTH_FIELDS = new Set([ + "host", + "port", + "user", + "passw", + "account", + "warehouse", + "catalog", + "schema", + "dbname", + "database", + "project_id", + "dataset_id", + "token", +]); + +/* ── Custom ObjectFieldTemplate for grid layout ── */ +const GridObjectFieldTemplate = (props) => ( +
+ {props.properties.map((prop) => { + const fieldName = prop.name; + const isHalf = HALF_WIDTH_FIELDS.has(fieldName); + return ( +
+ {prop.content} +
+ ); + })} +
+); + +/* ── DB Tile component — uses real logo from API ── */ +const DBTile = ({ db, isActive, isDisabled, onClick }) => ( +
+
+ {db.label} +
+
{db.label}
+
+); + +const ConnectionDrawer = ({ + open, + onClose, + connectionId, + onSaved, + getContainer, +}) => { + const axiosRef = useAxiosPrivate(); + const { selectedOrgId } = orgStore(); + const csrfToken = Cookies.get("csrftoken"); + const { notify } = useNotificationService(); + const [form] = Form.useForm(); + + // Data sources + const [dataSources, setDataSources] = useState([]); + const [dsLoading, setDsLoading] = useState(false); + + // Selected DB and fields + const [selectedDb, setSelectedDb] = useState(""); + const [connectionDetails, setConnectionDetails] = useState({}); + const [inputFields, setInputFields] = useState({}); + const [connType, setConnType] = useState("host"); + + // Edit mode + const [originalInfo, setOriginalInfo] = useState(null); + const [isCredentialsRevealed, setIsCredentialsRevealed] = useState(false); + const [isRevealLoading, setIsRevealLoading] = useState(false); + + // Schema for RJSF + const [schema, setSchema] = useState(null); + const [uiSchema, setUiSchema] = useState({}); + + // Actions + const [isTestLoading, setIsTestLoading] = useState(false); + const [isTestSuccess, setIsTestSuccess] = useState(false); + const [testError, setTestError] = useState(null); + const [showErrorDetail, setShowErrorDetail] = useState(false); + const [isSaveLoading, setIsSaveLoading] = useState(false); + const [isEncryptionLoading, setIsEncryptionLoading] = useState(true); + + const hasCapturedOriginalRef = useRef(false); + const [originalConnectionData, setOriginalConnectionData] = useState({}); + + const isEditing = Boolean(connectionId); + + /* ── Init encryption ── */ + useEffect(() => { + if (!open) return; + const init = async () => { + setIsEncryptionLoading(true); + try { + await encryptionService.initialize(selectedOrgId || "default_org"); + } catch { + // Encryption unavailable — proceed without + } finally { + setIsEncryptionLoading(false); + } + }; + init(); + }, [open, selectedOrgId]); + + /* ── Fetch datasources ── */ + useEffect(() => { + if (!open) return; + const load = async () => { + setDsLoading(true); + try { + const ds = await fetchDataSources(axiosRef, selectedOrgId); + setDataSources(ds); + if (!connectionId && ds.length > 0 && !selectedDb) { + setSelectedDb(ds[0].value); + } + } catch (error) { + notify({ error }); + } finally { + setDsLoading(false); + } + }; + load(); + }, [open, selectedOrgId]); + + /* ── Fetch field schema when DB changes ── */ + useEffect(() => { + if (!selectedDb || !open) return; + const load = async () => { + try { + const details = await fetchDataSourceFields( + axiosRef, + selectedOrgId, + selectedDb + ); + setConnectionDetails(details); + } catch (error) { + notify({ error }); + } + }; + load(); + }, [selectedDb, selectedOrgId, open]); + + /* ── Build RJSF schema from connectionDetails + connType ── */ + useEffect(() => { + if (Object.keys(connectionDetails).length === 0) { + setSchema(null); + return; + } + if (["postgres", "snowflake"].includes(selectedDb)) { + const updatedProperties = { ...connectionDetails.properties }; + delete updatedProperties["connection_type"]; + const updatedRequired = + connType === "url" + ? ["connection_url"] + : connectionDetails?.required?.filter( + (el) => + !["connection_url", "schema", "connection_type"].includes(el) + ); + setSchema({ + type: "object", + properties: updatedProperties, + required: updatedRequired, + }); + const ui = {}; + Object.keys(updatedProperties).forEach((key) => { + ui[key] = { + "ui:disabled": + connType === "url" + ? key !== "connection_url" + : key === "connection_url", + }; + }); + setUiSchema({ ...ui, schema: { "ui:disabled": false } }); + } else { + setSchema(connectionDetails); + setUiSchema({}); + } + }, [connectionDetails, connType, selectedDb]); + + /* ── Load existing connection for edit ── */ + useEffect(() => { + if (!connectionId || !open) return; + hasCapturedOriginalRef.current = false; + const load = async () => { + try { + const data = await fetchSingleConnection( + axiosRef, + selectedOrgId, + connectionId + ); + const { name, description, datasource_name, connection_details } = data; + setSelectedDb(datasource_name); + setOriginalInfo({ name: collapseSpaces(name || ""), description }); + form.setFieldsValue({ name, description }); + + const processed = { ...connection_details }; + if ( + datasource_name === "bigquery" && + processed.credentials && + typeof processed.credentials === "object" + ) { + processed.credentials = JSON.stringify( + processed.credentials, + null, + 2 + ); + } + setInputFields(processed); + if (["postgres", "snowflake"].includes(datasource_name)) { + setConnType(connection_details.connection_type || "host"); + } + setIsCredentialsRevealed(false); + } catch (error) { + notify({ error }); + } + }; + load(); + }, [connectionId, open]); + + /* ── Capture original data for change detection ── */ + useEffect(() => { + if ( + connectionId && + Object.keys(inputFields).length > 0 && + !hasCapturedOriginalRef.current + ) { + setOriginalConnectionData(JSON.parse(JSON.stringify(inputFields))); + hasCapturedOriginalRef.current = true; + } else if (!connectionId) { + setOriginalConnectionData({}); + hasCapturedOriginalRef.current = false; + } + }, [connectionId, inputFields]); + + /* ── Reset on close ── */ + useEffect(() => { + if (!open) { + form.resetFields(); + setSelectedDb(""); + setInputFields({}); + setConnectionDetails({}); + setSchema(null); + setIsTestSuccess(false); + setTestError(null); + setShowErrorDetail(false); + setOriginalInfo(null); + setIsCredentialsRevealed(false); + hasCapturedOriginalRef.current = false; + setOriginalConnectionData({}); + } + }, [open]); + + /* ── Has credential data changed? ── */ + const hasCredChanges = useMemo(() => { + if (!connectionId) { + return Object.values(inputFields).some( + (v) => v !== undefined && v !== null && v !== "" + ); + } + if (Object.keys(originalConnectionData).length === 0) return false; + const curr = { ...inputFields }; + const orig = { ...originalConnectionData }; + delete curr.connection_type; + delete orig.connection_type; + return JSON.stringify(curr) !== JSON.stringify(orig); + }, [connectionId, inputFields, originalConnectionData]); + + const hasDetailsChanged = useMemo(() => { + if (!connectionId || !originalInfo) return false; + const formVals = form.getFieldsValue(); + return ( + formVals.name !== originalInfo.name || + formVals.description !== originalInfo.description + ); + }, [connectionId, originalInfo, form]); + + const hasValidData = useMemo(() => { + return Object.values(inputFields).some( + (v) => v !== undefined && v !== null && v !== "" + ); + }, [inputFields]); + + /* ── Reveal credentials ── */ + const handleReveal = useCallback(async () => { + if (!connectionId || isCredentialsRevealed) return; + setIsRevealLoading(true); + try { + const creds = await revealConnectionCredentials( + axiosRef, + selectedOrgId, + connectionId + ); + const processed = { ...creds }; + if ( + selectedDb === "bigquery" && + processed.credentials && + typeof processed.credentials === "object" + ) { + processed.credentials = JSON.stringify(processed.credentials, null, 2); + } + setInputFields(processed); + setIsCredentialsRevealed(true); + } catch (error) { + notify({ error }); + } finally { + setIsRevealLoading(false); + } + }, [connectionId, selectedOrgId, isCredentialsRevealed, selectedDb]); + + /* ── Test connection ── */ + const handleTest = useCallback(async () => { + setIsTestLoading(true); + setIsTestSuccess(false); + setTestError(null); + setShowErrorDetail(false); + try { + const testData = { + ...inputFields, + ...(["postgres", "snowflake"].includes(selectedDb) && { + schema: inputFields.schema || "", + connection_type: connType, + }), + }; + const data = encryptionService.isAvailable() + ? await encryptionService.encryptSensitiveFields(testData) + : testData; + await testConnectionApi( + axiosRef, + selectedOrgId, + csrfToken, + selectedDb, + data, + connectionId || null + ); + setIsTestSuccess(true); + } catch (error) { + const errorData = error?.response?.data; + const errMsg = + errorData?.error_message || + errorData?.message || + errorData?.error || + error?.message || + "Connection test failed"; + const statusCode = error?.response?.status; + setTestError({ + summary: statusCode ? `Error ${statusCode}` : "Connection test failed", + detail: errMsg, + }); + } finally { + setIsTestLoading(false); + } + }, [ + inputFields, + selectedDb, + connType, + selectedOrgId, + csrfToken, + connectionId, + ]); + + /* ── Save connection ── */ + const handleSave = useCallback(async () => { + try { + await form.validateFields(); + } catch { + return; + } + setIsSaveLoading(true); + try { + const { name, description } = form.getFieldsValue(); + const payload = { + datasource_name: selectedDb, + name, + description, + connection_details: { + ...inputFields, + ...(["postgres", "snowflake"].includes(selectedDb) && { + schema: inputFields.schema || "", + connection_type: connType, + }), + }, + ...(connectionId && + hasDetailsChanged && + !hasCredChanges && { metadata_only: true }), + }; + if (encryptionService.isAvailable()) { + payload.connection_details = + await encryptionService.encryptSensitiveFields( + payload.connection_details + ); + } + if (!connectionId) { + const res = await createConnectionApi( + axiosRef, + selectedOrgId, + csrfToken, + payload + ); + if (res.status === 200) { + notify({ + type: "success", + message: "Connection created successfully", + }); + onSaved?.(); + onClose(); + } + } else { + const res = await updateConnectionApi( + axiosRef, + selectedOrgId, + csrfToken, + connectionId, + payload + ); + if (res.status === 200) { + notify({ + type: "success", + message: "Connection updated successfully", + }); + onSaved?.(); + onClose(); + } + } + } catch (error) { + notify({ error }); + } finally { + setIsSaveLoading(false); + } + }, [ + form, + selectedDb, + inputFields, + connType, + connectionId, + hasDetailsChanged, + hasCredChanges, + selectedOrgId, + csrfToken, + ]); + + /* ── RJSF handlers ── */ + const handleFieldChange = ({ formData }) => { + setInputFields(formData); + if (isTestSuccess) setIsTestSuccess(false); + if (testError) { + setTestError(null); + setShowErrorDetail(false); + } + }; + + const handleFieldSubmit = ({ formData }) => { + setInputFields(formData); + handleTest(); + }; + + const handleConnTypeChange = (value) => { + setConnType(value); + if (!connectionId) setInputFields({}); + }; + + /* ── Derive selected DB info ── */ + const selectedDbInfo = dataSources.find((d) => d.value === selectedDb); + const dbLabel = + selectedDbInfo?.label || + selectedDb.charAt(0).toUpperCase() + selectedDb.slice(1); + + const canSave = + isTestSuccess || (connectionId && hasDetailsChanged && !hasCredChanges); + + return ( + + + {isEditing ? "Edit Connection" : "New Connection"} + + } + width={640} + open={open} + onClose={onClose} + destroyOnClose + keyboard={false} + maskClosable={false} + getContainer={getContainer} + className="conn-drawer" + footer={ + + + {isTestSuccess && ( + + + Tested + + )} + + + + + + + + + } + > +
+ {/* ── STEP 1: Database picker ── */} +
+ 1.{" "} + {isEditing ? ( + + Database{" "} + + · Locked after creation + + + ) : ( + "Pick your database" + )} +
+ {!isEditing && ( + + The fields below will adjust based on your choice. + + )} + {isEditing && ( + } + message={ + + Database can't be changed after creation.{" "} + Create a new connection for a different database. + + } + style={{ marginBottom: 14 }} + /> + )} + {dsLoading ? ( + + ) : ( +
+ {dataSources.map((db) => { + const isActive = selectedDb === db.value; + const isDisabled = isEditing && !isActive; + return ( + { + if (!isEditing) { + setSelectedDb(db.value); + setInputFields({}); + setIsTestSuccess(false); + } + }} + /> + ); + })} +
+ )} + + + + {/* ── STEP 2: Name & Describe ── */} +
2. Name & describe
+ + + } + /> + + +