mirror of
https://github.com/apache/superset.git
synced 2024-09-16 02:29:39 -04:00
feat: DBC-UI Globally available across the app 🌎 (#18722)
* more data nav menu * fix lint and fix nav css * update test and remove icons * Update superset-frontend/src/views/components/Menu.test.tsx Co-authored-by: Hugh A. Miles II <hughmil3s@gmail.com> * Apply suggestions from code review * use backend app.link to show new nav changes * fix lint * update test * usetheme and remove chaining * add more suggestions * fix lint * working global db connection * add allowed extensions to bootstrap and hard code links * remove backend links * fix test * apply stashed gsheets * fix check for google sheets * setup gsheets * add extensions to frontend conf * fix test and add be changes * remove package json changes * test is python test passes * update python test and reremove app links * fix tslint issues * fix other linting tools * fix pylint * fix test * fix * refactor * fix lint * working fixed test * clean up test * address concerns * address concerns * change to tenarary Co-authored-by: Phillip Kelley-Dotson <pkelleydotson@yahoo.com>
This commit is contained in:
parent
822dd6de5d
commit
209e3f4554
@ -21,7 +21,64 @@ import * as reactRedux from 'react-redux';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Menu } from './Menu';
|
||||
import { dropdownItems } from './MenuRight';
|
||||
|
||||
const dropdownItems = [
|
||||
{
|
||||
label: 'Data',
|
||||
icon: 'fa-database',
|
||||
childs: [
|
||||
{
|
||||
label: 'Connect Database',
|
||||
name: 'dbconnect',
|
||||
perm: true,
|
||||
},
|
||||
{
|
||||
label: 'Connect Google Sheet',
|
||||
name: 'gsheets',
|
||||
perm: true,
|
||||
},
|
||||
{
|
||||
label: 'Upload a CSV',
|
||||
name: 'Upload a CSV',
|
||||
url: '/csvtodatabaseview/form',
|
||||
perm: true,
|
||||
},
|
||||
{
|
||||
label: 'Upload a Columnar File',
|
||||
name: 'Upload a Columnar file',
|
||||
url: '/columnartodatabaseview/form',
|
||||
perm: true,
|
||||
},
|
||||
{
|
||||
label: 'Upload Excel',
|
||||
name: 'Upload Excel',
|
||||
url: '/exceltodatabaseview/form',
|
||||
perm: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SQL query',
|
||||
url: '/superset/sqllab?new=true',
|
||||
icon: 'fa-fw fa-search',
|
||||
perm: 'can_sqllab',
|
||||
view: 'Superset',
|
||||
},
|
||||
{
|
||||
label: 'Chart',
|
||||
url: '/chart/add',
|
||||
icon: 'fa-fw fa-bar-chart',
|
||||
perm: 'can_write',
|
||||
view: 'Chart',
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
url: '/dashboard/new',
|
||||
icon: 'fa-fw fa-dashboard',
|
||||
perm: 'can_write',
|
||||
view: 'Dashboard',
|
||||
},
|
||||
];
|
||||
|
||||
const user = {
|
||||
createdOn: '2021-04-27T18:12:38.952304',
|
||||
@ -185,13 +242,13 @@ beforeEach(() => {
|
||||
|
||||
test('should render', () => {
|
||||
useSelectorMock.mockReturnValue({ roles: user.roles });
|
||||
const { container } = render(<Menu {...mockedProps} />);
|
||||
const { container } = render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the navigation', () => {
|
||||
useSelectorMock.mockReturnValue({ roles: user.roles });
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -202,7 +259,7 @@ test('should render the brand', () => {
|
||||
brand: { alt, icon },
|
||||
},
|
||||
} = mockedProps;
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
const image = screen.getByAltText(alt);
|
||||
expect(image).toHaveAttribute('src', icon);
|
||||
});
|
||||
@ -212,7 +269,7 @@ test('should render all the top navbar menu items', () => {
|
||||
const {
|
||||
data: { menu },
|
||||
} = mockedProps;
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
menu.forEach(item => {
|
||||
expect(screen.getByText(item.label)).toBeInTheDocument();
|
||||
});
|
||||
@ -223,7 +280,7 @@ test('should render the top navbar child menu items', async () => {
|
||||
const {
|
||||
data: { menu },
|
||||
} = mockedProps;
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
const sources = screen.getByText('Sources');
|
||||
userEvent.hover(sources);
|
||||
const datasets = await screen.findByText('Datasets');
|
||||
@ -237,7 +294,7 @@ test('should render the top navbar child menu items', async () => {
|
||||
|
||||
test('should render the dropdown items', async () => {
|
||||
useSelectorMock.mockReturnValue({ roles: user.roles });
|
||||
render(<Menu {...notanonProps} />);
|
||||
render(<Menu {...notanonProps} />, { useRedux: true });
|
||||
const dropdown = screen.getByTestId('new-dropdown-icon');
|
||||
userEvent.hover(dropdown);
|
||||
// todo (philip): test data submenu
|
||||
@ -263,14 +320,14 @@ test('should render the dropdown items', async () => {
|
||||
|
||||
test('should render the Settings', async () => {
|
||||
useSelectorMock.mockReturnValue({ roles: user.roles });
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
const settings = await screen.findByText('Settings');
|
||||
expect(settings).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the Settings menu item', async () => {
|
||||
useSelectorMock.mockReturnValue({ roles: user.roles });
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
userEvent.hover(screen.getByText('Settings'));
|
||||
const label = await screen.findByText('Security');
|
||||
expect(label).toBeInTheDocument();
|
||||
@ -281,7 +338,7 @@ test('should render the Settings dropdown child menu items', async () => {
|
||||
const {
|
||||
data: { settings },
|
||||
} = mockedProps;
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
userEvent.hover(screen.getByText('Settings'));
|
||||
const listUsers = await screen.findByText('List Users');
|
||||
expect(listUsers).toHaveAttribute('href', settings[0].childs[0].url);
|
||||
@ -289,13 +346,13 @@ test('should render the Settings dropdown child menu items', async () => {
|
||||
|
||||
test('should render the plus menu (+) when user is not anonymous', () => {
|
||||
useSelectorMock.mockReturnValue({ roles: user.roles });
|
||||
render(<Menu {...notanonProps} />);
|
||||
render(<Menu {...notanonProps} />, { useRedux: true });
|
||||
expect(screen.getByTestId('new-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should NOT render the plus menu (+) when user is anonymous', () => {
|
||||
useSelectorMock.mockReturnValue({ roles: user.roles });
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
expect(screen.queryByTestId('new-dropdown')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -307,7 +364,7 @@ test('should render the user actions when user is not anonymous', async () => {
|
||||
},
|
||||
} = mockedProps;
|
||||
|
||||
render(<Menu {...notanonProps} />);
|
||||
render(<Menu {...notanonProps} />, { useRedux: true });
|
||||
userEvent.hover(screen.getByText('Settings'));
|
||||
const user = await screen.findByText('User');
|
||||
expect(user).toBeInTheDocument();
|
||||
@ -321,7 +378,7 @@ test('should render the user actions when user is not anonymous', async () => {
|
||||
|
||||
test('should NOT render the user actions when user is anonymous', () => {
|
||||
useSelectorMock.mockReturnValue({ roles: user.roles });
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
expect(screen.queryByText('User')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -333,7 +390,7 @@ test('should render the Profile link when available', async () => {
|
||||
},
|
||||
} = mockedProps;
|
||||
|
||||
render(<Menu {...notanonProps} />);
|
||||
render(<Menu {...notanonProps} />, { useRedux: true });
|
||||
|
||||
userEvent.hover(screen.getByText('Settings'));
|
||||
const profile = await screen.findByText('Profile');
|
||||
@ -348,7 +405,7 @@ test('should render the About section and version_string, sha or build_number wh
|
||||
},
|
||||
} = mockedProps;
|
||||
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
userEvent.hover(screen.getByText('Settings'));
|
||||
const about = await screen.findByText('About');
|
||||
const version = await screen.findByText(`Version: ${version_string}`);
|
||||
@ -367,7 +424,7 @@ test('should render the Documentation link when available', async () => {
|
||||
navbar_right: { documentation_url },
|
||||
},
|
||||
} = mockedProps;
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
userEvent.hover(screen.getByText('Settings'));
|
||||
const doc = await screen.findByTitle('Documentation');
|
||||
expect(doc).toHaveAttribute('href', documentation_url);
|
||||
@ -381,7 +438,7 @@ test('should render the Bug Report link when available', async () => {
|
||||
},
|
||||
} = mockedProps;
|
||||
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
const bugReport = await screen.findByTitle('Report a bug');
|
||||
expect(bugReport).toHaveAttribute('href', bug_report_url);
|
||||
});
|
||||
@ -394,19 +451,19 @@ test('should render the Login link when user is anonymous', () => {
|
||||
},
|
||||
} = mockedProps;
|
||||
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
const login = screen.getByText('Login');
|
||||
expect(login).toHaveAttribute('href', user_login_url);
|
||||
});
|
||||
|
||||
test('should render the Language Picker', () => {
|
||||
useSelectorMock.mockReturnValue({ roles: user.roles });
|
||||
render(<Menu {...mockedProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
expect(screen.getByLabelText('Languages')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should hide create button without proper roles', () => {
|
||||
useSelectorMock.mockReturnValue({ roles: [] });
|
||||
render(<Menu {...notanonProps} />);
|
||||
render(<Menu {...mockedProps} />, { useRedux: true });
|
||||
expect(screen.queryByTestId('new-dropdown')).not.toBeInTheDocument();
|
||||
});
|
||||
|
@ -79,7 +79,7 @@ interface MenuObjectChildProps {
|
||||
index?: number;
|
||||
url?: string;
|
||||
isFrontendRoute?: boolean;
|
||||
perm?: string;
|
||||
perm?: string | boolean;
|
||||
view?: string;
|
||||
}
|
||||
|
||||
|
@ -16,67 +16,22 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { MainNav as Menu } from 'src/common/components';
|
||||
import { t, styled, css, SupersetTheme } from '@superset-ui/core';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Icons from 'src/components/Icons';
|
||||
import findPermission from 'src/dashboard/util/findPermission';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
UserWithPermissionsAndRoles,
|
||||
CommonBootstrapData,
|
||||
} from 'src/types/bootstrapTypes';
|
||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
import LanguagePicker from './LanguagePicker';
|
||||
import { NavBarProps, MenuObjectProps } from './Menu';
|
||||
|
||||
export const dropdownItems: MenuObjectProps[] = [
|
||||
{
|
||||
label: t('Data'),
|
||||
icon: 'fa-database',
|
||||
childs: [
|
||||
{
|
||||
icon: 'fa-upload',
|
||||
label: t('Upload a CSV'),
|
||||
name: 'Upload a CSV',
|
||||
url: '/csvtodatabaseview/form',
|
||||
},
|
||||
{
|
||||
icon: 'fa-upload',
|
||||
label: t('Upload a Columnar File'),
|
||||
name: 'Upload a Columnar file',
|
||||
url: '/columnartodatabaseview/form',
|
||||
},
|
||||
{
|
||||
icon: 'fa-upload',
|
||||
label: t('Upload Excel'),
|
||||
name: 'Upload Excel',
|
||||
url: '/exceltodatabaseview/form',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('SQL query'),
|
||||
url: '/superset/sqllab?new=true',
|
||||
icon: 'fa-fw fa-search',
|
||||
perm: 'can_sqllab',
|
||||
view: 'Superset',
|
||||
},
|
||||
{
|
||||
label: t('Chart'),
|
||||
url: '/chart/add',
|
||||
icon: 'fa-fw fa-bar-chart',
|
||||
perm: 'can_write',
|
||||
view: 'Chart',
|
||||
},
|
||||
{
|
||||
label: t('Dashboard'),
|
||||
url: '/dashboard/new',
|
||||
icon: 'fa-fw fa-dashboard',
|
||||
perm: 'can_write',
|
||||
view: 'Dashboard',
|
||||
},
|
||||
];
|
||||
import DatabaseModal from '../CRUD/data/database/DatabaseModal';
|
||||
import {
|
||||
ExtentionConfigs,
|
||||
GlobalMenuDataOptions,
|
||||
RightMenuProps,
|
||||
} from './types';
|
||||
import { MenuObjectProps } from './Menu';
|
||||
|
||||
const versionInfoStyles = (theme: SupersetTheme) => css`
|
||||
padding: ${theme.gridUnit * 1.5}px ${theme.gridUnit * 4}px
|
||||
@ -107,13 +62,6 @@ const StyledAnchor = styled.a`
|
||||
|
||||
const { SubMenu } = Menu;
|
||||
|
||||
interface RightMenuProps {
|
||||
align: 'flex-start' | 'flex-end';
|
||||
settings: MenuObjectProps[];
|
||||
navbarRight: NavBarProps;
|
||||
isFrontendRoute: (path?: string) => boolean;
|
||||
}
|
||||
|
||||
const RightMenu = ({
|
||||
align,
|
||||
settings,
|
||||
@ -123,30 +71,106 @@ const RightMenu = ({
|
||||
const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
|
||||
state => state.user,
|
||||
);
|
||||
// @ts-ignore
|
||||
const { CSV_EXTENSIONS, COLUMNAR_EXTENSIONS, EXCEL_EXTENSIONS } = useSelector<
|
||||
any,
|
||||
CommonBootstrapData
|
||||
>(state => state.common.conf);
|
||||
// if user has any of these roles the dropdown will appear
|
||||
const configMap = {
|
||||
'Upload a CSV': CSV_EXTENSIONS,
|
||||
'Upload a Columnar file': COLUMNAR_EXTENSIONS,
|
||||
'Upload Excel': EXCEL_EXTENSIONS,
|
||||
};
|
||||
const {
|
||||
CSV_EXTENSIONS,
|
||||
COLUMNAR_EXTENSIONS,
|
||||
EXCEL_EXTENSIONS,
|
||||
HAS_GSHEETS_INSTALLED,
|
||||
} = useSelector<any, ExtentionConfigs>(state => state.common.conf);
|
||||
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
const [engine, setEngine] = useState<string>('');
|
||||
const canSql = findPermission('can_sqllab', 'Superset', roles);
|
||||
const canDashboard = findPermission('can_write', 'Dashboard', roles);
|
||||
const canChart = findPermission('can_write', 'Chart', roles);
|
||||
const showActionDropdown = canSql || canChart || canDashboard;
|
||||
const dropdownItems: MenuObjectProps[] = [
|
||||
{
|
||||
label: t('Data'),
|
||||
icon: 'fa-database',
|
||||
childs: [
|
||||
{
|
||||
label: t('Connect Database'),
|
||||
name: GlobalMenuDataOptions.DB_CONNECTION,
|
||||
perm: true,
|
||||
},
|
||||
{
|
||||
label: t('Connect Google Sheet'),
|
||||
name: GlobalMenuDataOptions.GOOGLE_SHEETS,
|
||||
perm: HAS_GSHEETS_INSTALLED,
|
||||
},
|
||||
{
|
||||
label: t('Upload a CSV'),
|
||||
name: 'Upload a CSV',
|
||||
url: '/csvtodatabaseview/form',
|
||||
perm: CSV_EXTENSIONS,
|
||||
},
|
||||
{
|
||||
label: t('Upload a Columnar File'),
|
||||
name: 'Upload a Columnar file',
|
||||
url: '/columnartodatabaseview/form',
|
||||
perm: COLUMNAR_EXTENSIONS,
|
||||
},
|
||||
{
|
||||
label: t('Upload Excel'),
|
||||
name: 'Upload Excel',
|
||||
url: '/exceltodatabaseview/form',
|
||||
perm: EXCEL_EXTENSIONS,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('SQL query'),
|
||||
url: '/superset/sqllab?new=true',
|
||||
icon: 'fa-fw fa-search',
|
||||
perm: 'can_sqllab',
|
||||
view: 'Superset',
|
||||
},
|
||||
{
|
||||
label: t('Chart'),
|
||||
url: '/chart/add',
|
||||
icon: 'fa-fw fa-bar-chart',
|
||||
perm: 'can_write',
|
||||
view: 'Chart',
|
||||
},
|
||||
{
|
||||
label: t('Dashboard'),
|
||||
url: '/dashboard/new',
|
||||
icon: 'fa-fw fa-dashboard',
|
||||
perm: 'can_write',
|
||||
view: 'Dashboard',
|
||||
},
|
||||
];
|
||||
|
||||
const menuIconAndLabel = (menu: MenuObjectProps) => (
|
||||
<>
|
||||
<i data-test={`menu-item-${menu.label}`} className={`fa ${menu.icon}`} />
|
||||
{menu.label}
|
||||
</>
|
||||
);
|
||||
|
||||
const handleMenuSelection = (itemChose: any) => {
|
||||
if (itemChose.key === GlobalMenuDataOptions.DB_CONNECTION) {
|
||||
setShowModal(true);
|
||||
} else if (itemChose.key === GlobalMenuDataOptions.GOOGLE_SHEETS) {
|
||||
setShowModal(true);
|
||||
setEngine('Google Sheets');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnHideModal = () => {
|
||||
setEngine('');
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledDiv align={align}>
|
||||
<Menu mode="horizontal">
|
||||
<DatabaseModal
|
||||
onHide={handleOnHideModal}
|
||||
show={showModal}
|
||||
dbEngine={engine}
|
||||
/>
|
||||
<Menu selectable={false} mode="horizontal" onClick={handleMenuSelection}>
|
||||
{!navbarRight.user_is_anonymous && showActionDropdown && (
|
||||
<SubMenu
|
||||
data-test="new-dropdown"
|
||||
@ -163,13 +187,18 @@ const RightMenu = ({
|
||||
className="data-menu"
|
||||
title={menuIconAndLabel(menu)}
|
||||
>
|
||||
{menu.childs.map(item =>
|
||||
typeof item !== 'string' &&
|
||||
item.name &&
|
||||
configMap[item.name] === true ? (
|
||||
{menu.childs.map((item, idx) =>
|
||||
typeof item !== 'string' && item.name && item.perm ? (
|
||||
<>
|
||||
{idx === 2 && <Menu.Divider />}
|
||||
<Menu.Item key={item.name}>
|
||||
{item.url ? (
|
||||
<a href={item.url}> {item.label} </a>
|
||||
) : (
|
||||
item.label
|
||||
)}
|
||||
</Menu.Item>
|
||||
</>
|
||||
) : null,
|
||||
)}
|
||||
</SubMenu>
|
||||
|
38
superset-frontend/src/views/components/types.ts
Normal file
38
superset-frontend/src/views/components/types.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { NavBarProps, MenuObjectProps } from './Menu';
|
||||
|
||||
export interface ExtentionConfigs {
|
||||
CSV_EXTENSIONS: boolean;
|
||||
COLUMNAR_EXTENSIONS: boolean;
|
||||
EXCEL_EXTENSIONS: boolean;
|
||||
HAS_GSHEETS_INSTALLED: boolean;
|
||||
}
|
||||
export interface RightMenuProps {
|
||||
align: 'flex-start' | 'flex-end';
|
||||
settings: MenuObjectProps[];
|
||||
navbarRight: NavBarProps;
|
||||
isFrontendRoute: (path?: string) => boolean;
|
||||
}
|
||||
|
||||
export enum GlobalMenuDataOptions {
|
||||
GOOGLE_SHEETS = 'gsheets',
|
||||
DB_CONNECTION = 'dbconnection',
|
||||
}
|
@ -62,6 +62,8 @@ from superset import (
|
||||
from superset.commands.exceptions import CommandException, CommandInvalidError
|
||||
from superset.connectors.sqla import models
|
||||
from superset.datasets.commands.exceptions import get_dataset_exist_error_msg
|
||||
from superset.db_engine_specs import get_available_engine_specs
|
||||
from superset.db_engine_specs.gsheets import GSheetsEngineSpec
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import (
|
||||
SupersetErrorException,
|
||||
@ -366,6 +368,10 @@ def common_bootstrap_payload() -> Dict[str, Any]:
|
||||
ReportRecipientType.EMAIL,
|
||||
]
|
||||
|
||||
# verify client has google sheets installed
|
||||
available_specs = get_available_engine_specs()
|
||||
frontend_config["HAS_GSHEETS_INSTALLED"] = bool(available_specs[GSheetsEngineSpec])
|
||||
|
||||
bootstrap_data = {
|
||||
"flash_messages": messages,
|
||||
"conf": frontend_config,
|
||||
|
Loading…
Reference in New Issue
Block a user