Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6aef573
feat(database): add validation loading state and duplicate name check
EnxDev Dec 31, 2025
ad92ec6
wip: add validation loading state to SSH tunnel form fields
EnxDev Dec 31, 2025
a44b8a6
feat(database): add SSH tunnel validation to database parameters endp…
EnxDev Jan 2, 2026
2df2243
Merge branch 'master' into enxdev/feat/enhance-database-modal-validation
EnxDev Jan 13, 2026
4e156dc
Merge branch 'master' into enxdev/feat/enhance-database-modal-validation
EnxDev Jan 20, 2026
899ecf8
fix: update RTL tests to match new behavior
EnxDev Jan 20, 2026
84c228e
Merge branch 'master' into enxdev/feat/enhance-database-modal-validation
EnxDev Jan 28, 2026
c0be048
Merge branch master into enxdev/feat/enhance-database-modal-validation
EnxDev Feb 4, 2026
7d53e4d
Merge branch 'master' into enxdev/feat/enhance-database-modal-validation
EnxDev Feb 10, 2026
055fa36
Merge branch 'master' into enxdev/feat/enhance-database-modal-validation
EnxDev Feb 14, 2026
0bfaf3c
perf(database): skip redundant validation API calls on blur
EnxDev Feb 15, 2026
13ed9b5
fix CI test
EnxDev Feb 16, 2026
d1ec3eb
Merge branch master into enxdev/feat/enhance-database-modal-validation
EnxDev Mar 12, 2026
2f98032
Merge branch 'master' into enxdev/feat/enhance-database-modal-validation
EnxDev Mar 18, 2026
e7c9cf0
refactor(database): simplify SSH tunnel error accumulation in useData…
EnxDev Mar 18, 2026
15b2863
chore: remove duplicated handleClearValidationErrors function
EnxDev Mar 18, 2026
6c69cc2
lint
EnxDev Mar 18, 2026
d036ef4
Merge branch 'master' into enxdev/feat/enhance-database-modal-validation
EnxDev Mar 18, 2026
f6ac345
fix(cypress): wait for final validation to settle before asserting bu…
EnxDev Mar 18, 2026
edc8e4b
Merge branch 'master' into enxdev/feat/enhance-database-modal-validation
EnxDev Apr 27, 2026
3a52d60
Merge branch 'master' into enxdev/feat/enhance-database-modal-validation
EnxDev May 5, 2026
e4f8472
Merge branch 'master' into enxdev/feat/enhance-database-modal-validation
EnxDev May 6, 2026
15ec3bb
fix(database): prevent stale validation errors from out-of-order resp…
EnxDev May 6, 2026
9651d0e
test(database): stabilize DatabaseModal jest tests on slow CI
EnxDev May 6, 2026
86b3d89
test(database): cover new validate command branches
EnxDev May 6, 2026
864b27d
test(database): explicitly wait for each blur validation in cypress
EnxDev May 6, 2026
3e26f02
fix(database): retry blur validation after a transient request failure
EnxDev May 6, 2026
f846e10
fix(database): tighten validate_parameters SSH and bypass-engine paths
EnxDev May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 37 additions & 36 deletions superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,56 +63,57 @@ 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);

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').should('not.be.disabled');
cy.getBySel('btn-submit-connection', { timeout: 60000 }).should(
'not.be.disabled',
);
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');
});
});

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('body').click(0, 0);
cy.get('input[name="host"]').type('localhost', { force: true }).blur();
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.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.get('input[name="password"]').type('testpass', { force: true });
cy.wait('@validateParams');

