From 5cc11bd1a123bf49b9f8ba97fc3e95822c9aa3cf Mon Sep 17 00:00:00 2001 From: Alexa V <239999+axelavargas@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:02:13 +0200 Subject: [PATCH] Dashboard Scene: Improve Angular experience (#92847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Dashboard Scene: * Display a warning in the panel header when rendering an angular panel * Display a warning in the dashboard when rendering one or more angular panels * Display a button to migrate in the dashboard banner * Display a button to migrate in the panel editor * Display a button to "Edit options" when it is an Angular panel, to open the panel JSON inspector to be able to edit the options * Add tests --------- Co-authored-by: Torkel Ödegaard --- .../panel-edit/PanelOptionsPane.tsx | 57 +++++++++- .../scene/DashboardScene.test.tsx | 80 ++++++++++++++ .../dashboard-scene/scene/DashboardScene.tsx | 22 ++++ .../scene/DashboardSceneRenderer.test.tsx | 73 +++++++++++++ .../scene/DashboardSceneRenderer.tsx | 5 +- .../scene/angular/AngularDeprecation.tsx | 103 ++++++++++++++++++ .../DashboardAngularDeprecationBanner.tsx | 31 ++++++ .../serialization/angularMigration.ts | 1 - .../transformSaveModelToScene.ts | 4 + .../utils/dashboardSceneGraph.ts | 10 +- .../AngularDeprecationPluginNotice.tsx | 20 +++- public/locales/en-US/grafana.json | 5 + public/locales/pseudo-LOCALE/grafana.json | 5 + 13 files changed, 402 insertions(+), 14 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/angular/AngularDeprecation.tsx create mode 100644 public/app/features/dashboard-scene/scene/angular/DashboardAngularDeprecationBanner.tsx diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx index 3b100b15b83..62033211381 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx @@ -8,8 +8,10 @@ import { isStandardFieldProp, PanelPluginMeta, restoreCustomOverrideRules, + PluginType, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { config, locationService } from '@grafana/runtime'; import { DeepPartial, SceneComponentProps, @@ -19,11 +21,15 @@ import { VizPanel, sceneGraph, } from '@grafana/scenes'; -import { FilterInput, Stack, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { Button, Card, FilterInput, Stack, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; import { getPanelPluginNotFound } from 'app/features/panel/components/PanelPluginError'; import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicker/types'; import { getAllPanelPluginMeta } from 'app/features/panel/state/util'; +import { AngularDeprecationPluginNotice } from 'app/features/plugins/angularDeprecation/AngularDeprecationPluginNotice'; + +import { isUsingAngularPanelPlugin } from '../scene/angular/AngularDeprecation'; import { PanelOptions } from './PanelOptions'; import { PanelVizTypePicker } from './PanelVizTypePicker'; @@ -82,13 +88,20 @@ export class PanelOptionsPane extends SceneObjectBase { this.setState({ listMode }); }; + onOpenPanelJSON = (vizPanel: VizPanel) => { + locationService.partial({ + inspect: vizPanel.state.key, + inspectTab: 'json', + }); + }; + static Component = ({ model }: SceneComponentProps) => { const { isVizPickerOpen, searchQuery, listMode, panelRef } = model.useState(); const panel = panelRef.resolve(); const { pluginId } = panel.useState(); const { data } = sceneGraph.getData(panel).useState(); const styles = useStyles2(getStyles); - + const isAngularPanel = isUsingAngularPanelPlugin(panel); return ( <> {!isVizPickerOpen && ( @@ -102,6 +115,39 @@ export class PanelOptionsPane extends SceneObjectBase { onChange={model.onSetSearchQuery} /> + {isAngularPanel && ( +
+ + + Panel options + + + + Angular panels options can only be edited using the JSON editor. + + + + + + +
+ )}
@@ -146,6 +192,13 @@ function getStyles(theme: GrafanaTheme2) { rotateIcon: css({ rotate: '180deg', }), + angularDeprecationContainer: css({ + label: 'angular-deprecation-container', + padding: theme.spacing(1), + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', + }), }; } diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 741bc4bb5a7..f7f49b5b5f8 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -59,6 +59,17 @@ jest.mock('@grafana/runtime', () => ({ getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }), }; }, + config: { + ...jest.requireActual('@grafana/runtime').config, + angularSupportEnabled: true, + panels: { + 'briangann-datatable-panel': { + id: 'briangann-datatable-panel', + state: 'deprecated', + angular: { detected: true, hideDeprecation: false }, + }, + }, + }, })); jest.mock('app/features/playlist/PlaylistSrv', () => ({ @@ -826,6 +837,75 @@ describe('DashboardScene', () => { expect(restoredGrid.state.grid.state.children.length).toBe(1); }); }); + + describe('When a dashboard contain angular panels', () => { + it('should return true if the dashboard contains angular panels', () => { + // create a scene with angular panels inside + const scene = buildTestScene({ + body: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [ + new DashboardGridItem({ + key: 'griditem-1', + x: 0, + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'briangann-datatable-panel', + $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + }), + }), + new DashboardGridItem({ + key: 'griditem-2', + body: new VizPanel({ + title: 'Panel B', + key: 'panel-2', + pluginId: 'table', + }), + }), + ], + }), + }), + }); + + scene.activate(); + + expect(scene.hasDashboardAngularPlugins()).toBe(true); + }); + it('should return true if the dashboard contains explicitControllerMigration panels', () => { + // create a scene with angular panels inside + const scene = buildTestScene({ + body: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [ + new DashboardGridItem({ + key: 'griditem-1', + x: 0, + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'graph', + $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + }), + }), + new DashboardGridItem({ + key: 'griditem-2', + body: new VizPanel({ + title: 'Panel B', + key: 'panel-2', + pluginId: 'table', + }), + }), + ], + }), + }), + }); + + scene.activate(); + + expect(scene.hasDashboardAngularPlugins()).toBe(true); + }); + }); }); function buildTestScene(overrides?: Partial) { diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index cad6daa473a..0d037f71285 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -67,6 +67,7 @@ import { DashboardSceneUrlSync } from './DashboardSceneUrlSync'; import { LibraryPanelBehavior } from './LibraryPanelBehavior'; import { RowRepeaterBehavior } from './RowRepeaterBehavior'; import { ViewPanelScene } from './ViewPanelScene'; +import { isUsingAngularDatasourcePlugin, isUsingAngularPanelPlugin } from './angular/AngularDeprecation'; import { setupKeyboardShortcuts } from './keyboardShortcuts'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; import { DashboardLayoutManager } from './types'; @@ -662,6 +663,27 @@ export class DashboardScene extends SceneObjectBase { locationService.replace('/'); } + public getDashboardPanels() { + return dashboardSceneGraph.getVizPanels(this); + } + + public hasDashboardAngularPlugins() { + const sceneGridLayout = this.state.body; + if (!(sceneGridLayout instanceof DefaultGridLayoutManager)) { + return false; + } + const gridItems = sceneGridLayout.state.grid.state.children; + const dashboardWasAngular = gridItems.some((gridItem) => { + if (!(gridItem instanceof DashboardGridItem)) { + return false; + } + const isAngularPanel = isUsingAngularPanelPlugin(gridItem.state.body); + const isAngularDs = isUsingAngularDatasourcePlugin(gridItem.state.body); + return isAngularPanel || isAngularDs; + }); + return dashboardWasAngular; + } + public onSetScrollRef = (scrollElement: ScrollRefElement): void => { this._scrollRef = scrollElement; }; diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.test.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.test.tsx index 881883dd441..d4ec59ed2cf 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.test.tsx @@ -1,13 +1,36 @@ import { screen } from '@testing-library/react'; import { render } from 'test/test-utils'; +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { selectors } from '@grafana/e2e-selectors'; +import { config, setPluginImportUtils } from '@grafana/runtime'; import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +setPluginImportUtils({ + importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: (id: string) => undefined, +}); + jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), useChromeHeaderHeight: jest.fn(), + getDataSourceSrv: () => { + return { + getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }), + }; + }, + config: { + ...jest.requireActual('@grafana/runtime').config, + angularSupportEnabled: true, + panels: { + 'briangann-datatable-panel': { + id: 'briangann-datatable-panel', + state: 'deprecated', + angular: { detected: true, hideDeprecation: false }, + }, + }, + }, })); describe('DashboardSceneRenderer', () => { @@ -50,4 +73,54 @@ describe('DashboardSceneRenderer', () => { expect(await screen.findByTestId(selectors.components.EntityNotFound.container)).toBeInTheDocument(); }); + + it('should render angular deprecation notice when dashboard contains angular components', async () => { + const noticeText = /This dashboard depends on Angular/i; + //enable feature flag angularDeprecationUI + config.featureToggles.angularDeprecationUI = true; + const scene = transformSaveModelToScene({ + meta: {}, + dashboard: { + title: 'Angular dashboard', + uid: 'uid', + schemaVersion: 0, + // Disabling build in annotations to avoid mocking Grafana data source + annotations: { + list: [ + { + builtIn: 1, + datasource: { + type: 'grafana', + uid: '-- Grafana --', + }, + enable: false, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + type: 'dashboard', + }, + ], + }, + + panels: [ + { + id: 1, + type: 'briangann-datatable-panel', + gridPos: { x: 0, y: 0, w: 12, h: 6 }, + title: 'Angular component', + options: { + showHeader: true, + }, + fieldConfig: { defaults: {}, overrides: [] }, + datasource: { uid: 'abcdef' }, + targets: [{ refId: 'A' }], + }, + ], + }, + }); + + render(); + + expect(await screen.findByText(noticeText)).toBeInTheDocument(); + }); }); diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx index ae71d1e7d16..32780129248 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx @@ -16,6 +16,7 @@ import { useSelector } from 'app/types'; import { DashboardScene } from './DashboardScene'; import { NavToolbarActions } from './NavToolbarActions'; import { PanelSearchLayout } from './PanelSearchLayout'; +import { DashboardAngularDeprecationBanner } from './angular/DashboardAngularDeprecationBanner'; export function DashboardSceneRenderer({ model }: SceneComponentProps) { const { controls, overlay, editview, editPanel, isEmpty, meta, viewPanelScene, panelSearch, panelsPerRow } = @@ -65,7 +66,9 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps; - let body: React.ReactNode = [withPanels]; + const angularBanner = ; + + let body: React.ReactNode = [angularBanner, withPanels]; if (notFound) { body = [notFound]; diff --git a/public/app/features/dashboard-scene/scene/angular/AngularDeprecation.tsx b/public/app/features/dashboard-scene/scene/angular/AngularDeprecation.tsx new file mode 100644 index 00000000000..4f39f8f4530 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/angular/AngularDeprecation.tsx @@ -0,0 +1,103 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { SceneComponentProps, SceneObjectBase, VizPanel } from '@grafana/scenes'; +import { Icon, PanelChrome, Tooltip, useStyles2 } from '@grafana/ui'; +import { explicitlyControlledMigrationPanels } from 'app/features/dashboard/state/PanelModel'; +import { isAngularDatasourcePluginAndNotHidden } from 'app/features/plugins/angularDeprecation/utils'; + +import { getQueryRunnerFor } from '../../utils/utils'; + +export class AngularDeprecation extends SceneObjectBase { + static Component = AngularDeprecationRenderer; + + constructor() { + super({}); + this.addActivationHandler(this.onActivate); + } + + private onActivate = () => { + const panel = this.parent; + if (!panel || !(panel instanceof VizPanel)) { + throw new Error('PanelNotices can be used only as title items for VizPanel'); + } + }; + + public getPanel() { + const panel = this.parent; + + if (panel && panel instanceof VizPanel) { + return panel; + } + + return null; + } +} + +function AngularDeprecationRenderer({ model }: SceneComponentProps) { + const panel = model.getPanel(); + + const styles = useStyles2(getStyles); + if (!panel) { + return null; + } + + const showAngularNotice = shouldShowAngularNotice(panel); + if (showAngularNotice) { + const pluginTypeNotice = getPluginTypeNotice( + isUsingAngularDatasourcePlugin(panel), + isUsingAngularPanelPlugin(panel) + ); + const message = `This ${pluginTypeNotice} requires Angular (deprecated).`; + const angularNoticeTooltip = ( + + + + + + ); + return angularNoticeTooltip; + } + return null; +} + +export function getPluginTypeNotice(isAngularDatasource: boolean, isAngularPanel: boolean) { + if (isAngularPanel) { + return 'panel'; + } + if (isAngularDatasource) { + return 'data source'; + } + return 'panel or data source'; +} + +export function isUsingAngularPanelPlugin(panel: VizPanel) { + return ( + (config.panels[panel.state.pluginId]?.angular?.detected || + explicitlyControlledMigrationPanels.includes(panel.state.pluginId)) && + !config.panels[panel.state.pluginId]?.angular?.hideDeprecation + ); +} + +export function isUsingAngularDatasourcePlugin(panel: VizPanel) { + const queryRunner = getQueryRunnerFor(panel); + const datasource = queryRunner?.state.datasource; + + return datasource?.uid ? isAngularDatasourcePluginAndNotHidden(datasource?.uid) : false; +} + +export function shouldShowAngularNotice(panel: VizPanel) { + return ( + (config.featureToggles.angularDeprecationUI ?? false) && + (isUsingAngularDatasourcePlugin(panel) || isUsingAngularPanelPlugin(panel)) + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + angularNotice: css({ + color: theme.colors.warning.text, + }), + }; +} diff --git a/public/app/features/dashboard-scene/scene/angular/DashboardAngularDeprecationBanner.tsx b/public/app/features/dashboard-scene/scene/angular/DashboardAngularDeprecationBanner.tsx new file mode 100644 index 00000000000..b5c7fda4007 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/angular/DashboardAngularDeprecationBanner.tsx @@ -0,0 +1,31 @@ +import { config } from '@grafana/runtime'; +import { VizPanel } from '@grafana/scenes'; +import { explicitlyControlledMigrationPanels } from 'app/features/dashboard/state/PanelModel'; +import { AngularDeprecationNotice } from 'app/features/plugins/angularDeprecation/AngularDeprecationNotice'; + +import { DashboardScene } from '../DashboardScene'; + +interface Props { + dashboard: DashboardScene; +} + +export const DashboardAngularDeprecationBanner = ({ dashboard }: Props) => { + const panels = dashboard.getDashboardPanels(); + const shouldShowAutoMigrateLink = panels.some((panel) => { + if (panel instanceof VizPanel) { + return explicitlyControlledMigrationPanels.includes(panel.state.pluginId); + } + return false; + }); + + const isContainingAngularPanels = + config.featureToggles.angularDeprecationUI && dashboard.hasDashboardAngularPlugins(); + + return isContainingAngularPanels && dashboard.state.uid ? ( + + ) : null; +}; diff --git a/public/app/features/dashboard-scene/serialization/angularMigration.ts b/public/app/features/dashboard-scene/serialization/angularMigration.ts index 82115f1365f..d92f13cb1ed 100644 --- a/public/app/features/dashboard-scene/serialization/angularMigration.ts +++ b/public/app/features/dashboard-scene/serialization/angularMigration.ts @@ -18,7 +18,6 @@ export function getAngularPanelMigrationHandler(oldModel: PanelModel) { const wasAngular = autoMigrateAngular[oldModel.autoMigrateFrom] != null; const oldOptions = oldModel.getOptionsToRemember(); const prevPluginId = oldModel.autoMigrateFrom; - if (plugin.onPanelTypeChanged) { const prevOptions = wasAngular ? { angular: oldOptions } : oldOptions.options; Object.assign(panel.options, plugin.onPanelTypeChanged(panel, prevPluginId, prevOptions, panel.fieldConfig)); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index b5237a83683..e23afc410d6 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -37,6 +37,7 @@ import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavio import { PanelNotices } from '../scene/PanelNotices'; import { PanelTimeRange } from '../scene/PanelTimeRange'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; +import { AngularDeprecation } from '../scene/angular/AngularDeprecation'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { RowActions } from '../scene/row-actions/RowActions'; import { setDashboardPanelContext } from '../scene/setDashboardPanelContext'; @@ -278,6 +279,9 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem { const titleItems: SceneObject[] = []; + if (config.featureToggles.angularDeprecationUI) { + titleItems.push(new AngularDeprecation()); + } titleItems.push( new VizPanelLinks({ rawLinks: panel.links, diff --git a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts index 61ee5d75291..ff4eb38cc49 100644 --- a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts +++ b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts @@ -13,12 +13,10 @@ function getRefreshPicker(scene: DashboardScene) { } function getPanelLinks(panel: VizPanel) { - if ( - panel.state.titleItems && - Array.isArray(panel.state.titleItems) && - panel.state.titleItems[0] instanceof VizPanelLinks - ) { - return panel.state.titleItems[0]; + if (panel.state.titleItems && Array.isArray(panel.state.titleItems)) { + // search panel.state.titleItems for VizPanelLinks + const panelLink = panel.state.titleItems.find((item) => item instanceof VizPanelLinks); + return panelLink ?? null; } return null; diff --git a/public/app/features/plugins/angularDeprecation/AngularDeprecationPluginNotice.tsx b/public/app/features/plugins/angularDeprecation/AngularDeprecationPluginNotice.tsx index 8e42e1dcef1..61d8e1d9abd 100644 --- a/public/app/features/plugins/angularDeprecation/AngularDeprecationPluginNotice.tsx +++ b/public/app/features/plugins/angularDeprecation/AngularDeprecationPluginNotice.tsx @@ -7,14 +7,12 @@ import { Alert } from '@grafana/ui'; type Props = { className?: string; - pluginId?: string; pluginType?: PluginType; - angularSupportEnabled?: boolean; showPluginDetailsLink?: boolean; - interactionElementId?: string; + children?: React.ReactNode; }; function deprecationMessage(pluginType?: string, angularSupportEnabled?: boolean): string { @@ -45,7 +43,15 @@ function deprecationMessage(pluginType?: string, angularSupportEnabled?: boolean // An Alert showing information about Angular deprecation notice. // If the plugin does not use Angular (!plugin.angularDetected), it returns null. export function AngularDeprecationPluginNotice(props: Props): React.ReactElement | null { - const { className, angularSupportEnabled, pluginId, pluginType, showPluginDetailsLink, interactionElementId } = props; + const { + className, + angularSupportEnabled, + pluginId, + pluginType, + showPluginDetailsLink, + interactionElementId, + children, + } = props; const [dismissed, setDismissed] = useState(false); const interactionAttributes: Record = {}; @@ -88,6 +94,12 @@ export function AngularDeprecationPluginNotice(props: Props): React.ReactElement ) : null} + {children && ( + <> +
+ {children} + + )} ); } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 0c9b09b6853..3d5fff7e31a 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -751,6 +751,11 @@ } }, "dashboards": { + "panel-edit": { + "angular-deprecation-button-open-panel-json": "Open JSON editor", + "angular-deprecation-description": "Angular panels options can only be edited using the JSON editor.", + "angular-deprecation-heading": "Panel options" + }, "settings": { "variables": { "dependencies": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 8e39227ae2f..60eb86a1b52 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -751,6 +751,11 @@ } }, "dashboards": { + "panel-edit": { + "angular-deprecation-button-open-panel-json": "Øpęʼn ĴŜØŃ ęđįŧőř", + "angular-deprecation-description": "Åʼnģūľäř päʼnęľş őpŧįőʼnş čäʼn őʼnľy þę ęđįŧęđ ūşįʼnģ ŧĥę ĴŜØŃ ęđįŧőř.", + "angular-deprecation-heading": "Päʼnęľ őpŧįőʼnş" + }, "settings": { "variables": { "dependencies": {