From 6aef573304aa8fa5ac0fc7d8b97fe97b74fd1902 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 31 Dec 2025 17:23:13 +0100 Subject: [PATCH 01/16] feat(database): add validation loading state and duplicate name check - Add isValidating prop to TableCatalog, ValidatedInputField, and CommonParameters to show loading spinner during validation - Fix LabeledErrorBoundInput hasFeedback to display spinner while validating, not just on errors - Add duplicate database name validation to validate_parameters endpoint for real-time feedback before form submission --- .../Form/LabeledErrorBoundInput.tsx | 2 +- .../CommonParameters.tsx | 2 ++ .../DatabaseConnectionForm/TableCatalog.tsx | 3 +++ .../ValidatedInputField.tsx | 2 ++ .../databases/DatabaseModal/index.tsx | 17 ++++++++----- superset/commands/database/validate.py | 24 ++++++++++++++++++- 6 files changed, 42 insertions(+), 8 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/Form/LabeledErrorBoundInput.tsx b/superset-frontend/packages/superset-ui-core/src/components/Form/LabeledErrorBoundInput.tsx index b8f1dc1ef49b..b2f1f5a34fff 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Form/LabeledErrorBoundInput.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Form/LabeledErrorBoundInput.tsx @@ -79,7 +79,7 @@ export const LabeledErrorBoundInput = ({ isValidating ? 'validating' : hasError ? 'error' : 'success' } help={errorMessage || helpText} - hasFeedback={!!hasError} + hasFeedback={isValidating || !!hasError} > {visibilityToggle || props.name === 'password' ? ( ( @@ -250,6 +251,7 @@ export const accessTokenField = ({ id="access_token" name="access_token" required={required} + isValidating={isValidating} visibilityToggle={!isEditMode} value={db?.parameters?.access_token} validationMethods={{ onBlur: getValidation }} diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx index 4798f5f3c028..f372291369d5 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx @@ -33,6 +33,7 @@ export const TableCatalog = ({ getValidation, validationErrors, db, + isValidating, }: FieldPropTypes) => { const tableCatalog = db?.catalog || []; const catalogError = validationErrors || {}; @@ -51,6 +52,7 @@ export const TableCatalog = ({ ( = ({ [onChange], ); + const handleTextChange = useCallback( + ({ target }: { target: HTMLInputElement }) => { + onChange(ActionType.TextChange, { + name: target.name, + value: target.value, + }); + }, + [onChange], + ); + const handleChangeWithValidation = useCallback( ( actionType: ActionType, @@ -1796,12 +1806,7 @@ const DatabaseModal: FunctionComponent = ({ }); }} onParametersChange={handleParametersChange} - onChange={({ target }: { target: HTMLInputElement }) => - handleChangeWithValidation(ActionType.TextChange, { - name: target.name, - value: target.value, - }) - } + onChange={handleTextChange} getValidation={() => getValidation(db)} validationErrors={validationErrors} getPlaceholder={getPlaceholder} diff --git a/superset/commands/database/validate.py b/superset/commands/database/validate.py index 29c9497140b5..df431076af73 100644 --- a/superset/commands/database/validate.py +++ b/superset/commands/database/validate.py @@ -139,5 +139,27 @@ def run(self) -> None: ) def validate(self) -> None: - if (database_id := self._properties.get("id")) is not None: + database_id = self._properties.get("id") + database_name = self._properties.get("database_name") + + if database_id is not None: self._model = DatabaseDAO.find_by_id(database_id) + + # Check for duplicate database name + if database_name: + is_unique = ( + DatabaseDAO.validate_update_uniqueness(database_id, database_name) + if database_id is not None + else DatabaseDAO.validate_uniqueness(database_name) + ) + if not is_unique: + raise InvalidParametersError( + [ + SupersetError( + message=__("A database with the same name already exists."), + error_type=SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR, + level=ErrorLevel.ERROR, + extra={"invalid": ["database_name"]}, + ) + ] + ) From ad92ec683becd3d8cc4a4508b3de8639c4553a34 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 31 Dec 2025 17:35:50 +0100 Subject: [PATCH 02/16] wip: add validation loading state to SSH tunnel form fields --- .../databases/DatabaseModal/SSHTunnelForm.tsx | 140 +++++++++--------- .../databases/DatabaseModal/index.tsx | 4 +- 2 files changed, 77 insertions(+), 67 deletions(-) diff --git a/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx b/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx index 79e2a6fab7dd..3df9ddd2a203 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx @@ -17,19 +17,18 @@ * under the License. */ import { useState } from 'react'; -import { t } from '@superset-ui/core'; +import { t, JsonObject } from '@superset-ui/core'; import { styled } from '@apache-superset/core/ui'; import { Form, FormLabel, Col, Row, - Tooltip, + LabeledErrorBoundInput, } from '@superset-ui/core/components'; import { Input } from '@superset-ui/core/components/Input'; import { Radio } from '@superset-ui/core/components/Radio'; -import { Icons } from '@superset-ui/core/components/Icons'; -import { DatabaseObject, FieldPropTypes } from '../types'; +import { DatabaseObject, CustomEventHandlerType } from '../types'; import { AuthType } from '.'; const StyledDiv = styled.div` @@ -48,50 +47,60 @@ const StyledFormItem = styled(Form.Item)` margin-bottom: 0 !important; `; -const StyledInputPassword = styled(Input.Password)` - margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`}; -`; +interface SSHTunnelFormProps { + db: DatabaseObject | null; + onSSHTunnelParametersChange: CustomEventHandlerType; + setSSHTunnelLoginMethod: (method: AuthType) => void; + isValidating?: boolean; + validationErrors?: JsonObject | null; + getValidation?: () => void; +} const SSHTunnelForm = ({ db, onSSHTunnelParametersChange, setSSHTunnelLoginMethod, -}: { - db: DatabaseObject | null; - onSSHTunnelParametersChange: FieldPropTypes['changeMethods']['onSSHTunnelParametersChange']; - setSSHTunnelLoginMethod: (method: AuthType) => void; -}) => { + isValidating = false, + validationErrors, + getValidation, +}: SSHTunnelFormProps) => { const [usePassword, setUsePassword] = useState(AuthType.Password); + const sshErrors = validationErrors?.ssh_tunnel || {}; return (
- - {t('SSH Host')} - - - - {t('SSH Port')} - - @@ -100,15 +109,17 @@ const SSHTunnelForm = ({ - - {t('Username')} - - @@ -148,27 +159,19 @@ const SSHTunnelForm = ({ - - {t('SSH Password')} - - - visible ? ( - - - - ) : ( - - - - ) - } - role="textbox" /> @@ -182,41 +185,46 @@ const SSHTunnelForm = ({ {t('Private Key')} - + + + - - {t('Private Key Password')} - - - visible ? ( - - - - ) : ( - - - - ) - } - role="textbox" /> diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index 425d86768992..f77ce16db9bd 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -1730,7 +1730,6 @@ const DatabaseModal: FunctionComponent = ({ name: target.name, value: target.value, }); - handleClearValidationErrors(); }} setSSHTunnelLoginMethod={(method: AuthType) => setDB({ @@ -1738,6 +1737,9 @@ const DatabaseModal: FunctionComponent = ({ payload: { login_method: method }, }) } + isValidating={isValidating} + validationErrors={validationErrors} + getValidation={() => getValidation(db)} /> ); From a44b8a6cf00d8b331211d7aa6f1cba3eb72416a8 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Fri, 2 Jan 2026 17:53:01 +0100 Subject: [PATCH 03/16] feat(database): add SSH tunnel validation to database parameters endpoint - Remove early return in frontend validation hook when SSH is enabled, allowing backend validation to run for all database parameters - Add SSH tunnel field validation in ValidateDatabaseParametersCommand to validate server_address, server_port, username, and credentials - Add DatabaseSSHTunnelValidation schema for partial SSH tunnel data validation without strict authentication requirements - Add ssh_tunnel field to DatabaseValidateParametersSchema - Parse SSH tunnel errors in frontend and display under ssh_tunnel key - Collect database_name duplicate errors alongside parameter errors --- .../databases/DatabaseModal/SSHTunnelForm.tsx | 2 +- superset-frontend/src/views/CRUD/hooks.ts | 20 ++- superset/commands/database/validate.py | 125 +++++++++++++++--- superset/databases/schemas.py | 18 +++ 4 files changed, 139 insertions(+), 26 deletions(-) diff --git a/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx b/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx index 3df9ddd2a203..6a80d94ba86e 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx @@ -53,7 +53,7 @@ interface SSHTunnelFormProps { setSSHTunnelLoginMethod: (method: AuthType) => void; isValidating?: boolean; validationErrors?: JsonObject | null; - getValidation?: () => void; + getValidation: () => void; } const SSHTunnelForm = ({ diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index 5a5721205eec..9da074798bef 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -765,13 +765,6 @@ export function useDatabaseValidation() { const getValidation = useCallback( async (database: Partial | null, onCreate = false) => { - if (database?.parameters?.ssh) { - setValidationErrors(null); - setIsValidating(false); - setHasValidated(true); - return Promise.resolve([]); - } - setIsValidating(true); try { @@ -809,6 +802,19 @@ export function useDatabaseValidation() { return acc; } + // Handle SSH tunnel errors + if (extra?.ssh_tunnel) { + if (!acc.ssh_tunnel) { + acc.ssh_tunnel = {}; + } + if (extra?.missing) { + extra.missing.forEach((field: string) => { + acc.ssh_tunnel[field] = 'This is a required field'; + }); + } + return acc; + } + if (extra?.invalid) { extra.invalid.forEach((field: string) => { acc[field] = message; diff --git a/superset/commands/database/validate.py b/superset/commands/database/validate.py index df431076af73..7edc2d0a13d4 100644 --- a/superset/commands/database/validate.py +++ b/superset/commands/database/validate.py @@ -19,6 +19,7 @@ from flask_babel import gettext as __ +from superset import is_feature_enabled from superset.commands.base import BaseCommand from superset.commands.database.exceptions import ( DatabaseOfflineError, @@ -26,6 +27,10 @@ InvalidEngineError, InvalidParametersError, ) +from superset.commands.database.ssh_tunnel.exceptions import ( + SSHTunnelDatabasePortError, + SSHTunnelingNotEnabledError, +) from superset.daos.database import DatabaseDAO from superset.databases.utils import make_url_safe from superset.db_engine_specs import get_engine_spec @@ -42,7 +47,7 @@ def __init__(self, properties: dict[str, Any]): self._properties = properties.copy() self._model: Optional[Database] = None - def run(self) -> None: + def run(self) -> None: # noqa: C901 self.validate() engine = self._properties["engine"] @@ -50,6 +55,8 @@ def run(self) -> None: if engine in BYPASS_VALIDATION_ENGINES: # Skip engines that are only validated onCreate + # But still validate database_name uniqueness + self._validate_database_name() return engine_spec = get_engine_spec(engine, driver) @@ -65,8 +72,17 @@ def run(self) -> None: ), ) - # perform initial validation + # perform initial validation (host, port, database, username) errors = engine_spec.validate_parameters(self._properties) # type: ignore + + # Collect database_name errors along with parameter errors + if database_name_error := self._get_database_name_error(): + errors.append(database_name_error) + + # Collect SSH tunnel errors + ssh_tunnel_errors = self._get_ssh_tunnel_errors() + errors.extend(ssh_tunnel_errors) + if errors: event_logger.log_with_context(action="validation_error", engine=engine) raise InvalidParametersError(errors) @@ -138,28 +154,101 @@ def run(self) -> None: ), ) - def validate(self) -> None: - database_id = self._properties.get("id") - database_name = self._properties.get("database_name") - - if database_id is not None: + def _load_model(self) -> None: + """Load the existing database model if updating.""" + if (database_id := self._properties.get("id")) is not None: self._model = DatabaseDAO.find_by_id(database_id) - # Check for duplicate database name - if database_name: + def _get_database_name_error(self) -> Optional[SupersetError]: + """Check for duplicate database name and return error if found.""" + database_id = self._properties.get("id") + + if database_name := self._properties.get("database_name"): is_unique = ( DatabaseDAO.validate_update_uniqueness(database_id, database_name) if database_id is not None else DatabaseDAO.validate_uniqueness(database_name) ) if not is_unique: - raise InvalidParametersError( - [ - SupersetError( - message=__("A database with the same name already exists."), - error_type=SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR, - level=ErrorLevel.ERROR, - extra={"invalid": ["database_name"]}, - ) - ] + return SupersetError( + message=__("A database with the same name already exists."), + error_type=SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR, + level=ErrorLevel.ERROR, + extra={"invalid": ["database_name"]}, + ) + return None + + def _validate_database_name(self) -> None: + """Check for duplicate database name and raise if found.""" + if error := self._get_database_name_error(): + raise InvalidParametersError([error]) + + def validate(self) -> None: + """Load the model and validate SSH tunnel if enabled.""" + self._load_model() + self._validate_ssh_tunnel() + + def _validate_ssh_tunnel(self) -> None: + """Validate SSH tunnel configuration if enabled.""" + ssh_tunnel = self._properties.get("ssh_tunnel") + if ssh_tunnel: + if not is_feature_enabled("SSH_TUNNELING"): + raise SSHTunnelingNotEnabledError() + # Check if port is provided (required for SSH tunneling) + parameters = self._properties.get("parameters", {}) + if not parameters.get("port"): + raise SSHTunnelDatabasePortError() + + def _get_ssh_tunnel_errors(self) -> list[SupersetError]: + """Validate SSH tunnel fields and return list of errors.""" + errors: list[SupersetError] = [] + ssh_tunnel = self._properties.get("ssh_tunnel") or {} + parameters = self._properties.get("parameters", {}) + + # Check if SSH is enabled via parameters.ssh flag + ssh_enabled = parameters.get("ssh", False) + + # Only validate SSH tunnel if SSH is enabled or ssh_tunnel is provided + if not ssh_enabled and not ssh_tunnel: + return errors + + # Required fields + required_fields = ["server_address", "server_port", "username"] + missing = [f for f in required_fields if not ssh_tunnel.get(f)] + + if missing: + errors.append( + SupersetError( + message=__("One or more parameters are missing: %(missing)s"), + error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR, + level=ErrorLevel.WARNING, + extra={"missing": missing, "ssh_tunnel": True}, + ) + ) + + # Either password or private_key is required + has_password = bool(ssh_tunnel.get("password")) + has_private_key = bool(ssh_tunnel.get("private_key")) + + if not has_password and not has_private_key: + errors.append( + SupersetError( + message=__("Must provide credentials for the SSH Tunnel"), + error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR, + level=ErrorLevel.WARNING, + extra={"missing": ["password"], "ssh_tunnel": True}, ) + ) + + # If private_key is provided, private_key_password is required + if has_private_key and not ssh_tunnel.get("private_key_password"): + errors.append( + SupersetError( + message=__("One or more parameters are missing: %(missing)s"), + error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR, + level=ErrorLevel.WARNING, + extra={"missing": ["private_key_password"], "ssh_tunnel": True}, + ) + ) + + return errors diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py index 105496efa4c6..2bddb0eb4775 100644 --- a/superset/databases/schemas.py +++ b/superset/databases/schemas.py @@ -433,6 +433,24 @@ class Meta: # pylint: disable=too-few-public-methods required=True, metadata={"description": configuration_method_description}, ) + ssh_tunnel = fields.Nested("DatabaseSSHTunnelValidation", allow_none=True) + + +class DatabaseSSHTunnelValidation(Schema): + """SSH Tunnel schema for validation. + + Allows partial data without strict authentication requirements. + """ + + id = fields.Integer( + allow_none=True, metadata={"description": "SSH Tunnel ID (for updates)"} + ) + server_address = fields.String(allow_none=True) + server_port = fields.Integer(allow_none=True) + username = fields.String(allow_none=True) + password = fields.String(required=False, allow_none=True) + private_key = fields.String(required=False, allow_none=True) + private_key_password = fields.String(required=False, allow_none=True) class DatabaseSSHTunnel(Schema): From 899ecf82140e2b981e2dff304183814893952141 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Tue, 20 Jan 2026 11:16:06 +0100 Subject: [PATCH 04/16] fix: update RTL tests to match new behavior --- .../databases/DatabaseModal/index.test.tsx | 87 ++++++++++++++----- 1 file changed, 63 insertions(+), 24 deletions(-) diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx index 9b2b967e2be0..e5d5e94b59e0 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx @@ -1216,26 +1216,40 @@ describe('DatabaseModal', () => { 'ssh-tunnel-server_address-input', ); expect(SSHTunnelServerAddressInput).toHaveValue(''); - userEvent.type(SSHTunnelServerAddressInput, 'localhost'); - expect(SSHTunnelServerAddressInput).toHaveValue('localhost'); + fireEvent.change(SSHTunnelServerAddressInput, { + target: { value: 'localhost' }, + }); + await waitFor(() => + expect(SSHTunnelServerAddressInput).toHaveValue('localhost'), + ); const SSHTunnelServerPortInput = screen.getByTestId( 'ssh-tunnel-server_port-input', ); expect(SSHTunnelServerPortInput).toHaveValue(null); - userEvent.type(SSHTunnelServerPortInput, '22'); - expect(SSHTunnelServerPortInput).toHaveValue(22); + fireEvent.change(SSHTunnelServerPortInput, { + target: { value: '22' }, + }); + await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22)); const SSHTunnelUsernameInput = screen.getByTestId( 'ssh-tunnel-username-input', ); expect(SSHTunnelUsernameInput).toHaveValue(''); - userEvent.type(SSHTunnelUsernameInput, 'test'); - expect(SSHTunnelUsernameInput).toHaveValue('test'); + fireEvent.change(SSHTunnelUsernameInput, { + target: { value: 'test' }, + }); + await waitFor(() => + expect(SSHTunnelUsernameInput).toHaveValue('test'), + ); const SSHTunnelPasswordInput = screen.getByTestId( 'ssh-tunnel-password-input', ); expect(SSHTunnelPasswordInput).toHaveValue(''); - userEvent.type(SSHTunnelPasswordInput, 'pass'); - expect(SSHTunnelPasswordInput).toHaveValue('pass'); + fireEvent.change(SSHTunnelPasswordInput, { + target: { value: 'pass' }, + }); + await waitFor(() => + expect(SSHTunnelPasswordInput).toHaveValue('pass'), + ); }); test('properly interacts with SSH Tunnel form textboxes', async () => { @@ -1254,26 +1268,40 @@ describe('DatabaseModal', () => { 'ssh-tunnel-server_address-input', ); expect(SSHTunnelServerAddressInput).toHaveValue(''); - userEvent.type(SSHTunnelServerAddressInput, 'localhost'); - expect(SSHTunnelServerAddressInput).toHaveValue('localhost'); + fireEvent.change(SSHTunnelServerAddressInput, { + target: { value: 'localhost' }, + }); + await waitFor(() => + expect(SSHTunnelServerAddressInput).toHaveValue('localhost'), + ); const SSHTunnelServerPortInput = screen.getByTestId( 'ssh-tunnel-server_port-input', ); expect(SSHTunnelServerPortInput).toHaveValue(null); - userEvent.type(SSHTunnelServerPortInput, '22'); - expect(SSHTunnelServerPortInput).toHaveValue(22); + fireEvent.change(SSHTunnelServerPortInput, { + target: { value: '22' }, + }); + await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22)); const SSHTunnelUsernameInput = screen.getByTestId( 'ssh-tunnel-username-input', ); expect(SSHTunnelUsernameInput).toHaveValue(''); - userEvent.type(SSHTunnelUsernameInput, 'test'); - expect(SSHTunnelUsernameInput).toHaveValue('test'); + fireEvent.change(SSHTunnelUsernameInput, { + target: { value: 'test' }, + }); + await waitFor(() => + expect(SSHTunnelUsernameInput).toHaveValue('test'), + ); const SSHTunnelPasswordInput = screen.getByTestId( 'ssh-tunnel-password-input', ); expect(SSHTunnelPasswordInput).toHaveValue(''); - userEvent.type(SSHTunnelPasswordInput, 'pass'); - expect(SSHTunnelPasswordInput).toHaveValue('pass'); + fireEvent.change(SSHTunnelPasswordInput, { + target: { value: 'pass' }, + }); + await waitFor(() => + expect(SSHTunnelPasswordInput).toHaveValue('pass'), + ); }); test('if the SSH Tunneling toggle is not true, no inputs are displayed', async () => { @@ -1368,7 +1396,10 @@ describe('DatabaseModal', () => { }), ); - const textboxes = screen.getAllByRole('textbox'); + // Wait for step 2 to render + expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument(); + + const textboxes = await screen.findAllByRole('textbox'); const hostField = textboxes[0]; const portField = screen.getByRole('spinbutton'); const databaseNameField = textboxes[1]; @@ -1384,15 +1415,20 @@ describe('DatabaseModal', () => { expect(connectButton).toBeDisabled(); - userEvent.type(hostField, 'localhost'); - userEvent.type(portField, '5432'); - userEvent.type(databaseNameField, 'postgres'); - userEvent.type(usernameField, 'testdb'); - userEvent.type(passwordField, 'demoPassword'); + fireEvent.change(hostField, { target: { value: 'localhost' } }); + fireEvent.blur(hostField); + fireEvent.change(portField, { target: { value: '5432' } }); + fireEvent.blur(portField); + fireEvent.change(databaseNameField, { target: { value: 'postgres' } }); + fireEvent.blur(databaseNameField); + fireEvent.change(usernameField, { target: { value: 'testdb' } }); + fireEvent.blur(usernameField); + fireEvent.change(passwordField, { target: { value: 'demoPassword' } }); + fireEvent.blur(passwordField); await waitFor(() => expect(connectButton).toBeEnabled()); - expect(await screen.findByDisplayValue(/5432/i)).toBeInTheDocument(); + await waitFor(() => expect(portField).toHaveValue(5432)); expect(hostField).toHaveValue('localhost'); expect(portField).toHaveValue(5432); expect(databaseNameField).toHaveValue('postgres'); @@ -1401,8 +1437,11 @@ describe('DatabaseModal', () => { expect(connectButton).toBeEnabled(); userEvent.click(connectButton); + // Verify that validation was called at least once during the form interaction await waitFor(() => { - expect(fetchMock.calls(VALIDATE_PARAMS_ENDPOINT).length).toEqual(5); + expect( + fetchMock.calls(VALIDATE_PARAMS_ENDPOINT).length, + ).toBeGreaterThan(0); }); }); }); From 0bfaf3c50ea6ecbf6c45cc766ebccf0e85778318 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Sun, 15 Feb 2026 16:41:47 +0100 Subject: [PATCH 05/16] perf(database): skip redundant validation API calls on blur --- .../cypress/e2e/database/modal.test.ts | 50 +- .../databases/DatabaseModal/index.test.tsx | 100 +- .../databases/DatabaseModal/index.tsx | 15 +- superset/static/service-worker.js | 1492 ++++++++++++++++- 4 files changed, 1560 insertions(+), 97 deletions(-) diff --git a/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts b/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts index 340fb7392c54..fe0c43350395 100644 --- a/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts @@ -68,21 +68,19 @@ describe('Add database', () => { cy.get('input[name="username"]').type('testusername', { force: true }); cy.get('input[name="database"]').type('testdb', { force: true }); cy.get('input[name="password"]').type('testpass', { force: true }); + cy.get('input[name="password"]').blur(); - cy.get('body').click(0, 0); - - cy.wait('@validateParams', { timeout: 30000 }); - - cy.getBySel('btn-submit-connection').should('not.be.disabled'); + // Wait for the button to be enabled after validation completes on blur + cy.getBySel('btn-submit-connection').should('not.be.disabled', { + timeout: 30000, + }); cy.getBySel('btn-submit-connection').click({ force: true }); - cy.wait('@validateParams', { timeout: 30000 }).then(() => { - cy.wait('@createDb', { timeout: 60000 }).then(() => { - cy.contains( - '.ant-form-item-explain-error', - "The hostname provided can't be resolved", - ).should('exist'); - }); + cy.wait('@createDb', { timeout: 60000 }).then(() => { + cy.contains( + '.ant-form-item-explain-error', + "The hostname provided can't be resolved", + ).should('exist'); }); }); @@ -90,29 +88,23 @@ describe('Add database', () => { cy.get('.preferred > :nth-child(1)').click(); cy.get('input[name="host"]').type('localhost', { force: true }); - cy.get('body').click(0, 0); - cy.wait('@validateParams', { timeout: 30000 }); - cy.get('input[name="port"]').type('5430', { force: true }); cy.get('input[name="database"]').type('testdb', { force: true }); cy.get('input[name="username"]').type('testusername', { force: true }); - - cy.wait('@validateParams', { timeout: 30000 }); - cy.get('input[name="password"]').type('testpass', { force: true }); - cy.wait('@validateParams'); + cy.get('input[name="password"]').blur(); - cy.getBySel('btn-submit-connection').should('not.be.disabled'); + // Wait for the button to be enabled after validation completes on blur + cy.getBySel('btn-submit-connection').should('not.be.disabled', { + timeout: 30000, + }); cy.getBySel('btn-submit-connection').click({ force: true }); - cy.wait('@validateParams', { timeout: 30000 }).then(() => { - cy.get('body').click(0, 0); - cy.getBySel('btn-submit-connection').click({ force: true }); - cy.wait('@createDb', { timeout: 60000 }).then(() => { - cy.contains( - '.ant-form-item-explain-error', - 'The port is closed', - ).should('exist'); - }); + + cy.wait('@createDb', { timeout: 60000 }).then(() => { + cy.contains( + '.ant-form-item-explain-error', + 'The port is closed', + ).should('exist'); }); }); }); diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx index 3d3bb6c903d8..79e296b81df8 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx @@ -26,7 +26,6 @@ import { userEvent, within, waitFor, - fireEvent, } from 'spec/helpers/testing-library'; import { getExtensionsRegistry } from '@superset-ui/core'; import setupCodeOverrides from 'src/setup/setupCodeOverrides'; @@ -436,11 +435,7 @@ describe('DatabaseModal', () => { userEvent.click(selectInput); // Simulate pasting text into the input - expect(() => - fireEvent.paste(selectInput, { - clipboardData: { getData: () => 'post' }, - }), - ).not.toThrow(); + expect(() => userEvent.paste(selectInput, 'post')).not.toThrow(); }); test('renders the "Basic" tab of SQL Alchemy form (step 2 of 2) correctly', async () => { @@ -1214,9 +1209,7 @@ describe('DatabaseModal', () => { 'ssh-tunnel-server_address-input', ); expect(SSHTunnelServerAddressInput).toHaveValue(''); - fireEvent.change(SSHTunnelServerAddressInput, { - target: { value: 'localhost' }, - }); + userEvent.type(SSHTunnelServerAddressInput, 'localhost'); await waitFor(() => expect(SSHTunnelServerAddressInput).toHaveValue('localhost'), ); @@ -1224,17 +1217,13 @@ describe('DatabaseModal', () => { 'ssh-tunnel-server_port-input', ); expect(SSHTunnelServerPortInput).toHaveValue(null); - fireEvent.change(SSHTunnelServerPortInput, { - target: { value: '22' }, - }); + userEvent.type(SSHTunnelServerPortInput, '22'); await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22)); const SSHTunnelUsernameInput = screen.getByTestId( 'ssh-tunnel-username-input', ); expect(SSHTunnelUsernameInput).toHaveValue(''); - fireEvent.change(SSHTunnelUsernameInput, { - target: { value: 'test' }, - }); + userEvent.type(SSHTunnelUsernameInput, 'test'); await waitFor(() => expect(SSHTunnelUsernameInput).toHaveValue('test'), ); @@ -1242,9 +1231,7 @@ describe('DatabaseModal', () => { 'ssh-tunnel-password-input', ); expect(SSHTunnelPasswordInput).toHaveValue(''); - fireEvent.change(SSHTunnelPasswordInput, { - target: { value: 'pass' }, - }); + userEvent.type(SSHTunnelPasswordInput, 'pass'); await waitFor(() => expect(SSHTunnelPasswordInput).toHaveValue('pass'), ); @@ -1266,9 +1253,7 @@ describe('DatabaseModal', () => { 'ssh-tunnel-server_address-input', ); expect(SSHTunnelServerAddressInput).toHaveValue(''); - fireEvent.change(SSHTunnelServerAddressInput, { - target: { value: 'localhost' }, - }); + userEvent.type(SSHTunnelServerAddressInput, 'localhost'); await waitFor(() => expect(SSHTunnelServerAddressInput).toHaveValue('localhost'), ); @@ -1276,17 +1261,13 @@ describe('DatabaseModal', () => { 'ssh-tunnel-server_port-input', ); expect(SSHTunnelServerPortInput).toHaveValue(null); - fireEvent.change(SSHTunnelServerPortInput, { - target: { value: '22' }, - }); + userEvent.type(SSHTunnelServerPortInput, '22'); await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22)); const SSHTunnelUsernameInput = screen.getByTestId( 'ssh-tunnel-username-input', ); expect(SSHTunnelUsernameInput).toHaveValue(''); - fireEvent.change(SSHTunnelUsernameInput, { - target: { value: 'test' }, - }); + userEvent.type(SSHTunnelUsernameInput, 'test'); await waitFor(() => expect(SSHTunnelUsernameInput).toHaveValue('test'), ); @@ -1294,9 +1275,7 @@ describe('DatabaseModal', () => { 'ssh-tunnel-password-input', ); expect(SSHTunnelPasswordInput).toHaveValue(''); - fireEvent.change(SSHTunnelPasswordInput, { - target: { value: 'pass' }, - }); + userEvent.type(SSHTunnelPasswordInput, 'pass'); await waitFor(() => expect(SSHTunnelPasswordInput).toHaveValue('pass'), ); @@ -1413,16 +1392,16 @@ describe('DatabaseModal', () => { expect(connectButton).toBeDisabled(); - fireEvent.change(hostField, { target: { value: 'localhost' } }); - fireEvent.blur(hostField); - fireEvent.change(portField, { target: { value: '5432' } }); - fireEvent.blur(portField); - fireEvent.change(databaseNameField, { target: { value: 'postgres' } }); - fireEvent.blur(databaseNameField); - fireEvent.change(usernameField, { target: { value: 'testdb' } }); - fireEvent.blur(usernameField); - fireEvent.change(passwordField, { target: { value: 'demoPassword' } }); - fireEvent.blur(passwordField); + userEvent.type(hostField, 'localhost'); + userEvent.tab(); + userEvent.type(portField, '5432'); + userEvent.tab(); + userEvent.type(databaseNameField, 'postgres'); + userEvent.tab(); + userEvent.type(usernameField, 'testdb'); + userEvent.tab(); + userEvent.type(passwordField, 'demoPassword'); + userEvent.tab(); await waitFor(() => expect(connectButton).toBeEnabled()); @@ -1435,11 +1414,48 @@ describe('DatabaseModal', () => { expect(connectButton).toBeEnabled(); userEvent.click(connectButton); - // Verify that validation was called at least once during the form interaction + // Verify that validation was called during the form interaction + // Note: With the optimized validation, redundant calls on the same db state are skipped + await waitFor(() => { + expect( + fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length, + ).toBeGreaterThan(0); + }); + }); + + test('does not fire redundant validation on blur when db has not changed', async () => { + setup(); + + userEvent.click( + await screen.findByRole('button', { + name: /postgresql/i, + }), + ); + + expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument(); + + const textboxes = await screen.findAllByRole('textbox'); + const hostField = textboxes[0]; + + // Type a value and blur - should trigger validation + userEvent.type(hostField, 'localhost'); + userEvent.tab(); + + await waitFor(() => { + expect( + fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length, + ).toEqual(1); + }); + + // Blur again without changing the value - should NOT trigger another validation + userEvent.click(hostField); + userEvent.tab(); + + // Wait a tick to ensure no additional calls are made await waitFor(() => { expect( fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length, - ).toEqual(5); + ).toEqual(1); }); }); }); diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index 124c89d298fc..28c8cb123d1d 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -614,6 +614,7 @@ const DatabaseModal: FunctionComponent = ({ hasValidated, setHasValidated, ] = useDatabaseValidation(); + const lastValidatedDbSnapshotRef = useRef(null); const [hasConnectedDb, setHasConnectedDb] = useState(false); const [showCTAbtns, setShowCTAbtns] = useState(false); const [dbName, setDbName] = useState(''); @@ -776,6 +777,7 @@ const DatabaseModal: FunctionComponent = ({ const handleClearValidationErrors = useCallback(() => { setValidationErrors(null); setHasValidated(false); + lastValidatedDbSnapshotRef.current = null; clearError(); }, [setValidationErrors, setHasValidated, clearError]); @@ -812,6 +814,15 @@ const DatabaseModal: FunctionComponent = ({ [onChange, handleClearValidationErrors], ); + const getBlurValidation = useCallback(() => { + const currentDbSnapshot = JSON.stringify(db); + if (currentDbSnapshot === lastValidatedDbSnapshotRef.current) { + return Promise.resolve([]); + } + lastValidatedDbSnapshotRef.current = currentDbSnapshot; + return getValidation(db); + }, [db, getValidation]); + const onClose = () => { setDB({ type: ActionType.Reset }); setHasConnectedDb(false); @@ -1740,7 +1751,7 @@ const DatabaseModal: FunctionComponent = ({ } isValidating={isValidating} validationErrors={validationErrors} - getValidation={() => getValidation(db)} + getValidation={getBlurValidation} /> ); @@ -1810,7 +1821,7 @@ const DatabaseModal: FunctionComponent = ({ }} onParametersChange={handleParametersChange} onChange={handleTextChange} - getValidation={() => getValidation(db)} + getValidation={getBlurValidation} validationErrors={validationErrors} getPlaceholder={getPlaceholder} clearValidationErrors={handleClearValidationErrors} diff --git a/superset/static/service-worker.js b/superset/static/service-worker.js index 43cb14a4894c..6de9ae778344 100644 --- a/superset/static/service-worker.js +++ b/superset/static/service-worker.js @@ -1,27 +1,1471 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. +/* + * ATTENTION: An "eval-source-map" devtool has been used. + * This devtool is neither made for production nor for readable output files. + * It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools. + * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/) + * or disable the default devtool with "devtool: false". + * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/). */ +/******/ (() => { // webpackBootstrap +/******/ "use strict"; +/******/ var __webpack_modules__ = ({ -// Minimal service worker for PWA file handling support -self.addEventListener('install', event => { - event.waitUntil(self.skipWaiting()); -}); +/***/ "./src/service-worker.ts" +/*!*******************************!*\ + !*** ./src/service-worker.ts ***! + \*******************************/ +() { -self.addEventListener('activate', event => { - event.waitUntil(self.clients.claim()); -}); +eval("{/**\n * Licensed to the Apache Software Foundation (ASF) under one\n * or more contributor license agreements. See the NOTICE file\n * distributed with this work for additional information\n * regarding copyright ownership. The ASF licenses this file\n * to you under the Apache License, Version 2.0 (the\n * \"License\"); you may not use this file except in compliance\n * with the License. You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing,\n * software distributed under the License is distributed on an\n * \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n * KIND, either express or implied. See the License for the\n * specific language governing permissions and limitations\n * under the License.\n */ // Service Worker types (declared locally to avoid polluting global scope)\nself.addEventListener('install', (event)=>{\n event.waitUntil(self.skipWaiting());\n});\nself.addEventListener('activate', (event)=>{\n event.waitUntil(self.clients.claim());\n});\n\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiLi9zcmMvc2VydmljZS13b3JrZXIudHMiLCJtYXBwaW5ncyI6IkFBQUE7Ozs7Ozs7Ozs7Ozs7Ozs7O0FBaUJBO0FBWUE7QUFDQTtBQUNBO0FBRUE7QUFDQTtBQUNBO0FBRUEiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9zdXBlcnNldC8uL3NyYy9zZXJ2aWNlLXdvcmtlci50cz83ZjU4Il0sInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogTGljZW5zZWQgdG8gdGhlIEFwYWNoZSBTb2Z0d2FyZSBGb3VuZGF0aW9uIChBU0YpIHVuZGVyIG9uZVxuICogb3IgbW9yZSBjb250cmlidXRvciBsaWNlbnNlIGFncmVlbWVudHMuICBTZWUgdGhlIE5PVElDRSBmaWxlXG4gKiBkaXN0cmlidXRlZCB3aXRoIHRoaXMgd29yayBmb3IgYWRkaXRpb25hbCBpbmZvcm1hdGlvblxuICogcmVnYXJkaW5nIGNvcHlyaWdodCBvd25lcnNoaXAuICBUaGUgQVNGIGxpY2Vuc2VzIHRoaXMgZmlsZVxuICogdG8geW91IHVuZGVyIHRoZSBBcGFjaGUgTGljZW5zZSwgVmVyc2lvbiAyLjAgKHRoZVxuICogXCJMaWNlbnNlXCIpOyB5b3UgbWF5IG5vdCB1c2UgdGhpcyBmaWxlIGV4Y2VwdCBpbiBjb21wbGlhbmNlXG4gKiB3aXRoIHRoZSBMaWNlbnNlLiAgWW91IG1heSBvYnRhaW4gYSBjb3B5IG9mIHRoZSBMaWNlbnNlIGF0XG4gKlxuICogICBodHRwOi8vd3d3LmFwYWNoZS5vcmcvbGljZW5zZXMvTElDRU5TRS0yLjBcbiAqXG4gKiBVbmxlc3MgcmVxdWlyZWQgYnkgYXBwbGljYWJsZSBsYXcgb3IgYWdyZWVkIHRvIGluIHdyaXRpbmcsXG4gKiBzb2Z0d2FyZSBkaXN0cmlidXRlZCB1bmRlciB0aGUgTGljZW5zZSBpcyBkaXN0cmlidXRlZCBvbiBhblxuICogXCJBUyBJU1wiIEJBU0lTLCBXSVRIT1VUIFdBUlJBTlRJRVMgT1IgQ09ORElUSU9OUyBPRiBBTllcbiAqIEtJTkQsIGVpdGhlciBleHByZXNzIG9yIGltcGxpZWQuICBTZWUgdGhlIExpY2Vuc2UgZm9yIHRoZVxuICogc3BlY2lmaWMgbGFuZ3VhZ2UgZ292ZXJuaW5nIHBlcm1pc3Npb25zIGFuZCBsaW1pdGF0aW9uc1xuICogdW5kZXIgdGhlIExpY2Vuc2UuXG4gKi9cblxuLy8gU2VydmljZSBXb3JrZXIgdHlwZXMgKGRlY2xhcmVkIGxvY2FsbHkgdG8gYXZvaWQgcG9sbHV0aW5nIGdsb2JhbCBzY29wZSlcbmRlY2xhcmUgY29uc3Qgc2VsZjoge1xuICBza2lwV2FpdGluZygpOiBQcm9taXNlPHZvaWQ+O1xuICBjbGllbnRzOiB7IGNsYWltKCk6IFByb21pc2U8dm9pZD4gfTtcbiAgYWRkRXZlbnRMaXN0ZW5lcihcbiAgICB0eXBlOiAnaW5zdGFsbCcgfCAnYWN0aXZhdGUnLFxuICAgIGxpc3RlbmVyOiAoZXZlbnQ6IHsgd2FpdFVudGlsKHByb21pc2U6IFByb21pc2U8dW5rbm93bj4pOiB2b2lkIH0pID0+IHZvaWQsXG4gICk6IHZvaWQ7XG59O1xuXG5zZWxmLmFkZEV2ZW50TGlzdGVuZXIoJ2luc3RhbGwnLCBldmVudCA9PiB7XG4gIGV2ZW50LndhaXRVbnRpbChzZWxmLnNraXBXYWl0aW5nKCkpO1xufSk7XG5cbnNlbGYuYWRkRXZlbnRMaXN0ZW5lcignYWN0aXZhdGUnLCBldmVudCA9PiB7XG4gIGV2ZW50LndhaXRVbnRpbChzZWxmLmNsaWVudHMuY2xhaW0oKSk7XG59KTtcblxuZXhwb3J0IHt9O1xuIl0sIm5hbWVzIjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///./src/service-worker.ts\n\n}"); + +/***/ } + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Check if module exists (development only) +/******/ if (__webpack_modules__[moduleId] === undefined) { +/******/ var e = new Error("Cannot find module '" + moduleId + "'"); +/******/ e.code = 'MODULE_NOT_FOUND'; +/******/ throw e; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ id: moduleId, +/******/ loaded: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ var execOptions = { id: moduleId, module: module, factory: __webpack_modules__[moduleId], require: __webpack_require__ }; +/******/ __webpack_require__.i.forEach(function(handler) { handler(execOptions); }); +/******/ module = execOptions.module; +/******/ execOptions.factory.call(module.exports, module, module.exports, execOptions.require); +/******/ +/******/ // Flag the module as loaded +/******/ module.loaded = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = __webpack_modules__; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = __webpack_module_cache__; +/******/ +/******/ // expose the module execution interceptor +/******/ __webpack_require__.i = []; +/******/ +/************************************************************************/ +/******/ /* webpack/runtime/chunk loaded */ +/******/ (() => { +/******/ var deferred = []; +/******/ __webpack_require__.O = (result, chunkIds, fn, priority) => { +/******/ if(chunkIds) { +/******/ priority = priority || 0; +/******/ for(var i = deferred.length; i > 0 && deferred[i - 1][2] > priority; i--) deferred[i] = deferred[i - 1]; +/******/ deferred[i] = [chunkIds, fn, priority]; +/******/ return; +/******/ } +/******/ var notFulfilled = Infinity; +/******/ for (var i = 0; i < deferred.length; i++) { +/******/ var [chunkIds, fn, priority] = deferred[i]; +/******/ var fulfilled = true; +/******/ for (var j = 0; j < chunkIds.length; j++) { +/******/ if ((priority & 1 === 0 || notFulfilled >= priority) && Object.keys(__webpack_require__.O).every((key) => (__webpack_require__.O[key](chunkIds[j])))) { +/******/ chunkIds.splice(j--, 1); +/******/ } else { +/******/ fulfilled = false; +/******/ if(priority < notFulfilled) notFulfilled = priority; +/******/ } +/******/ } +/******/ if(fulfilled) { +/******/ deferred.splice(i--, 1) +/******/ var r = fn(); +/******/ if (r !== undefined) result = r; +/******/ } +/******/ } +/******/ return result; +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/compat get default export */ +/******/ (() => { +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = (module) => { +/******/ var getter = module && module.__esModule ? +/******/ () => (module['default']) : +/******/ () => (module); +/******/ __webpack_require__.d(getter, { a: getter }); +/******/ return getter; +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/create fake namespace object */ +/******/ (() => { +/******/ var getProto = Object.getPrototypeOf ? (obj) => (Object.getPrototypeOf(obj)) : (obj) => (obj.__proto__); +/******/ var leafPrototypes; +/******/ // create a fake namespace object +/******/ // mode & 1: value is a module id, require it +/******/ // mode & 2: merge all properties of value into the ns +/******/ // mode & 4: return value when already ns object +/******/ // mode & 16: return value when it's Promise-like +/******/ // mode & 8|1: behave like require +/******/ __webpack_require__.t = function(value, mode) { +/******/ if(mode & 1) value = this(value); +/******/ if(mode & 8) return value; +/******/ if(typeof value === 'object' && value) { +/******/ if((mode & 4) && value.__esModule) return value; +/******/ if((mode & 16) && typeof value.then === 'function') return value; +/******/ } +/******/ var ns = Object.create(null); +/******/ __webpack_require__.r(ns); +/******/ var def = {}; +/******/ leafPrototypes = leafPrototypes || [null, getProto({}), getProto([]), getProto(getProto)]; +/******/ for(var current = mode & 2 && value; (typeof current == 'object' || typeof current == 'function') && !~leafPrototypes.indexOf(current); current = getProto(current)) { +/******/ Object.getOwnPropertyNames(current).forEach((key) => (def[key] = () => (value[key]))); +/******/ } +/******/ def['default'] = () => (value); +/******/ __webpack_require__.d(ns, def); +/******/ return ns; +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/define property getters */ +/******/ (() => { +/******/ // define getter functions for harmony exports +/******/ __webpack_require__.d = (exports, definition) => { +/******/ for(var key in definition) { +/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { +/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); +/******/ } +/******/ } +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/get javascript update chunk filename */ +/******/ (() => { +/******/ // This function allow to reference all chunks +/******/ __webpack_require__.hu = (chunkId) => { +/******/ // return url for filenames based on template +/******/ return "" + chunkId + "." + __webpack_require__.h() + ".hot-update.js"; +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/get update manifest filename */ +/******/ (() => { +/******/ __webpack_require__.hmrF = () => ("service-worker." + __webpack_require__.h() + ".hot-update.json"); +/******/ })(); +/******/ +/******/ /* webpack/runtime/getFullHash */ +/******/ (() => { +/******/ __webpack_require__.h = () => ("668ef06e78946af73b45") +/******/ })(); +/******/ +/******/ /* webpack/runtime/harmony module decorator */ +/******/ (() => { +/******/ __webpack_require__.hmd = (module) => { +/******/ module = Object.create(module); +/******/ if (!module.children) module.children = []; +/******/ Object.defineProperty(module, 'exports', { +/******/ enumerable: true, +/******/ set: () => { +/******/ throw new Error('ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: ' + module.id); +/******/ } +/******/ }); +/******/ return module; +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/hasOwnProperty shorthand */ +/******/ (() => { +/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) +/******/ })(); +/******/ +/******/ /* webpack/runtime/load script */ +/******/ (() => { +/******/ var inProgress = {}; +/******/ var dataWebpackPrefix = "superset:"; +/******/ // loadScript function to load a script via script tag +/******/ __webpack_require__.l = (url, done, key, chunkId) => { +/******/ if(inProgress[url]) { inProgress[url].push(done); return; } +/******/ var script, needAttach; +/******/ if(key !== undefined) { +/******/ var scripts = document.getElementsByTagName("script"); +/******/ for(var i = 0; i < scripts.length; i++) { +/******/ var s = scripts[i]; +/******/ if(s.getAttribute("src") == url || s.getAttribute("data-webpack") == dataWebpackPrefix + key) { script = s; break; } +/******/ } +/******/ } +/******/ if(!script) { +/******/ needAttach = true; +/******/ script = document.createElement('script'); +/******/ +/******/ script.charset = 'utf-8'; +/******/ if (__webpack_require__.nc) { +/******/ script.setAttribute("nonce", __webpack_require__.nc); +/******/ } +/******/ script.setAttribute("data-webpack", dataWebpackPrefix + key); +/******/ +/******/ script.src = url; +/******/ } +/******/ inProgress[url] = [done]; +/******/ var onScriptComplete = (prev, event) => { +/******/ // avoid mem leaks in IE. +/******/ script.onerror = script.onload = null; +/******/ clearTimeout(timeout); +/******/ var doneFns = inProgress[url]; +/******/ delete inProgress[url]; +/******/ script.parentNode && script.parentNode.removeChild(script); +/******/ doneFns && doneFns.forEach((fn) => (fn(event))); +/******/ if(prev) return prev(event); +/******/ } +/******/ var timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000); +/******/ script.onerror = onScriptComplete.bind(null, script.onerror); +/******/ script.onload = onScriptComplete.bind(null, script.onload); +/******/ needAttach && document.head.appendChild(script); +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/make namespace object */ +/******/ (() => { +/******/ // define __esModule on exports +/******/ __webpack_require__.r = (exports) => { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/node module decorator */ +/******/ (() => { +/******/ __webpack_require__.nmd = (module) => { +/******/ module.paths = []; +/******/ if (!module.children) module.children = []; +/******/ return module; +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/sharing */ +/******/ (() => { +/******/ __webpack_require__.S = {}; +/******/ var initPromises = {}; +/******/ var initTokens = {}; +/******/ __webpack_require__.I = (name, initScope) => { +/******/ if(!initScope) initScope = []; +/******/ // handling circular init calls +/******/ var initToken = initTokens[name]; +/******/ if(!initToken) initToken = initTokens[name] = {}; +/******/ if(initScope.indexOf(initToken) >= 0) return; +/******/ initScope.push(initToken); +/******/ // only runs once +/******/ if(initPromises[name]) return initPromises[name]; +/******/ // creates a new share scope if needed +/******/ if(!__webpack_require__.o(__webpack_require__.S, name)) __webpack_require__.S[name] = {}; +/******/ // runs all init snippets from all modules reachable +/******/ var scope = __webpack_require__.S[name]; +/******/ var warn = (msg) => { +/******/ if (typeof console !== "undefined" && console.warn) console.warn(msg); +/******/ }; +/******/ var uniqueName = "superset"; +/******/ var register = (name, version, factory, eager) => { +/******/ var versions = scope[name] = scope[name] || {}; +/******/ var activeVersion = versions[version]; +/******/ if(!activeVersion || (!activeVersion.loaded && (!eager != !activeVersion.eager ? eager : uniqueName > activeVersion.from))) versions[version] = { get: factory, from: uniqueName, eager: !!eager }; +/******/ }; +/******/ var initExternal = (id) => { +/******/ var handleError = (err) => (warn("Initialization of sharing external failed: " + err)); +/******/ try { +/******/ var module = __webpack_require__(id); +/******/ if(!module) return; +/******/ var initFn = (module) => (module && module.init && module.init(__webpack_require__.S[name], initScope)) +/******/ if(module.then) return promises.push(module.then(initFn, handleError)); +/******/ var initResult = initFn(module); +/******/ if(initResult && initResult.then) return promises.push(initResult['catch'](handleError)); +/******/ } catch(err) { handleError(err); } +/******/ } +/******/ var promises = []; +/******/ switch(name) { +/******/ case "default": { +/******/ register("antd", "5.27.6", () => (() => (__webpack_require__(/*! ./node_modules/antd/es/index.js */ "./node_modules/antd/es/index.js"))), 1); +/******/ register("react-dom", "17.0.2", () => (() => (__webpack_require__(/*! ./node_modules/react-dom/index.js */ "./node_modules/react-dom/index.js"))), 1); +/******/ register("react", "17.0.2", () => (() => (__webpack_require__(/*! ./node_modules/react/index.js */ "./node_modules/react/index.js"))), 1); +/******/ } +/******/ break; +/******/ } +/******/ if(!promises.length) return initPromises[name] = 1; +/******/ return initPromises[name] = Promise.all(promises).then(() => (initPromises[name] = 1)); +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/hot module replacement */ +/******/ (() => { +/******/ var currentModuleData = {}; +/******/ var installedModules = __webpack_require__.c; +/******/ +/******/ // module and require creation +/******/ var currentChildModule; +/******/ var currentParents = []; +/******/ +/******/ // status +/******/ var registeredStatusHandlers = []; +/******/ var currentStatus = "idle"; +/******/ +/******/ // while downloading +/******/ var blockingPromises = 0; +/******/ var blockingPromisesWaiting = []; +/******/ +/******/ // The update info +/******/ var currentUpdateApplyHandlers; +/******/ var queuedInvalidatedModules; +/******/ +/******/ __webpack_require__.hmrD = currentModuleData; +/******/ +/******/ __webpack_require__.i.push(function (options) { +/******/ var module = options.module; +/******/ var require = createRequire(options.require, options.id); +/******/ module.hot = createModuleHotObject(options.id, module); +/******/ module.parents = currentParents; +/******/ module.children = []; +/******/ currentParents = []; +/******/ options.require = require; +/******/ }); +/******/ +/******/ __webpack_require__.hmrC = {}; +/******/ __webpack_require__.hmrI = {}; +/******/ +/******/ function createRequire(require, moduleId) { +/******/ var me = installedModules[moduleId]; +/******/ if (!me) return require; +/******/ var fn = function (request) { +/******/ if (me.hot.active) { +/******/ if (installedModules[request]) { +/******/ var parents = installedModules[request].parents; +/******/ if (parents.indexOf(moduleId) === -1) { +/******/ parents.push(moduleId); +/******/ } +/******/ } else { +/******/ currentParents = [moduleId]; +/******/ currentChildModule = request; +/******/ } +/******/ if (me.children.indexOf(request) === -1) { +/******/ me.children.push(request); +/******/ } +/******/ } else { +/******/ console.warn( +/******/ "[HMR] unexpected require(" + +/******/ request + +/******/ ") from disposed module " + +/******/ moduleId +/******/ ); +/******/ currentParents = []; +/******/ } +/******/ return require(request); +/******/ }; +/******/ var createPropertyDescriptor = function (name) { +/******/ return { +/******/ configurable: true, +/******/ enumerable: true, +/******/ get: function () { +/******/ return require[name]; +/******/ }, +/******/ set: function (value) { +/******/ require[name] = value; +/******/ } +/******/ }; +/******/ }; +/******/ for (var name in require) { +/******/ if (Object.prototype.hasOwnProperty.call(require, name) && name !== "e") { +/******/ Object.defineProperty(fn, name, createPropertyDescriptor(name)); +/******/ } +/******/ } +/******/ fn.e = function (chunkId, fetchPriority) { +/******/ return trackBlockingPromise(require.e(chunkId, fetchPriority)); +/******/ }; +/******/ return fn; +/******/ } +/******/ +/******/ function createModuleHotObject(moduleId, me) { +/******/ var _main = currentChildModule !== moduleId; +/******/ var hot = { +/******/ // private stuff +/******/ _acceptedDependencies: {}, +/******/ _acceptedErrorHandlers: {}, +/******/ _declinedDependencies: {}, +/******/ _selfAccepted: false, +/******/ _selfDeclined: false, +/******/ _selfInvalidated: false, +/******/ _disposeHandlers: [], +/******/ _main: _main, +/******/ _requireSelf: function () { +/******/ currentParents = me.parents.slice(); +/******/ currentChildModule = _main ? undefined : moduleId; +/******/ __webpack_require__(moduleId); +/******/ }, +/******/ +/******/ // Module API +/******/ active: true, +/******/ accept: function (dep, callback, errorHandler) { +/******/ if (dep === undefined) hot._selfAccepted = true; +/******/ else if (typeof dep === "function") hot._selfAccepted = dep; +/******/ else if (typeof dep === "object" && dep !== null) { +/******/ for (var i = 0; i < dep.length; i++) { +/******/ hot._acceptedDependencies[dep[i]] = callback || function () {}; +/******/ hot._acceptedErrorHandlers[dep[i]] = errorHandler; +/******/ } +/******/ } else { +/******/ hot._acceptedDependencies[dep] = callback || function () {}; +/******/ hot._acceptedErrorHandlers[dep] = errorHandler; +/******/ } +/******/ }, +/******/ decline: function (dep) { +/******/ if (dep === undefined) hot._selfDeclined = true; +/******/ else if (typeof dep === "object" && dep !== null) +/******/ for (var i = 0; i < dep.length; i++) +/******/ hot._declinedDependencies[dep[i]] = true; +/******/ else hot._declinedDependencies[dep] = true; +/******/ }, +/******/ dispose: function (callback) { +/******/ hot._disposeHandlers.push(callback); +/******/ }, +/******/ addDisposeHandler: function (callback) { +/******/ hot._disposeHandlers.push(callback); +/******/ }, +/******/ removeDisposeHandler: function (callback) { +/******/ var idx = hot._disposeHandlers.indexOf(callback); +/******/ if (idx >= 0) hot._disposeHandlers.splice(idx, 1); +/******/ }, +/******/ invalidate: function () { +/******/ this._selfInvalidated = true; +/******/ switch (currentStatus) { +/******/ case "idle": +/******/ currentUpdateApplyHandlers = []; +/******/ Object.keys(__webpack_require__.hmrI).forEach(function (key) { +/******/ __webpack_require__.hmrI[key]( +/******/ moduleId, +/******/ currentUpdateApplyHandlers +/******/ ); +/******/ }); +/******/ setStatus("ready"); +/******/ break; +/******/ case "ready": +/******/ Object.keys(__webpack_require__.hmrI).forEach(function (key) { +/******/ __webpack_require__.hmrI[key]( +/******/ moduleId, +/******/ currentUpdateApplyHandlers +/******/ ); +/******/ }); +/******/ break; +/******/ case "prepare": +/******/ case "check": +/******/ case "dispose": +/******/ case "apply": +/******/ (queuedInvalidatedModules = queuedInvalidatedModules || []).push( +/******/ moduleId +/******/ ); +/******/ break; +/******/ default: +/******/ // ignore requests in error states +/******/ break; +/******/ } +/******/ }, +/******/ +/******/ // Management API +/******/ check: hotCheck, +/******/ apply: hotApply, +/******/ status: function (l) { +/******/ if (!l) return currentStatus; +/******/ registeredStatusHandlers.push(l); +/******/ }, +/******/ addStatusHandler: function (l) { +/******/ registeredStatusHandlers.push(l); +/******/ }, +/******/ removeStatusHandler: function (l) { +/******/ var idx = registeredStatusHandlers.indexOf(l); +/******/ if (idx >= 0) registeredStatusHandlers.splice(idx, 1); +/******/ }, +/******/ +/******/ // inherit from previous dispose call +/******/ data: currentModuleData[moduleId] +/******/ }; +/******/ currentChildModule = undefined; +/******/ return hot; +/******/ } +/******/ +/******/ function setStatus(newStatus) { +/******/ currentStatus = newStatus; +/******/ var results = []; +/******/ +/******/ for (var i = 0; i < registeredStatusHandlers.length; i++) +/******/ results[i] = registeredStatusHandlers[i].call(null, newStatus); +/******/ +/******/ return Promise.all(results).then(function () {}); +/******/ } +/******/ +/******/ function unblock() { +/******/ if (--blockingPromises === 0) { +/******/ setStatus("ready").then(function () { +/******/ if (blockingPromises === 0) { +/******/ var list = blockingPromisesWaiting; +/******/ blockingPromisesWaiting = []; +/******/ for (var i = 0; i < list.length; i++) { +/******/ list[i](); +/******/ } +/******/ } +/******/ }); +/******/ } +/******/ } +/******/ +/******/ function trackBlockingPromise(promise) { +/******/ switch (currentStatus) { +/******/ case "ready": +/******/ setStatus("prepare"); +/******/ /* fallthrough */ +/******/ case "prepare": +/******/ blockingPromises++; +/******/ promise.then(unblock, unblock); +/******/ return promise; +/******/ default: +/******/ return promise; +/******/ } +/******/ } +/******/ +/******/ function waitForBlockingPromises(fn) { +/******/ if (blockingPromises === 0) return fn(); +/******/ return new Promise(function (resolve) { +/******/ blockingPromisesWaiting.push(function () { +/******/ resolve(fn()); +/******/ }); +/******/ }); +/******/ } +/******/ +/******/ function hotCheck(applyOnUpdate) { +/******/ if (currentStatus !== "idle") { +/******/ throw new Error("check() is only allowed in idle status"); +/******/ } +/******/ return setStatus("check") +/******/ .then(__webpack_require__.hmrM) +/******/ .then(function (update) { +/******/ if (!update) { +/******/ return setStatus(applyInvalidatedModules() ? "ready" : "idle").then( +/******/ function () { +/******/ return null; +/******/ } +/******/ ); +/******/ } +/******/ +/******/ return setStatus("prepare").then(function () { +/******/ var updatedModules = []; +/******/ currentUpdateApplyHandlers = []; +/******/ +/******/ return Promise.all( +/******/ Object.keys(__webpack_require__.hmrC).reduce(function ( +/******/ promises, +/******/ key +/******/ ) { +/******/ __webpack_require__.hmrC[key]( +/******/ update.c, +/******/ update.r, +/******/ update.m, +/******/ promises, +/******/ currentUpdateApplyHandlers, +/******/ updatedModules, +/******/ update.css +/******/ ); +/******/ return promises; +/******/ }, []) +/******/ ).then(function () { +/******/ return waitForBlockingPromises(function () { +/******/ if (applyOnUpdate) { +/******/ return internalApply(applyOnUpdate); +/******/ } +/******/ return setStatus("ready").then(function () { +/******/ return updatedModules; +/******/ }); +/******/ }); +/******/ }); +/******/ }); +/******/ }); +/******/ } +/******/ +/******/ function hotApply(options) { +/******/ if (currentStatus !== "ready") { +/******/ return Promise.resolve().then(function () { +/******/ throw new Error( +/******/ "apply() is only allowed in ready status (state: " + +/******/ currentStatus + +/******/ ")" +/******/ ); +/******/ }); +/******/ } +/******/ return internalApply(options); +/******/ } +/******/ +/******/ function internalApply(options) { +/******/ options = options || {}; +/******/ +/******/ applyInvalidatedModules(); +/******/ +/******/ var results = currentUpdateApplyHandlers.map(function (handler) { +/******/ return handler(options); +/******/ }); +/******/ currentUpdateApplyHandlers = undefined; +/******/ +/******/ var errors = results +/******/ .map(function (r) { +/******/ return r.error; +/******/ }) +/******/ .filter(Boolean); +/******/ +/******/ if (errors.length > 0) { +/******/ return setStatus("abort").then(function () { +/******/ throw errors[0]; +/******/ }); +/******/ } +/******/ +/******/ // Now in "dispose" phase +/******/ var disposePromise = setStatus("dispose"); +/******/ +/******/ results.forEach(function (result) { +/******/ if (result.dispose) result.dispose(); +/******/ }); +/******/ +/******/ // Now in "apply" phase +/******/ var applyPromise = setStatus("apply"); +/******/ +/******/ var error; +/******/ var reportError = function (err) { +/******/ if (!error) error = err; +/******/ }; +/******/ +/******/ var outdatedModules = []; +/******/ +/******/ var onAccepted = function () { +/******/ return Promise.all([disposePromise, applyPromise]).then(function () { +/******/ // handle errors in accept handlers and self accepted module load +/******/ if (error) { +/******/ return setStatus("fail").then(function () { +/******/ throw error; +/******/ }); +/******/ } +/******/ +/******/ if (queuedInvalidatedModules) { +/******/ return internalApply(options).then(function (list) { +/******/ outdatedModules.forEach(function (moduleId) { +/******/ if (list.indexOf(moduleId) < 0) list.push(moduleId); +/******/ }); +/******/ return list; +/******/ }); +/******/ } +/******/ +/******/ return setStatus("idle").then(function () { +/******/ return outdatedModules; +/******/ }); +/******/ }); +/******/ }; +/******/ +/******/ return Promise.all( +/******/ results +/******/ .filter(function (result) { +/******/ return result.apply; +/******/ }) +/******/ .map(function (result) { +/******/ return result.apply(reportError); +/******/ }) +/******/ ) +/******/ .then(function (applyResults) { +/******/ applyResults.forEach(function (modules) { +/******/ if (modules) { +/******/ for (var i = 0; i < modules.length; i++) { +/******/ outdatedModules.push(modules[i]); +/******/ } +/******/ } +/******/ }); +/******/ }) +/******/ .then(onAccepted); +/******/ } +/******/ +/******/ function applyInvalidatedModules() { +/******/ if (queuedInvalidatedModules) { +/******/ if (!currentUpdateApplyHandlers) currentUpdateApplyHandlers = []; +/******/ Object.keys(__webpack_require__.hmrI).forEach(function (key) { +/******/ queuedInvalidatedModules.forEach(function (moduleId) { +/******/ __webpack_require__.hmrI[key]( +/******/ moduleId, +/******/ currentUpdateApplyHandlers +/******/ ); +/******/ }); +/******/ }); +/******/ queuedInvalidatedModules = undefined; +/******/ return true; +/******/ } +/******/ } +/******/ })(); +/******/ +/******/ /* webpack/runtime/publicPath */ +/******/ (() => { +/******/ __webpack_require__.p = "/static/assets/"; +/******/ })(); +/******/ +/******/ /* webpack/runtime/react refresh */ +/******/ (() => { +/******/ const setup = (moduleId) => { +/******/ const refresh = { +/******/ moduleId: moduleId, +/******/ register: (type, id) => { +/******/ const typeId = moduleId + ' ' + id; +/******/ refresh.runtime.register(type, typeId); +/******/ }, +/******/ signature: () => (refresh.runtime.createSignatureFunctionForTransform()), +/******/ runtime: { +/******/ createSignatureFunctionForTransform: () => ((type) => (type)), +/******/ register: x => {} +/******/ }, +/******/ }; +/******/ return refresh; +/******/ }; +/******/ +/******/ __webpack_require__.i.push((options) => { +/******/ const originalFactory = options.factory; +/******/ options.factory = function(moduleObject, moduleExports, webpackRequire) { +/******/ const hotRequire = (request) => (webpackRequire(request)); +/******/ const createPropertyDescriptor = (name) => { +/******/ return { +/******/ configurable: true, +/******/ enumerable: true, +/******/ get: () => (webpackRequire[name]), +/******/ set: (value) => { +/******/ webpackRequire[name] = value; +/******/ }, +/******/ }; +/******/ }; +/******/ for (const name in webpackRequire) { +/******/ if (name === "$Refresh$") continue; +/******/ if (Object.prototype.hasOwnProperty.call(webpackRequire, name)) { +/******/ Object.defineProperty(hotRequire, name, createPropertyDescriptor(name)); +/******/ } +/******/ } +/******/ hotRequire.$Refresh$ = setup(options.id); +/******/ originalFactory.call(this, moduleObject, moduleExports, hotRequire); +/******/ }; +/******/ }); +/******/ })(); +/******/ +/******/ /* webpack/runtime/consumes */ +/******/ (() => { +/******/ var parseVersion = (str) => { +/******/ // see webpack/lib/util/semver.js for original code +/******/ var p=p=>{return p.split(".").map(p=>{return+p==p?+p:p})},n=/^([^-+]+)?(?:-([^+]+))?(?:\+(.+))?$/.exec(str),r=n[1]?p(n[1]):[];return n[2]&&(r.length++,r.push.apply(r,p(n[2]))),n[3]&&(r.push([]),r.push.apply(r,p(n[3]))),r; +/******/ } +/******/ var versionLt = (a, b) => { +/******/ // see webpack/lib/util/semver.js for original code +/******/ a=parseVersion(a),b=parseVersion(b);for(var r=0;;){if(r>=a.length)return r=b.length)return"u"==n;var t=b[r],f=(typeof t)[0];if(n!=f)return"o"==n&&"n"==f||("s"==f||"u"==n);if("o"!=n&&"u"!=n&&e!=t)return e { +/******/ // see webpack/lib/util/semver.js for original code +/******/ var r=range[0],n="";if(1===range.length)return"*";if(r+.5){n+=0==r?">=":-1==r?"<":1==r?"^":2==r?"~":r>0?"=":"!=";for(var e=1,a=1;a0?".":"")+(e=2,t)}return n}var g=[];for(a=1;a { +/******/ // see webpack/lib/util/semver.js for original code +/******/ if(0 in range){version=parseVersion(version);var e=range[0],r=e<0;r&&(e=-e-1);for(var n=0,i=1,a=!0;;i++,n++){var f,s,g=i=version.length||"o"==(s=(typeof(f=version[n]))[0]))return!a||("u"==g?i>e&&!r:""==g!=r);if("u"==s){if(!a||"u"!=g)return!1}else if(a)if(g==s)if(i<=e){if(f!=range[i])return!1}else{if(r?f>range[i]:f { +/******/ return scope && __webpack_require__.o(scope, key); +/******/ } +/******/ var get = (entry) => { +/******/ entry.loaded = 1; +/******/ return entry.get() +/******/ }; +/******/ var eagerOnly = (versions) => { +/******/ return Object.keys(versions).reduce((filtered, version) => { +/******/ if (versions[version].eager) { +/******/ filtered[version] = versions[version]; +/******/ } +/******/ return filtered; +/******/ }, {}); +/******/ }; +/******/ var findLatestVersion = (scope, key, eager) => { +/******/ var versions = eager ? eagerOnly(scope[key]) : scope[key]; +/******/ var key = Object.keys(versions).reduce((a, b) => { +/******/ return !a || versionLt(a, b) ? b : a; +/******/ }, 0); +/******/ return key && versions[key]; +/******/ }; +/******/ var findSatisfyingVersion = (scope, key, requiredVersion, eager) => { +/******/ var versions = eager ? eagerOnly(scope[key]) : scope[key]; +/******/ var key = Object.keys(versions).reduce((a, b) => { +/******/ if (!satisfy(requiredVersion, b)) return a; +/******/ return !a || versionLt(a, b) ? b : a; +/******/ }, 0); +/******/ return key && versions[key] +/******/ }; +/******/ var findSingletonVersionKey = (scope, key, eager) => { +/******/ var versions = eager ? eagerOnly(scope[key]) : scope[key]; +/******/ return Object.keys(versions).reduce((a, b) => { +/******/ return !a || (!versions[a].loaded && versionLt(a, b)) ? b : a; +/******/ }, 0); +/******/ }; +/******/ var getInvalidSingletonVersionMessage = (scope, key, version, requiredVersion) => { +/******/ return "Unsatisfied version " + version + " from " + (version && scope[key][version].from) + " of shared singleton module " + key + " (required " + rangeToString(requiredVersion) + ")" +/******/ }; +/******/ var getInvalidVersionMessage = (scope, scopeName, key, requiredVersion, eager) => { +/******/ var versions = scope[key]; +/******/ return "No satisfying version (" + rangeToString(requiredVersion) + ")" + (eager ? " for eager consumption" : "") + " of shared module " + key + " found in shared scope " + scopeName + ".\n" + +/******/ "Available versions: " + Object.keys(versions).map((key) => { +/******/ return key + " from " + versions[key].from; +/******/ }).join(", "); +/******/ }; +/******/ var fail = (msg) => { +/******/ throw new Error(msg); +/******/ } +/******/ var failAsNotExist = (scopeName, key) => { +/******/ return fail("Shared module " + key + " doesn't exist in shared scope " + scopeName); +/******/ } +/******/ var warn = /*#__PURE__*/ (msg) => { +/******/ if (typeof console !== "undefined" && console.warn) console.warn(msg); +/******/ }; +/******/ var init = (fn) => (function(scopeName, key, eager, c, d) { +/******/ var promise = __webpack_require__.I(scopeName); +/******/ if (promise && promise.then && !eager) { +/******/ return promise.then(fn.bind(fn, scopeName, __webpack_require__.S[scopeName], key, false, c, d)); +/******/ } +/******/ return fn(scopeName, __webpack_require__.S[scopeName], key, eager, c, d); +/******/ }); +/******/ +/******/ var useFallback = (scopeName, key, fallback) => { +/******/ return fallback ? fallback() : failAsNotExist(scopeName, key); +/******/ } +/******/ var load = /*#__PURE__*/ init((scopeName, scope, key, eager, fallback) => { +/******/ if (!exists(scope, key)) return useFallback(scopeName, key, fallback); +/******/ return get(findLatestVersion(scope, key, eager)); +/******/ }); +/******/ var loadVersion = /*#__PURE__*/ init((scopeName, scope, key, eager, requiredVersion, fallback) => { +/******/ if (!exists(scope, key)) return useFallback(scopeName, key, fallback); +/******/ var satisfyingVersion = findSatisfyingVersion(scope, key, requiredVersion, eager); +/******/ if (satisfyingVersion) return get(satisfyingVersion); +/******/ warn(getInvalidVersionMessage(scope, scopeName, key, requiredVersion, eager)) +/******/ return get(findLatestVersion(scope, key, eager)); +/******/ }); +/******/ var loadStrictVersion = /*#__PURE__*/ init((scopeName, scope, key, eager, requiredVersion, fallback) => { +/******/ if (!exists(scope, key)) return useFallback(scopeName, key, fallback); +/******/ var satisfyingVersion = findSatisfyingVersion(scope, key, requiredVersion, eager); +/******/ if (satisfyingVersion) return get(satisfyingVersion); +/******/ if (fallback) return fallback(); +/******/ fail(getInvalidVersionMessage(scope, scopeName, key, requiredVersion, eager)); +/******/ }); +/******/ var loadSingleton = /*#__PURE__*/ init((scopeName, scope, key, eager, fallback) => { +/******/ if (!exists(scope, key)) return useFallback(scopeName, key, fallback); +/******/ var version = findSingletonVersionKey(scope, key, eager); +/******/ return get(scope[key][version]); +/******/ }); +/******/ var loadSingletonVersion = /*#__PURE__*/ init((scopeName, scope, key, eager, requiredVersion, fallback) => { +/******/ if (!exists(scope, key)) return useFallback(scopeName, key, fallback); +/******/ var version = findSingletonVersionKey(scope, key, eager); +/******/ if (!satisfy(requiredVersion, version)) { +/******/ warn(getInvalidSingletonVersionMessage(scope, key, version, requiredVersion)); +/******/ } +/******/ return get(scope[key][version]); +/******/ }); +/******/ var loadStrictSingletonVersion = /*#__PURE__*/ init((scopeName, scope, key, eager, requiredVersion, fallback) => { +/******/ if (!exists(scope, key)) return useFallback(scopeName, key, fallback); +/******/ var version = findSingletonVersionKey(scope, key, eager); +/******/ if (!satisfy(requiredVersion, version)) { +/******/ fail(getInvalidSingletonVersionMessage(scope, key, version, requiredVersion)); +/******/ } +/******/ return get(scope[key][version]); +/******/ }); +/******/ var installedModules = {}; +/******/ var moduleToHandlerMapping = { +/******/ "webpack/sharing/consume/default/react-dom/react-dom": () => (loadSingletonVersion("default", "react-dom", true, [1,17,0,2], () => (() => (__webpack_require__(/*! react-dom */ "./node_modules/react-dom/index.js"))))), +/******/ "webpack/sharing/consume/default/react/react": () => (loadSingletonVersion("default", "react", true, [1,17,0,2], () => (() => (__webpack_require__(/*! react */ "./node_modules/react/index.js"))))) +/******/ }; +/******/ var initialConsumes = ["webpack/sharing/consume/default/react-dom/react-dom","webpack/sharing/consume/default/react/react"]; +/******/ initialConsumes.forEach((id) => { +/******/ __webpack_require__.m[id] = (module) => { +/******/ // Handle case when module is used sync +/******/ installedModules[id] = 0; +/******/ delete __webpack_require__.c[id]; +/******/ var factory = moduleToHandlerMapping[id](); +/******/ if(typeof factory !== "function") throw new Error("Shared module is not available for eager consumption: " + id); +/******/ module.exports = factory(); +/******/ } +/******/ }); +/******/ // no chunk loading of consumes +/******/ })(); +/******/ +/******/ /* webpack/runtime/jsonp chunk loading */ +/******/ (() => { +/******/ // no baseURI +/******/ +/******/ // object to store loaded and loading chunks +/******/ // undefined = chunk not loaded, null = chunk preloaded/prefetched +/******/ // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded +/******/ var installedChunks = __webpack_require__.hmrS_jsonp = __webpack_require__.hmrS_jsonp || { +/******/ "service-worker": 0, +/******/ "webpack_sharing_consume_default_react-dom_react-dom-webpack_sharing_consume_default_react_rea-153fef": 0 +/******/ }; +/******/ +/******/ // no chunk on demand loading +/******/ +/******/ // no prefetching +/******/ +/******/ // no preloaded +/******/ +/******/ var currentUpdatedModulesList; +/******/ var waitingUpdateResolves = {}; +/******/ function loadUpdateChunk(chunkId, updatedModulesList) { +/******/ currentUpdatedModulesList = updatedModulesList; +/******/ return new Promise((resolve, reject) => { +/******/ waitingUpdateResolves[chunkId] = resolve; +/******/ // start update chunk loading +/******/ var url = __webpack_require__.p + __webpack_require__.hu(chunkId); +/******/ // create error before stack unwound to get useful stacktrace later +/******/ var error = new Error(); +/******/ var loadingEnded = (event) => { +/******/ if(waitingUpdateResolves[chunkId]) { +/******/ waitingUpdateResolves[chunkId] = undefined +/******/ var errorType = event && (event.type === 'load' ? 'missing' : event.type); +/******/ var realSrc = event && event.target && event.target.src; +/******/ error.message = 'Loading hot update chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')'; +/******/ error.name = 'ChunkLoadError'; +/******/ error.type = errorType; +/******/ error.request = realSrc; +/******/ reject(error); +/******/ } +/******/ }; +/******/ __webpack_require__.l(url, loadingEnded); +/******/ }); +/******/ } +/******/ +/******/ globalThis["webpackHotUpdatesuperset"] = (chunkId, moreModules, runtime) => { +/******/ for(var moduleId in moreModules) { +/******/ if(__webpack_require__.o(moreModules, moduleId)) { +/******/ currentUpdate[moduleId] = moreModules[moduleId]; +/******/ if(currentUpdatedModulesList) currentUpdatedModulesList.push(moduleId); +/******/ } +/******/ } +/******/ if(runtime) currentUpdateRuntime.push(runtime); +/******/ if(waitingUpdateResolves[chunkId]) { +/******/ waitingUpdateResolves[chunkId](); +/******/ waitingUpdateResolves[chunkId] = undefined; +/******/ } +/******/ }; +/******/ +/******/ var currentUpdateChunks; +/******/ var currentUpdate; +/******/ var currentUpdateRemovedChunks; +/******/ var currentUpdateRuntime; +/******/ function applyHandler(options) { +/******/ if (__webpack_require__.f) delete __webpack_require__.f.jsonpHmr; +/******/ currentUpdateChunks = undefined; +/******/ function getAffectedModuleEffects(updateModuleId) { +/******/ var outdatedModules = [updateModuleId]; +/******/ var outdatedDependencies = {}; +/******/ +/******/ var queue = outdatedModules.map(function (id) { +/******/ return { +/******/ chain: [id], +/******/ id: id +/******/ }; +/******/ }); +/******/ while (queue.length > 0) { +/******/ var queueItem = queue.pop(); +/******/ var moduleId = queueItem.id; +/******/ var chain = queueItem.chain; +/******/ var module = __webpack_require__.c[moduleId]; +/******/ if ( +/******/ !module || +/******/ (module.hot._selfAccepted && !module.hot._selfInvalidated) +/******/ ) +/******/ continue; +/******/ if (module.hot._selfDeclined) { +/******/ return { +/******/ type: "self-declined", +/******/ chain: chain, +/******/ moduleId: moduleId +/******/ }; +/******/ } +/******/ if (module.hot._main) { +/******/ return { +/******/ type: "unaccepted", +/******/ chain: chain, +/******/ moduleId: moduleId +/******/ }; +/******/ } +/******/ for (var i = 0; i < module.parents.length; i++) { +/******/ var parentId = module.parents[i]; +/******/ var parent = __webpack_require__.c[parentId]; +/******/ if (!parent) continue; +/******/ if (parent.hot._declinedDependencies[moduleId]) { +/******/ return { +/******/ type: "declined", +/******/ chain: chain.concat([parentId]), +/******/ moduleId: moduleId, +/******/ parentId: parentId +/******/ }; +/******/ } +/******/ if (outdatedModules.indexOf(parentId) !== -1) continue; +/******/ if (parent.hot._acceptedDependencies[moduleId]) { +/******/ if (!outdatedDependencies[parentId]) +/******/ outdatedDependencies[parentId] = []; +/******/ addAllToSet(outdatedDependencies[parentId], [moduleId]); +/******/ continue; +/******/ } +/******/ delete outdatedDependencies[parentId]; +/******/ outdatedModules.push(parentId); +/******/ queue.push({ +/******/ chain: chain.concat([parentId]), +/******/ id: parentId +/******/ }); +/******/ } +/******/ } +/******/ +/******/ return { +/******/ type: "accepted", +/******/ moduleId: updateModuleId, +/******/ outdatedModules: outdatedModules, +/******/ outdatedDependencies: outdatedDependencies +/******/ }; +/******/ } +/******/ +/******/ function addAllToSet(a, b) { +/******/ for (var i = 0; i < b.length; i++) { +/******/ var item = b[i]; +/******/ if (a.indexOf(item) === -1) a.push(item); +/******/ } +/******/ } +/******/ +/******/ // at begin all updates modules are outdated +/******/ // the "outdated" status can propagate to parents if they don't accept the children +/******/ var outdatedDependencies = {}; +/******/ var outdatedModules = []; +/******/ var appliedUpdate = {}; +/******/ +/******/ var warnUnexpectedRequire = function warnUnexpectedRequire(module) { +/******/ console.warn( +/******/ "[HMR] unexpected require(" + module.id + ") to disposed module" +/******/ ); +/******/ }; +/******/ +/******/ for (var moduleId in currentUpdate) { +/******/ if (__webpack_require__.o(currentUpdate, moduleId)) { +/******/ var newModuleFactory = currentUpdate[moduleId]; +/******/ var result = newModuleFactory +/******/ ? getAffectedModuleEffects(moduleId) +/******/ : { +/******/ type: "disposed", +/******/ moduleId: moduleId +/******/ }; +/******/ /** @type {Error|false} */ +/******/ var abortError = false; +/******/ var doApply = false; +/******/ var doDispose = false; +/******/ var chainInfo = ""; +/******/ if (result.chain) { +/******/ chainInfo = "\nUpdate propagation: " + result.chain.join(" -> "); +/******/ } +/******/ switch (result.type) { +/******/ case "self-declined": +/******/ if (options.onDeclined) options.onDeclined(result); +/******/ if (!options.ignoreDeclined) +/******/ abortError = new Error( +/******/ "Aborted because of self decline: " + +/******/ result.moduleId + +/******/ chainInfo +/******/ ); +/******/ break; +/******/ case "declined": +/******/ if (options.onDeclined) options.onDeclined(result); +/******/ if (!options.ignoreDeclined) +/******/ abortError = new Error( +/******/ "Aborted because of declined dependency: " + +/******/ result.moduleId + +/******/ " in " + +/******/ result.parentId + +/******/ chainInfo +/******/ ); +/******/ break; +/******/ case "unaccepted": +/******/ if (options.onUnaccepted) options.onUnaccepted(result); +/******/ if (!options.ignoreUnaccepted) +/******/ abortError = new Error( +/******/ "Aborted because " + moduleId + " is not accepted" + chainInfo +/******/ ); +/******/ break; +/******/ case "accepted": +/******/ if (options.onAccepted) options.onAccepted(result); +/******/ doApply = true; +/******/ break; +/******/ case "disposed": +/******/ if (options.onDisposed) options.onDisposed(result); +/******/ doDispose = true; +/******/ break; +/******/ default: +/******/ throw new Error("Unexception type " + result.type); +/******/ } +/******/ if (abortError) { +/******/ return { +/******/ error: abortError +/******/ }; +/******/ } +/******/ if (doApply) { +/******/ appliedUpdate[moduleId] = newModuleFactory; +/******/ addAllToSet(outdatedModules, result.outdatedModules); +/******/ for (moduleId in result.outdatedDependencies) { +/******/ if (__webpack_require__.o(result.outdatedDependencies, moduleId)) { +/******/ if (!outdatedDependencies[moduleId]) +/******/ outdatedDependencies[moduleId] = []; +/******/ addAllToSet( +/******/ outdatedDependencies[moduleId], +/******/ result.outdatedDependencies[moduleId] +/******/ ); +/******/ } +/******/ } +/******/ } +/******/ if (doDispose) { +/******/ addAllToSet(outdatedModules, [result.moduleId]); +/******/ appliedUpdate[moduleId] = warnUnexpectedRequire; +/******/ } +/******/ } +/******/ } +/******/ currentUpdate = undefined; +/******/ +/******/ // Store self accepted outdated modules to require them later by the module system +/******/ var outdatedSelfAcceptedModules = []; +/******/ for (var j = 0; j < outdatedModules.length; j++) { +/******/ var outdatedModuleId = outdatedModules[j]; +/******/ var module = __webpack_require__.c[outdatedModuleId]; +/******/ if ( +/******/ module && +/******/ (module.hot._selfAccepted || module.hot._main) && +/******/ // removed self-accepted modules should not be required +/******/ appliedUpdate[outdatedModuleId] !== warnUnexpectedRequire && +/******/ // when called invalidate self-accepting is not possible +/******/ !module.hot._selfInvalidated +/******/ ) { +/******/ outdatedSelfAcceptedModules.push({ +/******/ module: outdatedModuleId, +/******/ require: module.hot._requireSelf, +/******/ errorHandler: module.hot._selfAccepted +/******/ }); +/******/ } +/******/ } +/******/ +/******/ var moduleOutdatedDependencies; +/******/ +/******/ return { +/******/ dispose: function () { +/******/ currentUpdateRemovedChunks.forEach(function (chunkId) { +/******/ delete installedChunks[chunkId]; +/******/ }); +/******/ currentUpdateRemovedChunks = undefined; +/******/ +/******/ var idx; +/******/ var queue = outdatedModules.slice(); +/******/ while (queue.length > 0) { +/******/ var moduleId = queue.pop(); +/******/ var module = __webpack_require__.c[moduleId]; +/******/ if (!module) continue; +/******/ +/******/ var data = {}; +/******/ +/******/ // Call dispose handlers +/******/ var disposeHandlers = module.hot._disposeHandlers; +/******/ for (j = 0; j < disposeHandlers.length; j++) { +/******/ disposeHandlers[j].call(null, data); +/******/ } +/******/ __webpack_require__.hmrD[moduleId] = data; +/******/ +/******/ // disable module (this disables requires from this module) +/******/ module.hot.active = false; +/******/ +/******/ // remove module from cache +/******/ delete __webpack_require__.c[moduleId]; +/******/ +/******/ // when disposing there is no need to call dispose handler +/******/ delete outdatedDependencies[moduleId]; +/******/ +/******/ // remove "parents" references from all children +/******/ for (j = 0; j < module.children.length; j++) { +/******/ var child = __webpack_require__.c[module.children[j]]; +/******/ if (!child) continue; +/******/ idx = child.parents.indexOf(moduleId); +/******/ if (idx >= 0) { +/******/ child.parents.splice(idx, 1); +/******/ } +/******/ } +/******/ } +/******/ +/******/ // remove outdated dependency from module children +/******/ var dependency; +/******/ for (var outdatedModuleId in outdatedDependencies) { +/******/ if (__webpack_require__.o(outdatedDependencies, outdatedModuleId)) { +/******/ module = __webpack_require__.c[outdatedModuleId]; +/******/ if (module) { +/******/ moduleOutdatedDependencies = +/******/ outdatedDependencies[outdatedModuleId]; +/******/ for (j = 0; j < moduleOutdatedDependencies.length; j++) { +/******/ dependency = moduleOutdatedDependencies[j]; +/******/ idx = module.children.indexOf(dependency); +/******/ if (idx >= 0) module.children.splice(idx, 1); +/******/ } +/******/ } +/******/ } +/******/ } +/******/ }, +/******/ apply: function (reportError) { +/******/ var acceptPromises = []; +/******/ // insert new code +/******/ for (var updateModuleId in appliedUpdate) { +/******/ if (__webpack_require__.o(appliedUpdate, updateModuleId)) { +/******/ __webpack_require__.m[updateModuleId] = appliedUpdate[updateModuleId]; +/******/ } +/******/ } +/******/ +/******/ // run new runtime modules +/******/ for (var i = 0; i < currentUpdateRuntime.length; i++) { +/******/ currentUpdateRuntime[i](__webpack_require__); +/******/ } +/******/ +/******/ // call accept handlers +/******/ for (var outdatedModuleId in outdatedDependencies) { +/******/ if (__webpack_require__.o(outdatedDependencies, outdatedModuleId)) { +/******/ var module = __webpack_require__.c[outdatedModuleId]; +/******/ if (module) { +/******/ moduleOutdatedDependencies = +/******/ outdatedDependencies[outdatedModuleId]; +/******/ var callbacks = []; +/******/ var errorHandlers = []; +/******/ var dependenciesForCallbacks = []; +/******/ for (var j = 0; j < moduleOutdatedDependencies.length; j++) { +/******/ var dependency = moduleOutdatedDependencies[j]; +/******/ var acceptCallback = +/******/ module.hot._acceptedDependencies[dependency]; +/******/ var errorHandler = +/******/ module.hot._acceptedErrorHandlers[dependency]; +/******/ if (acceptCallback) { +/******/ if (callbacks.indexOf(acceptCallback) !== -1) continue; +/******/ callbacks.push(acceptCallback); +/******/ errorHandlers.push(errorHandler); +/******/ dependenciesForCallbacks.push(dependency); +/******/ } +/******/ } +/******/ for (var k = 0; k < callbacks.length; k++) { +/******/ var result; +/******/ try { +/******/ result = callbacks[k].call(null, moduleOutdatedDependencies); +/******/ } catch (err) { +/******/ if (typeof errorHandlers[k] === "function") { +/******/ try { +/******/ errorHandlers[k](err, { +/******/ moduleId: outdatedModuleId, +/******/ dependencyId: dependenciesForCallbacks[k] +/******/ }); +/******/ } catch (err2) { +/******/ if (options.onErrored) { +/******/ options.onErrored({ +/******/ type: "accept-error-handler-errored", +/******/ moduleId: outdatedModuleId, +/******/ dependencyId: dependenciesForCallbacks[k], +/******/ error: err2, +/******/ originalError: err +/******/ }); +/******/ } +/******/ if (!options.ignoreErrored) { +/******/ reportError(err2); +/******/ reportError(err); +/******/ } +/******/ } +/******/ } else { +/******/ if (options.onErrored) { +/******/ options.onErrored({ +/******/ type: "accept-errored", +/******/ moduleId: outdatedModuleId, +/******/ dependencyId: dependenciesForCallbacks[k], +/******/ error: err +/******/ }); +/******/ } +/******/ if (!options.ignoreErrored) { +/******/ reportError(err); +/******/ } +/******/ } +/******/ } +/******/ if (result && typeof result.then === "function") { +/******/ acceptPromises.push(result); +/******/ } +/******/ } +/******/ } +/******/ } +/******/ } +/******/ +/******/ var onAccepted = function () { +/******/ // Load self accepted modules +/******/ for (var o = 0; o < outdatedSelfAcceptedModules.length; o++) { +/******/ var item = outdatedSelfAcceptedModules[o]; +/******/ var moduleId = item.module; +/******/ try { +/******/ item.require(moduleId); +/******/ } catch (err) { +/******/ if (typeof item.errorHandler === "function") { +/******/ try { +/******/ item.errorHandler(err, { +/******/ moduleId: moduleId, +/******/ module: __webpack_require__.c[moduleId] +/******/ }); +/******/ } catch (err1) { +/******/ if (options.onErrored) { +/******/ options.onErrored({ +/******/ type: "self-accept-error-handler-errored", +/******/ moduleId: moduleId, +/******/ error: err1, +/******/ originalError: err +/******/ }); +/******/ } +/******/ if (!options.ignoreErrored) { +/******/ reportError(err1); +/******/ reportError(err); +/******/ } +/******/ } +/******/ } else { +/******/ if (options.onErrored) { +/******/ options.onErrored({ +/******/ type: "self-accept-errored", +/******/ moduleId: moduleId, +/******/ error: err +/******/ }); +/******/ } +/******/ if (!options.ignoreErrored) { +/******/ reportError(err); +/******/ } +/******/ } +/******/ } +/******/ } +/******/ }; +/******/ +/******/ return Promise.all(acceptPromises) +/******/ .then(onAccepted) +/******/ .then(function () { +/******/ return outdatedModules; +/******/ }); +/******/ } +/******/ }; +/******/ } +/******/ __webpack_require__.hmrI.jsonp = function (moduleId, applyHandlers) { +/******/ if (!currentUpdate) { +/******/ currentUpdate = {}; +/******/ currentUpdateRuntime = []; +/******/ currentUpdateRemovedChunks = []; +/******/ applyHandlers.push(applyHandler); +/******/ } +/******/ if (!__webpack_require__.o(currentUpdate, moduleId)) { +/******/ currentUpdate[moduleId] = __webpack_require__.m[moduleId]; +/******/ } +/******/ }; +/******/ __webpack_require__.hmrC.jsonp = function ( +/******/ chunkIds, +/******/ removedChunks, +/******/ removedModules, +/******/ promises, +/******/ applyHandlers, +/******/ updatedModulesList +/******/ ) { +/******/ applyHandlers.push(applyHandler); +/******/ currentUpdateChunks = {}; +/******/ currentUpdateRemovedChunks = removedChunks; +/******/ currentUpdate = removedModules.reduce(function (obj, key) { +/******/ obj[key] = false; +/******/ return obj; +/******/ }, {}); +/******/ currentUpdateRuntime = []; +/******/ chunkIds.forEach(function (chunkId) { +/******/ if ( +/******/ __webpack_require__.o(installedChunks, chunkId) && +/******/ installedChunks[chunkId] !== undefined +/******/ ) { +/******/ promises.push(loadUpdateChunk(chunkId, updatedModulesList)); +/******/ currentUpdateChunks[chunkId] = true; +/******/ } else { +/******/ currentUpdateChunks[chunkId] = false; +/******/ } +/******/ }); +/******/ if (__webpack_require__.f) { +/******/ __webpack_require__.f.jsonpHmr = function (chunkId, promises) { +/******/ if ( +/******/ currentUpdateChunks && +/******/ __webpack_require__.o(currentUpdateChunks, chunkId) && +/******/ !currentUpdateChunks[chunkId] +/******/ ) { +/******/ promises.push(loadUpdateChunk(chunkId)); +/******/ currentUpdateChunks[chunkId] = true; +/******/ } +/******/ }; +/******/ } +/******/ }; +/******/ +/******/ __webpack_require__.hmrM = () => { +/******/ if (typeof fetch === "undefined") throw new Error("No browser support: need fetch API"); +/******/ return fetch(__webpack_require__.p + __webpack_require__.hmrF()).then((response) => { +/******/ if(response.status === 404) return; // no update available +/******/ if(!response.ok) throw new Error("Failed to fetch update manifest " + response.statusText); +/******/ return response.json(); +/******/ }); +/******/ }; +/******/ +/******/ __webpack_require__.O.j = (chunkId) => (installedChunks[chunkId] === 0); +/******/ +/******/ // install a JSONP callback for chunk loading +/******/ var webpackJsonpCallback = (parentChunkLoadingFunction, data) => { +/******/ var [chunkIds, moreModules, runtime] = data; +/******/ // add "moreModules" to the modules object, +/******/ // then flag all "chunkIds" as loaded and fire callback +/******/ var moduleId, chunkId, i = 0; +/******/ if(chunkIds.some((id) => (installedChunks[id] !== 0))) { +/******/ for(moduleId in moreModules) { +/******/ if(__webpack_require__.o(moreModules, moduleId)) { +/******/ __webpack_require__.m[moduleId] = moreModules[moduleId]; +/******/ } +/******/ } +/******/ if(runtime) var result = runtime(__webpack_require__); +/******/ } +/******/ if(parentChunkLoadingFunction) parentChunkLoadingFunction(data); +/******/ for(;i < chunkIds.length; i++) { +/******/ chunkId = chunkIds[i]; +/******/ if(__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) { +/******/ installedChunks[chunkId][0](); +/******/ } +/******/ installedChunks[chunkId] = 0; +/******/ } +/******/ return __webpack_require__.O(result); +/******/ } +/******/ +/******/ var chunkLoadingGlobal = globalThis["webpackChunksuperset"] = globalThis["webpackChunksuperset"] || []; +/******/ chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0)); +/******/ chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal)); +/******/ })(); +/******/ +/************************************************************************/ +/******/ +/******/ // module cache are used so entry inlining is disabled +/******/ // startup +/******/ // Load entry module and return exports +/******/ __webpack_require__.O(undefined, ["vendors","vendors-node_modules_rc-component_color-picker_es_index_js-node_modules_rc-component_mutate-o-484854","webpack_sharing_consume_default_react-dom_react-dom-webpack_sharing_consume_default_react_rea-153fef"], () => (__webpack_require__("./node_modules/@pmmmwh/react-refresh-webpack-plugin/client/ReactRefreshEntry.js"))) +/******/ __webpack_require__.O(undefined, ["vendors","vendors-node_modules_rc-component_color-picker_es_index_js-node_modules_rc-component_mutate-o-484854","webpack_sharing_consume_default_react-dom_react-dom-webpack_sharing_consume_default_react_rea-153fef"], () => (__webpack_require__("./node_modules/webpack-dev-server/client/index.js?protocol=ws%3A&hostname=0.0.0.0&port=0&pathname=%2Fws&logging=info&overlay=%7B%22errors%22%3Atrue%2C%22warnings%22%3Afalse%2C%22runtimeErrors%22%3A%22error%2520%253D%253E%2520%21%252FResizeObserver%252F.test%28error.message%29%22%7D&reconnect=10&hot=only&live-reload=false"))) +/******/ __webpack_require__.O(undefined, ["vendors","vendors-node_modules_rc-component_color-picker_es_index_js-node_modules_rc-component_mutate-o-484854","webpack_sharing_consume_default_react-dom_react-dom-webpack_sharing_consume_default_react_rea-153fef"], () => (__webpack_require__("./node_modules/webpack/hot/only-dev-server.js"))) +/******/ __webpack_require__.O(undefined, ["vendors","vendors-node_modules_rc-component_color-picker_es_index_js-node_modules_rc-component_mutate-o-484854","webpack_sharing_consume_default_react-dom_react-dom-webpack_sharing_consume_default_react_rea-153fef"], () => (__webpack_require__("./node_modules/@pmmmwh/react-refresh-webpack-plugin/client/ErrorOverlayEntry.js"))) +/******/ var __webpack_exports__ = __webpack_require__.O(undefined, ["vendors","vendors-node_modules_rc-component_color-picker_es_index_js-node_modules_rc-component_mutate-o-484854","webpack_sharing_consume_default_react-dom_react-dom-webpack_sharing_consume_default_react_rea-153fef"], () => (__webpack_require__("./src/service-worker.ts"))) +/******/ __webpack_exports__ = __webpack_require__.O(__webpack_exports__); +/******/ +/******/ })() +; \ No newline at end of file From 13ed9b5bad8c11dbc89225849854630f03de71b4 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Mon, 16 Feb 2026 16:32:56 +0100 Subject: [PATCH 06/16] fix CI test --- .../cypress/e2e/database/modal.test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts b/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts index fe0c43350395..557b1f038ff2 100644 --- a/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts @@ -68,12 +68,11 @@ describe('Add database', () => { cy.get('input[name="username"]').type('testusername', { force: true }); cy.get('input[name="database"]').type('testdb', { force: true }); cy.get('input[name="password"]').type('testpass', { force: true }); - cy.get('input[name="password"]').blur(); + cy.get('body').click(0, 0); - // Wait for the button to be enabled after validation completes on blur - cy.getBySel('btn-submit-connection').should('not.be.disabled', { - timeout: 30000, - }); + cy.wait('@validateParams', { timeout: 30000 }); + + cy.getBySel('btn-submit-connection').should('not.be.disabled'); cy.getBySel('btn-submit-connection').click({ force: true }); cy.wait('@createDb', { timeout: 60000 }).then(() => { @@ -92,12 +91,11 @@ describe('Add database', () => { cy.get('input[name="database"]').type('testdb', { force: true }); cy.get('input[name="username"]').type('testusername', { force: true }); cy.get('input[name="password"]').type('testpass', { force: true }); - cy.get('input[name="password"]').blur(); + cy.get('body').click(0, 0); - // Wait for the button to be enabled after validation completes on blur - cy.getBySel('btn-submit-connection').should('not.be.disabled', { - timeout: 30000, - }); + cy.wait('@validateParams', { timeout: 30000 }); + + cy.getBySel('btn-submit-connection').should('not.be.disabled'); cy.getBySel('btn-submit-connection').click({ force: true }); cy.wait('@createDb', { timeout: 60000 }).then(() => { From e7c9cf0d043bfabf57c9ee2197c6ae12254b7f78 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 18 Mar 2026 11:57:35 +0100 Subject: [PATCH 07/16] refactor(database): simplify SSH tunnel error accumulation in useDatabaseValidation --- superset-frontend/src/views/CRUD/hooks.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index cb3115db3826..dd48ce49309c 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -859,16 +859,16 @@ export function useDatabaseValidation() { return acc; } - // Handle SSH tunnel errors if (extra?.ssh_tunnel) { - if (!acc.ssh_tunnel) { - acc.ssh_tunnel = {}; - } - if (extra?.missing) { - extra.missing.forEach((field: string) => { - acc.ssh_tunnel[field] = 'This is a required field'; - }); - } + acc.ssh_tunnel = { + ...acc.ssh_tunnel, + ...Object.fromEntries( + (extra.missing ?? []).map((field: string) => [ + field, + 'This is a required field', + ]), + ), + }; return acc; } From 15b28631bf17d6ca688ab3650c4fd87cd48a4863 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 18 Mar 2026 14:44:13 +0100 Subject: [PATCH 08/16] chore: remove duplicated handleClearValidationErrors function --- .../features/databases/DatabaseModal/SSHTunnelForm.tsx | 6 ++++-- .../src/features/databases/DatabaseModal/index.tsx | 8 +------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx b/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx index d03c2d638540..41678bcad23e 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx @@ -26,6 +26,8 @@ import { Col, Row, LabeledErrorBoundInput, + Icons, + Tooltip, } from '@superset-ui/core/components'; import { Input } from '@superset-ui/core/components/Input'; import { Radio } from '@superset-ui/core/components/Radio'; @@ -173,7 +175,7 @@ const SSHTunnelForm = ({ errorMessage={sshErrors?.password} isValidating={isValidating} data-test="ssh-tunnel-password-input" - iconRender={visible => + iconRender={(visible: boolean) => visible ? ( @@ -238,7 +240,7 @@ const SSHTunnelForm = ({ errorMessage={sshErrors?.private_key_password} isValidating={isValidating} data-test="ssh-tunnel-private_key_password-input" - iconRender={visible => + iconRender={(visible: boolean) => visible ? ( diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index b20a4c80f7f6..8474cde2af66 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -725,6 +725,7 @@ const DatabaseModal: FunctionComponent = ({ const handleClearValidationErrors = useCallback(() => { setValidationErrors(null); setHasValidated(false); + lastValidatedDbSnapshotRef.current = null; clearError(); }, [setValidationErrors, setHasValidated, clearError]); @@ -789,13 +790,6 @@ const DatabaseModal: FunctionComponent = ({ [], ); - const handleClearValidationErrors = useCallback(() => { - setValidationErrors(null); - setHasValidated(false); - lastValidatedDbSnapshotRef.current = null; - clearError(); - }, [setValidationErrors, setHasValidated, clearError]); - const handleParametersChange = useCallback( ({ target }: { target: HTMLInputElement }) => { onChange(ActionType.ParametersChange, { From 6c69cc23eaba2688dc52bd6585a90482193a1b50 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 18 Mar 2026 14:56:58 +0100 Subject: [PATCH 09/16] lint --- .../cypress-base/cypress/e2e/database/modal.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts b/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts index 557b1f038ff2..dba0a56b55bd 100644 --- a/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts @@ -99,10 +99,9 @@ describe('Add database', () => { cy.getBySel('btn-submit-connection').click({ force: true }); cy.wait('@createDb', { timeout: 60000 }).then(() => { - cy.contains( - '.ant-form-item-explain-error', - 'The port is closed', - ).should('exist'); + cy.contains('.ant-form-item-explain-error', 'The port is closed').should( + 'exist', + ); }); }); }); From f6ac345ef39be69c98a7c76d905b7f8b29fc6e28 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 18 Mar 2026 15:24:12 +0100 Subject: [PATCH 10/16] fix(cypress): wait for final validation to settle before asserting button state --- .../cypress/e2e/database/modal.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts b/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts index dba0a56b55bd..45859e230a5f 100644 --- a/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts @@ -70,9 +70,10 @@ describe('Add database', () => { cy.get('input[name="password"]').type('testpass', { force: true }); cy.get('body').click(0, 0); - cy.wait('@validateParams', { timeout: 30000 }); - - cy.getBySel('btn-submit-connection').should('not.be.disabled'); + // Wait for all intermediate validation calls to settle, then check the button + cy.getBySel('btn-submit-connection').should('not.be.disabled', { + timeout: 60000, + }); cy.getBySel('btn-submit-connection').click({ force: true }); cy.wait('@createDb', { timeout: 60000 }).then(() => { @@ -93,9 +94,10 @@ describe('Add database', () => { cy.get('input[name="password"]').type('testpass', { force: true }); cy.get('body').click(0, 0); - cy.wait('@validateParams', { timeout: 30000 }); - - cy.getBySel('btn-submit-connection').should('not.be.disabled'); + // Wait for all intermediate validation calls to settle, then check the button + cy.getBySel('btn-submit-connection').should('not.be.disabled', { + timeout: 60000, + }); cy.getBySel('btn-submit-connection').click({ force: true }); cy.wait('@createDb', { timeout: 60000 }).then(() => { From 15ec3bbb87732680d43097aa93c20ce5490e8c47 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 6 May 2026 11:21:16 +0200 Subject: [PATCH 11/16] fix(database): prevent stale validation errors from out-of-order responses The blur-debounced validation can fire multiple in-flight requests as the user types across fields. Without sequencing, an earlier request that returns later overwrites the result of a newer one, leaving the modal showing stale errors and keeping the Connect button disabled even after the form is fully valid. Track a per-call request id and only update validation state when the response corresponds to the latest request. Co-Authored-By: Claude Opus 4.7 (1M context) --- superset-frontend/src/views/CRUD/hooks.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index df91cc9ddf5a..b7dae7a1e4fd 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -819,9 +819,13 @@ export function useDatabaseValidation() { ); const [isValidating, setIsValidating] = useState(false); const [hasValidated, setHasValidated] = useState(false); + const latestRequestIdRef = useRef(0); const getValidation = useCallback( async (database: Partial | null, onCreate = false) => { + const requestId = latestRequestIdRef.current + 1; + latestRequestIdRef.current = requestId; + const isLatest = () => latestRequestIdRef.current === requestId; setIsValidating(true); try { @@ -830,6 +834,7 @@ export function useDatabaseValidation() { body: JSON.stringify(transformDB(database)), headers: { 'Content-Type': 'application/json' }, }); + if (!isLatest()) return []; setValidationErrors(null); setIsValidating(false); setHasValidated(true); @@ -891,6 +896,7 @@ export function useDatabaseValidation() { return acc; }, {}); + if (!isLatest()) return parsedErrors; setValidationErrors(parsedErrors); setIsValidating(false); setHasValidated(true); @@ -899,8 +905,10 @@ export function useDatabaseValidation() { } console.error('Unexpected error during validation:', error); - setIsValidating(false); - setHasValidated(true); + if (isLatest()) { + setIsValidating(false); + setHasValidated(true); + } return {}; } }, From 9651d0e6737103d1cf51e856c069634c8a392de8 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 6 May 2026 11:21:33 +0200 Subject: [PATCH 12/16] test(database): stabilize DatabaseModal jest tests on slow CI userEvent.type fires keystrokes serially through React's event system, which races with the new debounced validation: ports rendered as number inputs lose values mid-typing, and dynamic-form tests time out at the 20s default while tabbing between five fields. userEvent.paste also no longer supplies a clipboardData object the Select onPaste handler can read. Switch the affected interactions to fireEvent.change/blur (single-shot, no per-key validation churn) and revert the paste case to fireEvent.paste, matching the master form. Behavior under test is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../databases/DatabaseModal/index.test.tsx | 67 ++++++++++++------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx index b8eac7dcf2b6..2a9b259288cc 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx @@ -26,6 +26,7 @@ import { userEvent, within, waitFor, + fireEvent, } from 'spec/helpers/testing-library'; import { getExtensionsRegistry } from '@superset-ui/core'; import setupCodeOverrides from 'src/setup/setupCodeOverrides'; @@ -435,7 +436,11 @@ describe('DatabaseModal', () => { userEvent.click(selectInput); // Simulate pasting text into the input - expect(() => userEvent.paste(selectInput, 'post')).not.toThrow(); + expect(() => + fireEvent.paste(selectInput, { + clipboardData: { getData: () => 'post' }, + }), + ).not.toThrow(); }); test('renders the "Basic" tab of SQL Alchemy form (step 2 of 2) correctly', async () => { @@ -1207,7 +1212,9 @@ describe('DatabaseModal', () => { 'ssh-tunnel-server_address-input', ); expect(SSHTunnelServerAddressInput).toHaveValue(''); - userEvent.type(SSHTunnelServerAddressInput, 'localhost'); + fireEvent.change(SSHTunnelServerAddressInput, { + target: { value: 'localhost' }, + }); await waitFor(() => expect(SSHTunnelServerAddressInput).toHaveValue('localhost'), ); @@ -1215,13 +1222,17 @@ describe('DatabaseModal', () => { 'ssh-tunnel-server_port-input', ); expect(SSHTunnelServerPortInput).toHaveValue(null); - userEvent.type(SSHTunnelServerPortInput, '22'); + fireEvent.change(SSHTunnelServerPortInput, { + target: { value: '22' }, + }); await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22)); const SSHTunnelUsernameInput = screen.getByTestId( 'ssh-tunnel-username-input', ); expect(SSHTunnelUsernameInput).toHaveValue(''); - userEvent.type(SSHTunnelUsernameInput, 'test'); + fireEvent.change(SSHTunnelUsernameInput, { + target: { value: 'test' }, + }); await waitFor(() => expect(SSHTunnelUsernameInput).toHaveValue('test'), ); @@ -1229,7 +1240,9 @@ describe('DatabaseModal', () => { 'ssh-tunnel-password-input', ); expect(SSHTunnelPasswordInput).toHaveValue(''); - userEvent.type(SSHTunnelPasswordInput, 'pass'); + fireEvent.change(SSHTunnelPasswordInput, { + target: { value: 'pass' }, + }); await waitFor(() => expect(SSHTunnelPasswordInput).toHaveValue('pass'), ); @@ -1251,7 +1264,9 @@ describe('DatabaseModal', () => { 'ssh-tunnel-server_address-input', ); expect(SSHTunnelServerAddressInput).toHaveValue(''); - userEvent.type(SSHTunnelServerAddressInput, 'localhost'); + fireEvent.change(SSHTunnelServerAddressInput, { + target: { value: 'localhost' }, + }); await waitFor(() => expect(SSHTunnelServerAddressInput).toHaveValue('localhost'), ); @@ -1259,13 +1274,17 @@ describe('DatabaseModal', () => { 'ssh-tunnel-server_port-input', ); expect(SSHTunnelServerPortInput).toHaveValue(null); - userEvent.type(SSHTunnelServerPortInput, '22'); + fireEvent.change(SSHTunnelServerPortInput, { + target: { value: '22' }, + }); await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22)); const SSHTunnelUsernameInput = screen.getByTestId( 'ssh-tunnel-username-input', ); expect(SSHTunnelUsernameInput).toHaveValue(''); - userEvent.type(SSHTunnelUsernameInput, 'test'); + fireEvent.change(SSHTunnelUsernameInput, { + target: { value: 'test' }, + }); await waitFor(() => expect(SSHTunnelUsernameInput).toHaveValue('test'), ); @@ -1273,7 +1292,9 @@ describe('DatabaseModal', () => { 'ssh-tunnel-password-input', ); expect(SSHTunnelPasswordInput).toHaveValue(''); - userEvent.type(SSHTunnelPasswordInput, 'pass'); + fireEvent.change(SSHTunnelPasswordInput, { + target: { value: 'pass' }, + }); await waitFor(() => expect(SSHTunnelPasswordInput).toHaveValue('pass'), ); @@ -1390,16 +1411,16 @@ describe('DatabaseModal', () => { expect(connectButton).toBeDisabled(); - userEvent.type(hostField, 'localhost'); - userEvent.tab(); - userEvent.type(portField, '5432'); - userEvent.tab(); - userEvent.type(databaseNameField, 'postgres'); - userEvent.tab(); - userEvent.type(usernameField, 'testdb'); - userEvent.tab(); - userEvent.type(passwordField, 'demoPassword'); - userEvent.tab(); + fireEvent.change(hostField, { target: { value: 'localhost' } }); + fireEvent.blur(hostField); + fireEvent.change(portField, { target: { value: '5432' } }); + fireEvent.blur(portField); + fireEvent.change(databaseNameField, { target: { value: 'postgres' } }); + fireEvent.blur(databaseNameField); + fireEvent.change(usernameField, { target: { value: 'testdb' } }); + fireEvent.blur(usernameField); + fireEvent.change(passwordField, { target: { value: 'demoPassword' } }); + fireEvent.blur(passwordField); await waitFor(() => expect(connectButton).toBeEnabled()); @@ -1436,8 +1457,8 @@ describe('DatabaseModal', () => { const hostField = textboxes[0]; // Type a value and blur - should trigger validation - userEvent.type(hostField, 'localhost'); - userEvent.tab(); + fireEvent.change(hostField, { target: { value: 'localhost' } }); + fireEvent.blur(hostField); await waitFor(() => { expect( @@ -1446,8 +1467,8 @@ describe('DatabaseModal', () => { }); // Blur again without changing the value - should NOT trigger another validation - userEvent.click(hostField); - userEvent.tab(); + fireEvent.focus(hostField); + fireEvent.blur(hostField); // Wait a tick to ensure no additional calls are made await waitFor(() => { From 86b3d8922ca3db6a1735ed03372cd21d80d3e9e9 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 6 May 2026 11:21:53 +0200 Subject: [PATCH 13/16] test(database): cover new validate command branches Adds unit tests for the duplicate-database-name check (create + update paths, plus the bypass-engine path), the SSH tunnel feature-flag and db-port guards, and the SSH tunnel field-level error collection (missing required fields, missing credentials, private key without password). Brings patch coverage on commands/database/validate.py up from ~44%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../commands/databases/validate_test.py | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/tests/unit_tests/commands/databases/validate_test.py b/tests/unit_tests/commands/databases/validate_test.py index 96f613315fe5..ad6238cbe365 100644 --- a/tests/unit_tests/commands/databases/validate_test.py +++ b/tests/unit_tests/commands/databases/validate_test.py @@ -23,6 +23,10 @@ DatabaseTestConnectionFailedError, InvalidParametersError, ) +from superset.commands.database.ssh_tunnel.exceptions import ( + SSHTunnelDatabasePortError, + SSHTunnelingNotEnabledError, +) from superset.commands.database.validate import ValidateDatabaseParametersCommand from superset.errors import ErrorLevel, SupersetError, SupersetErrorType @@ -205,3 +209,264 @@ def test_command_with_oauth2_not_configured(mocker: MockerFixture) -> None: extra={"engine_name": "gsheets"}, ) ] + + +def test_command_duplicate_database_name(mocker: MockerFixture) -> None: + """ + Validation surfaces a duplicate-name error for a new database with a + name already in use. + """ + DatabaseDAO = mocker.patch( # noqa: N806 + "superset.commands.database.validate.DatabaseDAO" + ) + DatabaseDAO.validate_uniqueness.return_value = False + mocker.patch( + "superset.commands.database.validate.get_engine_spec", + return_value=mocker.MagicMock( + validate_parameters=mocker.MagicMock(return_value=[]), + ), + ) + + properties = { + "engine": "postgresql", + "database_name": "duplicate", + "parameters": { + "host": "localhost", + "port": 5432, + "username": "u", + "database": "d", + }, + } + command = ValidateDatabaseParametersCommand(properties) + with pytest.raises(InvalidParametersError) as excinfo: + command.run() + assert any( + err.error_type == SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR + and err.extra is not None + and err.extra.get("invalid") == ["database_name"] + for err in excinfo.value.errors + ) + + +def test_command_duplicate_database_name_on_update(mocker: MockerFixture) -> None: + """ + Validation uses ``validate_update_uniqueness`` when an ``id`` is provided. + """ + DatabaseDAO = mocker.patch( # noqa: N806 + "superset.commands.database.validate.DatabaseDAO" + ) + DatabaseDAO.find_by_id.return_value = mocker.MagicMock() + DatabaseDAO.validate_update_uniqueness.return_value = False + mocker.patch( + "superset.commands.database.validate.get_engine_spec", + return_value=mocker.MagicMock( + validate_parameters=mocker.MagicMock(return_value=[]), + ), + ) + + properties = { + "id": 1, + "engine": "postgresql", + "database_name": "existing", + "parameters": { + "host": "localhost", + "port": 5432, + "username": "u", + "database": "d", + }, + } + command = ValidateDatabaseParametersCommand(properties) + with pytest.raises(InvalidParametersError): + command.run() + DatabaseDAO.validate_update_uniqueness.assert_called_once_with(1, "existing") + + +def test_command_duplicate_database_name_bypass_engine( + mocker: MockerFixture, +) -> None: + """ + Bypass engines (e.g. ``bigquery``) still validate database name uniqueness. + """ + DatabaseDAO = mocker.patch( # noqa: N806 + "superset.commands.database.validate.DatabaseDAO" + ) + DatabaseDAO.validate_uniqueness.return_value = False + + properties = { + "engine": "bigquery", + "database_name": "duplicate", + } + command = ValidateDatabaseParametersCommand(properties) + with pytest.raises(InvalidParametersError) as excinfo: + command.run() + assert excinfo.value.errors[0].error_type == ( + SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR + ) + + +def test_validate_ssh_tunnel_feature_disabled(mocker: MockerFixture) -> None: + """ + Enabling SSH tunnel without the feature flag raises an error. + """ + mocker.patch( + "superset.commands.database.validate.is_feature_enabled", + return_value=False, + ) + + properties = { + "engine": "postgresql", + "ssh_tunnel": {"server_address": "localhost"}, + } + command = ValidateDatabaseParametersCommand(properties) + with pytest.raises(SSHTunnelingNotEnabledError): + command.run() + + +def test_validate_ssh_tunnel_missing_db_port(mocker: MockerFixture) -> None: + """ + SSH tunneling requires a database port. + """ + mocker.patch( + "superset.commands.database.validate.is_feature_enabled", + return_value=True, + ) + + properties = { + "engine": "postgresql", + "ssh_tunnel": {"server_address": "localhost"}, + "parameters": {"host": "localhost"}, + } + command = ValidateDatabaseParametersCommand(properties) + with pytest.raises(SSHTunnelDatabasePortError): + command.run() + + +def test_get_ssh_tunnel_errors_missing_required_fields( + mocker: MockerFixture, +) -> None: + """ + SSH tunnel collects missing required fields (server_address, server_port, + username) and missing credentials. + """ + mocker.patch( + "superset.commands.database.validate.is_feature_enabled", + return_value=True, + ) + mocker.patch( + "superset.commands.database.validate.get_engine_spec", + return_value=mocker.MagicMock( + validate_parameters=mocker.MagicMock(return_value=[]), + ), + ) + + properties = { + "engine": "postgresql", + "parameters": { + "host": "localhost", + "port": 5432, + "username": "u", + "database": "d", + }, + "ssh_tunnel": {"server_address": "ssh.example.com"}, + } + command = ValidateDatabaseParametersCommand(properties) + with pytest.raises(InvalidParametersError) as excinfo: + command.run() + + assert any( + err.extra is not None + and err.extra.get("ssh_tunnel") is True + and err.extra.get("missing") == ["server_port", "username"] + for err in excinfo.value.errors + ) + assert any( + err.extra is not None + and err.extra.get("ssh_tunnel") is True + and err.extra.get("missing") == ["password"] + for err in excinfo.value.errors + ) + + +def test_get_ssh_tunnel_errors_private_key_without_password( + mocker: MockerFixture, +) -> None: + """ + Providing a private_key without private_key_password raises a missing + parameters error. + """ + mocker.patch( + "superset.commands.database.validate.is_feature_enabled", + return_value=True, + ) + mocker.patch( + "superset.commands.database.validate.get_engine_spec", + return_value=mocker.MagicMock( + validate_parameters=mocker.MagicMock(return_value=[]), + ), + ) + + properties = { + "engine": "postgresql", + "parameters": { + "host": "localhost", + "port": 5432, + "username": "u", + "database": "d", + }, + "ssh_tunnel": { + "server_address": "ssh.example.com", + "server_port": 22, + "username": "ssh-user", + "private_key": "----- KEY -----", + }, + } + command = ValidateDatabaseParametersCommand(properties) + with pytest.raises(InvalidParametersError) as excinfo: + command.run() + + assert any( + err.extra is not None + and err.extra.get("ssh_tunnel") is True + and err.extra.get("missing") == ["private_key_password"] + for err in excinfo.value.errors + ) + + +def test_get_ssh_tunnel_errors_skipped_when_not_enabled( + mocker: MockerFixture, +) -> None: + """ + SSH tunnel validation is a no-op when ssh is not enabled and no tunnel + is provided. + """ + DatabaseDAO = mocker.patch( # noqa: N806 + "superset.commands.database.validate.DatabaseDAO" + ) + DatabaseDAO.validate_uniqueness.return_value = True + + database = mocker.MagicMock() + with database.get_sqla_engine() as engine: + engine.dialect.do_ping.return_value = True + DatabaseDAO.build_db_for_connection_test.return_value = database + + mocker.patch( + "superset.commands.database.validate.get_engine_spec", + return_value=mocker.MagicMock( + validate_parameters=mocker.MagicMock(return_value=[]), + build_sqlalchemy_uri=mocker.MagicMock(return_value="postgresql://"), + unmask_encrypted_extra=mocker.MagicMock(return_value="{}"), + ), + ) + + properties = { + "engine": "postgresql", + "database_name": "ok", + "parameters": { + "host": "localhost", + "port": 5432, + "username": "u", + "database": "d", + }, + } + command = ValidateDatabaseParametersCommand(properties) + command.run() From 864b27d0e3c56bd854527fab3ec3d7453ab8f8c3 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 6 May 2026 11:52:14 +0200 Subject: [PATCH 14/16] test(database): explicitly wait for each blur validation in cypress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two ``show error alerts on dynamic form`` cases relied on a single 60s ``should('not.be.disabled')`` after typing five fields, but the ``{ timeout: 60000 }`` option on ``.should()`` is not honoured the way it is on the query — the assertion still uses the project's 8s default and times out before the chained validation calls complete. Match the master cadence: explicitly blur each field and wait for the ``@validateParams`` interception before moving on, so the button-state assertion only fires once validation is settled. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cypress/e2e/database/modal.test.ts | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts b/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts index 45859e230a5f..44a02d051bbe 100644 --- a/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts @@ -63,17 +63,22 @@ describe('Add database', () => { it('show error alerts on dynamic form for bad host', () => { cy.get('.preferred > :nth-child(1)').click(); - cy.get('input[name="host"]').type('badhost', { force: true }); - cy.get('input[name="port"]').type('5432', { force: true }); - cy.get('input[name="username"]').type('testusername', { force: true }); - cy.get('input[name="database"]').type('testdb', { force: true }); - cy.get('input[name="password"]').type('testpass', { force: true }); - cy.get('body').click(0, 0); - - // Wait for all intermediate validation calls to settle, then check the button - cy.getBySel('btn-submit-connection').should('not.be.disabled', { - timeout: 60000, - }); + cy.get('input[name="host"]').type('badhost', { force: true }).blur(); + cy.wait('@validateParams', { timeout: 30000 }); + cy.get('input[name="port"]').type('5432', { force: true }).blur(); + cy.wait('@validateParams', { timeout: 30000 }); + cy.get('input[name="username"]') + .type('testusername', { force: true }) + .blur(); + cy.wait('@validateParams', { timeout: 30000 }); + cy.get('input[name="database"]').type('testdb', { force: true }).blur(); + cy.wait('@validateParams', { timeout: 30000 }); + cy.get('input[name="password"]').type('testpass', { force: true }).blur(); + cy.wait('@validateParams', { timeout: 30000 }); + + cy.getBySel('btn-submit-connection', { timeout: 60000 }).should( + 'not.be.disabled', + ); cy.getBySel('btn-submit-connection').click({ force: true }); cy.wait('@createDb', { timeout: 60000 }).then(() => { @@ -87,17 +92,22 @@ describe('Add database', () => { it('show error alerts on dynamic form for bad port', () => { cy.get('.preferred > :nth-child(1)').click(); - cy.get('input[name="host"]').type('localhost', { force: true }); - cy.get('input[name="port"]').type('5430', { force: true }); - cy.get('input[name="database"]').type('testdb', { force: true }); - cy.get('input[name="username"]').type('testusername', { force: true }); - cy.get('input[name="password"]').type('testpass', { force: true }); - cy.get('body').click(0, 0); - - // Wait for all intermediate validation calls to settle, then check the button - cy.getBySel('btn-submit-connection').should('not.be.disabled', { - timeout: 60000, - }); + cy.get('input[name="host"]').type('localhost', { force: true }).blur(); + cy.wait('@validateParams', { timeout: 30000 }); + cy.get('input[name="port"]').type('5430', { force: true }).blur(); + cy.wait('@validateParams', { timeout: 30000 }); + cy.get('input[name="database"]').type('testdb', { force: true }).blur(); + cy.wait('@validateParams', { timeout: 30000 }); + cy.get('input[name="username"]') + .type('testusername', { force: true }) + .blur(); + cy.wait('@validateParams', { timeout: 30000 }); + cy.get('input[name="password"]').type('testpass', { force: true }).blur(); + cy.wait('@validateParams', { timeout: 30000 }); + + cy.getBySel('btn-submit-connection', { timeout: 60000 }).should( + 'not.be.disabled', + ); cy.getBySel('btn-submit-connection').click({ force: true }); cy.wait('@createDb', { timeout: 60000 }).then(() => { From 3e26f0218f6477a61bd328bf1714fb4dcee2a11a Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 6 May 2026 12:32:10 +0200 Subject: [PATCH 15/16] fix(database): retry blur validation after a transient request failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the validate_parameters request failed without a structured error body (e.g. network drop), getValidation returned an empty object that the caller could not distinguish from "validation passed" — and the blur dedup cache was already updated, so the same form state would never revalidate until the user changed a field. Have getValidation return null on unexpected failure and only update the snapshot cache after a usable response. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/features/databases/DatabaseModal/index.tsx | 14 ++++++++++---- superset-frontend/src/views/CRUD/hooks.ts | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index 8474cde2af66..bd9a26084af1 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -823,13 +823,19 @@ const DatabaseModal: FunctionComponent = ({ [onChange, handleClearValidationErrors], ); - const getBlurValidation = useCallback(() => { + const getBlurValidation = useCallback(async () => { const currentDbSnapshot = JSON.stringify(db); if (currentDbSnapshot === lastValidatedDbSnapshotRef.current) { - return Promise.resolve([]); + return []; } - lastValidatedDbSnapshotRef.current = currentDbSnapshot; - return getValidation(db); + const result = await getValidation(db); + // Only cache after a request that produced a usable response. ``null`` + // signals an unexpected/network failure, in which case we leave the + // snapshot untouched so the next blur retries. + if (result !== null) { + lastValidatedDbSnapshotRef.current = currentDbSnapshot; + } + return result; }, [db, getValidation]); const onClose = () => { diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index b7dae7a1e4fd..4cb9e9f9c974 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -909,7 +909,7 @@ export function useDatabaseValidation() { setIsValidating(false); setHasValidated(true); } - return {}; + return null; } }, [setValidationErrors], From f846e106b843d4f63c8c5e26a395bd3fd6d6c727 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 6 May 2026 12:32:35 +0200 Subject: [PATCH 16/16] fix(database): tighten validate_parameters SSH and bypass-engine paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three correctness fixes to ValidateDatabaseParametersCommand: 1. Bypass engines (bigquery, datastore, snowflake) now also surface database_name uniqueness errors and SSH tunnel field errors during progressive validation, instead of silently passing. 2. The SSH feature-flag and database-port guards now fire when the UI marks parameters.ssh, not just when the ssh_tunnel payload is non-empty — the form sends an empty tunnel object in early stages. 3. The "parameters are missing" message for SSH tunnel fields now interpolates the %(missing)s placeholder via gettext, so the response surfaces the actual missing fields instead of the literal token. Adds unit tests for each branch and removes the now-unused _validate_database_name helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- superset/commands/database/validate.py | 49 ++++++---- .../commands/databases/validate_test.py | 94 +++++++++++++++++++ 2 files changed, 125 insertions(+), 18 deletions(-) diff --git a/superset/commands/database/validate.py b/superset/commands/database/validate.py index 8ef31bbbce48..a37bb3cf53c6 100644 --- a/superset/commands/database/validate.py +++ b/superset/commands/database/validate.py @@ -54,9 +54,16 @@ def run(self) -> None: # noqa: C901 driver = self._properties.get("driver") if engine in BYPASS_VALIDATION_ENGINES: - # Skip engines that are only validated onCreate - # But still validate database_name uniqueness - self._validate_database_name() + # Skip engines that are only validated onCreate, but still surface + # database_name uniqueness and SSH tunnel field errors so the + # progressive validation flow stays consistent across engines. + errors: list[SupersetError] = [] + if database_name_error := self._get_database_name_error(): + errors.append(database_name_error) + errors.extend(self._get_ssh_tunnel_errors()) + if errors: + event_logger.log_with_context(action="validation_error", engine=engine) + raise InvalidParametersError(errors) return engine_spec = get_engine_spec(engine, driver) @@ -178,11 +185,6 @@ def _get_database_name_error(self) -> Optional[SupersetError]: ) return None - def _validate_database_name(self) -> None: - """Check for duplicate database name and raise if found.""" - if error := self._get_database_name_error(): - raise InvalidParametersError([error]) - def validate(self) -> None: """Load the model and validate SSH tunnel if enabled.""" self._load_model() @@ -190,14 +192,19 @@ def validate(self) -> None: def _validate_ssh_tunnel(self) -> None: """Validate SSH tunnel configuration if enabled.""" - ssh_tunnel = self._properties.get("ssh_tunnel") - if ssh_tunnel: - if not is_feature_enabled("SSH_TUNNELING"): - raise SSHTunnelingNotEnabledError() - # Check if port is provided (required for SSH tunneling) - parameters = self._properties.get("parameters", {}) - if not parameters.get("port"): - raise SSHTunnelDatabasePortError() + ssh_tunnel = self._properties.get("ssh_tunnel") or {} + parameters = self._properties.get("parameters") or {} + # SSH can be turned on via the dedicated tunnel payload OR the + # ``parameters.ssh`` flag the UI sets while the user is filling the + # form. Both paths must enforce the feature flag and the database + # port requirement. + ssh_enabled = bool(ssh_tunnel) or bool(parameters.get("ssh")) + if not ssh_enabled: + return + if not is_feature_enabled("SSH_TUNNELING"): + raise SSHTunnelingNotEnabledError() + if not parameters.get("port"): + raise SSHTunnelDatabasePortError() def _get_ssh_tunnel_errors(self) -> list[SupersetError]: """Validate SSH tunnel fields and return list of errors.""" @@ -219,7 +226,10 @@ def _get_ssh_tunnel_errors(self) -> list[SupersetError]: if missing: errors.append( SupersetError( - message=__("One or more parameters are missing: %(missing)s"), + message=__( + "One or more parameters are missing: %(missing)s", + missing=", ".join(missing), + ), error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR, level=ErrorLevel.WARNING, extra={"missing": missing, "ssh_tunnel": True}, @@ -244,7 +254,10 @@ def _get_ssh_tunnel_errors(self) -> list[SupersetError]: if has_private_key and not ssh_tunnel.get("private_key_password"): errors.append( SupersetError( - message=__("One or more parameters are missing: %(missing)s"), + message=__( + "One or more parameters are missing: %(missing)s", + missing="private_key_password", + ), error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR, level=ErrorLevel.WARNING, extra={"missing": ["private_key_password"], "ssh_tunnel": True}, diff --git a/tests/unit_tests/commands/databases/validate_test.py b/tests/unit_tests/commands/databases/validate_test.py index ad6238cbe365..53ea51a634d0 100644 --- a/tests/unit_tests/commands/databases/validate_test.py +++ b/tests/unit_tests/commands/databases/validate_test.py @@ -470,3 +470,97 @@ def test_get_ssh_tunnel_errors_skipped_when_not_enabled( } command = ValidateDatabaseParametersCommand(properties) command.run() + + +def test_bypass_engine_surfaces_ssh_tunnel_errors(mocker: MockerFixture) -> None: + """ + Bypass engines also surface SSH tunnel field errors so the progressive + validation flow stays consistent across engine types. + """ + mocker.patch( + "superset.commands.database.validate.is_feature_enabled", + return_value=True, + ) + DatabaseDAO = mocker.patch( # noqa: N806 + "superset.commands.database.validate.DatabaseDAO" + ) + DatabaseDAO.validate_uniqueness.return_value = True + + properties = { + "engine": "snowflake", + "database_name": "ok", + "parameters": {"port": 443}, + "ssh_tunnel": {"server_address": "ssh.example.com"}, + } + command = ValidateDatabaseParametersCommand(properties) + with pytest.raises(InvalidParametersError) as excinfo: + command.run() + assert any( + err.extra is not None and err.extra.get("ssh_tunnel") is True + for err in excinfo.value.errors + ) + + +def test_validate_ssh_tunnel_feature_disabled_via_parameters_ssh( + mocker: MockerFixture, +) -> None: + """ + The SSH feature-flag guard fires when the UI marks ``parameters.ssh`` + even if ``ssh_tunnel`` itself is empty during progressive validation. + """ + mocker.patch( + "superset.commands.database.validate.is_feature_enabled", + return_value=False, + ) + + properties = { + "engine": "postgresql", + "parameters": {"host": "localhost", "port": 5432, "ssh": True}, + "ssh_tunnel": {}, + } + command = ValidateDatabaseParametersCommand(properties) + with pytest.raises(SSHTunnelingNotEnabledError): + command.run() + + +def test_ssh_tunnel_missing_message_is_interpolated( + mocker: MockerFixture, +) -> None: + """ + The translated ``parameters are missing`` message is interpolated with + the actual missing fields rather than the raw ``%(missing)s`` token. + """ + mocker.patch( + "superset.commands.database.validate.is_feature_enabled", + return_value=True, + ) + mocker.patch( + "superset.commands.database.validate.get_engine_spec", + return_value=mocker.MagicMock( + validate_parameters=mocker.MagicMock(return_value=[]), + ), + ) + + properties = { + "engine": "postgresql", + "parameters": { + "host": "localhost", + "port": 5432, + "username": "u", + "database": "d", + }, + "ssh_tunnel": {"server_address": "ssh.example.com"}, + } + command = ValidateDatabaseParametersCommand(properties) + with pytest.raises(InvalidParametersError) as excinfo: + command.run() + missing_field_messages = [ + err.message + for err in excinfo.value.errors + if err.extra is not None + and err.extra.get("missing") + and err.extra.get("ssh_tunnel") # noqa: E501 + ] + assert missing_field_messages + assert all("%(missing)s" not in msg for msg in missing_field_messages) + assert any("server_port" in msg for msg in missing_field_messages)