diff --git a/RELEASE.md b/RELEASE.md index 438912f528..e98cb63d54 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -17,6 +17,7 @@ Please follow the established format: - Refactor namespace pipelines. (#1897) - Expose the internal Redux state through `options` prop while using Kedro-Viz as a React component. (#1969) - Enhance documentation for the Kedro-Viz standalone React component. (#1954) +- Add Datasets preview toggle in the settings panel. (#1977) ## Bug fixes and other changes diff --git a/package/kedro_viz/api/rest/requests.py b/package/kedro_viz/api/rest/requests.py index 6f0a8bafb3..b35f4f39b0 100644 --- a/package/kedro_viz/api/rest/requests.py +++ b/package/kedro_viz/api/rest/requests.py @@ -10,3 +10,9 @@ class DeployerConfiguration(BaseModel): is_all_previews_enabled: bool = False endpoint: str bucket_name: str + + +class UserPreference(BaseModel): + """User preferences for Kedro Viz.""" + + showDatasetPreviews: bool diff --git a/package/kedro_viz/api/rest/router.py b/package/kedro_viz/api/rest/router.py index d8a8499677..64ad2dc4d6 100644 --- a/package/kedro_viz/api/rest/router.py +++ b/package/kedro_viz/api/rest/router.py @@ -7,12 +7,13 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse -from kedro_viz.api.rest.requests import DeployerConfiguration +from kedro_viz.api.rest.requests import DeployerConfiguration, UserPreference from kedro_viz.constants import PACKAGE_REQUIREMENTS from kedro_viz.integrations.deployment.deployer_factory import DeployerFactory from .responses import ( APIErrorMessage, + DataNodeMetadata, GraphAPIResponse, NodeMetadataAPIResponse, PackageCompatibilityAPIResponse, @@ -49,6 +50,36 @@ async def get_single_node_metadata(node_id: str): return get_node_metadata_response(node_id) +@router.post("/preferences") +async def update_preferences(preferences: UserPreference): + try: + DataNodeMetadata.set_is_all_previews_enabled(preferences.showDatasetPreviews) + return JSONResponse( + status_code=200, content={"message": "Preferences updated successfully"} + ) + except Exception as exception: + logger.error("Failed to update preferences: %s", str(exception)) + return JSONResponse( + status_code=500, + content={"message": "Failed to update preferences"}, + ) + + +@router.get("/preferences", response_model=UserPreference) +async def get_preferences(): + try: + show_dataset_previews = DataNodeMetadata.is_all_previews_enabled + return JSONResponse( + status_code=200, content={"showDatasetPreviews": show_dataset_previews} + ) + except Exception as exception: + logger.error("Failed to fetch preferences: %s", str(exception)) + return JSONResponse( + status_code=500, + content={"message": "Failed to fetch preferences"}, + ) + + @router.get( "/pipelines/{registered_pipeline_id}", response_model=GraphAPIResponse, @@ -99,7 +130,7 @@ async def get_package_compatibilities(): return get_package_compatibilities_response(PACKAGE_REQUIREMENTS) except Exception as exc: logger.exception( - "An exception occured while getting package compatibility info : %s", exc + "An exception occurred while getting package compatibility info : %s", exc ) return JSONResponse( status_code=500, diff --git a/package/tests/test_api/test_rest/test_router.py b/package/tests/test_api/test_rest/test_router.py index 9ba1de7e2c..e3333fea15 100644 --- a/package/tests/test_api/test_rest/test_router.py +++ b/package/tests/test_api/test_rest/test_router.py @@ -86,3 +86,45 @@ def test_get_package_compatibilities( assert response.status_code == expected_status_code assert response.json() == expected_response + + +def test_update_preferences_success(client, mocker): + mocker.patch( + "kedro_viz.api.rest.responses.DataNodeMetadata.set_is_all_previews_enabled" + ) + response = client.post("api/preferences", json={"showDatasetPreviews": True}) + + assert response.status_code == 200 + assert response.json() == {"message": "Preferences updated successfully"} + + +def test_update_preferences_failure(client, mocker): + mocker.patch( + "kedro_viz.api.rest.responses.DataNodeMetadata.set_is_all_previews_enabled", + side_effect=Exception("Test Exception"), + ) + response = client.post("api/preferences", json={"showDatasetPreviews": True}) + + assert response.status_code == 500 + assert response.json() == {"message": "Failed to update preferences"} + + +def test_get_preferences_success(client, mocker): + mocker.patch( + "kedro_viz.api.rest.responses.DataNodeMetadata", is_all_previews_enabled=True + ) + response = client.get("/api/preferences") + + assert response.status_code == 200 + assert response.json() == {"showDatasetPreviews": True} + + +def test_get_preferences_failure(client, mocker): + mocker.patch( + "kedro_viz.api.rest.responses.DataNodeMetadata.is_all_previews_enabled", + side_effect=Exception("Test Exception"), + ) + response = client.get("/api/preferences") + + assert response.status_code == 500 + assert response.json() == {"message": "Failed to fetch preferences"} diff --git a/src/actions/preferences.js b/src/actions/preferences.js new file mode 100644 index 0000000000..2dade70770 --- /dev/null +++ b/src/actions/preferences.js @@ -0,0 +1,19 @@ +import { fetchPreferences } from '../utils/preferences-api'; + +// Action Types +export const UPDATE_USER_PREFERENCES = 'UPDATE_USER_PREFERENCES'; + +// Action Creators +export const updateUserPreferences = (preferences) => ({ + type: UPDATE_USER_PREFERENCES, + payload: preferences, +}); + +export const getPreferences = () => async (dispatch) => { + try { + const preferences = await fetchPreferences(); + dispatch(updateUserPreferences(preferences)); + } catch (error) { + console.error('Error fetching preferences:', error); + } +}; diff --git a/src/components/settings-modal/settings-modal.js b/src/components/settings-modal/settings-modal.js index 0800fa10a5..3423b04d3c 100644 --- a/src/components/settings-modal/settings-modal.js +++ b/src/components/settings-modal/settings-modal.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { connect } from 'react-redux'; import { changeFlag, @@ -6,11 +6,16 @@ import { toggleIsPrettyName, toggleSettingsModal, } from '../../actions'; +import { + getPreferences, + updateUserPreferences, +} from '../../actions/preferences'; import { getFlagsState } from '../../utils/flags'; import SettingsModalRow from './settings-modal-row'; import { settings as settingsConfig, localStorageName } from '../../config'; import { saveLocalStorage } from '../../store/helpers'; import { localStorageKeyFeatureHintsStep } from '../../components/feature-hints/feature-hints'; +import { updatePreferences } from '../../utils/preferences-api'; import Button from '../ui/button'; import Modal from '../ui/modal'; @@ -27,11 +32,14 @@ const SettingsModal = ({ showFeatureHints, isOutdated, isPrettyName, + showDatasetPreviews, latestVersion, onToggleFlag, onToggleShowFeatureHints, onToggleIsPrettyName, + onToggleShowDatasetPreviews, showSettingsModal, + getPreferences, visible, }) => { const flagData = getFlagsState(); @@ -40,12 +48,32 @@ const SettingsModal = ({ const [isPrettyNameValue, setIsPrettyName] = useState(isPrettyName); const [showFeatureHintsValue, setShowFeatureHintsValue] = useState(showFeatureHints); + const [showDatasetPreviewsValue, setShowDatasetPreviewsValue] = + useState(showDatasetPreviews); const [toggleFlags, setToggleFlags] = useState(flags); useEffect(() => { setShowFeatureHintsValue(showFeatureHints); }, [showFeatureHints]); + useEffect(() => { + setShowDatasetPreviewsValue(showDatasetPreviews); + }, [showDatasetPreviews]); + + useEffect(() => { + if (visible.settingsModal) { + getPreferences(); + } + }, [visible.settingsModal, getPreferences]); + + const handleSavePreferences = useCallback(async () => { + try { + await updatePreferences(showDatasetPreviewsValue); + } catch (error) { + console.error('Error updating preferences:', error); + } + }, [showDatasetPreviewsValue]); + useEffect(() => { let modalTimeout, resetTimeout; @@ -63,8 +91,10 @@ const SettingsModal = ({ return onToggleFlag(name, value); }); + handleSavePreferences(); onToggleIsPrettyName(isPrettyNameValue); onToggleShowFeatureHints(showFeatureHintsValue); + onToggleShowDatasetPreviews(showDatasetPreviewsValue); setHasNotInteracted(true); setHasClickApplyAndClose(false); @@ -80,11 +110,14 @@ const SettingsModal = ({ hasClickedApplyAndClose, showFeatureHintsValue, isPrettyNameValue, + showDatasetPreviewsValue, onToggleFlag, onToggleShowFeatureHints, onToggleIsPrettyName, + onToggleShowDatasetPreviews, showSettingsModal, toggleFlags, + handleSavePreferences, ]); const resetStateCloseModal = () => { @@ -92,7 +125,8 @@ const SettingsModal = ({ setHasNotInteracted(true); setToggleFlags(flags); setIsPrettyName(isPrettyName); - setShowFeatureHintsValue(showFeatureHintsValue); + setShowFeatureHintsValue(showFeatureHints); + setShowDatasetPreviewsValue(showDatasetPreviews); }; return ( @@ -130,6 +164,16 @@ const SettingsModal = ({ } }} /> + { + setShowDatasetPreviewsValue(event.target.checked); + setHasNotInteracted(false); + }} + /> {flagData.map(({ name, value, description }) => ( ({ flags: state.flags, showFeatureHints: state.showFeatureHints, isPrettyName: state.isPrettyName, + showDatasetPreviews: state.userPreferences.showDatasetPreviews, visible: state.visible, }); @@ -216,6 +261,9 @@ export const mapDispatchToProps = (dispatch) => ({ showSettingsModal: (value) => { dispatch(toggleSettingsModal(value)); }, + getPreferences: () => { + dispatch(getPreferences()); + }, onToggleFlag: (name, value) => { dispatch(changeFlag(name, value)); }, @@ -225,6 +273,9 @@ export const mapDispatchToProps = (dispatch) => ({ onToggleShowFeatureHints: (value) => { dispatch(toggleShowFeatureHints(value)); }, + onToggleShowDatasetPreviews: (value) => { + dispatch(updateUserPreferences({ showDatasetPreviews: value })); + }, }); export default connect(mapStateToProps, mapDispatchToProps)(SettingsModal); diff --git a/src/components/settings-modal/settings-modal.test.js b/src/components/settings-modal/settings-modal.test.js index 749e2889e5..48a1278806 100644 --- a/src/components/settings-modal/settings-modal.test.js +++ b/src/components/settings-modal/settings-modal.test.js @@ -45,6 +45,7 @@ describe('SettingsModal', () => { exportModal: expect.any(Boolean), settingsModal: expect.any(Boolean), }), + showDatasetPreviews: expect.any(Boolean), flags: expect.any(Object), isPrettyName: expect.any(Boolean), showFeatureHints: expect.any(Boolean), @@ -73,5 +74,11 @@ describe('SettingsModal', () => { type: 'TOGGLE_IS_PRETTY_NAME', isPrettyName: false, }); + + mapDispatchToProps(dispatch).onToggleShowDatasetPreviews(false); + expect(dispatch.mock.calls[3][0]).toEqual({ + type: 'UPDATE_USER_PREFERENCES', + payload: { showDatasetPreviews: false }, + }); }); }); diff --git a/src/config.js b/src/config.js index 03795f400f..ea25d9d9a6 100644 --- a/src/config.js +++ b/src/config.js @@ -77,6 +77,11 @@ export const settings = { description: 'Enable or disable all new feature hints in the interface.', default: true, }, + showDatasetPreviews: { + name: 'Dataset previews', + description: 'Display preview data for all datasets.', + default: true, + }, }; // Sidebar groups is an ordered map of { id: label } diff --git a/src/reducers/index.js b/src/reducers/index.js index 86e92e02be..60c0e79cef 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -24,6 +24,7 @@ import { TOGGLE_EXPAND_ALL_PIPELINES, UPDATE_STATE_FROM_OPTIONS, } from '../actions'; +import userPreferences from './preferences'; import { TOGGLE_PARAMETERS_HOVERED } from '../actions'; /** @@ -81,6 +82,7 @@ const combinedReducer = combineReducers({ modularPipeline, visible, runsMetadata, + userPreferences, // These props don't have any actions associated with them display: createReducer(null), dataSource: createReducer(null), diff --git a/src/reducers/preferences.js b/src/reducers/preferences.js new file mode 100644 index 0000000000..d8991435f1 --- /dev/null +++ b/src/reducers/preferences.js @@ -0,0 +1,19 @@ +import { UPDATE_USER_PREFERENCES } from '../actions/preferences'; + +const initialState = { + showDatasetPreviews: true, +}; + +const userPreferences = (state = initialState, action) => { + switch (action.type) { + case UPDATE_USER_PREFERENCES: + return { + ...state, + showDatasetPreviews: action.payload.showDatasetPreviews, + }; + default: + return state; + } +}; + +export default userPreferences; diff --git a/src/reducers/reducers.test.js b/src/reducers/reducers.test.js index ab2e5e7a9e..30e7beb697 100644 --- a/src/reducers/reducers.test.js +++ b/src/reducers/reducers.test.js @@ -36,6 +36,7 @@ import { import { UPDATE_ACTIVE_PIPELINE } from '../actions/pipelines'; import { TOGGLE_MODULAR_PIPELINE_ACTIVE } from '../actions/modular-pipelines'; import { TOGGLE_GRAPH_LOADING } from '../actions/graph'; +import { UPDATE_USER_PREFERENCES } from '../actions/preferences'; describe('Reducer', () => { it('should return an Object', () => { @@ -141,6 +142,19 @@ describe('Reducer', () => { }); }); + describe('UPDATE_USER_PREFERENCES', () => { + it('should update the value of showDatasetPreviews', () => { + const newState = reducer(mockState.spaceflights, { + type: UPDATE_USER_PREFERENCES, + payload: { showDatasetPreviews: true }, + }); + expect(mockState.spaceflights.userPreferences.showDatasetPreviews).toBe( + true + ); + expect(newState.userPreferences.showDatasetPreviews).toBe(true); + }); + }); + describe('TOGGLE_TEXT_LABELS', () => { it('should toggle the value of textLabels', () => { const newState = reducer(mockState.spaceflights, { diff --git a/src/store/initial-state.js b/src/store/initial-state.js index 3a86525849..86257d4fc7 100644 --- a/src/store/initial-state.js +++ b/src/store/initial-state.js @@ -24,6 +24,9 @@ export const createInitialState = () => ({ expandAllPipelines: false, isPrettyName: settings.isPrettyName.default, showFeatureHints: settings.showFeatureHints.default, + userPreferences: { + showDatasetPreviews: settings.showDatasetPreviews.default, + }, ignoreLargeWarning: false, loading: { graph: false, diff --git a/src/store/store.js b/src/store/store.js index 01bf864cf8..6b20e60c3e 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -57,6 +57,7 @@ const saveStateToLocalStorage = (state) => { theme: state.theme, isPrettyName: state.isPrettyName, showFeatureHints: state.showFeatureHints, + userPreferences: state.userPreferences, flags: state.flags, expandAllPipelines: state.expandAllPipelines, }); diff --git a/src/utils/preferences-api.js b/src/utils/preferences-api.js new file mode 100644 index 0000000000..c6040a56a7 --- /dev/null +++ b/src/utils/preferences-api.js @@ -0,0 +1,56 @@ +/** + * Update user preferences for Kedro Viz. + * + * This function sends a POST request to the '/api/preferences' endpoint + * to update the user preference settings + * + * @param {boolean} showDatasetPreviews - Indicates whether to show dataset previews. + * @return {Promise} - A promise that resolves to the response data. + * @throws {Error} - Throws an error if the API request fails. + */ +export const updatePreferences = async (showDatasetPreviews) => { + try { + const response = await fetch('/api/preferences', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + showDatasetPreviews, + }), + }); + + if (!response.ok) { + throw new Error('Failed to update preferences'); + } + + return await response.json(); + } catch (error) { + console.error('Error updating preferences:', error); + throw error; + } +}; + +/** + * Fetch preferences from the backend. + * @return {Promise} Preferences object containing showDatasetPreviews. + */ +export const fetchPreferences = async () => { + try { + const response = await fetch('/api/preferences', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch preferences'); + } + + return await response.json(); + } catch (error) { + console.error('Error fetching preferences:', error); + throw error; + } +};