fix(crud): reorder table actions + improve react memoization + improve hooks (#37897)

This commit is contained in:
Gabriel Torres Ruiz
2026-02-20 11:58:28 -05:00
committed by GitHub
parent e30a9caba5
commit 6fdaa8e9b3
12 changed files with 1269 additions and 681 deletions

View File

@@ -16,6 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
// Register TypeScript require hook so ESLint can load .ts plugin files
require('tsx/cjs');
const packageConfig = require('./package.json');
const importCoreModules = [];
@@ -148,7 +152,7 @@ module.exports = {
// Custom Superset rules
'theme-colors/no-literal-colors': 'error',
'icons/no-fa-icons-usage': 'error',
'i18n-strings/no-template-vars': ['error', true],
'i18n-strings/no-template-vars': 'error',
// Core ESLint overrides for Superset
'no-console': 'warn',
@@ -195,7 +199,7 @@ module.exports = {
'**/jest.setup.js',
'**/webpack.config.js',
'**/webpack.config.*.js',
'**/.eslintrc.js',
'**/.eslintrc*.js',
],
optionalDependencies: false,
},

View File

@@ -17,6 +17,9 @@
* under the License.
*/
// Register TypeScript require hook so ESLint can load .ts plugin files
require('tsx/cjs');
/**
* MINIMAL ESLint config - ONLY for rules OXC doesn't support
* This config is designed to be run alongside OXC linter
@@ -66,7 +69,7 @@ module.exports = {
// Custom Superset plugins
'theme-colors/no-literal-colors': 'error',
'icons/no-fa-icons-usage': 'error',
'i18n-strings/no-template-vars': ['error', true],
'i18n-strings/no-template-vars': 'error',
'file-progress/activate': 1,
// Explicitly turn off all other rules to avoid conflicts

View File

@@ -342,10 +342,6 @@ test('displays chart data correctly in table rows', async () => {
within(chartRow).getByText(testChart.changed_on_delta_humanized),
).toBeInTheDocument();
// Check actions column within the specific row
const actionsContainer = chartRow.querySelector('.actions');
expect(actionsContainer).toBeInTheDocument();
// Verify action buttons exist within the specific row
expect(within(chartRow).getByTestId('delete')).toBeInTheDocument();
expect(within(chartRow).getByTestId('upload')).toBeInTheDocument();

View File

@@ -222,9 +222,13 @@ function ChartList(props: ChartListProps) {
] = useState<string[]>([]);
// TODO: Fix usage of localStorage keying on the user id
const userSettings = dangerouslyGetItemDoNotUse(userId?.toString(), null) as {
thumbnails: boolean;
};
const userSettings = useMemo(
() =>
dangerouslyGetItemDoNotUse(userId?.toString(), null) as {
thumbnails: boolean;
},
[userId],
);
const openChartImportModal = () => {
showImportModal(true);
@@ -245,18 +249,22 @@ function ChartList(props: ChartListProps) {
const canDelete = hasPerm('can_write');
const canExport = hasPerm('can_export');
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
const handleBulkChartExport = async (chartsToExport: Chart[]) => {
const ids = chartsToExport.map(({ id }) => id);
setPreparingExport(true);
try {
await handleResourceExport('chart', ids, () => {
const handleBulkChartExport = useCallback(
async (chartsToExport: Chart[]) => {
const ids = chartsToExport.map(({ id }) => id);
setPreparingExport(true);
try {
await handleResourceExport('chart', ids, () => {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the selected charts'));
}
};
addDangerToast(t('There was an issue exporting the selected charts'));
}
},
[addDangerToast],
);
function handleBulkChartDelete(chartsToDelete: Chart[]) {
SupersetClient.delete({
@@ -275,54 +283,53 @@ function ChartList(props: ChartListProps) {
),
);
}
const fetchDashboards = async (
filterValue = '',
page: number,
pageSize: number,
) => {
// add filters if filterValue
const filters = filterValue
? {
filters: [
{
col: 'dashboard_title',
opr: FilterOperator.StartsWith,
value: filterValue,
},
],
}
: {};
const queryParams = rison.encode({
select_columns: ['dashboard_title', 'id'],
keys: ['none'],
order_column: 'dashboard_title',
order_direction: 'asc',
page,
page_size: pageSize,
...filters,
});
const response: void | JsonResponse = await SupersetClient.get({
endpoint: `/api/v1/dashboard/?q=${queryParams}`,
}).catch(() =>
addDangerToast(t('An error occurred while fetching dashboards')),
);
const dashboards = response?.json?.result?.map(
({
dashboard_title: dashboardTitle,
id,
}: {
dashboard_title: string;
id: number;
}) => ({
label: dashboardTitle,
value: id,
}),
);
return {
data: uniqBy<LabeledValue>(dashboards, 'value'),
totalCount: response?.json?.count,
};
};
const fetchDashboards = useCallback(
async (filterValue = '', page: number, pageSize: number) => {
// add filters if filterValue
const filters = filterValue
? {
filters: [
{
col: 'dashboard_title',
opr: FilterOperator.StartsWith,
value: filterValue,
},
],
}
: {};
const queryParams = rison.encode({
select_columns: ['dashboard_title', 'id'],
keys: ['none'],
order_column: 'dashboard_title',
order_direction: 'asc',
page,
page_size: pageSize,
...filters,
});
const response: void | JsonResponse = await SupersetClient.get({
endpoint: `/api/v1/dashboard/?q=${queryParams}`,
}).catch(() =>
addDangerToast(t('An error occurred while fetching dashboards')),
);
const dashboards = response?.json?.result?.map(
({
dashboard_title: dashboardTitle,
id,
}: {
dashboard_title: string;
id: number;
}) => ({
label: dashboardTitle,
value: id,
}),
);
return {
data: uniqBy<LabeledValue>(dashboards, 'value'),
totalCount: response?.json?.count,
};
},
[addDangerToast],
);
const columns = useMemo(
() => [
@@ -506,6 +513,38 @@ function ChartList(props: ChartListProps) {
return (
<StyledActions className="actions">
{canEdit && (
<Tooltip
id="edit-action-tooltip"
title={t('Edit')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={openEditModal}
>
<Icons.EditOutlined data-test="edit-alt" iconSize="l" />
</span>
</Tooltip>
)}
{canExport && (
<Tooltip
id="export-action-tooltip"
title={t('Export')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleExport}
>
<Icons.UploadOutlined iconSize="l" />
</span>
</Tooltip>
)}
{canDelete && (
<ConfirmStatusChange
title={t('Please confirm')}
@@ -535,38 +574,6 @@ function ChartList(props: ChartListProps) {
)}
</ConfirmStatusChange>
)}
{canExport && (
<Tooltip
id="export-action-tooltip"
title={t('Export')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleExport}
>
<Icons.UploadOutlined iconSize="l" />
</span>
</Tooltip>
)}
{canEdit && (
<Tooltip
id="edit-action-tooltip"
title={t('Edit')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={openEditModal}
>
<Icons.EditOutlined data-test="edit-alt" iconSize="l" />
</span>
</Tooltip>
)}
</StyledActions>
);
},
@@ -592,6 +599,8 @@ function ChartList(props: ChartListProps) {
refreshData,
addSuccessToast,
addDangerToast,
handleBulkChartExport,
openChartEditModal,
],
);
@@ -613,7 +622,7 @@ function ChartList(props: ChartListProps) {
);
const filters: ListViewFilters = useMemo(() => {
const filters_list = [
const filtersList = [
{
Header: t('Name'),
key: 'search',
@@ -741,8 +750,15 @@ function ChartList(props: ChartListProps) {
dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
},
] as ListViewFilters;
return filters_list;
}, [addDangerToast, favoritesFilter, props.user]);
return filtersList;
}, [
addDangerToast,
canReadTag,
favoritesFilter,
fetchDashboards,
props.user,
userId,
]);
const sortTypes = [
{
@@ -792,8 +808,14 @@ function ChartList(props: ChartListProps) {
addSuccessToast,
bulkSelectEnabled,
favoriteStatus,
handleBulkChartExport,
hasPerm,
loading,
openChartEditModal,
refreshData,
saveFavoriteStatus,
userId,
userSettings,
],
);

View File

@@ -224,9 +224,9 @@ function DashboardList(props: DashboardListProps) {
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
function openDashboardEditModal(dashboard: Dashboard) {
const openDashboardEditModal = useCallback((dashboard: Dashboard) => {
setDashboardToEdit(dashboard);
}
}, []);
function handleDashboardEdit(edits: Dashboard) {
return SupersetClient.get({
@@ -237,29 +237,29 @@ function DashboardList(props: DashboardListProps) {
dashboards.map(dashboard => {
if (dashboard.id === json?.result?.id) {
const {
changed_by_name,
changed_by,
dashboard_title = '',
changed_by_name: changedByName,
changed_by: changedBy,
dashboard_title: dashboardTitle = '',
slug = '',
json_metadata = '',
changed_on_delta_humanized,
json_metadata: jsonMetadata = '',
changed_on_delta_humanized: changedOnDeltaHumanized,
url = '',
certified_by = '',
certification_details = '',
certified_by: certifiedBy = '',
certification_details: certificationDetails = '',
owners,
tags,
} = json.result;
return {
...dashboard,
changed_by_name,
changed_by,
dashboard_title,
changed_by_name: changedByName,
changed_by: changedBy,
dashboard_title: dashboardTitle,
slug,
json_metadata,
changed_on_delta_humanized,
json_metadata: jsonMetadata,
changed_on_delta_humanized: changedOnDeltaHumanized,
url,
certified_by,
certification_details,
certified_by: certifiedBy,
certification_details: certificationDetails,
owners,
tags,
};
@@ -276,18 +276,23 @@ function DashboardList(props: DashboardListProps) {
);
}
const handleBulkDashboardExport = async (dashboardsToExport: Dashboard[]) => {
const ids = dashboardsToExport.map(({ id }) => id);
setPreparingExport(true);
try {
await handleResourceExport('dashboard', ids, () => {
const handleBulkDashboardExport = useCallback(
async (dashboardsToExport: Dashboard[]) => {
const ids = dashboardsToExport.map(({ id }) => id);
setPreparingExport(true);
try {
await handleResourceExport('dashboard', ids, () => {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the selected dashboards'));
}
};
addDangerToast(
t('There was an issue exporting the selected dashboards'),
);
}
},
[addDangerToast],
);
function handleBulkDashboardDelete(dashboardsToDelete: Dashboard[]) {
return SupersetClient.delete({
@@ -435,6 +440,38 @@ function DashboardList(props: DashboardListProps) {
return (
<Actions className="actions">
{canEdit && (
<Tooltip
id="edit-action-tooltip"
title={t('Edit')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleEdit}
>
<Icons.EditOutlined data-test="edit-alt" iconSize="l" />
</span>
</Tooltip>
)}
{canExport && (
<Tooltip
id="export-action-tooltip"
title={t('Export')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleExport}
>
<Icons.UploadOutlined iconSize="l" />
</span>
</Tooltip>
)}
{canDelete && (
<ConfirmStatusChange
title={t('Please confirm')}
@@ -467,38 +504,6 @@ function DashboardList(props: DashboardListProps) {
)}
</ConfirmStatusChange>
)}
{canExport && (
<Tooltip
id="export-action-tooltip"
title={t('Export')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleExport}
>
<Icons.UploadOutlined iconSize="l" />
</span>
</Tooltip>
)}
{canEdit && (
<Tooltip
id="edit-action-tooltip"
title={t('Edit')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleEdit}
>
<Icons.EditOutlined data-test="edit-alt" iconSize="l" />
</span>
</Tooltip>
)}
</Actions>
);
},
@@ -523,6 +528,8 @@ function DashboardList(props: DashboardListProps) {
refreshData,
addSuccessToast,
addDangerToast,
handleBulkDashboardExport,
openDashboardEditModal,
],
);
@@ -544,7 +551,7 @@ function DashboardList(props: DashboardListProps) {
);
const filters: ListViewFilters = useMemo(() => {
const filters_list = [
const filtersList = [
{
Header: t('Name'),
key: 'search',
@@ -594,7 +601,7 @@ function DashboardList(props: DashboardListProps) {
),
),
),
props.user,
user,
),
optionFilterProps: OWNER_OPTION_FILTER_PROPS,
paginate: true,
@@ -636,8 +643,8 @@ function DashboardList(props: DashboardListProps) {
dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
},
] as ListViewFilters;
return filters_list;
}, [addDangerToast, favoritesFilter, props.user]);
return filtersList;
}, [addDangerToast, canReadTag, favoritesFilter, user]);
const sortTypes = [
{
@@ -688,6 +695,8 @@ function DashboardList(props: DashboardListProps) {
user?.userId,
saveFavoriteStatus,
userKey,
handleBulkDashboardExport,
openDashboardEditModal,
],
);

View File

@@ -19,7 +19,7 @@
import { t } from '@apache-superset/core';
import { getExtensionsRegistry, SupersetClient } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo, useEffect, useCallback } from 'react';
import rison from 'rison';
import { useSelector } from 'react-redux';
import { useQueryParams, BooleanParam } from 'use-query-params';
@@ -169,26 +169,29 @@ function DatabaseList({
}
}, [query, setQuery, refreshData]);
const openDatabaseDeleteModal = (database: DatabaseObject) =>
SupersetClient.get({
endpoint: `/api/v1/database/${database.id}/related_objects/`,
})
.then(({ json = {} }) => {
setDatabaseCurrentlyDeleting({
...database,
charts: json.charts,
dashboards: json.dashboards,
sqllab_tab_count: json.sqllab_tab_states.count,
});
const openDatabaseDeleteModal = useCallback(
(database: DatabaseObject) =>
SupersetClient.get({
endpoint: `/api/v1/database/${database.id}/related_objects/`,
})
.catch(
createErrorHandler(errMsg =>
t(
'An error occurred while fetching database related data: %s',
errMsg,
.then(({ json = {} }) => {
setDatabaseCurrentlyDeleting({
...database,
charts: json.charts,
dashboards: json.dashboards,
sqllab_tab_count: json.sqllab_tab_states.count,
});
})
.catch(
createErrorHandler(errMsg =>
t(
'An error occurred while fetching database related data: %s',
errMsg,
),
),
),
);
[],
);
function handleDatabaseDelete(database: DatabaseObject) {
const { id, database_name: dbName } = database;
@@ -216,14 +219,17 @@ function DatabaseList({
);
}
function handleDatabaseEditModal({
database = null,
modalOpen = false,
}: { database?: DatabaseObject | null; modalOpen?: boolean } = {}) {
// Set database and modal
setCurrentDatabase(database);
setDatabaseModalOpen(modalOpen);
}
const handleDatabaseEditModal = useCallback(
({
database = null,
modalOpen = false,
}: { database?: DatabaseObject | null; modalOpen?: boolean } = {}) => {
// Set database and modal
setCurrentDatabase(database);
setDatabaseModalOpen(modalOpen);
},
[],
);
const canCreate = hasPerm('can_write');
const canEdit = hasPerm('can_write');
@@ -328,59 +334,70 @@ function DatabaseList({
];
}
async function handleDatabaseExport(database: DatabaseObject) {
if (database.id === undefined) {
return;
}
const handleDatabaseExport = useCallback(
async (database: DatabaseObject) => {
if (database.id === undefined) {
return;
}
setPreparingExport(true);
try {
await handleResourceExport('database', [database.id], () => {
setPreparingExport(true);
try {
await handleResourceExport('database', [database.id], () => {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the database'));
}
}
addDangerToast(t('There was an issue exporting the database'));
}
},
[addDangerToast, setPreparingExport],
);
function handleDatabasePermSync(database: DatabaseObject) {
if (shouldSyncPermsInAsyncMode) {
addInfoToast(t('Validating connectivity for %s', database.database_name));
} else {
addInfoToast(t('Syncing permissions for %s', database.database_name));
}
SupersetClient.post({
endpoint: `/api/v1/database/${database.id}/sync_permissions/`,
}).then(
({ response }) => {
// Sync request
if (response.status === 200) {
addSuccessToast(
t('Permissions successfully synced for %s', database.database_name),
);
}
// Async request
else {
addInfoToast(
const handleDatabasePermSync = useCallback(
(database: DatabaseObject) => {
if (shouldSyncPermsInAsyncMode) {
addInfoToast(
t('Validating connectivity for %s', database.database_name),
);
} else {
addInfoToast(t('Syncing permissions for %s', database.database_name));
}
SupersetClient.post({
endpoint: `/api/v1/database/${database.id}/sync_permissions/`,
}).then(
({ response }) => {
// Sync request
if (response.status === 200) {
addSuccessToast(
t(
'Permissions successfully synced for %s',
database.database_name,
),
);
}
// Async request
else {
addInfoToast(
t(
'Syncing permissions for %s in the background',
database.database_name,
),
);
}
},
createErrorHandler(errMsg =>
addDangerToast(
t(
'Syncing permissions for %s in the background',
'An error occurred while syncing permissions for %s: %s',
database.database_name,
errMsg,
),
);
}
},
createErrorHandler(errMsg =>
addDangerToast(
t(
'An error occurred while syncing permissions for %s: %s',
database.database_name,
errMsg,
),
),
),
);
}
);
},
[shouldSyncPermsInAsyncMode, addInfoToast, addSuccessToast, addDangerToast],
);
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
@@ -487,39 +504,6 @@ function DatabaseList({
}
return (
<Actions className="actions">
{canDelete && (
<span
role="button"
tabIndex={0}
className="action-button"
data-test="database-delete"
onClick={handleDelete}
>
<Tooltip
id="delete-action-tooltip"
title={t('Delete database')}
placement="bottom"
>
<Icons.DeleteOutlined iconSize="l" />
</Tooltip>
</span>
)}
{canExport && (
<Tooltip
id="export-action-tooltip"
title={t('Export')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleExport}
>
<Icons.UploadOutlined iconSize="l" />
</span>
</Tooltip>
)}
{canEdit && (
<Tooltip
id="edit-action-tooltip"
@@ -537,6 +521,22 @@ function DatabaseList({
</span>
</Tooltip>
)}
{canExport && (
<Tooltip
id="export-action-tooltip"
title={t('Export')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleExport}
>
<Icons.UploadOutlined iconSize="l" />
</span>
</Tooltip>
)}
{canEdit && (
<Tooltip
id="sync-action-tooltip"
@@ -554,6 +554,23 @@ function DatabaseList({
</span>
</Tooltip>
)}
{canDelete && (
<span
role="button"
tabIndex={0}
className="action-button"
data-test="database-delete"
onClick={handleDelete}
>
<Tooltip
id="delete-action-tooltip"
title={t('Delete database')}
placement="bottom"
>
<Icons.DeleteOutlined iconSize="l" />
</Tooltip>
</span>
)}
</Actions>
);
},
@@ -568,7 +585,15 @@ function DatabaseList({
id: QueryObjectColumns.ChangedBy,
},
],
[canDelete, canEdit, canExport],
[
canDelete,
canEdit,
canExport,
handleDatabaseEditModal,
handleDatabaseExport,
handleDatabasePermSync,
openDatabaseDeleteModal,
],
);
const filters: ListViewFilters = useMemo(
@@ -634,7 +659,7 @@ function DatabaseList({
dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
},
],
[],
[user],
);
return (

View File

@@ -228,17 +228,19 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const addCertificationFields = json.result.columns.map(
(column: ColumnObject) => {
const {
certification: { details = '', certified_by = '' } = {},
certification: {
details = '',
certified_by: certifiedBy = '',
} = {},
} = JSON.parse(column.extra || '{}') || {};
return {
...column,
certification_details: details || '',
certified_by: certified_by || '',
is_certified: details || certified_by,
certified_by: certifiedBy || '',
is_certified: details || certifiedBy,
};
},
);
// eslint-disable-next-line no-param-reassign
json.result.columns = [...addCertificationFields];
setDatasetCurrentlyEditing(json.result);
})
@@ -251,51 +253,53 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
[addDangerToast],
);
const openDatasetDeleteModal = (dataset: Dataset) =>
SupersetClient.get({
endpoint: `/api/v1/dataset/${dataset.id}/related_objects`,
})
.then(({ json = {} }) => {
setDatasetCurrentlyDeleting({
...dataset,
charts: json.charts,
dashboards: json.dashboards,
});
const openDatasetDeleteModal = useCallback(
(dataset: Dataset) =>
SupersetClient.get({
endpoint: `/api/v1/dataset/${dataset.id}/related_objects`,
})
.catch(
createErrorHandler(errMsg =>
t(
'An error occurred while fetching dataset related data: %s',
errMsg,
.then(({ json = {} }) => {
setDatasetCurrentlyDeleting({
...dataset,
charts: json.charts,
dashboards: json.dashboards,
});
})
.catch(
createErrorHandler(errMsg =>
t(
'An error occurred while fetching dataset related data: %s',
errMsg,
),
),
),
);
[],
);
const openDatasetDuplicateModal = (dataset: VirtualDataset) => {
const openDatasetDuplicateModal = useCallback((dataset: VirtualDataset) => {
setDatasetCurrentlyDuplicating(dataset);
};
}, []);
const handleBulkDatasetExport = async (datasetsToExport: Dataset[]) => {
const ids = datasetsToExport.map(({ id }) => id);
setPreparingExport(true);
try {
await handleResourceExport('dataset', ids, () => {
const handleBulkDatasetExport = useCallback(
async (datasetsToExport: Dataset[]) => {
const ids = datasetsToExport.map(({ id }) => id);
setPreparingExport(true);
try {
await handleResourceExport('dataset', ids, () => {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the selected datasets'));
}
};
addDangerToast(t('There was an issue exporting the selected datasets'));
}
},
[addDangerToast, setPreparingExport],
);
const columns = useMemo(
() => [
{
Cell: ({
row: {
original: { kind },
},
}: any) => null,
Cell: () => null,
accessor: 'kind_icon',
disableSortBy: true,
size: 'xs',
@@ -432,38 +436,6 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
}
return (
<Actions className="actions">
{canDelete && (
<Tooltip
id="delete-action-tooltip"
title={t('Delete')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleDelete}
>
<Icons.DeleteOutlined iconSize="l" />
</span>
</Tooltip>
)}
{canExport && (
<Tooltip
id="export-action-tooltip"
title={t('Export')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleExport}
>
<Icons.UploadOutlined iconSize="l" />
</span>
</Tooltip>
)}
{canEdit && (
<Tooltip
id="edit-action-tooltip"
@@ -486,6 +458,22 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
</span>
</Tooltip>
)}
{canExport && (
<Tooltip
id="export-action-tooltip"
title={t('Export')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleExport}
>
<Icons.UploadOutlined iconSize="l" />
</span>
</Tooltip>
)}
{canDuplicate && original.kind === 'virtual' && (
<Tooltip
id="duplicate-action-tooltip"
@@ -502,6 +490,22 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
</span>
</Tooltip>
)}
{canDelete && (
<Tooltip
id="delete-action-tooltip"
title={t('Delete')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleDelete}
>
<Icons.DeleteOutlined iconSize="l" />
</span>
</Tooltip>
)}
</Actions>
);
},
@@ -516,7 +520,18 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
id: QueryObjectColumns.ChangedBy,
},
],
[canEdit, canDelete, canExport, openDatasetEditModal, canDuplicate, user],
[
canEdit,
canDelete,
canExport,
canDuplicate,
openDatasetEditModal,
openDatasetDeleteModal,
openDatasetDuplicateModal,
handleBulkDatasetExport,
PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET,
user,
],
);
const filterTypes: ListViewFilters = useMemo(

View File

@@ -18,7 +18,7 @@
*/
import { t } from '@apache-superset/core';
import { SupersetClient } from '@superset-ui/core';
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { ConfirmStatusChange, Tooltip } from '@superset-ui/core/components';
import {
ModifiedInfo,
@@ -51,7 +51,7 @@ interface RLSProps {
function RowLevelSecurityList(props: RLSProps) {
const { addDangerToast, addSuccessToast, user } = props;
const [ruleModalOpen, setRuleModalOpen] = useState<boolean>(false);
const [currentRule, setCurrentRule] = useState(null);
const [currentRule, setCurrentRule] = useState<RLSObject | null>(null);
const {
state: {
@@ -74,29 +74,31 @@ function RowLevelSecurityList(props: RLSProps) {
true,
);
function handleRuleEdit(rule: null) {
const handleRuleEdit = useCallback((rule: RLSObject | null) => {
setCurrentRule(rule);
setRuleModalOpen(true);
}
}, []);
function handleRuleDelete(
{ id, name }: RLSObject,
refreshData: (arg0?: FetchDataConfig | null) => void,
addSuccessToast: (arg0: string) => void,
addDangerToast: (arg0: string) => void,
) {
return SupersetClient.delete({
endpoint: `/api/v1/rowlevelsecurity/${id}`,
}).then(
() => {
refreshData();
addSuccessToast(t('Deleted %s', name));
},
createErrorHandler(errMsg =>
addDangerToast(t('There was an issue deleting %s: %s', name, errMsg)),
const handleRuleDelete = useCallback(
(
{ id, name }: RLSObject,
refreshData: (arg0?: FetchDataConfig | null) => void,
addSuccessToast: (arg0: string) => void,
addDangerToast: (arg0: string) => void,
) =>
SupersetClient.delete({
endpoint: `/api/v1/rowlevelsecurity/${id}`,
}).then(
() => {
refreshData();
addSuccessToast(t('Deleted %s', name));
},
createErrorHandler(errMsg =>
addDangerToast(t('There was an issue deleting %s: %s', name, errMsg)),
),
),
);
}
[],
);
function handleBulkRulesDelete(rulesToDelete: RLSObject[]) {
const ids = rulesToDelete.map(({ id }) => id);
return SupersetClient.delete({
@@ -174,6 +176,22 @@ function RowLevelSecurityList(props: RLSProps) {
const handleEdit = () => handleRuleEdit(original);
return (
<div className="actions">
{canEdit && (
<Tooltip
id="edit-action-tooltip"
title={t('Edit')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleEdit}
>
<Icons.EditOutlined data-test="edit-alt" iconSize="l" />
</span>
</Tooltip>
)}
{canWrite && (
<ConfirmStatusChange
title={t('Please confirm')}
@@ -206,22 +224,6 @@ function RowLevelSecurityList(props: RLSProps) {
)}
</ConfirmStatusChange>
)}
{canEdit && (
<Tooltip
id="edit-action-tooltip"
title={t('Edit')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleEdit}
>
<Icons.EditOutlined data-test="edit-alt" iconSize="l" />
</span>
</Tooltip>
)}
</div>
);
},
@@ -238,14 +240,14 @@ function RowLevelSecurityList(props: RLSProps) {
},
],
[
user.userId,
canEdit,
canWrite,
canExport,
hasPerm,
refreshData,
addDangerToast,
addSuccessToast,
handleRuleDelete,
handleRuleEdit,
],
);

View File

@@ -451,13 +451,6 @@ function SavedQueryList({
const handleDelete = () => setQueryCurrentlyDeleting(original);
const actions = [
{
label: 'preview-action',
tooltip: t('Query preview'),
placement: 'bottom',
icon: 'Binoculars',
onClick: handlePreview,
},
canEdit && {
label: 'edit-action',
tooltip: t('Edit query'),
@@ -465,6 +458,13 @@ function SavedQueryList({
icon: 'EditOutlined',
onClick: handleEdit,
},
{
label: 'preview-action',
tooltip: t('Query preview'),
placement: 'bottom',
icon: 'Binoculars',
onClick: handlePreview,
},
{
label: 'copy-action',
tooltip: t('Copy query URL'),

View File

@@ -439,15 +439,6 @@ function ThemesList({
const handleExport = () => handleBulkThemeExport([original]);
const actions = [
canApply
? {
label: 'apply-action',
tooltip: t('Set local theme for testing'),
placement: 'bottom',
icon: 'ThunderboltOutlined',
onClick: handleApply,
}
: null,
canEdit
? {
label: 'edit-action',
@@ -457,6 +448,15 @@ function ThemesList({
onClick: handleEdit,
}
: null,
canApply
? {
label: 'apply-action',
tooltip: t('Set local theme for testing'),
placement: 'bottom',
icon: 'ThunderboltOutlined',
onClick: handleApply,
}
: null,
canExport
? {
label: 'export-action',

View File

@@ -16,245 +16,724 @@
* specific language governing permissions and limitations
* under the License.
*/
import { renderHook } from '@testing-library/react-hooks';
import { renderHook, act } from '@testing-library/react-hooks';
import { waitFor } from 'spec/helpers/testing-library';
import { JsonResponse, SupersetClient } from '@superset-ui/core';
import { useListViewResource } from './hooks';
import {
useListViewResource,
useSingleViewResource,
useFavoriteStatus,
useChartEditModal,
} from './hooks';
import type Chart from 'src/types/Chart';
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('useListViewResource', () => {
afterEach(() => {
jest.restoreAllMocks();
});
/** Find the endpoint string from a spy's mock calls that matches a substring. */
function findEndpoint(spy: jest.SpyInstance, substring: string): string {
const match = spy.mock.calls.find(
(call: unknown[]) =>
typeof (call[0] as Record<string, string>)?.endpoint === 'string' &&
(call[0] as Record<string, string>).endpoint.includes(substring),
);
test('should fetch data with correct query parameters', async () => {
const pageIndex = 0; // Declare and initialize the pageIndex variable
const pageSize = 10; // Declare and initialize the pageSize variable
const baseFilters = [{ id: 'status', operator: 'equals', value: 'active' }];
if (!match) {
throw new Error(`No call found with endpoint containing "${substring}"`);
}
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {
result: {
dashboard_title: 'New Title',
slug: '/new',
json_metadata: '{"something":"foo"}',
owners: [{ id: 1, first_name: 'Al', last_name: 'Pacino' }],
roles: [],
},
},
} as unknown as JsonResponse);
return (match[0] as Record<string, string>).endpoint;
}
const { result } = renderHook(() =>
useListViewResource('example', 'Example', jest.fn()),
);
result.current.fetchData({
pageIndex,
pageSize,
sortBy: [{ id: 'foo' }], // Change the type of sortBy from string to SortColumn[]
filters: baseFilters,
});
beforeEach(() => {
jest.restoreAllMocks();
});
expect(fetchSpy).toHaveBeenNthCalledWith(2, {
endpoint:
'/api/v1/example/?q=(filters:!((col:status,opr:equals,value:active)),order_column:foo,order_direction:asc,page:0,page_size:10)',
// useListViewResource
test('useListViewResource: initial state has loading true and empty collection', () => {
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { permissions: ['can_read'] },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);
expect(result.current.state.loading).toBe(true);
expect(result.current.state.resourceCollection).toEqual([]);
expect(result.current.state.resourceCount).toBe(0);
expect(result.current.state.bulkSelectEnabled).toBe(false);
});
test('useListViewResource: fetches permissions on mount', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { permissions: ['can_read', 'can_write'] },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);
await waitFor(() => {
expect(getSpy).toHaveBeenCalledWith({
endpoint: expect.stringContaining('/api/v1/chart/_info'),
});
});
test('should pass the selectColumns to the fetch call', async () => {
const pageIndex = 0; // Declare and initialize the pageIndex variable
const pageSize = 10; // Declare and initialize the pageSize variable
const baseFilters = [{ id: 'status', operator: 'equals', value: 'active' }];
const selectColumns = ['id', 'name'];
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {
result: {
dashboard_title: 'New Title',
slug: '/new',
json_metadata: '{"something":"foo"}',
owners: [{ id: 1, first_name: 'Al', last_name: 'Pacino' }],
roles: [],
},
},
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource(
'example',
'Example',
jest.fn(),
undefined,
undefined,
undefined,
undefined,
selectColumns,
),
);
result.current.fetchData({
pageIndex,
pageSize,
sortBy: [{ id: 'foo' }], // Change the type of sortBy from string to SortColumn[]
filters: baseFilters,
});
expect(fetchSpy).toHaveBeenNthCalledWith(2, {
endpoint:
'/api/v1/example/?q=(filters:!((col:status,opr:equals,value:active)),order_column:foo,order_direction:asc,page:0,page_size:10,select_columns:!(id,name))',
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('ChartList-specific filter scenarios', () => {
afterEach(() => {
jest.restoreAllMocks();
});
test('converts Type filter to correct API call for charts', async () => {
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Chart', jest.fn()),
);
const typeFilter = [{ id: 'viz_type', operator: 'eq', value: 'table' }];
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'changed_on_delta_humanized', desc: true }],
filters: typeFilter,
});
expect(fetchSpy).toHaveBeenNthCalledWith(2, {
endpoint: expect.stringContaining('/api/v1/chart/?q='),
});
const call = fetchSpy.mock.calls[1];
const { endpoint } = call[0];
expect(endpoint).toMatch(/col:viz_type/);
expect(endpoint).toMatch(/opr:eq/);
expect(endpoint).toMatch(/value:table/);
expect(endpoint).toMatch(/order_column:changed_on_delta_humanized/);
expect(endpoint).toMatch(/order_direction:desc/);
});
test('converts chart search filter with ChartAllText operator', async () => {
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Chart', jest.fn()),
);
const searchFilter = [
{
id: 'slice_name',
operator: 'chart_all_text',
value: 'test chart',
},
];
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'changed_on_delta_humanized', desc: true }],
filters: searchFilter,
});
const call = fetchSpy.mock.calls[1];
const { endpoint } = call[0];
expect(endpoint).toContain('col%3Aslice_name');
expect(endpoint).toContain('opr%3Achart_all_text');
expect(endpoint).toContain("value%3A'test+chart'");
});
test('converts chart-specific favorite filter', async () => {
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Chart', jest.fn()),
);
const favoriteFilter = [
{ id: 'id', operator: 'chart_is_favorite', value: true },
];
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'changed_on_delta_humanized', desc: true }],
filters: favoriteFilter,
});
const call = fetchSpy.mock.calls[1];
const { endpoint } = call[0];
expect(endpoint).toMatch(/col:id/);
expect(endpoint).toMatch(/opr:chart_is_favorite/);
expect(endpoint).toContain('value:!t');
});
test('handles multiple chart filters correctly', async () => {
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Chart', jest.fn()),
);
const multipleFilters = [
{ id: 'viz_type', operator: 'eq', value: 'table' },
{ id: 'slice_name', operator: 'chart_all_text', value: 'test' },
];
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'changed_on_delta_humanized', desc: true }],
filters: multipleFilters,
});
const call = fetchSpy.mock.calls[1];
const { endpoint } = call[0];
// Should contain both filters
expect(endpoint).toMatch(/col:viz_type/);
expect(endpoint).toMatch(/value:table/);
expect(endpoint).toMatch(/col:slice_name/);
expect(endpoint).toMatch(/value:test/);
});
test('handles chart sorting scenarios', async () => {
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Chart', jest.fn()),
);
// Test alphabetical sort (slice_name ASC)
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'slice_name', desc: false }],
filters: [],
});
const call = fetchSpy.mock.calls[1];
const { endpoint } = call[0];
expect(endpoint).toMatch(/order_column:slice_name/);
expect(endpoint).toMatch(/order_direction:asc/);
});
await waitFor(() => {
expect(result.current.hasPerm('can_read')).toBe(true);
expect(result.current.hasPerm('can_write')).toBe(true);
expect(result.current.hasPerm('can_delete')).toBe(false);
});
});
test('useListViewResource: skips permissions fetch when infoEnable is false', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { permissions: [] },
} as unknown as JsonResponse);
renderHook(() => useListViewResource('chart', 'Charts', jest.fn(), false));
await act(async () => {});
// No API call expected
expect(getSpy).not.toHaveBeenCalled();
});
test('useListViewResource: hasPerm returns false when permissions are empty', async () => {
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {},
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);
expect(result.current.hasPerm('can_read')).toBe(false);
});
test('useListViewResource: fetchData calls correct endpoint and updates state', async () => {
const mockData = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
];
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: mockData, count: 2 },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);
await act(async () => {
await result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name', desc: false }],
filters: [],
});
});
// First call is permissions _info, second is the data fetch
const endpoint = findEndpoint(getSpy, '/api/v1/chart/?q=');
expect(endpoint).toContain('order_column:name');
expect(endpoint).toContain('order_direction:asc');
expect(endpoint).toContain('page:0');
expect(endpoint).toContain('page_size:25');
await waitFor(() => {
expect(result.current.state.resourceCollection).toEqual(mockData);
expect(result.current.state.resourceCount).toBe(2);
expect(result.current.state.loading).toBe(false);
});
});
test('useListViewResource: fetchData includes selectColumns in query', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
const selectColumns = ['id', 'name'];
const { result } = renderHook(() =>
useListViewResource(
'chart',
'Charts',
jest.fn(),
undefined,
undefined,
undefined,
undefined,
selectColumns,
),
);
await act(async () => {
await result.current.fetchData({
pageIndex: 0,
pageSize: 10,
sortBy: [{ id: 'name' }],
filters: [],
});
});
const endpoint = findEndpoint(getSpy, '/api/v1/chart/?q=');
expect(endpoint).toContain('select_columns:!(id,name)');
});
test('useListViewResource: fetchData merges baseFilters with user filters', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
const baseFilters = [{ id: 'published', operator: 'eq', value: true }];
const { result } = renderHook(() =>
useListViewResource(
'dashboard',
'Dashboards',
jest.fn(),
true,
[],
baseFilters,
),
);
await act(async () => {
await result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'title' }],
filters: [{ id: 'name', operator: 'ct', value: 'test' }],
});
});
const endpoint = findEndpoint(getSpy, '/api/v1/dashboard/?q=');
// Both base filter and user filter should be present
expect(endpoint).toContain('col:published');
expect(endpoint).toContain('col:name');
});
test('useListViewResource: fetchData filters out empty/null/undefined filter values', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);
await act(async () => {
await result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [
{ id: 'name', operator: 'ct', value: 'keep' },
{ id: 'empty', operator: 'eq', value: '' },
{ id: 'nullval', operator: 'eq', value: null },
{ id: 'undef', operator: 'eq', value: undefined },
],
});
});
const endpoint = findEndpoint(getSpy, '/api/v1/chart/?q=');
expect(endpoint).toContain('col:name');
expect(endpoint).not.toContain('col:empty');
expect(endpoint).not.toContain('col:nullval');
expect(endpoint).not.toContain('col:undef');
});
test('useListViewResource: fetchData sets loading to true then false', async () => {
let resolveGet: ((value: unknown) => void) | undefined;
jest.spyOn(SupersetClient, 'get').mockImplementation(
() =>
new Promise(resolve => {
resolveGet = resolve;
}) as Promise<JsonResponse>,
);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn(), false),
);
// Initial loading
expect(result.current.state.loading).toBe(true);
act(() => {
result.current.fetchData({
pageIndex: 0,
pageSize: 10,
sortBy: [{ id: 'name' }],
filters: [],
});
});
// Loading should be true while fetching
expect(result.current.state.loading).toBe(true);
await act(async () => {
expect(resolveGet).toBeDefined();
resolveGet?.({ json: { result: [], count: 0 } });
});
await waitFor(() => {
expect(result.current.state.loading).toBe(false);
});
});
test('useListViewResource: refreshData re-fetches with last config', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);
const config = {
pageIndex: 2,
pageSize: 50,
sortBy: [{ id: 'name', desc: true }],
filters: [],
};
// FetchData to cache the config
await act(async () => {
await result.current.fetchData(config);
});
getSpy.mockClear();
// RefreshData should reuse the cached config
await act(async () => {
await result.current.refreshData();
});
expect(getSpy).toHaveBeenCalledWith({
endpoint: expect.stringContaining('page:2'),
});
expect(getSpy).toHaveBeenCalledWith({
endpoint: expect.stringContaining('page_size:50'),
});
});
test('useListViewResource: refreshData returns null when no cached config', () => {
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {},
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);
const returnValue = result.current.refreshData();
expect(returnValue).toBeNull();
});
test('useListViewResource: toggleBulkSelect toggles bulkSelectEnabled', async () => {
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {},
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);
expect(result.current.state.bulkSelectEnabled).toBe(false);
act(() => {
result.current.toggleBulkSelect();
});
expect(result.current.state.bulkSelectEnabled).toBe(true);
act(() => {
result.current.toggleBulkSelect();
});
expect(result.current.state.bulkSelectEnabled).toBe(false);
});
test('useListViewResource: setResourceCollection updates the collection', async () => {
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {},
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);
const newCollection = [{ id: 1 }, { id: 2 }];
act(() => {
result.current.setResourceCollection(newCollection);
});
expect(result.current.state.resourceCollection).toEqual(newCollection);
});
test('useListViewResource: uses desc sort direction when desc is true', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);
await act(async () => {
await result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'changed_on', desc: true }],
filters: [],
});
});
const endpoint = findEndpoint(getSpy, '/api/v1/chart/?q=');
expect(endpoint).toContain('order_direction:desc');
});
// useSingleViewResource
test('useSingleViewResource: initial state has loading false and null resource', () => {
const { result } = renderHook(() =>
useSingleViewResource('chart', 'Charts', jest.fn()),
);
expect(result.current.state.loading).toBe(false);
expect(result.current.state.resource).toBeNull();
expect(result.current.state.error).toBeNull();
});
test('useSingleViewResource: fetchResource calls correct endpoint', async () => {
const mockResult = { id: 42, name: 'Test Chart' };
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: mockResult },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useSingleViewResource('chart', 'Charts', jest.fn()),
);
await act(async () => {
await result.current.fetchResource(42);
});
expect(getSpy).toHaveBeenCalledWith({
endpoint: '/api/v1/chart/42',
});
await waitFor(() => {
expect(result.current.state.resource).toEqual(mockResult);
expect(result.current.state.loading).toBe(false);
expect(result.current.state.error).toBeNull();
});
});
test('useSingleViewResource: fetchResource appends pathSuffix', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: {} },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useSingleViewResource('chart', 'Charts', jest.fn(), 'related_objects'),
);
await act(async () => {
await result.current.fetchResource(42);
});
expect(getSpy).toHaveBeenCalledWith({
endpoint: '/api/v1/chart/42/related_objects',
});
});
test('useSingleViewResource: createResource posts to correct endpoint', async () => {
const postSpy = jest.spyOn(SupersetClient, 'post').mockResolvedValue({
json: { id: 99, result: { name: 'New Chart' } },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useSingleViewResource('chart', 'Charts', jest.fn()),
);
let createdId: number | undefined;
await act(async () => {
createdId = await result.current.createResource({
name: 'New Chart',
} as any);
});
expect(postSpy).toHaveBeenCalledWith({
endpoint: '/api/v1/chart/',
body: JSON.stringify({ name: 'New Chart' }),
headers: { 'Content-Type': 'application/json' },
});
expect(createdId).toBe(99);
await waitFor(() => {
expect(result.current.state.loading).toBe(false);
});
});
test('useSingleViewResource: updateResource puts to correct endpoint', async () => {
const putSpy = jest.spyOn(SupersetClient, 'put').mockResolvedValue({
json: { id: 42, result: { name: 'Updated' } },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useSingleViewResource('chart', 'Charts', jest.fn()),
);
await act(async () => {
await result.current.updateResource(42, { name: 'Updated' } as any);
});
expect(putSpy).toHaveBeenCalledWith({
endpoint: '/api/v1/chart/42',
body: JSON.stringify({ name: 'Updated' }),
headers: { 'Content-Type': 'application/json' },
});
await waitFor(() => {
expect(result.current.state.resource).toEqual({ name: 'Updated', id: 42 });
expect(result.current.state.loading).toBe(false);
});
});
test('useSingleViewResource: clearError resets error to null', async () => {
// First make a failing request to get an error state
jest.spyOn(SupersetClient, 'get').mockRejectedValue('Network error');
const { result } = renderHook(() =>
useSingleViewResource('chart', 'Charts', jest.fn()),
);
await act(async () => {
try {
await result.current.fetchResource(1);
} catch {
// expected
}
});
act(() => {
result.current.clearError();
});
expect(result.current.state.error).toBeNull();
});
test('useSingleViewResource: setResource updates the resource', () => {
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {},
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useSingleViewResource('chart', 'Charts', jest.fn()),
);
act(() => {
result.current.setResource({ id: 1, name: 'Manual' } as any);
});
expect(result.current.state.resource).toEqual({ id: 1, name: 'Manual' });
});
// useFavoriteStatus
test('useFavoriteStatus: does not fetch when ids array is empty', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [] },
} as unknown as JsonResponse);
renderHook(() => useFavoriteStatus('chart', [], jest.fn()));
await act(async () => {});
// No API call should have been made
expect(getSpy).not.toHaveBeenCalled();
});
test('useFavoriteStatus: saveFaveStar posts when not starred', async () => {
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [] },
} as unknown as JsonResponse);
const postSpy = jest
.spyOn(SupersetClient, 'post')
.mockResolvedValue({} as JsonResponse);
const { result } = renderHook(() =>
useFavoriteStatus('chart', [], jest.fn()),
);
await act(async () => {
// isStarred = false --> should POST to add favorite
result.current[0](42, false);
});
expect(postSpy).toHaveBeenCalledWith({
endpoint: '/api/v1/chart/42/favorites/',
});
});
test('useFavoriteStatus: saveFaveStar deletes when already starred', async () => {
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [] },
} as unknown as JsonResponse);
const deleteSpy = jest
.spyOn(SupersetClient, 'delete')
.mockResolvedValue({} as JsonResponse);
const { result } = renderHook(() =>
useFavoriteStatus('chart', [], jest.fn()),
);
await act(async () => {
// isStarred = true --> should DELETE to remove favorite
result.current[0](42, true);
});
expect(deleteSpy).toHaveBeenCalledWith({
endpoint: '/api/v1/chart/42/favorites/',
});
});
test('useFavoriteStatus: saveFaveStar updates local status on success', async () => {
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [] },
} as unknown as JsonResponse);
jest.spyOn(SupersetClient, 'post').mockResolvedValue({} as JsonResponse);
const { result } = renderHook(() =>
useFavoriteStatus('chart', [], jest.fn()),
);
// Star a chart
await act(async () => {
result.current[0](42, false);
});
await waitFor(() => {
expect(result.current[1]).toEqual(expect.objectContaining({ 42: true }));
});
});
test('useFavoriteStatus: saveFaveStar uses correct endpoint per type', async () => {
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [] },
} as unknown as JsonResponse);
const postSpy = jest
.spyOn(SupersetClient, 'post')
.mockResolvedValue({} as JsonResponse);
const { result } = renderHook(() =>
useFavoriteStatus('dashboard', [], jest.fn()),
);
await act(async () => {
result.current[0](7, false);
});
expect(postSpy).toHaveBeenCalledWith({
endpoint: '/api/v1/dashboard/7/favorites/',
});
});
// useChartEditModal
test('useChartEditModal: openChartEditModal sets sliceCurrentlyEditing', () => {
const mockChart: Chart = {
id: 1,
slice_name: 'Test Chart',
description: 'A test',
cache_timeout: 300,
certified_by: 'Admin',
certification_details: 'Certified',
is_managed_externally: false,
} as Chart;
const { result } = renderHook(() =>
useChartEditModal(jest.fn(), [mockChart]),
);
expect(result.current.sliceCurrentlyEditing).toBeNull();
act(() => {
result.current.openChartEditModal(mockChart);
});
expect(result.current.sliceCurrentlyEditing).toEqual({
slice_id: 1,
slice_name: 'Test Chart',
description: 'A test',
cache_timeout: 300,
certified_by: 'Admin',
certification_details: 'Certified',
is_managed_externally: false,
});
});
test('useChartEditModal: closeChartEditModal clears sliceCurrentlyEditing', () => {
const mockChart: Chart = {
id: 1,
slice_name: 'Test Chart',
} as Chart;
const { result } = renderHook(() =>
useChartEditModal(jest.fn(), [mockChart]),
);
act(() => {
result.current.openChartEditModal(mockChart);
});
expect(result.current.sliceCurrentlyEditing).not.toBeNull();
act(() => {
result.current.closeChartEditModal();
});
expect(result.current.sliceCurrentlyEditing).toBeNull();
});
test('useChartEditModal: handleChartUpdated merges edits into chart list', () => {
const setCharts = jest.fn();
const charts: Chart[] = [
{ id: 1, slice_name: 'Original' } as Chart,
{ id: 2, slice_name: 'Other' } as Chart,
];
const { result } = renderHook(() => useChartEditModal(setCharts, charts));
act(() => {
result.current.handleChartUpdated({
id: 1,
slice_name: 'Updated Name',
} as Chart);
});
expect(setCharts).toHaveBeenCalledWith([
{ id: 1, slice_name: 'Updated Name' },
{ id: 2, slice_name: 'Other' },
]);
});
test('useChartEditModal: handleChartUpdated leaves non-matching charts unchanged', () => {
const setCharts = jest.fn();
const charts: Chart[] = [
{ id: 1, slice_name: 'A' } as Chart,
{ id: 2, slice_name: 'B' } as Chart,
];
const { result } = renderHook(() => useChartEditModal(setCharts, charts));
act(() => {
result.current.handleChartUpdated({
id: 999,
slice_name: 'Nonexistent',
} as Chart);
});
expect(setCharts).toHaveBeenCalledWith([
{ id: 1, slice_name: 'A' },
{ id: 2, slice_name: 'B' },
]);
});

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import rison from 'rison';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { t } from '@apache-superset/core';
import {
makeApi,
@@ -91,14 +91,22 @@ export function useListViewResource<D extends object = any>(
bulkSelectEnabled: false,
});
function updateState(update: Partial<ListViewResourceState<D>>) {
setState(currentState => ({ ...currentState, ...update }));
}
const updateState = useCallback(
(update: Partial<ListViewResourceState<D>>) => {
setState(currentState => ({ ...currentState, ...update }));
},
[],
);
function toggleBulkSelect() {
updateState({ bulkSelectEnabled: !state.bulkSelectEnabled });
}
const handleErrorMsgRef = useRef(handleErrorMsg);
useEffect(() => {
handleErrorMsgRef.current = handleErrorMsg;
});
useEffect(() => {
if (!infoEnable) return;
SupersetClient.get({
@@ -112,7 +120,7 @@ export function useListViewResource<D extends object = any>(
});
},
createErrorHandler(errMsg =>
handleErrorMsg(
handleErrorMsgRef.current(
t(
'An error occurred while fetching %s info: %s',
resourceLabel,
@@ -121,15 +129,20 @@ export function useListViewResource<D extends object = any>(
),
),
);
}, []);
}, [infoEnable, resource, resourceLabel, updateState]);
function hasPerm(perm: string) {
if (!state.permissions.length) {
return false;
}
const hasPerm = useCallback(
(perm: string) => {
if (!state.permissions.length) {
return false;
}
return Boolean(state.permissions.find(p => p === perm));
}
return Boolean(state.permissions.find(p => p === perm));
},
[state.permissions],
);
const lastFetchDataConfigRef = useRef<FetchDataConfig | null>(null);
const fetchData = useCallback(
({
@@ -138,14 +151,16 @@ export function useListViewResource<D extends object = any>(
sortBy,
filters: filterValues,
}: FetchDataConfig) => {
const config: FetchDataConfig = {
filters: filterValues,
pageIndex,
pageSize,
sortBy,
};
lastFetchDataConfigRef.current = config;
// set loading state, cache the last config for refreshing data.
updateState({
lastFetchDataConfig: {
filters: filterValues,
pageIndex,
pageSize,
sortBy,
},
lastFetchDataConfig: config,
loading: true,
});
const filterExps = (baseFilters || [])
@@ -196,7 +211,27 @@ export function useListViewResource<D extends object = any>(
updateState({ loading: false });
});
},
[baseFilters],
[
baseFilters,
handleErrorMsg,
resource,
resourceLabel,
selectColumns,
updateState,
],
);
const refreshData = useCallback(
(provideConfig?: FetchDataConfig) => {
if (lastFetchDataConfigRef.current) {
return fetchData(lastFetchDataConfigRef.current);
}
if (provideConfig) {
return fetchData(provideConfig);
}
return null;
},
[fetchData],
);
return {
@@ -214,15 +249,7 @@ export function useListViewResource<D extends object = any>(
hasPerm,
fetchData,
toggleBulkSelect,
refreshData: (provideConfig?: FetchDataConfig) => {
if (state.lastFetchDataConfig) {
return fetchData(state.lastFetchDataConfig);
}
if (provideConfig) {
return fetchData(provideConfig);
}
return null;
},
refreshData,
};
}
@@ -237,7 +264,7 @@ export function useSingleViewResource<D extends object = any>(
resourceName: string,
resourceLabel: string, // resourceLabel for translations
handleErrorMsg: (errorMsg: string) => void,
path_suffix = '',
pathSuffix = '',
) {
const [state, setState] = useState<SingleViewResourceState<D>>({
loading: false,
@@ -245,9 +272,12 @@ export function useSingleViewResource<D extends object = any>(
error: null,
});
function updateState(update: Partial<SingleViewResourceState<D>>) {
setState(currentState => ({ ...currentState, ...update }));
}
const updateState = useCallback(
(update: Partial<SingleViewResourceState<D>>) => {
setState(currentState => ({ ...currentState, ...update }));
},
[],
);
const fetchResource = useCallback(
(resourceID: number) => {
@@ -258,7 +288,7 @@ export function useSingleViewResource<D extends object = any>(
const baseEndpoint = `/api/v1/${resourceName}/${resourceID}`;
const endpoint =
path_suffix !== '' ? `${baseEndpoint}/${path_suffix}` : baseEndpoint;
pathSuffix !== '' ? `${baseEndpoint}/${pathSuffix}` : baseEndpoint;
return SupersetClient.get({
endpoint,
})
@@ -288,7 +318,7 @@ export function useSingleViewResource<D extends object = any>(
updateState({ loading: false });
});
},
[handleErrorMsg, resourceName, resourceLabel],
[handleErrorMsg, pathSuffix, resourceName, resourceLabel, updateState],
);
const createResource = useCallback(
@@ -332,7 +362,7 @@ export function useSingleViewResource<D extends object = any>(
updateState({ loading: false });
});
},
[handleErrorMsg, resourceName, resourceLabel],
[handleErrorMsg, resourceName, resourceLabel, updateState],
);
const updateResource = useCallback(
@@ -381,7 +411,7 @@ export function useSingleViewResource<D extends object = any>(
}
});
},
[handleErrorMsg, resourceName, resourceLabel],
[handleErrorMsg, resourceName, resourceLabel, updateState],
);
const clearError = () =>
@@ -427,12 +457,12 @@ export function useImportResource(
failed: false,
});
function updateState(update: Partial<ImportResourceState>) {
const updateState = useCallback((update: Partial<ImportResourceState>) => {
setState(currentState => ({ ...currentState, ...update }));
}
}, []);
const importResource = useCallback(
(
async (
bundle: File,
databasePasswords: Record<string, string> = {},
sshTunnelPasswords: Record<string, string> = {},
@@ -553,7 +583,7 @@ export function useImportResource(
updateState({ loading: false });
});
},
[],
[handleErrorMsg, resourceLabel, resourceName, updateState],
);
return { state, importResource };
@@ -591,8 +621,11 @@ export function useFavoriteStatus(
) {
const [favoriteStatus, setFavoriteStatus] = useState<FavoriteStatus>({});
const updateFavoriteStatus = (update: FavoriteStatus) =>
setFavoriteStatus(currentState => ({ ...currentState, ...update }));
const updateFavoriteStatus = useCallback(
(update: FavoriteStatus) =>
setFavoriteStatus(currentState => ({ ...currentState, ...update })),
[],
);
useEffect(() => {
if (!ids.length) {
@@ -615,7 +648,7 @@ export function useFavoriteStatus(
),
),
);
}, [ids, type, handleErrorMsg]);
}, [ids, type, handleErrorMsg, updateFavoriteStatus]);
const saveFaveStar = useCallback(
(id: number, isStarred: boolean) => {
@@ -639,7 +672,7 @@ export function useFavoriteStatus(
),
);
},
[type],
[handleErrorMsg, type, updateFavoriteStatus],
);
return [saveFaveStar, favoriteStatus] as const;
@@ -652,7 +685,7 @@ export const useChartEditModal = (
const [sliceCurrentlyEditing, setSliceCurrentlyEditing] =
useState<Slice | null>(null);
function openChartEditModal(chart: Chart) {
const openChartEditModal = useCallback((chart: Chart) => {
setSliceCurrentlyEditing({
slice_id: chart.id,
slice_name: chart.slice_name,
@@ -662,11 +695,11 @@ export const useChartEditModal = (
certification_details: chart.certification_details,
is_managed_externally: chart.is_managed_externally,
});
}
}, []);
function closeChartEditModal() {
const closeChartEditModal = useCallback(() => {
setSliceCurrentlyEditing(null);
}
}, []);
function handleChartUpdated(edits: Chart) {
// update the chart in our state with the edited info
@@ -796,8 +829,8 @@ export function useDatabaseValidation() {
];
return allowed.includes(err.error_type) || onCreate;
})
.reduce((acc: JsonObject, err_2: any) => {
const { message, extra } = err_2;
.reduce((acc: JsonObject, err2: any) => {
const { message, extra } = err2;
if (extra?.catalog) {
const { idx } = extra.catalog;
@@ -816,8 +849,8 @@ export function useDatabaseValidation() {
}
if (extra?.missing) {
extra.missing.forEach((field_1: string) => {
acc[field_1] = 'This is a required field';
extra.missing.forEach((field1: string) => {
acc[field1] = 'This is a required field';
});
}