From b820f31eeda9c083b4bf9e4a938724dd14c4522c Mon Sep 17 00:00:00 2001 From: Taya Leutina Date: Thu, 23 Oct 2025 18:52:17 +0300 Subject: [PATCH 1/5] Support base operations with selectors globality --- .../features-list/EnableGlobalSelectors.ts | 10 + src/server/components/sdk/dash.ts | 26 +- src/shared/types/dash.ts | 4 + src/shared/types/feature.ts | 2 + src/shared/zod-schemas/dash.ts | 2 + ...ion.tsx => CommonGroupSettingsSection.tsx} | 46 +- .../ConnectionSettings/ConnectionSettings.tsx | 12 +- .../DatasetSelector/DatasetSelector.scss | 0 .../DatasetSelectorSettings.tsx} | 21 +- .../ExternalSelectorSettings.tsx | 4 + .../TabsScopeSelect/TabsScopeSelect.scss | 6 + .../TabsScopeSelect/TabsScopeSelect.tsx | 215 ++++++++++ .../TabsScopeSelect/constants.ts | 6 + .../TabsScopeSelect/helpers.tsx | 85 ++++ .../plugins/GroupControl/GroupControl.tsx | 58 ++- .../DashKit/plugins/GroupControl/types.ts | 8 +- .../DialogExtendedSettings.tsx | 11 + .../DialogExternalControl.tsx | 16 +- .../DialogGroupControl.scss | 13 + .../DialogGroupControl/DialogGroupControl.tsx | 5 + .../GroupControlBody/GroupControlBody.tsx | 6 +- .../GroupControlSidebar.tsx | 40 ++ .../components/ListWithMenu/ListWithMenu.scss | 4 + .../components/ListWithMenu/ListWithMenu.tsx | 59 ++- .../SelectOptionWithIcon.tsx | 8 +- src/ui/components/TabMenu/TabMenu.tsx | 4 + src/ui/components/TabMenu/types.ts | 2 + src/ui/index.ts | 2 +- src/ui/store/actions/controlDialog.ts | 8 +- src/ui/store/reducers/controlDialog.ts | 21 +- src/ui/store/typings/controlDialog.ts | 4 + .../GlobalSelectorIcon.scss | 4 + .../GlobalSelectorIcon/GlobalSelectorIcon.tsx | 44 ++ src/ui/units/dash/containers/Body/Body.tsx | 25 +- .../units/dash/containers/Dialogs/Dialogs.js | 10 +- src/ui/units/dash/modules/constants.ts | 2 + .../units/dash/store/actions/base/actions.ts | 2 +- src/ui/units/dash/store/actions/dashTyped.ts | 34 +- src/ui/units/dash/store/actions/helpers.ts | 2 +- src/ui/units/dash/store/actions/index.ts | 4 +- src/ui/units/dash/store/reducers/dash.js | 60 ++- .../units/dash/store/reducers/dashHelpers.ts | 400 ++++++++++++++++++ .../dash/store/reducers/dashTypedReducer.ts | 46 +- .../store/selectors/dashTypedSelectors.ts | 8 +- src/ui/units/dash/store/typings/dash.ts | 46 ++ src/ui/units/dash/typings/selectors.ts | 3 + src/ui/units/dash/utils/selectors.ts | 54 +++ tests/page-objects/wizard/DatasetSelector.ts | 4 +- 48 files changed, 1352 insertions(+), 104 deletions(-) create mode 100644 src/server/components/features/features-list/EnableGlobalSelectors.ts rename src/ui/components/ControlComponents/Sections/CommonSettingsSection/{CommonSettingsSection.tsx => CommonGroupSettingsSection.tsx} (51%) delete mode 100644 src/ui/components/ControlComponents/Sections/CommonSettingsSection/DatasetSelector/DatasetSelector.scss rename src/ui/components/ControlComponents/Sections/CommonSettingsSection/{DatasetSelector/DatasetSelector.tsx => DatasetSelectorSettings/DatasetSelectorSettings.tsx} (89%) create mode 100644 src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/TabsScopeSelect.scss create mode 100644 src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/TabsScopeSelect.tsx create mode 100644 src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/constants.ts create mode 100644 src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/helpers.tsx create mode 100644 src/ui/units/dash/components/GlobalSelectorIcon/GlobalSelectorIcon.scss create mode 100644 src/ui/units/dash/components/GlobalSelectorIcon/GlobalSelectorIcon.tsx create mode 100644 src/ui/units/dash/store/reducers/dashHelpers.ts create mode 100644 src/ui/units/dash/store/typings/dash.ts create mode 100644 src/ui/units/dash/typings/selectors.ts create mode 100644 src/ui/units/dash/utils/selectors.ts diff --git a/src/server/components/features/features-list/EnableGlobalSelectors.ts b/src/server/components/features/features-list/EnableGlobalSelectors.ts new file mode 100644 index 0000000000..004ede991e --- /dev/null +++ b/src/server/components/features/features-list/EnableGlobalSelectors.ts @@ -0,0 +1,10 @@ +import {Feature} from '../../../../shared'; +import {createFeatureConfig} from '../utils'; + +export default createFeatureConfig({ + name: Feature.EnableGlobalSelectors, + state: { + development: true, + production: false, + }, +}); diff --git a/src/server/components/sdk/dash.ts b/src/server/components/sdk/dash.ts index 3b56d02552..0a4c6bd054 100644 --- a/src/server/components/sdk/dash.ts +++ b/src/server/components/sdk/dash.ts @@ -156,7 +156,7 @@ function validateData(data: DashData) { return true; }; - data.tabs.forEach(({id: tabId, title: tabTitle, items, layout, connections}) => { + data.tabs.forEach(({id: tabId, title: tabTitle, items, layout, connections, globalItems}) => { const currentItemsIds: Set = new Set(); const currentWidgetTabsIds: Set = new Set(); const currentControlsIds: Set = new Set(); @@ -165,6 +165,24 @@ function validateData(data: DashData) { allTabsIds.add(tabId); } + if (globalItems) { + globalItems.forEach(({id: itemId, type, data}) => { + allItemsIds.add(itemId); + currentItemsIds.add(itemId); + + if (type === DashTabItemType.Control || type === DashTabItemType.GroupControl) { + // if it is group control all connections set on its items + if ('group' in data) { + data.group.forEach((widgetItem) => { + currentControlsIds.add(widgetItem.id); + }); + } else { + currentControlsIds.add(itemId); + } + } + }); + } + items.forEach(({id: itemId, type, data}) => { if (isIdUniq(itemId)) { allItemsIds.add(itemId); @@ -190,10 +208,12 @@ function validateData(data: DashData) { } }); + const allItemsLength = items.length + (globalItems?.length ?? 0); + // checking that layout has all the ids from item, i.e. positions are set for all elements if ( - items.length !== layout.length || - items.length !== + allItemsLength !== layout.length || + allItemsLength !== intersection( Array.from(currentItemsIds), layout.map(({i}) => i), diff --git a/src/shared/types/dash.ts b/src/shared/types/dash.ts index 766c3787f9..8d41b34512 100644 --- a/src/shared/types/dash.ts +++ b/src/shared/types/dash.ts @@ -1,5 +1,6 @@ import type {ItemDropProps} from '@gravity-ui/dashkit'; +import type {TabsScope} from '../../ui/units/dash/typings/selectors'; import type {Operations} from '../modules'; import type { @@ -125,6 +126,7 @@ export interface DashTab { aliases: DashTabAliases; connections: DashTabConnection[]; settings?: DashTabSettings; + globalItems?: DashTabItem[]; } export type DashSettingsGlobalParams = Record; @@ -232,6 +234,7 @@ export interface DashTabItemControlData { width?: string; defaults?: StringParams; namespace: string; + tabsScope?: TabsScope; } export type DashTabItemControlSingle = DashTabItemControlDataset | DashTabItemControlManual; @@ -339,6 +342,7 @@ export interface DashTabItemGroupControlData { autoHeight: boolean; buttonApply: boolean; buttonReset: boolean; + tabsScope?: TabsScope; updateControlsOnChange?: boolean; diff --git a/src/shared/types/feature.ts b/src/shared/types/feature.ts index 6bd801d170..7df09b31f3 100644 --- a/src/shared/types/feature.ts +++ b/src/shared/types/feature.ts @@ -103,6 +103,8 @@ export enum Feature { EnableMobileFixedHeader = 'EnableMobileFixedHeader', EnableCommonChartDashSettings = 'EnableCommonChartDashSettings', + /** Enable a setting in the Selector settings dialog that allows you to make the selector pass-through for all or several tabs */ + EnableGlobalSelectors = 'EnableGlobalSelectors', } export type FeatureConfig = Record; diff --git a/src/shared/zod-schemas/dash.ts b/src/shared/zod-schemas/dash.ts index 8a380cbbb0..7a47d44061 100644 --- a/src/shared/zod-schemas/dash.ts +++ b/src/shared/zod-schemas/dash.ts @@ -116,6 +116,7 @@ const controlSchema = z namespace: z.literal(DASH_DEFAULT_NAMESPACE), title: z.string().min(1), sourceType: z.enum(DashTabItemControlSourceType), + tabsScope: z.union([z.string(), z.array(z.string())]).optional(), }) .and( z.discriminatedUnion('sourceType', [ @@ -147,6 +148,7 @@ const groupControlItemsSchema = z defaults: z.record(z.any(), z.any()), placementMode: z.enum(CONTROLS_PLACEMENT_MODE).optional(), width: z.string().optional(), + tabsScope: z.union([z.string(), z.array(z.string())]).optional(), }) .and( z.discriminatedUnion('sourceType', [ diff --git a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/CommonSettingsSection.tsx b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/CommonGroupSettingsSection.tsx similarity index 51% rename from src/ui/components/ControlComponents/Sections/CommonSettingsSection/CommonSettingsSection.tsx rename to src/ui/components/ControlComponents/Sections/CommonSettingsSection/CommonGroupSettingsSection.tsx index d7c9bdc84b..8c1295169b 100644 --- a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/CommonSettingsSection.tsx +++ b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/CommonGroupSettingsSection.tsx @@ -3,45 +3,47 @@ import React from 'react'; import {I18n} from 'i18n'; import {useSelector} from 'react-redux'; import {DashTabItemControlSourceType} from 'shared'; -import {selectSelectorDialog} from 'ui/store/selectors/controlDialog'; +import {selectSelectorDialog, selectSelectorsGroup} from 'ui/store/selectors/controlDialog'; import {ConnectionSettings} from './ConnectionSettings/ConnectionSettings'; -import {DatasetSelector} from './DatasetSelector/DatasetSelector'; -import {ExternalSelectorSettings} from './ExternalSelectorSettings/ExternalSelectorSettings'; +import {DatasetSelectorSettings} from './DatasetSelectorSettings/DatasetSelectorSettings'; import {ParameterNameInput} from './ParameterNameInput/ParameterNameInput'; +import {TabsScopeSelect} from './TabsScopeSelect/TabsScopeSelect'; const i18n = I18n.keyset('dash.control-dialog.edit'); -export const CommonSettingsSection = ({ +export const CommonGroupSettingsSection = ({ navigationPath, changeNavigationPath, - enableAutoheightDefault, + enableGlobalSelectors, className, }: { navigationPath: string | null; changeNavigationPath: (newNavigationPath: string) => void; - enableAutoheightDefault?: boolean; + enableGlobalSelectors?: boolean; className?: string; }) => { const {sourceType} = useSelector(selectSelectorDialog); + const {group, tabsScope} = useSelector(selectSelectorsGroup); + + const hasMultipleSelectors = group.length > 1; switch (sourceType) { - case DashTabItemControlSourceType.External: - return ( - - ); case DashTabItemControlSourceType.Manual: return ( - + + + {enableGlobalSelectors && ( + + )} + ); case DashTabItemControlSourceType.Connection: return ( @@ -49,14 +51,16 @@ export const CommonSettingsSection = ({ rowClassName={className} changeNavigationPath={changeNavigationPath} navigationPath={navigationPath} + enableGlobalSelectors={enableGlobalSelectors} /> ); default: return ( - ); } diff --git a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ConnectionSettings/ConnectionSettings.tsx b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ConnectionSettings/ConnectionSettings.tsx index 0c2e6281c6..8c1da3caf3 100644 --- a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ConnectionSettings/ConnectionSettings.tsx +++ b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ConnectionSettings/ConnectionSettings.tsx @@ -2,9 +2,10 @@ import React from 'react'; import {I18n} from 'i18n'; import {useSelector} from 'react-redux'; -import {selectSelectorDialog} from 'ui/store/selectors/controlDialog'; +import {selectSelectorDialog, selectSelectorsGroup} from 'ui/store/selectors/controlDialog'; import {ParameterNameInput} from '../ParameterNameInput/ParameterNameInput'; +import {TabsScopeSelect} from '../TabsScopeSelect/TabsScopeSelect'; import {ConnectionSelector} from './components/ConnectionSelector/ConnectionSelector'; import {QueryTypeControl} from './components/QueryTypeControl/QueryTypeControl'; @@ -15,12 +16,15 @@ export const ConnectionSettings = ({ navigationPath, changeNavigationPath, rowClassName, + enableGlobalSelectors, }: { navigationPath: string | null; changeNavigationPath: (newNavigationPath: string) => void; rowClassName?: string; + enableGlobalSelectors?: boolean; }) => { const {connectionQueryTypes} = useSelector(selectSelectorDialog); + const {tabsScope, group} = useSelector(selectSelectorsGroup); return ( @@ -38,6 +42,12 @@ export const ConnectionSettings = ({ )} + {enableGlobalSelectors && ( + 1} + /> + )} ); }; diff --git a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/DatasetSelector/DatasetSelector.scss b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/DatasetSelector/DatasetSelector.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/DatasetSelector/DatasetSelector.tsx b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/DatasetSelectorSettings/DatasetSelectorSettings.tsx similarity index 89% rename from src/ui/components/ControlComponents/Sections/CommonSettingsSection/DatasetSelector/DatasetSelector.tsx rename to src/ui/components/ControlComponents/Sections/CommonSettingsSection/DatasetSelectorSettings/DatasetSelectorSettings.tsx index 24a86bbe63..3e70c02e48 100644 --- a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/DatasetSelector/DatasetSelector.tsx +++ b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/DatasetSelectorSettings/DatasetSelectorSettings.tsx @@ -15,25 +15,32 @@ import { import logger from 'ui/libs/logger'; import {setLastUsedDatasetId, setSelectorDialogItem} from 'ui/store/actions/controlDialog'; import {ELEMENT_TYPE} from 'ui/store/constants/controlDialog'; -import {selectOpenedItemMeta, selectSelectorDialog} from 'ui/store/selectors/controlDialog'; +import { + selectOpenedItemMeta, + selectSelectorDialog, + selectSelectorsGroup, +} from 'ui/store/selectors/controlDialog'; import type {SelectorElementType, SetSelectorDialogItemArgs} from 'ui/store/typings/controlDialog'; import {DatasetField} from '../../Switchers/DatasetField/DatasetField'; import {EntrySelector} from '../EntrySelector/EntrySelector'; +import {TabsScopeSelect} from '../TabsScopeSelect/TabsScopeSelect'; const i18n = I18n.keyset('dash.control-dialog.edit'); const getDatasetLink = (entryId: string) => `/datasets/${entryId}`; -function DatasetSelector(props: { +function DatasetSelectorSettings(props: { navigationPath: string | null; changeNavigationPath: (newNavigationPath: string) => void; rowClassName?: string; + enableGlobalSelectors?: boolean; }) { const dispatch = useDispatch(); const {datasetId, datasetFieldId, isManualTitle, title, fieldType, validation} = useSelector(selectSelectorDialog); const {workbookId} = useSelector(selectOpenedItemMeta); + const {tabsScope, group} = useSelector(selectSelectorsGroup); const [isInvalid, setIsInvalid] = React.useState(false); const fetchDataset = React.useCallback((entryId: string) => { @@ -53,7 +60,7 @@ function DatasetSelector(props: { }) .catch((isInvalid) => { setIsInvalid(true); - logger.logError('DatasetSelector: load dataset failed', isInvalid); + logger.logError('DatasetSelectorSettings: load dataset failed', isInvalid); }); }, []); @@ -171,8 +178,14 @@ function DatasetSelector(props: { /> + {props.enableGlobalSelectors && ( + 1} + groupTabsScope={tabsScope} + > + )} ); } -export {DatasetSelector}; +export {DatasetSelectorSettings}; diff --git a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ExternalSelectorSettings/ExternalSelectorSettings.tsx b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ExternalSelectorSettings/ExternalSelectorSettings.tsx index 55101834c2..473937554e 100644 --- a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ExternalSelectorSettings/ExternalSelectorSettings.tsx +++ b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ExternalSelectorSettings/ExternalSelectorSettings.tsx @@ -15,6 +15,7 @@ import {selectOpenedItemMeta, selectSelectorDialog} from 'ui/store/selectors/con import {EntryTypeNode} from 'ui/units/dash/modules/constants'; import {EntrySelector} from '../EntrySelector/EntrySelector'; +import {TabsScopeSelect} from '../TabsScopeSelect/TabsScopeSelect'; import './ExternalSelectorSettings.scss'; @@ -32,6 +33,7 @@ const ExternalSelectorSettings: React.FC<{ navigationPath: string | null; changeNavigationPath: (newNavigationPath: string) => void; enableAutoheightDefault?: boolean; + enableGlobalSelectors?: boolean; rowClassName?: string; }> = (props) => { const dispatch = useDispatch(); @@ -121,6 +123,8 @@ const ExternalSelectorSettings: React.FC<{ changeNavigationPath={props.changeNavigationPath} /> + {props.enableGlobalSelectors && } + {!props.enableAutoheightDefault && ( ; + +export type TabsScopeSelectProps = { + groupTabsScope?: TabsScope; + hasMultipleSelectors?: boolean; + isGroupSettings?: boolean; +}; + +export const TabsScopeSelect = ({ + groupTabsScope, + hasMultipleSelectors, + isGroupSettings, +}: TabsScopeSelectProps) => { + const dispatch = useDispatch(); + const selectorDialog = useSelector(selectSelectorDialog); + + const currentTabId = useSelector(selectTabId) as string; + const tabs = useSelector(selectTabs); + const selectorsGroup = useSelector(selectSelectorsGroup); + + const [selectedTabs, setSelectedTabs] = React.useState( + getInitialSelectedTabs({ + selectorTabsScope: selectorDialog.tabsScope, + isGroupSettings, + groupTabsScope, + currentTabId, + }), + ); + + const tabsOptions = React.useMemo(() => { + return tabs.map((tab) => ({ + value: tab.id, + content: tab.title, + disabled: tab.id === currentTabId, + })); + }, [tabs, currentTabId]); + + const currentTabsScope = getTabsScopeByValue({ + selectorTabsScope: isGroupSettings ? groupTabsScope : selectorDialog.tabsScope, + hasMultipleSelectors, + }); + + // Create options based on whether there are multiple selectors + const tabsScopeOptions: SelectOption<{icon?: JSX.Element}>[] = React.useMemo(() => { + const currentTab = tabs.find((tab) => tab.id === currentTabId); + const currentTabTitle = currentTab?.title || ''; + + const baseOptions = [ + { + value: TABS_SCOPE_SELECT_VALUE.CURRENT_TAB, + content: ( + + {LABEL_BY_SCOPE_MAP[TABS_SCOPE_SELECT_VALUE.CURRENT_TAB]} + {currentTabTitle && ( + {currentTabTitle} + )} + + ), + }, + { + value: TABS_SCOPE_SELECT_VALUE.ALL, + content: LABEL_BY_SCOPE_MAP[TABS_SCOPE_SELECT_VALUE.ALL], + data: {icon: getIconByTabsScope(TABS_SCOPE_SELECT_VALUE.ALL)}, + }, + { + value: TABS_SCOPE_SELECT_VALUE.SELECTED_TABS, + content: LABEL_BY_SCOPE_MAP[TABS_SCOPE_SELECT_VALUE.SELECTED_TABS], + data: { + icon: getIconByTabsScope(TABS_SCOPE_SELECT_VALUE.SELECTED_TABS), + }, + }, + ]; + + if (hasMultipleSelectors && !isGroupSettings) { + const groupTabsScopeItem = getTabsScopeByValue({ + selectorTabsScope: groupTabsScope, + }); + + return [ + { + value: TABS_SCOPE_SELECT_VALUE.AS_GROUP, + content: ( + + Как в группе + + {LABEL_BY_SCOPE_MAP[groupTabsScopeItem]} + + + ), + data: { + icon: getIconByTabsScope(groupTabsScopeItem), + }, + }, + ...baseOptions, + ]; + } + + return baseOptions; + }, [groupTabsScope, hasMultipleSelectors, isGroupSettings, tabs, currentTabId]); + + const updateSelectorsState = React.useCallback( + (tabsScope: TabsScope) => { + // add get tabsScope by tabsScope value + dispatch( + isGroupSettings + ? updateSelectorsGroup({ + ...selectorsGroup, + tabsScope, + }) + : setSelectorDialogItem({ + tabsScope, + }), + ); + }, + [dispatch, isGroupSettings, selectorsGroup], + ); + + const handleTabsScopeChange = React.useCallback( + (value: string[]) => { + const newTabsScope = value[0]; + + updateSelectorsState( + getTabsScopeValueByName({ + name: newTabsScope, + currentTabId, + selectedTabs, + }), + ); + }, + [currentTabId, selectedTabs, updateSelectorsState], + ); + + const handleSelectedTabsChange = React.useCallback( + (value: string[]) => { + // Always ensure current tab is included and can't be removed + const newSelectedTabs = value.includes(currentTabId) ? value : [...value, currentTabId]; + setSelectedTabs(newSelectedTabs); + updateSelectorsState(newSelectedTabs); + }, + [currentTabId, updateSelectorsState], + ); + + const showTabsSelector = currentTabsScope === TABS_SCOPE_SELECT_VALUE.SELECTED_TABS; + + if (!currentTabId || !isEnabledFeature(Feature.EnableGlobalSelectors)) { + return null; + } + + const hasClear = + currentTabsScope !== TABS_SCOPE_SELECT_VALUE.CURRENT_TAB && + currentTabsScope !== TABS_SCOPE_SELECT_VALUE.AS_GROUP; + + return ( + + + + + )} + + + + ); +}; diff --git a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/constants.ts b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/constants.ts new file mode 100644 index 0000000000..91745a42c8 --- /dev/null +++ b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/constants.ts @@ -0,0 +1,6 @@ +export const TABS_SCOPE_SELECT_VALUE = { + ALL: 'all', + CURRENT_TAB: 'current_tab', + AS_GROUP: 'as_group', + SELECTED_TABS: 'selected_tabs', +}; diff --git a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/helpers.tsx b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/helpers.tsx new file mode 100644 index 0000000000..d607d715d9 --- /dev/null +++ b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/helpers.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +import {GlobalSelectorIcon} from 'ui/units/dash/components/GlobalSelectorIcon/GlobalSelectorIcon'; +import {TABS_SCOPE_ALL} from 'ui/units/dash/modules/constants'; +import type {TabsScope} from 'ui/units/dash/typings/selectors'; + +import {TABS_SCOPE_SELECT_VALUE} from './constants'; + +export const getTabsScopeByValue = ({ + selectorTabsScope, + hasMultipleSelectors, +}: { + selectorTabsScope: TabsScope; + hasMultipleSelectors?: boolean; +}) => { + if (selectorTabsScope === TABS_SCOPE_ALL) { + return TABS_SCOPE_SELECT_VALUE.ALL; + } + if (Array.isArray(selectorTabsScope)) { + return TABS_SCOPE_SELECT_VALUE.SELECTED_TABS; + } + if (typeof selectorTabsScope === 'string') { + return TABS_SCOPE_SELECT_VALUE.CURRENT_TAB; + } + + return hasMultipleSelectors + ? TABS_SCOPE_SELECT_VALUE.AS_GROUP + : TABS_SCOPE_SELECT_VALUE.CURRENT_TAB; +}; + +export const getTabsScopeValueByName = ({ + name, + currentTabId, + + selectedTabs, +}: { + name: string; + currentTabId: string; + + selectedTabs: string[]; +}) => { + switch (name) { + case TABS_SCOPE_SELECT_VALUE.ALL: + return TABS_SCOPE_ALL; + case TABS_SCOPE_SELECT_VALUE.CURRENT_TAB: + return currentTabId; + case TABS_SCOPE_SELECT_VALUE.AS_GROUP: + return undefined; + case TABS_SCOPE_SELECT_VALUE.SELECTED_TABS: + return selectedTabs; + default: + return undefined; + } +}; + +export const getIconByTabsScope = (tabsScope: TabsScope) => { + switch (tabsScope) { + case TABS_SCOPE_SELECT_VALUE.ALL: + return ; + case TABS_SCOPE_SELECT_VALUE.SELECTED_TABS: + return ; + default: + return undefined; + } +}; + +export const getInitialSelectedTabs = ({ + selectorTabsScope, + isGroupSettings, + groupTabsScope, + currentTabId, +}: { + selectorTabsScope: TabsScope; + isGroupSettings?: boolean; + groupTabsScope?: TabsScope; + currentTabId: string; +}) => { + if (isGroupSettings && Array.isArray(groupTabsScope)) { + return groupTabsScope; + } else if (Array.isArray(selectorTabsScope)) { + return selectorTabsScope; + } else { + return [currentTabId]; + } +}; diff --git a/src/ui/components/DashKit/plugins/GroupControl/GroupControl.tsx b/src/ui/components/DashKit/plugins/GroupControl/GroupControl.tsx index 09231fa912..0c4608e379 100644 --- a/src/ui/components/DashKit/plugins/GroupControl/GroupControl.tsx +++ b/src/ui/components/DashKit/plugins/GroupControl/GroupControl.tsx @@ -15,7 +15,7 @@ import type { DashTabItemGroupControlData, StringParams, } from 'shared'; -import {ControlQA, DashTabItemType} from 'shared'; +import {ControlQA, DashTabItemType, Feature} from 'shared'; import {DL} from 'ui/constants/common'; import {CHARTKIT_SCROLLABLE_NODE_CLASSNAME} from 'ui/libs/DatalensChartkit/ChartKit/helpers/constants'; import {ControlButton} from 'ui/libs/DatalensChartkit/components/Control/Items/Items'; @@ -23,7 +23,12 @@ import { CLICK_ACTION_TYPE, CONTROL_TYPE, } from 'ui/libs/DatalensChartkit/modules/constants/constants'; +import { + isGroupSettingAvailableOnTab, + isItemScopeAvailableOnTab, +} from 'ui/units/dash/utils/selectors'; import {getUrlGlobalParams} from 'ui/units/dash/utils/url'; +import {isEnabledFeature} from 'ui/utils/isEnabledFeature'; import {ExtendedDashKitContext} from '../../../../units/dash/utils/context'; import {DEBOUNCE_RENDER_TIMEOUT, DEFAULT_CONTROL_LAYOUT} from '../../constants'; @@ -113,12 +118,12 @@ class GroupControl extends React.PureComponent { + this.controlsProgressCount = memoGroupItems.length || 0; + memoGroupItems.forEach((item) => { this.controlsStatus[item.id] = LOAD_STATUS.INITIAL; this.controlsData[item.id] = null; }); @@ -134,6 +139,7 @@ class GroupControl extends React.PureComponent item.tabsScope === undefined, + ); + const isGroupAvailable = isGroupSettingAvailableOnTab(controlData.tabsScope, currentTabId); + + if (isGroupSettingPrevails && isGroupAvailable) { + return controlData.group; + } + + return controlData.group.filter( + (item) => + isItemScopeAvailableOnTab(item.tabsScope, currentTabId) || + (item.tabsScope === undefined && isGroupAvailable), + ); + } + private get dependentSelectors() { return this.props.settings.dependentSelectors ?? false; } @@ -300,7 +338,7 @@ class GroupControl extends React.PureComponent { const initialQueue: string[] = []; - for (const groupItem of this.props.data.group || []) { + for (const groupItem of this.state.memoGroupItems || []) { if (!groupItem.defaults) { continue; } @@ -345,7 +383,7 @@ class GroupControl extends React.PureComponent { - let controlIdOrder = data.group.map(({id}) => id); + let controlIdOrder = this.state.memoGroupItems.map(({id}) => id); if (!data.buttonApply || controlId) { return controlId ? [controlId] : controlIdOrder; @@ -354,7 +392,7 @@ class GroupControl extends React.PureComponent Object.keys(groupItem.defaults || {})[0], ); @@ -739,7 +777,7 @@ class GroupControl extends React.PureComponent, data) => { paramsState[data.id] = data.defaults || {}; return paramsState; @@ -845,12 +883,10 @@ class GroupControl extends React.PureComponent {this.renderSubHeader()} - {controlData.group?.map((item: DashTabItemControlSingle) => + {this.state.memoGroupItems?.map((item: DashTabItemControlSingle) => this.renderControl(item), )} {this.renderButtons()} diff --git a/src/ui/components/DashKit/plugins/GroupControl/types.ts b/src/ui/components/DashKit/plugins/GroupControl/types.ts index c5e776c8ff..5423cabb1e 100644 --- a/src/ui/components/DashKit/plugins/GroupControl/types.ts +++ b/src/ui/components/DashKit/plugins/GroupControl/types.ts @@ -1,5 +1,10 @@ import type {QueueItem, StateAndParamsMetaData} from '@gravity-ui/dashkit/helpers'; -import type {DashTabItemControlSourceType, StringParams, WorkbookId} from 'shared'; +import type { + DashTabItemControlSingle, + DashTabItemControlSourceType, + StringParams, + WorkbookId, +} from 'shared'; import type {ResponseSuccessSingleControl} from 'ui/libs/DatalensChartkit/modules/data-provider/charts'; import type {LoadStatus} from '../Control/types'; @@ -23,6 +28,7 @@ export interface PluginGroupControlState { localUpdateLoader: boolean; quickActionLoader: boolean; disableButtons?: boolean; + memoGroupItems: DashTabItemControlSingle[]; } export type GroupControlLocalMeta = Omit & { diff --git a/src/ui/components/DialogExtendedSettings/DialogExtendedSettings.tsx b/src/ui/components/DialogExtendedSettings/DialogExtendedSettings.tsx index 50db2ed06c..845e9e5c6e 100644 --- a/src/ui/components/DialogExtendedSettings/DialogExtendedSettings.tsx +++ b/src/ui/components/DialogExtendedSettings/DialogExtendedSettings.tsx @@ -13,6 +13,7 @@ import {selectSelectorsGroup} from 'ui/store/selectors/controlDialog'; import type {SelectorDialogState} from 'ui/store/typings/controlDialog'; import {CONTROLS_PLACEMENT_MODE} from '../../constants/dialogs'; +import {TabsScopeSelect} from '../ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/TabsScopeSelect'; import {FormSection} from '../FormSection/FormSection'; import {ControlPlacementRow} from './ControlPlacementRow/ControlPlacementRow'; @@ -27,6 +28,7 @@ export type ExtendedSettingsDialogProps = { selectorsGroupTitlePlaceholder?: string; enableAutoheightDefault?: boolean; showSelectorsGroupTitle?: boolean; + enableGlobalSelectors?: boolean; }; export type OpenDialogExtendedSettingsArgs = { @@ -48,6 +50,7 @@ const DialogExtendedSettings: React.FC = ({ selectorsGroupTitlePlaceholder, enableAutoheightDefault, showSelectorsGroupTitle, + enableGlobalSelectors, }) => { const selectorsGroup = useSelector(selectSelectorsGroup); const [itemsState, setItemsState] = React.useState(selectorsGroup.group); @@ -229,6 +232,7 @@ const DialogExtendedSettings: React.FC = ({ // we allow to enable autoheight selectorsGroup.group[0].titlePlacement === TitlePlacementOption.Top); const showUpdateControlsOnChange = selectorsGroup.buttonApply && isMultipleSelectors; + const showTabsScopeSelect = isMultipleSelectors && enableGlobalSelectors; return ( @@ -327,6 +331,13 @@ const DialogExtendedSettings: React.FC = ({ /> )} + + {showTabsScopeSelect && ( + + )} {isMultipleSelectors && ( diff --git a/src/ui/components/DialogExternalControl/DialogExternalControl.tsx b/src/ui/components/DialogExternalControl/DialogExternalControl.tsx index a3772c6f0d..4edef6fb34 100644 --- a/src/ui/components/DialogExternalControl/DialogExternalControl.tsx +++ b/src/ui/components/DialogExternalControl/DialogExternalControl.tsx @@ -8,7 +8,6 @@ import type {DatalensGlobalState} from 'index'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import {ControlQA} from 'shared'; -import {CommonSettingsSection} from 'ui/components/ControlComponents/Sections/CommonSettingsSection/CommonSettingsSection'; import {ParametersSection} from 'ui/components/ControlComponents/Sections/ParametersSection/ParametersSection'; import {SelectorPreview} from 'ui/components/ControlComponents/SelectorPreview/SelectorPreview'; import {SectionWrapper} from 'ui/components/SectionWrapper/SectionWrapper'; @@ -24,6 +23,8 @@ import { } from 'ui/store/selectors/controlDialog'; import type {SetItemDataArgs} from 'ui/units/dash/store/actions/dashTyped'; +import {ExternalSelectorSettings} from '../ControlComponents/Sections/CommonSettingsSection/ExternalSelectorSettings/ExternalSelectorSettings'; + import './DialogExternalControl.scss'; const controlI18n = I18n.keyset('dash.control-dialog.edit'); @@ -34,6 +35,8 @@ type DispatchProps = ReturnType; export type DialogExternalControlFeaturesProps = { enableAutoheightDefault?: boolean; + showSelectorsGroupTitle?: boolean; + enableGlobalSelectors?: boolean; }; type OwnProps = { @@ -86,7 +89,13 @@ class DialogExternalControl extends React.Component { private renderBody() { const showParametersSection = this.props.isParametersSectionAvailable; - const {navigationPath, changeNavigationPath, enableAutoheightDefault} = this.props; + const { + navigationPath, + changeNavigationPath, + enableAutoheightDefault, + // TODO: Add true by default + enableGlobalSelectors, + } = this.props; return ( @@ -95,10 +104,11 @@ class DialogExternalControl extends React.Component {
-
diff --git a/src/ui/components/DialogGroupControl/DialogGroupControl.scss b/src/ui/components/DialogGroupControl/DialogGroupControl.scss index 8f86d65944..6f111dd83c 100644 --- a/src/ui/components/DialogGroupControl/DialogGroupControl.scss +++ b/src/ui/components/DialogGroupControl/DialogGroupControl.scss @@ -49,4 +49,17 @@ display: flex; flex-direction: column; } + + &__global-icon { + margin: 0 8px 0 4px; + } + + &__item-wrapper { + display: flex; + align-items: center; + + &_secondary { + color: var(--g-color-text-secondary); + } + } } diff --git a/src/ui/components/DialogGroupControl/DialogGroupControl.tsx b/src/ui/components/DialogGroupControl/DialogGroupControl.tsx index 3f568a8e9b..00868f2575 100644 --- a/src/ui/components/DialogGroupControl/DialogGroupControl.tsx +++ b/src/ui/components/DialogGroupControl/DialogGroupControl.tsx @@ -24,6 +24,7 @@ export type DialogGroupControlFeaturesProps = { enableAutoheightDefault?: boolean; showSelectorsGroupTitle?: boolean; theme?: string; + enableGlobalSelectors?: boolean; }; export type DialogGroupControlProps = { @@ -45,6 +46,8 @@ export const DialogGroupControl: React.FC = ({ enableAutoheightDefault, showSelectorsGroupTitle, selectorsGroupTitlePlaceholder, + // TODO: Add true by default + enableGlobalSelectors, }) => { const {id, draftId} = useSelector(selectSelectorDialog); @@ -80,6 +83,7 @@ export const DialogGroupControl: React.FC = ({ selectorsGroupTitlePlaceholder={selectorsGroupTitlePlaceholder} enableAutoheightDefault={enableAutoheightDefault} showSelectorsGroupTitle={showSelectorsGroupTitle} + enableGlobalSelectors={enableGlobalSelectors} handleCopyItem={handleCopyItem} /> } @@ -90,6 +94,7 @@ export const DialogGroupControl: React.FC = ({ key={draftId || id} navigationPath={navigationPath} changeNavigationPath={changeNavigationPath} + enableGlobalSelectors={enableGlobalSelectors} /> } footer={} diff --git a/src/ui/components/DialogGroupControl/GroupControlBody/GroupControlBody.tsx b/src/ui/components/DialogGroupControl/GroupControlBody/GroupControlBody.tsx index db515ea8b6..4ff0dfabca 100644 --- a/src/ui/components/DialogGroupControl/GroupControlBody/GroupControlBody.tsx +++ b/src/ui/components/DialogGroupControl/GroupControlBody/GroupControlBody.tsx @@ -10,7 +10,7 @@ import {HintRow} from 'ui/components/ControlComponents/Sections/AppearanceSectio import {InnerTitleRow} from 'ui/components/ControlComponents/Sections/AppearanceSection/Rows/InnerTitleRow/InnerTitleRow'; import {TitlePlacementRow} from 'ui/components/ControlComponents/Sections/AppearanceSection/Rows/TitlePlacementRow/TitlePlacementRow'; import {TitleRow} from 'ui/components/ControlComponents/Sections/AppearanceSection/Rows/TitleRow/TitleRow'; -import {CommonSettingsSection} from 'ui/components/ControlComponents/Sections/CommonSettingsSection/CommonSettingsSection'; +import {CommonGroupSettingsSection} from 'ui/components/ControlComponents/Sections/CommonSettingsSection/CommonGroupSettingsSection'; import {InputTypeSelector} from 'ui/components/ControlComponents/Sections/CommonSettingsSection/InputTypeSelector/InputTypeSelector'; import {OperationSelector} from 'ui/components/ControlComponents/Sections/OperationSelector/OperationSelector'; import {RequiredValueCheckbox} from 'ui/components/ControlComponents/Sections/ValueSelector/RequiredValueCheckbox/RequiredValueCheckbox'; @@ -30,6 +30,7 @@ const i18n = I18n.keyset('dash.group-controls-dialog.edit'); export const GroupControlBody: React.FC<{ navigationPath: string | null; changeNavigationPath: (newNavigationPath: string) => void; + enableGlobalSelectors?: boolean; }> = (props) => { const elementType = useSelector(selectSelectorControlType); @@ -41,10 +42,11 @@ export const GroupControlBody: React.FC<{ -
diff --git a/src/ui/components/DialogGroupControl/GroupControlSidebar/GroupControlSidebar.tsx b/src/ui/components/DialogGroupControl/GroupControlSidebar/GroupControlSidebar.tsx index b1f3b6b807..edaa8b1ea8 100644 --- a/src/ui/components/DialogGroupControl/GroupControlSidebar/GroupControlSidebar.tsx +++ b/src/ui/components/DialogGroupControl/GroupControlSidebar/GroupControlSidebar.tsx @@ -23,8 +23,11 @@ import { } from 'ui/store/reducers/controlDialog'; import {selectActiveSelectorIndex, selectSelectorsGroup} from 'ui/store/selectors/controlDialog'; import type {SelectorDialogState, SelectorsGroupDialogState} from 'ui/store/typings/controlDialog'; +import {GlobalSelectorIcon} from 'ui/units/dash/components/GlobalSelectorIcon/GlobalSelectorIcon'; import type {CopiedConfigData} from 'ui/units/dash/modules/helpers'; import {isItemPasteAllowed} from 'ui/units/dash/modules/helpers'; +import {selectCurrentTabId} from 'ui/units/dash/store/selectors/dashTypedSelectors'; +import {isItemVisibleOnCurrentTab} from 'ui/units/dash/utils/selectors'; import {DIALOG_EXTENDED_SETTINGS} from '../../DialogExtendedSettings/DialogExtendedSettings'; @@ -91,14 +94,17 @@ export const GroupControlSidebar: React.FC<{ selectorsGroupTitlePlaceholder?: string; enableAutoheightDefault?: boolean; showSelectorsGroupTitle?: boolean; + enableGlobalSelectors?: boolean; }> = ({ handleCopyItem, selectorsGroupTitlePlaceholder, enableAutoheightDefault, showSelectorsGroupTitle, + enableGlobalSelectors, }) => { const selectorsGroup = useSelector(selectSelectorsGroup); const activeSelectorIndex = useSelector(selectActiveSelectorIndex); + const currentTabId = useSelector(selectCurrentTabId); const dispatch = useDispatch(); @@ -150,6 +156,7 @@ export const GroupControlSidebar: React.FC<{ selectorsGroupTitlePlaceholder, enableAutoheightDefault, showSelectorsGroupTitle, + enableGlobalSelectors, }, }), ); @@ -172,6 +179,38 @@ export const GroupControlSidebar: React.FC<{ [dispatch], ); + const renderControlIcon = React.useCallback( + (item: SelectorDialogState) => { + return ( + + ); + }, + [selectorsGroup.tabsScope], + ); + + const renderControlWrapper = React.useCallback( + (item: SelectorDialogState, children: React.ReactNode) => { + const isVisible = isItemVisibleOnCurrentTab( + item, + currentTabId, + selectorsGroup.tabsScope, + ); + const iconElement = renderControlIcon(item); + + return ( +
+ {iconElement} + {children} +
+ ); + }, + [currentTabId, selectorsGroup.tabsScope, renderControlIcon], + ); + return (
@@ -187,6 +226,7 @@ export const GroupControlSidebar: React.FC<{ canPasteItems={canPasteItems} onCopyItem={handleCopyItem} onUpdateItem={handleUpdateItem} + renderWrapper={renderControlWrapper} />
diff --git a/src/ui/components/ListWithMenu/ListWithMenu.scss b/src/ui/components/ListWithMenu/ListWithMenu.scss index b48e001fd4..79af07b11e 100644 --- a/src/ui/components/ListWithMenu/ListWithMenu.scss +++ b/src/ui/components/ListWithMenu/ListWithMenu.scss @@ -15,6 +15,10 @@ visibility: visible; } } + + &_secondary { + color: var(--g-color-text-secondary); + } } &__item { diff --git a/src/ui/components/ListWithMenu/ListWithMenu.tsx b/src/ui/components/ListWithMenu/ListWithMenu.tsx index 7fcdefe4a4..1ee7abbe3a 100644 --- a/src/ui/components/ListWithMenu/ListWithMenu.tsx +++ b/src/ui/components/ListWithMenu/ListWithMenu.tsx @@ -34,11 +34,14 @@ export interface ListWithMenuProps { iconOnHover?: boolean; /* * Callback on update item data via TabMenu */ onUpdateItem: (title: string) => void; + renderIcon?: (item: T) => React.ReactNode; + renderWrapper?: (item: T, children: React.ReactNode) => React.ReactNode; } type ItemWithTitleAndDraftId = { title?: string; draftId?: string; + secondary?: string; }; export const ListWithMenu = ({ @@ -49,6 +52,8 @@ export const ListWithMenu = ({ onDuplicate, onCopy, onUpdateItem, + renderIcon, + renderWrapper, }: ListWithMenuProps): React.ReactElement => { const {items, className, ...restListProps} = list; @@ -145,16 +150,34 @@ export const ListWithMenu = ({ return (
{showEdit ? (
- + {renderWrapper ? ( + renderWrapper( + item, + , + ) + ) : ( + + {renderIcon?.(item)} + + + )}
) : (
({ key={item.draftId || String(itemIndex)} onDoubleClick={handleDoubleClick} > -
- - {item.title} - -
+ {renderWrapper ? ( + renderWrapper( + item, +
+ + {item.title} + +
, + ) + ) : ( + + {renderIcon?.(item)} +
+ + {item.title} + +
+
+ )}
)} diff --git a/src/ui/components/SelectComponents/components/SelectOptionWithIcon/SelectOptionWithIcon.tsx b/src/ui/components/SelectComponents/components/SelectOptionWithIcon/SelectOptionWithIcon.tsx index d35a600037..b4de2f56cc 100644 --- a/src/ui/components/SelectComponents/components/SelectOptionWithIcon/SelectOptionWithIcon.tsx +++ b/src/ui/components/SelectComponents/components/SelectOptionWithIcon/SelectOptionWithIcon.tsx @@ -16,9 +16,11 @@ const b = block('dl-select-option-with-icon'); export const SelectOptionWithIcon = (props: BaseSelectOptionProps) => { return (
- - {props.option.data?.icon} - + {props.option.data?.icon && ( + + {props.option.data?.icon} + + )} {props.option.content}
); diff --git a/src/ui/components/TabMenu/TabMenu.tsx b/src/ui/components/TabMenu/TabMenu.tsx index 463d46a96e..e166772033 100644 --- a/src/ui/components/TabMenu/TabMenu.tsx +++ b/src/ui/components/TabMenu/TabMenu.tsx @@ -36,6 +36,8 @@ export const TabMenu = ({ pasteButtonText, onCopyItem, onUpdateItem, + renderIcon, + renderWrapper, }: TabMenuProps) => { const [pasteConfig, setPasteConfig] = React.useState(null); const workbookId = useSelector(selectDashWorkbookId); @@ -291,6 +293,8 @@ export const TabMenu = ({ iconOnHover={true} onCopy={onCopyItem} onUpdateItem={onUpdateItem} + renderIcon={renderIcon} + renderWrapper={renderWrapper} /> ); }; diff --git a/src/ui/components/TabMenu/types.ts b/src/ui/components/TabMenu/types.ts index 3633751e82..2f1edee1f6 100644 --- a/src/ui/components/TabMenu/types.ts +++ b/src/ui/components/TabMenu/types.ts @@ -35,6 +35,8 @@ export type TabMenuProps = { onPasteItems?: (pasteConfig: CopiedConfigData | null) => null | TabMenuItemData[]; canPasteItems?: (pasteConfig: CopiedConfigData | null, workbooId?: string | null) => boolean; onCopyItem?: (itemIndex: number) => void; + renderIcon?: (item: TabMenuItemData) => React.ReactNode; + renderWrapper?: (item: TabMenuItemData, children: React.ReactNode) => React.ReactNode; } & (TabsWithMenu | TabsWithRemove); export type TabsWithMenu = { diff --git a/src/ui/index.ts b/src/ui/index.ts index 31420dad0b..daa02c4cef 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -13,7 +13,7 @@ import type {MigrationToWorkbookState} from 'ui/store/reducers/migrationToWorkbo import type {AuthState} from 'units/auth/store/reducers'; import type {CollectionsState} from 'units/collections/store/reducers'; import type {ConnectionsReduxState} from 'units/connections/store/typings'; -import type {DashState} from 'units/dash/store/reducers/dashTypedReducer'; +import type {DashState} from 'units/dash/store/typings/dash'; import type {DatasetReduxState} from 'units/datasets/store/types'; import type {QLState} from 'units/ql/store/typings'; import type {WizardGlobalState} from 'units/wizard/reducers'; diff --git a/src/ui/store/actions/controlDialog.ts b/src/ui/store/actions/controlDialog.ts index 5ef9e355fd..150f39af12 100644 --- a/src/ui/store/actions/controlDialog.ts +++ b/src/ui/store/actions/controlDialog.ts @@ -311,8 +311,13 @@ export const applyGroupControlDialog = ({ width: isSingleControl ? '' : selector.width, defaults: getControlDefaultsForField(selector, hasChangedSourceType), namespace: selector.namespace, + tabsScope: selector.tabsScope, }; }), + tabsScope: + selectorsGroup.group.length > 1 + ? selectorsGroup.tabsScope ?? state.dash.tabId ?? undefined + : undefined, }; const getExtendedItemData = getExtendedItemDataAction(); @@ -418,7 +423,7 @@ export const applyExternalControlDialog = ({ return (dispatch: AppDispatch, getState: () => DatalensGlobalState) => { const state = getState(); const selectorDialog = selectSelectorDialog(state); - const {title, sourceType, autoHeight} = selectorDialog; + const {title, sourceType, autoHeight, tabsScope} = selectorDialog; const validation = getControlValidation(selectorDialog); @@ -439,6 +444,7 @@ export const applyExternalControlDialog = ({ sourceType, autoHeight, source: getItemDataSource(selectorDialog), + tabsScope, }; const getExtendedItemData = getExtendedItemDataAction(); const itemData = dispatch(getExtendedItemData({data, defaults})); diff --git a/src/ui/store/reducers/controlDialog.ts b/src/ui/store/reducers/controlDialog.ts index 1420fa7922..42449f1dae 100644 --- a/src/ui/store/reducers/controlDialog.ts +++ b/src/ui/store/reducers/controlDialog.ts @@ -92,6 +92,7 @@ export function getSelectorDialogInitialState( required: false, showHint: false, draftId: getRandomKey(), + tabsScope: undefined, ...(args.title ? {title: args.title} : {}), }; } @@ -103,6 +104,7 @@ export function getGroupSelectorDialogInitialState(): SelectorsGroupDialogState buttonApply: false, buttonReset: false, updateControlsOnChange: true, + tabsScope: undefined, group: [], }; } @@ -167,6 +169,7 @@ export function getSelectorDialogFromData( id: data.id, namespace: data.namespace, + tabsScope: data.tabsScope, }; } @@ -407,15 +410,7 @@ export function controlDialog( case UPDATE_SELECTORS_GROUP: { const {selectorsGroup} = state; - const { - group, - autoHeight, - buttonApply, - buttonReset, - updateControlsOnChange, - showGroupName, - groupName, - } = action.payload; + const {group, autoHeight} = action.payload; const {enableAutoheightDefault} = state.features[DashTabItemType.GroupControl] || {}; @@ -432,13 +427,9 @@ export function controlDialog( ...state, selectorsGroup: { ...selectorsGroup, - group, + ...action.payload, + autoHeight: updatedAutoHeight, - buttonApply, - buttonReset, - updateControlsOnChange, - showGroupName, - groupName, }, }; } diff --git a/src/ui/store/typings/controlDialog.ts b/src/ui/store/typings/controlDialog.ts index 2a0c26593c..9b7b5260be 100644 --- a/src/ui/store/typings/controlDialog.ts +++ b/src/ui/store/typings/controlDialog.ts @@ -20,6 +20,7 @@ import type {DialogChartWidgetFeatureProps} from 'ui/components/DialogChartWidge import type {DialogGroupControlFeaturesProps} from 'ui/components/DialogGroupControl/DialogGroupControl'; import type {DialogExternalControlFeaturesProps} from 'ui/components/DialogExternalControl/DialogExternalControl'; import type {DialogImageWidgetFeatureProps} from 'ui/components/DialogImageWidget'; +import type {TabsScope} from 'ui/units/dash/typings/selectors'; export type DialogEditItemFeaturesProp = { [DashTabItemType.Title]?: DialogTitleWidgetFeatureProps; @@ -49,6 +50,7 @@ export type SelectorsGroupDialogState = { buttonReset: boolean; updateControlsOnChange: boolean; group: SelectorDialogState[]; + tabsScope?: TabsScope; }; export type SelectorElementType = 'select' | 'date' | 'input' | 'checkbox'; @@ -132,6 +134,8 @@ export type SelectorDialogState = { accentType?: AccentTypeValue; // unique id for manipulating selectors in the creation phase draftId?: string; + // which tabs display the selector + tabsScope?: TabsScope; }; export type PastedSelectorDialogState = SelectorDialogState & { diff --git a/src/ui/units/dash/components/GlobalSelectorIcon/GlobalSelectorIcon.scss b/src/ui/units/dash/components/GlobalSelectorIcon/GlobalSelectorIcon.scss new file mode 100644 index 0000000000..6611315496 --- /dev/null +++ b/src/ui/units/dash/components/GlobalSelectorIcon/GlobalSelectorIcon.scss @@ -0,0 +1,4 @@ +.global-selector-icon { + color: var(--g-color-text-utility); + flex-shrink: 0; +} diff --git a/src/ui/units/dash/components/GlobalSelectorIcon/GlobalSelectorIcon.tsx b/src/ui/units/dash/components/GlobalSelectorIcon/GlobalSelectorIcon.tsx new file mode 100644 index 0000000000..2a68e7fe97 --- /dev/null +++ b/src/ui/units/dash/components/GlobalSelectorIcon/GlobalSelectorIcon.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import {Globe, LayoutTabs} from '@gravity-ui/icons'; +import {ActionTooltip, Icon} from '@gravity-ui/uikit'; +import block from 'bem-cn-lite'; +import {TABS_SCOPE_SELECT_VALUE} from 'ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/constants'; + +import {TABS_SCOPE_ALL} from '../../modules/constants'; +import type {TabsScope} from '../../typings/selectors'; + +import './GlobalSelectorIcon.scss'; + +const b = block('global-selector-icon'); + +type GlobalSelectorIconType = { + size?: number; + withHint?: boolean; + tabsScope?: TabsScope; + className?: string; +}; + +export const GlobalSelectorIcon = ({ + size = 16, + withHint, + tabsScope, + className, +}: GlobalSelectorIconType) => { + let icon; + let hintTitle = ''; + if (tabsScope === TABS_SCOPE_ALL) { + icon = ; + hintTitle = 'На всех вкладках'; + } else if (Array.isArray(tabsScope) || tabsScope === TABS_SCOPE_SELECT_VALUE.SELECTED_TABS) { + icon = ; + hintTitle = 'На выбранных вкладках'; + } + + if (icon) { + const showHint = withHint && hintTitle; + return showHint ? {icon} : icon; + } + + return null; +}; diff --git a/src/ui/units/dash/containers/Body/Body.tsx b/src/ui/units/dash/containers/Body/Body.tsx index d6d1dac2d4..0e1e14237b 100644 --- a/src/ui/units/dash/containers/Body/Body.tsx +++ b/src/ui/units/dash/containers/Body/Body.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { + Config, ConfigItem, ConfigLayout, DashKit as DashKitComponent, @@ -92,6 +93,7 @@ import { } from '../../modules/helpers'; import type {TabsHashStates} from '../../store/actions/dashTyped'; import { + removeGlobalItems, setCurrentTabData, setDashKitRef, setErrorMode, @@ -203,6 +205,7 @@ type MemoContext = { isPublicMode?: boolean; workbookId?: string | null; enableAssistant?: boolean; + currentTabId?: string | null; }; type DashkitGroupRenderWithContextProps = DashkitGroupRenderProps & {context: MemoContext}; @@ -496,6 +499,19 @@ class Body extends React.PureComponent { config: DashKitProps['config']; itemsStateAndParams: DashKitProps['itemsStateAndParams']; }) => { + // if the widget was deleted and it was in globalItems, we need to remove it from other places manually + if ( + this.props.tabData?.globalItems && + this.props.tabData.globalItems.length !== config.globalItems?.length + ) { + const updatedGlobalItems = config.globalItems as DashTabItem[]; + const removedItems = this.props.tabData.globalItems.filter( + (item) => !updatedGlobalItems?.includes(item), + ); + + this.props.removeGlobalItems({items: removedItems}); + } + if ( this.props.hashStates !== itemsStateAndParams && itemsStateAndParams && @@ -922,7 +938,8 @@ class Body extends React.PureComponent { if ( memoContext.workbookId !== this.props.workbookId || memoContext.fixedHeaderCollapsed !== isCollapsed || - memoContext.enableAssistant !== enableAssistant + memoContext.enableAssistant !== enableAssistant || + memoContext.currentTabId !== this.props.tabId ) { this._memoizedContext = { ...(memoContext || {}), @@ -931,6 +948,7 @@ class Body extends React.PureComponent { isEmbeddedMode: isEmbeddedMode(), isPublicMode: Boolean(this.props.isPublicMode), enableAssistant, + currentTabId: this.props.tabId, }; } @@ -1098,6 +1116,8 @@ class Body extends React.PureComponent { ...tabDataConfig, layout: sortedLayout, items: sortedItems as ConfigItem[], + // just for types + globalItems: tabDataConfig.globalItems as Config['globalItems'], }, }; } @@ -1139,7 +1159,7 @@ class Body extends React.PureComponent { const fixedHeaderCollapsed = context.fixedHeaderCollapsed || false; const isEditMode = this.isEditMode(); - const isEmptyTab = !tabDataConfig?.items.length; + const isEmptyTab = !tabDataConfig?.items.length && !tabDataConfig?.globalItems?.length; const DashKit = getConfiguredDashKit(undefined, {disableHashNavigation}); @@ -1440,6 +1460,7 @@ const mapDispatchToProps = { showToast, setWidgetCurrentTab, toggleTableOfContent, + removeGlobalItems, }; export default compose( diff --git a/src/ui/units/dash/containers/Dialogs/Dialogs.js b/src/ui/units/dash/containers/Dialogs/Dialogs.js index f2ea1f294b..f7cf4940a7 100644 --- a/src/ui/units/dash/containers/Dialogs/Dialogs.js +++ b/src/ui/units/dash/containers/Dialogs/Dialogs.js @@ -2,7 +2,7 @@ import React from 'react'; import {DEFAULT_NAMESPACE} from '@gravity-ui/dashkit/helpers'; import {useDispatch, useSelector} from 'react-redux'; -import {EntryScope} from 'shared'; +import {DashTabItemType, EntryScope} from 'shared'; import {useEffectOnce} from 'ui'; import {DialogEditItem, isDialogEditItemType} from 'ui/components/DialogEditItem/DialogEditItem'; import {registry} from 'ui/registry'; @@ -24,6 +24,13 @@ import { import Settings from './Settings/Settings'; import Tabs from './Tabs/Tabs'; +// TODO: remove after disable enableGlobalSelectors in other places +const DASHBOARD_FEATURES = { + [DashTabItemType.GroupControl]: { + enableGlobalSelectors: true, + }, +}; + // TODO: to see if dialogs with complex content will slow down due to the fact that mount/unmount is happening // TODO: if there are noticeable lags, it will be possible to render the contents of the dialogs as available // TODO: however, this content is not really needed by those who are not going to edit the dashboard @@ -85,6 +92,7 @@ export function Dialogs() { navigationPath={navigationPath} changeNavigationPath={changeNavigationPathHandle} theme={theme} + features={DASHBOARD_FEATURES} /> ); } diff --git a/src/ui/units/dash/modules/constants.ts b/src/ui/units/dash/modules/constants.ts index f1dffc8482..3965a51a98 100644 --- a/src/ui/units/dash/modules/constants.ts +++ b/src/ui/units/dash/modules/constants.ts @@ -88,3 +88,5 @@ export enum Mode { export const socialNets = [ShareOptions.Telegram, ShareOptions.VK]; export const CROSS_PASTE_ITEMS_ALLOWED = [ITEM_TYPE.TITLE, ITEM_TYPE.TEXT]; + +export const TABS_SCOPE_ALL = 'all' as const; diff --git a/src/ui/units/dash/store/actions/base/actions.ts b/src/ui/units/dash/store/actions/base/actions.ts index 6fbccb9820..76391cca48 100644 --- a/src/ui/units/dash/store/actions/base/actions.ts +++ b/src/ui/units/dash/store/actions/base/actions.ts @@ -22,7 +22,7 @@ import {registry} from '../../../../../registry'; import {showToast} from '../../../../../store/actions/toaster'; import {DashErrorCode, Mode} from '../../../modules/constants'; import {collectDashStats} from '../../../modules/pushStats'; -import type {DashState} from '../../reducers/dashTypedReducer'; +import type {DashState} from '../../typings/dash'; import {getFakeDashEntry} from '../../utils'; import { SET_ERROR_MODE, diff --git a/src/ui/units/dash/store/actions/dashTyped.ts b/src/ui/units/dash/store/actions/dashTyped.ts index cfbb5e7a9d..b65edc4bba 100644 --- a/src/ui/units/dash/store/actions/dashTyped.ts +++ b/src/ui/units/dash/store/actions/dashTyped.ts @@ -22,6 +22,7 @@ import type { DashTabItem, DashTabItemControl, DashTabItemGroupControl, + DashTabItemGroupControlData, DashTabItemImage, DashTabItemWidget, RecursivePartial, @@ -52,7 +53,6 @@ import {collectDashStats} from '../../modules/pushStats'; import {DashUpdateStatus} from '../../typings/dash'; import {DASH_EDIT_HISTORY_UNIT_ID} from '../constants'; import * as actionTypes from '../constants/dashActionTypes'; -import type {DashState} from '../reducers/dashTypedReducer'; import { selectDash, selectDashData, @@ -60,6 +60,7 @@ import { selectDashEntry, selectEntryId, } from '../selectors/dashTypedSelectors'; +import type {DashState} from '../typings/dash'; import {save} from './base/actions'; import {migrateDataSettings} from './helpers'; @@ -782,7 +783,7 @@ export function purgeData(data: DashData) { return { ...data, tabs: data.tabs.map((tab) => { - const {id: tabId, items: tabItems, layout, connections, aliases} = tab; + const {id: tabId, items: tabItems, layout, connections, aliases, globalItems} = tab; const currentItemsIds = new Set(); const currentWidgetTabsIds = new Set(); @@ -790,6 +791,23 @@ export function purgeData(data: DashData) { allTabsIds.add(tabId); + if (globalItems) { + globalItems.forEach((item) => { + allItemsIds.add(item.id); + currentItemsIds.add(item.id); + + if ('group' in data) { + (data as unknown as DashTabItemGroupControlData).group.forEach( + (widgetItem) => { + currentControlsIds.add(widgetItem.id); + }, + ); + } else { + currentControlsIds.add(item.id); + } + }); + } + const resultItems = tabItems // there are empty data .filter((item) => !isEmpty(item.data)) @@ -1008,3 +1026,15 @@ export function updateDeprecatedDashConfig() { dispatch(addDashEditHistoryPoint()); }; } + +export const REMOVE_GLOBAL_ITEMS = Symbol('dash/REMOVE_GLOBAL_ITEMS'); +export type RemoveGlobalItemsAction = { + type: typeof REMOVE_GLOBAL_ITEMS; + payload: {items: DashTabItem[]}; +}; +export const removeGlobalItems = ( + payload: RemoveGlobalItemsAction['payload'], +): RemoveGlobalItemsAction => ({ + type: REMOVE_GLOBAL_ITEMS, + payload, +}); diff --git a/src/ui/units/dash/store/actions/helpers.ts b/src/ui/units/dash/store/actions/helpers.ts index 21c71ea6b7..84286e9d66 100644 --- a/src/ui/units/dash/store/actions/helpers.ts +++ b/src/ui/units/dash/store/actions/helpers.ts @@ -5,7 +5,7 @@ import {isEmbeddedEntry} from 'ui/utils/embedded'; import ChartKit from '../../../../libs/DatalensChartkit'; import {registry} from '../../../../registry'; -import type {DashState} from '../reducers/dashTypedReducer'; +import type {DashState} from '../typings/dash'; import type {SetItemDataArgs} from './dashTyped'; diff --git a/src/ui/units/dash/store/actions/index.ts b/src/ui/units/dash/store/actions/index.ts index bbadb8080a..e586e0f348 100644 --- a/src/ui/units/dash/store/actions/index.ts +++ b/src/ui/units/dash/store/actions/index.ts @@ -13,6 +13,7 @@ import type {EntryContentAction} from '../../../../store/actions/entryContent'; import type {SaveDashErrorAction, SaveDashSuccessAction} from './dash'; import type { ChangeNavigationPathAction, + RemoveGlobalItemsAction, SetAccessDescriptionAction, SetDashKeyAction, SetDashKitRefAction, @@ -76,6 +77,7 @@ export type DashAction = | SaveDashErrorAction | SetSettingsAction | SetHistoryStateAction - | EditHistoryAction; + | EditHistoryAction + | RemoveGlobalItemsAction; export type DashDispatch = ThunkDispatch; diff --git a/src/ui/units/dash/store/reducers/dash.js b/src/ui/units/dash/store/reducers/dash.js index 039810ed40..46b81affe9 100644 --- a/src/ui/units/dash/store/reducers/dash.js +++ b/src/ui/units/dash/store/reducers/dash.js @@ -12,18 +12,15 @@ import {Mode} from '../../modules/constants'; import {getUniqIdsFromDashData} from '../../modules/helpers'; import * as actionTypes from '../constants/dashActionTypes'; +import { + TAB_PROPERTIES, + addItemToTab, + getGlobalItemsToCopy, + getStateForControlWithGlobalLogic, + isItemGlobal, +} from './dashHelpers'; import {dashTypedReducer} from './dashTypedReducer'; -export const TAB_PROPERTIES = [ - 'id', - 'title', - 'items', - 'layout', - 'connections', - 'aliases', - 'settings', -]; - const initialState = { mode: Mode.Loading, @@ -75,6 +72,10 @@ function dash(state = initialState, action) { const salt = data.salt; const dashDataUniqIds = getUniqIdsFromDashData(data); + // Get global items to copy from the first existing tab + const firstExistingTab = data.tabs.length > 0 ? data.tabs[0] : null; + const {globalItems: globalItemsToCopy, layout} = getGlobalItemsToCopy(firstExistingTab); + const newTabs = action.payload.map((tab) => { let tabItem = null; const idsMapper = {}; @@ -183,7 +184,17 @@ function dash(state = initialState, action) { const uniqTabIdData = generateUniqId({salt, counter, ids: dashDataUniqIds}); counter = uniqTabIdData.counter; - tabItem = {id: uniqTabIdData.id, ...tab}; + tabItem = { + id: uniqTabIdData.id, + ...tab, + }; + + // Copy global items to new tab if they exist + if (globalItemsToCopy.length > 0) { + globalItemsToCopy.forEach((globalItem) => { + tabItem = addItemToTab(tabItem, globalItem, layout[globalItem.id]); + }); + } } return tabItem; @@ -296,6 +307,14 @@ function dash(state = initialState, action) { }; } case actionTypes.SET_ITEM_DATA: { + const itemType = action.payload.type; + const itemData = action.payload.data; + + const isGlobal = + itemType === DashTabItemType.GroupControl || itemType === DashTabItemType.Control + ? isItemGlobal(itemType, itemData) + : false; + const tabData = DashKit.setItem({ item: { id: state.openedItemId, @@ -309,6 +328,7 @@ function dash(state = initialState, action) { options: { excludeIds: getUniqIdsFromDashData(data), updateLayout: state.dragOperationProps?.newLayout, + ...(isGlobal ? {useGlobalItems: true} : {}), }, }); @@ -352,6 +372,24 @@ function dash(state = initialState, action) { tabData.connections = updatedConnections; } + // Handle global control items (GroupControl and Control types) + if (itemType === DashTabItemType.GroupControl || itemType === DashTabItemType.Control) { + const updatedState = getStateForControlWithGlobalLogic({ + state, + data, + tabData, + tabIndex, + itemType, + itemData, + isGlobal, + }); + + // If the function returned a state, return it + if (updatedState) { + return updatedState; + } + } + const modifiedItem = tabData.layout[tabData.layout.length - 1]; return { diff --git a/src/ui/units/dash/store/reducers/dashHelpers.ts b/src/ui/units/dash/store/reducers/dashHelpers.ts new file mode 100644 index 0000000000..2991573ad6 --- /dev/null +++ b/src/ui/units/dash/store/reducers/dashHelpers.ts @@ -0,0 +1,400 @@ +/* eslint-disable complexity */ +import type {Config} from '@gravity-ui/dashkit'; +import {DashKit} from '@gravity-ui/dashkit'; +import update from 'immutability-helper'; +import type { + DashData, + DashTab, + DashTabItem, + DashTabItemControlData, + DashTabItemGroupControlData, + DashTabLayout, +} from 'shared'; +import {DashTabItemType} from 'shared'; + +import {TABS_SCOPE_ALL} from '../../modules/constants'; +import type {TabsScope} from '../../typings/selectors'; +import type {DashState} from '../typings/dash'; + +// Tab properties that can be updated +export const TAB_PROPERTIES = [ + 'id', + 'title', + 'items', + 'layout', + 'connections', + 'aliases', + 'settings', + 'globalItems', +] as const; + +export function isItemGlobal( + itemType: DashTabItemType, + itemData: DashTabItemControlData | DashTabItemGroupControlData, +): boolean { + if (itemType === DashTabItemType.Control) { + return isControlGlobal((itemData as DashTabItemControlData).tabsScope); + } + + if (itemType === DashTabItemType.GroupControl) { + return isGroupControlGlobal(itemData as DashTabItemGroupControlData); + } + + return false; +} + +function isControlGlobal(tabsScope: TabsScope): boolean { + return tabsScope === TABS_SCOPE_ALL || (Array.isArray(tabsScope) && tabsScope.length > 1); +} + +function isGroupControlGlobal(itemData: DashTabItemGroupControlData): boolean { + const groupTabsScope = itemData.tabsScope; + const isGroupSettingsApplied = itemData.group.some( + (selector) => selector.tabsScope === undefined, + ); + + if (isControlGlobal(groupTabsScope) && isGroupSettingsApplied) { + return true; + } + + return itemData.group.some((selector) => isControlGlobal(selector.tabsScope)); +} + +type DetailedGlobalStatus = { + hasAllScope: boolean; + usedTabs: Set; +}; + +export function getDetailedGlobalStatus( + itemType: DashTabItemType, + itemData: DashTabItemControlData | DashTabItemGroupControlData, +): DetailedGlobalStatus { + const usedTabs = new Set(); + let hasAllScope = false; + + if (itemType === DashTabItemType.Control) { + const controlData = itemData as DashTabItemControlData; + const tabsScope = controlData.tabsScope; + if (tabsScope === TABS_SCOPE_ALL) { + hasAllScope = true; + } else if (Array.isArray(tabsScope) && tabsScope.length > 1) { + tabsScope.forEach((tabId) => usedTabs.add(tabId)); + } + } + + if (itemType === DashTabItemType.GroupControl) { + const groupData = itemData as DashTabItemGroupControlData; + const groupTabsScope = groupData.tabsScope; + const isGroupSettingsPrevails = groupData.group.every( + (selector) => selector.tabsScope === undefined, + ); + const isGroupSettingsApplied = groupData.group.some( + (selector) => selector.tabsScope === undefined, + ); + + if (isGroupSettingsApplied) { + if (groupTabsScope === TABS_SCOPE_ALL) { + hasAllScope = true; + } else if (Array.isArray(groupTabsScope)) { + groupTabsScope.forEach((tabId) => usedTabs.add(tabId)); + } else if (groupTabsScope !== TABS_SCOPE_ALL && typeof groupTabsScope === 'string') { + usedTabs.add(groupTabsScope); + } + } + + if (!isGroupSettingsPrevails) { + for (const selector of groupData.group) { + const selectorTabsScope = selector.tabsScope; + if (selectorTabsScope === undefined) { + continue; + } + + if (selectorTabsScope === TABS_SCOPE_ALL) { + hasAllScope = true; + } else if (Array.isArray(selectorTabsScope) && selectorTabsScope.length > 1) { + selectorTabsScope.forEach((tabId) => usedTabs.add(tabId)); + } else if (typeof selectorTabsScope === 'string') { + usedTabs.add(selectorTabsScope); + } + } + } + } + + return {hasAllScope, usedTabs}; +} + +export function addItemToTab(tab: DashTab, item: DashTabItem, layoutItem?: DashTabLayout): DashTab { + // we need only to update layout as globalItem will be passed in separate field + // add new global item to top of parent + // TODO: Need groups + const reflowedLayout = DashKit.reflowLayout({ + newLayoutItem: layoutItem + ? {...layoutItem, x: 0, y: 0} + : { + i: item.id, + x: 0, + y: 0, + w: 8, + h: 2, + }, + layout: tab.layout, + groups: [{id: 'default'}], + }); + + return { + ...tab, + layout: reflowedLayout, + globalItems: [...(tab.globalItems || []), item], + }; +} + +export function updateTabsWithGlobalItem( + data: DashData, + addedItem: DashTabItem, + hasAllScope: boolean, + usedTabs: Set, + tabData: DashTab, + currentTabIndex: number, + removeFromCurrentTab?: boolean, +): DashTab[] { + const tabsToProcess = hasAllScope ? data.tabs : data.tabs.filter((tab) => usedTabs.has(tab.id)); + const layoutItem = tabData.layout.find((item) => item.i === addedItem.id); + + return data.tabs.map((tab, index) => { + if (index === currentTabIndex) { + return removeFromCurrentTab + ? { + ...tabData, + items: tabData.items.slice(0, tabData.items.length - 1), + layout: tabData.layout.slice(0, tabData.layout.length - 1), + } + : tabData; + } + + // If tab is not in tabsToProcess, remove the item from globalItems if it exists + if (!tabsToProcess.includes(tab) && tab.globalItems) { + const updatedGlobalItems = tab.globalItems?.filter((item) => item.id !== addedItem.id); + + // Only return updated tab if globalItems actually changed + if (updatedGlobalItems.length !== tab.globalItems.length) { + return { + ...tab, + globalItems: updatedGlobalItems, + }; + } + + return tab; + } + + // Check if item with same id already exists in this tab's globalItems + const existingItemIndex = tab.globalItems?.findIndex((item) => item.id === addedItem.id); + + if (existingItemIndex !== undefined && existingItemIndex !== -1) { + // Update existing item + const updatedGlobalItems = [...(tab.globalItems || [])]; + updatedGlobalItems[existingItemIndex] = addedItem; + + return { + ...tab, + globalItems: updatedGlobalItems, + }; + } + + // Add new item to tab + return addItemToTab(tab, addedItem, layoutItem); + }); +} + +export function removeGlobalItemFromTabs( + data: DashData, + openedItemId: string, + currentTabIndex: number, + tabData?: DashTab, +): DashTab[] { + return data.tabs.map((tab, index) => { + if (index === currentTabIndex) { + return tabData || tab; + } + + const updateFields: Partial = { + layout: tab.layout.filter((layoutItem) => layoutItem.i !== openedItemId), + }; + + if (tab.globalItems) { + updateFields.globalItems = tab.globalItems.filter((item) => item.id !== openedItemId); + } + + return { + ...tab, + ...updateFields, + }; + }); +} + +export function getStateForControlWithGlobalLogic({ + state, + data, + tabData, + tabIndex, + itemType, + itemData, + isGlobal, +}: { + state: DashState; + data: DashData; + tabData: DashTab & Pick; + tabIndex: number; + itemType: DashTabItemType; + itemData: DashTabItemControlData | DashTabItemGroupControlData; + isGlobal: boolean; +}): DashState | null { + const detailedGlobalStatus = getDetailedGlobalStatus(itemType, itemData); + const {hasAllScope, usedTabs} = detailedGlobalStatus; + const removeFromCurrentTab = !hasAllScope && !usedTabs.has(tabData.id); + + // Editing existing control + if (state.openedItemId) { + const savedGlobalItem = data.tabs[tabIndex].globalItems?.find( + (item) => item.id === state.openedItemId, + ); + const wasGlobal = Boolean(savedGlobalItem); + + if (isGlobal && wasGlobal) { + // Case: Global item remains global - item data or tabsScope was changed + const updatedItem = tabData.globalItems?.find((item) => item.id === state.openedItemId); + + if (!updatedItem) { + return null; + } + + const updatedTabs = updateTabsWithGlobalItem( + data, + updatedItem, + detailedGlobalStatus.hasAllScope, + detailedGlobalStatus.usedTabs, + tabData, + tabIndex, + removeFromCurrentTab, + ); + + return { + ...state, + lastModifiedItemId: updatedItem.id, + data: update(data, { + tabs: {$set: updatedTabs}, + counter: {$set: tabData.counter}, + }), + }; + } else if (wasGlobal && !isGlobal) { + // Case: Global to local - remove from globalItems, add to current tab + const updatedTabs = removeGlobalItemFromTabs( + data, + state.openedItemId, + tabIndex, + tabData, + ); + + return { + ...state, + lastModifiedItemId: state.openedItemId, + data: update(data, { + tabs: {$set: updatedTabs}, + counter: {$set: tabData.counter}, + }), + }; + } else if (!wasGlobal && isGlobal) { + // Case: Local to global - remove from current tab, add to globalItems and appropriate tabs + const addedItem = tabData.globalItems?.find((item) => item.id === state.openedItemId); + + if (!addedItem) { + return null; + } + + const updatedTabs = updateTabsWithGlobalItem( + data, + addedItem, + hasAllScope, + usedTabs, + tabData, + tabIndex, + removeFromCurrentTab, + ); + + return { + ...state, + lastModifiedItemId: addedItem.id, + data: update(data, { + tabs: {$set: updatedTabs}, + counter: {$set: tabData.counter}, + }), + }; + } + } + + // Creating new global item + if (!state.openedItemId && isGlobal && tabData.globalItems) { + const addedItem = tabData.globalItems[tabData.globalItems.length - 1]; + + if (!addedItem) { + return null; + } + + const updatedTabs = updateTabsWithGlobalItem( + data, + addedItem, + hasAllScope, + usedTabs, + tabData, + tabIndex, + removeFromCurrentTab, + ); + + return { + ...state, + lastModifiedItemId: addedItem.id, + data: update(data, { + tabs: {$set: updatedTabs}, + counter: {$set: tabData.counter}, + }), + }; + } + + // No special handling needed + return null; +} + +export function getGlobalItemsToCopy(tab: DashTab) { + if (!tab || !tab.globalItems) { + return {globalItems: [], layout: {}}; + } + + const layout: Record = {}; + + const usedGlobalItems = tab.globalItems.filter((item) => { + if (item.type === DashTabItemType.Control) { + const controlData = item.data; + if (controlData.tabsScope === TABS_SCOPE_ALL) { + layout[item.id] = tab.layout.find((layoutItem) => layoutItem.i === item.id); + return true; + } + return false; + } + + if (item.type === DashTabItemType.GroupControl) { + const groupData = item.data; + + if ( + (groupData.tabsScope === TABS_SCOPE_ALL && + groupData.group.some((item) => item.tabsScope === undefined)) || + groupData.group.some((item) => item.tabsScope === TABS_SCOPE_ALL) + ) { + layout[item.id] = tab.layout.find((layoutItem) => layoutItem.i === item.id); + return true; + } + } + + return false; + }); + + return {globalItems: usedGlobalItems, layout}; +} diff --git a/src/ui/units/dash/store/reducers/dashTypedReducer.ts b/src/ui/units/dash/store/reducers/dashTypedReducer.ts index 659e055779..497be2c92d 100644 --- a/src/ui/units/dash/store/reducers/dashTypedReducer.ts +++ b/src/ui/units/dash/store/reducers/dashTypedReducer.ts @@ -1,5 +1,3 @@ -import type React from 'react'; - import type {DashKit} from '@gravity-ui/dashkit'; import update from 'immutability-helper'; import {cloneDeep, pick} from 'lodash'; @@ -15,6 +13,7 @@ import type {DIALOG_TYPE} from 'ui/constants/dialogs'; import type {ValuesType} from 'utility-types'; import {Mode} from '../../modules/constants'; +import type {TabsHashStates} from '../../store/actions/dashTyped'; import type {DashUpdateStatus} from '../../typings/dash'; import { CLOSE_DIALOG, @@ -23,9 +22,9 @@ import { SAVE_DASH_ERROR, SAVE_DASH_SUCCESS, } from '../actions/dash'; -import type {TabsHashStates} from '../actions/dashTyped'; import { CHANGE_NAVIGATION_PATH, + REMOVE_GLOBAL_ITEMS, SET_DASHKIT_REF, SET_DASH_ACCESS_DESCRIPTION, SET_DASH_DESCRIPTION, @@ -51,8 +50,9 @@ import { } from '../actions/dashTyped'; import type {DashAction} from '../actions/index'; -import {TAB_PROPERTIES} from './dash'; +import {TAB_PROPERTIES} from './dashHelpers'; +// TODO: Remove after up version export type DashState = { tabId: null | string; lastModifiedItemId: null | string; @@ -369,6 +369,44 @@ export function dashTypedReducer( }; } + case REMOVE_GLOBAL_ITEMS: { + const removedItems = action.payload.items; + + if (removedItems.length === 0) { + return state; + } + + const removedItemsIds = removedItems.map((item) => item.id); + + return { + ...state, + data: { + ...state.data, + tabs: state.data.tabs.map((tab) => { + if (!tab.globalItems) { + return tab; + } + + const filteredGlobalItems = tab.globalItems.filter( + (item) => !removedItemsIds.includes(item.id), + ); + + if (filteredGlobalItems.length === tab.globalItems.length) { + return tab; + } + + return { + ...tab, + globalItems: tab.globalItems.filter( + (item) => !removedItemsIds.includes(item.id), + ), + layout: tab.layout.filter((item) => !removedItemsIds.includes(item.i)), + }; + }), + }, + }; + } + default: return state; } diff --git a/src/ui/units/dash/store/selectors/dashTypedSelectors.ts b/src/ui/units/dash/store/selectors/dashTypedSelectors.ts index 3f157ecfac..c21be396d8 100644 --- a/src/ui/units/dash/store/selectors/dashTypedSelectors.ts +++ b/src/ui/units/dash/store/selectors/dashTypedSelectors.ts @@ -1,11 +1,12 @@ import type {DatalensGlobalState} from 'index'; import isEqual from 'lodash/isEqual'; import {createSelector} from 'reselect'; +import type {DashTabItem} from 'shared/types'; import {ITEM_TYPE} from '../../../../constants/dialogs'; import {isOrderIdsChanged} from '../../containers/Dialogs/Tabs/PopupWidgetsOrder/helpers'; import {Mode} from '../../modules/constants'; -import type {DashState} from '../reducers/dashTypedReducer'; +import type {DashState} from '../typings/dash'; export const selectDash = (state: DatalensGlobalState) => state.dash || null; @@ -176,7 +177,10 @@ export const selectOpenedItemData = createSelector( [selectCurrentTab, selectDash], (currentTab, dash) => { if (dash.openedItemId && currentTab) { - const item = currentTab.items.find(({id}) => id === dash.openedItemId); + const allItems = currentTab.items.concat( + (currentTab.globalItems as DashTabItem[]) || [], + ); + const item = allItems.find(({id}) => id === dash.openedItemId); return item?.data; } return undefined; diff --git a/src/ui/units/dash/store/typings/dash.ts b/src/ui/units/dash/store/typings/dash.ts new file mode 100644 index 0000000000..b44cce86de --- /dev/null +++ b/src/ui/units/dash/store/typings/dash.ts @@ -0,0 +1,46 @@ +import type {DashKit} from '@gravity-ui/dashkit'; +import type { + DashData, + DashDragOptions, + DashEntry, + EntryAnnotation, + Permissions, + WidgetType, +} from 'shared'; +import type {DIALOG_TYPE} from 'ui/constants/dialogs'; +import type {ValuesType} from 'utility-types'; + +import type {Mode} from '../../modules/constants'; +import type {TabsHashStates} from '../../store/actions/dashTyped'; +import type {DashUpdateStatus} from '../../typings/dash'; + +export type DashState = { + tabId: null | string; + lastModifiedItemId: null | string; + hashStates?: null | TabsHashStates; + stateHashId: null | string; + initialTabsSettings?: null | DashData['tabs']; + mode: Mode; + navigationPath: null | string; + dashKitRef: null | React.RefObject; + error: null | Error; + openedDialog: null | ValuesType; + openedItemId: string | null; + showTableOfContent: boolean; + lastUsedConnectionId: undefined | string; + entry: DashEntry; + data: DashData; + annotation?: EntryAnnotation | null; + updateStatus: DashUpdateStatus; + convertedEntryData: DashData | null; + permissions?: Permissions; + lockToken: string | null; + isFullscreenMode?: boolean; + isLoadingEditMode: boolean; + skipReload?: boolean; + openedItemWidgetType?: WidgetType; + // contains widgetId: currentTabId to open widget dialog with current tab + widgetsCurrentTab: {[key: string]: string}; + dragOperationProps: DashDragOptions | null; + openInfoOnLoad?: boolean; +}; diff --git a/src/ui/units/dash/typings/selectors.ts b/src/ui/units/dash/typings/selectors.ts new file mode 100644 index 0000000000..522ca1128d --- /dev/null +++ b/src/ui/units/dash/typings/selectors.ts @@ -0,0 +1,3 @@ +import type {TABS_SCOPE_ALL} from '../modules/constants'; + +export type TabsScope = typeof TABS_SCOPE_ALL | string | string[] | undefined; diff --git a/src/ui/units/dash/utils/selectors.ts b/src/ui/units/dash/utils/selectors.ts new file mode 100644 index 0000000000..12cbffd6fb --- /dev/null +++ b/src/ui/units/dash/utils/selectors.ts @@ -0,0 +1,54 @@ +import {Feature} from 'shared'; +import {TABS_SCOPE_ALL} from 'ui/units/dash/modules/constants'; +import {isEnabledFeature} from 'ui/utils/isEnabledFeature'; + +import type {TabsScope} from '../typings/selectors'; + +export interface TabsScopeItem { + tabsScope?: string | string[]; +} + +export const isGroupSettingAvailableOnTab = ( + groupTabsScope: TabsScope, + currentTabId: string, +): boolean => { + return ( + !groupTabsScope || + groupTabsScope === TABS_SCOPE_ALL || + groupTabsScope === currentTabId || + (Array.isArray(groupTabsScope) && groupTabsScope.includes(currentTabId)) + ); +}; + +export const isItemScopeAvailableOnTab = ( + itemTabsScope: TabsScope, + currentTabId: string, +): boolean => { + return ( + itemTabsScope === currentTabId || + itemTabsScope === TABS_SCOPE_ALL || + (Array.isArray(itemTabsScope) && itemTabsScope.includes(currentTabId)) + ); +}; + +export const isItemVisibleOnCurrentTab = ( + item: {tabsScope?: TabsScope}, + currentTabId: string | null, + groupTabsScope: TabsScope, +): boolean => { + if (!isEnabledFeature(Feature.EnableGlobalSelectors) || !currentTabId) { + return true; + } + + const isGroupSettingPrevails = item.tabsScope === undefined; + const isGroupAvailable = isGroupSettingAvailableOnTab(groupTabsScope, currentTabId); + + if (isGroupSettingPrevails && isGroupAvailable) { + return true; + } + + return ( + isItemScopeAvailableOnTab(item.tabsScope, currentTabId) || + (item.tabsScope === undefined && isGroupAvailable) + ); +}; diff --git a/tests/page-objects/wizard/DatasetSelector.ts b/tests/page-objects/wizard/DatasetSelector.ts index 65b87e716e..46d3ad43a4 100644 --- a/tests/page-objects/wizard/DatasetSelector.ts +++ b/tests/page-objects/wizard/DatasetSelector.ts @@ -4,7 +4,7 @@ import {slct} from '../../utils'; import {RobotChartsDatasets} from '../../utils/constants'; import {SectionDatasetQA} from '../../../src/shared'; -class DatasetSelector { +class DatasetSelectorSettings { errorIconSelector = slct('dataset-error-icon'); private page: Page; @@ -61,4 +61,4 @@ class DatasetSelector { } } -export default DatasetSelector; +export default DatasetSelectorSettings; From 859442ca011c4c6b94582aa384df6ed334b9b272 Mon Sep 17 00:00:00 2001 From: Taya Leutina Date: Fri, 24 Oct 2025 12:56:50 +0300 Subject: [PATCH 2/5] Add stubs for keysets --- .../TabsScopeSelect/TabsScopeSelect.tsx | 30 ++++++++++++++----- .../GlobalSelectorIcon/GlobalSelectorIcon.tsx | 16 ++++++++-- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/TabsScopeSelect.tsx b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/TabsScopeSelect.tsx index 6a0c006ed7..2f12709a4b 100644 --- a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/TabsScopeSelect.tsx +++ b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/TabsScopeSelect/TabsScopeSelect.tsx @@ -25,11 +25,27 @@ import './TabsScopeSelect.scss'; const b = block('tabs-scope-select'); +// const i18n = I18n.keyset('dash.control-dialog.edit'); + +// TODO: Add translations +const i18n = (key: string) => { + const values: Record = { + 'label_tabs-scope': 'Показать во вкладках', + 'label_selected-tabs-placeholder': 'Выберите вкладки', + 'value_all-tabs': 'На всех вкладках', + 'value_selected-tabs': 'Выбранные вкладки', + 'value_current-tab': 'Текущая вкладка', + 'value_as-group': 'Как у группы', + }; + + return values[key]; +}; + const LABEL_BY_SCOPE_MAP = { - [TABS_SCOPE_SELECT_VALUE.ALL]: 'Во всех вкладках', - [TABS_SCOPE_SELECT_VALUE.CURRENT_TAB]: 'Текущая вкладка', - [TABS_SCOPE_SELECT_VALUE.AS_GROUP]: 'Как у группы', - [TABS_SCOPE_SELECT_VALUE.SELECTED_TABS]: 'Выбранные вкладки', + [TABS_SCOPE_SELECT_VALUE.ALL]: i18n('value_all-tabs'), + [TABS_SCOPE_SELECT_VALUE.CURRENT_TAB]: i18n('value_current-tab'), + [TABS_SCOPE_SELECT_VALUE.AS_GROUP]: i18n('value_as-group'), + [TABS_SCOPE_SELECT_VALUE.SELECTED_TABS]: i18n('value_selected-tabs'), }; const renderOptions = (option: SelectOption) => ; @@ -134,7 +150,6 @@ export const TabsScopeSelect = ({ const updateSelectorsState = React.useCallback( (tabsScope: TabsScope) => { - // add get tabsScope by tabsScope value dispatch( isGroupSettings ? updateSelectorsGroup({ @@ -167,6 +182,7 @@ export const TabsScopeSelect = ({ const handleSelectedTabsChange = React.useCallback( (value: string[]) => { // Always ensure current tab is included and can't be removed + // TODO: change logic const newSelectedTabs = value.includes(currentTabId) ? value : [...value, currentTabId]; setSelectedTabs(newSelectedTabs); updateSelectorsState(newSelectedTabs); @@ -186,7 +202,7 @@ export const TabsScopeSelect = ({ return ( - + + + {showTabsSelector && ( + - - {showTabsSelector && ( -