This is an automated email from the ASF dual-hosted git repository.
elizabeth pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 6fdaa8e9b36 fix(crud): reorder table actions + improve react
memoization + improve hooks (#37897)
6fdaa8e9b36 is described below
commit 6fdaa8e9b36cf5838d3d0d20a52ab492c0cfa06b
Author: Gabriel Torres Ruiz <[email protected]>
AuthorDate: Fri Feb 20 11:58:28 2026 -0500
fix(crud): reorder table actions + improve react memoization + improve
hooks (#37897)
---
superset-frontend/.eslintrc.js | 8 +-
superset-frontend/.eslintrc.minimal.js | 5 +-
.../pages/ChartList/ChartList.listview.test.tsx | 4 -
superset-frontend/src/pages/ChartList/index.tsx | 216 ++---
.../src/pages/DashboardList/index.tsx | 135 ++--
superset-frontend/src/pages/DatabaseList/index.tsx | 227 +++---
superset-frontend/src/pages/DatasetList/index.tsx | 141 ++--
.../src/pages/RowLevelSecurityList/index.tsx | 80 +-
.../src/pages/SavedQueryList/index.tsx | 14 +-
superset-frontend/src/pages/ThemeList/index.tsx | 18 +-
superset-frontend/src/views/CRUD/hooks.test.tsx | 889 ++++++++++++++++-----
superset-frontend/src/views/CRUD/hooks.ts | 137 ++--
12 files changed, 1231 insertions(+), 643 deletions(-)
diff --git a/superset-frontend/.eslintrc.js b/superset-frontend/.eslintrc.js
index bf6cb9b6fd6..96933e2f7da 100644
--- a/superset-frontend/.eslintrc.js
+++ b/superset-frontend/.eslintrc.js
@@ -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,
},
diff --git a/superset-frontend/.eslintrc.minimal.js
b/superset-frontend/.eslintrc.minimal.js
index 6018fdd0122..b6b796e0ad2 100644
--- a/superset-frontend/.eslintrc.minimal.js
+++ b/superset-frontend/.eslintrc.minimal.js
@@ -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
diff --git a/superset-frontend/src/pages/ChartList/ChartList.listview.test.tsx
b/superset-frontend/src/pages/ChartList/ChartList.listview.test.tsx
index 6d7f1d4cfe4..11220bc709c 100644
--- a/superset-frontend/src/pages/ChartList/ChartList.listview.test.tsx
+++ b/superset-frontend/src/pages/ChartList/ChartList.listview.test.tsx
@@ -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();
diff --git a/superset-frontend/src/pages/ChartList/index.tsx
b/superset-frontend/src/pages/ChartList/index.tsx
index d54a6932d48..50573e005cb 100644
--- a/superset-frontend/src/pages/ChartList/index.tsx
+++ b/superset-frontend/src/pages/ChartList/index.tsx
@@ -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,
],
);
diff --git a/superset-frontend/src/pages/DashboardList/index.tsx
b/superset-frontend/src/pages/DashboardList/index.tsx
index 6b96d36e13e..8c5a31f6552 100644
--- a/superset-frontend/src/pages/DashboardList/index.tsx
+++ b/superset-frontend/src/pages/DashboardList/index.tsx
@@ -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,
],
);
diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx
b/superset-frontend/src/pages/DatabaseList/index.tsx
index 84baa209bbe..e8bf7ab29c2 100644
--- a/superset-frontend/src/pages/DatabaseList/index.tsx
+++ b/superset-frontend/src/pages/DatabaseList/index.tsx
@@ -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,53 +504,36 @@ 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 && (
+ {canEdit && (
<Tooltip
- id="export-action-tooltip"
- title={t('Export')}
+ id="edit-action-tooltip"
+ title={t('Edit')}
placement="bottom"
>
<span
role="button"
+ data-test="database-edit"
tabIndex={0}
className="action-button"
- onClick={handleExport}
+ onClick={handleEdit}
>
- <Icons.UploadOutlined iconSize="l" />
+ <Icons.EditOutlined data-test="edit-alt" iconSize="l" />
</span>
</Tooltip>
)}
- {canEdit && (
+ {canExport && (
<Tooltip
- id="edit-action-tooltip"
- title={t('Edit')}
+ id="export-action-tooltip"
+ title={t('Export')}
placement="bottom"
>
<span
role="button"
- data-test="database-edit"
tabIndex={0}
className="action-button"
- onClick={handleEdit}
+ onClick={handleExport}
>
- <Icons.EditOutlined data-test="edit-alt" iconSize="l" />
+ <Icons.UploadOutlined iconSize="l" />
</span>
</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 (
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx
b/superset-frontend/src/pages/DatasetList/index.tsx
index d88a0ae8fb3..39684dd9ca7 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -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,19 +436,25 @@ const DatasetList: FunctionComponent<DatasetListProps> =
({
}
return (
<Actions className="actions">
- {canDelete && (
+ {canEdit && (
<Tooltip
- id="delete-action-tooltip"
- title={t('Delete')}
+ id="edit-action-tooltip"
+ title={
+ allowEdit
+ ? t('Edit')
+ : t(
+ 'You must be a dataset owner in order to edit.
Please reach out to a dataset owner to request modifications or edit access.',
+ )
+ }
placement="bottom"
>
<span
role="button"
tabIndex={0}
- className="action-button"
- onClick={handleDelete}
+ className={`action-button ${allowEdit ? '' : 'disabled'}`}
+ onClick={allowEdit ? handleEdit : undefined}
>
- <Icons.DeleteOutlined iconSize="l" />
+ <Icons.EditOutlined iconSize="l" />
</span>
</Tooltip>
)}
@@ -464,41 +474,35 @@ const DatasetList: FunctionComponent<DatasetListProps> =
({
</span>
</Tooltip>
)}
- {canEdit && (
+ {canDuplicate && original.kind === 'virtual' && (
<Tooltip
- id="edit-action-tooltip"
- title={
- allowEdit
- ? t('Edit')
- : t(
- 'You must be a dataset owner in order to edit.
Please reach out to a dataset owner to request modifications or edit access.',
- )
- }
+ id="duplicate-action-tooltip"
+ title={t('Duplicate')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
- className={`action-button ${allowEdit ? '' : 'disabled'}`}
- onClick={allowEdit ? handleEdit : undefined}
+ className="action-button"
+ onClick={handleDuplicate}
>
- <Icons.EditOutlined iconSize="l" />
+ <Icons.CopyOutlined iconSize="l" />
</span>
</Tooltip>
)}
- {canDuplicate && original.kind === 'virtual' && (
+ {canDelete && (
<Tooltip
- id="duplicate-action-tooltip"
- title={t('Duplicate')}
+ id="delete-action-tooltip"
+ title={t('Delete')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
- onClick={handleDuplicate}
+ onClick={handleDelete}
>
- <Icons.CopyOutlined iconSize="l" />
+ <Icons.DeleteOutlined iconSize="l" />
</span>
</Tooltip>
)}
@@ -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(
diff --git a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx
b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx
index e6e86413a38..724cadf2bc7 100644
--- a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx
+++ b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx
@@ -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,
],
);
diff --git a/superset-frontend/src/pages/SavedQueryList/index.tsx
b/superset-frontend/src/pages/SavedQueryList/index.tsx
index 9b32384ce5b..5101963951c 100644
--- a/superset-frontend/src/pages/SavedQueryList/index.tsx
+++ b/superset-frontend/src/pages/SavedQueryList/index.tsx
@@ -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'),
diff --git a/superset-frontend/src/pages/ThemeList/index.tsx
b/superset-frontend/src/pages/ThemeList/index.tsx
index 3e2db4615de..d0c84adf4be 100644
--- a/superset-frontend/src/pages/ThemeList/index.tsx
+++ b/superset-frontend/src/pages/ThemeList/index.tsx
@@ -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',
diff --git a/superset-frontend/src/views/CRUD/hooks.test.tsx
b/superset-frontend/src/views/CRUD/hooks.test.tsx
index 2f08fc6e871..907a4120603 100644
--- a/superset-frontend/src/views/CRUD/hooks.test.tsx
+++ b/superset-frontend/src/views/CRUD/hooks.test.tsx
@@ -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';
-
-// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from
describe blocks
-describe('useListViewResource', () => {
- afterEach(() => {
- jest.restoreAllMocks();
- });
-
- 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'
}];
-
- 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()),
- );
- result.current.fetchData({
- pageIndex,
- pageSize,
- sortBy: [{ id: 'foo' }], // Change the type of sortBy from string to
SortColumn[]
- filters: baseFilters,
- });
+import {
+ useListViewResource,
+ useSingleViewResource,
+ useFavoriteStatus,
+ useChartEditModal,
+} from './hooks';
+import type Chart from 'src/types/Chart';
+
+/** 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),
+ );
+
+ if (!match) {
+ throw new Error(`No call found with endpoint containing "${substring}"`);
+ }
+
+ return (match[0] as Record<string, string>).endpoint;
+}
+
+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,
- ),
- );
+ 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);
+ });
+});
- result.current.fetchData({
- pageIndex,
- pageSize,
- sortBy: [{ id: 'foo' }], // Change the type of sortBy from string to
SortColumn[]
- filters: baseFilters,
+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);
+ });
+});
- 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))',
+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: [],
});
});
- // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from
describe blocks
- describe('ChartList-specific filter scenarios', () => {
- afterEach(() => {
- jest.restoreAllMocks();
+ 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' }],
});
+ });
- 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 endpoint = findEndpoint(getSpy, '/api/v1/dashboard/?q=');
- const { result } = renderHook(() =>
- useListViewResource('chart', 'Chart', jest.fn()),
- );
+ // Both base filter and user filter should be present
+ expect(endpoint).toContain('col:published');
+ expect(endpoint).toContain('col:name');
+});
- const typeFilter = [{ id: 'viz_type', operator: 'eq', value: 'table' }];
+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 },
+ ],
+ });
+ });
- result.current.fetchData({
- pageIndex: 0,
- pageSize: 25,
- sortBy: [{ id: 'changed_on_delta_humanized', desc: true }],
- filters: typeFilter,
- });
+ const endpoint = findEndpoint(getSpy, '/api/v1/chart/?q=');
- expect(fetchSpy).toHaveBeenNthCalledWith(2, {
- endpoint: expect.stringContaining('/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');
+});
- const call = fetchSpy.mock.calls[1];
- const { endpoint } = call[0];
+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>,
+ );
- 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/);
- });
+ const { result } = renderHook(() =>
+ useListViewResource('chart', 'Charts', jest.fn(), false),
+ );
+
+ // Initial loading
+ expect(result.current.state.loading).toBe(true);
- 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'");
+ act(() => {
+ result.current.fetchData({
+ pageIndex: 0,
+ pageSize: 10,
+ sortBy: [{ id: 'name' }],
+ filters: [],
});
+ });
- test('converts chart-specific favorite filter', async () => {
- const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
- json: { result: [], count: 0 },
- } as unknown as JsonResponse);
+ // Loading should be true while fetching
+ expect(result.current.state.loading).toBe(true);
- const { result } = renderHook(() =>
- useListViewResource('chart', 'Chart', jest.fn()),
- );
+ await act(async () => {
+ expect(resolveGet).toBeDefined();
+ resolveGet?.({ json: { result: [], count: 0 } });
+ });
- const favoriteFilter = [
- { id: 'id', operator: 'chart_is_favorite', value: true },
- ];
+ await waitFor(() => {
+ expect(result.current.state.loading).toBe(false);
+ });
+});
- result.current.fetchData({
- pageIndex: 0,
- pageSize: 25,
- sortBy: [{ id: 'changed_on_delta_humanized', desc: true }],
- filters: favoriteFilter,
- });
+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);
+ });
- const call = fetchSpy.mock.calls[1];
- const { endpoint } = call[0];
+ getSpy.mockClear();
- expect(endpoint).toMatch(/col:id/);
- expect(endpoint).toMatch(/opr:chart_is_favorite/);
- expect(endpoint).toContain('value:!t');
- });
+ // RefreshData should reuse the cached config
+ await act(async () => {
+ await result.current.refreshData();
+ });
- 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/);
- });
+ 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);
- 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', 'Charts', jest.fn()),
+ );
- const { result } = renderHook(() =>
- useListViewResource('chart', 'Chart', 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();
+ });
- // Test alphabetical sort (slice_name ASC)
- result.current.fetchData({
- pageIndex: 0,
- pageSize: 25,
- sortBy: [{ id: 'slice_name', desc: false }],
- filters: [],
- });
+ expect(result.current.state.bulkSelectEnabled).toBe(true);
- const call = fetchSpy.mock.calls[1];
- const { endpoint } = call[0];
+ 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);
+});
- expect(endpoint).toMatch(/order_column:slice_name/);
- expect(endpoint).toMatch(/order_direction:asc/);
+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' },
+ ]);
});
diff --git a/superset-frontend/src/views/CRUD/hooks.ts
b/superset-frontend/src/views/CRUD/hooks.ts
index 85f22183485..6d85d3d8e5f 100644
--- a/superset-frontend/src/views/CRUD/hooks.ts
+++ b/superset-frontend/src/views/CRUD/hooks.ts
@@ -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';
});
}