cy.getBySel('btn-submit-connection').should('not.be.disabled');
cy.getBySel('btn-submit-connection', { timeout: 60000 }).should(
'not.be.disabled',
);
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',
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export const LabeledErrorBoundInput = ({
isValidating ? 'validating' : hasError ? 'error' : 'success'
}
help={errorMessage || helpText}
hasFeedback={!!hasError}
hasFeedback={isValidating || !!hasError}
>
{visibilityToggle || props.name === 'password' ? (
<StyledInputPassword
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,13 +243,15 @@ export const accessTokenField = ({
validationErrors,
db,
isEditMode,
isValidating,
default_value,
description,
}: FieldPropTypes) => (
<ValidatedInput
id="access_token"
name="access_token"
required={required}
isValidating={isValidating}
visibilityToggle={!isEditMode}
value={db?.parameters?.access_token}
validationMethods={{ onBlur: getValidation }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const TableCatalog = ({
getValidation,
validationErrors,
db,
isValidating,
}: FieldPropTypes) => {
const tableCatalog = db?.catalog || [];
const catalogError = validationErrors || {};
Expand All @@ -51,6 +52,7 @@ export const TableCatalog = ({
<ValidatedInput
className="catalog-name-input"
required={required}
isValidating={isValidating}
validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.name}
placeholder={t('Enter a name for this sheet')}
Expand Down Expand Up @@ -84,6 +86,7 @@ export const TableCatalog = ({
<ValidatedInput
className="catalog-name-url"
required={required}
isValidating={isValidating}
validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.url}
placeholder={t('Paste the shareable Google Sheet URL here')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ export const validatedInputField = ({
validationErrors,
db,
field,
isValidating,
}: FieldPropTypes) => (
<ValidatedInput
id={field}
name={field}
required={required}
isValidating={isValidating}
value={db?.parameters?.[field as keyof DatabaseParameters]}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.[field]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,20 @@
*/
import { useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { JsonObject } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import {
Form,
FormLabel,
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';
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`
Expand All @@ -48,50 +50,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>(AuthType.Password);
const sshErrors = validationErrors?.ssh_tunnel || {};

return (
<Form>
<StyledRow gutter={16}>
<Col xs={24} md={12}>
<StyledDiv>
<FormLabel htmlFor="server_address" required>
{t('SSH Host')}
</FormLabel>
<Input
<LabeledErrorBoundInput
id="server_address"
name="server_address"
type="text"
label={t('SSH Host')}
required
placeholder={t('e.g. 127.0.0.1')}
value={db?.ssh_tunnel?.server_address || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.server_address}
isValidating={isValidating}
data-test="ssh-tunnel-server_address-input"
/>
</StyledDiv>
</Col>
<Col xs={24} md={12}>
<StyledDiv>
<FormLabel htmlFor="server_port" required>
{t('SSH Port')}
</FormLabel>
<Input
<LabeledErrorBoundInput
id="server_port"
name="server_port"
label={t('SSH Port')}
required
placeholder={t('22')}
type="number"
value={db?.ssh_tunnel?.server_port}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.server_port}
isValidating={isValidating}
data-test="ssh-tunnel-server_port-input"
/>
</StyledDiv>
Expand All @@ -100,15 +112,17 @@ const SSHTunnelForm = ({
<StyledRow gutter={16}>
<Col xs={24}>
<StyledDiv>
<FormLabel htmlFor="username" required>
{t('Username')}
</FormLabel>
<Input
<LabeledErrorBoundInput
id="username"
name="username"
type="text"
label={t('Username')}
required
placeholder={t('e.g. Analytics')}
value={db?.ssh_tunnel?.username || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.username}
isValidating={isValidating}
data-test="ssh-tunnel-username-input"
/>
</StyledDiv>
Expand Down Expand Up @@ -148,16 +162,20 @@ const SSHTunnelForm = ({
<StyledRow gutter={16}>
<Col xs={24}>
<StyledDiv>
<FormLabel htmlFor="password" required>
{t('SSH Password')}
</FormLabel>
<StyledInputPassword
<LabeledErrorBoundInput
id="password"
name="password"
label={t('SSH Password')}
required
visibilityToggle
placeholder={t('e.g. ********')}
value={db?.ssh_tunnel?.password || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.password}
isValidating={isValidating}
data-test="ssh-tunnel-password-input"
iconRender={visible =>
iconRender={(visible: boolean) =>
visible ? (
<Tooltip title={t('Hide password.')}>
<Icons.EyeInvisibleOutlined />
Expand All @@ -182,30 +200,47 @@ const SSHTunnelForm = ({
<FormLabel htmlFor="private_key" required>
{t('Private Key')}
</FormLabel>
<Input.TextArea
name="private_key"
placeholder={t('Paste Private Key here')}
value={db?.ssh_tunnel?.private_key || ''}
onChange={onSSHTunnelParametersChange}
data-test="ssh-tunnel-private_key-input"
rows={4}
/>
<StyledFormItem
validateStatus={
isValidating
? 'validating'
: sshErrors?.private_key
? 'error'
: 'success'
}
help={sshErrors?.private_key}
hasFeedback={isValidating || !!sshErrors?.private_key}
>
<Input.TextArea
name="private_key"
placeholder={t('Paste Private Key here')}
value={db?.ssh_tunnel?.private_key || ''}
onChange={onSSHTunnelParametersChange}
onBlur={getValidation}
data-test="ssh-tunnel-private_key-input"
rows={4}
/>
</StyledFormItem>
</StyledDiv>
</Col>
</StyledRow>
<StyledRow gutter={16}>
<Col xs={24}>
<StyledDiv>
<FormLabel htmlFor="private_key_password" required>
{t('Private Key Password')}
</FormLabel>
<StyledInputPassword
<LabeledErrorBoundInput
id="private_key_password"
name="private_key_password"
label={t('Private Key Password')}
required
visibilityToggle
placeholder={t('e.g. ********')}
value={db?.ssh_tunnel?.private_key_password || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.private_key_password}
isValidating={isValidating}
data-test="ssh-tunnel-private_key_password-input"
iconRender={visible =>
iconRender={(visible: boolean) =>
visible ? (
<Tooltip title={t('Hide password.')}>
<Icons.EyeInvisibleOutlined />
Expand Down
Loading
Loading