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..7b32bc8cee 100644 --- a/src/shared/types/dash.ts +++ b/src/shared/types/dash.ts @@ -2,6 +2,9 @@ import type {ItemDropProps} from '@gravity-ui/dashkit'; import type {Operations} from '../modules'; +export type ImpactType = 'allTabs' | 'currentTab' | 'selectedTabs' | undefined; +export type ImpactTabsIds = string[] | null | undefined; + import type { ClientChartsConfig, Dictionary, @@ -125,6 +128,7 @@ export interface DashTab { aliases: DashTabAliases; connections: DashTabConnection[]; settings?: DashTabSettings; + globalItems?: DashTabItem[]; } export type DashSettingsGlobalParams = Record; @@ -232,6 +236,8 @@ export interface DashTabItemControlData { width?: string; defaults?: StringParams; namespace: string; + impactType?: ImpactType; + impactTabsIds?: ImpactTabsIds; } export type DashTabItemControlSingle = DashTabItemControlDataset | DashTabItemControlManual; @@ -339,6 +345,8 @@ export interface DashTabItemGroupControlData { autoHeight: boolean; buttonApply: boolean; buttonReset: boolean; + impactType?: ImpactType; + impactTabsIds?: ImpactTabsIds; updateControlsOnChange?: boolean; diff --git a/src/shared/types/feature.ts b/src/shared/types/feature.ts index acc6b60bf8..10f0f107a8 100644 --- a/src/shared/types/feature.ts +++ b/src/shared/types/feature.ts @@ -101,6 +101,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', /** Shows updated settings page */ EnableNewServiceSettings = 'EnableNewServiceSettings', } diff --git a/src/shared/zod-schemas/dash.ts b/src/shared/zod-schemas/dash.ts index 8a380cbbb0..dc0fa3d196 100644 --- a/src/shared/zod-schemas/dash.ts +++ b/src/shared/zod-schemas/dash.ts @@ -116,6 +116,8 @@ const controlSchema = z namespace: z.literal(DASH_DEFAULT_NAMESPACE), title: z.string().min(1), sourceType: z.enum(DashTabItemControlSourceType), + impactType: z.enum(['allTabs', 'currentTab', 'selectedTabs']).optional(), + impactTabsIds: z.array(z.string()).optional(), }) .and( z.discriminatedUnion('sourceType', [ @@ -147,6 +149,8 @@ const groupControlItemsSchema = z defaults: z.record(z.any(), z.any()), placementMode: z.enum(CONTROLS_PLACEMENT_MODE).optional(), width: z.string().optional(), + impactType: z.enum(['allTabs', 'currentTab', 'selectedTabs']).optional(), + impactTabsIds: z.array(z.string()).optional(), }) .and( z.discriminatedUnion('sourceType', [ diff --git a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/CommonGroupSettingsSection.tsx b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/CommonGroupSettingsSection.tsx new file mode 100644 index 0000000000..c02f047f83 --- /dev/null +++ b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/CommonGroupSettingsSection.tsx @@ -0,0 +1,68 @@ +import React from 'react'; + +import {I18n} from 'i18n'; +import {useSelector} from 'react-redux'; +import {DashTabItemControlSourceType} from 'shared'; +import {selectSelectorDialog, selectSelectorsGroup} from 'ui/store/selectors/controlDialog'; + +import {ConnectionSettings} from './ConnectionSettings/ConnectionSettings'; +import {DatasetSelectorSettings} from './DatasetSelectorSettings/DatasetSelectorSettings'; +import {ImpactTypeSelect} from './ImpactTypeSelect/ImpactTypeSelect'; +import {ParameterNameInput} from './ParameterNameInput/ParameterNameInput'; + +const i18n = I18n.keyset('dash.control-dialog.edit'); + +export const CommonGroupSettingsSection = ({ + navigationPath, + changeNavigationPath, + enableGlobalSelectors, + className, +}: { + navigationPath: string | null; + changeNavigationPath: (newNavigationPath: string) => void; + enableGlobalSelectors?: boolean; + className?: string; +}) => { + const {sourceType} = useSelector(selectSelectorDialog); + const {group, impactType, impactTabsIds} = useSelector(selectSelectorsGroup); + + const hasMultipleSelectors = group.length > 1; + + switch (sourceType) { + case DashTabItemControlSourceType.Manual: + return ( + + + {enableGlobalSelectors && ( + + )} + + ); + case DashTabItemControlSourceType.Connection: + return ( + + ); + default: + return ( + + ); + } +}; diff --git a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/CommonSettingsSection.tsx b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/CommonSettingsSection.tsx deleted file mode 100644 index d7c9bdc84b..0000000000 --- a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/CommonSettingsSection.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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 {ConnectionSettings} from './ConnectionSettings/ConnectionSettings'; -import {DatasetSelector} from './DatasetSelector/DatasetSelector'; -import {ExternalSelectorSettings} from './ExternalSelectorSettings/ExternalSelectorSettings'; -import {ParameterNameInput} from './ParameterNameInput/ParameterNameInput'; - -const i18n = I18n.keyset('dash.control-dialog.edit'); - -export const CommonSettingsSection = ({ - navigationPath, - changeNavigationPath, - enableAutoheightDefault, - className, -}: { - navigationPath: string | null; - changeNavigationPath: (newNavigationPath: string) => void; - enableAutoheightDefault?: boolean; - className?: string; -}) => { - const {sourceType} = useSelector(selectSelectorDialog); - - switch (sourceType) { - case DashTabItemControlSourceType.External: - return ( - - ); - case DashTabItemControlSourceType.Manual: - return ( - - ); - case DashTabItemControlSourceType.Connection: - return ( - - ); - 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..6280cbafda 100644 --- a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ConnectionSettings/ConnectionSettings.tsx +++ b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ConnectionSettings/ConnectionSettings.tsx @@ -2,8 +2,9 @@ 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 {ImpactTypeSelect} from '../ImpactTypeSelect/ImpactTypeSelect'; import {ParameterNameInput} from '../ParameterNameInput/ParameterNameInput'; import {ConnectionSelector} from './components/ConnectionSelector/ConnectionSelector'; @@ -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 {impactType, impactTabsIds, group} = useSelector(selectSelectorsGroup); return ( @@ -38,6 +42,13 @@ 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 88% rename from src/ui/components/ControlComponents/Sections/CommonSettingsSection/DatasetSelector/DatasetSelector.tsx rename to src/ui/components/ControlComponents/Sections/CommonSettingsSection/DatasetSelectorSettings/DatasetSelectorSettings.tsx index 24a86bbe63..4231126900 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 {ImpactTypeSelect} from '../ImpactTypeSelect/ImpactTypeSelect'; 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 {impactType, impactTabsIds, 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,15 @@ function DatasetSelector(props: { /> + {props.enableGlobalSelectors && ( + 1} + groupImpactType={impactType} + groupImpactTabsIds={impactTabsIds} + > + )} ); } -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..d301f0f171 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 {ImpactTypeSelect} from '../ImpactTypeSelect/ImpactTypeSelect'; 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 && ( { + 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 = { + [IMPACT_TYPE_OPTION_VALUE.ALL_TABS]: i18n('value_all-tabs'), + [IMPACT_TYPE_OPTION_VALUE.CURRENT_TAB]: i18n('value_current-tab'), + [IMPACT_TYPE_OPTION_VALUE.AS_GROUP]: i18n('value_as-group'), + [IMPACT_TYPE_OPTION_VALUE.SELECTED_TABS]: i18n('value_selected-tabs'), +}; + +const renderOptions = (option: SelectOption) => ; + +export type ImpactTypeSelectProps = { + groupImpactType?: ImpactType; + groupImpactTabsIds?: ImpactTabsIds; + hasMultipleSelectors?: boolean; + isGroupSettings?: boolean; +}; + +const getInitialImpactTabsIds = ({ + isGroupSettings, + groupImpactTabsIds, + selectorImpactTabsIds, +}: { + isGroupSettings?: boolean; + groupImpactTabsIds?: ImpactTabsIds; + selectorImpactTabsIds?: ImpactTabsIds; +}) => { + if (isGroupSettings) { + return groupImpactTabsIds || []; + } + + return selectorImpactTabsIds || []; +}; + +export const ImpactTypeSelect = ({ + groupImpactType, + groupImpactTabsIds, + hasMultipleSelectors, + isGroupSettings, +}: ImpactTypeSelectProps) => { + const dispatch = useDispatch(); + const selectorDialog = useSelector(selectSelectorDialog); + + const currentTabId = useSelector(selectTabId) as string; + const tabs = useSelector(selectTabs); + const selectorsGroup = useSelector(selectSelectorsGroup); + + const [impactTabsIds, setImpactTabsIds] = React.useState( + getInitialImpactTabsIds({ + isGroupSettings, + groupImpactTabsIds, + selectorImpactTabsIds: selectorDialog.impactTabsIds, + }), + ); + + const optionTabTitle = React.useMemo(() => { + if (impactTabsIds.length !== 1) { + const currentTab = tabs.find((tab) => tab.id === currentTabId); + return currentTab?.title || ''; + } + + const optionTab = tabs.find((tab) => tab.id === impactTabsIds[0]); + const currentTabTitle = optionTab?.title || ''; + + return currentTabTitle; + }, [currentTabId, impactTabsIds, tabs]); + + const tabsOptions = React.useMemo(() => { + return tabs.map((tab) => ({ + value: tab.id, + content: tab.title, + disabled: tab.id === currentTabId, + })); + }, [tabs, currentTabId]); + + const currentImpactType = getImpactTypeByValue({ + selectorImpactType: isGroupSettings ? groupImpactType : selectorDialog.impactType, + hasMultipleSelectors, + }); + + // Create options based on whether there are multiple selectors + const tabsScopeOptions: SelectOption<{icon?: JSX.Element}>[] = React.useMemo(() => { + const baseOptions = [ + { + value: IMPACT_TYPE_OPTION_VALUE.CURRENT_TAB, + content: ( + + {LABEL_BY_SCOPE_MAP[IMPACT_TYPE_OPTION_VALUE.CURRENT_TAB]} + {optionTabTitle && {optionTabTitle}} + + ), + }, + { + value: IMPACT_TYPE_OPTION_VALUE.ALL_TABS, + content: LABEL_BY_SCOPE_MAP[IMPACT_TYPE_OPTION_VALUE.ALL_TABS], + data: {icon: getIconByImpactType(IMPACT_TYPE_OPTION_VALUE.ALL_TABS)}, + }, + { + value: IMPACT_TYPE_OPTION_VALUE.SELECTED_TABS, + content: LABEL_BY_SCOPE_MAP[IMPACT_TYPE_OPTION_VALUE.SELECTED_TABS], + data: { + icon: getIconByImpactType(IMPACT_TYPE_OPTION_VALUE.SELECTED_TABS), + }, + }, + ]; + + if (hasMultipleSelectors && !isGroupSettings) { + const groupImpactTypeItem = getImpactTypeByValue({ + selectorImpactType: groupImpactType, + }); + + return [ + { + value: IMPACT_TYPE_OPTION_VALUE.AS_GROUP, + content: ( + + {i18n('value_as-group')} + + {LABEL_BY_SCOPE_MAP[groupImpactTypeItem]} + + + ), + data: { + icon: getIconByImpactType(groupImpactTypeItem), + }, + }, + ...baseOptions, + ]; + } + + return baseOptions; + }, [optionTabTitle, hasMultipleSelectors, isGroupSettings, groupImpactType]); + + const updateSelectorsState = React.useCallback( + (impactType: ImpactType, newImpactTabsIds?: string[] | null) => { + dispatch( + isGroupSettings + ? updateSelectorsGroup({ + ...selectorsGroup, + impactType, + impactTabsIds: newImpactTabsIds, + }) + : setSelectorDialogItem({ + impactType, + impactTabsIds: newImpactTabsIds, + }), + ); + }, + [dispatch, isGroupSettings, selectorsGroup], + ); + + const handleImpactTypeChange = React.useCallback( + (value: string[]) => { + const newImpactType = value[0]; + const tabsScopeValue = getImpactTypeValueByName({name: newImpactType}); + + let newImpactTabsIds = null; + if (tabsScopeValue === 'selectedTabs') { + // When switching to selected tabs, ensure current tab is included + newImpactTabsIds = impactTabsIds.includes(currentTabId) + ? impactTabsIds + : [...impactTabsIds, currentTabId]; + setImpactTabsIds(newImpactTabsIds); + } else if (tabsScopeValue === 'currentTab') { + // When switching to current tab, set impactTabsIds to current tab + newImpactTabsIds = [currentTabId]; + setImpactTabsIds(newImpactTabsIds); + } + + updateSelectorsState(tabsScopeValue, newImpactTabsIds); + }, + [currentTabId, impactTabsIds, updateSelectorsState], + ); + + const handleImpactTabsIdsChange = React.useCallback( + (value: string[]) => { + // TODO (global selectors): Add validation instead of disable current tab + const newImpactTabsIds = value.includes(currentTabId) + ? value + : [...value, currentTabId]; + setImpactTabsIds(newImpactTabsIds); + updateSelectorsState('selectedTabs', newImpactTabsIds); + }, + [currentTabId, updateSelectorsState], + ); + + const showTabsSelector = currentImpactType === 'selectedTabs'; + + if (!currentTabId || !isEnabledFeature(Feature.EnableGlobalSelectors)) { + return null; + } + + const hasClear = currentImpactType !== 'currentTab' && currentImpactType !== 'asGroup'; + + return ( + + + + + )} + + + + ); +}; diff --git a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ImpactTypeSelect/constants.ts b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ImpactTypeSelect/constants.ts new file mode 100644 index 0000000000..72e07ac33e --- /dev/null +++ b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ImpactTypeSelect/constants.ts @@ -0,0 +1,9 @@ +import type {ImpactType} from 'shared/types'; + +export const IMPACT_TYPE_OPTION_VALUE: Record | 'asGroup'> = + { + ALL_TABS: 'allTabs', + CURRENT_TAB: 'currentTab', + AS_GROUP: 'asGroup', + SELECTED_TABS: 'selectedTabs', + }; diff --git a/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ImpactTypeSelect/helpers.tsx b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ImpactTypeSelect/helpers.tsx new file mode 100644 index 0000000000..19f2bb2114 --- /dev/null +++ b/src/ui/components/ControlComponents/Sections/CommonSettingsSection/ImpactTypeSelect/helpers.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import type {ImpactType} from 'shared/types/dash'; +import {GlobalSelectorIcon} from 'ui/units/dash/components/GlobalSelectorIcon/GlobalSelectorIcon'; + +import {IMPACT_TYPE_OPTION_VALUE} from './constants'; + +export const getImpactTypeByValue = ({ + selectorImpactType, + hasMultipleSelectors, +}: { + selectorImpactType: ImpactType; + hasMultipleSelectors?: boolean; +}) => { + switch (selectorImpactType) { + case IMPACT_TYPE_OPTION_VALUE.ALL_TABS: + case IMPACT_TYPE_OPTION_VALUE.SELECTED_TABS: + case IMPACT_TYPE_OPTION_VALUE.CURRENT_TAB: + return selectorImpactType; + default: + return hasMultipleSelectors + ? IMPACT_TYPE_OPTION_VALUE.AS_GROUP + : IMPACT_TYPE_OPTION_VALUE.CURRENT_TAB; + } +}; + +export const getImpactTypeValueByName = ({name}: {name: string}): ImpactType => { + switch (name) { + case IMPACT_TYPE_OPTION_VALUE.ALL_TABS: + case IMPACT_TYPE_OPTION_VALUE.CURRENT_TAB: + case IMPACT_TYPE_OPTION_VALUE.SELECTED_TABS: + return name as ImpactType; + case IMPACT_TYPE_OPTION_VALUE.AS_GROUP: + default: + return undefined; + } +}; + +export const getIconByImpactType = (impactType: ImpactType | string) => { + switch (impactType) { + case 'allTabs': + case 'selectedTabs': + return ; + default: + return undefined; + } +}; diff --git a/src/ui/components/DashKit/plugins/GroupControl/GroupControl.tsx b/src/ui/components/DashKit/plugins/GroupControl/GroupControl.tsx index 09231fa912..949465ad2b 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.impactType === undefined, + ); + const isGroupAvailable = isGroupSettingAvailableOnTab( + currentTabId, + controlData.impactType, + controlData.impactTabsIds, + ); + + if (isGroupSettingPrevails && isGroupAvailable) { + return controlData.group; + } + + return controlData.group.filter( + (item) => + (item.impactType === undefined && isGroupAvailable) || + isItemScopeAvailableOnTab(currentTabId, item.impactType, item.impactTabsIds), + ); + } + private get dependentSelectors() { return this.props.settings.dependentSelectors ?? false; } @@ -300,7 +342,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 +387,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 +396,7 @@ class GroupControl extends React.PureComponent Object.keys(groupItem.defaults || {})[0], ); @@ -739,7 +781,7 @@ class GroupControl extends React.PureComponent, data) => { paramsState[data.id] = data.defaults || {}; return paramsState; @@ -845,12 +887,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..704537ac17 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 {ImpactTypeSelect} from '../ControlComponents/Sections/CommonSettingsSection/ImpactTypeSelect/ImpactTypeSelect'; 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 showImpactTypeSelect = isMultipleSelectors && enableGlobalSelectors; return ( @@ -327,6 +331,14 @@ const DialogExtendedSettings: React.FC = ({ /> )} + + {showImpactTypeSelect && ( + + )} {isMultipleSelectors && ( diff --git a/src/ui/components/DialogExternalControl/DialogExternalControl.tsx b/src/ui/components/DialogExternalControl/DialogExternalControl.tsx index a3772c6f0d..3aa28d3cc5 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,12 @@ class DialogExternalControl extends React.Component { private renderBody() { const showParametersSection = this.props.isParametersSectionAvailable; - const {navigationPath, changeNavigationPath, enableAutoheightDefault} = this.props; + const { + navigationPath, + changeNavigationPath, + enableAutoheightDefault, + enableGlobalSelectors, + } = this.props; return ( @@ -95,10 +103,11 @@ class DialogExternalControl extends React.Component {
-
diff --git a/src/ui/components/DialogGroupControl/DialogGroupControl.scss b/src/ui/components/DialogGroupControl/DialogGroupControl.scss index f9a2059c47..14989839cc 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..c1f38baeeb 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,7 @@ export const DialogGroupControl: React.FC = ({ enableAutoheightDefault, showSelectorsGroupTitle, selectorsGroupTitlePlaceholder, + enableGlobalSelectors, }) => { const {id, draftId} = useSelector(selectSelectorDialog); @@ -80,6 +82,7 @@ export const DialogGroupControl: React.FC = ({ selectorsGroupTitlePlaceholder={selectorsGroupTitlePlaceholder} enableAutoheightDefault={enableAutoheightDefault} showSelectorsGroupTitle={showSelectorsGroupTitle} + enableGlobalSelectors={enableGlobalSelectors} handleCopyItem={handleCopyItem} /> } @@ -90,6 +93,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..cd407564bf 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,39 @@ export const GroupControlSidebar: React.FC<{ [dispatch], ); + const renderControlIcon = React.useCallback( + (item: SelectorDialogState) => { + return ( + + ); + }, + [selectorsGroup.impactType], + ); + + const renderControlWrapper = React.useCallback( + (item: SelectorDialogState, children: React.ReactNode) => { + const isVisible = isItemVisibleOnCurrentTab( + item, + currentTabId, + selectorsGroup.impactType, + selectorsGroup.impactTabsIds, + ); + const iconElement = renderControlIcon(item); + + return ( +
+ {iconElement} + {children} +
+ ); + }, + [currentTabId, selectorsGroup.impactType, selectorsGroup.impactTabsIds, renderControlIcon], + ); + return (
@@ -187,6 +227,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 b1cfbcde19..a23a351a88 100644 --- a/src/ui/components/TabMenu/TabMenu.tsx +++ b/src/ui/components/TabMenu/TabMenu.tsx @@ -37,6 +37,8 @@ export const TabMenu = ({ pasteButtonText, onCopyItem, onUpdateItem, + renderIcon, + renderWrapper, className, }: TabMenuProps) => { const [pasteConfig, setPasteConfig] = React.useState(null); @@ -295,6 +297,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 1cd5ed9e8e..9aed1e2a93 100644 --- a/src/ui/components/TabMenu/types.ts +++ b/src/ui/components/TabMenu/types.ts @@ -37,6 +37,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..7ffd5af8d5 100644 --- a/src/ui/store/actions/controlDialog.ts +++ b/src/ui/store/actions/controlDialog.ts @@ -3,6 +3,8 @@ import type { DashTabItem, DashTabItemControlData, DashTabItemGroupControl, + ImpactTabsIds, + ImpactType, StringParams, } from 'shared'; import type { @@ -180,6 +182,32 @@ const isSelectorWithContext = ( return 'originalId' in selector; }; +const getValidScopeFields = ({ + impactType, + impactTabsIds, + tabId, + isMainSetting, +}: { + impactType: ImpactType; + impactTabsIds: ImpactTabsIds; + tabId: string | null; + isMainSetting?: boolean; +}): {impactType: ImpactType; impactTabsIds: ImpactTabsIds} => { + if (impactType === 'allTabs') { + return {impactType, impactTabsIds: null}; + } + + if (impactType === 'selectedTabs' && impactTabsIds?.length) { + return {impactType, impactTabsIds}; + } + + if (!isMainSetting && impactType === undefined) { + return {impactTabsIds: null, impactType: undefined}; + } + + return {impactType: 'currentTab', impactTabsIds: tabId ? [tabId] : undefined}; +}; + export const applyGroupControlDialog = ({ setItemData, closeDialog, @@ -282,6 +310,13 @@ export const applyGroupControlDialog = ({ } }); + const {impactType, impactTabsIds} = getValidScopeFields({ + impactType: selectorsGroup.impactType, + impactTabsIds: selectorsGroup.impactTabsIds, + tabId: state.dash.tabId, + isMainSetting: true, + }); + const data = { autoHeight, updateControlsOnChange, @@ -306,17 +341,25 @@ export const applyGroupControlDialog = ({ id: selector.id, title: selector.title, sourceType: selector.sourceType, - source: getItemDataSource(selector) as DashTabItemControlData['source'], + source: getItemDataSource(selector), placementMode: isSingleControl ? 'auto' : selector.placementMode, width: isSingleControl ? '' : selector.width, defaults: getControlDefaultsForField(selector, hasChangedSourceType), namespace: selector.namespace, + ...getValidScopeFields({ + impactType: selector.impactType, + impactTabsIds: selector.impactTabsIds, + tabId: state.dash.tabId, + }), }; }), + // if control is single we take the scope params from the control data + impactType: isSingleControl ? undefined : impactType, + impactTabsIds: isSingleControl ? undefined : impactTabsIds, }; const getExtendedItemData = getExtendedItemDataAction(); - const itemData = dispatch(getExtendedItemData({data})); + const itemData = dispatch(getExtendedItemData({data: data as SetItemDataArgs['data']})); const finalItemData = { ...itemData, @@ -418,7 +461,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, impactType, impactTabsIds} = selectorDialog; const validation = getControlValidation(selectorDialog); @@ -439,6 +482,12 @@ export const applyExternalControlDialog = ({ sourceType, autoHeight, source: getItemDataSource(selectorDialog), + ...getValidScopeFields({ + impactType, + impactTabsIds, + tabId: state.dash.tabId, + isMainSetting: true, + }), }; 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..385e62da50 100644 --- a/src/ui/store/reducers/controlDialog.ts +++ b/src/ui/store/reducers/controlDialog.ts @@ -92,6 +92,8 @@ export function getSelectorDialogInitialState( required: false, showHint: false, draftId: getRandomKey(), + impactType: undefined, + impactTabsIds: undefined, ...(args.title ? {title: args.title} : {}), }; } @@ -103,6 +105,8 @@ export function getGroupSelectorDialogInitialState(): SelectorsGroupDialogState buttonApply: false, buttonReset: false, updateControlsOnChange: true, + impactType: undefined, + impactTabsIds: undefined, group: [], }; } @@ -167,6 +171,8 @@ export function getSelectorDialogFromData( id: data.id, namespace: data.namespace, + impactType: data.impactType, + impactTabsIds: data.impactTabsIds, }; } @@ -407,15 +413,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 +430,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..12b04c2792 100644 --- a/src/ui/store/typings/controlDialog.ts +++ b/src/ui/store/typings/controlDialog.ts @@ -9,6 +9,7 @@ import type { DashTabItemType, Dataset, DatasetFieldType, + ImpactTabsIds, StringParams, TitlePlacement, TitlePlacementOption, @@ -20,6 +21,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 {ImpactType} from 'shared/types/dash'; export type DialogEditItemFeaturesProp = { [DashTabItemType.Title]?: DialogTitleWidgetFeatureProps; @@ -49,6 +51,8 @@ export type SelectorsGroupDialogState = { buttonReset: boolean; updateControlsOnChange: boolean; group: SelectorDialogState[]; + impactType?: ImpactType; + impactTabsIds?: ImpactTabsIds; }; export type SelectorElementType = 'select' | 'date' | 'input' | 'checkbox'; @@ -95,7 +99,7 @@ export type SelectorDialogState = { titlePlacement?: TitlePlacement; innerTitle?: string; - sourceType?: SelectorSourceType; + sourceType: SelectorSourceType; autoHeight?: boolean; chartId?: string; showInnerTitle?: boolean; @@ -132,6 +136,9 @@ export type SelectorDialogState = { accentType?: AccentTypeValue; // unique id for manipulating selectors in the creation phase draftId?: string; + // which tabs display the selector + impactType?: ImpactType; + impactTabsIds?: ImpactTabsIds; }; 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..b5e37dfb67 --- /dev/null +++ b/src/ui/units/dash/components/GlobalSelectorIcon/GlobalSelectorIcon.tsx @@ -0,0 +1,53 @@ +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 type {ImpactType} from 'shared/types/dash'; + +import './GlobalSelectorIcon.scss'; + +const b = block('global-selector-icon'); + +type GlobalSelectorIconType = { + size?: number; + withHint?: boolean; + impactType?: ImpactType; + className?: string; +}; + +// const i18n = I18n.keyset('dash.control-dialog.edit'); + +// TODO (global selectors): Add translations +const i18n = (key: string) => { + const values: Record = { + 'value_all-tabs': 'На всех вкладках', + 'value_selected-tabs': 'Выбранные вкладки', + }; + + return values[key]; +}; + +export const GlobalSelectorIcon = ({ + size = 16, + withHint, + impactType, + className, +}: GlobalSelectorIconType) => { + let icon; + let hintTitle = ''; + if (impactType === 'allTabs') { + icon = ; + hintTitle = i18n('value_all-tabs'); + } else if (impactType === 'selectedTabs') { + icon = ; + hintTitle = i18n('value_selected-tabs'); + } + + 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 4907066bca..6bb42176de 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, @@ -77,6 +78,7 @@ import { } from '../../modules/helpers'; import type {TabsHashStates} from '../../store/actions/dashTyped'; import { + removeGlobalItems, setCurrentTabData, setDashKitRef, setErrorMode, @@ -185,6 +187,7 @@ type MemoContext = { isPublicMode?: boolean; workbookId?: string | null; enableAssistant?: boolean; + currentTabId?: string | null; }; type DashkitGroupRenderWithContextProps = DashkitGroupRenderProps & {context: MemoContext}; @@ -495,6 +498,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 && @@ -921,7 +937,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 || {}), @@ -930,6 +947,7 @@ class Body extends React.PureComponent { isEmbeddedMode: isEmbeddedMode(), isPublicMode: Boolean(this.props.isPublicMode), enableAssistant, + currentTabId: this.props.tabId, }; } @@ -1097,6 +1115,8 @@ class Body extends React.PureComponent { ...tabDataConfig, layout: sortedLayout, items: sortedItems as ConfigItem[], + // just for types + globalItems: tabDataConfig.globalItems as Config['globalItems'], }, }; } @@ -1138,7 +1158,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}); @@ -1338,6 +1358,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..db0f3d4fc3 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,15 @@ import { import Settings from './Settings/Settings'; import Tabs from './Tabs/Tabs'; +const DASHBOARD_FEATURES = { + [DashTabItemType.GroupControl]: { + enableGlobalSelectors: true, + }, + [DashTabItemType.Control]: { + 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 +94,7 @@ export function Dialogs() { navigationPath={navigationPath} changeNavigationPath={changeNavigationPathHandle} theme={theme} + features={DASHBOARD_FEATURES} /> ); } 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..6f8a6a9ff5 100644 --- a/src/ui/units/dash/store/actions/dashTyped.ts +++ b/src/ui/units/dash/store/actions/dashTyped.ts @@ -21,7 +21,9 @@ import type { DashTab, DashTabItem, DashTabItemControl, + DashTabItemControlSingle, DashTabItemGroupControl, + DashTabItemGroupControlData, DashTabItemImage, DashTabItemWidget, RecursivePartial, @@ -52,7 +54,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 +61,7 @@ import { selectDashEntry, selectEntryId, } from '../selectors/dashTypedSelectors'; +import type {DashState} from '../typings/dash'; import {save} from './base/actions'; import {migrateDataSettings} from './helpers'; @@ -394,15 +396,25 @@ type SetItemDataBase = { autoHeight?: boolean; source?: ItemDataSource; }; + +type SetItemDataGroupControlItem = Partial> & { + sourceType?: string; +}; export type SetItemDataText = RecursivePartial & SetItemDataBase; export type SetItemDataTitle = RecursivePartial & SetItemDataBase; -export type SetItemDataGroupControl = Partial & SetItemDataBase; +export type SetItemDataGroupControl = Partial> & + SetItemDataBase & {group?: SetItemDataGroupControlItem[]}; export type SetItemDataExternalControl = Partial & SetItemDataBase; export type SetItemDataImage = DashTabItemImage['data']; export type SetItemDataDefaults = Record; export type SetItemDataArgs = { - data: SetItemDataText | SetItemDataTitle | SetItemDataImage | SetItemDataGroupControl; + data: + | SetItemDataText + | SetItemDataTitle + | SetItemDataImage + | SetItemDataExternalControl + | SetItemDataGroupControl; defaults?: SetItemDataDefaults; type?: string; namespace?: string; @@ -782,7 +794,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 +802,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 +1037,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..4e26de12e1 --- /dev/null +++ b/src/ui/units/dash/store/reducers/dashHelpers.ts @@ -0,0 +1,429 @@ +/* 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 type {ImpactTabsIds, ImpactType} from 'shared/types/dash'; + +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) { + const controlData = itemData as DashTabItemControlData; + return isControlGlobal(controlData.impactType, controlData.impactTabsIds); + } + + if (itemType === DashTabItemType.GroupControl) { + return isGroupControlGlobal(itemData as DashTabItemGroupControlData); + } + + return false; +} + +function isControlGlobal(impactType: ImpactType, impactTabsIds?: ImpactTabsIds): boolean { + return ( + impactType === 'allTabs' || + (impactType === 'selectedTabs' && Boolean(impactTabsIds && impactTabsIds?.length > 1)) + ); +} + +function isGroupControlGlobal(itemData: DashTabItemGroupControlData): boolean { + const groupImpactType = itemData.impactType; + const groupImpactTabsIds = itemData.impactTabsIds; + const isGroupSettingsApplied = itemData.group.some( + (selector) => selector.impactType === undefined, + ); + + if (isControlGlobal(groupImpactType, groupImpactTabsIds) && isGroupSettingsApplied) { + return true; + } + + return itemData.group.some((selector) => + isControlGlobal(selector.impactType, selector.impactTabsIds), + ); +} + +type DetailedGlobalStatus = { + hasAllScope: boolean; + usedTabs: Set; +}; + +function getUsedTabsFromScope({ + impactType, + impactTabsIds, +}: { + impactType: ImpactType; + impactTabsIds?: ImpactTabsIds; +}) { + if (impactType === 'allTabs') { + return {hasAllScope: true, usedTabs: []}; + } else if ( + (impactType === 'selectedTabs' || impactType === 'currentTab') && + impactTabsIds && + impactTabsIds?.length > 1 + ) { + return {usedTabs: [...impactTabsIds], hasAllScope: false}; + } + + return {usedTabs: [], hasAllScope: false}; +} + +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 impactType = controlData.impactType; + const impactTabsIds = controlData.impactTabsIds; + + const usedTabsResult = getUsedTabsFromScope({impactType, impactTabsIds}); + + hasAllScope = usedTabsResult.hasAllScope; + usedTabsResult.usedTabs.forEach((tabId) => usedTabs.add(tabId)); + } + + if (itemType === DashTabItemType.GroupControl) { + const groupData = itemData as DashTabItemGroupControlData; + const groupImpactType = groupData.impactType; + const groupImpactTabsIds = groupData.impactTabsIds; + const isGroupSettingsPrevails = groupData.group.every( + (selector) => selector.impactType === undefined, + ); + const isGroupSettingsApplied = groupData.group.some( + (selector) => selector.impactType === undefined, + ); + + if (isGroupSettingsApplied) { + const usedTabsResult = getUsedTabsFromScope({ + impactType: groupImpactType, + impactTabsIds: groupImpactTabsIds, + }); + + hasAllScope = usedTabsResult.hasAllScope; + usedTabsResult.usedTabs.forEach((tabId) => usedTabs.add(tabId)); + } + + if (!isGroupSettingsPrevails) { + for (const selector of groupData.group) { + const selectorImpactType = selector.impactType; + const selectorImpactTabsIds = selector.impactTabsIds; + if (selectorImpactType === undefined) { + continue; + } + + const usedTabsResult = getUsedTabsFromScope({ + impactType: selectorImpactType, + impactTabsIds: selectorImpactTabsIds, + }); + + hasAllScope = usedTabsResult.hasAllScope; + usedTabsResult.usedTabs.forEach((tabId) => usedTabs.add(tabId)); + } + } + } + + 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 impactType 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.impactType === 'allTabs') { + 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.impactType === 'allTabs' && + groupData.group.some((item) => item.impactType === undefined)) || + groupData.group.some((item) => item.impactType === 'allTabs') + ) { + 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..5058142bf7 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 (global selectors): 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/utils/selectors.ts b/src/ui/units/dash/utils/selectors.ts new file mode 100644 index 0000000000..9b00092b80 --- /dev/null +++ b/src/ui/units/dash/utils/selectors.ts @@ -0,0 +1,63 @@ +import {Feature} from 'shared'; +import type {ImpactTabsIds, ImpactType} from 'shared/types/dash'; +import {isEnabledFeature} from 'ui/utils/isEnabledFeature'; + +export interface ImpactTypeItem { + impactType?: ImpactType; + impactTabsIds?: ImpactTabsIds; +} + +export const isItemScopeAvailableOnTab = ( + currentTabId: string, + itemImpactType: ImpactType, + itemImpactTabsIds?: ImpactTabsIds, +): boolean => { + switch (itemImpactType) { + case 'allTabs': + return true; + case 'currentTab': + case 'selectedTabs': + return itemImpactTabsIds ? itemImpactTabsIds.includes(currentTabId) : true; + default: + return false; + } +}; + +export const isGroupSettingAvailableOnTab = ( + currentTabId: string, + groupImpactType: ImpactType, + groupImpactTabsIds?: ImpactTabsIds, +): boolean => { + if (!groupImpactType) { + return true; + } + + return isItemScopeAvailableOnTab(currentTabId, groupImpactType, groupImpactTabsIds); +}; + +export const isItemVisibleOnCurrentTab = ( + item: {impactType?: ImpactType; impactTabsIds?: ImpactTabsIds}, + currentTabId: string | null, + groupImpactType: ImpactType, + groupImpactTabsIds?: ImpactTabsIds, +): boolean => { + if (!isEnabledFeature(Feature.EnableGlobalSelectors) || !currentTabId) { + return true; + } + + const isGroupSettingPrevails = item.impactType === undefined; + const isGroupAvailable = isGroupSettingAvailableOnTab( + currentTabId, + groupImpactType, + groupImpactTabsIds, + ); + + if (isGroupSettingPrevails && isGroupAvailable) { + return true; + } + + return ( + isItemScopeAvailableOnTab(currentTabId, item.impactType, item.impactTabsIds) || + (item.impactType === 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;