From d9a91f99dbb85f3044971c2755ee936fa121b8c1 Mon Sep 17 00:00:00 2001 From: Vitor Avila <96086495+Vitor-Avila@users.noreply.github.com> Date: Mon, 9 Mar 2026 01:59:54 -0300 Subject: [PATCH 01/31] feat: support for import/export masked_encrypted_extra (frontend) (#38078) --- .../ImportModal/ImportModal.test.tsx | 44 +++++ .../src/components/ImportModal/index.tsx | 51 +++++- .../src/components/ImportModal/types.ts | 9 +- .../databases/DatabaseModal/index.tsx | 83 +++++++++- superset-frontend/src/views/CRUD/hooks.ts | 26 ++- superset-frontend/src/views/CRUD/types.ts | 10 ++ .../src/views/CRUD/utils.test.tsx | 154 ++++++++++++++++++ superset-frontend/src/views/CRUD/utils.tsx | 43 ++++- 8 files changed, 410 insertions(+), 10 deletions(-) diff --git a/superset-frontend/src/components/ImportModal/ImportModal.test.tsx b/superset-frontend/src/components/ImportModal/ImportModal.test.tsx index a19929bb5b6..63f15de94ba 100644 --- a/superset-frontend/src/components/ImportModal/ImportModal.test.tsx +++ b/superset-frontend/src/components/ImportModal/ImportModal.test.tsx @@ -140,3 +140,47 @@ test('should render ssh_tunnel private_key_password fields when needed for impor }); expect(getByTestId('ssh_tunnel_private_key_password')).toBeInTheDocument(); }); + +test('should render encrypted extra secret fields when needed for import', () => { + const { getByTestId } = setup({ + encryptedExtraFields: [ + { + fileName: 'databases/examples.yaml', + fields: [ + { + path: '$.credentials_info.private_key', + label: 'Service Account Private Key', + }, + ], + }, + ], + }); + expect(getByTestId('encrypted_extra_secret')).toBeInTheDocument(); +}); + +test('should render multiple encrypted extra secret fields for multiple files', () => { + const { getAllByTestId } = setup({ + encryptedExtraFields: [ + { + fileName: 'databases/bigquery.yaml', + fields: [ + { + path: '$.credentials_info.private_key', + label: 'Service Account Private Key', + }, + ], + }, + { + fileName: 'databases/snowflake.yaml', + fields: [ + { path: '$.auth_params.privatekey_body', label: 'Private Key Body' }, + { + path: '$.auth_params.privatekey_pass', + label: 'Private Key Password', + }, + ], + }, + ], + }); + expect(getAllByTestId('encrypted_extra_secret')).toHaveLength(3); +}); diff --git a/superset-frontend/src/components/ImportModal/index.tsx b/superset-frontend/src/components/ImportModal/index.tsx index 39fa38398ef..b046abf8880 100644 --- a/superset-frontend/src/components/ImportModal/index.tsx +++ b/superset-frontend/src/components/ImportModal/index.tsx @@ -78,6 +78,8 @@ export const ImportModal: FunctionComponent = ({ setSSHTunnelPrivateKeyFields = () => {}, sshTunnelPrivateKeyPasswordFields = [], setSSHTunnelPrivateKeyPasswordFields = () => {}, + encryptedExtraFields = [], + setEncryptedExtraFields = () => {}, }) => { const [isHidden, setIsHidden] = useState(true); const [passwords, setPasswords] = useState>({}); @@ -95,6 +97,9 @@ export const ImportModal: FunctionComponent = ({ >({}); const [sshTunnelPrivateKeyPasswords, setSSHTunnelPrivateKeyPasswords] = useState>({}); + const [encryptedExtraSecrets, setEncryptedExtraSecrets] = useState< + Record> + >({}); const clearModal = () => { setFileList([]); @@ -110,6 +115,8 @@ export const ImportModal: FunctionComponent = ({ setSSHTunnelPasswords({}); setSSHTunnelPrivateKeys({}); setSSHTunnelPrivateKeyPasswords({}); + setEncryptedExtraFields([]); + setEncryptedExtraSecrets({}); }; const handleErrorMsg = (msg: string) => { @@ -123,6 +130,7 @@ export const ImportModal: FunctionComponent = ({ sshPasswordNeeded, sshPrivateKeyNeeded, sshPrivateKeyPasswordNeeded, + encryptedExtraFieldsNeeded, }, importResource, } = useImportResource(resourceName, resourceLabel, handleErrorMsg); @@ -162,6 +170,13 @@ export const ImportModal: FunctionComponent = ({ } }, [sshPrivateKeyPasswordNeeded, setSSHTunnelPrivateKeyPasswordFields]); + useEffect(() => { + setEncryptedExtraFields(encryptedExtraFieldsNeeded); + if (encryptedExtraFieldsNeeded.length > 0) { + setImportingModel(false); + } + }, [encryptedExtraFieldsNeeded, setEncryptedExtraFields]); + // Functions const hide = () => { setIsHidden(true); @@ -181,6 +196,7 @@ export const ImportModal: FunctionComponent = ({ sshTunnelPasswords, sshTunnelPrivateKeys, sshTunnelPrivateKeyPasswords, + encryptedExtraSecrets, confirmedOverwrite, ).then(result => { if (result) { @@ -214,7 +230,8 @@ export const ImportModal: FunctionComponent = ({ passwordFields.length === 0 && sshTunnelPasswordFields.length === 0 && sshTunnelPrivateKeyFields.length === 0 && - sshTunnelPrivateKeyPasswordFields.length === 0 + sshTunnelPrivateKeyPasswordFields.length === 0 && + encryptedExtraFields.length === 0 ) { return null; } @@ -320,6 +337,35 @@ export const ImportModal: FunctionComponent = ({ )} ))} + {encryptedExtraFields.map(({ fileName, fields }) => ( + +
+ {t('%s ENCRYPTED EXTRA', fileName.slice(10))} +
+ {fields.map(field => ( +
+
+ {field.label} + * +
+ + setEncryptedExtraSecrets({ + ...encryptedExtraSecrets, + [fileName]: { + ...encryptedExtraSecrets[fileName], + [field.path]: event.target.value, + }, + }) + } + data-test="encrypted_extra_secret" + /> +
+ ))} +
+ ))} ); }; @@ -392,7 +438,8 @@ export const ImportModal: FunctionComponent = ({ passwordFields.length > 0 || sshTunnelPasswordFields.length > 0 || sshTunnelPrivateKeyFields.length > 0 || - sshTunnelPrivateKeyPasswordFields.length > 0 + sshTunnelPrivateKeyPasswordFields.length > 0 || + encryptedExtraFields.length > 0 } /> )} diff --git a/superset-frontend/src/components/ImportModal/types.ts b/superset-frontend/src/components/ImportModal/types.ts index 5b9fc57ab22..c824259abdc 100644 --- a/superset-frontend/src/components/ImportModal/types.ts +++ b/superset-frontend/src/components/ImportModal/types.ts @@ -16,7 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -import { ImportResourceName } from 'src/views/CRUD/types'; +import { + FileEncryptedExtraFields, + ImportResourceName, +} from 'src/views/CRUD/types'; export interface ImportModelsModalProps { resourceName: ImportResourceName; @@ -38,4 +41,8 @@ export interface ImportModelsModalProps { setSSHTunnelPrivateKeyPasswordFields?: ( sshTunnelPrivateKeyPasswordFields: string[], ) => void; + encryptedExtraFields?: FileEncryptedExtraFields[]; + setEncryptedExtraFields?: ( + encryptedExtraFields: FileEncryptedExtraFields[], + ) => void; } diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index 2d3111a2c9a..0919941938a 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -62,6 +62,7 @@ import { getConnectionAlert, useImportResource, } from 'src/views/CRUD/hooks'; +import { FileEncryptedExtraFields } from 'src/views/CRUD/types'; import { useCommonConf } from 'src/features/databases/state'; import { isEmpty, pick } from 'lodash'; import { OnlyKeyWithType } from 'src/utils/types'; @@ -646,6 +647,12 @@ const DatabaseModal: FunctionComponent = ({ sshTunnelPrivateKeyPasswordFields, setSSHTunnelPrivateKeyPasswordFields, ] = useState([]); + const [encryptedExtraFields, setEncryptedExtraFields] = useState< + FileEncryptedExtraFields[] + >([]); + const [encryptedExtraSecrets, setEncryptedExtraSecrets] = useState< + Record> + >({}); const [extraExtensionComponentState, setExtraExtensionComponentState] = useState({}); @@ -821,6 +828,8 @@ const DatabaseModal: FunctionComponent = ({ setSSHTunnelPasswords({}); setSSHTunnelPrivateKeys({}); setSSHTunnelPrivateKeyPasswords({}); + setEncryptedExtraFields([]); + setEncryptedExtraSecrets({}); setConfirmedOverwrite(false); setUseSSHTunneling(undefined); onHide(); @@ -838,6 +847,7 @@ const DatabaseModal: FunctionComponent = ({ sshPasswordNeeded, sshPrivateKeyNeeded, sshPrivateKeyPasswordNeeded, + encryptedExtraFieldsNeeded, loading: importLoading, failed: importErrored, }, @@ -1026,6 +1036,7 @@ const DatabaseModal: FunctionComponent = ({ sshTunnelPasswords, sshTunnelPrivateKeys, sshTunnelPrivateKeyPasswords, + encryptedExtraSecrets, confirmedOverwrite, ); if (dbId) { @@ -1230,16 +1241,24 @@ const DatabaseModal: FunctionComponent = ({ setSSHTunnelPasswordFields([]); setSSHTunnelPrivateKeyFields([]); setSSHTunnelPrivateKeyPasswordFields([]); + setEncryptedExtraFields([]); setPasswords({}); setSSHTunnelPasswords({}); setSSHTunnelPrivateKeys({}); setSSHTunnelPrivateKeyPasswords({}); + setEncryptedExtraSecrets({}); } setDB({ type: ActionType.Reset }); setFileList([]); }; const handleDisableOnImport = () => { + // Check if any encrypted extra field is missing a secret + const hasEmptyEncryptedExtraSecrets = encryptedExtraFields.some( + ({ fileName, fields }) => + fields.some(field => !encryptedExtraSecrets[fileName]?.[field.path]), + ); + if ( importLoading || (alreadyExists.length && !confirmedOverwrite) || @@ -1249,7 +1268,8 @@ const DatabaseModal: FunctionComponent = ({ (sshPrivateKeyNeeded.length && JSON.stringify(sshTunnelPrivateKeys) === '{}') || (sshPrivateKeyPasswordNeeded.length && - JSON.stringify(sshTunnelPrivateKeyPasswords) === '{}') + JSON.stringify(sshTunnelPrivateKeyPasswords) === '{}') || + (encryptedExtraFields.length && hasEmptyEncryptedExtraSecrets) ) return true; return false; @@ -1369,6 +1389,7 @@ const DatabaseModal: FunctionComponent = ({ !sshPasswordNeeded.length && !sshPrivateKeyNeeded.length && !sshPrivateKeyPasswordNeeded.length && + !encryptedExtraFieldsNeeded.length && !isLoading && // This prevents a double toast for non-related imports !importErrored // This prevents a success toast on error ) { @@ -1383,6 +1404,7 @@ const DatabaseModal: FunctionComponent = ({ sshPasswordNeeded, sshPrivateKeyNeeded, sshPrivateKeyPasswordNeeded, + encryptedExtraFieldsNeeded, ]); useEffect(() => { @@ -1424,7 +1446,7 @@ const DatabaseModal: FunctionComponent = ({ if (importingModal) { document ?.getElementsByClassName('ant-upload-list-item-name')[0] - .scrollIntoView(); + ?.scrollIntoView(); } }, [importingModal]); @@ -1444,6 +1466,10 @@ const DatabaseModal: FunctionComponent = ({ setSSHTunnelPrivateKeyPasswordFields([...sshPrivateKeyPasswordNeeded]); }, [sshPrivateKeyPasswordNeeded]); + useEffect(() => { + setEncryptedExtraFields([...encryptedExtraFieldsNeeded]); + }, [encryptedExtraFieldsNeeded]); + useEffect(() => { if (db?.parameters?.ssh !== undefined) { setUseSSHTunneling(db.parameters.ssh); @@ -1456,10 +1482,12 @@ const DatabaseModal: FunctionComponent = ({ setSSHTunnelPasswordFields([]); setSSHTunnelPrivateKeyFields([]); setSSHTunnelPrivateKeyPasswordFields([]); + setEncryptedExtraFields([]); setPasswords({}); setSSHTunnelPasswords({}); setSSHTunnelPrivateKeys({}); setSSHTunnelPrivateKeyPasswords({}); + setEncryptedExtraSecrets({}); setImportingModal(true); setFileList([ { @@ -1475,6 +1503,7 @@ const DatabaseModal: FunctionComponent = ({ sshTunnelPasswords, sshTunnelPrivateKeys, sshTunnelPrivateKeyPasswords, + encryptedExtraSecrets, confirmedOverwrite, ); if (dbId) onDatabaseAdd?.(); @@ -1508,7 +1537,7 @@ const DatabaseModal: FunctionComponent = ({ showIcon message="Database passwords" description={t( - `The passwords for the databases below are needed in order to import them. Please note that the "Secure Extra" and "Certificate" sections of the database configuration are not present in explore files and should be added manually after the import if they are needed.`, + `The passwords for the databases below are needed in order to import them.`, )} /> @@ -1591,6 +1620,50 @@ const DatabaseModal: FunctionComponent = ({ )); }; + const encryptedExtraNeededField = () => { + if (!encryptedExtraFields.length) return null; + + return encryptedExtraFields.map(({ fileName, fields }) => ( +
+ + antDAlertStyles(theme)} + type="info" + showIcon + message={t('Encrypted extra fields')} + description={t( + `The following fields contain sensitive information that was masked during export. Please provide the values to import this database.`, + )} + /> + + {fields.map(field => ( + ) => + setEncryptedExtraSecrets({ + ...encryptedExtraSecrets, + [fileName]: { + ...encryptedExtraSecrets[fileName], + [field.path]: event.target.value, + }, + }) + } + isValidating={isValidating} + validationMethods={{ onBlur: () => {} }} + label={t('%s %s', fileName.slice(10), field.label)} + css={formScrollableStyles} + /> + ))} +
+ )); + }; + const importingErrorAlert = () => { if (!importingErrorMessage) return null; @@ -1868,7 +1941,8 @@ const DatabaseModal: FunctionComponent = ({ passwordFields.length || sshTunnelPasswordFields.length || sshTunnelPrivateKeyFields.length || - sshTunnelPrivateKeyPasswordFields.length) + sshTunnelPrivateKeyPasswordFields.length || + encryptedExtraFields.length) ) { return ( = ({ {confirmOverwriteField()} {importingErrorAlert()} {passwordNeededField()} + {encryptedExtraNeededField()} ); } diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index caad7beef47..dc705c1da72 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -34,6 +34,7 @@ import { getSSHPasswordsNeeded, getSSHPrivateKeysNeeded, getSSHPrivateKeyPasswordsNeeded, + getEncryptedExtraFieldsNeeded, } from 'src/views/CRUD/utils'; import type { ListViewFetchDataConfig as FetchDataConfig, @@ -44,7 +45,11 @@ import copyTextToClipboard from 'src/utils/copy'; import { ensureAppRoot } from 'src/utils/pathUtils'; import SupersetText from 'src/utils/textUtils'; import { DatabaseObject } from 'src/features/databases/types'; -import { FavoriteStatus, ImportResourceName } from './types'; +import { + FavoriteStatus, + FileEncryptedExtraFields, + ImportResourceName, +} from './types'; interface ListViewResourceState { loading: boolean; @@ -439,6 +444,7 @@ interface ImportResourceState { sshPasswordNeeded: string[]; sshPrivateKeyNeeded: string[]; sshPrivateKeyPasswordNeeded: string[]; + encryptedExtraFieldsNeeded: FileEncryptedExtraFields[]; failed: boolean; } @@ -454,6 +460,7 @@ export function useImportResource( sshPasswordNeeded: [], sshPrivateKeyNeeded: [], sshPrivateKeyPasswordNeeded: [], + encryptedExtraFieldsNeeded: [], failed: false, }); @@ -468,6 +475,7 @@ export function useImportResource( sshTunnelPasswords: Record = {}, sshTunnelPrivateKey: Record = {}, sshTunnelPrivateKeyPasswords: Record = {}, + encryptedExtraSecrets: Record> = {}, overwrite = false, ) => { // Set loading state @@ -522,6 +530,18 @@ export function useImportResource( JSON.stringify(sshTunnelPrivateKeyPasswords), ); } + /* The import bundle may contain masked_encrypted_extra; if required + * the secrets should be provided by the user during import. + */ + if ( + encryptedExtraSecrets && + Object.keys(encryptedExtraSecrets).length > 0 + ) { + formData.append( + 'encrypted_extra_secrets', + JSON.stringify(encryptedExtraSecrets), + ); + } return SupersetClient.post({ endpoint: `/api/v1/${resourceName}/import/`, @@ -535,6 +555,7 @@ export function useImportResource( sshPasswordNeeded: [], sshPrivateKeyNeeded: [], sshPrivateKeyPasswordNeeded: [], + encryptedExtraFieldsNeeded: [], failed: false, }); return true; @@ -573,6 +594,9 @@ export function useImportResource( sshPrivateKeyPasswordNeeded: getSSHPrivateKeyPasswordsNeeded( error.errors, ), + encryptedExtraFieldsNeeded: getEncryptedExtraFieldsNeeded( + error.errors, + ), alreadyExists: getAlreadyExists(error.errors), }); } diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts index 5049323055b..e781a282910 100644 --- a/superset-frontend/src/views/CRUD/types.ts +++ b/superset-frontend/src/views/CRUD/types.ts @@ -154,3 +154,13 @@ export interface Tag { export type DatabaseObject = Partial & Pick; + +export interface EncryptedExtraField { + path: string; + label: string; +} + +export interface FileEncryptedExtraFields { + fileName: string; + fields: EncryptedExtraField[]; +} diff --git a/superset-frontend/src/views/CRUD/utils.test.tsx b/superset-frontend/src/views/CRUD/utils.test.tsx index 28d3a175d65..52dc48a220e 100644 --- a/superset-frontend/src/views/CRUD/utils.test.tsx +++ b/superset-frontend/src/views/CRUD/utils.test.tsx @@ -20,6 +20,7 @@ import rison from 'rison'; import { checkUploadExtensions, getAlreadyExists, + getEncryptedExtraFieldsNeeded, getFilterValues, getPasswordsNeeded, getSSHPasswordsNeeded, @@ -27,6 +28,7 @@ import { getSSHPrivateKeyPasswordsNeeded, hasTerminalValidation, isAlreadyExists, + isNeedsEncryptedExtraField, isNeedsPassword, isNeedsSSHPassword, isNeedsSSHPrivateKey, @@ -184,6 +186,79 @@ const sshTunnelPrivateKeyPasswordNeededErrors = { ], }; +const encryptedExtraFieldNeededErrors = { + errors: [ + { + message: 'Error importing database', + error_type: 'GENERIC_COMMAND_ERROR', + level: 'warning', + extra: { + 'databases/imported_database.yaml': { + _schema: [ + 'Must provide value for masked_encrypted_extra field: $.credentials_info.private_key (Service Account Private Key)', + ], + }, + issue_codes: [ + { + code: 1010, + message: + 'Issue 1010 - Superset encountered an error while running a command.', + }, + ], + }, + }, + ], +}; + +const multipleEncryptedExtraFieldsNeededErrors = { + errors: [ + { + message: 'Error importing database', + error_type: 'GENERIC_COMMAND_ERROR', + level: 'warning', + extra: { + 'databases/snowflake_db.yaml': { + _schema: [ + 'Must provide value for masked_encrypted_extra field: $.auth_params.privatekey_body (Private Key Body)', + 'Must provide value for masked_encrypted_extra field: $.auth_params.privatekey_pass (Private Key Password)', + ], + }, + issue_codes: [ + { + code: 1010, + message: + 'Issue 1010 - Superset encountered an error while running a command.', + }, + ], + }, + }, + ], +}; + +const encryptedExtraFieldNoLabelErrors = { + errors: [ + { + message: 'Error importing database', + error_type: 'GENERIC_COMMAND_ERROR', + level: 'warning', + extra: { + 'databases/imported_database.yaml': { + _schema: [ + 'Must provide value for masked_encrypted_extra field: $.some.field', + ], + }, + issue_codes: [ + { + code: 1010, + message: + 'Issue 1010 - Superset encountered an error while running a command.', + }, + ], + }, + }, + ], +}; + test('identifies error payloads indicating that password is needed', () => { let needsPassword; @@ -366,6 +441,85 @@ test('does not ask for password when the import type is wrong', () => { expect(hasTerminalValidation(error.errors)).toBe(true); }); +test('identifies error payloads indicating that encrypted extra fields are needed', () => { + expect( + isNeedsEncryptedExtraField({ + _schema: [ + 'Must provide value for masked_encrypted_extra field: $.credentials_info.private_key (Service Account Private Key)', + ], + }), + ).toBe(true); + + expect( + isNeedsEncryptedExtraField( + 'Database already exists and `overwrite=true` was not passed', + ), + ).toBe(false); + + expect( + isNeedsEncryptedExtraField({ type: ['Must be equal to Database.'] }), + ).toBe(false); + + expect( + isNeedsEncryptedExtraField({ + _schema: ['Must provide a password for the database'], + }), + ).toBe(false); +}); + +test('extracts encrypted extra fields needed with path and label', () => { + const result = getEncryptedExtraFieldsNeeded( + encryptedExtraFieldNeededErrors.errors, + ); + expect(result).toEqual([ + { + fileName: 'databases/imported_database.yaml', + fields: [ + { + path: '$.credentials_info.private_key', + label: 'Service Account Private Key', + }, + ], + }, + ]); +}); + +test('extracts multiple encrypted extra fields from a single file', () => { + const result = getEncryptedExtraFieldsNeeded( + multipleEncryptedExtraFieldsNeededErrors.errors, + ); + expect(result).toEqual([ + { + fileName: 'databases/snowflake_db.yaml', + fields: [ + { path: '$.auth_params.privatekey_body', label: 'Private Key Body' }, + { + path: '$.auth_params.privatekey_pass', + label: 'Private Key Password', + }, + ], + }, + ]); +}); + +test('falls back to path as label when no parenthetical label is present', () => { + const result = getEncryptedExtraFieldsNeeded( + encryptedExtraFieldNoLabelErrors.errors, + ); + expect(result).toEqual([ + { + fileName: 'databases/imported_database.yaml', + fields: [{ path: '$.some.field', label: '$.some.field' }], + }, + ]); +}); + +test('encrypted extra field errors are non-terminal', () => { + expect(hasTerminalValidation(encryptedExtraFieldNeededErrors.errors)).toBe( + false, + ); +}); + test('successfully modified rison to encode correctly', () => { const problemCharacters = '& # ? ^ { } [ ] | " = + `'; diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx index 0f53e3492da..a7042146348 100644 --- a/superset-frontend/src/views/CRUD/utils.tsx +++ b/superset-frontend/src/views/CRUD/utils.tsx @@ -42,7 +42,13 @@ import { OWNER_TEXT_LABEL_PROP, OWNER_EMAIL_PROP, } from 'src/features/owners/OwnerSelectLabel'; -import { Dashboard, Filter, TableTab } from './types'; +import { + Dashboard, + EncryptedExtraField, + FileEncryptedExtraFields, + Filter, + TableTab, +} from './types'; // Modifies the rison encoding slightly to match the backend's rison encoding/decoding. Applies globally. // Code pulled from rison.js (https://github.com/Nanonid/rison), rison is licensed under the MIT license. @@ -516,6 +522,38 @@ export const getAlreadyExists = (errors: Record[]) => .map(([fileName]) => fileName), ); +// Matches error messages for masked_encrypted_extra fields. +// Format: "Must provide value for masked_encrypted_extra field: $.path (Label)" +// The label in parentheses is optional. +const ENCRYPTED_EXTRA_FIELD_REGEX = + /^Must provide value for masked_encrypted_extra field: (.+?)(?:\s+\((.+)\))?$/; + +export /* eslint-disable no-underscore-dangle */ +const isNeedsEncryptedExtraField = (payload: any) => + typeof payload === 'object' && + Array.isArray(payload._schema) && + payload._schema?.some((e: string) => ENCRYPTED_EXTRA_FIELD_REGEX.test(e)); + +export const getEncryptedExtraFieldsNeeded = ( + errors: Record[], +): FileEncryptedExtraFields[] => + errors.flatMap(error => + Object.entries(error.extra) + .filter(([, payload]) => isNeedsEncryptedExtraField(payload)) + .map(([fileName, payload]) => ({ + fileName, + fields: (payload as any)._schema + .filter((e: string) => ENCRYPTED_EXTRA_FIELD_REGEX.test(e)) + .map((e: string) => { + const match = e.match(ENCRYPTED_EXTRA_FIELD_REGEX); + if (!match) return null; + const path = match[1]; + return { path, label: match[2] || path }; + }) + .filter(Boolean) as EncryptedExtraField[], + })), + ); + export const hasTerminalValidation = (errors: Record[]) => errors.some(error => { const noIssuesCodes = Object.entries(error.extra).filter( @@ -530,7 +568,8 @@ export const hasTerminalValidation = (errors: Record[]) => isAlreadyExists(payload) || isNeedsSSHPassword(payload) || isNeedsSSHPrivateKey(payload) || - isNeedsSSHPrivateKeyPassword(payload), + isNeedsSSHPrivateKeyPassword(payload) || + isNeedsEncryptedExtraField(payload), ); }); From 9983e255f8295f992de59adfa420e27501aac83b Mon Sep 17 00:00:00 2001 From: yousoph Date: Mon, 9 Mar 2026 00:09:09 -0700 Subject: [PATCH 02/31] fix(charts): revert: improve negative stacked bar label positioning and accessibility (#37405) (#38484) Co-authored-by: Kamil Gabryjelski Co-authored-by: Claude Opus 4.6 --- .../src/Timeseries/transformers.ts | 43 +++----- .../test/Timeseries/transformers.test.ts | 97 ++++++++----------- 2 files changed, 53 insertions(+), 87 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts index d6e7f57fc92..799fce3003c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts @@ -31,7 +31,6 @@ import { ValueFormatter, } from '@superset-ui/core'; import { SupersetTheme, isThemeDark } from '@apache-superset/core/theme'; -import { getContrastingColor } from '@superset-ui/core'; import type { CallbackDataParams, DefaultStatesMixin, @@ -143,39 +142,28 @@ export const getBaselineSeriesForStream = ( }; }; -export function optimizeBarLabelPlacement( +export function transformNegativeLabelsPosition( series: SeriesOption, isHorizontal: boolean, ): TimeseriesDataRecord[] { /* - * Adjusts label position for all values in bar series - * Positions labels inside bars at appropriate edges to avoid axis overlap + * Adjusts label position for negative values in bar series * @param series - Array of series options * @param isHorizontal - Whether chart is horizontal - * @returns data with adjusted label positions for all values + * @returns data with adjusted label positions for negative values */ const transformValue = (value: any) => { const [xValue, yValue] = Array.isArray(value) ? value : [null, null]; const axisValue = isHorizontal ? xValue : yValue; - if (axisValue === null || axisValue === undefined) { - return value; - } - - // Use inside positioning for all bar charts to avoid axis overlap - const labelPosition = - axisValue < 0 - ? isHorizontal - ? 'insideLeft' - : 'insideBottom' - : isHorizontal - ? 'insideRight' - : 'insideTop'; - - return { - value, - label: { position: labelPosition }, - }; + return axisValue < 0 + ? { + value, + label: { + position: 'outside', + }, + } + : value; }; return (series.data as TimeseriesDataRecord[]).map(transformValue); @@ -388,7 +376,7 @@ export function transformSeries( ), } : seriesType === 'bar' && !stack - ? { data: optimizeBarLabelPlacement(series, isHorizontal) } + ? { data: transformNegativeLabelsPosition(series, isHorizontal) } : null : null), connectNulls, @@ -422,11 +410,8 @@ export function transformSeries( symbolSize: markerSize, label: { show: !!showValue, - position: stack ? 'inside' : isHorizontal ? 'right' : 'top', - color: - stack || seriesType === 'bar' - ? getContrastingColor(String(itemStyle.color)) - : theme?.colorText, + position: isHorizontal ? 'right' : 'top', + color: theme?.colorText, textBorderWidth: 0, formatter: (params: any) => { // don't show confidence band value labels, as they're already visible on the tooltip diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts index 7a2cb392337..54da9c14ea0 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts @@ -22,24 +22,20 @@ import { supersetTheme } from '@apache-superset/core/theme'; import type { SeriesOption } from 'echarts'; import { EchartsTimeseriesSeriesType } from '../../src'; import { TIMESERIES_CONSTANTS } from '../../src/constants'; -import { - LegendOrientation, - EchartsTimeseriesChartProps, -} from '../../src/types'; +import { LegendOrientation } from '../../src/types'; import { transformSeries, - optimizeBarLabelPlacement, + transformNegativeLabelsPosition, getPadding, } from '../../src/Timeseries/transformers'; import transformProps from '../../src/Timeseries/transformProps'; +import { EchartsTimeseriesChartProps } from '../../src/types'; import * as seriesUtils from '../../src/utils/series'; -// Mock the colorScale function to return different colors based on key -const mockColorScale = jest.fn((key: string) => { - if (key === 'test-key') return '#1f77b4'; // blue - if (key === 'series-key') return '#ff7f0e'; // orange - return '#2ca02c'; // green for any other key -}) as unknown as CategoricalColorScale; +// Mock the colorScale function +const mockColorScale = jest.fn( + (key: string, sliceId?: number) => `color-for-${key}-${sliceId}`, +) as unknown as CategoricalColorScale; describe('transformSeries', () => { const series = { name: 'test-series' }; @@ -53,8 +49,7 @@ describe('transformSeries', () => { const result = transformSeries(series, mockColorScale, 'test-key', opts); - expect(mockColorScale).toHaveBeenCalledWith('test-key', 1); - expect((result as any)?.itemStyle.color).toBe('#1f77b4'); + expect((result as any)?.itemStyle.color).toBe('color-for-test-key-1'); }); test('should use seriesKey if timeShiftColor is not enabled', () => { @@ -66,8 +61,7 @@ describe('transformSeries', () => { const result = transformSeries(series, mockColorScale, 'test-key', opts); - expect(mockColorScale).toHaveBeenCalledWith('series-key', 2); - expect((result as any)?.itemStyle.color).toBe('#ff7f0e'); + expect((result as any)?.itemStyle.color).toBe('color-for-series-key-2'); }); test('should apply border styles for bar series with connectNulls', () => { @@ -129,8 +123,8 @@ describe('transformSeries', () => { }); }); -describe('optimizeBarLabelPlacement', () => { - test('label position for non-stacked vertical charts', () => { +describe('transformNegativeLabelsPosition', () => { + test('label position bottom of negative value no Horizontal', () => { const isHorizontal = false; const series: SeriesOption = { data: [ @@ -143,12 +137,15 @@ describe('optimizeBarLabelPlacement', () => { type: EchartsTimeseriesSeriesType.Bar, stack: undefined, }; - const result = optimizeBarLabelPlacement(series, isHorizontal); - expect((result as any)[0].label.position).toBe('insideTop'); - expect((result as any)[1].label.position).toBe('insideTop'); - expect((result as any)[2].label.position).toBe('insideBottom'); - expect((result as any)[3].label.position).toBe('insideBottom'); - expect((result as any)[4].label.position).toBe('insideTop'); + const result = + Array.isArray(series.data) && series.type === 'bar' && !series.stack + ? transformNegativeLabelsPosition(series, isHorizontal) + : series.data; + expect((result as any)[0].label).toBe(undefined); + expect((result as any)[1].label).toBe(undefined); + expect((result as any)[2].label.position).toBe('outside'); + expect((result as any)[3].label.position).toBe('outside'); + expect((result as any)[4].label).toBe(undefined); }); test('label position left of negative value is Horizontal', () => { @@ -165,12 +162,15 @@ describe('optimizeBarLabelPlacement', () => { stack: undefined, }; - const result = optimizeBarLabelPlacement(series, isHorizontal); - expect((result as any)[0].label.position).toBe('insideRight'); - expect((result as any)[1].label.position).toBe('insideLeft'); - expect((result as any)[2].label.position).toBe('insideRight'); - expect((result as any)[3].label.position).toBe('insideLeft'); - expect((result as any)[4].label.position).toBe('insideLeft'); + const result = + Array.isArray(series.data) && series.type === 'bar' && !series.stack + ? transformNegativeLabelsPosition(series, isHorizontal) + : series.data; + expect((result as any)[0].label).toBe(undefined); + expect((result as any)[1].label.position).toBe('outside'); + expect((result as any)[2].label).toBe(undefined); + expect((result as any)[3].label.position).toBe('outside'); + expect((result as any)[4].label.position).toBe('outside'); }); test('label position to line type', () => { @@ -192,7 +192,7 @@ describe('optimizeBarLabelPlacement', () => { !series.stack && series.type !== 'line' && series.type === 'bar' - ? optimizeBarLabelPlacement(series, isHorizontal) + ? transformNegativeLabelsPosition(series, isHorizontal) : series.data; expect((result as any)[0].label).toBe(undefined); expect((result as any)[1].label).toBe(undefined); @@ -215,34 +215,15 @@ describe('optimizeBarLabelPlacement', () => { stack: 'obs', }; - const result = optimizeBarLabelPlacement(series, isHorizontal); - expect((result as any)[0].label.position).toBe('insideTop'); - expect((result as any)[1].label.position).toBe('insideTop'); - expect((result as any)[2].label.position).toBe('insideBottom'); - expect((result as any)[3].label.position).toBe('insideBottom'); - expect((result as any)[4].label.position).toBe('insideTop'); - }); - - test('label position for horizontal stacked charts', () => { - const isHorizontal = true; - const series: SeriesOption = { - data: [ - [1, 2020], - [-3, 2021], - [2, 2022], - [-4, 2023], - [-6, 2024], - ], - type: EchartsTimeseriesSeriesType.Bar, - stack: 'obs', - }; - - const result = optimizeBarLabelPlacement(series, isHorizontal); - expect((result as any)[0].label.position).toBe('insideRight'); - expect((result as any)[1].label.position).toBe('insideLeft'); - expect((result as any)[2].label.position).toBe('insideRight'); - expect((result as any)[3].label.position).toBe('insideLeft'); - expect((result as any)[4].label.position).toBe('insideLeft'); + const result = + Array.isArray(series.data) && series.type === 'bar' && !series.stack + ? transformNegativeLabelsPosition(series, isHorizontal) + : series.data; + expect((result as any)[0].label).toBe(undefined); + expect((result as any)[1].label).toBe(undefined); + expect((result as any)[2].label).toBe(undefined); + expect((result as any)[3].label).toBe(undefined); + expect((result as any)[4].label).toBe(undefined); }); }); From c7a1f57487ce7e60bb873005ecf29094e81d7a94 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:05:55 +0100 Subject: [PATCH 03/31] fix(sqla): parenthesize extras where/having clauses in query generation (#38183) Co-authored-by: Diego Pucci Co-authored-by: Claude Opus 4.6 --- superset/models/helpers.py | 8 +- tests/unit_tests/models/helpers_test.py | 113 ++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/superset/models/helpers.py b/superset/models/helpers.py index 46ae374fd57..f6f28f63850 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -58,7 +58,7 @@ from sqlalchemy import and_, Column, or_, UniqueConstraint from sqlalchemy.exc import MultipleResultsFound from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import Mapper, validates -from sqlalchemy.sql.elements import ColumnElement, literal_column, TextClause +from sqlalchemy.sql.elements import ColumnElement, Grouping, literal_column, TextClause from sqlalchemy.sql.expression import Label, Select, TextAsFrom from sqlalchemy.sql.selectable import Alias, TableClause from sqlalchemy_utils import UUIDType @@ -2980,7 +2980,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods where_clause_and: list[ColumnElement] = [] having_clause_and: list[ColumnElement] = [] - for flt in filter: # type: ignore + for flt in filter or []: if not all(flt.get(s) for s in ["col", "op"]): continue flt_col = flt["col"] @@ -3221,7 +3221,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods schema=self.schema, template_processor=template_processor, ) - where_clause_and += [self.text(where)] + where_clause_and += [Grouping(self.text(where))] having = extras.get("having") if having: having = self._process_select_expression( @@ -3231,7 +3231,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods schema=self.schema, template_processor=template_processor, ) - having_clause_and += [self.text(having)] + having_clause_and += [Grouping(self.text(having))] if apply_fetch_values_predicate and self.fetch_values_predicate: qry = qry.where( diff --git a/tests/unit_tests/models/helpers_test.py b/tests/unit_tests/models/helpers_test.py index e5a177285e8..c0354844737 100644 --- a/tests/unit_tests/models/helpers_test.py +++ b/tests/unit_tests/models/helpers_test.py @@ -1743,3 +1743,116 @@ def test_orderby_adhoc_column(database: Database) -> None: # Verify the SQL contains the expression from the adhoc column sql = str(result.sqla_query) assert "ORDER BY" in sql.upper() + + +def test_extras_where_is_parenthesized( + database: Database, +) -> None: + """ + Test that extras.where is wrapped in parentheses when composed with other + filters. + + Without parentheses, an extras.where containing OR operators combined + with other filters via AND could produce unexpected evaluation order due + to SQL operator precedence (AND binds tighter than OR). Wrapping in + parentheses ensures the expression is treated as a single logical unit. + """ + from unittest.mock import patch + + from sqlalchemy import text as sa_text + + from superset.connectors.sqla.models import SqlaTable, TableColumn + + table = SqlaTable( + database=database, + schema=None, + table_name="t", + columns=[ + TableColumn(column_name="a", type="INTEGER"), + TableColumn(column_name="b", type="TEXT"), + ], + ) + + with ( + patch.object( + table, + "get_sqla_row_level_filters", + return_value=[sa_text("(b = 'restricted')")], + ), + patch.object( + table, + "_process_select_expression", + return_value="1 = 1 OR 1 = 1", + ), + ): + sqla_query = table.get_sqla_query( + columns=["a"], + extras={"where": "1=1 OR 1=1"}, + is_timeseries=False, + metrics=[], + ) + + with database.get_sqla_engine() as engine: + sql = str( + sqla_query.sqla_query.compile( + dialect=engine.dialect, + compile_kwargs={"literal_binds": True}, + ) + ) + + assert "(1 = 1 OR 1 = 1)" in sql, ( + f"extras.where should be wrapped in parentheses. Generated SQL: {sql}" + ) + + assert "b = 'restricted'" in sql, ( + f"Additional filters should be present in query. Generated SQL: {sql}" + ) + + +def test_extras_having_is_parenthesized( + database: Database, +) -> None: + """ + Test that extras.having is wrapped in parentheses when composed with + other HAVING filters, to ensure correct evaluation order. + """ + from unittest.mock import patch + + from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn + + table = SqlaTable( + database=database, + schema=None, + table_name="t", + columns=[ + TableColumn(column_name="a", type="INTEGER"), + TableColumn(column_name="b", type="TEXT"), + ], + metrics=[ + SqlMetric(metric_name="cnt", expression="COUNT(*)"), + ], + ) + + with patch.object( + table, + "_process_select_expression", + return_value="COUNT(*) > 0 OR 1 = 1", + ): + sqla_query = table.get_sqla_query( + groupby=["b"], + metrics=["cnt"], + extras={"having": "COUNT(*) > 0 OR 1=1"}, + is_timeseries=False, + ) + + with database.get_sqla_engine() as engine: + sql = str( + sqla_query.sqla_query.compile( + dialect=engine.dialect, + compile_kwargs={"literal_binds": True}, + ) + ) + + assert "(COUNT(*) > 0 OR 1 = 1)" in sql, ( + f"extras.having should be wrapped in parentheses. Generated SQL: {sql}" + ) From 577654cd02ef3efdeaac096d08b28aaae77da337 Mon Sep 17 00:00:00 2001 From: yousoph Date: Mon, 9 Mar 2026 08:54:47 -0700 Subject: [PATCH 04/31] fix(heatmap): correct tooltip display to show axis values instead of indices (#38487) --- .../src/Heatmap/transformProps.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts index 8d6185c05a5..cb2d250dd04 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts @@ -370,20 +370,32 @@ export default function transformProps( metricLabel, ); const paramsValue = params.value as (string | number)[]; - const x = paramsValue?.[0]; - const y = paramsValue?.[1]; + // paramsValue contains [xIndex, yIndex, metricValue, rankValue?] + // We need to look up the actual axis values from the sorted arrays + const xIndex = paramsValue?.[0] as number; + const yIndex = paramsValue?.[1] as number; const value = paramsValue?.[2] as number | null | undefined; - const formattedX = xAxisFormatter(x); - const formattedY = yAxisFormatter(y); + const xValue = sortedXAxisValues[xIndex]; + const yValue = sortedYAxisValues[yIndex]; + // Format the axis values for display (handle null/undefined with empty string fallback) + // Convert to string/number for formatter compatibility + const formattedX = + xValue !== null && xValue !== undefined + ? xAxisFormatter(xValue as string | number) + : ''; + const formattedY = + yValue !== null && yValue !== undefined + ? yAxisFormatter(yValue as string | number) + : ''; const formattedValue = valueFormatter(value); let percentage = 0; let suffix = 'heatmap'; if (typeof value === 'number') { if (normalizeAcross === 'x') { - percentage = value / totals.x[x]; + percentage = value / totals.x[String(xValue)]; suffix = formattedX; } else if (normalizeAcross === 'y') { - percentage = value / totals.y[y]; + percentage = value / totals.y[String(yValue)]; suffix = formattedY; } else { percentage = value / totals.total; From e70c7944b7549934bc61b71677e16a66af2a7419 Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:25:39 -0300 Subject: [PATCH 05/31] fix(tests): achieve 100% TypeScript coverage for core packages (#38518) --- .../src/theme/utils/themeUtils.test.ts | 30 ++++ .../src/theme/utils/utils.test.ts | 22 +++ .../src/translation/Translator.test.ts | 132 ++++++++++++++++++ .../src/translation/Translator.ts | 1 + .../translation/TranslatorSingleton.test.ts | 112 +++++++++++++++ .../superset-core/src/utils/logging.ts | 1 + .../test/query/getAxis.test.ts | 50 ++++++- 7 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 superset-frontend/packages/superset-core/src/translation/Translator.test.ts create mode 100644 superset-frontend/packages/superset-core/src/translation/TranslatorSingleton.test.ts diff --git a/superset-frontend/packages/superset-core/src/theme/utils/themeUtils.test.ts b/superset-frontend/packages/superset-core/src/theme/utils/themeUtils.test.ts index 76b8922668f..33b2df7ef4a 100644 --- a/superset-frontend/packages/superset-core/src/theme/utils/themeUtils.test.ts +++ b/superset-frontend/packages/superset-core/src/theme/utils/themeUtils.test.ts @@ -16,12 +16,16 @@ * specific language governing permissions and limitations * under the License. */ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { ThemeProvider } from '@emotion/react'; import { theme as antdTheme } from 'antd'; import { getFontSize, getColorVariants, isThemeDark, isThemeConfigDark, + useThemeMode, } from './themeUtils'; import { Theme } from '../Theme'; import { ThemeAlgorithm } from '../types'; @@ -234,3 +238,29 @@ test('isThemeConfigDark returns false for config with empty token object', () => }; expect(isThemeConfigDark(config)).toBe(false); }); + +test('useThemeMode returns false for a light theme', () => { + const { result } = renderHook(() => useThemeMode(), { + wrapper: ({ children }) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + React.createElement( + ThemeProvider, + { theme: lightTheme.theme } as any, + children, + ), + }); + expect(result.current).toBe(false); +}); + +test('useThemeMode returns true for a dark theme', () => { + const { result } = renderHook(() => useThemeMode(), { + wrapper: ({ children }) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + React.createElement( + ThemeProvider, + { theme: darkTheme.theme } as any, + children, + ), + }); + expect(result.current).toBe(true); +}); diff --git a/superset-frontend/packages/superset-core/src/theme/utils/utils.test.ts b/superset-frontend/packages/superset-core/src/theme/utils/utils.test.ts index c1a45d73388..65f40b802dc 100644 --- a/superset-frontend/packages/superset-core/src/theme/utils/utils.test.ts +++ b/superset-frontend/packages/superset-core/src/theme/utils/utils.test.ts @@ -336,3 +336,25 @@ test('getSystemColors extracts system colors from tokens', () => { colorInfo: '#info', }); }); + +test('deserializeThemeConfig unwraps a single-element valid algorithm array', () => { + const config: SerializableThemeConfig = { + token: { colorPrimary: '#ff0000' }, + algorithm: [ThemeAlgorithm.DARK], + }; + + const result = deserializeThemeConfig(config); + + expect(result.algorithm).toBe(antdThemeImport.darkAlgorithm); +}); + +test('deserializeThemeConfig falls back to defaultAlgorithm when all algorithms in array are invalid', () => { + const config = { + token: { colorPrimary: '#ff0000' }, + algorithm: ['unknown_algo'], + }; + + const result = deserializeThemeConfig(config as any); + + expect(result.algorithm).toBe(antdThemeImport.defaultAlgorithm); +}); diff --git a/superset-frontend/packages/superset-core/src/translation/Translator.test.ts b/superset-frontend/packages/superset-core/src/translation/Translator.test.ts new file mode 100644 index 00000000000..23216bd0288 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/translation/Translator.test.ts @@ -0,0 +1,132 @@ +/** + * 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. + */ +import Translator from './Translator'; +import logging from '../utils/logging'; + +let warnSpy: jest.SpyInstance; + +beforeEach(() => { + warnSpy = jest.spyOn(logging, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + warnSpy.mockRestore(); +}); + +test('addTranslation adds a key-value translation', () => { + const translator = new Translator(); + translator.addTranslation('hello', ['Hello World']); + expect(translator.translate('hello')).toBe('Hello World'); +}); + +test('addTranslations adds multiple translations from an object', () => { + const translator = new Translator(); + translator.addTranslations({ + 'key one': ['value one'], + 'key two': ['value two'], + }); + expect(translator.translate('key one')).toBe('value one'); + expect(translator.translate('key two')).toBe('value two'); +}); + +test('addTranslations warns on null input', () => { + const translator = new Translator(); + translator.addTranslations(null as any); + expect(warnSpy).toHaveBeenCalledWith('Invalid translations'); +}); + +test('addTranslations warns on array input', () => { + const translator = new Translator(); + translator.addTranslations(['value'] as any); + expect(warnSpy).toHaveBeenCalledWith('Invalid translations'); +}); + +test('addLocaleData adds translations for the current locale', () => { + const translator = new Translator(); + translator.addLocaleData({ en: { greeting: ['Hello!'] } }); + expect(translator.translate('greeting')).toBe('Hello!'); +}); + +test('addLocaleData falls back to English when current locale has no data', () => { + const translator = new Translator({ + languagePack: { + domain: 'superset', + locale_data: { + superset: { + '': { + domain: 'superset', + lang: 'fr', + plural_forms: 'nplurals=2; plural=(n != 1)', + }, + }, + }, + }, + }); + translator.addLocaleData({ en: { bonjour: ['Hello from en!'] } }); + expect(translator.translate('bonjour')).toBe('Hello from en!'); +}); + +test('addLocaleData warns when locale data has no matching locale', () => { + const translator = new Translator(); + translator.addLocaleData({} as any); + expect(warnSpy).toHaveBeenCalledWith('Invalid locale data'); +}); + +test('translate returns the input key when Jed throws', () => { + const translator = new Translator(); + (translator as any).i18n = { + translate: () => { + throw new Error('jed error'); + }, + }; + expect(translator.translate('error key')).toBe('error key'); +}); + +test('translateWithNumber returns a string when translating with number as first arg', () => { + const translator = new Translator(); + translator.addTranslation('%(num)d item', ['%(num)d item', '%(num)d items']); + const result = translator.translateWithNumber( + '%(num)d item', + 1, + '%(num)d item', + '%(num)d items', + ); + expect(typeof result).toBe('string'); +}); + +test('translateWithNumber returns a string when translating with plural string as first arg', () => { + const translator = new Translator(); + translator.addTranslation('%(num)d item', ['%(num)d item', '%(num)d items']); + const result = translator.translateWithNumber( + '%(num)d item', + '%(num)d item', + 2, + ); + expect(typeof result).toBe('string'); +}); + +test('translateWithNumber returns the key when Jed throws', () => { + const translator = new Translator(); + (translator as any).i18n = { + translate: () => { + throw new Error('jed error'); + }, + }; + expect(translator.translateWithNumber('plural key', 1)).toBe('plural key'); +}); diff --git a/superset-frontend/packages/superset-core/src/translation/Translator.ts b/superset-frontend/packages/superset-core/src/translation/Translator.ts index bf27ec7ce4f..ba40a87e2f7 100644 --- a/superset-frontend/packages/superset-core/src/translation/Translator.ts +++ b/superset-frontend/packages/superset-core/src/translation/Translator.ts @@ -56,6 +56,7 @@ export default class Translator { */ addTranslation(key: string, texts: ReadonlyArray) { const translations = this.i18n.options.locale_data.superset; + /* istanbul ignore next */ if (process.env.WEBPACK_MODE !== 'test' && key in translations) { logging.warn(`Duplicate translation key "${key}", will override.`); } diff --git a/superset-frontend/packages/superset-core/src/translation/TranslatorSingleton.test.ts b/superset-frontend/packages/superset-core/src/translation/TranslatorSingleton.test.ts new file mode 100644 index 00000000000..57573d4fc5c --- /dev/null +++ b/superset-frontend/packages/superset-core/src/translation/TranslatorSingleton.test.ts @@ -0,0 +1,112 @@ +/** + * 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. + */ + +// Each test uses jest.isolateModules to get a fresh module state +// (isConfigured = false, singleton = undefined) independent of other tests. + +test('t() warns and creates a default translator when called before configure', () => { + jest.isolateModules(() => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { t } = require('./TranslatorSingleton'); + const result = t('hello'); + expect(consoleSpy).toHaveBeenCalledWith( + 'You should call configure(...) before calling other methods', + ); + expect(result).toBe('hello'); + consoleSpy.mockRestore(); + }); +}); + +test('configure sets up the singleton and t() works without warnings', () => { + jest.isolateModules(() => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { configure, t } = require('./TranslatorSingleton'); + configure(); + const result = t('hello'); + expect(consoleSpy).not.toHaveBeenCalled(); + expect(result).toBe('hello'); + consoleSpy.mockRestore(); + }); +}); + +test('resetTranslation resets the configured singleton', () => { + jest.isolateModules(() => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { configure, resetTranslation, t } = require('./TranslatorSingleton'); + configure(); + resetTranslation(); + // After reset, calling t() should warn again + t('hello'); + expect(consoleSpy).toHaveBeenCalledWith( + 'You should call configure(...) before calling other methods', + ); + consoleSpy.mockRestore(); + }); +}); + +test('addTranslation adds a translation via the singleton', () => { + jest.isolateModules(() => { + const { configure, addTranslation, t } = require('./TranslatorSingleton'); + configure(); + addTranslation('greeting', ['Hello!']); + expect(t('greeting')).toBe('Hello!'); + }); +}); + +test('addTranslations adds multiple translations via the singleton', () => { + jest.isolateModules(() => { + const { configure, addTranslations, t } = require('./TranslatorSingleton'); + configure(); + addTranslations({ farewell: ['Goodbye!'] }); + expect(t('farewell')).toBe('Goodbye!'); + }); +}); + +test('addLocaleData adds locale translations via the singleton', () => { + jest.isolateModules(() => { + const { configure, addLocaleData, t } = require('./TranslatorSingleton'); + configure(); + addLocaleData({ en: { locale_key: ['locale value'] } }); + expect(t('locale_key')).toBe('locale value'); + }); +}); + +test('tn() calls translateWithNumber on the singleton', () => { + jest.isolateModules(() => { + const { configure, tn } = require('./TranslatorSingleton'); + configure(); + const result = tn('item', 1); + expect(typeof result).toBe('string'); + }); +}); + +test('resetTranslation does nothing when not yet configured', () => { + jest.isolateModules(() => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { resetTranslation, t } = require('./TranslatorSingleton'); + // resetTranslation is a no-op when isConfigured is false + resetTranslation(); + // The singleton is still unconfigured, so t() warns + t('hello'); + expect(consoleSpy).toHaveBeenCalledWith( + 'You should call configure(...) before calling other methods', + ); + consoleSpy.mockRestore(); + }); +}); diff --git a/superset-frontend/packages/superset-core/src/utils/logging.ts b/superset-frontend/packages/superset-core/src/utils/logging.ts index dd122cc9a80..e4c18688122 100644 --- a/superset-frontend/packages/superset-core/src/utils/logging.ts +++ b/superset-frontend/packages/superset-core/src/utils/logging.ts @@ -17,6 +17,7 @@ * under the License. */ +/* istanbul ignore next */ const console = typeof window !== 'undefined' ? window.console || {} : globalThis.console; const log = console.log || (() => {}); diff --git a/superset-frontend/packages/superset-ui-core/test/query/getAxis.test.ts b/superset-frontend/packages/superset-ui-core/test/query/getAxis.test.ts index 9f2d2846142..05dc6bbcb43 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/getAxis.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/getAxis.test.ts @@ -16,7 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import { isXAxisSet, VizType } from '@superset-ui/core'; +import { + isXAxisSet, + getXAxisColumn, + getXAxisLabel, + DTTM_ALIAS, + VizType, +} from '@superset-ui/core'; test('isXAxisSet', () => { expect( @@ -26,3 +32,45 @@ test('isXAxisSet', () => { isXAxisSet({ datasource: '123', viz_type: VizType.Table, x_axis: 'axis' }), ).toBeTruthy(); }); + +test('getXAxisColumn returns undefined when neither granularity_sqla nor x_axis is set', () => { + expect( + getXAxisColumn({ datasource: '123', viz_type: VizType.Table }), + ).toBeUndefined(); +}); + +test('getXAxisColumn returns x_axis when x_axis is set', () => { + expect( + getXAxisColumn({ + datasource: '123', + viz_type: VizType.Table, + x_axis: 'my_axis', + }), + ).toBe('my_axis'); +}); + +test('getXAxisColumn returns DTTM_ALIAS when only granularity_sqla is set', () => { + expect( + getXAxisColumn({ + datasource: '123', + viz_type: VizType.Table, + granularity_sqla: 'ds', + }), + ).toBe(DTTM_ALIAS); +}); + +test('getXAxisLabel returns the column label when x_axis is set', () => { + expect( + getXAxisLabel({ + datasource: '123', + viz_type: VizType.Table, + x_axis: 'my_axis', + }), + ).toBe('my_axis'); +}); + +test('getXAxisLabel returns undefined when no column is set', () => { + expect( + getXAxisLabel({ datasource: '123', viz_type: VizType.Table }), + ).toBeUndefined(); +}); From 62cebc8a0e620fade64fc3283075005777d7c8ee Mon Sep 17 00:00:00 2001 From: yousoph Date: Mon, 9 Mar 2026 09:29:43 -0700 Subject: [PATCH 06/31] fix(dashboard): prevent Apply button from disabling when required filters are auto-applied (#38479) Co-authored-by: Kamil Gabryjelski Co-authored-by: Claude Opus 4.6 --- .../nativeFilters/FilterBar/index.tsx | 14 +- .../nativeFilters/FilterBar/utils.test.ts | 219 +++++++++++++++++- .../nativeFilters/FilterBar/utils.ts | 38 ++- 3 files changed, 255 insertions(+), 16 deletions(-) diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx index 7bb0987353d..5647e327de1 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx @@ -238,7 +238,19 @@ const FilterBar: FC = ({ needsAutoApply); if (shouldDispatch) { - dispatch(updateDataMask(filter.id, dataMask)); + // Strip validateStatus before dispatching to Redux + // validateStatus is UI-only state and shouldn't persist in Redux + const { filterState, ...restDataMask } = dataMask; + const dataMaskForRedux = filterState + ? { + ...restDataMask, + filterState: { + ...filterState, + validateStatus: undefined, + }, + } + : dataMask; + dispatch(updateDataMask(filter.id, dataMaskForRedux)); } // Mark filter as initialized after getting its first value WITH extraFormData diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.test.ts index e2ca95c5c67..4536b907988 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.test.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.test.ts @@ -326,7 +326,9 @@ describe('FilterBar Utils - Validation and Apply Logic', () => { ).toBe(true); }); - test('should handle filter count mismatch', () => { + test('should enable Apply when new filter is selected', () => { + // User selects a new filter that hasn't been applied yet + // Apply should be ENABLED to allow applying the new selection const dataMaskSelected: DataMaskStateWithId = { 'filter-1': { id: 'filter-1', @@ -360,7 +362,7 @@ describe('FilterBar Utils - Validation and Apply Logic', () => { filters: [{ col: 'state', op: 'IN', val: ['CA'] }], }, }, - // Missing filter-2 + // filter-2 not yet applied }; const filters: Filter[] = [ @@ -370,7 +372,7 @@ describe('FilterBar Utils - Validation and Apply Logic', () => { expect( checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), - ).toBe(true); + ).toBe(false); }); test('should handle validation status recalculation scenario', () => { @@ -414,5 +416,216 @@ describe('FilterBar Utils - Validation and Apply Logic', () => { checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), ).toBe(false); }); + + test('should not disable Apply when required filter is auto-applied (bug fix)', () => { + // Bug scenario: Filter A has "required" + "select first by default" + // Filter A auto-applies and is synced to selected + // User changes Filter B (adds it to selected, not in applied yet) + // Apply button should be ENABLED (changes exist and required filter has value) + + const dataMaskSelected: DataMaskStateWithId = { + 'filter-country': { + id: 'filter-country', + filterState: { + validateStatus: undefined, + value: ['USA'], // Auto-applied value (synced from applied) + }, + extraFormData: { + filters: [{ col: 'country', op: 'IN', val: ['USA'] }], + }, + }, + 'filter-product': { + id: 'filter-product', + filterState: { + validateStatus: undefined, + value: ['Product A'], // User changed this filter + }, + extraFormData: { + filters: [{ col: 'product_line', op: 'IN', val: ['Product A'] }], + }, + }, + }; + + const dataMaskApplied: DataMaskStateWithId = { + 'filter-country': { + id: 'filter-country', + filterState: { + value: ['USA'], // Already applied + }, + extraFormData: { + filters: [{ col: 'country', op: 'IN', val: ['USA'] }], + }, + }, + // filter-product not yet applied + }; + + const filters: Filter[] = [ + { + id: 'filter-country', + controlValues: { + enableEmptyFilter: true, // Required filter + }, + } as unknown as Filter, + { + id: 'filter-product', + controlValues: { + enableEmptyFilter: false, + }, + } as unknown as Filter, + ]; + + // Should be ENABLED - Filter B has changes to apply and Filter A (required) has value + expect( + checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), + ).toBe(false); + }); + + test('should not disable Apply when required filter is only in applied state during sync gap', () => { + // Edge case: Required filter auto-applied but not yet synced to selected + // The filter is NOT in dataMaskSelected at all (key doesn't exist) + // This can happen during the React state update cycle + // Apply button should still be enabled if other filters have changes + + const dataMaskSelected: DataMaskStateWithId = { + 'filter-product': { + id: 'filter-product', + filterState: { + validateStatus: undefined, + value: ['Product B'], + }, + extraFormData: { + filters: [{ col: 'product_line', op: 'IN', val: ['Product B'] }], + }, + }, + // filter-country not yet in selected (sync gap) - KEY DOESN'T EXIST + }; + + const dataMaskApplied: DataMaskStateWithId = { + 'filter-country': { + id: 'filter-country', + filterState: { + value: ['USA'], // Auto-applied, has value + }, + extraFormData: { + filters: [{ col: 'country', op: 'IN', val: ['USA'] }], + }, + }, + // filter-product not yet applied + }; + + const filters: Filter[] = [ + { + id: 'filter-country', + controlValues: { + enableEmptyFilter: true, // Required filter + }, + } as unknown as Filter, + { + id: 'filter-product', + controlValues: { + enableEmptyFilter: false, + }, + } as unknown as Filter, + ]; + + // Should be ENABLED - Required filter has value in applied state (auto-applied), + // and it's not in selected at all (not explicitly cleared by user) + expect( + checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), + ).toBe(false); + }); + + test('should disable Apply when user explicitly clears a required filter value', () => { + // Different from sync gap: filter IS in selected state but with undefined value + // This means user explicitly cleared it + // Apply should be DISABLED because it's a required filter + + const dataMaskSelected: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { + validateStatus: undefined, + value: undefined, // User explicitly cleared the value + }, + extraFormData: {}, + }, + }; + + const dataMaskApplied: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { + value: ['CA'], // Previously had a value + }, + extraFormData: { + filters: [{ col: 'state', op: 'IN', val: ['CA'] }], + }, + }, + }; + + const filters: Filter[] = [ + { + id: 'filter-1', + controlValues: { + enableEmptyFilter: true, // Required filter + }, + } as unknown as Filter, + ]; + + // Should be DISABLED - User cleared a required filter + // Even though it has value in applied state, the selected state shows user intent + expect( + checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), + ).toBe(true); + }); + + test('should enable Apply when filter has value but needs extraFormData update (PR #36927 regression test)', () => { + // Original bug from PR #36927: defaultDataMask has value but empty extraFormData + // The filter loads with a value in applied state but no extraFormData + // Then the filter plugin generates extraFormData and updates selected state + // Apply should be ENABLED to allow the extraFormData to be applied + + const dataMaskSelected: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { + validateStatus: undefined, + value: ['value1', 'value2'], // Same value + }, + extraFormData: { + // Now has extraFormData generated by filter plugin + filters: [ + { col: 'test_column', op: 'IN', val: ['value1', 'value2'] }, + ], + }, + }, + }; + + const dataMaskApplied: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { + value: ['value1', 'value2'], // Has value from defaultDataMask + }, + extraFormData: {}, // But extraFormData is empty (the bug scenario) + }, + }; + + const filters: Filter[] = [ + { + id: 'filter-1', + controlValues: { + enableEmptyFilter: true, // Required filter + }, + } as unknown as Filter, + ]; + + // Should be ENABLED because extraFormData changed (not equal) + // even though the value is the same + // This allows the auto-apply logic to update the applied state with extraFormData + expect( + checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), + ).toBe(false); + }); }); }); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts index 78e0fbf05c6..7fe635b5c93 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts @@ -61,15 +61,32 @@ export const checkIsApplyDisabled = ( return true; } - const dataSelectedValues = Object.values(dataMaskSelected); - const dataAppliedValues = Object.values(dataMaskApplied); + // Check if any required filter is missing a value + // For filters that may have been auto-applied (e.g., requiredFirst filters), + // check both selected and applied states to avoid false positives during initialization + const hasMissingRequiredFilter = filters.some(filter => { + const selectedDataMask = dataMaskSelected?.[filter?.id]; + const selectedState = selectedDataMask?.filterState; + const appliedState = dataMaskApplied?.[filter?.id]?.filterState; - const hasMissingRequiredFilter = filters.some(filter => - checkIsMissingRequiredValue( - filter, - dataMaskSelected?.[filter?.id]?.filterState, - ), - ); + // If filter has value in selected state, it's not missing + if (selectedState?.value !== null && selectedState?.value !== undefined) { + return false; + } + + // If filter is not in selected state at all (not initialized yet), + // check if it was auto-applied and has value in applied state + // This handles the case where auto-applied filters haven't synced to selected yet + if (!selectedDataMask) { + if (appliedState?.value !== null && appliedState?.value !== undefined) { + return false; // Not missing, it's auto-applied + } + } + + // Otherwise, check if it's actually a required filter with missing value + // This includes cases where user explicitly cleared the value (selectedDataMask exists but value is undefined) + return checkIsMissingRequiredValue(filter, selectedState); + }); const selectedExtraFormData = getOnlyExtraFormData(dataMaskSelected); const appliedExtraFormData = getOnlyExtraFormData(dataMaskApplied); @@ -80,10 +97,7 @@ export const checkIsApplyDisabled = ( { ignoreUndefined: true }, ); - const result = - areEqual || - dataSelectedValues.length !== dataAppliedValues.length || - hasMissingRequiredFilter; + const result = areEqual || hasMissingRequiredFilter; return result; }; From dca41f9a7b8aa6274f048344a23bdc94b4e00e56 Mon Sep 17 00:00:00 2001 From: Gabriel Torres Ruiz Date: Mon, 9 Mar 2026 12:53:38 -0400 Subject: [PATCH 07/31] fix(theme): prevent background color flash on page load (#38399) --- superset-embedded-sdk/src/index.ts | 1 + superset/templates/superset/spa.html | 32 +++++++++++++++++++++------- superset/views/base.py | 5 +++++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/superset-embedded-sdk/src/index.ts b/superset-embedded-sdk/src/index.ts index 419ed2526ec..e732a36c22f 100644 --- a/superset-embedded-sdk/src/index.ts +++ b/superset-embedded-sdk/src/index.ts @@ -232,6 +232,7 @@ export async function embedDashboard({ }); iframe.src = `${supersetDomain}/embedded/${id}${urlParamsString}`; iframe.title = iframeTitle; + iframe.style.background = 'transparent'; if (iframeAllowExtras.length > 0) { iframe.setAttribute('allow', iframeAllowExtras.join('; ')); } diff --git a/superset/templates/superset/spa.html b/superset/templates/superset/spa.html index 8ca114b3cf3..40f5af6f34f 100644 --- a/superset/templates/superset/spa.html +++ b/superset/templates/superset/spa.html @@ -35,19 +35,35 @@ {% endblock %} + {% if dark_theme_bg and entry != 'embedded' %} + + {% endif %} + {% block head_css %} {% for favicon in favicons %} Date: Mon, 9 Mar 2026 14:40:08 -0300 Subject: [PATCH 08/31] chore(extensions): bump superset-core and superset-extensions-cli to 0.1.0rc1 (#38516) --- superset-core/README.md | 98 ++++++------ superset-core/pyproject.toml | 2 +- superset-extensions-cli/README.md | 75 ++++----- superset-extensions-cli/pyproject.toml | 2 +- superset-frontend/package-lock.json | 2 +- .../packages/superset-core/README.md | 144 ++++++++++-------- .../packages/superset-core/package.json | 2 +- 7 files changed, 161 insertions(+), 164 deletions(-) diff --git a/superset-core/README.md b/superset-core/README.md index 0ca50a252f7..61fefbac142 100644 --- a/superset-core/README.md +++ b/superset-core/README.md @@ -31,70 +31,70 @@ The official core package for building Apache Superset backend extensions and in pip install apache-superset-core ``` -## 🏗️ Architecture +## 🏗️ Package Structure -The package is organized into logical modules, each providing specific functionality: - -- **`api`** - REST API base classes, models access, query utilities, and registration -- **`api.models`** - Access to Superset's database models (datasets, databases, etc.) -- **`api.query`** - Database query utilities and SQL dialect handling -- **`api.rest_api`** - Extension API registration and management -- **`api.types.rest_api`** - REST API base classes and type definitions +``` +src/superset_core/ +├── common/ +├── extensions/ +├── mcp/ +├── queries/ +├── rest_api/ +├── tasks/ +└── __init__.py +``` ## 🚀 Quick Start -### Basic Extension Structure +### Basic Extension API ```python -from flask import request, Response from flask_appbuilder.api import expose, permission_name, protect, safe -from superset_core import common, queries, rest_api from superset_core.rest_api.api import RestApi +from superset_core.rest_api.decorators import api + +@api(id="dataset_references", name="Dataset References API") class DatasetReferencesAPI(RestApi): - """Example extension API demonstrating core functionality.""" - - resource_name = "dataset_references" - openapi_spec_tag = "Dataset references" - class_permission_name = "dataset_references" @expose("/metadata", methods=("POST",)) @protect() @safe @permission_name("read") def metadata(self) -> Response: - """Get dataset metadata for tables referenced in SQL.""" - sql: str = request.json.get("sql") - database_id: int = request.json.get("databaseId") - - # Access Superset's models using core APIs - databases = models.get_databases(id=database_id) - if not databases: - return self.response_404() - - database = databases[0] - dialect = query.get_sqlglot_dialect(database) - - # Access datasets to get owner information - datasets = models.get_datasets() - owners_map = { - dataset.table_name: [ - f"{owner.first_name} {owner.last_name}" - for owner in dataset.owners - ] - for dataset in datasets - } - - # Process SQL and return dataset metadata - return self.response(200, result=owners_map) - -# Register the extension API -rest_api.add_extension_api(DatasetReferencesAPI) + # ... endpoint implementation ``` -## 🤝 Contributing +### Background Tasks -We welcome contributions! Please see the [Developer Portal](https://superset.apache.org/developer_portal/) for details. +```python +from superset_core.tasks.decorators import task +from superset_core.tasks.types import TaskScope + +@task(name="generate_report", scope=TaskScope.SHARED) +def generate_report(chart_id: int) -> None: + # ... task implementation +``` + +### MCP Tools + +```python +from superset_core.mcp.decorators import tool + +@tool(name="my_tool", description="Custom business logic", tags=["extension"]) +def my_extension_tool(param: str) -> dict: + # ... tool implementation +``` + +### MCP Prompts + +```python +from superset_core.mcp.decorators import prompt + +@prompt(name="my_prompt", title="My Prompt", description="Interactive prompt", tags={"extension"}) +async def my_prompt_handler(ctx: Context) -> str: + # ... prompt implementation +``` ## 📄 License @@ -102,12 +102,6 @@ Licensed under the Apache License, Version 2.0. See [LICENSE](https://github.com ## 🔗 Links -- [Apache Superset](https://superset.apache.org/) -- [Documentation](https://superset.apache.org/docs/) - [Community](https://superset.apache.org/community/) - [GitHub Repository](https://github.com/apache/superset) -- [Extension Development Guide](https://superset.apache.org/docs/extensions/) - ---- - -**Note**: This package is currently in release candidate status. APIs may change before the 1.0.0 release. Please check the [changelog](CHANGELOG.md) for breaking changes between versions. +- [Extensions Documentation](https://superset.apache.org/developer-docs/extensions/overview) diff --git a/superset-core/pyproject.toml b/superset-core/pyproject.toml index 44b2b3df06e..57dfb231ade 100644 --- a/superset-core/pyproject.toml +++ b/superset-core/pyproject.toml @@ -18,7 +18,7 @@ [project] name = "apache-superset-core" -version = "0.0.1rc4" +version = "0.1.0rc1" description = "Core Python package for building Apache Superset backend extensions and integrations" readme = "README.md" authors = [ diff --git a/superset-extensions-cli/README.md b/superset-extensions-cli/README.md index 00b75f9ef33..a5143a80ba3 100644 --- a/superset-extensions-cli/README.md +++ b/superset-extensions-cli/README.md @@ -28,6 +28,7 @@ Official command-line interface for building, bundling, and managing Apache Supe ## 🚀 Features - **Extension Scaffolding** - Generate initial folder structure and scaffold new extension projects +- **Validation** - Validate extension structure and configuration before building - **Development Server** - Automatically rebuild extensions as files change during development - **Build System** - Build extension assets for production deployment - **Bundle Packaging** - Package extensions into distributable .supx files @@ -43,68 +44,52 @@ pip install apache-superset-extensions-cli ### Available Commands ```bash -# Generate initial folder structure and scaffold a new extension project -superset-extensions init +# Scaffold a new extension project (interactive prompts, or pass options directly) +superset-extensions init [--publisher ] [--name ] [--display-name ] + [--version ] [--license ] + [--frontend/--no-frontend] [--backend/--no-backend] + +# Validate extension structure and configuration +superset-extensions validate + +# Build extension assets for production (runs validate first) +superset-extensions build + +# Package extension into a distributable .supx file (runs build first) +superset-extensions bundle [--output/-o ] # Automatically rebuild extension as files change during development superset-extensions dev - -# Build extension assets for production -superset-extensions build - -# Package extension into a distributable .supx file -superset-extensions bundle ``` ## 📋 Extension Structure -The CLI generates extensions with the following structure: +The CLI scaffolds extensions with the following structure: ``` -extension_name/ +{publisher}.{name}/ # e.g., my-org.dashboard-widgets/ ├── extension.json # Extension configuration and metadata -├── frontend/ # Frontend code -│ ├── src/ # TypeScript/React source files -│ ├── webpack.config.js # Frontend build configuration -│ ├── tsconfig.json # TypeScript configuration -│ └── package.json # Frontend dependencies -├── backend/ # Backend code +├── .gitignore +├── frontend/ # Optional frontend code │ ├── src/ -│ │ └── dataset_references/ # Python package source -│ ├── tests/ # Backend tests -│ ├── pyproject.toml # Python package configuration -│ └── requirements.txt # Python dependencies -├── dist/ # Built extension files (generated) -│ ├── manifest.json # Generated extension manifest -│ ├── frontend/ -│ │ └── dist/ # Built frontend assets -│ │ ├── remoteEntry.*.js # Module federation entry -│ │ └── *.js # Additional frontend bundles -│ └── backend/ -│ └── dataset_references/ # Built backend package -│ ├── __init__.py -│ ├── api.py -│ └── entrypoint.py -├── dataset_references-1.0.0.supx # Packaged extension file (generated) -└── README.md # Extension documentation +│ │ └── index.tsx # Frontend entry point +│ ├── package.json +│ ├── webpack.config.js +│ └── tsconfig.json +└── backend/ # Optional backend code + ├── src/ + │ └── {publisher}/ # e.g., my_org/ + │ └── {name}/ # e.g., dashboard_widgets/ + │ └── entrypoint.py + └── pyproject.toml ``` -## 🤝 Contributing - -We welcome contributions! Please see the [Developer Portal](https://superset.apache.org/developer_portal/) for details. - ## 📄 License Licensed under the Apache License, Version 2.0. See [LICENSE](https://github.com/apache/superset/blob/master/LICENSE.txt) for details. ## 🔗 Links -- [Apache Superset](https://superset.apache.org/) -- [Extension Development Guide](https://superset.apache.org/docs/extensions/) -- [API Documentation](https://superset.apache.org/docs/api/) -- [GitHub Repository](https://github.com/apache/superset) - [Community](https://superset.apache.org/community/) - ---- - -**Note**: This package is currently in early development. APIs and commands may change before the 1.0.0 release. Please check the [changelog](CHANGELOG.md) for breaking changes between versions. +- [GitHub Repository](https://github.com/apache/superset) +- [Extensions Documentation](https://superset.apache.org/developer-docs/extensions/overview) diff --git a/superset-extensions-cli/pyproject.toml b/superset-extensions-cli/pyproject.toml index 4487152c171..7a09fbe274e 100644 --- a/superset-extensions-cli/pyproject.toml +++ b/superset-extensions-cli/pyproject.toml @@ -17,7 +17,7 @@ [project] name = "apache-superset-extensions-cli" -version = "0.0.1rc2" +version = "0.1.0rc1" description = "Official command-line interface for building, bundling, and managing Apache Superset extensions" readme = "README.md" authors = [ diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 436da649f45..b0dd8471377 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -51390,7 +51390,7 @@ }, "packages/superset-core": { "name": "@apache-superset/core", - "version": "0.0.1-rc11", + "version": "0.1.0-rc1", "license": "ISC", "devDependencies": { "@babel/cli": "^7.28.6", diff --git a/superset-frontend/packages/superset-core/README.md b/superset-frontend/packages/superset-core/README.md index 948f8ac39d2..d9f835dab98 100644 --- a/superset-frontend/packages/superset-core/README.md +++ b/superset-frontend/packages/superset-core/README.md @@ -30,74 +30,98 @@ The official core package for building Apache Superset extensions and integratio npm install @apache-superset/core ``` -## 🏗️ Architecture +## 🏗️ Package Structure -The package is organized into logical namespaces, each providing specific functionality: +The source is organized into focused namespaces, each in its own directory: -- **`authentication`** - User authentication and authorization APIs -- **`commands`** - Command registration and execution system -- **`contributions`** - UI contribution points and customization APIs -- **`core`** - Fundamental types, utilities, and lifecycle management -- **`environment`** - Environment detection and configuration APIs -- **`extensions`** - Extension management and metadata APIs -- **`sqlLab`** - SQL Lab integration and event handling +``` +src/ +├── authentication/ +├── commands/ +├── common/ +├── components/ +├── contributions/ +├── editors/ +├── extensions/ +├── menus/ +├── sqlLab/ +├── theme/ +├── translation/ +├── utils/ +├── views/ +└── index.ts +``` ## 🚀 Quick Start -### Basic Extension Structure +Frontend contributions are registered as module-level side effects from your extension's entry point. -```typescript -import { - core, - commands, - sqlLab, - authentication, -} from '@apache-superset/core'; +### Views -export function activate(context: core.ExtensionContext) { - // Register a command to save current query - const commandDisposable = commands.registerCommand( - 'my_extension.save_query', - async () => { - const currentTab = sqlLab.getCurrentTab(); - if (currentTab?.editor.content) { - const token = await authentication.getCSRFToken(); - // Use token for secure API calls - console.log('Saving query with CSRF token:', token); - } - }, - ); +Add custom panels or UI components at specific locations in the application: - // Listen for query execution events - const eventDisposable = sqlLab.onDidQueryRun(editor => { - console.log('Query executed:', editor.content.substring(0, 50) + '...'); - }); +```tsx +import { views } from '@apache-superset/core'; +import MyPanel from './MyPanel'; - // Register a simple view - const viewDisposable = core.registerViewProvider( - 'my_extension.panel', - () => ( -
-

My Extension

- -
- ) - ); - - // Cleanup registration - context.subscriptions.push(commandDisposable, eventDisposable, viewDisposable); -} - -export function deactivate() { - // Cleanup handled automatically via disposables -} +views.registerView( + { id: 'my-extension.main', name: 'My Panel Name' }, + 'sqllab.panels', + () => , +); ``` -## 🤝 Contributing +### Commands -We welcome contributions! Please see the [Developer Portal](https://superset.apache.org/developer_portal/) for details. +Define named actions that can be triggered from menus, keyboard shortcuts, or code: + +```typescript +import { commands } from '@apache-superset/core'; + +commands.registerCommand( + { + id: 'my-extension.copy-query', + title: 'Copy Query', + icon: 'CopyOutlined', + description: 'Copy the current query to clipboard', + }, + () => { + /* implementation */ + }, +); +``` + +### Menus + +Attach commands to primary, secondary, or context menus at a given location: + +```typescript +import { menus } from '@apache-superset/core'; + +menus.registerMenuItem( + { view: 'sqllab.editor', command: 'my-extension.copy-query' }, + 'sqllab.editor', + 'primary', +); +``` + +### Editors + +Replace the default text editor for one or more languages: + +```typescript +import { editors } from '@apache-superset/core'; +import MonacoSQLEditor from './MonacoSQLEditor'; + +editors.registerEditor( + { + id: 'my-extension.monaco-sql', + name: 'Monaco SQL Editor', + languages: ['sql'], + }, + MonacoSQLEditor, +); +``` ## 📄 License @@ -105,12 +129,6 @@ Licensed under the Apache License, Version 2.0. See [LICENSE](https://github.com ## 🔗 Links -- [Apache Superset](https://superset.apache.org/) -- [Documentation](https://superset.apache.org/docs/) - [Community](https://superset.apache.org/community/) - [GitHub Repository](https://github.com/apache/superset) -- [Extension Development Guide](https://superset.apache.org/docs/extensions/) - ---- - -**Note**: This package is currently in release candidate status. APIs may change before the 1.0.0 release. Please check the [changelog](CHANGELOG.md) for breaking changes between versions. +- [Extensions Documentation](https://superset.apache.org/developer-docs/extensions/overview) diff --git a/superset-frontend/packages/superset-core/package.json b/superset-frontend/packages/superset-core/package.json index c6cae725228..d95b5bea6ec 100644 --- a/superset-frontend/packages/superset-core/package.json +++ b/superset-frontend/packages/superset-core/package.json @@ -1,6 +1,6 @@ { "name": "@apache-superset/core", - "version": "0.0.1-rc11", + "version": "0.1.0-rc1", "description": "This package contains UI elements, APIs, and utility functions used by Superset.", "sideEffects": false, "main": "lib/index.js", From bc99b710bda44661c6ed549c69dc0aadd9490514 Mon Sep 17 00:00:00 2001 From: Gabriel Torres Ruiz Date: Mon, 9 Mar 2026 16:02:57 -0400 Subject: [PATCH 09/31] fix(dashboard): ensure clear all respects required filter validation (#37681) --- .../src/components/Tabs/Tabs.tsx | 111 +- .../ConfigModal/SharedStyles.tsx | 19 +- .../FilterBar/ActionButtons/index.tsx | 126 +- .../FilterBar/FilterBar.test.tsx | 1259 ++++++++------- .../nativeFilters/FilterBar/index.tsx | 86 +- .../nativeFilters/FilterBar/utils.test.ts | 1364 +++++++++-------- .../nativeFilters/FilterBar/utils.ts | 115 +- .../FiltersConfigForm/FiltersConfigForm.tsx | 5 + 8 files changed, 1771 insertions(+), 1314 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.tsx b/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.tsx index 9a192168fae..bc12380b84b 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.tsx @@ -17,81 +17,88 @@ * under the License. */ import type { FC } from 'react'; -import { css, styled, useTheme } from '@apache-superset/core/theme'; - -// eslint-disable-next-line no-restricted-imports +import { css, styled } from '@apache-superset/core/theme'; import { Tabs as AntdTabs, TabsProps as AntdTabsProps } from 'antd'; import { Icons } from '@superset-ui/core/components/Icons'; import type { SerializedStyles } from '@emotion/react'; export interface TabsProps extends AntdTabsProps { allowOverflow?: boolean; + contentHeight?: string | number; fullHeight?: boolean; contentStyle?: SerializedStyles; + contentPadding?: SerializedStyles; } const StyledTabs = ({ animated = false, allowOverflow = true, + contentHeight = '100%', fullHeight = false, tabBarStyle, contentStyle, + contentPadding, ...props -}: TabsProps) => { - const theme = useTheme(); - const defaultTabBarStyle = { paddingLeft: theme.sizeUnit * 4 }; - const mergedStyle = { ...defaultTabBarStyle, ...tabBarStyle }; +}: TabsProps) => ( + css` + overflow: ${allowOverflow ? 'visible' : 'hidden'}; + ${fullHeight && 'height: 100%;'} - return ( - css` - overflow: ${allowOverflow ? 'visible' : 'hidden'}; + .ant-tabs-content-holder { + overflow: ${allowOverflow ? 'visible' : 'auto'}; ${fullHeight && 'height: 100%;'} + ${contentHeight && + `height: ${typeof contentHeight === 'number' ? `${contentHeight}px` : contentHeight};`} + ${contentPadding} + } + .ant-tabs-content { + ${fullHeight && 'height: 100%;'} + } + .ant-tabs-tabpane { + ${fullHeight && 'height: 100%;'} + ${contentStyle} + } + .ant-tabs-nav { + margin: 0; + } + .ant-tabs-nav-wrap { + ${!(tabBarStyle && 'paddingLeft' in tabBarStyle) + ? `padding: 0 ${theme.sizeUnit * 4}px;` + : ''} + } + .ant-tabs-tab { + flex: 1 1 auto; - .ant-tabs-content-holder { - overflow: ${allowOverflow ? 'visible' : 'auto'}; - ${fullHeight && 'height: 100%;'} - } - .ant-tabs-content { - ${fullHeight && 'height: 100%;'} - } - .ant-tabs-tabpane { - ${fullHeight && 'height: 100%;'} - ${contentStyle} - } - .ant-tabs-tab { - flex: 1 1 auto; - - .short-link-trigger.btn { - padding: 0 ${theme.sizeUnit}px; - & > .fa.fa-link { - top: 0; - } + .short-link-trigger.btn { + padding: 0 ${theme.sizeUnit}px; + & > .fa.fa-link { + top: 0; } } - .ant-tabs-tab-btn { - display: flex; - flex: 1 1 auto; - align-items: center; - justify-content: center; - font-size: ${theme.fontSizeSM}px; - text-align: center; - user-select: none; - .required { - margin-left: ${theme.sizeUnit / 2}px; - color: ${theme.colorError}; - } - &:focus-visible { - box-shadow: none; - } + } + .ant-tabs-tab-btn { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + font-size: ${theme.fontSizeSM}px; + text-align: center; + user-select: none; + .required { + margin-left: ${theme.sizeUnit / 2}px; + color: ${theme.colorError}; } - `} - /> - ); -}; + &:focus-visible { + box-shadow: none; + } + } + `} + /> +); const StyledTabPane = styled(AntdTabs.TabPane)``; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ConfigModal/SharedStyles.tsx b/superset-frontend/src/dashboard/components/nativeFilters/ConfigModal/SharedStyles.tsx index 8677cfa5bc8..b51ac560722 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/ConfigModal/SharedStyles.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/ConfigModal/SharedStyles.tsx @@ -39,8 +39,23 @@ export const BaseModalWrapper = styled(StyledModal)` min-width: auto; } + .ant-modal-header { + margin-bottom: 0; + } + .ant-modal-body { - padding: 0px; + overflow: hidden; + padding: 0; + flex: 1 1 auto; + min-height: 0; + } + + .ant-collapse { + border-bottom: 0; + + .ant-collapse-item:last-child > .ant-collapse-content { + border-radius: 0; + } } ${({ expanded }) => @@ -59,7 +74,7 @@ export const BaseModalWrapper = styled(StyledModal)` export const BaseModalBody = styled.div` display: flex; - height: ${({ expanded }) => (expanded ? '100%' : '700px')}; + height: 100%; flex-direction: row; flex: 1; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx index d2cc5b8187c..7c685842946 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx @@ -26,7 +26,7 @@ import { ChartCustomizationDivider, } from '@superset-ui/core'; import { css, SupersetTheme, styled } from '@apache-superset/core/theme'; -import { Button } from '@superset-ui/core/components'; +import { Button, Tooltip, Icons, Flex } from '@superset-ui/core/components'; import { OPEN_FILTER_BAR_WIDTH } from 'src/dashboard/constants'; import tinycolor from 'tinycolor2'; import { FilterBarOrientation } from 'src/dashboard/types'; @@ -41,14 +41,47 @@ interface ActionButtonsProps { chartCustomizationItems?: (ChartCustomization | ChartCustomizationDivider)[]; isApplyDisabled: boolean; filterBarOrientation?: FilterBarOrientation; + hasOutOfScopeRequiredFilters?: boolean; } -const containerStyle = (theme: SupersetTheme) => css` - display: flex; +const ButtonsContainer = styled.div<{ isVertical: boolean; width: number }>` + ${({ theme, isVertical, width }) => css` + display: flex; - && > .filter-clear-all-button { + ${isVertical + ? css` + flex-direction: column; + align-items: center; + position: fixed; + z-index: 100; + width: ${width - 1}px; + bottom: 0; + padding: ${theme.sizeUnit * 4}px; + padding-top: ${theme.sizeUnit * 6}px; + background: linear-gradient( + ${tinycolor(theme.colorBgLayout).setAlpha(0).toRgbString()}, + ${theme.colorBgContainer} 20% + ); + ` + : css` + align-items: center; + margin-left: auto; + `} + `} +`; + +const applyButtonStyle = (theme: SupersetTheme, isVertical: boolean) => css` + ${isVertical && + css` + margin-bottom: ${theme.sizeUnit * 3}px; + `} +`; + +const clearAllButtonStyle = (theme: SupersetTheme, isVertical: boolean) => css` + && { color: ${theme.colorTextSecondary}; margin-left: 0; + &:hover { color: ${theme.colorPrimaryText}; } @@ -57,48 +90,15 @@ const containerStyle = (theme: SupersetTheme) => css` &[disabled]:hover { color: ${theme.colorTextDisabled}; } + + ${!isVertical && + css` + text-transform: capitalize; + font-weight: ${theme.fontWeightNormal}; + `} } `; -const verticalStyle = (theme: SupersetTheme, width: number) => css` - flex-direction: column; - align-items: center; - position: fixed; - z-index: 100; - - // filter bar width minus 1px for border - width: ${width - 1}px; - bottom: 0; - - padding: ${theme.sizeUnit * 4}px; - padding-top: ${theme.sizeUnit * 6}px; - - background: linear-gradient( - ${tinycolor(theme.colorBgLayout).setAlpha(0).toRgbString()}, - ${theme.colorBgContainer} 20% - ); - - & > .filter-apply-button { - margin-bottom: ${theme.sizeUnit * 3}px; - } -`; - -const horizontalStyle = (theme: SupersetTheme) => css` - align-items: center; - margin-left: auto; - && > .filter-clear-all-button { - text-transform: capitalize; - font-weight: ${theme.fontWeightNormal}; - } -`; - -const ButtonsContainer = styled.div<{ isVertical: boolean; width: number }>` - ${({ theme, isVertical, width }) => css` - ${containerStyle(theme)}; - ${isVertical ? verticalStyle(theme, width) : horizontalStyle(theme)}; - `} -`; - const ActionButtons = ({ width = OPEN_FILTER_BAR_WIDTH, onApply, @@ -108,7 +108,10 @@ const ActionButtons = ({ isApplyDisabled, filterBarOrientation = FilterBarOrientation.Vertical, chartCustomizationItems, + hasOutOfScopeRequiredFilters = false, }: ActionButtonsProps) => { + const isVertical = filterBarOrientation === FilterBarOrientation.Vertical; + const isClearAllEnabled = useMemo(() => { const hasSelectedChanges = Object.entries(dataMaskSelected).some( ([, mask]) => { @@ -136,7 +139,6 @@ const ActionButtons = ({ return hasSelectedChanges || hasAppliedChanges || hasChartCustomizations; }, [dataMaskSelected, dataMaskApplied, chartCustomizationItems]); - const isVertical = filterBarOrientation === FilterBarOrientation.Vertical; return ( applyButtonStyle(theme, isVertical)} onClick={onApply} {...getFilterBarTestId('apply-button')} > {isVertical ? t('Apply filters') : t('Apply')} - + + + {hasOutOfScopeRequiredFilters && ( + + css` + margin-left: ${theme.sizeUnit}px; + `} + /> + + )} + ); }; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx index 43ffe05cdb4..2ece961fd33 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx @@ -42,6 +42,7 @@ jest.mock('@superset-ui/core', () => ({ const mockedMakeApi = makeApi as jest.Mock; +// Register preset once for all tests class MainPreset extends Preset { constructor() { super({ @@ -55,65 +56,41 @@ class MainPreset extends Preset { } } +new MainPreset().register(); + fetchMock.get('glob:*/api/v1/dataset/7', { description_columns: {}, id: 1, - label_columns: { - columns: 'Columns', - table_name: 'Table Name', - }, + label_columns: { columns: 'Columns', table_name: 'Table Name' }, result: { metrics: [], - columns: [ - { - column_name: 'Column A', - id: 1, - }, - ], + columns: [{ column_name: 'Column A', id: 1 }], table_name: 'birth_names', id: 1, }, show_columns: ['id', 'table_name'], }); +// Cleanup between tests +beforeEach(() => { + jest.clearAllMocks(); +}); + const getTestId = testWithId(FILTER_BAR_TEST_ID, true); const getModalTestId = testWithId(FILTERS_CONFIG_MODAL_TEST_ID, true); -const FILTER_NAME = 'Time filter 1'; +function createClosedBarProps(toggleFiltersBar = jest.fn()) { + return { filtersOpen: false, toggleFiltersBar }; +} -const addFilterFlow = async () => { - // open filter config modals - userEvent.click(screen.getByTestId(getTestId('collapsable'))); - userEvent.click(screen.getByLabelText('setting')); - userEvent.click(screen.getByText('Add or edit filters and controls')); - // select filter - userEvent.click(screen.getByText('Value')); - userEvent.click(screen.getByText('Time range')); - userEvent.type(screen.getByTestId(getModalTestId('name-input')), FILTER_NAME); - userEvent.click(screen.getByText('Save')); - // TODO: fix this flaky test - // await screen.findByText('All filters (1)'); -}; +function createOpenedBarProps(toggleFiltersBar = jest.fn()) { + return { filtersOpen: true, toggleFiltersBar }; +} -// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks -describe('FilterBar', () => { - new MainPreset().register(); - const toggleFiltersBar = jest.fn(); - const closedBarProps = { - filtersOpen: false, - toggleFiltersBar, - }; - const openedBarProps = { - filtersOpen: true, - toggleFiltersBar, - }; - - const mockApi = jest.fn(async data => { +function createMockApi(filterName = 'Time filter 1') { + return jest.fn(async data => { if (!data?.modified?.length) { - return { - id: 1234, - result: [], - }; + return { id: 1234, result: [] }; } const filterId = data.modified[0].id; return { @@ -121,7 +98,7 @@ describe('FilterBar', () => { result: [ { id: filterId, - name: FILTER_NAME, + name: filterName, filterType: 'filter_time', targets: [{ datasetId: 11, column: { name: 'color' } }], defaultDataMask: { filterState: { value: null } }, @@ -132,540 +109,684 @@ describe('FilterBar', () => { ], }; }); +} - const getTimeRangeNoFilterMockUrl = - 'glob:*/api/v1/time_range/?q=%27No%20filter%27'; - const getTimeRangeLastDayMockUrl = - 'glob:*/api/v1/time_range/?q=%27Last%20day%27'; - const getTimeRangeLastWeekMockUrl = - 'glob:*/api/v1/time_range/?q=%27Last%20week%27'; +function createFilter(overrides: Record = {}) { + const id = (overrides.id as string) || 'test-filter'; + return { + id, + name: 'Test Filter', + filterType: 'filter_select', + targets: [{ datasetId: 1, column: { name: 'test_column' } }], + defaultDataMask: { filterState: { value: null }, extraFormData: {} }, + controlValues: {}, + cascadeParentIds: [], + scope: { rootPath: ['ROOT_ID'], excluded: [] }, + type: 'NATIVE_FILTER', + description: '', + chartsInScope: [], + tabsInScope: [], + ...overrides, + }; +} - beforeEach(() => { - jest.clearAllMocks(); +function createDataMask( + filterId: string, + value: unknown = undefined, + extraFormData: Record = {}, +) { + return { + id: filterId, + filterState: { value }, + extraFormData, + }; +} - fetchMock.removeRoute(getTimeRangeNoFilterMockUrl); - fetchMock.get( - getTimeRangeNoFilterMockUrl, - { - result: { since: '', until: '', timeRange: 'No filter' }, +function createDivider(overrides: Record = {}) { + return { + id: 'NATIVE_FILTER_DIVIDER-1', + type: 'DIVIDER', + scope: { rootPath: ['ROOT_ID'], excluded: [] }, + title: 'Select time range', + description: 'Select year/month etc..', + chartsInScope: [], + tabsInScope: [], + ...overrides, + }; +} + +function createStateWithFilter( + filter: ReturnType, + dataMask: ReturnType, + dashboardInfoOverrides: Record = {}, +) { + return { + ...stateWithoutNativeFilters, + dashboardInfo: { + id: 1, + dash_edit_perm: true, + metadata: { + native_filter_configuration: [filter], }, - { name: getTimeRangeNoFilterMockUrl }, - ); + ...dashboardInfoOverrides, + }, + dashboardState: { + ...stateWithoutNativeFilters.dashboardState, + activeTabs: ['ROOT_ID'], + }, + dataMask: { [filter.id]: dataMask }, + nativeFilters: { + filters: { [filter.id]: filter }, + filtersState: {}, + }, + }; +} - fetchMock.removeRoute(getTimeRangeLastDayMockUrl); - fetchMock.get( - getTimeRangeLastDayMockUrl, - { - result: { - since: '2021-04-13T00:00:00', - until: '2021-04-14T00:00:00', - timeRange: 'Last day', - }, +function setupTimeRangeMocks() { + const urls = { + noFilter: 'glob:*/api/v1/time_range/?q=%27No%20filter%27', + lastDay: 'glob:*/api/v1/time_range/?q=%27Last%20day%27', + lastWeek: 'glob:*/api/v1/time_range/?q=%27Last%20week%27', + }; + + fetchMock.removeRoute(urls.noFilter); + fetchMock.get( + urls.noFilter, + { result: { since: '', until: '', timeRange: 'No filter' } }, + { name: urls.noFilter }, + ); + + fetchMock.removeRoute(urls.lastDay); + fetchMock.get( + urls.lastDay, + { + result: { + since: '2021-04-13T00:00:00', + until: '2021-04-14T00:00:00', + timeRange: 'Last day', }, - { name: getTimeRangeLastDayMockUrl }, - ); + }, + { name: urls.lastDay }, + ); - fetchMock.removeRoute(getTimeRangeLastWeekMockUrl); - fetchMock.get( - getTimeRangeLastWeekMockUrl, - { - result: { - since: '2021-04-07T00:00:00', - until: '2021-04-14T00:00:00', - timeRange: 'Last week', - }, + fetchMock.removeRoute(urls.lastWeek); + fetchMock.get( + urls.lastWeek, + { + result: { + since: '2021-04-07T00:00:00', + until: '2021-04-14T00:00:00', + timeRange: 'Last week', }, - { name: getTimeRangeLastWeekMockUrl }, - ); + }, + { name: urls.lastWeek }, + ); +} - mockedMakeApi.mockReturnValue(mockApi); - }); +function renderFilterBar( + props: { filtersOpen: boolean; toggleFiltersBar: jest.Mock }, + state?: object, +) { + return render( + , + { + initialState: state, + useDnd: true, + useRedux: true, + useRouter: true, + }, + ); +} - const renderWrapper = (props = closedBarProps, state?: object) => - render( - , - { - initialState: state, - useDnd: true, - useRedux: true, - useRouter: true, - }, - ); - - test('should render', () => { - const { container } = renderWrapper(); - expect(container).toBeInTheDocument(); - }); - - test('should render the "Filters and controls" heading', () => { - renderWrapper(); - expect(screen.getByText('Filters and controls')).toBeInTheDocument(); - }); - - test('should render the "Clear all" option', () => { - renderWrapper(); - expect(screen.getByText('Clear all')).toBeInTheDocument(); - }); - - test('should render the "Apply filters" option', () => { - renderWrapper(); - expect(screen.getByText('Apply filters')).toBeInTheDocument(); - }); - - test('should render the collapse icon', () => { - renderWrapper(); - expect( - screen.getByRole('img', { name: 'vertical-align' }), - ).toBeInTheDocument(); - }); - - test('should render the filter icon', () => { - renderWrapper(); - expect(screen.getByRole('img', { name: 'filter' })).toBeInTheDocument(); - }); - - test('should toggle', () => { - renderWrapper(); - const collapse = screen.getByRole('img', { - name: 'vertical-align', - }); - expect(toggleFiltersBar).not.toHaveBeenCalled(); - userEvent.click(collapse); - expect(toggleFiltersBar).toHaveBeenCalled(); - }); - - test('open filter bar', () => { - renderWrapper(); - expect(screen.getByTestId(getTestId('filter-icon'))).toBeInTheDocument(); - expect(screen.getByTestId(getTestId('expand-button'))).toBeInTheDocument(); - - userEvent.click(screen.getByTestId(getTestId('collapsable'))); - expect(toggleFiltersBar).toHaveBeenCalledWith(true); - }); - - test('no edit filter button by disabled permissions', () => { - renderWrapper(openedBarProps, { - ...stateWithoutNativeFilters, - dashboardInfo: { metadata: {} }, - }); - - expect( - screen.queryByTestId(getTestId('create-filter')), - ).not.toBeInTheDocument(); - }); - - test('close filter bar', () => { - renderWrapper(openedBarProps); - const collapseButton = screen.getByTestId(getTestId('collapse-button')); - - expect(collapseButton).toBeInTheDocument(); - userEvent.click(collapseButton); - - expect(toggleFiltersBar).toHaveBeenCalledWith(false); - }); - - test('no filters', () => { - renderWrapper(openedBarProps, stateWithoutNativeFilters); - - expect(screen.getByTestId(getTestId('clear-button'))).toBeDisabled(); - expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled(); - }); - - test('renders dividers', async () => { - const divider = { - id: 'NATIVE_FILTER_DIVIDER-1', - type: 'DIVIDER', - scope: { - rootPath: ['ROOT_ID'], - excluded: [], - }, - title: 'Select time range', - description: 'Select year/month etc..', - chartsInScope: [], - tabsInScope: [], - }; - const stateWithDivider = { - ...stateWithoutNativeFilters, - dashboardInfo: { - ...stateWithoutNativeFilters.dashboardInfo, - metadata: { - ...stateWithoutNativeFilters.dashboardInfo.metadata, - native_filter_configuration: [divider], - }, - }, - nativeFilters: { - filters: { - 'NATIVE_FILTER_DIVIDER-1': divider, - }, - }, - }; - - renderWrapper(openedBarProps, stateWithDivider); - - await act(async () => { - jest.advanceTimersByTime(1000); // 1s - }); - - const title = await screen.findByText('Select time range'); - const description = await screen.findByText('Select year/month etc..'); - - expect(title.tagName).toBe('H3'); - expect(description.tagName).toBe('P'); - // Do not enable buttons if there are not filters - expect(screen.getByTestId(getTestId('clear-button'))).toBeDisabled(); - expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled(); - }); - - test('create filter and apply it flow', async () => { - renderWrapper(openedBarProps, stateWithoutNativeFilters); - expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled(); - - await addFilterFlow(); - - expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled(); - }); - - test('should render without errors with proper state setup', () => { - const stateWithFilter = { - ...stateWithoutNativeFilters, - dashboardInfo: { - id: 1, - }, - dataMask: { - 'test-filter': { - id: 'test-filter', - filterState: { value: undefined }, - extraFormData: {}, - }, - }, - nativeFilters: { - filters: { - 'test-filter': { - id: 'test-filter', - name: 'Test Filter', - filterType: 'filter_select', - targets: [{ datasetId: 1, column: { name: 'test_column' } }], - defaultDataMask: { - filterState: { value: undefined }, - extraFormData: {}, - }, - controlValues: { - enableEmptyFilter: true, - }, - cascadeParentIds: [], - scope: { - rootPath: ['ROOT_ID'], - excluded: [], - }, - type: 'NATIVE_FILTER', - description: '', - chartsInScope: [], - tabsInScope: [], - }, - }, - filtersState: {}, - }, - }; - - const { container } = renderWrapper(openedBarProps, stateWithFilter); - expect(container).toBeInTheDocument(); - }); - - test('auto-applies filter when extraFormData is empty in applied state', async () => { - const filterId = 'test-filter-auto-apply'; - const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask'); - - const stateWithIncompleteFilter = { - ...stateWithoutNativeFilters, - dashboardInfo: { - id: 1, - dash_edit_perm: true, - }, - dataMask: { - [filterId]: { - id: filterId, - filterState: { value: ['value1', 'value2'] }, - extraFormData: {}, - }, - }, - nativeFilters: { - filters: { - [filterId]: { - id: filterId, - name: 'Test Filter', - filterType: 'filter_select', - targets: [{ datasetId: 1, column: { name: 'test_column' } }], - defaultDataMask: { - filterState: { value: ['value1', 'value2'] }, - extraFormData: {}, - }, - controlValues: { - enableEmptyFilter: true, - }, - cascadeParentIds: [], - scope: { - rootPath: ['ROOT_ID'], - excluded: [], - }, - type: 'NATIVE_FILTER', - description: '', - chartsInScope: [], - tabsInScope: [], - }, - }, - filtersState: {}, - }, - }; - - renderWrapper(openedBarProps, stateWithIncompleteFilter); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - expect(screen.getByTestId(getTestId('filter-icon'))).toBeInTheDocument(); - - updateDataMaskSpy.mockRestore(); - }); - - test('renders correctly when filter has complete extraFormData', async () => { - const filterId = 'test-filter-complete'; - const stateWithCompleteFilter = { - ...stateWithoutNativeFilters, - dashboardInfo: { - id: 1, - dash_edit_perm: true, - }, - dataMask: { - [filterId]: { - id: filterId, - filterState: { value: ['value1'] }, - extraFormData: { - filters: [{ col: 'test_column', op: 'IN', val: ['value1'] }], - }, - }, - }, - nativeFilters: { - filters: { - [filterId]: { - id: filterId, - name: 'Test Filter', - filterType: 'filter_select', - targets: [{ datasetId: 1, column: { name: 'test_column' } }], - defaultDataMask: { - filterState: { value: ['value1'] }, - extraFormData: { - filters: [{ col: 'test_column', op: 'IN', val: ['value1'] }], - }, - }, - controlValues: { - enableEmptyFilter: true, - }, - cascadeParentIds: [], - scope: { - rootPath: ['ROOT_ID'], - excluded: [], - }, - type: 'NATIVE_FILTER', - description: '', - chartsInScope: [], - tabsInScope: [], - }, - }, - filtersState: {}, - }, - }; - - renderWrapper(openedBarProps, stateWithCompleteFilter); - - await act(async () => { - jest.advanceTimersByTime(100); - }); - - expect(screen.getByTestId(getTestId('filter-icon'))).toBeInTheDocument(); - }); - - test('handleClearAll dispatches updateDataMask with value null for filter_select', async () => { - const filterId = 'NATIVE_FILTER-clear-select'; - const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask'); - const selectFilterConfig = { - id: filterId, - name: 'Region', - filterType: 'filter_select', - targets: [{ datasetId: 7, column: { name: 'region' } }], - defaultDataMask: { filterState: { value: null }, extraFormData: {} }, - cascadeParentIds: [], - scope: { rootPath: ['ROOT_ID'], excluded: [] }, - type: 'NATIVE_FILTER', - description: '', - chartsInScope: [18], - tabsInScope: [], - }; - const stateWithSelect = { - ...stateWithoutNativeFilters, - dashboardInfo: { - id: 1, - dash_edit_perm: true, - filterBarOrientation: FilterBarOrientation.Vertical, - metadata: { - native_filter_configuration: [selectFilterConfig], - chart_configuration: {}, - }, - }, - dataMask: { - [filterId]: { - id: filterId, - filterState: { value: ['East'] }, - extraFormData: {}, - }, - }, - }; - - renderWrapper(openedBarProps, stateWithSelect); - await act(async () => { - jest.advanceTimersByTime(300); - }); - - const clearBtn = screen.getByTestId(getTestId('clear-button')); - expect(clearBtn).not.toBeDisabled(); - await act(async () => { - userEvent.click(clearBtn); - }); - - expect(updateDataMaskSpy).toHaveBeenCalledWith(filterId, { - filterState: { value: undefined }, - extraFormData: {}, - }); - updateDataMaskSpy.mockRestore(); - }); - - test('handleClearAll dispatches updateDataMask with value [null, null] for filter_range', async () => { - fetchMock.post('glob:*/api/v1/chart/data', { - result: [{ data: [{ min: 0, max: 100 }] }], - }); - const filterId = 'NATIVE_FILTER-clear-range'; - const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask'); - const rangeFilterConfig = { - id: filterId, - name: 'Age', - filterType: 'filter_range', - targets: [{ datasetId: 7, column: { name: 'age' } }], - defaultDataMask: { filterState: { value: null }, extraFormData: {} }, - cascadeParentIds: [], - scope: { rootPath: ['ROOT_ID'], excluded: [] }, - type: 'NATIVE_FILTER', - description: '', - chartsInScope: [18], - tabsInScope: [], - }; - const stateWithRange = { - ...stateWithoutNativeFilters, - dashboardInfo: { - id: 1, - dash_edit_perm: true, - filterBarOrientation: FilterBarOrientation.Vertical, - metadata: { - native_filter_configuration: [rangeFilterConfig], - chart_configuration: {}, - }, - }, - dataMask: { - [filterId]: { - id: filterId, - filterState: { value: [10, 50] }, - extraFormData: {}, - }, - }, - }; - - renderWrapper(openedBarProps, stateWithRange); - await act(async () => { - jest.advanceTimersByTime(300); - }); - - const clearBtn = screen.getByTestId(getTestId('clear-button')); - expect(clearBtn).not.toBeDisabled(); - await act(async () => { - userEvent.click(clearBtn); - }); - - expect(updateDataMaskSpy).toHaveBeenCalledWith(filterId, { - filterState: { value: [null, null] }, - extraFormData: {}, - }); - updateDataMaskSpy.mockRestore(); - }); - - test('handleClearAll only dispatches for filters present in dataMask', async () => { - const idInMask = 'NATIVE_FILTER-has-value'; - const idNotInMask = 'NATIVE_FILTER-no-value'; - const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask'); - const baseFilter = { - targets: [{ datasetId: 7, column: { name: 'x' } }], - defaultDataMask: { filterState: { value: null }, extraFormData: {} }, - cascadeParentIds: [], - scope: { rootPath: ['ROOT_ID'], excluded: [] }, - type: 'NATIVE_FILTER', - description: '', - chartsInScope: [18], - tabsInScope: [], - }; - const stateWithTwoFiltersOneInMask = { - ...stateWithoutNativeFilters, - dashboardInfo: { - id: 1, - dash_edit_perm: true, - filterBarOrientation: FilterBarOrientation.Vertical, - metadata: { - native_filter_configuration: [ - { - ...baseFilter, - id: idInMask, - name: 'A', - filterType: 'filter_select', - }, - { - ...baseFilter, - id: idNotInMask, - name: 'B', - filterType: 'filter_select', - }, - ], - chart_configuration: {}, - }, - }, - dataMask: { - [idInMask]: { - id: idInMask, - filterState: { value: ['v'] }, - extraFormData: {}, - }, - }, - }; - - renderWrapper(openedBarProps, stateWithTwoFiltersOneInMask); - await act(async () => { - jest.advanceTimersByTime(300); - }); - - const clearBtn = screen.getByTestId(getTestId('clear-button')); - await act(async () => { - userEvent.click(clearBtn); - }); - - expect(updateDataMaskSpy).toHaveBeenCalledTimes(1); - expect(updateDataMaskSpy).toHaveBeenCalledWith(idInMask, { - filterState: { value: undefined }, - extraFormData: {}, - }); - updateDataMaskSpy.mockRestore(); - }); +test('FilterBar renders without crashing', () => { + const props = createClosedBarProps(); + const { container } = renderFilterBar(props); + expect(container).toBeInTheDocument(); +}); + +test('FilterBar renders "Filters and controls" heading', () => { + const props = createClosedBarProps(); + renderFilterBar(props); + expect(screen.getByText('Filters and controls')).toBeInTheDocument(); +}); + +test('FilterBar renders "Clear all" button', () => { + const props = createClosedBarProps(); + renderFilterBar(props); + expect(screen.getByText('Clear all')).toBeInTheDocument(); +}); + +test('FilterBar renders "Apply filters" button', () => { + const props = createClosedBarProps(); + renderFilterBar(props); + expect(screen.getByText('Apply filters')).toBeInTheDocument(); +}); + +test('FilterBar renders collapse icon', () => { + const props = createClosedBarProps(); + renderFilterBar(props); + expect( + screen.getByRole('img', { name: 'vertical-align' }), + ).toBeInTheDocument(); +}); + +test('FilterBar renders filter icon', () => { + const props = createClosedBarProps(); + renderFilterBar(props); + expect(screen.getByRole('img', { name: 'filter' })).toBeInTheDocument(); +}); + +test('FilterBar calls toggleFiltersBar when collapse icon is clicked', () => { + const toggleFiltersBar = jest.fn(); + const props = createClosedBarProps(toggleFiltersBar); + renderFilterBar(props); + + const collapse = screen.getByRole('img', { name: 'vertical-align' }); + expect(toggleFiltersBar).not.toHaveBeenCalled(); + + userEvent.click(collapse); + expect(toggleFiltersBar).toHaveBeenCalled(); +}); + +test('FilterBar opens when expand button is clicked', () => { + const toggleFiltersBar = jest.fn(); + const props = createClosedBarProps(toggleFiltersBar); + renderFilterBar(props); + + expect(screen.getByTestId(getTestId('filter-icon'))).toBeInTheDocument(); + expect(screen.getByTestId(getTestId('expand-button'))).toBeInTheDocument(); + + userEvent.click(screen.getByTestId(getTestId('collapsable'))); + expect(toggleFiltersBar).toHaveBeenCalledWith(true); +}); + +test('FilterBar hides edit filter button when user lacks permissions', () => { + const props = createOpenedBarProps(); + const stateWithoutPermissions = { + ...stateWithoutNativeFilters, + dashboardInfo: { metadata: {} }, + }; + + renderFilterBar(props, stateWithoutPermissions); + + expect( + screen.queryByTestId(getTestId('create-filter')), + ).not.toBeInTheDocument(); +}); + +test('FilterBar closes when collapse button is clicked', () => { + const toggleFiltersBar = jest.fn(); + const props = createOpenedBarProps(toggleFiltersBar); + renderFilterBar(props); + + const collapseButton = screen.getByTestId(getTestId('collapse-button')); + expect(collapseButton).toBeInTheDocument(); + + userEvent.click(collapseButton); + expect(toggleFiltersBar).toHaveBeenCalledWith(false); +}); + +test('FilterBar disables buttons when there are no filters', () => { + const props = createOpenedBarProps(); + renderFilterBar(props, stateWithoutNativeFilters); + + expect(screen.getByTestId(getTestId('clear-button'))).toBeDisabled(); + expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled(); +}); + +test('FilterBar renders dividers with title and description', async () => { + const props = createOpenedBarProps(); + const divider = createDivider(); + const stateWithDivider = { + ...stateWithoutNativeFilters, + dashboardInfo: { + ...stateWithoutNativeFilters.dashboardInfo, + metadata: { + ...stateWithoutNativeFilters.dashboardInfo.metadata, + native_filter_configuration: [divider], + }, + }, + nativeFilters: { + filters: { [divider.id]: divider }, + }, + }; + + renderFilterBar(props, stateWithDivider); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + const title = await screen.findByText('Select time range'); + const description = await screen.findByText('Select year/month etc..'); + + expect(title.tagName).toBe('H3'); + expect(description.tagName).toBe('P'); + expect(screen.getByTestId(getTestId('clear-button'))).toBeDisabled(); + expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled(); +}); + +test('FilterBar apply button is disabled after creating a filter', async () => { + setupTimeRangeMocks(); + mockedMakeApi.mockReturnValue(createMockApi()); + + const props = createOpenedBarProps(); + renderFilterBar(props, stateWithoutNativeFilters); + + expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled(); + + // Simulate add filter flow + userEvent.click(screen.getByTestId(getTestId('collapsable'))); + userEvent.click(screen.getByLabelText('setting')); + userEvent.click(screen.getByText('Add or edit filters and controls')); + userEvent.click(screen.getByText('Value')); + userEvent.click(screen.getByText('Time range')); + userEvent.type( + screen.getByTestId(getModalTestId('name-input')), + 'Time filter 1', + ); + userEvent.click(screen.getByText('Save')); + + expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled(); +}); + +test('FilterBar renders without errors when filter has required controlValues', () => { + const props = createOpenedBarProps(); + const filter = createFilter({ + id: 'test-filter', + controlValues: { enableEmptyFilter: true }, + }); + const dataMask = createDataMask('test-filter', undefined, {}); + const state = createStateWithFilter(filter, dataMask); + + const { container } = renderFilterBar(props, state); + expect(container).toBeInTheDocument(); +}); + +test('FilterBar does not crash when filter has value but empty extraFormData', async () => { + const filterId = 'test-filter-auto-apply'; + const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask'); + const props = createOpenedBarProps(); + + const filter = createFilter({ + id: filterId, + requiredFirst: true, + controlValues: { enableEmptyFilter: true }, + defaultDataMask: { + filterState: { value: ['value1'] }, + extraFormData: {}, + }, + }); + + const dataMask = createDataMask(filterId, ['value1'], {}); + const state = createStateWithFilter(filter, dataMask); + + renderFilterBar(props, state); + + await act(async () => { + jest.advanceTimersByTime(300); + }); + + expect(screen.getByTestId(getTestId('filter-icon'))).toBeInTheDocument(); + expect(screen.getByText('Filters and controls')).toBeInTheDocument(); + + // The filter value should not be cleared during initialization + expect(updateDataMaskSpy).not.toHaveBeenCalled(); + + updateDataMaskSpy.mockRestore(); +}); + +test('FilterBar renders correctly when filter has complete extraFormData', async () => { + const filterId = 'test-filter-complete'; + const props = createOpenedBarProps(); + const filter = createFilter({ + id: filterId, + controlValues: { enableEmptyFilter: true }, + defaultDataMask: { + filterState: { value: ['value1'] }, + extraFormData: { + filters: [{ col: 'test_column', op: 'IN', val: ['value1'] }], + }, + }, + }); + const dataMask = createDataMask(filterId, ['value1'], { + filters: [{ col: 'test_column', op: 'IN', val: ['value1'] }], + }); + const state = createStateWithFilter(filter, dataMask); + + renderFilterBar(props, state); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + expect(screen.getByTestId(getTestId('filter-icon'))).toBeInTheDocument(); +}); + +test('handleClearAll dispatches updateDataMask with value undefined for filter_select', async () => { + const filterId = 'NATIVE_FILTER-clear-select'; + const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask'); + const selectFilter = createFilter({ + id: filterId, + name: 'Region', + filterType: 'filter_select', + targets: [{ datasetId: 7, column: { name: 'region' } }], + defaultDataMask: { filterState: { value: null }, extraFormData: {} }, + chartsInScope: [18], + }); + const stateWithSelect = { + ...stateWithoutNativeFilters, + dashboardInfo: { + id: 1, + dash_edit_perm: true, + filterBarOrientation: FilterBarOrientation.Vertical, + metadata: { + native_filter_configuration: [selectFilter], + chart_configuration: {}, + }, + }, + dashboardState: { + ...stateWithoutNativeFilters.dashboardState, + activeTabs: ['ROOT_ID'], + }, + dataMask: { + [filterId]: createDataMask(filterId, ['East']), + }, + nativeFilters: { + filters: { [filterId]: selectFilter }, + filtersState: {}, + }, + }; + + const props = createOpenedBarProps(); + renderFilterBar(props, stateWithSelect); + await act(async () => { + jest.advanceTimersByTime(300); + }); + + const clearBtn = screen.getByTestId(getTestId('clear-button')); + expect(clearBtn).not.toBeDisabled(); + await act(async () => { + userEvent.click(clearBtn); + }); + + expect(updateDataMaskSpy).toHaveBeenCalledWith(filterId, { + filterState: { value: undefined }, + extraFormData: {}, + }); + updateDataMaskSpy.mockRestore(); +}); + +test('handleClearAll dispatches updateDataMask with [null, null] for filter_range', async () => { + fetchMock.post('glob:*/api/v1/chart/data', { + result: [{ data: [{ min: 0, max: 100 }] }], + }); + const filterId = 'NATIVE_FILTER-clear-range'; + const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask'); + const rangeFilter = createFilter({ + id: filterId, + name: 'Age', + filterType: 'filter_range', + targets: [{ datasetId: 7, column: { name: 'age' } }], + defaultDataMask: { filterState: { value: null }, extraFormData: {} }, + chartsInScope: [18], + }); + const stateWithRange = { + ...stateWithoutNativeFilters, + dashboardInfo: { + id: 1, + dash_edit_perm: true, + filterBarOrientation: FilterBarOrientation.Vertical, + metadata: { + native_filter_configuration: [rangeFilter], + chart_configuration: {}, + }, + }, + dashboardState: { + ...stateWithoutNativeFilters.dashboardState, + activeTabs: ['ROOT_ID'], + }, + dataMask: { + [filterId]: createDataMask(filterId, [10, 50]), + }, + nativeFilters: { + filters: { [filterId]: rangeFilter }, + filtersState: {}, + }, + }; + + const props = createOpenedBarProps(); + renderFilterBar(props, stateWithRange); + await act(async () => { + jest.advanceTimersByTime(300); + }); + + const clearBtn = screen.getByTestId(getTestId('clear-button')); + expect(clearBtn).not.toBeDisabled(); + await act(async () => { + userEvent.click(clearBtn); + }); + + expect(updateDataMaskSpy).toHaveBeenCalledWith(filterId, { + filterState: { value: [null, null] }, + extraFormData: {}, + }); + updateDataMaskSpy.mockRestore(); +}); + +test('handleClearAll only dispatches for filters present in dataMask', async () => { + const idInMask = 'NATIVE_FILTER-has-value'; + const idNotInMask = 'NATIVE_FILTER-no-value'; + const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask'); + const filterInMask = createFilter({ + id: idInMask, + name: 'A', + filterType: 'filter_select', + targets: [{ datasetId: 7, column: { name: 'x' } }], + chartsInScope: [18], + }); + const filterNotInMask = createFilter({ + id: idNotInMask, + name: 'B', + filterType: 'filter_select', + targets: [{ datasetId: 7, column: { name: 'x' } }], + chartsInScope: [18], + }); + const stateWithTwoFilters = { + ...stateWithoutNativeFilters, + dashboardInfo: { + id: 1, + dash_edit_perm: true, + filterBarOrientation: FilterBarOrientation.Vertical, + metadata: { + native_filter_configuration: [filterInMask, filterNotInMask], + chart_configuration: {}, + }, + }, + dashboardState: { + ...stateWithoutNativeFilters.dashboardState, + activeTabs: ['ROOT_ID'], + }, + dataMask: { + [idInMask]: createDataMask(idInMask, ['v']), + }, + nativeFilters: { + filters: { + [idInMask]: filterInMask, + [idNotInMask]: filterNotInMask, + }, + filtersState: {}, + }, + }; + + const props = createOpenedBarProps(); + renderFilterBar(props, stateWithTwoFilters); + await act(async () => { + jest.advanceTimersByTime(300); + }); + + const clearBtn = screen.getByTestId(getTestId('clear-button')); + await act(async () => { + userEvent.click(clearBtn); + }); + + expect(updateDataMaskSpy).toHaveBeenCalledTimes(1); + expect(updateDataMaskSpy).toHaveBeenCalledWith(idInMask, { + filterState: { value: undefined }, + extraFormData: {}, + }); + updateDataMaskSpy.mockRestore(); +}); + +test('FilterBar Clear All only clears in-scope filters, not out-of-scope ones', async () => { + const inScopeFilterId = 'NATIVE_FILTER-in-scope'; + const outOfScopeRequiredFilterId = 'NATIVE_FILTER-out-of-scope-required'; + const outOfScopeNonRequiredFilterId = + 'NATIVE_FILTER-out-of-scope-non-required'; + const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask'); + + const dashboardLayoutWithTabs = { + ROOT_ID: { id: 'ROOT_ID', type: 'ROOT', children: ['TABS-1'] }, + 'TABS-1': { + id: 'TABS-1', + type: 'TABS', + children: ['TAB-active', 'TAB-inactive'], + }, + 'TAB-active': { + id: 'TAB-active', + type: 'TAB', + children: ['CHART_ROW-1'], + meta: { text: 'Active Tab' }, + parents: ['ROOT_ID', 'TABS-1'], + }, + 'TAB-inactive': { + id: 'TAB-inactive', + type: 'TAB', + children: ['CHART_ROW-2'], + meta: { text: 'Inactive Tab' }, + parents: ['ROOT_ID', 'TABS-1'], + }, + 'CHART_ROW-1': { + id: 'CHART_ROW-1', + type: 'CHART', + meta: { chartId: 1 }, + parents: ['ROOT_ID', 'TABS-1', 'TAB-active'], + }, + 'CHART_ROW-2': { + id: 'CHART_ROW-2', + type: 'CHART', + meta: { chartId: 2 }, + parents: ['ROOT_ID', 'TABS-1', 'TAB-inactive'], + }, + }; + + const inScopeFilter = createFilter({ + id: inScopeFilterId, + name: 'In Scope Filter', + targets: [{ datasetId: 1, column: { name: 'column1' } }], + controlValues: { enableEmptyFilter: false }, + chartsInScope: [1], + tabsInScope: ['TAB-active'], + }); + + const outOfScopeRequiredFilter = createFilter({ + id: outOfScopeRequiredFilterId, + name: 'Out of Scope Required Filter', + targets: [{ datasetId: 1, column: { name: 'column2' } }], + controlValues: { enableEmptyFilter: true }, + chartsInScope: [2], + tabsInScope: ['TAB-inactive'], + }); + + const outOfScopeNonRequiredFilter = createFilter({ + id: outOfScopeNonRequiredFilterId, + name: 'Out of Scope Non-Required Filter', + targets: [{ datasetId: 1, column: { name: 'column3' } }], + controlValues: { enableEmptyFilter: false }, + chartsInScope: [2], + tabsInScope: ['TAB-inactive'], + }); + + const stateWithTabsAndFilters = { + ...stateWithoutNativeFilters, + dashboardLayout: { + present: dashboardLayoutWithTabs, + past: [], + future: [], + }, + dashboardState: { + ...stateWithoutNativeFilters.dashboardState, + activeTabs: ['TAB-active'], + }, + dashboardInfo: { + id: 1, + dash_edit_perm: true, + metadata: { + native_filter_configuration: [ + inScopeFilter, + outOfScopeRequiredFilter, + outOfScopeNonRequiredFilter, + ], + }, + }, + dataMask: { + [inScopeFilterId]: createDataMask(inScopeFilterId, ['value1'], { + filters: [{ col: 'column1', op: 'IN', val: ['value1'] }], + }), + [outOfScopeRequiredFilterId]: createDataMask( + outOfScopeRequiredFilterId, + ['value2'], + { filters: [{ col: 'column2', op: 'IN', val: ['value2'] }] }, + ), + [outOfScopeNonRequiredFilterId]: createDataMask( + outOfScopeNonRequiredFilterId, + ['value3'], + { filters: [{ col: 'column3', op: 'IN', val: ['value3'] }] }, + ), + }, + nativeFilters: { + filters: { + [inScopeFilterId]: inScopeFilter, + [outOfScopeRequiredFilterId]: outOfScopeRequiredFilter, + [outOfScopeNonRequiredFilterId]: outOfScopeNonRequiredFilter, + }, + filtersState: {}, + }, + }; + + const props = createOpenedBarProps(); + renderFilterBar(props, stateWithTabsAndFilters); + + await act(async () => { + jest.advanceTimersByTime(300); + }); + + const clearButton = screen.getByTestId(getTestId('clear-button')); + expect(clearButton).toBeInTheDocument(); + + await act(async () => { + userEvent.click(clearButton); + }); + + // Verify only the in-scope filter was cleared, not the out-of-scope ones + const clearedFilterIds = updateDataMaskSpy.mock.calls.map(call => call[0]); + expect(clearedFilterIds).toContain(inScopeFilterId); + expect(clearedFilterIds).not.toContain(outOfScopeRequiredFilterId); + expect(clearedFilterIds).not.toContain(outOfScopeNonRequiredFilterId); + + // Verify the in-scope filter was cleared with the correct value + expect(updateDataMaskSpy).toHaveBeenCalledWith(inScopeFilterId, { + filterState: { value: undefined }, + extraFormData: {}, + }); + + updateDataMaskSpy.mockRestore(); }); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx index 5647e327de1..25377ee36a2 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx @@ -63,7 +63,7 @@ import { LOG_ACTIONS_CHANGE_DASHBOARD_FILTER } from 'src/logger/LogUtils'; import { FilterBarOrientation, RootState } from 'src/dashboard/types'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import { isChartCustomization } from '../FiltersConfigModal/utils'; -import { checkIsApplyDisabled } from './utils'; +import { checkIsApplyDisabled, getFiltersToApply } from './utils'; import { extractLabel } from '../selectors'; import { FiltersBarProps } from './types'; import { @@ -144,12 +144,12 @@ const publishDataMask = debounce( // it when necessary. We strip any prefix so that history.replace adds it back and doesn't // double it up. const appRoot = applicationRoot(); - let replacement_pathname = window.location.pathname; - if (appRoot !== '/' && replacement_pathname.startsWith(appRoot)) { - replacement_pathname = replacement_pathname.substring(appRoot.length); + let replacementPathname = window.location.pathname; + if (appRoot !== '/' && replacementPathname.startsWith(appRoot)) { + replacementPathname = replacementPathname.substring(appRoot.length); } - history.location.pathname = replacement_pathname; history.replace({ + pathname: replacementPathname, search: newParams.toString(), }); } @@ -196,6 +196,21 @@ const FilterBar: FC = ({ >(state => state.user); const [filtersInScope] = useSelectFiltersInScope(nativeFilterValues); + const inScopeFilterIds = useMemo( + () => new Set(filtersInScope.map(f => f.id)), + [filtersInScope], + ); + + const hasOutOfScopeRequiredFilters = useMemo( + () => + nativeFilterValues.some( + filter => + !inScopeFilterIds.has(filter.id) && + !!filter.controlValues?.enableEmptyFilter, + ), + [nativeFilterValues, inScopeFilterIds], + ); + const [clearAllTriggers, setClearAllTriggers] = useState< Record >({}); @@ -331,10 +346,49 @@ const FilterBar: FC = ({ }, [dashboardId, filters, previousDashboardId, setDataMaskSelected]); const dataMaskAppliedText = JSON.stringify(dataMaskApplied); + const prevDataMaskAppliedRef = useRef(dataMaskApplied); useEffect(() => { - setDataMaskSelected(() => dataMaskApplied); - }, [dataMaskAppliedText, setDataMaskSelected, dashboardId]); + const dashboardChanged = dashboardId !== previousDashboardId; + + if (dashboardChanged) { + setDataMaskSelected(() => dataMaskApplied); + } else { + const prevApplied = prevDataMaskAppliedRef.current; + + // Only sync filters whose applied state actually changed + setDataMaskSelected(prev => { + let hasChanges = false; + const updated = { ...prev }; + + Object.entries(dataMaskApplied).forEach(([filterId, appliedMask]) => { + const prevAppliedMask = prevApplied[filterId]; + const appliedChanged = !isEqual(appliedMask, prevAppliedMask); + const notInitialized = !prev[filterId]; + + if (appliedChanged || notInitialized) { + updated[filterId] = appliedMask; + hasChanges = true; + } + }); + + // Remove stale entries that no longer exist in dataMaskApplied + Object.keys(updated).forEach(filterId => { + if ( + !isChartCustomization(filterId) && + !(filterId in dataMaskApplied) + ) { + delete updated[filterId]; + hasChanges = true; + } + }); + + return hasChanges ? updated : prev; + }); + } + + prevDataMaskAppliedRef.current = dataMaskApplied; + }, [dataMaskApplied, setDataMaskSelected, dashboardId, previousDashboardId]); useEffect(() => { // embedded users can't persist filter combinations @@ -363,7 +417,13 @@ const FilterBar: FC = ({ dispatch(logEvent(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {})); setUpdateKey(1); - Object.entries(dataMaskSelected).forEach(([filterId, dataMask]) => { + const filtersToApply = getFiltersToApply( + dataMaskSelected, + inScopeFilterIds, + ); + + filtersToApply.forEach(filterId => { + const dataMask = dataMaskSelected[filterId]; if (dataMask) { dispatch(updateDataMask(filterId, dataMask)); } @@ -419,6 +479,7 @@ const FilterBar: FC = ({ }, [ dataMaskSelected, dispatch, + inScopeFilterIds, pendingChartCustomizations, pendingCustomizationDataMasks, hasClearedChartCustomizations, @@ -427,8 +488,13 @@ const FilterBar: FC = ({ const handleClearAll = useCallback(() => { const newClearAllTriggers = { ...clearAllTriggers }; + nativeFilterValues.forEach(filter => { const { id, filterType } = filter; + + // Only clear in-scope filters + if (!inScopeFilterIds.has(id)) return; + // Range filters use [null, null] as the cleared value; others use undefined const clearedValue = filterType === 'filter_range' ? [null, null] : undefined; @@ -480,6 +546,7 @@ const FilterBar: FC = ({ dataMaskSelected, dataMaskApplied, nativeFilterValues, + inScopeFilterIds, setDataMaskSelected, chartCustomizationValues, clearAllTriggers, @@ -518,6 +585,7 @@ const FilterBar: FC = ({ dataMaskSelected, dataMaskApplied, filtersInScope.filter(isNativeFilter), + nativeFilterValues, ); const isApplyDisabled = @@ -539,6 +607,7 @@ const FilterBar: FC = ({ dataMaskApplied={dataMaskApplied} isApplyDisabled={isApplyDisabled} chartCustomizationItems={chartCustomizationValues} + hasOutOfScopeRequiredFilters={hasOutOfScopeRequiredFilters} /> ), [ @@ -550,6 +619,7 @@ const FilterBar: FC = ({ dataMaskApplied, isApplyDisabled, chartCustomizationValues, + hasOutOfScopeRequiredFilters, ], ); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.test.ts index 4536b907988..accb766a7b2 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.test.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.test.ts @@ -17,615 +17,771 @@ * under the License. */ -import { DataMaskStateWithId, Filter } from '@superset-ui/core'; +import { + DataMaskStateWithId, + DataRecordValue, + Filter, + FilterState, +} from '@superset-ui/core'; import { checkIsApplyDisabled, checkIsValidateError, checkIsMissingRequiredValue, + getOnlyExtraFormData, + getFiltersToApply, } from './utils'; -// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks -describe('FilterBar Utils - Validation and Apply Logic', () => { - // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks - describe('checkIsValidateError', () => { - test('should return true when no filters have validation errors', () => { - const dataMask: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - validateStatus: undefined, - value: ['CA'], - }, - extraFormData: {}, - }, - 'filter-2': { - id: 'filter-2', - filterState: { - validateStatus: undefined, - value: ['NY'], - }, - extraFormData: {}, - }, - }; - - expect(checkIsValidateError(dataMask)).toBe(true); - }); - - test('should return false when any filter has validation error', () => { - const dataMask: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - validateStatus: 'error', - value: undefined, - }, - extraFormData: {}, - }, - 'filter-2': { - id: 'filter-2', - filterState: { - validateStatus: undefined, - value: ['NY'], - }, - extraFormData: {}, - }, - }; - - expect(checkIsValidateError(dataMask)).toBe(false); - }); - - test('should handle empty dataMask', () => { - const dataMask: DataMaskStateWithId = {}; - expect(checkIsValidateError(dataMask)).toBe(true); - }); - - test('should handle filters without filterState', () => { - const dataMask: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - extraFormData: {}, - }, - }; - - expect(checkIsValidateError(dataMask)).toBe(true); - }); - }); - - // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks - describe('checkIsMissingRequiredValue', () => { - test('should return true for required filter with undefined value', () => { - const filter = { - id: 'test-filter', - controlValues: { - enableEmptyFilter: true, - }, - } as unknown as Filter; - - const filterState = { - value: undefined, - }; - - expect(checkIsMissingRequiredValue(filter, filterState)).toBe(true); - }); - - test('should return true for required filter with null value', () => { - const filter = { - id: 'test-filter', - controlValues: { - enableEmptyFilter: true, - }, - } as unknown as Filter; - - const filterState = { - value: null, - }; - - expect(checkIsMissingRequiredValue(filter, filterState)).toBe(true); - }); - - test('should return false for required filter with valid value', () => { - const filter = { - id: 'test-filter', - controlValues: { - enableEmptyFilter: true, - }, - } as unknown as Filter; - - const filterState = { - value: ['CA'], - }; - - expect(checkIsMissingRequiredValue(filter, filterState)).toBe(false); - }); - - test('should return false for non-required filter with undefined value', () => { - const filter = { - id: 'test-filter', - controlValues: { - enableEmptyFilter: false, - }, - } as unknown as Filter; - - const filterState = { - value: undefined, - }; - - expect(checkIsMissingRequiredValue(filter, filterState)).toBe(false); - }); - - test('should return false for filter without controlValues', () => { - const filter = { - id: 'test-filter', - } as Filter; - - const filterState = { - value: undefined, - }; - - // checkIsMissingRequiredValue returns undefined when controlValues is missing - // undefined is falsy, so we check for truthiness instead of exact false - expect(checkIsMissingRequiredValue(filter, filterState)).toBeFalsy(); - }); - }); - - // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks - describe('checkIsApplyDisabled', () => { - test('should return true when filters have validation errors', () => { - const dataMaskSelected: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - validateStatus: 'error', - value: undefined, - }, - extraFormData: {}, - }, - }; - - const dataMaskApplied: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - value: ['CA'], - }, - extraFormData: { - filters: [{ col: 'state', op: 'IN', val: ['CA'] }], - }, - }, - }; - - const filters: Filter[] = [ - { - id: 'filter-1', - controlValues: { - enableEmptyFilter: true, - }, - } as unknown as Filter, - ]; - - expect( - checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), - ).toBe(true); - }); - - test('should return false when selected and applied states differ', () => { - const dataMaskSelected: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - validateStatus: undefined, - value: ['NY'], - }, - extraFormData: { - filters: [{ col: 'state', op: 'IN', val: ['NY'] }], - }, - }, - }; - - const dataMaskApplied: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - value: ['CA'], - }, - extraFormData: { - filters: [{ col: 'state', op: 'IN', val: ['CA'] }], - }, - }, - }; - - const filters: Filter[] = [ - { - id: 'filter-1', - controlValues: { - enableEmptyFilter: false, - }, - } as unknown as Filter, - ]; - - expect( - checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), - ).toBe(false); - }); - - test('should return true when selected and applied states are identical', () => { - const dataMaskSelected: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - validateStatus: undefined, - value: ['CA'], - }, - extraFormData: { - filters: [{ col: 'state', op: 'IN', val: ['CA'] }], - }, - }, - }; - - const dataMaskApplied: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - value: ['CA'], - }, - extraFormData: { - filters: [{ col: 'state', op: 'IN', val: ['CA'] }], - }, - }, - }; - - const filters: Filter[] = [ - { - id: 'filter-1', - controlValues: { - enableEmptyFilter: false, - }, - } as unknown as Filter, - ]; - - expect( - checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), - ).toBe(true); - }); - - test('should return true when required filter is missing value in selected state', () => { - const dataMaskSelected: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - validateStatus: undefined, - value: undefined, - }, - extraFormData: {}, - }, - }; - - const dataMaskApplied: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - value: ['CA'], - }, - extraFormData: { - filters: [{ col: 'state', op: 'IN', val: ['CA'] }], - }, - }, - }; - - const filters: Filter[] = [ - { - id: 'filter-1', - controlValues: { - enableEmptyFilter: true, // Required filter - }, - } as unknown as Filter, - ]; - - expect( - checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), - ).toBe(true); - }); - - test('should enable Apply when new filter is selected', () => { - // User selects a new filter that hasn't been applied yet - // Apply should be ENABLED to allow applying the new selection - const dataMaskSelected: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - validateStatus: undefined, - value: ['CA'], - }, - extraFormData: { - filters: [{ col: 'state', op: 'IN', val: ['CA'] }], - }, - }, - 'filter-2': { - id: 'filter-2', - filterState: { - validateStatus: undefined, - value: ['Product A'], - }, - extraFormData: { - filters: [{ col: 'product', op: 'IN', val: ['Product A'] }], - }, - }, - }; - - const dataMaskApplied: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - value: ['CA'], - }, - extraFormData: { - filters: [{ col: 'state', op: 'IN', val: ['CA'] }], - }, - }, - // filter-2 not yet applied - }; - - const filters: Filter[] = [ - { id: 'filter-1', controlValues: {} } as unknown as Filter, - { id: 'filter-2', controlValues: {} } as unknown as Filter, - ]; - - expect( - checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), - ).toBe(false); - }); - - test('should handle validation status recalculation scenario', () => { - // Scenario: Filter was required and had error, then user selected value - // The validateStatus should be cleared and Apply should be enabled - - const dataMaskSelected: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - validateStatus: undefined, // Error cleared after selection - value: ['CA'], - }, - extraFormData: { - filters: [{ col: 'state', op: 'IN', val: ['CA'] }], - }, - }, - }; - - const dataMaskApplied: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - value: undefined, // Previously cleared - }, - extraFormData: {}, - }, - }; - - const filters: Filter[] = [ - { - id: 'filter-1', - controlValues: { - enableEmptyFilter: true, - }, - } as unknown as Filter, - ]; - - // Should be enabled because states differ and no validation errors - expect( - checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), - ).toBe(false); - }); - - test('should not disable Apply when required filter is auto-applied (bug fix)', () => { - // Bug scenario: Filter A has "required" + "select first by default" - // Filter A auto-applies and is synced to selected - // User changes Filter B (adds it to selected, not in applied yet) - // Apply button should be ENABLED (changes exist and required filter has value) - - const dataMaskSelected: DataMaskStateWithId = { - 'filter-country': { - id: 'filter-country', - filterState: { - validateStatus: undefined, - value: ['USA'], // Auto-applied value (synced from applied) - }, - extraFormData: { - filters: [{ col: 'country', op: 'IN', val: ['USA'] }], - }, - }, - 'filter-product': { - id: 'filter-product', - filterState: { - validateStatus: undefined, - value: ['Product A'], // User changed this filter - }, - extraFormData: { - filters: [{ col: 'product_line', op: 'IN', val: ['Product A'] }], - }, - }, - }; - - const dataMaskApplied: DataMaskStateWithId = { - 'filter-country': { - id: 'filter-country', - filterState: { - value: ['USA'], // Already applied - }, - extraFormData: { - filters: [{ col: 'country', op: 'IN', val: ['USA'] }], - }, - }, - // filter-product not yet applied - }; - - const filters: Filter[] = [ - { - id: 'filter-country', - controlValues: { - enableEmptyFilter: true, // Required filter - }, - } as unknown as Filter, - { - id: 'filter-product', - controlValues: { - enableEmptyFilter: false, - }, - } as unknown as Filter, - ]; - - // Should be ENABLED - Filter B has changes to apply and Filter A (required) has value - expect( - checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), - ).toBe(false); - }); - - test('should not disable Apply when required filter is only in applied state during sync gap', () => { - // Edge case: Required filter auto-applied but not yet synced to selected - // The filter is NOT in dataMaskSelected at all (key doesn't exist) - // This can happen during the React state update cycle - // Apply button should still be enabled if other filters have changes - - const dataMaskSelected: DataMaskStateWithId = { - 'filter-product': { - id: 'filter-product', - filterState: { - validateStatus: undefined, - value: ['Product B'], - }, - extraFormData: { - filters: [{ col: 'product_line', op: 'IN', val: ['Product B'] }], - }, - }, - // filter-country not yet in selected (sync gap) - KEY DOESN'T EXIST - }; - - const dataMaskApplied: DataMaskStateWithId = { - 'filter-country': { - id: 'filter-country', - filterState: { - value: ['USA'], // Auto-applied, has value - }, - extraFormData: { - filters: [{ col: 'country', op: 'IN', val: ['USA'] }], - }, - }, - // filter-product not yet applied - }; - - const filters: Filter[] = [ - { - id: 'filter-country', - controlValues: { - enableEmptyFilter: true, // Required filter - }, - } as unknown as Filter, - { - id: 'filter-product', - controlValues: { - enableEmptyFilter: false, - }, - } as unknown as Filter, - ]; - - // Should be ENABLED - Required filter has value in applied state (auto-applied), - // and it's not in selected at all (not explicitly cleared by user) - expect( - checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), - ).toBe(false); - }); - - test('should disable Apply when user explicitly clears a required filter value', () => { - // Different from sync gap: filter IS in selected state but with undefined value - // This means user explicitly cleared it - // Apply should be DISABLED because it's a required filter - - const dataMaskSelected: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - validateStatus: undefined, - value: undefined, // User explicitly cleared the value - }, - extraFormData: {}, - }, - }; - - const dataMaskApplied: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - value: ['CA'], // Previously had a value - }, - extraFormData: { - filters: [{ col: 'state', op: 'IN', val: ['CA'] }], - }, - }, - }; - - const filters: Filter[] = [ - { - id: 'filter-1', - controlValues: { - enableEmptyFilter: true, // Required filter - }, - } as unknown as Filter, - ]; - - // Should be DISABLED - User cleared a required filter - // Even though it has value in applied state, the selected state shows user intent - expect( - checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), - ).toBe(true); - }); - - test('should enable Apply when filter has value but needs extraFormData update (PR #36927 regression test)', () => { - // Original bug from PR #36927: defaultDataMask has value but empty extraFormData - // The filter loads with a value in applied state but no extraFormData - // Then the filter plugin generates extraFormData and updates selected state - // Apply should be ENABLED to allow the extraFormData to be applied - - const dataMaskSelected: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - validateStatus: undefined, - value: ['value1', 'value2'], // Same value - }, - extraFormData: { - // Now has extraFormData generated by filter plugin - filters: [ - { col: 'test_column', op: 'IN', val: ['value1', 'value2'] }, - ], - }, - }, - }; - - const dataMaskApplied: DataMaskStateWithId = { - 'filter-1': { - id: 'filter-1', - filterState: { - value: ['value1', 'value2'], // Has value from defaultDataMask - }, - extraFormData: {}, // But extraFormData is empty (the bug scenario) - }, - }; - - const filters: Filter[] = [ - { - id: 'filter-1', - controlValues: { - enableEmptyFilter: true, // Required filter - }, - } as unknown as Filter, - ]; - - // Should be ENABLED because extraFormData changed (not equal) - // even though the value is the same - // This allows the auto-apply logic to update the applied state with extraFormData - expect( - checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters), - ).toBe(false); - }); +// Factory functions for test data +function createDataMaskEntry( + id: string, + overrides: { + value?: unknown; + validateStatus?: 'error' | undefined; + extraFormData?: Record; + } = {}, +) { + const { value, validateStatus, extraFormData = {} } = overrides; + return { + id, + filterState: { + value, + validateStatus, + }, + extraFormData, + }; +} + +function createFilter( + id: string, + overrides: { + enableEmptyFilter?: boolean; + controlValues?: Record; + } = {}, +): Filter { + const { enableEmptyFilter, controlValues = {} } = overrides; + return { + id, + controlValues: { + ...(enableEmptyFilter !== undefined && { enableEmptyFilter }), + ...controlValues, + }, + } as unknown as Filter; +} + +function createExtraFormDataWithFilter( + col: string, + val: DataRecordValue[], + op: 'IN' | 'NOT IN' = 'IN', +) { + return { + filters: [{ col, op, val }], + }; +} + +// getOnlyExtraFormData tests +test('getOnlyExtraFormData extracts extraFormData from all filters when no filterIds provided', () => { + const dataMask: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { value: ['CA'] }, + extraFormData: { filters: [{ col: 'state', op: 'IN', val: ['CA'] }] }, + }, + 'filter-2': { + id: 'filter-2', + filterState: { value: ['NY'] }, + extraFormData: { filters: [{ col: 'city', op: 'IN', val: ['NY'] }] }, + }, + }; + + const result = getOnlyExtraFormData(dataMask); + + expect(result).toEqual({ + 'filter-1': { filters: [{ col: 'state', op: 'IN', val: ['CA'] }] }, + 'filter-2': { filters: [{ col: 'city', op: 'IN', val: ['NY'] }] }, }); }); + +test('getOnlyExtraFormData only extracts extraFormData for specified filterIds', () => { + const dataMask: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { value: ['CA'] }, + extraFormData: { filters: [{ col: 'state', op: 'IN', val: ['CA'] }] }, + }, + 'filter-2': { + id: 'filter-2', + filterState: { value: ['NY'] }, + extraFormData: { filters: [{ col: 'city', op: 'IN', val: ['NY'] }] }, + }, + 'filter-3': { + id: 'filter-3', + filterState: { value: ['Product'] }, + extraFormData: { + filters: [{ col: 'product', op: 'IN', val: ['Product'] }], + }, + }, + }; + + const filterIds = new Set(['filter-1', 'filter-3']); + const result = getOnlyExtraFormData(dataMask, filterIds); + + expect(result).toEqual({ + 'filter-1': { filters: [{ col: 'state', op: 'IN', val: ['CA'] }] }, + 'filter-3': { filters: [{ col: 'product', op: 'IN', val: ['Product'] }] }, + }); + expect(result).not.toHaveProperty('filter-2'); +}); + +test('getOnlyExtraFormData returns empty object when filterIds is empty set', () => { + const dataMask: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { value: ['CA'] }, + extraFormData: { filters: [{ col: 'state', op: 'IN', val: ['CA'] }] }, + }, + }; + + const filterIds = new Set(); + const result = getOnlyExtraFormData(dataMask, filterIds); + + expect(result).toEqual({}); +}); + +// checkIsValidateError tests +test('checkIsValidateError returns true when no filters have validation errors', () => { + const dataMask: DataMaskStateWithId = { + 'filter-1': createDataMaskEntry('filter-1', { value: ['CA'] }), + 'filter-2': createDataMaskEntry('filter-2', { value: ['NY'] }), + }; + + expect(checkIsValidateError(dataMask)).toBe(true); +}); + +test('checkIsValidateError returns false when any filter has validation error', () => { + const dataMask: DataMaskStateWithId = { + 'filter-1': createDataMaskEntry('filter-1', { validateStatus: 'error' }), + 'filter-2': createDataMaskEntry('filter-2', { value: ['NY'] }), + }; + + expect(checkIsValidateError(dataMask)).toBe(false); +}); + +test('checkIsValidateError handles empty dataMask', () => { + const dataMask: DataMaskStateWithId = {}; + expect(checkIsValidateError(dataMask)).toBe(true); +}); + +test('checkIsValidateError handles filters without filterState', () => { + const dataMask: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + extraFormData: {}, + }, + }; + + expect(checkIsValidateError(dataMask)).toBe(true); +}); + +// checkIsMissingRequiredValue tests +test('checkIsMissingRequiredValue returns true for required filter with undefined value', () => { + const filter = createFilter('test-filter', { enableEmptyFilter: true }); + const filterState: FilterState = { value: undefined }; + + expect(checkIsMissingRequiredValue(filter, filterState)).toBe(true); +}); + +test('checkIsMissingRequiredValue returns true for required filter with null value', () => { + const filter = createFilter('test-filter', { enableEmptyFilter: true }); + const filterState: FilterState = { value: null }; + + expect(checkIsMissingRequiredValue(filter, filterState)).toBe(true); +}); + +test('checkIsMissingRequiredValue returns false for required filter with valid value', () => { + const filter = createFilter('test-filter', { enableEmptyFilter: true }); + const filterState: FilterState = { value: ['CA'] }; + + expect(checkIsMissingRequiredValue(filter, filterState)).toBe(false); +}); + +test('checkIsMissingRequiredValue returns false for non-required filter with undefined value', () => { + const filter = createFilter('test-filter', { enableEmptyFilter: false }); + const filterState: FilterState = { value: undefined }; + + expect(checkIsMissingRequiredValue(filter, filterState)).toBe(false); +}); + +test('checkIsMissingRequiredValue returns falsy for filter without controlValues', () => { + const filter = { id: 'test-filter' } as Filter; + const filterState: FilterState = { value: undefined }; + + expect(checkIsMissingRequiredValue(filter, filterState)).toBeFalsy(); +}); + +// checkIsApplyDisabled tests +test('checkIsApplyDisabled returns true when filters have validation errors', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'filter-1': createDataMaskEntry('filter-1', { validateStatus: 'error' }), + }; + const dataMaskApplied: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { value: ['CA'] }, + extraFormData: createExtraFormDataWithFilter('state', ['CA']), + }, + }; + const filters = [createFilter('filter-1', { enableEmptyFilter: true })]; + + expect(checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters)).toBe( + true, + ); +}); + +test('checkIsApplyDisabled returns false when selected and applied states differ', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { value: ['NY'] }, + extraFormData: createExtraFormDataWithFilter('state', ['NY']), + }, + }; + const dataMaskApplied: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { value: ['CA'] }, + extraFormData: createExtraFormDataWithFilter('state', ['CA']), + }, + }; + const filters = [createFilter('filter-1', { enableEmptyFilter: false })]; + + expect(checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters)).toBe( + false, + ); +}); + +test('checkIsApplyDisabled returns true when selected and applied states are identical', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { value: ['CA'] }, + extraFormData: createExtraFormDataWithFilter('state', ['CA']), + }, + }; + const dataMaskApplied: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { value: ['CA'] }, + extraFormData: createExtraFormDataWithFilter('state', ['CA']), + }, + }; + const filters = [createFilter('filter-1', { enableEmptyFilter: false })]; + + expect(checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters)).toBe( + true, + ); +}); + +test('checkIsApplyDisabled returns true when required filter is missing value in selected state', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'filter-1': createDataMaskEntry('filter-1', { value: undefined }), + }; + const dataMaskApplied: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { value: ['CA'] }, + extraFormData: createExtraFormDataWithFilter('state', ['CA']), + }, + }; + const filters = [createFilter('filter-1', { enableEmptyFilter: true })]; + + expect(checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters)).toBe( + true, + ); +}); + +test('checkIsApplyDisabled handles filter count mismatch', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { value: ['CA'] }, + extraFormData: createExtraFormDataWithFilter('state', ['CA']), + }, + 'filter-2': { + id: 'filter-2', + filterState: { value: ['Product A'] }, + extraFormData: createExtraFormDataWithFilter('product', ['Product A']), + }, + }; + const dataMaskApplied: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { value: ['CA'] }, + extraFormData: createExtraFormDataWithFilter('state', ['CA']), + }, + }; + const filters = [createFilter('filter-1'), createFilter('filter-2')]; + + expect(checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters)).toBe( + true, + ); +}); + +test('checkIsApplyDisabled handles validation status recalculation scenario', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { validateStatus: undefined, value: ['CA'] }, + extraFormData: createExtraFormDataWithFilter('state', ['CA']), + }, + }; + const dataMaskApplied: DataMaskStateWithId = { + 'filter-1': { + id: 'filter-1', + filterState: { value: undefined }, + extraFormData: {}, + }, + }; + const filters = [createFilter('filter-1', { enableEmptyFilter: true })]; + + expect(checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters)).toBe( + false, + ); +}); + +test('checkIsApplyDisabled detects out-of-scope changes and enables Apply for explicit user changes', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'filter-in-scope': { + id: 'filter-in-scope', + filterState: { value: ['CA'] }, + extraFormData: createExtraFormDataWithFilter('state', ['CA']), + }, + 'filter-out-of-scope': { + id: 'filter-out-of-scope', + filterState: { value: ['New Product'] }, + extraFormData: createExtraFormDataWithFilter('product', ['New Product']), + }, + }; + const dataMaskApplied: DataMaskStateWithId = { + 'filter-in-scope': { + id: 'filter-in-scope', + filterState: { value: ['CA'] }, + extraFormData: createExtraFormDataWithFilter('state', ['CA']), + }, + 'filter-out-of-scope': { + id: 'filter-out-of-scope', + filterState: { value: ['Product A'] }, + extraFormData: createExtraFormDataWithFilter('product', ['Product A']), + }, + }; + const filtersInScope = [createFilter('filter-in-scope')]; + + expect( + checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filtersInScope), + ).toBe(false); +}); + +test('checkIsApplyDisabled enables apply when in-scope filter has changes regardless of out-of-scope state', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'filter-in-scope': { + id: 'filter-in-scope', + filterState: { value: ['CA'] }, + extraFormData: createExtraFormDataWithFilter('state', ['CA']), + }, + 'filter-out-of-scope': { + id: 'filter-out-of-scope', + filterState: { value: undefined }, + extraFormData: {}, + }, + }; + const dataMaskApplied: DataMaskStateWithId = { + 'filter-in-scope': { + id: 'filter-in-scope', + filterState: { value: ['NY'] }, + extraFormData: createExtraFormDataWithFilter('state', ['NY']), + }, + 'filter-out-of-scope': { + id: 'filter-out-of-scope', + filterState: { value: ['Product A'] }, + extraFormData: createExtraFormDataWithFilter('product', ['Product A']), + }, + }; + const filtersInScope = [createFilter('filter-in-scope')]; + + expect( + checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filtersInScope), + ).toBe(false); +}); + +test('checkIsApplyDisabled only validates required filters that are in scope', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'filter-in-scope': { + id: 'filter-in-scope', + filterState: { value: ['CA'] }, + extraFormData: createExtraFormDataWithFilter('state', ['CA']), + }, + 'filter-out-of-scope-required': { + id: 'filter-out-of-scope-required', + filterState: { value: undefined }, + extraFormData: {}, + }, + }; + const dataMaskApplied: DataMaskStateWithId = { + 'filter-in-scope': { + id: 'filter-in-scope', + filterState: { value: ['NY'] }, + extraFormData: createExtraFormDataWithFilter('state', ['NY']), + }, + 'filter-out-of-scope-required': { + id: 'filter-out-of-scope-required', + filterState: { value: ['Product A'] }, + extraFormData: createExtraFormDataWithFilter('product', ['Product A']), + }, + }; + const filtersInScope = [createFilter('filter-in-scope')]; + + expect( + checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filtersInScope), + ).toBe(false); +}); + +test('CRITICAL: checkIsApplyDisabled Apply button must be ENABLED when out-of-scope filter has explicit changes', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'tab-a-filter': { + id: 'tab-a-filter', + filterState: { value: ['same-value'] }, + extraFormData: createExtraFormDataWithFilter('col_a', ['same-value']), + }, + 'tab-b-filter': { + id: 'tab-b-filter', + filterState: { value: ['new-value'] }, + extraFormData: createExtraFormDataWithFilter('col_b', ['new-value']), + }, + }; + const dataMaskApplied: DataMaskStateWithId = { + 'tab-a-filter': { + id: 'tab-a-filter', + filterState: { value: ['same-value'] }, + extraFormData: createExtraFormDataWithFilter('col_a', ['same-value']), + }, + 'tab-b-filter': { + id: 'tab-b-filter', + filterState: { value: ['old-value'] }, + extraFormData: createExtraFormDataWithFilter('col_b', ['old-value']), + }, + }; + const filtersInScope = [createFilter('tab-a-filter')]; + + const result = checkIsApplyDisabled( + dataMaskSelected, + dataMaskApplied, + filtersInScope, + ); + expect(result).toBe(false); +}); + +test('CRITICAL: checkIsApplyDisabled Apply button must only consider in-scope required filter validation', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'tab-a-required': { + id: 'tab-a-required', + filterState: { value: ['has-value'] }, + extraFormData: createExtraFormDataWithFilter('col_a', ['has-value']), + }, + 'tab-b-required': { + id: 'tab-b-required', + filterState: { value: undefined }, + extraFormData: {}, + }, + }; + const dataMaskApplied: DataMaskStateWithId = { + 'tab-a-required': { + id: 'tab-a-required', + filterState: { value: ['old-value'] }, + extraFormData: createExtraFormDataWithFilter('col_a', ['old-value']), + }, + 'tab-b-required': { + id: 'tab-b-required', + filterState: { value: ['had-value'] }, + extraFormData: createExtraFormDataWithFilter('col_b', ['had-value']), + }, + }; + const filtersInScope = [ + createFilter('tab-a-required', { enableEmptyFilter: true }), + ]; + + const result = checkIsApplyDisabled( + dataMaskSelected, + dataMaskApplied, + filtersInScope, + ); + expect(result).toBe(false); +}); + +test('checkIsApplyDisabled disables Apply when in-scope required filter is empty', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'required-filter': { + id: 'required-filter', + filterState: { value: undefined }, + extraFormData: {}, + }, + }; + const dataMaskApplied: DataMaskStateWithId = { + 'required-filter': { + id: 'required-filter', + filterState: { value: ['had-value'] }, + extraFormData: createExtraFormDataWithFilter('col', ['had-value']), + }, + }; + const filtersInScope = [ + createFilter('required-filter', { enableEmptyFilter: true }), + ]; + + const result = checkIsApplyDisabled( + dataMaskSelected, + dataMaskApplied, + filtersInScope, + ); + expect(result).toBe(true); +}); + +test('checkIsApplyDisabled enabled when in-scope filter has changes, even if out-of-scope required is empty', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'tab-a-filter': { + id: 'tab-a-filter', + filterState: { value: ['new-value'] }, + extraFormData: createExtraFormDataWithFilter('col_a', ['new-value']), + }, + 'tab-b-required': { + id: 'tab-b-required', + filterState: { value: undefined }, + extraFormData: {}, + }, + }; + const dataMaskApplied: DataMaskStateWithId = { + 'tab-a-filter': { + id: 'tab-a-filter', + filterState: { value: ['old-value'] }, + extraFormData: createExtraFormDataWithFilter('col_a', ['old-value']), + }, + 'tab-b-required': { + id: 'tab-b-required', + filterState: { value: ['was-set'] }, + extraFormData: createExtraFormDataWithFilter('col_b', ['was-set']), + }, + }; + const filtersInScope = [createFilter('tab-a-filter')]; + const allFilters = [ + createFilter('tab-a-filter'), + createFilter('tab-b-required', { enableEmptyFilter: true }), + ]; + + const result = checkIsApplyDisabled( + dataMaskSelected, + dataMaskApplied, + filtersInScope, + allFilters, + ); + expect(result).toBe(false); +}); + +test('checkIsApplyDisabled disabled when ONLY out-of-scope changes exist and required filter is empty', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'tab-a-filter': { + id: 'tab-a-filter', + filterState: { value: ['same-value'] }, + extraFormData: createExtraFormDataWithFilter('col_a', ['same-value']), + }, + 'tab-b-required': { + id: 'tab-b-required', + filterState: { value: undefined }, + extraFormData: {}, + }, + }; + const dataMaskApplied: DataMaskStateWithId = { + 'tab-a-filter': { + id: 'tab-a-filter', + filterState: { value: ['same-value'] }, + extraFormData: createExtraFormDataWithFilter('col_a', ['same-value']), + }, + 'tab-b-required': { + id: 'tab-b-required', + filterState: { value: ['was-set'] }, + extraFormData: createExtraFormDataWithFilter('col_b', ['was-set']), + }, + }; + const filtersInScope = [createFilter('tab-a-filter')]; + const allFilters = [ + createFilter('tab-a-filter'), + createFilter('tab-b-required', { enableEmptyFilter: true }), + ]; + + const result = checkIsApplyDisabled( + dataMaskSelected, + dataMaskApplied, + filtersInScope, + allFilters, + ); + expect(result).toBe(true); +}); + +test('checkIsApplyDisabled enabled when user sets value for out-of-scope required filter', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'tab-a-filter': { + id: 'tab-a-filter', + filterState: { value: ['value'] }, + extraFormData: createExtraFormDataWithFilter('col_a', ['value']), + }, + 'tab-b-required': { + id: 'tab-b-required', + filterState: { value: ['user-selected'] }, + extraFormData: createExtraFormDataWithFilter('col_b', ['user-selected']), + }, + }; + const dataMaskApplied: DataMaskStateWithId = { + 'tab-a-filter': { + id: 'tab-a-filter', + filterState: { value: ['value'] }, + extraFormData: createExtraFormDataWithFilter('col_a', ['value']), + }, + 'tab-b-required': { + id: 'tab-b-required', + filterState: { value: ['old-value'] }, + extraFormData: createExtraFormDataWithFilter('col_b', ['old-value']), + }, + }; + const filtersInScope = [createFilter('tab-a-filter')]; + const allFilters = [ + createFilter('tab-a-filter'), + createFilter('tab-b-required', { enableEmptyFilter: true }), + ]; + + const result = checkIsApplyDisabled( + dataMaskSelected, + dataMaskApplied, + filtersInScope, + allFilters, + ); + expect(result).toBe(false); +}); + +// getFiltersToApply tests +test('CRITICAL: getFiltersToApply includes in-scope filters regardless of value', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'in-scope-with-value': { + id: 'in-scope-with-value', + filterState: { value: ['CA'] }, + extraFormData: {}, + }, + 'in-scope-empty': { + id: 'in-scope-empty', + filterState: { value: undefined }, + extraFormData: {}, + }, + }; + const inScopeFilterIds = new Set(['in-scope-with-value', 'in-scope-empty']); + + const result = getFiltersToApply(dataMaskSelected, inScopeFilterIds); + + expect(result).toContain('in-scope-with-value'); + expect(result).toContain('in-scope-empty'); +}); + +test('CRITICAL: getFiltersToApply includes out-of-scope filters ONLY if they have a value', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'in-scope': { + id: 'in-scope', + filterState: { value: ['CA'] }, + extraFormData: {}, + }, + 'out-of-scope-with-value': { + id: 'out-of-scope-with-value', + filterState: { value: ['Product'] }, + extraFormData: {}, + }, + 'out-of-scope-empty': { + id: 'out-of-scope-empty', + filterState: { value: undefined }, + extraFormData: {}, + }, + 'out-of-scope-null': { + id: 'out-of-scope-null', + filterState: { value: null }, + extraFormData: {}, + }, + }; + const inScopeFilterIds = new Set(['in-scope']); + + const result = getFiltersToApply(dataMaskSelected, inScopeFilterIds); + + expect(result).toContain('in-scope'); + expect(result).toContain('out-of-scope-with-value'); + expect(result).not.toContain('out-of-scope-empty'); + expect(result).not.toContain('out-of-scope-null'); +}); + +test('CRITICAL: getFiltersToApply scenario - Clear All on Tab B, then apply on Tab A', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'tab-a-filter': { + id: 'tab-a-filter', + filterState: { value: ['selected-value'] }, + extraFormData: {}, + }, + 'tab-b-required': { + id: 'tab-b-required', + filterState: { value: undefined }, + extraFormData: {}, + }, + }; + const inScopeFilterIds = new Set(['tab-a-filter']); + + const result = getFiltersToApply(dataMaskSelected, inScopeFilterIds); + + expect(result).toContain('tab-a-filter'); + expect(result).not.toContain('tab-b-required'); +}); + +test('CRITICAL: getFiltersToApply scenario - Change out-of-scope filter via "Filters out of scope" panel', () => { + const dataMaskSelected: DataMaskStateWithId = { + 'tab-a-filter': { + id: 'tab-a-filter', + filterState: { value: ['value-a'] }, + extraFormData: {}, + }, + 'tab-b-filter': { + id: 'tab-b-filter', + filterState: { value: ['value-b'] }, + extraFormData: {}, + }, + }; + const inScopeFilterIds = new Set(['tab-a-filter']); + + const result = getFiltersToApply(dataMaskSelected, inScopeFilterIds); + + expect(result).toContain('tab-a-filter'); + expect(result).toContain('tab-b-filter'); +}); + +test('getFiltersToApply handles empty dataMaskSelected', () => { + const dataMaskSelected: DataMaskStateWithId = {}; + const inScopeFilterIds = new Set(['filter-1']); + + const result = getFiltersToApply(dataMaskSelected, inScopeFilterIds); + + expect(result).toEqual([]); +}); + +test('getFiltersToApply handles null dataMask entries', () => { + const dataMaskSelected = { + 'filter-1': null, + 'filter-2': { + id: 'filter-2', + filterState: { value: ['CA'] }, + extraFormData: {}, + }, + } as unknown as DataMaskStateWithId; + const inScopeFilterIds = new Set(['filter-1', 'filter-2']); + + const result = getFiltersToApply(dataMaskSelected, inScopeFilterIds); + + expect(result).not.toContain('filter-1'); + expect(result).toContain('filter-2'); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts index 7fe635b5c93..b28a24bcf1d 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts @@ -17,7 +17,13 @@ * under the License. */ -import { DataMaskStateWithId, Filter, FilterState } from '@superset-ui/core'; +import { + DataMaskStateWithId, + ExtraFormData, + Filter, + FilterState, +} from '@superset-ui/core'; +import { isEqual } from 'lodash'; import { useSelector } from 'react-redux'; import { createSelector } from '@reduxjs/toolkit'; import { areObjectsEqual } from 'src/reduxUtils'; @@ -25,9 +31,16 @@ import { testWithId } from 'src/utils/testUtils'; import { RootState } from 'src/dashboard/types'; import { FilterElement } from './FilterControls/types'; -export const getOnlyExtraFormData = (data: DataMaskStateWithId) => - Object.values(data).reduce( - (prev, next) => ({ ...prev, [next.id]: next.extraFormData }), +export const getOnlyExtraFormData = ( + data: DataMaskStateWithId, + filterIds?: Set, +): Record => + Object.values(data).reduce>( + (prev, next) => { + // If filterIds is provided, only include those filters + if (filterIds && !filterIds.has(next.id)) return prev; + return { ...prev, [next.id]: next.extraFormData }; + }, {}, ); @@ -40,11 +53,9 @@ export const checkIsMissingRequiredValue = ( if (!isRequired) return false; const value = filterState?.value; + // TODO: this property should be unhardcoded - return ( - filter.controlValues?.enableEmptyFilter && - (value === null || value === undefined) - ); + return value === null || value === undefined; }; export const checkIsValidateError = (dataMask: DataMaskStateWithId) => { @@ -55,16 +66,60 @@ export const checkIsValidateError = (dataMask: DataMaskStateWithId) => { export const checkIsApplyDisabled = ( dataMaskSelected: DataMaskStateWithId, dataMaskApplied: DataMaskStateWithId, - filters: Filter[], + filtersInScope: Filter[], + allFilters?: Filter[], ) => { - if (!checkIsValidateError(dataMaskSelected)) { - return true; - } + if (!checkIsValidateError(dataMaskSelected)) return true; + + const selectedExtraFormData = getOnlyExtraFormData(dataMaskSelected); + const appliedExtraFormData = getOnlyExtraFormData(dataMaskApplied); + + // Check counts first + const selectedCount = Object.keys(selectedExtraFormData).length; + const appliedCount = Object.keys(appliedExtraFormData).length; + + if (selectedCount !== appliedCount) return true; + + // Check for changes + const dataEqual = areObjectsEqual( + selectedExtraFormData, + appliedExtraFormData, + { ignoreUndefined: true }, + ); + + // If no changes at all, Apply should be disabled + if (dataEqual) return true; + + // Determine which filters to validate for required values + const inScopeFilterIds = new Set(filtersInScope.map(f => f.id)); + + // Check if changes are in-scope or out-of-scope + const hasInScopeChanges = filtersInScope.some(filter => { + const selected = selectedExtraFormData[filter.id]; + const applied = appliedExtraFormData[filter.id]; + return !isEqual(selected, applied); + }); + + // Determine which filters to validate for required values + const hasOutOfScopeChanges = + !hasInScopeChanges && + allFilters?.some(filter => { + if (inScopeFilterIds.has(filter.id)) return false; + const selected = selectedExtraFormData[filter.id]; + const applied = appliedExtraFormData[filter.id]; + return !isEqual(selected, applied); + }); + + const shouldValidateAllRequired = + hasOutOfScopeChanges && allFilters && allFilters.length > 0; + const filtersToValidateRequired = shouldValidateAllRequired + ? allFilters + : filtersInScope; // Check if any required filter is missing a value // For filters that may have been auto-applied (e.g., requiredFirst filters), // check both selected and applied states to avoid false positives during initialization - const hasMissingRequiredFilter = filters.some(filter => { + const hasMissingRequiredFilter = filtersToValidateRequired.some(filter => { const selectedDataMask = dataMaskSelected?.[filter?.id]; const selectedState = selectedDataMask?.filterState; const appliedState = dataMaskApplied?.[filter?.id]?.filterState; @@ -88,18 +143,7 @@ export const checkIsApplyDisabled = ( return checkIsMissingRequiredValue(filter, selectedState); }); - const selectedExtraFormData = getOnlyExtraFormData(dataMaskSelected); - const appliedExtraFormData = getOnlyExtraFormData(dataMaskApplied); - - const areEqual = areObjectsEqual( - selectedExtraFormData, - appliedExtraFormData, - { ignoreUndefined: true }, - ); - - const result = areEqual || hasMissingRequiredFilter; - - return result; + return hasMissingRequiredFilter; }; const chartsVerboseMapSelector = createSelector( @@ -125,5 +169,26 @@ export const useChartsVerboseMaps = () => chartsVerboseMapSelector, ); +/** + * Determines which filters should be applied when the Apply button is clicked. + */ +export const getFiltersToApply = ( + dataMaskSelected: DataMaskStateWithId, + inScopeFilterIds: Set, +): string[] => + Object.entries(dataMaskSelected) + .filter(([filterId, dataMask]) => { + if (!dataMask) return false; + + const isInScope = inScopeFilterIds.has(filterId); + const hasValue = + dataMask.filterState?.value !== undefined && + dataMask.filterState?.value !== null; + + // Apply if in-scope OR if out-of-scope with a value + return isInScope || hasValue; + }) + .map(([filterId]) => filterId); + export const FILTER_BAR_TEST_ID = 'filter-bar'; export const getFilterBarTestId = testWithId(FILTER_BAR_TEST_ID); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx index c59eb1b20aa..ab6a9f22e69 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -821,6 +821,11 @@ const FiltersConfigForm = ( ); return ( setActiveTabKey(activeKey)} items={[ From 9017b9a74f295fe4890958fff977082c356e99de Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Mon, 9 Mar 2026 16:42:09 -0400 Subject: [PATCH 10/31] chore: enable allow_update_branch in .asf.yaml (#38530) Co-authored-by: Superset Dev Co-authored-by: Claude Sonnet 4.6 --- .asf.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.asf.yaml b/.asf.yaml index 90f75523f67..e717a33715e 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -24,7 +24,9 @@ notifications: discussions: notifications@superset.apache.org github: - del_branch_on_merge: true + pull_requests: + del_branch_on_merge: true + allow_update_branch: true description: "Apache Superset is a Data Visualization and Data Exploration Platform" homepage: https://superset.apache.org/ labels: From 61fbfda501ef00d24e5dfeeabaf6b8220b73c4c7 Mon Sep 17 00:00:00 2001 From: "Hugh A. Miles II" Date: Mon, 9 Mar 2026 16:44:56 -0400 Subject: [PATCH 11/31] feat(security): add granular export controls (Phase 1) (#38361) --- docs/static/feature-flags.json | 6 + .../src/utils/featureFlags.ts | 1 + .../src/explore/actions/hydrateExplore.ts | 13 +- .../src/explore/reducers/exploreReducer.ts | 2 + superset-frontend/src/explore/types.ts | 2 + .../src/hooks/usePermissions.test.tsx | 128 ++++++++++++++++++ superset-frontend/src/hooks/usePermissions.ts | 18 ++- superset/charts/data/api.py | 13 +- superset/config.py | 4 + ...3d4e5f6_add_granular_export_permissions.py | 76 +++++++++++ superset/security/manager.py | 11 ++ superset/views/core.py | 7 +- tests/integration_tests/security_tests.py | 2 + .../test_granular_export_permissions.py | 110 +++++++++++++++ 14 files changed, 387 insertions(+), 6 deletions(-) create mode 100644 superset-frontend/src/hooks/usePermissions.test.tsx create mode 100644 superset/migrations/versions/2026-03-02_12-00_a1b2c3d4e5f6_add_granular_export_permissions.py create mode 100644 tests/unit_tests/security/test_granular_export_permissions.py diff --git a/docs/static/feature-flags.json b/docs/static/feature-flags.json index 227d529c1db..5d7a86994f1 100644 --- a/docs/static/feature-flags.json +++ b/docs/static/feature-flags.json @@ -51,6 +51,12 @@ "lifecycle": "development", "description": "Enable Superset extensions for custom functionality without modifying core" }, + { + "name": "GRANULAR_EXPORT_CONTROLS", + "default": false, + "lifecycle": "development", + "description": "Enable granular export controls (can_export_data, can_export_image, can_copy_clipboard) instead of the single can_csv permission" + }, { "name": "MATRIXIFY", "default": false, diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index 7a5ba44dce8..18bc85af00c 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -56,6 +56,7 @@ export enum FeatureFlag { FilterBarClosedByDefault = 'FILTERBAR_CLOSED_BY_DEFAULT', GlobalAsyncQueries = 'GLOBAL_ASYNC_QUERIES', GlobalTaskFramework = 'GLOBAL_TASK_FRAMEWORK', + GranularExportControls = 'GRANULAR_EXPORT_CONTROLS', ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW', Matrixify = 'MATRIXIFY', ScheduledQueries = 'SCHEDULED_QUERIES', diff --git a/superset-frontend/src/explore/actions/hydrateExplore.ts b/superset-frontend/src/explore/actions/hydrateExplore.ts index b8e0375a648..0ceb1259b7b 100644 --- a/superset-frontend/src/explore/actions/hydrateExplore.ts +++ b/superset-frontend/src/explore/actions/hydrateExplore.ts @@ -29,9 +29,11 @@ import { Dispatch } from 'redux'; import { Currency, ensureIsArray, + FeatureFlag, getCategoricalSchemeRegistry, getColumnLabel, getSequentialSchemeRegistry, + isFeatureEnabled, NO_TIME_RANGE, QueryFormColumn, VizType, @@ -142,11 +144,20 @@ export const hydrateExplore = if (colorSchemeKey) verifyColorScheme(ColorSchemeType.CATEGORICAL); if (linearColorSchemeKey) verifyColorScheme(ColorSchemeType.SEQUENTIAL); + const granularExport = isFeatureEnabled(FeatureFlag.GranularExportControls); const exploreState = { // note this will add `form_data` to state, // which will be manipulable by future reducers. can_add: findPermission('can_write', 'Chart', user?.roles), - can_download: findPermission('can_csv', 'Superset', user?.roles), + can_download: granularExport + ? findPermission('can_export_data', 'Superset', user?.roles) + : findPermission('can_csv', 'Superset', user?.roles), + can_export_image: granularExport + ? findPermission('can_export_image', 'Superset', user?.roles) + : true, + can_copy_clipboard: granularExport + ? findPermission('can_copy_clipboard', 'Superset', user?.roles) + : true, can_overwrite: ensureIsArray(slice?.owners).includes( user?.userId as number, ), diff --git a/superset-frontend/src/explore/reducers/exploreReducer.ts b/superset-frontend/src/explore/reducers/exploreReducer.ts index d038dd40003..e940699e32c 100644 --- a/superset-frontend/src/explore/reducers/exploreReducer.ts +++ b/superset-frontend/src/explore/reducers/exploreReducer.ts @@ -41,6 +41,8 @@ import { SaveActionType } from 'src/explore/types'; export interface ExploreState { can_add?: boolean; can_download?: boolean; + can_export_image?: boolean; + can_copy_clipboard?: boolean; can_overwrite?: boolean; isDatasourceMetaLoading?: boolean; isDatasourcesLoading?: boolean; diff --git a/superset-frontend/src/explore/types.ts b/superset-frontend/src/explore/types.ts index 6bc6b1bb303..a032de3dd76 100644 --- a/superset-frontend/src/explore/types.ts +++ b/superset-frontend/src/explore/types.ts @@ -112,6 +112,8 @@ export interface ExplorePageState { explore: { can_add: boolean; can_download: boolean; + can_export_image: boolean; + can_copy_clipboard: boolean; can_overwrite: boolean; isDatasourceMetaLoading: boolean; isStarred: boolean; diff --git a/superset-frontend/src/hooks/usePermissions.test.tsx b/superset-frontend/src/hooks/usePermissions.test.tsx new file mode 100644 index 00000000000..23d704c8e40 --- /dev/null +++ b/superset-frontend/src/hooks/usePermissions.test.tsx @@ -0,0 +1,128 @@ +/** + * 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. + */ +import { renderHook } from '@testing-library/react-hooks'; +import { Provider } from 'react-redux'; +import { ReactNode } from 'react'; +import configureStore from 'redux-mock-store'; +import { usePermissions } from './usePermissions'; + +const mockStore = configureStore([]); + +const rolesWithAllPerms = { + Admin: [ + ['can_csv', 'Superset'], + ['can_export_data', 'Superset'], + ['can_export_image', 'Superset'], + ['can_copy_clipboard', 'Superset'], + ['can_explore', 'Superset'], + ], +}; + +const rolesWithoutExportPerms = { + Gamma: [ + ['can_explore', 'Superset'], + ['can_copy_clipboard', 'Superset'], + ], +}; + +const rolesWithLegacyCsvOnly = { + CustomRole: [ + ['can_csv', 'Superset'], + ['can_explore', 'Superset'], + ], +}; + +function createWrapper(roles: Record) { + const store = mockStore({ user: { roles } }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + isFeatureEnabled: jest.fn(), +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { isFeatureEnabled } = require('@superset-ui/core'); + +test('returns canExportData true when user has can_export_data', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithAllPerms), + }); + expect(result.current.canExportData).toBe(true); +}); + +test('returns canExportImage true when user has can_export_image', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithAllPerms), + }); + expect(result.current.canExportImage).toBe(true); +}); + +test('returns canCopyClipboard true when user has can_copy_clipboard', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithAllPerms), + }); + expect(result.current.canCopyClipboard).toBe(true); +}); + +test('returns canExportData false when user lacks can_export_data', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithoutExportPerms), + }); + expect(result.current.canExportData).toBe(false); +}); + +test('returns canExportImage false when user lacks can_export_image', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithoutExportPerms), + }); + expect(result.current.canExportImage).toBe(false); +}); + +test('canDownload uses can_export_data when GRANULAR_EXPORT_CONTROLS enabled', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithAllPerms), + }); + expect(result.current.canDownload).toBe(true); +}); + +test('canDownload uses can_csv when GRANULAR_EXPORT_CONTROLS disabled', () => { + isFeatureEnabled.mockReturnValue(false); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithLegacyCsvOnly), + }); + expect(result.current.canDownload).toBe(true); +}); + +test('canDownload false when GRANULAR_EXPORT_CONTROLS enabled but no can_export_data', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithoutExportPerms), + }); + expect(result.current.canDownload).toBe(false); +}); diff --git a/superset-frontend/src/hooks/usePermissions.ts b/superset-frontend/src/hooks/usePermissions.ts index 79636dfa3d2..0d961dc249e 100644 --- a/superset-frontend/src/hooks/usePermissions.ts +++ b/superset-frontend/src/hooks/usePermissions.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core'; import { useSelector } from 'react-redux'; import { RootState } from 'src/dashboard/types'; import { findPermission } from 'src/utils/findPermission'; @@ -30,9 +31,21 @@ export const usePermissions = () => { const canDatasourceSamples = useSelector((state: RootState) => findPermission('can_samples', 'Datasource', state.user?.roles), ); - const canDownload = useSelector((state: RootState) => + const canCsvLegacy = useSelector((state: RootState) => findPermission('can_csv', 'Superset', state.user?.roles), ); + const canExportData = useSelector((state: RootState) => + findPermission('can_export_data', 'Superset', state.user?.roles), + ); + const canExportImage = useSelector((state: RootState) => + findPermission('can_export_image', 'Superset', state.user?.roles), + ); + const canCopyClipboard = useSelector((state: RootState) => + findPermission('can_copy_clipboard', 'Superset', state.user?.roles), + ); + const canDownload = isFeatureEnabled(FeatureFlag.GranularExportControls) + ? canExportData + : canCsvLegacy; const canDrill = useSelector((state: RootState) => findPermission('can_drill', 'Dashboard', state.user?.roles), ); @@ -55,6 +68,9 @@ export const usePermissions = () => { canWriteExploreFormData, canDatasourceSamples, canDownload, + canExportData, + canExportImage, + canCopyClipboard, canDrill, canDrillBy, canDrillToDetail, diff --git a/superset/charts/data/api.py b/superset/charts/data/api.py index 56d92dbc978..707ce79f918 100644 --- a/superset/charts/data/api.py +++ b/superset/charts/data/api.py @@ -299,8 +299,9 @@ class ChartDataRestApi(ChartRestApi): @protect() @statsd_metrics @event_logger.log_this_with_context( - action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" - f".data_from_cache", + action=lambda self, *args, **kwargs: ( + f"{self.__class__.__name__}.data_from_cache" + ), log_to_statsd=False, ) def data_from_cache(self, cache_key: str) -> Response: @@ -405,7 +406,13 @@ class ChartDataRestApi(ChartRestApi): if result_format in ChartDataResultFormat.table_like(): # Verify user has permission to export file - if not security_manager.can_access("can_csv", "Superset"): + if is_feature_enabled("GRANULAR_EXPORT_CONTROLS"): + has_export_perm = security_manager.can_access( + "can_export_data", "Superset" + ) + else: + has_export_perm = security_manager.can_access("can_csv", "Superset") + if not has_export_perm: return self.response_403() if not result["queries"]: diff --git a/superset/config.py b/superset/config.py index 7477f7f4f9e..2216c325b37 100644 --- a/superset/config.py +++ b/superset/config.py @@ -562,6 +562,10 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = { # in addition to relative timeshifts (e.g., "1 day ago") # @lifecycle: development "DATE_RANGE_TIMESHIFTS_ENABLED": False, + # Enable granular export controls (can_export_data, can_export_image, + # can_copy_clipboard) instead of the single can_csv permission + # @lifecycle: development + "GRANULAR_EXPORT_CONTROLS": False, # Enables advanced data type support # @lifecycle: development "ENABLE_ADVANCED_DATA_TYPES": False, diff --git a/superset/migrations/versions/2026-03-02_12-00_a1b2c3d4e5f6_add_granular_export_permissions.py b/superset/migrations/versions/2026-03-02_12-00_a1b2c3d4e5f6_add_granular_export_permissions.py new file mode 100644 index 00000000000..37c207c99ee --- /dev/null +++ b/superset/migrations/versions/2026-03-02_12-00_a1b2c3d4e5f6_add_granular_export_permissions.py @@ -0,0 +1,76 @@ +# 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. +"""add granular export permissions + +Revision ID: a1b2c3d4e5f6 +Revises: 4b2a8c9d3e1f +Create Date: 2026-03-02 12:00:00.000000 + +""" + +# revision identifiers, used by Alembic. +revision = "a1b2c3d4e5f6" +down_revision = "4b2a8c9d3e1f" + +from alembic import op # noqa: E402 +from sqlalchemy.orm import Session # noqa: E402 + +from superset.migrations.shared.security_converge import ( # noqa: E402 + add_pvms, + get_reversed_new_pvms, + get_reversed_pvm_map, + migrate_roles, + Pvm, +) + +NEW_PVMS = { + "Superset": ( + "can_export_data", + "can_export_image", + "can_copy_clipboard", + ) +} + +PVM_MAP = { + Pvm("Superset", "can_csv"): ( + Pvm("Superset", "can_export_data"), + Pvm("Superset", "can_export_image"), + Pvm("Superset", "can_copy_clipboard"), + ), +} + + +def do_upgrade(session: Session) -> None: + add_pvms(session, NEW_PVMS) + migrate_roles(session, PVM_MAP) + + +def do_downgrade(session: Session) -> None: + add_pvms(session, get_reversed_new_pvms(PVM_MAP)) + migrate_roles(session, get_reversed_pvm_map(PVM_MAP)) + + +def upgrade(): + bind = op.get_bind() + session = Session(bind=bind) + do_upgrade(session) + + +def downgrade(): + bind = op.get_bind() + session = Session(bind=bind) + do_downgrade(session) diff --git a/superset/security/manager.py b/superset/security/manager.py index 9f97c810d77..208ffec8576 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -280,6 +280,11 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods "Datasource", } | READ_ONLY_MODEL_VIEWS + GAMMA_EXCLUDED_PVMS = { + ("can_export_data", "Superset"), + ("can_export_image", "Superset"), + } + ADMIN_ONLY_VIEW_MENUS = { "Access Requests", "Action Logs", @@ -396,6 +401,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods SQLLAB_EXTRA_PERMISSION_VIEWS = { ("can_csv", "Superset"), # Deprecated permission remove on 3.0.0 + ("can_export_data", "Superset"), + ("can_copy_clipboard", "Superset"), ("can_read", "Superset"), ("can_read", "Database"), } @@ -1195,6 +1202,9 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods self.add_permission_view_menu("all_database_access", "all_database_access") self.add_permission_view_menu("all_query_access", "all_query_access") self.add_permission_view_menu("can_csv", "Superset") + self.add_permission_view_menu("can_export_data", "Superset") + self.add_permission_view_menu("can_export_image", "Superset") + self.add_permission_view_menu("can_copy_clipboard", "Superset") self.add_permission_view_menu("can_share_dashboard", "Superset") self.add_permission_view_menu("can_share_chart", "Superset") self.add_permission_view_menu("can_sqllab", "Superset") @@ -1476,6 +1486,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods or self._is_admin_only(pvm) or self._is_alpha_only(pvm) or self._is_sql_lab_only(pvm) + or (pvm.permission.name, pvm.view_menu.name) in self.GAMMA_EXCLUDED_PVMS ) or self._is_accessible_to_all(pvm) def _is_sql_lab_only(self, pvm: PermissionView) -> bool: diff --git a/superset/views/core.py b/superset/views/core.py index 690c00bbefd..19e8f3cf1e2 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -489,7 +489,12 @@ class Superset(BaseSupersetView): # slc perms slice_add_perm = security_manager.can_access("can_write", "Chart") slice_overwrite_perm = security_manager.is_owner(slc) if slc else False - slice_download_perm = security_manager.can_access("can_csv", "Superset") + if is_feature_enabled("GRANULAR_EXPORT_CONTROLS"): + slice_download_perm = security_manager.can_access( + "can_export_data", "Superset" + ) + else: + slice_download_perm = security_manager.can_access("can_csv", "Superset") form_data["datasource"] = str(datasource_id) + "__" + cast(str, datasource_type) diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index 2307d522a16..e59dd417b57 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -1571,6 +1571,7 @@ class TestRolePermission(SupersetTestCase): sql_lab_set = get_perm_tuples("sql_lab") assert sql_lab_set == { ("can_activate", "TabStateView"), + ("can_copy_clipboard", "Superset"), ("can_csv", "Superset"), ("can_delete_query", "TabStateView"), ("can_delete", "TabStateView"), @@ -1578,6 +1579,7 @@ class TestRolePermission(SupersetTestCase): ("can_execute_sql_query", "SQLLab"), ("can_export", "SavedQuery"), ("can_export_csv", "SQLLab"), + ("can_export_data", "Superset"), ("can_format_sql", "SQLLab"), ("can_get", "TabStateView"), ("can_get_results", "SQLLab"), diff --git a/tests/unit_tests/security/test_granular_export_permissions.py b/tests/unit_tests/security/test_granular_export_permissions.py new file mode 100644 index 00000000000..6db5e9712b2 --- /dev/null +++ b/tests/unit_tests/security/test_granular_export_permissions.py @@ -0,0 +1,110 @@ +# 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. + +from unittest.mock import MagicMock, patch + +from superset.security.manager import SupersetSecurityManager + + +def test_granular_export_permissions_registered_in_create_custom_permissions( + app_context: None, +) -> None: + """Verify that create_custom_permissions registers all granular export perms.""" + from superset.extensions import appbuilder + + sm = SupersetSecurityManager(appbuilder) + sm.add_permission_view_menu = MagicMock() + + sm.create_custom_permissions() + + calls = [ + (call.args[0], call.args[1]) + for call in sm.add_permission_view_menu.call_args_list + ] + assert ("can_export_data", "Superset") in calls + assert ("can_export_image", "Superset") in calls + assert ("can_copy_clipboard", "Superset") in calls + + +def test_sqllab_extra_permission_views_include_export_perms() -> None: + """Verify SQLLAB_EXTRA_PERMISSION_VIEWS includes granular export perms.""" + assert ("can_export_data", "Superset") in ( + SupersetSecurityManager.SQLLAB_EXTRA_PERMISSION_VIEWS + ) + assert ("can_copy_clipboard", "Superset") in ( + SupersetSecurityManager.SQLLAB_EXTRA_PERMISSION_VIEWS + ) + + +def test_gamma_excluded_pvms_excludes_export_data_and_image() -> None: + """Verify GAMMA_EXCLUDED_PVMS excludes can_export_data and can_export_image.""" + assert ("can_export_data", "Superset") in ( + SupersetSecurityManager.GAMMA_EXCLUDED_PVMS + ) + assert ("can_export_image", "Superset") in ( + SupersetSecurityManager.GAMMA_EXCLUDED_PVMS + ) + + +def test_gamma_excluded_pvms_allows_copy_clipboard() -> None: + """Verify GAMMA_EXCLUDED_PVMS does NOT exclude can_copy_clipboard.""" + assert ("can_copy_clipboard", "Superset") not in ( + SupersetSecurityManager.GAMMA_EXCLUDED_PVMS + ) + + +def test_is_gamma_pvm_excludes_export_data(app_context: None) -> None: + """Verify _is_gamma_pvm returns False for can_export_data.""" + from superset.extensions import appbuilder + + sm = SupersetSecurityManager(appbuilder) + pvm = MagicMock() + pvm.permission.name = "can_export_data" + pvm.view_menu.name = "Superset" + + assert sm._is_gamma_pvm(pvm) is False + + +def test_is_gamma_pvm_excludes_export_image(app_context: None) -> None: + """Verify _is_gamma_pvm returns False for can_export_image.""" + from superset.extensions import appbuilder + + sm = SupersetSecurityManager(appbuilder) + pvm = MagicMock() + pvm.permission.name = "can_export_image" + pvm.view_menu.name = "Superset" + + assert sm._is_gamma_pvm(pvm) is False + + +def test_is_gamma_pvm_allows_copy_clipboard(app_context: None) -> None: + """Verify _is_gamma_pvm returns True for can_copy_clipboard.""" + from superset.extensions import appbuilder + + sm = SupersetSecurityManager(appbuilder) + pvm = MagicMock() + pvm.permission.name = "can_copy_clipboard" + pvm.view_menu.name = "Superset" + # Ensure the pvm doesn't trigger other exclusion checks + with ( + patch.object(sm, "_is_user_defined_permission", return_value=False), + patch.object(sm, "_is_admin_only", return_value=False), + patch.object(sm, "_is_alpha_only", return_value=False), + patch.object(sm, "_is_sql_lab_only", return_value=False), + patch.object(sm, "_is_accessible_to_all", return_value=False), + ): + assert sm._is_gamma_pvm(pvm) is True From 8e3e57c1c827331d2a048182eaca9ac8d4732998 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Mon, 9 Mar 2026 17:18:59 -0400 Subject: [PATCH 12/31] fix(docs): swizzle MethodEndpoint to fix SSG crash on all API pages (#38533) Co-authored-by: Superset Dev Co-authored-by: Claude Sonnet 4.6 --- .../ApiExplorer/MethodEndpoint/index.tsx | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/src/theme/ApiExplorer/MethodEndpoint/index.tsx diff --git a/docs/src/theme/ApiExplorer/MethodEndpoint/index.tsx b/docs/src/theme/ApiExplorer/MethodEndpoint/index.tsx new file mode 100644 index 00000000000..276331db547 --- /dev/null +++ b/docs/src/theme/ApiExplorer/MethodEndpoint/index.tsx @@ -0,0 +1,120 @@ +/** + * 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. + * + * Swizzled from docusaurus-theme-openapi-docs to fix SSG crash. + * + * The original component calls useTypedSelector (Redux) at the top level, + * which fails during static site generation because no Redux store is + * available. This version moves the hook into a browser-only child component + * so SSG can render the page without a store context. + */ + +import React from "react"; + +import BrowserOnly from "@docusaurus/BrowserOnly"; +import { useSelector } from "react-redux"; + +interface ServerVariable { + default?: string; +} + +interface ServerValue { + url: string; + variables?: Record; +} + +interface StoreState { + server: { value: ServerValue | null }; +} + +function colorForMethod(method: string) { + switch (method.toLowerCase()) { + case "get": + return "primary"; + case "post": + return "success"; + case "delete": + return "danger"; + case "put": + return "info"; + case "patch": + return "warning"; + case "head": + return "secondary"; + case "event": + return "secondary"; + default: + return undefined; + } +} + +export interface Props { + method: string; + path: string; + context?: "endpoint" | "callback"; +} + +// Inner component rendered only in the browser, where the Redux store exists. +function ServerUrl() { + const serverValue = useSelector((state: StoreState) => state.server.value); + + if (serverValue && serverValue.variables) { + let serverUrlWithVariables = serverValue.url.replace(/\/$/, ""); + Object.keys(serverValue.variables).forEach((variable) => { + serverUrlWithVariables = serverUrlWithVariables.replace( + `{${variable}}`, + serverValue.variables?.[variable].default ?? "" + ); + }); + return <>{serverUrlWithVariables}; + } + + if (serverValue && serverValue.url) { + return <>{serverValue.url}; + } + + return null; +} + +function MethodEndpoint({ method, path, context }: Props) { + const renderServerUrl = () => { + if (context === "callback") { + return ""; + } + return {() => }; + }; + + return ( + <> +
+        
+          {method === "event" ? "Webhook" : method.toUpperCase()}
+        {" "}
+        {method !== "event" && (
+          

+ {renderServerUrl()} + {`${path.replace(/{([a-z0-9-_]+)}/gi, ":$1")}`} +

+ )} +
+
+ + ); +} + +export default MethodEndpoint; From 4cd3ce164de6fff538028b8449c9649687d1c82c Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Mon, 9 Mar 2026 23:32:27 +0100 Subject: [PATCH 13/31] fix(mcp): make fastmcp truly optional during Superset startup (#38534) --- superset/core/mcp/core_mcp_injection.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/superset/core/mcp/core_mcp_injection.py b/superset/core/mcp/core_mcp_injection.py index f37ae6e48f1..ad4a7b1211d 100644 --- a/superset/core/mcp/core_mcp_injection.py +++ b/superset/core/mcp/core_mcp_injection.py @@ -251,23 +251,28 @@ def initialize_core_mcp_dependencies() -> None: Also imports MCP service app to register all host tools BEFORE extension loading. """ + import superset_core.mcp.decorators + try: - # Replace the abstract decorators with concrete implementations + from fastmcp.tools import Tool # noqa: F401 + except ImportError: + logger.info( + "fastmcp is not installed, skipping MCP initialization. " + "Install it with: pip install 'apache-superset[fastmcp]'" + ) + return - import superset_core.mcp.decorators + # Replace the abstract decorators with concrete implementations + superset_core.mcp.decorators.tool = create_tool_decorator + superset_core.mcp.decorators.prompt = create_prompt_decorator - superset_core.mcp.decorators.tool = create_tool_decorator - superset_core.mcp.decorators.prompt = create_prompt_decorator - - logger.info("MCP dependency injection initialized successfully") + logger.info("MCP dependency injection initialized successfully") + try: # Import MCP service app to register host tools BEFORE extension loading # This prevents host tools from being registered during extension context - from superset.mcp_service import app # noqa: F401 logger.info("MCP service app imported - host tools registered") - except Exception as e: - logger.error("Failed to initialize MCP dependencies: %s", e) - raise + logger.error("Failed to register MCP host tools: %s", e) From 6ef4794778b4543c09acd7e1a4db57809e5b7238 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Mon, 9 Mar 2026 23:35:50 +0100 Subject: [PATCH 14/31] fix(mcp): resolve chatbot tool call flakiness with URL and instruction fixes (#38532) --- superset/mcp_service/app.py | 14 ++++++++++++++ .../sql_lab/tool/open_sql_lab_with_context.py | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py index dd48c0a6766..a26167151f0 100644 --- a/superset/mcp_service/app.py +++ b/superset/mcp_service/app.py @@ -142,6 +142,20 @@ Query Examples: - My dashboards: filters=[{{"col": "created_by_fk", "opr": "eq", "value": }}] +To modify an existing chart (add filters, change metrics, change dimensions, etc.): +1. get_chart_info(chart_id) -> examine current configuration +2. update_chart(chart_id, config) -> apply changes (filters, metrics, dimensions) +Do NOT use execute_sql for chart modifications. Use update_chart instead. + +CRITICAL RULES - NEVER VIOLATE: +- NEVER fabricate or invent URLs. ALL URLs must come from tool call results. + If you need a link, call the appropriate tool (generate_explore_link, generate_chart, + open_sql_lab_with_context, etc.) and use the URL it returns. +- To modify an existing chart's filters, metrics, or dimensions, use update_chart. + Do NOT use execute_sql for chart modifications. +- Parameter name reminders: open_sql_lab_with_context uses "sql" (not "query"), + execute_sql uses "sql" (not "query"). + General usage tips: - All listing tools use 1-based pagination (first page is 1) - Use get_schema to discover filterable columns, sortable columns, and default columns diff --git a/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py b/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py index cfedc72bd93..d27b970d609 100644 --- a/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py +++ b/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py @@ -33,6 +33,7 @@ from superset.mcp_service.sql_lab.schemas import ( SqlLabResponse, ) from superset.mcp_service.utils.schema_utils import parse_request +from superset.mcp_service.utils.url_utils import get_superset_base_url logger = logging.getLogger(__name__) @@ -95,7 +96,7 @@ def open_sql_lab_with_context( # Construct SQL Lab URL query_string = urlencode(params) - url = f"/sqllab?{query_string}" + url = f"{get_superset_base_url()}/sqllab?{query_string}" logger.info( "Generated SQL Lab URL for database %s", request.database_connection_id From a17f38a4e237179c2ea2e8d9994b94134530301b Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:08:37 +0100 Subject: [PATCH 15/31] fix(embedded): add CurrentUserRestApi read permission to Public role defaults (#38474) --- superset/security/manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/superset/security/manager.py b/superset/security/manager.py index 208ffec8576..2e2ca65017c 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -431,6 +431,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods ("can_read", "Theme"), # Embedded dashboard support ("can_read", "EmbeddedDashboard"), + ("can_read", "CurrentUserRestApi"), # Datasource metadata for chart rendering ("can_get", "Datasource"), ("can_external_metadata", "Datasource"), From 06fd0658ae794a7527946c6dd41ff67909422295 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:46:01 +0700 Subject: [PATCH 16/31] chore(deps-dev): bump prettier-plugin-packagejson from 3.0.0 to 3.0.2 in /superset-frontend (#38508) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Evan Rusackas --- superset-frontend/package-lock.json | 10 +++++----- superset-frontend/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index b0dd8471377..4ad586d551c 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -267,7 +267,7 @@ "oxlint": "^1.51.0", "po2json": "^0.4.5", "prettier": "3.8.1", - "prettier-plugin-packagejson": "^3.0.0", + "prettier-plugin-packagejson": "^3.0.2", "process": "^0.11.10", "react-refresh": "^0.18.0", "react-resizable": "^3.1.3", @@ -40323,13 +40323,13 @@ } }, "node_modules/prettier-plugin-packagejson": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-packagejson/-/prettier-plugin-packagejson-3.0.0.tgz", - "integrity": "sha512-z8/QmPSqx/ANvvQMWJSkSq1+ihBXeuwDEYdjX3ZjRJ5Ty1k7vGbFQfhzk2eDe0rwS/TNyRjWK/qnjJEStAOtDw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-packagejson/-/prettier-plugin-packagejson-3.0.2.tgz", + "integrity": "sha512-kmoj3hEynXwoHDo8ZhmWAIjRBoQWCDUVackiWfSDWdgD0rS3LGB61T9zoVbume/cotYdCoadUh4sqViAmXvpBQ==", "dev": true, "license": "MIT", "dependencies": { - "sort-package-json": "3.6.0" + "sort-package-json": "^3.6.0" }, "peerDependencies": { "prettier": "^3" diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 02891687cae..4c1634f03f7 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -348,7 +348,7 @@ "oxlint": "^1.51.0", "po2json": "^0.4.5", "prettier": "3.8.1", - "prettier-plugin-packagejson": "^3.0.0", + "prettier-plugin-packagejson": "^3.0.2", "process": "^0.11.10", "react-refresh": "^0.18.0", "react-resizable": "^3.1.3", From 6c1df93215f5c6786dc7d87023441c3f62055e19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:46:19 +0700 Subject: [PATCH 17/31] chore(deps): bump aquasecurity/trivy-action from 0.34.2 to 0.35.0 (#38502) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Evan Rusackas --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e17da498eeb..94a8de67ccf 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -104,7 +104,7 @@ jobs: # Scan for vulnerabilities in built container image after pushes to mainline branch. - name: Run Trivy container image vulnerabity scan if: github.event_name == 'push' && github.ref == 'refs/heads/master' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker) && matrix.build_preset == 'lean' - uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # v0.34.2 + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 with: image-ref: ${{ env.IMAGE_TAG }} format: 'sarif' From 9ecca47e6930746e3885b627c20cdd2194ff3c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BB=97=20Tr=E1=BB=8Dng=20H=E1=BA=A3i?= <41283691+hainenber@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:49:38 +0700 Subject: [PATCH 18/31] feat(ci): only run precommit on changed files (#38155) Signed-off-by: hainenber --- .github/workflows/pre-commit.yml | 10 ++++++++-- superset-frontend/oxlint.json | 3 +-- .../FiltersConfigModal/FilterTitleContainer.tsx | 1 + .../FiltersConfigModal/ItemTitleContainer.tsx | 1 + 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 06eb98116bb..4af4e743536 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -64,11 +64,17 @@ jobs: restore-keys: | pre-commit-v2-${{ runner.os }}-py${{ matrix.python-version }}- + - name: Get changed files + id: changed_files + uses: ./.github/actions/file-changes-action + with: + output: ' ' + - name: pre-commit run: | set +e # Don't exit immediately on failure - export SKIP=eslint-frontend,type-checking-frontend - pre-commit run --all-files + export SKIP=type-checking-frontend + pre-commit run --files ${{ steps.changed_files.outputs.files }} PRE_COMMIT_EXIT_CODE=$? git diff --quiet --exit-code GIT_DIFF_EXIT_CODE=$? diff --git a/superset-frontend/oxlint.json b/superset-frontend/oxlint.json index a77625cd0d1..fb4401c4bab 100644 --- a/superset-frontend/oxlint.json +++ b/superset-frontend/oxlint.json @@ -237,8 +237,7 @@ "jsx-a11y/no-noninteractive-tabindex": "error", "jsx-a11y/no-redundant-roles": "error", "jsx-a11y/no-static-element-interactions": "off", - // TODO: Fix missing aria-selected on tab roles - "jsx-a11y/role-has-required-aria-props": "warn", + "jsx-a11y/role-has-required-aria-props": "error", "jsx-a11y/role-supports-aria-props": "error", "jsx-a11y/scope": "error", "jsx-a11y/tabindex-no-positive": "error", diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx index 343be64c6f3..b04a63ecf6d 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx @@ -168,6 +168,7 @@ const FilterTitleContainer = forwardRef( key={`filter-title-tab-${id}`} onClick={() => onChange(id)} className={classNames.join(' ')} + aria-selected={isActive} >
( key={`item-title-tab-${id}`} onClick={() => onChange(id)} className={classNames.join(' ')} + aria-selected={isActive} >
Date: Tue, 10 Mar 2026 08:54:43 +0700 Subject: [PATCH 19/31] chore(deps-dev): bump eslint-plugin-react-you-might-not-need-an-effect from 0.9.1 to 0.9.2 in /superset-frontend (#38509) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Evan Rusackas --- superset-frontend/package-lock.json | 8 ++++---- superset-frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 4ad586d551c..1ee17415f39 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -244,7 +244,7 @@ "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-react-prefer-function-component": "^5.0.0", - "eslint-plugin-react-you-might-not-need-an-effect": "^0.9.1", + "eslint-plugin-react-you-might-not-need-an-effect": "^0.9.2", "eslint-plugin-storybook": "^0.8.0", "eslint-plugin-testing-library": "^7.16.0", "eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors", @@ -24406,9 +24406,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-react-you-might-not-need-an-effect": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.9.1.tgz", - "integrity": "sha512-P6Zq5hAKc10Zg5V6vusm3+WZc59OHqHeCTTH2soW3r9fBmeJssuHzltIEKtrONXqFwHVWw3bz2qGlYuO2rBk1g==", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.9.2.tgz", + "integrity": "sha512-VplJMf2kAYI4bF1KSCOygQ9BHzOqM/0P3cqpnBTylnSVv9aNxVrz2RDMs8bKJtITcp2CV9kuAUkzjUP0zgxbSw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 4c1634f03f7..84755457334 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -325,7 +325,7 @@ "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-react-prefer-function-component": "^5.0.0", - "eslint-plugin-react-you-might-not-need-an-effect": "^0.9.1", + "eslint-plugin-react-you-might-not-need-an-effect": "^0.9.2", "eslint-plugin-storybook": "^0.8.0", "eslint-plugin-testing-library": "^7.16.0", "eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors", From afa51125de43e9710eea1deebd0970fb93d7df17 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:09:40 +0700 Subject: [PATCH 20/31] chore(deps): bump the storybook group in /docs with 11 updates (#38501) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Evan Rusackas --- docs/package.json | 18 ++++---- docs/yarn.lock | 114 +++++++++++++++++++++++----------------------- 2 files changed, 66 insertions(+), 66 deletions(-) diff --git a/docs/package.json b/docs/package.json index e22ebe82b6e..53824fcc40b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -55,16 +55,16 @@ "@fontsource/inter": "^5.2.8", "@mdx-js/react": "^3.1.1", "@saucelabs/theme-github-codeblock": "^0.3.0", - "@storybook/addon-docs": "^8.6.17", + "@storybook/addon-docs": "^8.6.18", "@storybook/blocks": "^8.6.15", - "@storybook/channels": "^8.6.17", - "@storybook/client-logger": "^8.6.17", - "@storybook/components": "^8.6.17", - "@storybook/core": "^8.6.17", - "@storybook/core-events": "^8.6.17", + "@storybook/channels": "^8.6.18", + "@storybook/client-logger": "^8.6.18", + "@storybook/components": "^8.6.18", + "@storybook/core": "^8.6.18", + "@storybook/core-events": "^8.6.18", "@storybook/csf": "^0.1.13", - "@storybook/docs-tools": "^8.6.17", - "@storybook/preview-api": "^8.6.17", + "@storybook/docs-tools": "^8.6.18", + "@storybook/preview-api": "^8.6.18", "@storybook/theming": "^8.6.15", "@superset-ui/core": "^0.20.4", "@swc/core": "^1.15.17", @@ -85,7 +85,7 @@ "react-table": "^7.8.0", "remark-import-partial": "^0.0.2", "reselect": "^5.1.1", - "storybook": "^8.6.17", + "storybook": "^8.6.18", "swagger-ui-react": "^5.32.0", "swc-loader": "^0.2.7", "tinycolor2": "^1.4.2", diff --git a/docs/yarn.lock b/docs/yarn.lock index 49cd2412690..74abfb32ab5 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -3524,53 +3524,53 @@ resolved "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz" integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g== -"@storybook/addon-docs@^8.6.17": - version "8.6.17" - resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-8.6.17.tgz#3b7fdccebb60bcde62241a2ef2c9e493003003d5" - integrity sha512-zvcSzoYvaZO4l9NxsviDr5vmuq8GVnH4Ap0v+5sSTq192yevm/iQcRnkWYBD9E/Lg5GBeyE+Ml2vjEOK+EPBEg== +"@storybook/addon-docs@^8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-8.6.18.tgz#1910942ecdff4e5cda6352d22bc483f0c2058f61" + integrity sha512-55ADer0yNmmeR928Y3UAv3r4i7bJSd9LwywsQ+lRol/FNe0ZcwLEz31xL+jVsqQFNnDh/imsDIp8aYapGMtfEQ== dependencies: "@mdx-js/react" "^3.0.0" - "@storybook/blocks" "8.6.17" - "@storybook/csf-plugin" "8.6.17" - "@storybook/react-dom-shim" "8.6.17" + "@storybook/blocks" "8.6.18" + "@storybook/csf-plugin" "8.6.18" + "@storybook/react-dom-shim" "8.6.18" react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" ts-dedent "^2.0.0" -"@storybook/blocks@8.6.17", "@storybook/blocks@^8.6.15": - version "8.6.17" - resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-8.6.17.tgz#153a9e5ce2b1f2e769f2d095208a303266a85823" - integrity sha512-zuYHH+0egovMrjWRKwOtgVGbz6KALGowPSWBzQ8deTBu6IXfkz6Ce1hRLJPn5S6/jDqqr9xx8vuAiypnRQ98tA== +"@storybook/blocks@8.6.18", "@storybook/blocks@^8.6.15": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-8.6.18.tgz#d1bf7e9639a86cdf690bea1c53028be725afb1e8" + integrity sha512-esZv4msPQ9LxgTb8YUIZhhxVMuI6BPi5bkXtk8c7w7sWuAsqsCe/RnVInn7ooUry2gjnD4hd9+8Eqj0b8oTVoA== dependencies: "@storybook/icons" "^1.2.12" ts-dedent "^2.0.0" -"@storybook/channels@^8.6.17": - version "8.6.17" - resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-8.6.17.tgz#074930ccfc9ce4a6d798f274819b70d2852f0fbe" - integrity sha512-3uwPYVia6MdyeTI2oq46ybpFIZCCjohvzI7zn6NmnRqC8WvZapngLY6OT590eFCrFdgxMszKORUvSsPgtjpnuA== +"@storybook/channels@^8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-8.6.18.tgz#21bf4624badc41f343ac7e182ba7a88c5d682bff" + integrity sha512-J/xabOEHfMYEWpdm4gR6HD5IdC0e7OsNvgUEspQjcUMhjMwtGm/EaahwNpRUIxO2tgzKj4zHnflGfPCfTd4PgQ== -"@storybook/client-logger@^8.6.17": - version "8.6.17" - resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-8.6.17.tgz#43decc0f507dd7daf9310994fd612b25fc6915a5" - integrity sha512-l8vbDNyyR9YfWZzlsupxEeekA/eq4iibBo3gWwr+2G5QfNTGveTQdpgr2m5IL5k+Xjnii22AepmQ4NdjPbJXwA== +"@storybook/client-logger@^8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-8.6.18.tgz#21b95c5ecb30475ad5a1fa68c0af603a4199c01b" + integrity sha512-l7x3KkumMcTN+R1ozAqEyAkHpNBonIvicYoTgha/3Dh/tKiBYLLum2AEXbiu0TBJ7EEUfi4AG7eOBBfVdfWqvQ== -"@storybook/components@^8.6.17": - version "8.6.17" - resolved "https://registry.yarnpkg.com/@storybook/components/-/components-8.6.17.tgz#67c87f4a5b98999c81f17418cbe2396e6dd216f1" - integrity sha512-0b8xkkuPCNbM8LTOzyfxuo2KdJCHIfu3+QxWBFllXap0eYNHwVeSxE5KERQ/bk2GDCiRzaUbwH9PeLorxOzJJQ== +"@storybook/components@^8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/components/-/components-8.6.18.tgz#0e5431f9d84cae29a8b8a406c9ad99406bf2ccb4" + integrity sha512-55yViiZzPS/cPBuOeW4QGxGqrusjXVyxuknmbYCIwDtFyyvI/CgbjXRHdxNBaIjz+IlftxvBmmSaOqFG5+/dkA== -"@storybook/core-events@^8.6.17": - version "8.6.17" - resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-8.6.17.tgz#e67d6308e61cd6d7be574c40c605eafe7bb04c74" - integrity sha512-HiKVE2sSbJF6PVFt2DfJtLef1Mc35cN+sf2f8Ay2ibHy2gY1t3/7W1PhYVGt7UpJNOnVZfsmcE3yqGNojct3mw== +"@storybook/core-events@^8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-8.6.18.tgz#aaaf2a544fdb07036a08200692bb88a96d9df651" + integrity sha512-eUVwrcppny/ZYyke/SPVZVuco8wxkQ/0K20nlevSiDkgWZSELii5Ju0/l9Ubnopr9dshoFCYbC7q6liTSpok7A== -"@storybook/core@8.6.17", "@storybook/core@^8.6.17": - version "8.6.17" - resolved "https://registry.yarnpkg.com/@storybook/core/-/core-8.6.17.tgz#73af480521333e421413ffdda7a992b3c96b1afb" - integrity sha512-lndZDYIvUddWk54HmgYwE4h2B0JtWt8ztIRAzHRt6ReZZ9QQbmM5b85Qpa+ng4dyQEKc2JAtYD3Du7RRFcpHlw== +"@storybook/core@8.6.18", "@storybook/core@^8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/core/-/core-8.6.18.tgz#0ddbec8421715b372419ae5dfefef3df5848386c" + integrity sha512-dRBP2TnX6fGdS0T2mXBHjkS/3Nlu1ra1huovZVFuM67CYMzrhM/3hX/zru1vWSC5rqY93ZaAhjMciPW4pK5mMQ== dependencies: - "@storybook/theming" "8.6.17" + "@storybook/theming" "8.6.18" better-opn "^3.0.2" browser-assert "^1.2.1" esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0" @@ -3582,10 +3582,10 @@ util "^0.12.5" ws "^8.2.3" -"@storybook/csf-plugin@8.6.17": - version "8.6.17" - resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-8.6.17.tgz#004e25cd408d98a1514d0bf83e02f270c87a2091" - integrity sha512-ouvF/izbKclZxpfnRUkyC5ZVDU7QA0cHhjQnXTDT4F8b0uciQUDw1LosDZy5MXf03BeIDdyBAtzd/ym3wzd+kw== +"@storybook/csf-plugin@8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-8.6.18.tgz#f92cede49c71d4381187884d72e41ee44d324d3b" + integrity sha512-x1ioz/L0CwaelCkHci3P31YtvwayN3FBftvwQOPbvRh9qeb4Cpz5IdVDmyvSxxYwXN66uAORNoqgjTi7B4/y5Q== dependencies: unplugin "^1.3.1" @@ -3596,30 +3596,30 @@ dependencies: type-fest "^2.19.0" -"@storybook/docs-tools@^8.6.17": - version "8.6.17" - resolved "https://registry.yarnpkg.com/@storybook/docs-tools/-/docs-tools-8.6.17.tgz#e0beafce8ad36dbadf2e0c3a6bb39ee50ead8c03" - integrity sha512-lnGPEecD2nNrByIGhlJOJEi4/3PM+P5DElsFdJ9EhQwO0rwQhTL+4sdBMOXgwsJj4WrQTBXQ1jr/x0UYrl7Qzg== +"@storybook/docs-tools@^8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/docs-tools/-/docs-tools-8.6.18.tgz#ba79b08a41131f97d9c6970c48651552763acbcf" + integrity sha512-43ggjDA1ZV0FWjMlNBkKC1VWQ6zDQmSj0WWWqivGQdnBt4dufYQFXnbQeFr9Og+3OjZYmr3KTrLCjDiyCGOgjg== "@storybook/icons@^1.2.12": version "1.4.0" resolved "https://registry.npmjs.org/@storybook/icons/-/icons-1.4.0.tgz" integrity sha512-Td73IeJxOyalzvjQL+JXx72jlIYHgs+REaHiREOqfpo3A2AYYG71AUbcv+lg7mEDIweKVCxsMQ0UKo634c8XeA== -"@storybook/preview-api@^8.6.17": - version "8.6.17" - resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-8.6.17.tgz#92fa66f495c520074c88c3373be73e57f2803a5c" - integrity sha512-vpTCTkw11wXerYnlG5Q0y4SbFqG9O6GhR0hlYgCn3Z9kcHlNjK/xuwd3h4CvwNXxRNWZGT8qYYCLn5gSSrX6fA== +"@storybook/preview-api@^8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-8.6.18.tgz#2f5eb75c7587035a07670457c09b67208aa16735" + integrity sha512-joXRXh3GdVvzhbfIgmix1xs90p8Q/nja7AhEAC2egn5Pl7SKsIYZUCYI6UdrQANb2myg9P552LKXfPect8llKg== -"@storybook/react-dom-shim@8.6.17": - version "8.6.17" - resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-8.6.17.tgz#68a2e279ac2ce2e37d3f7331a16c5b46cc1c5659" - integrity sha512-bHLsR9b/tiwm9lXbN8kp9XlOgkRXeg84UFwXaWBPu3pOO7vRXukk23SQUpLW+HhjKtCJ3xClSi5uMpse5MpkVQ== +"@storybook/react-dom-shim@8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-8.6.18.tgz#34bdc010d3c3572fc74fa149f754d185df85044e" + integrity sha512-N4xULcAWZQTUv4jy1/d346Tyb4gufuC3UaLCuU/iVSZ1brYF4OW3ANr+096btbMxY8pR/65lmtoqr5CTGwnBvA== -"@storybook/theming@8.6.17", "@storybook/theming@^8.6.15": - version "8.6.17" - resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.6.17.tgz#0175bbc22cdc262d171168af67fce6a5e3d76a7f" - integrity sha512-IttFvRqozpuzN5MlQEWGOzUA2rZg86688Dyv1d+bjpYcFHtY1X4XyTCGwv1BPTaTsB959oM8R2yoNYWQkABbBA== +"@storybook/theming@8.6.18", "@storybook/theming@^8.6.15": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.6.18.tgz#18c66263868bfb00a419772b5460a5714c5e1181" + integrity sha512-n6OEjEtHupa2PdTwWzRepr7cO8NkDd4rgF6BKLitRbujOspLxzMBEqdphs+QLcuiCIgf33SqmEA64QWnbSMhPw== "@superset-ui/core@^0.20.4": version "0.20.4" @@ -14327,12 +14327,12 @@ stop-iteration-iterator@^1.1.0: es-errors "^1.3.0" internal-slot "^1.1.0" -storybook@^8.6.17: - version "8.6.17" - resolved "https://registry.yarnpkg.com/storybook/-/storybook-8.6.17.tgz#56299bf9e58622bb834fb100eac89c15f7d0de98" - integrity sha512-krR/l680A6qVnkGiK9p8jY0ucX3+kFCs2f4zw+S3w2Cdq8EiM/tFebPcX2V4S3z2UsO0v0dwAJOJNpzbFPdmVg== +storybook@^8.6.18: + version "8.6.18" + resolved "https://registry.yarnpkg.com/storybook/-/storybook-8.6.18.tgz#2a635a4b0c99693f43ba21b8eb511c5cc513a807" + integrity sha512-p8seiSI6FiVY6P3V0pG+5v7c8pDMehMAFRWEhG5XqIBSQszzOjDnW2rNvm3odoLKfo3V3P6Cs6Hv9ILzymULyQ== dependencies: - "@storybook/core" "8.6.17" + "@storybook/core" "8.6.18" string-convert@^0.2.0: version "0.2.1" From 06d6b513cde779599ffded3ec0386c3634530e20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:39:05 +0700 Subject: [PATCH 21/31] chore(deps-dev): bump jest from 30.2.0 to 30.3.0 in /superset-frontend (#38549) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 1601 ++++++++--------- superset-frontend/package.json | 2 +- .../packages/generator-superset/package.json | 2 +- .../plugin-chart-handlebars/package.json | 2 +- .../plugin-chart-pivot-table/package.json | 2 +- 5 files changed, 798 insertions(+), 811 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 1ee17415f39..a6cf3d1614f 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -254,7 +254,7 @@ "html-webpack-plugin": "^5.6.6", "http-server": "^14.1.1", "imports-loader": "^5.0.0", - "jest": "^30.2.0", + "jest": "^30.3.0", "jest-environment-jsdom": "^29.7.0", "jest-html-reporter": "^4.3.0", "jest-websocket-mock": "^2.5.0", @@ -4435,17 +4435,17 @@ } }, "node_modules/@jest/console": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", - "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", + "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -4466,9 +4466,9 @@ } }, "node_modules/@jest/console/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -4485,9 +4485,9 @@ } }, "node_modules/@jest/console/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -4509,19 +4509,19 @@ } }, "node_modules/@jest/console/node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -4530,18 +4530,18 @@ } }, "node_modules/@jest/console/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -4561,9 +4561,9 @@ } }, "node_modules/@jest/console/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4599,39 +4599,38 @@ } }, "node_modules/@jest/core": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", - "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", + "@jest/console": "30.3.0", "@jest/pattern": "30.0.1", - "@jest/reporters": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/reporters": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.2.0", - "jest-config": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-resolve-dependencies": "30.2.0", - "jest-runner": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "jest-watcher": "30.2.0", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -4660,9 +4659,9 @@ } }, "node_modules/@jest/core/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -4679,9 +4678,9 @@ } }, "node_modules/@jest/core/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -4703,19 +4702,19 @@ } }, "node_modules/@jest/core/node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -4724,18 +4723,18 @@ } }, "node_modules/@jest/core/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -4755,9 +4754,9 @@ } }, "node_modules/@jest/core/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4888,14 +4887,14 @@ } }, "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" + "expect": "30.3.0", + "jest-snapshot": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -4914,10 +4913,20 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/expect/node_modules/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/expect/node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", "dev": true, "license": "MIT", "dependencies": { @@ -4941,9 +4950,9 @@ } }, "node_modules/@jest/expect/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -4960,9 +4969,9 @@ } }, "node_modules/@jest/expect/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -4984,69 +4993,69 @@ } }, "node_modules/@jest/expect/node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect/node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", + "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect/node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect/node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -5055,33 +5064,33 @@ } }, "node_modules/@jest/expect/node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -5101,9 +5110,9 @@ } }, "node_modules/@jest/expect/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5167,50 +5176,50 @@ } }, "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/types": "30.3.0", + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/globals/node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0" + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/globals/node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -5230,9 +5239,9 @@ } }, "node_modules/@jest/globals/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -5249,16 +5258,16 @@ } }, "node_modules/@jest/globals/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, "node_modules/@jest/globals/node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", + "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5283,19 +5292,19 @@ } }, "node_modules/@jest/globals/node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -5304,33 +5313,33 @@ } }, "node_modules/@jest/globals/node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/globals/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -5350,9 +5359,9 @@ } }, "node_modules/@jest/globals/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5402,32 +5411,32 @@ } }, "node_modules/@jest/reporters": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", - "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", + "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -5458,9 +5467,9 @@ } }, "node_modules/@jest/reporters/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -5477,9 +5486,9 @@ } }, "node_modules/@jest/reporters/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -5560,19 +5569,19 @@ } }, "node_modules/@jest/reporters/node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -5581,33 +5590,33 @@ } }, "node_modules/@jest/reporters/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/reporters/node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -5661,9 +5670,9 @@ } }, "node_modules/@jest/reporters/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5712,13 +5721,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", + "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -5741,9 +5750,9 @@ } }, "node_modules/@jest/snapshot-utils/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -5760,9 +5769,9 @@ } }, "node_modules/@jest/snapshot-utils/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -5799,14 +5808,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", - "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", + "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/types": "30.3.0", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -5828,9 +5837,9 @@ } }, "node_modules/@jest/test-result/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -5847,9 +5856,9 @@ } }, "node_modules/@jest/test-result/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -5871,15 +5880,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", - "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", + "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", + "@jest/test-result": "30.3.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -5897,24 +5906,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", + "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", + "jest-util": "30.3.0", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -5937,9 +5945,9 @@ } }, "node_modules/@jest/transform/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -5956,9 +5964,9 @@ } }, "node_modules/@jest/transform/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -5980,18 +5988,18 @@ } }, "node_modules/@jest/transform/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -18104,16 +18112,16 @@ "license": "Apache-2.0" }, "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", + "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.2.0", + "@jest/transform": "30.3.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", + "babel-preset-jest": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -18200,9 +18208,9 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", + "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", "dev": true, "license": "MIT", "dependencies": { @@ -18356,13 +18364,13 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", + "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", + "babel-plugin-jest-hoist": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { @@ -24382,22 +24390,6 @@ } } }, - "node_modules/eslint-plugin-prettier/node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.9" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, "node_modules/eslint-plugin-react-prefer-function-component": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-prefer-function-component/-/eslint-plugin-react-prefer-function-component-5.0.0.tgz", @@ -30312,16 +30304,16 @@ "license": "MIT" }, "node_modules/jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", + "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.3.0", + "@jest/types": "30.3.0", "import-local": "^3.2.0", - "jest-cli": "30.2.0" + "jest-cli": "30.3.0" }, "bin": { "jest": "bin/jest.js" @@ -30339,14 +30331,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", + "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "p-limit": "^3.1.0" }, "engines": { @@ -30367,9 +30359,9 @@ } }, "node_modules/jest-changed-files/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -30386,9 +30378,9 @@ } }, "node_modules/jest-changed-files/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -30410,18 +30402,18 @@ } }, "node_modules/jest-changed-files/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -30441,29 +30433,29 @@ } }, "node_modules/jest-circus": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", + "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-each": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", "p-limit": "^3.1.0", - "pretty-format": "30.2.0", + "pretty-format": "30.3.0", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -30472,35 +30464,45 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-circus/node_modules/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-circus/node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0" + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-circus/node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -30520,9 +30522,9 @@ } }, "node_modules/jest-circus/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -30539,16 +30541,16 @@ } }, "node_modules/jest-circus/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, "node_modules/jest-circus/node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", + "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -30573,9 +30575,9 @@ } }, "node_modules/jest-circus/node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -30588,51 +30590,51 @@ } }, "node_modules/jest-circus/node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", + "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-circus/node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-circus/node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -30641,33 +30643,33 @@ } }, "node_modules/jest-circus/node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-circus/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -30687,9 +30689,9 @@ } }, "node_modules/jest-circus/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -30725,21 +30727,21 @@ } }, "node_modules/jest-cli": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", + "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-config": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "yargs": "^17.7.2" }, "bin": { @@ -30771,9 +30773,9 @@ } }, "node_modules/jest-cli/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -30790,9 +30792,9 @@ } }, "node_modules/jest-cli/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -30814,18 +30816,18 @@ } }, "node_modules/jest-cli/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -30845,34 +30847,33 @@ } }, "node_modules/jest-config": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", + "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.2.0", - "@jest/types": "30.2.0", - "babel-jest": "30.2.0", + "@jest/test-sequencer": "30.3.0", + "@jest/types": "30.3.0", + "babel-jest": "30.3.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", + "jest-circus": "30.3.0", "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", + "jest-environment-node": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", + "jest-resolve": "30.3.0", + "jest-runner": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "parse-json": "^5.2.0", - "pretty-format": "30.2.0", + "pretty-format": "30.3.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -30910,9 +30911,9 @@ } }, "node_modules/jest-config/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -30929,33 +30930,20 @@ } }, "node_modules/jest-config/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, - "node_modules/jest-config/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/jest-config/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" + "balanced-match": "^1.0.0" } }, "node_modules/jest-config/node_modules/chalk": { @@ -30979,6 +30967,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -30997,31 +30986,31 @@ } }, "node_modules/jest-config/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-config/node_modules/minimatch": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", - "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -31044,9 +31033,9 @@ } }, "node_modules/jest-config/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -31156,17 +31145,17 @@ } }, "node_modules/jest-each": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", + "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", - "jest-util": "30.2.0", - "pretty-format": "30.2.0" + "jest-util": "30.3.0", + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -31186,9 +31175,9 @@ } }, "node_modules/jest-each/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -31205,9 +31194,9 @@ } }, "node_modules/jest-each/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -31229,18 +31218,18 @@ } }, "node_modules/jest-each/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -31260,9 +31249,9 @@ } }, "node_modules/jest-each/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -31577,53 +31566,53 @@ } }, "node_modules/jest-environment-node": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", + "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0" + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node/node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0" + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node/node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -31643,9 +31632,9 @@ } }, "node_modules/jest-environment-node/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -31662,16 +31651,16 @@ } }, "node_modules/jest-environment-node/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, "node_modules/jest-environment-node/node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", + "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -31696,19 +31685,19 @@ } }, "node_modules/jest-environment-node/node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -31717,33 +31706,33 @@ } }, "node_modules/jest-environment-node/node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -31763,9 +31752,9 @@ } }, "node_modules/jest-environment-node/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -31811,21 +31800,21 @@ } }, "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", + "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", "walker": "^1.0.8" }, "engines": { @@ -31849,9 +31838,9 @@ } }, "node_modules/jest-haste-map/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -31868,9 +31857,9 @@ } }, "node_modules/jest-haste-map/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -31892,33 +31881,33 @@ } }, "node_modules/jest-haste-map/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map/node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -32087,14 +32076,14 @@ } }, "node_modules/jest-leak-detector": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", - "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", + "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -32114,9 +32103,9 @@ } }, "node_modules/jest-leak-detector/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -32134,9 +32123,9 @@ } }, "node_modules/jest-leak-detector/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -32427,18 +32416,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", + "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -32447,14 +32436,14 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", + "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", "dev": true, "license": "MIT", "dependencies": { "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" + "jest-snapshot": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -32474,9 +32463,9 @@ } }, "node_modules/jest-resolve/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -32493,9 +32482,9 @@ } }, "node_modules/jest-resolve/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -32517,18 +32506,18 @@ } }, "node_modules/jest-resolve/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -32558,32 +32547,32 @@ } }, "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", + "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/environment": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/environment": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-leak-detector": "30.2.0", - "jest-message-util": "30.2.0", - "jest-resolve": "30.2.0", - "jest-runtime": "30.2.0", - "jest-util": "30.2.0", - "jest-watcher": "30.2.0", - "jest-worker": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-leak-detector": "30.3.0", + "jest-message-util": "30.3.0", + "jest-resolve": "30.3.0", + "jest-runtime": "30.3.0", + "jest-util": "30.3.0", + "jest-watcher": "30.3.0", + "jest-worker": "30.3.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -32592,34 +32581,34 @@ } }, "node_modules/jest-runner/node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0" + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner/node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -32639,9 +32628,9 @@ } }, "node_modules/jest-runner/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -32658,16 +32647,16 @@ } }, "node_modules/jest-runner/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, "node_modules/jest-runner/node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", + "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -32692,19 +32681,19 @@ } }, "node_modules/jest-runner/node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -32713,48 +32702,48 @@ } }, "node_modules/jest-runner/node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner/node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -32792,9 +32781,9 @@ } }, "node_modules/jest-runner/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -32851,32 +32840,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", - "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", + "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/globals": "30.3.0", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-resolve": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -32885,34 +32874,34 @@ } }, "node_modules/jest-runtime/node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0" + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runtime/node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -32932,9 +32921,9 @@ } }, "node_modules/jest-runtime/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -32951,43 +32940,30 @@ } }, "node_modules/jest-runtime/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, "node_modules/jest-runtime/node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", + "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1" } }, - "node_modules/jest-runtime/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" + "balanced-match": "^1.0.0" } }, "node_modules/jest-runtime/node_modules/chalk": { @@ -33008,9 +32984,9 @@ } }, "node_modules/jest-runtime/node_modules/cjs-module-lexer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz", - "integrity": "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "dev": true, "license": "MIT" }, @@ -33018,6 +32994,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -33036,19 +33013,19 @@ } }, "node_modules/jest-runtime/node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -33057,46 +33034,46 @@ } }, "node_modules/jest-runtime/node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runtime/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runtime/node_modules/minimatch": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", - "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -33119,9 +33096,9 @@ } }, "node_modules/jest-runtime/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -33167,9 +33144,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", + "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -33178,20 +33155,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/snapshot-utils": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.2.0", + "expect": "30.3.0", "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", + "jest-diff": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "pretty-format": "30.3.0", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -33199,10 +33176,20 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-snapshot/node_modules/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-snapshot/node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", "dev": true, "license": "MIT", "dependencies": { @@ -33226,9 +33213,9 @@ } }, "node_modules/jest-snapshot/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -33245,9 +33232,9 @@ } }, "node_modules/jest-snapshot/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -33269,69 +33256,69 @@ } }, "node_modules/jest-snapshot/node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-snapshot/node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", + "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-snapshot/node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-snapshot/node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -33340,33 +33327,33 @@ } }, "node_modules/jest-snapshot/node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-snapshot/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -33386,9 +33373,9 @@ } }, "node_modules/jest-snapshot/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -33475,18 +33462,18 @@ } }, "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", + "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -33506,9 +33493,9 @@ } }, "node_modules/jest-validate/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -33525,9 +33512,9 @@ } }, "node_modules/jest-validate/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -33549,9 +33536,9 @@ } }, "node_modules/jest-validate/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -33577,19 +33564,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", + "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "string-length": "^4.0.2" }, "engines": { @@ -33610,9 +33597,9 @@ } }, "node_modules/jest-watcher/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -33629,9 +33616,9 @@ } }, "node_modules/jest-watcher/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -33653,18 +33640,18 @@ } }, "node_modules/jest-watcher/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -33740,9 +33727,9 @@ } }, "node_modules/jest/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -33759,9 +33746,9 @@ } }, "node_modules/jest/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -46339,9 +46326,9 @@ "license": "MIT" }, "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -51121,7 +51108,7 @@ "devDependencies": { "cross-env": "^10.1.0", "fs-extra": "^11.3.3", - "jest": "^30.2.0", + "jest": "^30.3.0", "yeoman-test": "^11.3.1" }, "engines": { @@ -52977,7 +52964,7 @@ "devDependencies": { "@types/jest": "^30.0.0", "@types/lodash": "^4.17.24", - "jest": "^30.2.0" + "jest": "^30.3.0" }, "peerDependencies": { "@apache-superset/core": "*", @@ -53018,7 +53005,7 @@ "devDependencies": { "@babel/types": "^7.29.0", "@types/jest": "^30.0.0", - "jest": "^30.2.0" + "jest": "^30.3.0" }, "peerDependencies": { "@ant-design/icons": "^5.2.6", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 84755457334..781165fdab2 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -335,7 +335,7 @@ "html-webpack-plugin": "^5.6.6", "http-server": "^14.1.1", "imports-loader": "^5.0.0", - "jest": "^30.2.0", + "jest": "^30.3.0", "jest-environment-jsdom": "^29.7.0", "jest-html-reporter": "^4.3.0", "jest-websocket-mock": "^2.5.0", diff --git a/superset-frontend/packages/generator-superset/package.json b/superset-frontend/packages/generator-superset/package.json index 50c8c854405..78b199b90a5 100644 --- a/superset-frontend/packages/generator-superset/package.json +++ b/superset-frontend/packages/generator-superset/package.json @@ -36,7 +36,7 @@ "devDependencies": { "cross-env": "^10.1.0", "fs-extra": "^11.3.3", - "jest": "^30.2.0", + "jest": "^30.3.0", "yeoman-test": "^11.3.1" }, "engines": { diff --git a/superset-frontend/plugins/plugin-chart-handlebars/package.json b/superset-frontend/plugins/plugin-chart-handlebars/package.json index f65dd9585af..b33296a086d 100644 --- a/superset-frontend/plugins/plugin-chart-handlebars/package.json +++ b/superset-frontend/plugins/plugin-chart-handlebars/package.json @@ -46,6 +46,6 @@ "devDependencies": { "@types/jest": "^30.0.0", "@types/lodash": "^4.17.24", - "jest": "^30.2.0" + "jest": "^30.3.0" } } diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/package.json b/superset-frontend/plugins/plugin-chart-pivot-table/package.json index 15658cf9613..00cf4d89b9b 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/package.json +++ b/superset-frontend/plugins/plugin-chart-pivot-table/package.json @@ -40,6 +40,6 @@ "devDependencies": { "@babel/types": "^7.29.0", "@types/jest": "^30.0.0", - "jest": "^30.2.0" + "jest": "^30.3.0" } } From 6d1d5d64d11d9568f807295125c6462e266708cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:21:00 +0700 Subject: [PATCH 22/31] chore(deps): bump antd from 6.3.1 to 6.3.2 in /docs (#38547) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- docs/yarn.lock | 83 ++++++++++++++++++----------------------------- 2 files changed, 33 insertions(+), 52 deletions(-) diff --git a/docs/package.json b/docs/package.json index 53824fcc40b..e4a31bf1294 100644 --- a/docs/package.json +++ b/docs/package.json @@ -68,7 +68,7 @@ "@storybook/theming": "^8.6.15", "@superset-ui/core": "^0.20.4", "@swc/core": "^1.15.17", - "antd": "^6.3.1", + "antd": "^6.3.2", "baseline-browser-mapping": "^2.10.0", "caniuse-lite": "^1.0.30001775", "docusaurus-plugin-openapi-docs": "^4.6.0", diff --git a/docs/yarn.lock b/docs/yarn.lock index 74abfb32ab5..ba6b1120e85 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -195,19 +195,19 @@ dependencies: "@ant-design/fast-color" "^3.0.0" -"@ant-design/cssinjs-utils@^2.1.1": - version "2.1.1" - resolved "https://registry.yarnpkg.com/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.1.tgz#c70d86206204e882073a0fe4969a5ddf154c6915" - integrity sha512-RKxkj5pGFB+FkPJ5NGhoX3DK3xsv0pMltha7Ei1AnY3tILeq38L7tuhaWDPQI/5nlPxOog44wvqpNyyGcUsNMg== +"@ant-design/cssinjs-utils@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz#a4a57e02dd7e7c3732ab7f1df406df98b5542d12" + integrity sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA== dependencies: - "@ant-design/cssinjs" "^2.1.0" + "@ant-design/cssinjs" "^2.1.2" "@babel/runtime" "^7.23.2" "@rc-component/util" "^1.4.0" -"@ant-design/cssinjs@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@ant-design/cssinjs/-/cssinjs-2.1.0.tgz#081394937f86aefe55e35198019d0483f405a484" - integrity sha512-eZFrPCnrYrF3XtL7qA4L75P0qA3TtZta8H3Yggy7UYFh8gZgu5bSMNF+v4UVCzGxzYmx8ZvPdgOce0BJ6PsW9g== +"@ant-design/cssinjs@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz#0219e37afdd957248b10da366febae1e4001c952" + integrity sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ== dependencies: "@babel/runtime" "^7.11.1" "@emotion/hash" "^0.8.0" @@ -2964,12 +2964,12 @@ "@rc-component/util" "^1.3.0" clsx "^2.1.1" -"@rc-component/color-picker@~3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@rc-component/color-picker/-/color-picker-3.1.0.tgz#437586ea2fc27862e7429a754cf85e519e05f461" - integrity sha512-o7Vavj7yyfVxFmeynXf0fCHVlC0UTE9al74c6nYuLck+gjuVdQNWSVXR8Efq/mmWFy7891SCOsfaPq6Eqe1s/g== +"@rc-component/color-picker@~3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@rc-component/color-picker/-/color-picker-3.1.1.tgz#0a00411457e697cf9320e945762a4b08f71938f9" + integrity sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg== dependencies: - "@ant-design/fast-color" "^3.0.0" + "@ant-design/fast-color" "^3.0.1" "@rc-component/util" "^1.3.0" clsx "^2.1.1" @@ -3009,10 +3009,10 @@ "@rc-component/util" "^1.2.1" clsx "^2.1.1" -"@rc-component/form@~1.6.2": - version "1.6.2" - resolved "https://registry.npmjs.org/@rc-component/form/-/form-1.6.2.tgz" - integrity sha512-OgIn2RAoaSBqaIgzJf/X6iflIa9LpTozci1lagLBdURDFhGA370v0+T0tXxOi8YShMjTha531sFhwtnrv+EJaQ== +"@rc-component/form@~1.7.1": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@rc-component/form/-/form-1.7.1.tgz#baf18de01e649415c39e895a2c2fc9c61e1f2e23" + integrity sha512-Uhw0FPvJ+Ko4xBxhvziqmqzIuO0YvVBzVyFGNAI9fMCz4r4DfrYK6PRIN6CkFqM0vdAX9sr4JGA1/h/VzpA1cA== dependencies: "@rc-component/async-validator" "^5.1.0" "@rc-component/util" "^1.6.2" @@ -3075,15 +3075,7 @@ dependencies: "@babel/runtime" "^7.18.0" -"@rc-component/motion@^1.0.0", "@rc-component/motion@^1.1.3", "@rc-component/motion@^1.1.4": - version "1.1.6" - resolved "https://registry.npmjs.org/@rc-component/motion/-/motion-1.1.6.tgz" - integrity sha512-aEQobs/YA0kqRvHIPjQvOytdtdRVyhf/uXAal4chBjxDu6odHckExJzjn2D+Ju1aKK6hx3pAs6BXdV9+86xkgQ== - dependencies: - "@rc-component/util" "^1.2.0" - clsx "^2.1.1" - -"@rc-component/motion@^1.3.1": +"@rc-component/motion@^1.0.0", "@rc-component/motion@^1.1.3", "@rc-component/motion@^1.1.4", "@rc-component/motion@^1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@rc-component/motion/-/motion-1.3.1.tgz#1e56b06841ee677261251e6e69fedc8d73e65b22" integrity sha512-Wo1mkd0tCcHtvYvpPOmlYJz546z16qlsiwaygmW7NPJpOZOF9GBjhGzdzZSsC2lEJ1IUkWLF4gMHlRA1aSA+Yw== @@ -3184,21 +3176,10 @@ "@rc-component/util" "^1.3.0" clsx "^2.1.1" -"@rc-component/select@~1.6.0": - version "1.6.5" - resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.6.5.tgz#69276239c6ac0884a67597961b0224c4ad0bc4ca" - integrity sha512-Cx+/OYEorXlPQ6ZFDro3HbalPZLlJWagvGtl8DGYO4losXM6gw43qbsxWqU1c3XOQVIOUDBlr7dSksSNMj8kXg== - dependencies: - "@rc-component/overflow" "^1.0.0" - "@rc-component/trigger" "^3.0.0" - "@rc-component/util" "^1.3.0" - "@rc-component/virtual-list" "^1.0.1" - clsx "^2.1.1" - -"@rc-component/select@~1.6.12": - version "1.6.12" - resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.6.12.tgz#24312b31aad2a78ce1ec0062b15f56428bddab8f" - integrity sha512-jYXAglYdOb54BrpWAcjjhdBP16NyCv/HbEaWFMbEHZQAJVmGHPAtmBqbFuPPuvInAVsIwLbCj4Agag9udOamiQ== +"@rc-component/select@~1.6.0", "@rc-component/select@~1.6.14": + version "1.6.14" + resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.6.14.tgz#61028c0abe02d2a909935b5cb586374968196c96" + integrity sha512-T1IWeLlSas7Z/igZtPtJ/bweCxMMkXIGKQBtnigK+I/n1AVNjCs+ZdL3Fj42mq3uqm4sd1uzeQLZkdCqR26ADw== dependencies: "@rc-component/overflow" "^1.0.0" "@rc-component/trigger" "^3.0.0" @@ -5668,14 +5649,14 @@ ansi-styles@^6.1.0: resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== -antd@^6.3.1: - version "6.3.1" - resolved "https://registry.yarnpkg.com/antd/-/antd-6.3.1.tgz#ea035d7b0f836a20938945d5a0eaef172537d89b" - integrity sha512-8pRjvxitZFyrYAtgwml93Km7fCXjw9IeqlmzpIsusRsmO3eWFVrOMum6+0TsGCtR/WrXVnPwfsgrFg3ChzGCeA== +antd@^6.3.2: + version "6.3.2" + resolved "https://registry.yarnpkg.com/antd/-/antd-6.3.2.tgz#ce1a33783d495fcfc77b58b73156ac6249e4fc0a" + integrity sha512-IlMoqaXlq5Bgxi0ANERhAzmDREYyGwr/U7MCVihaUQbE/ZOB3r4ArakUxjA1ULYNDA6K00dawSrB8aalGnZlLA== dependencies: "@ant-design/colors" "^8.0.1" - "@ant-design/cssinjs" "^2.1.0" - "@ant-design/cssinjs-utils" "^2.1.1" + "@ant-design/cssinjs" "^2.1.2" + "@ant-design/cssinjs-utils" "^2.1.2" "@ant-design/fast-color" "^3.0.1" "@ant-design/icons" "^6.1.0" "@ant-design/react-slick" "~2.0.0" @@ -5683,11 +5664,11 @@ antd@^6.3.1: "@rc-component/cascader" "~1.14.0" "@rc-component/checkbox" "~2.0.0" "@rc-component/collapse" "~1.2.0" - "@rc-component/color-picker" "~3.1.0" + "@rc-component/color-picker" "~3.1.1" "@rc-component/dialog" "~1.8.4" "@rc-component/drawer" "~1.4.2" "@rc-component/dropdown" "~1.0.2" - "@rc-component/form" "~1.6.2" + "@rc-component/form" "~1.7.1" "@rc-component/image" "~1.6.0" "@rc-component/input" "~1.1.2" "@rc-component/input-number" "~1.6.2" @@ -5703,7 +5684,7 @@ antd@^6.3.1: "@rc-component/rate" "~1.0.1" "@rc-component/resize-observer" "^1.1.1" "@rc-component/segmented" "~1.3.0" - "@rc-component/select" "~1.6.12" + "@rc-component/select" "~1.6.14" "@rc-component/slider" "~1.0.1" "@rc-component/steps" "~1.2.2" "@rc-component/switch" "~1.0.3" From 5f20d2e15ac3c6534ed58c5cf17dd7c5ad5bc4f1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:21:18 +0700 Subject: [PATCH 23/31] chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#38548) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index a6cf3d1614f..b01e45599bb 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -51477,7 +51477,7 @@ "react-js-cron": "^5.2.0", "react-markdown": "^8.0.7", "react-resize-detector": "^7.1.2", - "react-syntax-highlighter": "^16.1.1", + "react-syntax-highlighter": "^16.1.0", "react-ultimate-pagination": "^1.3.2", "regenerator-runtime": "^0.14.1", "rehype-raw": "^7.0.0", From 0533ca9941e9d3535a6647613e8832e094b49777 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Tue, 10 Mar 2026 09:48:38 +0100 Subject: [PATCH 24/31] feat(mcp): register GlobalErrorHandlerMiddleware and LoggingMiddleware (#38523) Co-authored-by: Claude Opus 4.6 --- superset/mcp_service/server.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/superset/mcp_service/server.py b/superset/mcp_service/server.py index f2ee1d69a0d..5435a66e68b 100644 --- a/superset/mcp_service/server.py +++ b/superset/mcp_service/server.py @@ -30,7 +30,11 @@ import uvicorn from superset.mcp_service.app import create_mcp_app, init_fastmcp_server from superset.mcp_service.mcp_config import get_mcp_factory_config, MCP_STORE_CONFIG -from superset.mcp_service.middleware import create_response_size_guard_middleware +from superset.mcp_service.middleware import ( + create_response_size_guard_middleware, + GlobalErrorHandlerMiddleware, + LoggingMiddleware, +) from superset.mcp_service.storage import _create_redis_store logger = logging.getLogger(__name__) @@ -224,16 +228,24 @@ def run_server( auth_provider = _create_auth_provider(flask_app) # Build middleware list + # FastMCP wraps handlers so that the LAST-added middleware is + # outermost. Order here is innermost → outermost. middleware_list = [] + # Add caching middleware (innermost – runs closest to the tool) + caching_middleware = create_response_caching_middleware() + if caching_middleware: + middleware_list.append(caching_middleware) + # Add response size guard (protects LLM clients from huge responses) if size_guard_middleware := create_response_size_guard_middleware(): middleware_list.append(size_guard_middleware) - # Add caching middleware - caching_middleware = create_response_caching_middleware() - if caching_middleware: - middleware_list.append(caching_middleware) + # Add logging middleware (logs all tool calls with duration tracking) + middleware_list.append(LoggingMiddleware()) + + # Add global error handler (outermost – catches all exceptions) + middleware_list.append(GlobalErrorHandlerMiddleware()) mcp_instance = init_fastmcp_server( auth=auth_provider, From 2a876e8b8686659bd06b1cc0d3d68fbab45cf7f0 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Tue, 10 Mar 2026 09:50:41 +0100 Subject: [PATCH 25/31] fix(mcp): add missing command.validate() to MCP chart data tools (#38521) Co-authored-by: Claude Opus 4.6 --- superset/mcp_service/chart/preview_utils.py | 1 + .../mcp_service/chart/tool/generate_chart.py | 1 + .../mcp_service/chart/tool/get_chart_data.py | 1 + .../chart/tool/get_chart_preview.py | 3 + .../chart/tool/test_get_chart_data.py | 187 ++++++++++++++++++ 5 files changed, 193 insertions(+) diff --git a/superset/mcp_service/chart/preview_utils.py b/superset/mcp_service/chart/preview_utils.py index 677d3034fd4..c28681a9da0 100644 --- a/superset/mcp_service/chart/preview_utils.py +++ b/superset/mcp_service/chart/preview_utils.py @@ -103,6 +103,7 @@ def generate_preview_from_form_data( # Execute query command = ChartDataCommand(query_context_obj) + command.validate() result = command.run() if not result or not result.get("queries"): diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py index 6ff3bee59cc..b5711d5e5f7 100644 --- a/superset/mcp_service/chart/tool/generate_chart.py +++ b/superset/mcp_service/chart/tool/generate_chart.py @@ -101,6 +101,7 @@ def _compile_chart( ) command = ChartDataCommand(query_context) + command.validate() result = command.run() warnings: List[str] = [] diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py index eaa50ca6b45..9239a278d9e 100644 --- a/superset/mcp_service/chart/tool/get_chart_data.py +++ b/superset/mcp_service/chart/tool/get_chart_data.py @@ -462,6 +462,7 @@ async def get_chart_data( # noqa: C901 # Execute the query with event_logger.log_context(action="mcp.get_chart_data.query_execution"): command = ChartDataCommand(query_context) + command.validate() result = command.run() # Handle empty query results for certain chart types diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py b/superset/mcp_service/chart/tool/get_chart_preview.py index ee7c70a481b..6ee3ece4df4 100644 --- a/superset/mcp_service/chart/tool/get_chart_preview.py +++ b/superset/mcp_service/chart/tool/get_chart_preview.py @@ -160,6 +160,7 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy): ) command = ChartDataCommand(query_context) + command.validate() result = command.run() data = [] @@ -234,6 +235,7 @@ class TablePreviewStrategy(PreviewFormatStrategy): ) command = ChartDataCommand(query_context) + command.validate() result = command.run() data = [] @@ -340,6 +342,7 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy): # Execute the query command = ChartDataCommand(query_context) + command.validate() result = command.run() # Extract data from result diff --git a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py index 7850ef82a9a..0230d5edcf8 100644 --- a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py +++ b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py @@ -671,3 +671,190 @@ class TestGetChartDataRequestSchema: assert data["identifier"] == 123 assert data["limit"] == 50 assert data["format"] == "json" + + +class TestChartDataCommandValidation: + """Tests that ChartDataCommand.validate() is called before run(). + + These tests verify the security fix (CWE-862) that adds command.validate() + before command.run() in MCP chart data tools. The validate() call enforces + row-level security, guest user guards, and schema-level permissions. + """ + + def test_preview_utils_calls_validate_before_run(self): + """Test that generate_preview_from_form_data calls validate() before run().""" + from unittest.mock import MagicMock, patch + + call_order: list[str] = [] + mock_query_result = {"queries": [{"data": [{"col1": "a", "col2": 1}]}]} + + mock_command = MagicMock() + mock_command.validate.side_effect = lambda: call_order.append("validate") + mock_command.run.side_effect = lambda: ( + call_order.append("run"), + mock_query_result, + )[1] + + mock_dataset = MagicMock() + mock_dataset.id = 10 + + # ChartDataCommand is module-level import in preview_utils; + # db and QueryContextFactory are local imports inside the function. + with ( + patch("superset.extensions.db") as mock_db, + patch( + "superset.mcp_service.chart.preview_utils.ChartDataCommand", + return_value=mock_command, + ), + patch( + "superset.common.query_context_factory.QueryContextFactory" + ) as mock_factory, + ): + mock_db.session.query.return_value.get.return_value = mock_dataset + mock_factory.return_value.create.return_value = MagicMock() + + from superset.mcp_service.chart.preview_utils import ( + generate_preview_from_form_data, + ) + + generate_preview_from_form_data( + form_data={"metrics": [{"label": "count"}], "viz_type": "table"}, + dataset_id=10, + preview_format="table", + ) + + mock_command.validate.assert_called_once() + mock_command.run.assert_called_once() + assert call_order == ["validate", "run"] + + def test_preview_utils_security_exception_from_validate(self): + """Test that SupersetSecurityException from validate() is propagated.""" + from unittest.mock import MagicMock, patch + + from superset.errors import ErrorLevel, SupersetError, SupersetErrorType + from superset.exceptions import SupersetSecurityException + from superset.mcp_service.chart.schemas import ChartError + + security_error = SupersetSecurityException( + SupersetError( + message="Access denied", + error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR, + level=ErrorLevel.ERROR, + ) + ) + + mock_command = MagicMock() + mock_command.validate.side_effect = security_error + + mock_dataset = MagicMock() + mock_dataset.id = 10 + + with ( + patch("superset.extensions.db") as mock_db, + patch( + "superset.mcp_service.chart.preview_utils.ChartDataCommand", + return_value=mock_command, + ), + patch( + "superset.common.query_context_factory.QueryContextFactory" + ) as mock_factory, + ): + mock_db.session.query.return_value.get.return_value = mock_dataset + mock_factory.return_value.create.return_value = MagicMock() + + from superset.mcp_service.chart.preview_utils import ( + generate_preview_from_form_data, + ) + + result = generate_preview_from_form_data( + form_data={"metrics": [{"label": "count"}], "viz_type": "table"}, + dataset_id=10, + preview_format="table", + ) + + # SupersetSecurityException is caught by the broad except and + # returned as a ChartError + assert isinstance(result, ChartError) + assert "Access denied" in result.error + mock_command.run.assert_not_called() + + def test_compile_chart_calls_validate_before_run(self): + """Test that _compile_chart calls validate() before run().""" + from unittest.mock import MagicMock, patch + + call_order: list[str] = [] + mock_query_result = {"queries": [{"data": [{"col1": 1}]}]} + + mock_command = MagicMock() + mock_command.validate.side_effect = lambda: call_order.append("validate") + mock_command.run.side_effect = lambda: ( + call_order.append("run"), + mock_query_result, + )[1] + + # Both ChartDataCommand and QueryContextFactory are local imports + # inside _compile_chart, so patch at source. + with ( + patch( + "superset.commands.chart.data.get_data_command.ChartDataCommand", + return_value=mock_command, + ), + patch( + "superset.common.query_context_factory.QueryContextFactory" + ) as mock_factory, + ): + mock_factory.return_value.create.return_value = MagicMock() + + from superset.mcp_service.chart.tool.generate_chart import _compile_chart + + result = _compile_chart( + form_data={"metrics": [{"label": "count"}], "viz_type": "table"}, + dataset_id=10, + ) + + assert result.success is True + mock_command.validate.assert_called_once() + mock_command.run.assert_called_once() + assert call_order == ["validate", "run"] + + def test_compile_chart_security_exception_from_validate(self): + """Test that _compile_chart propagates security exception from validate().""" + from unittest.mock import MagicMock, patch + + from superset.errors import ErrorLevel, SupersetError, SupersetErrorType + from superset.exceptions import SupersetSecurityException + + security_error = SupersetSecurityException( + SupersetError( + message="Row-level security violation", + error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR, + level=ErrorLevel.ERROR, + ) + ) + + mock_command = MagicMock() + mock_command.validate.side_effect = security_error + + with ( + patch( + "superset.commands.chart.data.get_data_command.ChartDataCommand", + return_value=mock_command, + ), + patch( + "superset.common.query_context_factory.QueryContextFactory" + ) as mock_factory, + ): + mock_factory.return_value.create.return_value = MagicMock() + + from superset.mcp_service.chart.tool.generate_chart import _compile_chart + + # SupersetSecurityException is not caught by _compile_chart's + # specific except blocks, so it propagates to the caller + # (generate_chart's broad except handler). + with pytest.raises(SupersetSecurityException): + _compile_chart( + form_data={"metrics": [{"label": "count"}], "viz_type": "table"}, + dataset_id=10, + ) + + mock_command.run.assert_not_called() From 5fa70bdbd822be025bacae01c8e3c3508b38c3bc Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Tue, 10 Mar 2026 09:51:23 +0100 Subject: [PATCH 26/31] fix(mcp): add guardrails to prevent LLM artifact generation (#38391) Co-authored-by: Claude Opus 4.6 --- superset/mcp_service/app.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py index a26167151f0..43d4723fb94 100644 --- a/superset/mcp_service/app.py +++ b/superset/mcp_service/app.py @@ -156,6 +156,19 @@ CRITICAL RULES - NEVER VIOLATE: - Parameter name reminders: open_sql_lab_with_context uses "sql" (not "query"), execute_sql uses "sql" (not "query"). +IMPORTANT - Tool-Only Interaction: +- Do NOT generate code artifacts, HTML pages, JavaScript snippets, or any code intended + for the user to run. All visualization, data retrieval, and authentication are handled + by the provided MCP tools. +- Always call the appropriate tool directly instead of writing code. For example, use + generate_chart to create visualizations rather than generating plotting code. +- When a tool returns a URL (chart URL, dashboard URL, explore link, SQL Lab link), + return that URL to the user. Do NOT attempt to recreate the visualization in code. +- Do NOT generate HTML dashboards, embed scripts, or custom frontend code. Use + generate_dashboard and add_chart_to_existing_dashboard for dashboard operations. +- If a user asks for something the tools cannot do, explain the limitation and suggest + the closest available tool rather than generating code as a workaround. + General usage tips: - All listing tools use 1-based pagination (first page is 1) - Use get_schema to discover filterable columns, sortable columns, and default columns From 6342c4f338afbf33a57df69cafcb543e3761b207 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Tue, 10 Mar 2026 09:52:12 +0100 Subject: [PATCH 27/31] feat(mcp): add horizontal bar chart orientation support to generate_chart (#38390) Co-authored-by: Claude Opus 4.6 --- superset/mcp_service/chart/chart_utils.py | 12 +++ .../chart/resources/chart_configs.py | 24 ++++++ superset/mcp_service/chart/schemas.py | 8 ++ .../mcp_service/chart/test_chart_schemas.py | 55 +++++++++++++ .../mcp_service/chart/test_chart_utils.py | 78 +++++++++++++++++++ 5 files changed, 177 insertions(+) diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py index 600252f2cfa..ca5e1dfeb47 100644 --- a/superset/mcp_service/chart/chart_utils.py +++ b/superset/mcp_service/chart/chart_utils.py @@ -468,6 +468,17 @@ def add_legend_config(form_data: Dict[str, Any], config: XYChartConfig) -> None: form_data["legend_orientation"] = config.legend.position +def add_orientation_config(form_data: Dict[str, Any], config: XYChartConfig) -> None: + """Add orientation configuration to form_data for bar charts. + + Only applies when kind='bar' and an explicit orientation is set. + When orientation is None (the default), Superset uses its own default + (vertical bars). + """ + if config.kind == "bar" and config.orientation: + form_data["orientation"] = config.orientation + + def configure_temporal_handling( form_data: Dict[str, Any], x_is_temporal: bool, @@ -576,6 +587,7 @@ def map_xy_config( # Add configurations add_axis_config(form_data, config) add_legend_config(form_data, config) + add_orientation_config(form_data, config) return form_data diff --git a/superset/mcp_service/chart/resources/chart_configs.py b/superset/mcp_service/chart/resources/chart_configs.py index ea40a89fdc6..2560a503e62 100644 --- a/superset/mcp_service/chart/resources/chart_configs.py +++ b/superset/mcp_service/chart/resources/chart_configs.py @@ -129,6 +129,29 @@ def get_chart_configs_resource() -> str: }, "use_cases": ["Correlation analysis", "Outlier detection"], }, + "horizontal_bar": { + "description": "Horizontal bar chart for categories with long names", + "config": { + "chart_type": "xy", + "kind": "bar", + "orientation": "horizontal", + "x": {"name": "department", "label": "Department"}, + "y": [ + { + "name": "headcount", + "aggregate": "SUM", + "label": "Headcount", + } + ], + "y_axis": {"title": "Department"}, + "x_axis": {"title": "Number of Employees"}, + }, + "use_cases": [ + "Long category labels", + "Rankings and leaderboards", + "Survey results", + ], + }, "stacked_area": { "description": "Stacked area chart for volume composition over time", "config": { @@ -215,6 +238,7 @@ def get_chart_configs_resource() -> str: "Use group_by to split data into series for comparison", "Use stacked=true for bar/area charts showing composition", "Configure axis format for readability ($,.0f for currency, .2% for pct)", + "Use orientation='horizontal' for bar charts with long category names", ], "table_charts": [ "Include only essential columns to avoid clutter", diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py index 10b694d22c2..28fdf1b0d88 100644 --- a/superset/mcp_service/chart/schemas.py +++ b/superset/mcp_service/chart/schemas.py @@ -749,6 +749,14 @@ class XYChartConfig(BaseModel): "If not specified, Superset will use its default behavior." ), ) + orientation: Literal["vertical", "horizontal"] | None = Field( + None, + description=( + "Bar chart orientation. Only applies when kind='bar'. " + "'vertical' (default): bars extend upward. " + "'horizontal': bars extend rightward, useful for long category names." + ), + ) stacked: bool = Field( False, description="Stack bars/areas on top of each other instead of side-by-side", diff --git a/tests/unit_tests/mcp_service/chart/test_chart_schemas.py b/tests/unit_tests/mcp_service/chart/test_chart_schemas.py index ae13bfc8a75..cea45bf0244 100644 --- a/tests/unit_tests/mcp_service/chart/test_chart_schemas.py +++ b/tests/unit_tests/mcp_service/chart/test_chart_schemas.py @@ -243,6 +243,61 @@ class TestXYChartConfig: assert config.group_by is not None assert config.group_by.name == "year" + def test_orientation_horizontal_accepted(self) -> None: + """Test that orientation='horizontal' is accepted for bar charts.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="department"), + y=[ColumnRef(name="headcount", aggregate="SUM")], + kind="bar", + orientation="horizontal", + ) + assert config.orientation == "horizontal" + + def test_orientation_vertical_accepted(self) -> None: + """Test that orientation='vertical' is accepted for bar charts.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="category"), + y=[ColumnRef(name="sales", aggregate="SUM")], + kind="bar", + orientation="vertical", + ) + assert config.orientation == "vertical" + + def test_orientation_none_by_default(self) -> None: + """Test that orientation defaults to None when not specified.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="category"), + y=[ColumnRef(name="sales", aggregate="SUM")], + kind="bar", + ) + assert config.orientation is None + + def test_orientation_invalid_value_rejected(self) -> None: + """Test that invalid orientation values are rejected.""" + with pytest.raises(ValidationError): + XYChartConfig( + chart_type="xy", + x=ColumnRef(name="category"), + y=[ColumnRef(name="sales", aggregate="SUM")], + kind="bar", + orientation="diagonal", + ) + + def test_orientation_with_non_bar_chart(self) -> None: + """Test that orientation field is accepted on non-bar charts at schema level.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="date"), + y=[ColumnRef(name="revenue", aggregate="SUM")], + kind="line", + orientation="horizontal", + ) + # Schema allows it; the chart_utils layer decides whether to apply it + assert config.orientation == "horizontal" + class TestTableChartConfigExtraFields: """Test TableChartConfig rejects unknown fields.""" diff --git a/tests/unit_tests/mcp_service/chart/test_chart_utils.py b/tests/unit_tests/mcp_service/chart/test_chart_utils.py index c142aabb8ba..9b53024b175 100644 --- a/tests/unit_tests/mcp_service/chart/test_chart_utils.py +++ b/tests/unit_tests/mcp_service/chart/test_chart_utils.py @@ -460,6 +460,84 @@ class TestMapXYConfig: assert result["groupby"] == ["category"] assert result["x_axis"] == "order_date" + def test_map_xy_config_bar_horizontal_orientation(self) -> None: + """Test XY config mapping for horizontal bar chart""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="department"), + y=[ColumnRef(name="headcount", aggregate="SUM")], + kind="bar", + orientation="horizontal", + ) + + result = map_xy_config(config) + + assert result["viz_type"] == "echarts_timeseries_bar" + assert result["orientation"] == "horizontal" + + def test_map_xy_config_bar_vertical_orientation(self) -> None: + """Test XY config mapping for vertical bar chart (explicit)""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="category"), + y=[ColumnRef(name="sales", aggregate="SUM")], + kind="bar", + orientation="vertical", + ) + + result = map_xy_config(config) + + assert result["viz_type"] == "echarts_timeseries_bar" + assert result["orientation"] == "vertical" + + def test_map_xy_config_bar_no_orientation(self) -> None: + """Test XY config mapping for bar chart without orientation.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="category"), + y=[ColumnRef(name="sales", aggregate="SUM")], + kind="bar", + ) + + result = map_xy_config(config) + + assert result["viz_type"] == "echarts_timeseries_bar" + assert "orientation" not in result + + def test_map_xy_config_line_orientation_ignored(self) -> None: + """Test that orientation is ignored for non-bar chart types""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="date"), + y=[ColumnRef(name="revenue", aggregate="SUM")], + kind="line", + orientation="horizontal", + ) + + result = map_xy_config(config) + + assert result["viz_type"] == "echarts_timeseries_line" + assert "orientation" not in result + + def test_map_xy_config_bar_horizontal_with_stacked(self) -> None: + """Test horizontal bar chart with stacked option""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="department"), + y=[ColumnRef(name="headcount", aggregate="SUM")], + kind="bar", + orientation="horizontal", + stacked=True, + group_by=ColumnRef(name="level"), + ) + + result = map_xy_config(config) + + assert result["viz_type"] == "echarts_timeseries_bar" + assert result["orientation"] == "horizontal" + assert result["stack"] == "Stack" + assert result["groupby"] == ["level"] + class TestMapConfigToFormData: """Test map_config_to_form_data function""" From 5c2cbb58bc98f6af4f3ca274b126a76e224d20d9 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Tue, 10 Mar 2026 09:52:48 +0100 Subject: [PATCH 28/31] fix(mcp): add missing __init__.py for chart, dashboard, dataset packages (#38400) --- superset/mcp_service/chart/__init__.py | 16 ++++++ superset/mcp_service/dashboard/__init__.py | 16 ++++++ superset/mcp_service/dataset/__init__.py | 16 ++++++ .../mcp_service/test_mcp_tool_registration.py | 53 +++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 superset/mcp_service/chart/__init__.py create mode 100644 superset/mcp_service/dashboard/__init__.py create mode 100644 superset/mcp_service/dataset/__init__.py diff --git a/superset/mcp_service/chart/__init__.py b/superset/mcp_service/chart/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset/mcp_service/chart/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/mcp_service/dashboard/__init__.py b/superset/mcp_service/dashboard/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset/mcp_service/dashboard/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/mcp_service/dataset/__init__.py b/superset/mcp_service/dataset/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset/mcp_service/dataset/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py index 088b3ad09a2..be470bc4fd0 100644 --- a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py +++ b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py @@ -37,3 +37,56 @@ def test_mcp_prompts_registered(): prompts = mcp._prompt_manager._prompts assert len(prompts) > 0 + + +def test_mcp_resources_registered(): + """Test that MCP resources are registered. + + Resources are registered via @mcp.resource() decorators in resource files. + They require __init__.py in parent packages for find_packages() to include + them in distributions. This test ensures all expected resources are found. + """ + from superset.mcp_service.app import mcp + + resource_manager = mcp._resource_manager + resources = resource_manager._resources + assert len(resources) > 0, "No MCP resources registered" + + # Verify the two documented resources are registered + resource_uris = set(resources.keys()) + assert "chart://configs" in resource_uris, ( + "chart://configs resource not registered - " + "check superset/mcp_service/chart/__init__.py exists" + ) + assert "instance://metadata" in resource_uris, ( + "instance://metadata resource not registered - " + "check superset/mcp_service/system/resources/ imports" + ) + + +def test_mcp_packages_discoverable_by_setuptools(): + """Test that all MCP sub-packages have __init__.py for setuptools. + + setuptools.find_packages() only discovers directories with __init__.py. + Without __init__.py, sub-packages (tool, resources, prompts) are excluded + from built distributions, causing missing module errors in deployments. + """ + from pathlib import Path + + mcp_root = Path(__file__).parents[3] / "superset" / "mcp_service" + assert mcp_root.is_dir(), f"MCP service root not found: {mcp_root}" + + # All immediate sub-directories that contain Python files should be packages + missing = [] + for subdir in sorted(mcp_root.iterdir()): + if not subdir.is_dir() or subdir.name.startswith(("_", ".")): + continue + # Check if it has any .py files in it or its subdirectories + has_py = any(subdir.rglob("*.py")) + if has_py and not (subdir / "__init__.py").exists(): + missing.append(subdir.name) + + assert not missing, ( + f"MCP sub-packages missing __init__.py (will be excluded from " + f"setuptools distributions): {missing}" + ) From cc8ad23d6f9a2f29b5b012d5df43fa12d931ec66 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:57:32 +0700 Subject: [PATCH 29/31] chore(deps): bump react-diff-viewer-continued from 3.4.0 to 4.2.0 in /superset-frontend (#38552) Signed-off-by: dependabot[bot] Signed-off-by: hainenber Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: hainenber --- superset-frontend/jest.config.js | 2 +- superset-frontend/package-lock.json | 52 ++++++++++++++++++++++------- superset-frontend/package.json | 2 +- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js index 4da37f9a245..e0b12184eaf 100644 --- a/superset-frontend/jest.config.js +++ b/superset-frontend/jest.config.js @@ -69,7 +69,7 @@ module.exports = { ], coverageReporters: ['lcov', 'json-summary', 'html', 'text'], transformIgnorePatterns: [ - 'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect)', + 'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued)', ], preset: 'ts-jest', transform: { diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index b01e45599bb..6a6997b316f 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -116,7 +116,7 @@ "react": "^17.0.2", "react-arborist": "^3.4.3", "react-checkbox-tree": "^1.8.0", - "react-diff-viewer-continued": "^3.4.0", + "react-diff-viewer-continued": "^4.2.0", "react-dnd": "^11.1.3", "react-dnd-html5-backend": "^11.1.3", "react-dom": "^17.0.2", @@ -41749,23 +41749,51 @@ } }, "node_modules/react-diff-viewer-continued": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/react-diff-viewer-continued/-/react-diff-viewer-continued-3.4.0.tgz", - "integrity": "sha512-kMZmUyb3Pv5L9vUtCfIGYsdOHs8mUojblGy1U1Sm0D7FhAOEsH9QhnngEIRo5hXWIPNGupNRJls1TJ6Eqx84eg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/react-diff-viewer-continued/-/react-diff-viewer-continued-4.2.0.tgz", + "integrity": "sha512-KXeevuPpMRNDAtF878G04Yih/01DBBoC+RjDzWiA5S6TPtUzSfqF5XOlEWyXVWvJuz5n+EQ9QdUQd0ffK2By6w==", "license": "MIT", "dependencies": { - "@emotion/css": "^11.11.2", - "classnames": "^2.3.2", - "diff": "^5.1.0", - "memoize-one": "^6.0.0", - "prop-types": "^15.8.1" + "@emotion/css": "^11.13.5", + "@emotion/react": "^11.14.0", + "classnames": "^2.5.1", + "diff": "^8.0.3", + "js-yaml": "^4.1.1", + "memoize-one": "^6.0.0" }, "engines": { - "node": ">= 8" + "node": ">= 16" }, "peerDependencies": { - "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-diff-viewer-continued/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/react-diff-viewer-continued/node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/react-diff-viewer-continued/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, "node_modules/react-diff-viewer-continued/node_modules/memoize-one": { diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 781165fdab2..945b677c0e3 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -197,7 +197,7 @@ "react": "^17.0.2", "react-arborist": "^3.4.3", "react-checkbox-tree": "^1.8.0", - "react-diff-viewer-continued": "^3.4.0", + "react-diff-viewer-continued": "^4.2.0", "react-dnd": "^11.1.3", "react-dnd-html5-backend": "^11.1.3", "react-dom": "^17.0.2", From 13fe88000a9677f3188f4a5496f0d3bc24dd33d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:51:58 +0700 Subject: [PATCH 30/31] chore(deps-dev): bump lightningcss from 1.31.1 to 1.32.0 in /superset-frontend (#38511) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Evan Rusackas --- superset-frontend/package-lock.json | 96 ++++++++++++++--------------- superset-frontend/package.json | 2 +- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 6a6997b316f..eaf5acb1325 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -261,7 +261,7 @@ "js-yaml-loader": "^1.2.2", "jsdom": "^28.1.0", "lerna": "^8.2.3", - "lightningcss": "^1.31.1", + "lightningcss": "^1.32.0", "mini-css-extract-plugin": "^2.10.0", "open-cli": "^8.0.0", "oxlint": "^1.51.0", @@ -35258,9 +35258,9 @@ } }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -35274,23 +35274,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -35309,9 +35309,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -35330,9 +35330,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -35351,9 +35351,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -35372,9 +35372,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -35393,9 +35393,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -35414,9 +35414,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -35435,9 +35435,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -35456,9 +35456,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -35477,9 +35477,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -35498,9 +35498,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 945b677c0e3..e5b6e7c5321 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -342,7 +342,7 @@ "js-yaml-loader": "^1.2.2", "jsdom": "^28.1.0", "lerna": "^8.2.3", - "lightningcss": "^1.31.1", + "lightningcss": "^1.32.0", "mini-css-extract-plugin": "^2.10.0", "open-cli": "^8.0.0", "oxlint": "^1.51.0", From 0cfd760a3657c1f78474bba52fc19fd49170f57d Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Tue, 10 Mar 2026 10:53:05 +0100 Subject: [PATCH 31/31] fix(mcp): improve default chart names with descriptive format (#38406) Co-authored-by: Claude Opus 4.6 --- superset/mcp_service/chart/chart_utils.py | 157 ++++++++++++++++-- .../mcp_service/chart/tool/generate_chart.py | 13 +- .../mcp_service/chart/test_chart_utils.py | 133 +++++++++++++-- .../mcp_service/chart/test_new_chart_types.py | 8 +- 4 files changed, 280 insertions(+), 31 deletions(-) diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py index ca5e1dfeb47..b1e5be880c1 100644 --- a/superset/mcp_service/chart/chart_utils.py +++ b/superset/mcp_service/chart/chart_utils.py @@ -801,34 +801,165 @@ def map_filter_operator(op: str) -> str: return operator_map.get(op, op) +def _humanize_column(col: ColumnRef) -> str: + """Return a human-readable label for a column reference.""" + if col.label: + return col.label + name = col.name.replace("_", " ").title() + if col.aggregate: + return f"{col.aggregate.capitalize()}({name})" + return name + + +def _summarize_filters( + filters: list[Any] | None, +) -> str | None: + """Extract a short context string from filter configs.""" + if not filters: + return None + parts: list[str] = [] + for f in filters[:2]: + col = getattr(f, "column", "") + val = getattr(f, "value", "") + if isinstance(val, list): + val = ", ".join(str(v) for v in val[:3]) + parts.append(f"{str(col).replace('_', ' ').title()} {val}") + return ", ".join(parts) if parts else None + + +def _truncate(name: str, max_length: int = 60) -> str: + """Truncate to *max_length*, preserving the en-dash context portion.""" + if len(name) <= max_length: + return name + if " \u2013 " in name: + what, _context = name.split(" \u2013 ", 1) + if len(what) <= max_length: + return what + return name[: max_length - 1] + "\u2026" + + +def _table_chart_what(config: TableChartConfig, dataset_name: str | None) -> str: + """Build the descriptive fragment for a table chart.""" + has_agg = any(col.aggregate for col in config.columns) + if has_agg: + metrics = [col for col in config.columns if col.aggregate] + what = ", ".join(_humanize_column(m) for m in metrics[:2]) + return f"{what} Summary" + if dataset_name: + return f"{dataset_name} Records" + cols = ", ".join(_humanize_column(c) for c in config.columns[:3]) + return f"{cols} Table" + + +def _xy_chart_what(config: XYChartConfig) -> str: + """Build the descriptive fragment for an XY chart.""" + primary_metric = _humanize_column(config.y[0]) if config.y else "Value" + dimension = _humanize_column(config.x) + + if config.kind in ("line", "area") and config.group_by is None: + return f"{primary_metric} Over Time" + if config.group_by is not None: + group_label = _humanize_column(config.group_by) + return f"{primary_metric} by {group_label}" + if config.kind == "scatter": + return f"{primary_metric} vs {dimension}" + return f"{primary_metric} by {dimension}" + + +_GRAIN_MAP: dict[str, str] = { + "PT1H": "Hourly", + "P1D": "Daily", + "P1W": "Weekly", + "P1M": "Monthly", + "P3M": "Quarterly", + "P1Y": "Yearly", +} + + +def _xy_chart_context(config: XYChartConfig) -> str | None: + """Build context (time grain / filters) for an XY chart name.""" + parts: list[str] = [] + if config.time_grain: + grain_val = ( + config.time_grain.value + if hasattr(config.time_grain, "value") + else str(config.time_grain) + ) + grain_str = _GRAIN_MAP.get(grain_val, grain_val) + parts.append(grain_str) + if filter_ctx := _summarize_filters(config.filters): + parts.append(filter_ctx) + return ", ".join(parts) if parts else None + + +def _pie_chart_what(config: PieChartConfig) -> str: + """Build the 'what' portion for a pie chart name.""" + dim = config.dimension.name + metric_label = config.metric.label or config.metric.name + return f"{dim} by {metric_label}" + + +def _pivot_table_what(config: PivotTableChartConfig) -> str: + """Build the 'what' portion for a pivot table chart name.""" + row_names = ", ".join(r.name for r in config.rows) + return f"Pivot Table \u2013 {row_names}" + + +def _mixed_timeseries_what(config: MixedTimeseriesChartConfig) -> str: + """Build the 'what' portion for a mixed timeseries chart name.""" + primary = config.y[0].label or config.y[0].name if config.y else "primary" + secondary = ( + config.y_secondary[0].label or config.y_secondary[0].name + if config.y_secondary + else "secondary" + ) + return f"{primary} + {secondary}" + + def generate_chart_name( config: TableChartConfig | XYChartConfig | PieChartConfig | PivotTableChartConfig | MixedTimeseriesChartConfig, + dataset_name: str | None = None, ) -> str: - """Generate a chart name based on the configuration.""" + """Generate a descriptive chart name following a standard format. + + Format conventions (by chart type): + Aggregated (bar/scatter with group_by): [Metric] by [Dimension] + Time-series (line/area, no group_by): [Metric] Over Time + Table (no aggregates): [Dataset] Records + Table (with aggregates): [Metric] Summary + Pie: [Dimension] by [Metric] + Pivot Table: Pivot Table – [Row1, Row2] + Mixed Timeseries: [Primary] + [Secondary] + An en-dash followed by context (filters / time grain) is appended + when such information is available. + """ if isinstance(config, TableChartConfig): - return f"Table Chart - {', '.join(col.name for col in config.columns)}" + what = _table_chart_what(config, dataset_name) + context = _summarize_filters(config.filters) elif isinstance(config, XYChartConfig): - chart_type = config.kind.capitalize() - x_col = config.x.name - y_cols = ", ".join(col.name for col in config.y) - return f"{chart_type} Chart - {x_col} vs {y_cols}" + what = _xy_chart_what(config) + context = _xy_chart_context(config) elif isinstance(config, PieChartConfig): - metric_label = config.metric.label or config.metric.name - return f"Pie Chart - {config.dimension.name} by {metric_label}" + what = _pie_chart_what(config) + context = _summarize_filters(config.filters) elif isinstance(config, PivotTableChartConfig): - rows = ", ".join(col.name for col in config.rows) - return f"Pivot Table - {rows}" + what = _pivot_table_what(config) + context = _summarize_filters(config.filters) elif isinstance(config, MixedTimeseriesChartConfig): - primary = ", ".join(col.name for col in config.y) - secondary = ", ".join(col.name for col in config.y_secondary) - return f"Mixed Chart - {primary} + {secondary}" + what = _mixed_timeseries_what(config) + context = _summarize_filters(config.filters) else: return "Chart" + name = what + if context: + name = f"{what} \u2013 {context}" + return _truncate(name) + def analyze_chart_capabilities(chart: Any | None, config: Any) -> ChartCapabilities: """Analyze chart capabilities based on type and configuration.""" diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py index b5711d5e5f7..f09d8f89fdf 100644 --- a/superset/mcp_service/chart/tool/generate_chart.py +++ b/superset/mcp_service/chart/tool/generate_chart.py @@ -264,10 +264,6 @@ async def generate_chart( # noqa: C901 await ctx.report_progress(2, 5, "Creating chart in database") from superset.commands.chart.create import CreateChartCommand - # Use custom chart name if provided, otherwise auto-generate - chart_name = request.chart_name or generate_chart_name(request.config) - await ctx.debug("Chart name: chart_name=%s" % (chart_name,)) - # Find the dataset to get its numeric ID from superset.daos.dataset import DatasetDAO @@ -344,6 +340,15 @@ async def generate_chart( # noqa: C901 } ) + # Generate chart name after dataset lookup so we can include dataset name + dataset_name = getattr(dataset, "datasource_name", None) or getattr( + dataset, "table_name", None + ) + chart_name = request.chart_name or generate_chart_name( + request.config, dataset_name=dataset_name + ) + await ctx.debug("Chart name: chart_name=%s" % (chart_name,)) + try: with event_logger.log_context(action="mcp.generate_chart.db_write"): command = CreateChartCommand( diff --git a/tests/unit_tests/mcp_service/chart/test_chart_utils.py b/tests/unit_tests/mcp_service/chart/test_chart_utils.py index 9b53024b175..ce5c618e03b 100644 --- a/tests/unit_tests/mcp_service/chart/test_chart_utils.py +++ b/tests/unit_tests/mcp_service/chart/test_chart_utils.py @@ -568,8 +568,8 @@ class TestMapConfigToFormData: class TestGenerateChartName: """Test generate_chart_name function""" - def test_generate_table_chart_name(self) -> None: - """Test generating name for table chart""" + def test_table_no_aggregates(self) -> None: + """Table without aggregates uses column names.""" config = TableChartConfig( chart_type="table", columns=[ @@ -579,25 +579,138 @@ class TestGenerateChartName: ) result = generate_chart_name(config) - assert result == "Table Chart - product, revenue" + assert result == "Product, Revenue Table" - def test_generate_xy_chart_name(self) -> None: - """Test generating name for XY chart""" + def test_table_no_aggregates_with_dataset_name(self) -> None: + """Table without aggregates includes dataset name when available.""" + config = TableChartConfig( + chart_type="table", + columns=[ColumnRef(name="product")], + ) + + result = generate_chart_name(config, dataset_name="Orders") + assert result == "Orders Records" + + def test_table_with_aggregates(self) -> None: + """Table with aggregates produces a summary name.""" + config = TableChartConfig( + chart_type="table", + columns=[ + ColumnRef(name="product"), + ColumnRef(name="revenue", aggregate="SUM"), + ], + ) + + result = generate_chart_name(config) + assert result == "Sum(Revenue) Summary" + + def test_line_chart_over_time(self) -> None: + """Line chart without group_by uses 'Over Time' format.""" config = XYChartConfig( chart_type="xy", - x=ColumnRef(name="date"), - y=[ColumnRef(name="revenue"), ColumnRef(name="orders")], + x=ColumnRef(name="order_date"), + y=[ColumnRef(name="revenue", aggregate="SUM")], kind="line", ) result = generate_chart_name(config) - assert result == "Line Chart - date vs revenue, orders" + assert result == "Sum(Revenue) Over Time" - def test_generate_chart_name_unsupported(self) -> None: - """Test generating name for unsupported config type""" + def test_bar_chart_by_dimension(self) -> None: + """Bar chart uses 'by [X]' format.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="product_category"), + y=[ColumnRef(name="order_count", aggregate="COUNT")], + kind="bar", + ) + + result = generate_chart_name(config) + assert result == "Count(Order Count) by Product Category" + + def test_line_chart_with_group_by(self) -> None: + """Line chart with group_by uses 'by [group]' format.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="date"), + y=[ColumnRef(name="revenue", aggregate="SUM")], + kind="line", + group_by=ColumnRef(name="sales_rep"), + ) + + result = generate_chart_name(config) + assert result == "Sum(Revenue) by Sales Rep" + + def test_scatter_plot(self) -> None: + """Scatter plot uses 'Y vs X' format.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="age"), + y=[ColumnRef(name="income")], + kind="scatter", + ) + + result = generate_chart_name(config) + assert result == "Income vs Age" + + def test_time_grain_in_context(self) -> None: + """Time grain is appended as context.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="date"), + y=[ColumnRef(name="revenue", aggregate="SUM")], + kind="line", + time_grain="P1M", + ) + + result = generate_chart_name(config) + assert result == "Sum(Revenue) Over Time \u2013 Monthly" + + def test_filter_context(self) -> None: + """Filters are appended as context.""" + config = TableChartConfig( + chart_type="table", + columns=[ColumnRef(name="product")], + filters=[FilterConfig(column="region", op="=", value="West")], + ) + + result = generate_chart_name(config, dataset_name="Orders") + assert result == "Orders Records \u2013 Region West" + + def test_name_truncation(self) -> None: + """Names exceeding 60 chars are truncated.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="date"), + y=[ + ColumnRef( + name="very_long_metric_name_that_goes_on_and_on", aggregate="SUM" + ) + ], + kind="line", + group_by=ColumnRef(name="another_very_long_dimension_name_here"), + ) + + result = generate_chart_name(config) + assert len(result) <= 60 + + def test_unsupported_config_type(self) -> None: + """Unsupported config type returns generic name.""" result = generate_chart_name("invalid_config") # type: ignore assert result == "Chart" + def test_custom_labels_used(self) -> None: + """Column labels are preferred over names.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="ds", label="Date"), + y=[ColumnRef(name="cnt", aggregate="COUNT", label="Order Count")], + kind="bar", + ) + + result = generate_chart_name(config) + assert result == "Order Count by Date" + class TestGenerateExploreLink: """Test generate_explore_link function""" diff --git a/tests/unit_tests/mcp_service/chart/test_new_chart_types.py b/tests/unit_tests/mcp_service/chart/test_new_chart_types.py index e930ab83091..e2e37ba99e6 100644 --- a/tests/unit_tests/mcp_service/chart/test_new_chart_types.py +++ b/tests/unit_tests/mcp_service/chart/test_new_chart_types.py @@ -723,7 +723,7 @@ class TestGenerateChartNameNewTypes: metric=ColumnRef(name="revenue", aggregate="SUM"), ) result = generate_chart_name(config) - assert result == "Pie Chart - product by revenue" + assert result == "product by revenue" def test_pie_chart_name_with_custom_label(self) -> None: config = PieChartConfig( @@ -732,7 +732,7 @@ class TestGenerateChartNameNewTypes: metric=ColumnRef(name="revenue", aggregate="SUM", label="Total Revenue"), ) result = generate_chart_name(config) - assert result == "Pie Chart - product by Total Revenue" + assert result == "product by Total Revenue" def test_pivot_table_chart_name(self) -> None: config = PivotTableChartConfig( @@ -741,7 +741,7 @@ class TestGenerateChartNameNewTypes: metrics=[ColumnRef(name="revenue", aggregate="SUM")], ) result = generate_chart_name(config) - assert result == "Pivot Table - product, region" + assert result == "Pivot Table \u2013 product, region" def test_mixed_timeseries_chart_name(self) -> None: config = MixedTimeseriesChartConfig( @@ -751,7 +751,7 @@ class TestGenerateChartNameNewTypes: y_secondary=[ColumnRef(name="orders", aggregate="COUNT")], ) result = generate_chart_name(config) - assert result == "Mixed Chart - revenue + orders" + assert result == "revenue + orders" # ============================================================