feat: Moves Profile to Single Page App (SPA) (#25001)

This commit is contained in:
Michael S. Molina 2023-08-17 11:01:24 -03:00 committed by GitHub
parent 269c99293f
commit 712e1f760c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 245 additions and 251 deletions

View File

@ -474,7 +474,7 @@ const RightMenu = ({
<Menu.ItemGroup key="user-section" title={t('User')}>
{navbarRight.user_profile_url && (
<Menu.Item key="profile">
<a href={navbarRight.user_profile_url}>{t('Profile')}</a>
<Link to={navbarRight.user_profile_url}>{t('Profile')}</Link>
</Menu.Item>
)}
{navbarRight.user_info_url && (

View File

@ -20,8 +20,8 @@ import React from 'react';
import { shallow } from 'enzyme';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import CreatedContent from 'src/profile/components/CreatedContent';
import TableLoader from 'src/components/TableLoader';
import CreatedContent from './CreatedContent';
import { user } from './fixtures';

View File

@ -20,8 +20,8 @@ import React from 'react';
import { shallow } from 'enzyme';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import Favorites from 'src/profile/components/Favorites';
import TableLoader from 'src/components/TableLoader';
import Favorites from './Favorites';
import { user } from './fixtures';

View File

@ -22,7 +22,7 @@ import moment from 'moment';
import { t } from '@superset-ui/core';
import { DashboardResponse, BootstrapUser } from 'src/types/bootstrapTypes';
import TableLoader from '../../components/TableLoader';
import { Chart } from '../types';
import { Chart } from './types';
interface FavoritesProps {
user: BootstrapUser;

View File

@ -18,8 +18,8 @@
*/
import React from 'react';
import { shallow } from 'enzyme';
import RecentActivity from 'src/profile/components/RecentActivity';
import TableLoader from 'src/components/TableLoader';
import RecentActivity from './RecentActivity';
import { user } from './fixtures';

View File

@ -20,10 +20,9 @@ import React from 'react';
import moment from 'moment';
import { t } from '@superset-ui/core';
import rison from 'rison';
import TableLoader from '../../components/TableLoader';
import { ActivityResult } from '../types';
import { BootstrapUser } from '../../types/bootstrapTypes';
import TableLoader from 'src/components/TableLoader';
import { BootstrapUser } from 'src/types/bootstrapTypes';
import { ActivityResult } from './types';
interface RecentActivityProps {
user: BootstrapUser;

View File

@ -18,9 +18,9 @@
*/
import React from 'react';
import { styledMount as mount } from 'spec/helpers/theming';
import Security from 'src/profile/components/Security';
import Label from 'src/components/Label';
import { user, userNoPerms } from './fixtures';
import Security from './Security';
describe('Security', () => {
const mockedProps = {

View File

@ -19,7 +19,7 @@
import React from 'react';
import Gravatar from 'react-gravatar';
import { mount } from 'enzyme';
import UserInfo from 'src/profile/components/UserInfo';
import UserInfo from './UserInfo';
import { user } from './fixtures';

View File

@ -19,26 +19,25 @@
import React from 'react';
import { Row, Col } from 'src/components';
import { shallow } from 'enzyme';
import App from 'src/profile/components/App';
import Profile from 'src/pages/Profile';
import { user } from 'src/features/profile/fixtures';
import { user } from './fixtures';
describe('App', () => {
describe('Profile', () => {
const mockedProps = {
user,
};
it('is valid', () => {
expect(React.isValidElement(<App {...mockedProps} />)).toBe(true);
expect(React.isValidElement(<Profile {...mockedProps} />)).toBe(true);
});
it('renders 2 Col', () => {
const wrapper = shallow(<App {...mockedProps} />);
const wrapper = shallow(<Profile {...mockedProps} />);
expect(wrapper.find(Row)).toExist();
expect(wrapper.find(Col)).toHaveLength(2);
});
it('renders 4 Tabs', () => {
const wrapper = shallow(<App {...mockedProps} />);
const wrapper = shallow(<Profile {...mockedProps} />);
expect(wrapper.find('[tab]')).toHaveLength(4);
});
});

View File

@ -21,11 +21,11 @@ import { t, styled } from '@superset-ui/core';
import { Row, Col } from 'src/components';
import Tabs from 'src/components/Tabs';
import { BootstrapUser } from 'src/types/bootstrapTypes';
import Favorites from './Favorites';
import UserInfo from './UserInfo';
import Security from './Security';
import RecentActivity from './RecentActivity';
import CreatedContent from './CreatedContent';
import Favorites from 'src/features/profile/Favorites';
import UserInfo from 'src/features/profile/UserInfo';
import Security from 'src/features/profile/Security';
import RecentActivity from 'src/features/profile/RecentActivity';
import CreatedContent from 'src/features/profile/CreatedContent';
interface AppProps {
user: BootstrapUser;

View File

@ -1,58 +0,0 @@
/**
* 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 React from 'react';
import { hot } from 'react-hot-loader/root';
import thunk from 'redux-thunk';
import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
import { Provider } from 'react-redux';
import { ThemeProvider } from '@superset-ui/core';
import { GlobalStyles } from 'src/GlobalStyles';
import App from 'src/profile/components/App';
import messageToastReducer from 'src/components/MessageToasts/reducers';
import { initEnhancer } from 'src/reduxUtils';
import setupApp from 'src/setup/setupApp';
import setupExtensions from 'src/setup/setupExtensions';
import { theme } from 'src/preamble';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
import getBootstrapData from 'src/utils/getBootstrapData';
setupApp();
setupExtensions();
const bootstrapData = getBootstrapData();
const store = createStore(
combineReducers({
messageToasts: messageToastReducer,
}),
{},
compose(applyMiddleware(thunk), initEnhancer(false)),
);
const Application = () => (
<Provider store={store}>
<ThemeProvider theme={theme}>
<GlobalStyles />
<App user={bootstrapData.user} />
<ToastContainer />
</ThemeProvider>
</Provider>
);
export default hot(Application);

View File

@ -1,23 +0,0 @@
/**
* 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 React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('app'));

View File

@ -119,6 +119,10 @@ const RowLevelSecurityList = lazy(
),
);
const Profile = lazy(
() => import(/* webpackChunkName: "Profile" */ 'src/pages/Profile'),
);
type Routes = {
path: string;
Component: React.ComponentType;
@ -217,6 +221,10 @@ export const routes: Routes = [
path: '/rowlevelsecurity/list',
Component: RowLevelSecurityList,
},
{
path: '/profile',
Component: Profile,
},
];
if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) {

View File

@ -212,7 +212,6 @@ const config = {
spa: addPreamble('/src/views/index.tsx'),
embedded: addPreamble('/src/embedded/index.tsx'),
sqllab: addPreamble('/src/SqlLab/index.tsx'),
profile: addPreamble('/src/profile/index.tsx'),
showSavedQuery: [path.join(APP_DIR, '/src/showSavedQuery/index.jsx')],
},
output,

View File

@ -182,6 +182,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.views.key_value import KV
from superset.views.log.api import LogRestApi
from superset.views.log.views import LogModelView
from superset.views.profile import ProfileView
from superset.views.redirects import R
from superset.views.sql_lab.views import (
SavedQueryView,
@ -309,6 +310,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_view_no_menu(ExplorePermalinkView)
appbuilder.add_view_no_menu(KV)
appbuilder.add_view_no_menu(R)
appbuilder.add_view_no_menu(ProfileView)
appbuilder.add_view_no_menu(SavedQueryView)
appbuilder.add_view_no_menu(SavedQueryViewApi)
appbuilder.add_view_no_menu(SliceAsync)

View File

@ -406,7 +406,7 @@ def menu_data(user: User) -> dict[str, Any]:
"user_login_url": appbuilder.get_url_for_login,
"user_profile_url": None
if user.is_anonymous or is_feature_enabled("MENU_HIDE_USER_INFO")
else "/superset/profile/",
else "/profile/",
"locale": session.get("locale", "en"),
},
}

View File

@ -80,7 +80,6 @@ from superset.utils.core import (
base_json_conv,
DatasourceType,
get_user_id,
get_username,
ReservedUrlParameters,
)
from superset.views.base import (
@ -985,24 +984,9 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access
@event_logger.log_this
@expose("/profile/")
@deprecated(new_target="/profile")
def profile(self) -> FlaskResponse:
"""User profile page"""
user = g.user if hasattr(g, "user") and g.user else None
if not user or security_manager.is_guest_user(user) or user.is_anonymous:
abort(404)
payload = {
"user": bootstrap_user_data(user, include_perms=True),
"common": common_bootstrap_payload(user),
}
return self.render_template(
"superset/basic.html",
title=_("%(user)s's profile", user=get_username()),
entry="profile",
bootstrap_data=json.dumps(
payload, default=utils.pessimistic_json_iso_dttm_ser
),
)
return redirect("/profile/")
@has_access
@event_logger.log_this

40
superset/views/profile.py Normal file
View File

@ -0,0 +1,40 @@
# 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.
from flask import abort, g
from flask_appbuilder import permission_name
from flask_appbuilder.api import expose
from flask_appbuilder.security.decorators import has_access
from superset import event_logger, security_manager
from superset.superset_typing import FlaskResponse
from .base import BaseSupersetView
class ProfileView(BaseSupersetView):
route_base = "/profile"
class_permission_name = "Profile"
@expose("/")
@has_access
@permission_name("read")
@event_logger.log_this
def root(self) -> FlaskResponse:
user = g.user if hasattr(g, "user") and g.user else None
if not user or security_manager.is_guest_user(user) or user.is_anonymous:
abort(404)
return super().render_app_template()

View File

@ -26,12 +26,10 @@ from unittest import mock
from urllib.parse import quote
import pandas as pd
import prison
import pytest
import pytz
import sqlalchemy as sqla
from flask_babel import lazy_gettext as _
from sqlalchemy import Table
from sqlalchemy.exc import SQLAlchemyError
import superset.utils.database
@ -56,7 +54,7 @@ from superset.utils import core as utils
from superset.utils.core import backend
from superset.utils.database import get_example_database
from superset.views.database.views import DatabaseView
from tests.integration_tests.conftest import CTAS_SCHEMA_NAME, with_feature_flags
from tests.integration_tests.conftest import with_feature_flags
from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices,
load_birth_names_data,
@ -65,12 +63,10 @@ from tests.integration_tests.fixtures.energy_dashboard import (
load_energy_table_data,
load_energy_table_with_slice,
)
from tests.integration_tests.fixtures.public_role import public_role_like_gamma
from tests.integration_tests.fixtures.world_bank_dashboard import (
load_world_bank_dashboard_with_slices,
load_world_bank_data,
)
from tests.integration_tests.insert_chart_mixin import InsertChartMixin
from tests.integration_tests.test_app import app
from .base_tests import SupersetTestCase
@ -86,7 +82,7 @@ def cleanup():
yield
class TestCore(SupersetTestCase, InsertChartMixin):
class TestCore(SupersetTestCase):
def setUp(self):
self.table_ids = {
tbl.table_name: tbl.id for tbl in (db.session.query(SqlaTable).all())
@ -107,25 +103,6 @@ class TestCore(SupersetTestCase, InsertChartMixin):
)
return dashboard
def insert_chart_created_by(self, username: str) -> Slice:
user = self.get_user(username)
dataset = db.session.query(SqlaTable).first()
chart = self.insert_chart(
f"create_title_test",
[user.id],
dataset.id,
created_by=user,
)
return chart
@pytest.fixture()
def insert_dashboard_created_by_admin(self):
with self.create_app().app_context():
dashboard = self.insert_dashboard_created_by("admin")
yield dashboard
db.session.delete(dashboard)
db.session.commit()
@pytest.fixture()
def insert_dashboard_created_by_gamma(self):
dashboard = self.insert_dashboard_created_by("gamma")
@ -133,14 +110,6 @@ class TestCore(SupersetTestCase, InsertChartMixin):
db.session.delete(dashboard)
db.session.commit()
@pytest.fixture()
def insert_chart_created_by_admin(self):
with self.create_app().app_context():
chart = self.insert_chart_created_by("admin")
yield chart
db.session.delete(chart)
db.session.commit()
def test_login(self):
resp = self.get_resp("/login/", data=dict(username="admin", password="general"))
self.assertNotIn("User confirmation needed", resp)
@ -513,100 +482,6 @@ class TestCore(SupersetTestCase, InsertChartMixin):
for k in keys:
self.assertIn(k, resp.keys())
@pytest.mark.usefixtures("insert_dashboard_created_by_admin")
@pytest.mark.usefixtures("insert_chart_created_by_admin")
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_user_profile(self, username="admin"):
self.login(username=username)
slc = self.get_slice("Girls", db.session)
dashboard = db.session.query(Dashboard).filter_by(slug="births").first()
# Set a favorite dashboard
self.client.post(f"/api/v1/dashboard/{dashboard.id}/favorites/", json={})
# Set a favorite chart
self.client.post(f"/api/v1/chart/{slc.id}/favorites/", json={})
# Get favorite dashboards:
request_query = {
"columns": ["created_on_delta_humanized", "dashboard_title", "url"],
"filters": [{"col": "id", "opr": "dashboard_is_favorite", "value": True}],
"keys": ["none"],
"order_column": "changed_on",
"order_direction": "desc",
"page": 0,
"page_size": 100,
}
url = f"/api/v1/dashboard/?q={prison.dumps(request_query)}"
resp = self.client.get(url)
assert resp.json["count"] == 1
assert resp.json["result"][0]["dashboard_title"] == "USA Births Names"
# Get Favorite Charts
request_query = {
"filters": [{"col": "id", "opr": "chart_is_favorite", "value": True}],
"order_column": "slice_name",
"order_direction": "asc",
"page": 0,
"page_size": 25,
}
url = f"api/v1/chart/?q={prison.dumps(request_query)}"
resp = self.client.get(url)
assert resp.json["count"] == 1
assert resp.json["result"][0]["id"] == slc.id
# Get recent activity
url = "/api/v1/log/recent_activity/?q=(page_size:50)"
resp = self.client.get(url)
# TODO data for recent activity varies for sqlite, we should be able to assert
# the returned data
assert resp.status_code == 200
# Get dashboards created by the user
request_query = {
"columns": ["created_on_delta_humanized", "dashboard_title", "url"],
"filters": [
{"col": "created_by", "opr": "dashboard_created_by_me", "value": "me"}
],
"keys": ["none"],
"order_column": "changed_on",
"order_direction": "desc",
"page": 0,
"page_size": 100,
}
url = f"/api/v1/dashboard/?q={prison.dumps(request_query)}"
resp = self.client.get(url)
assert resp.json["result"][0]["dashboard_title"] == "create_title_test"
# Get charts created by the user
request_query = {
"columns": ["created_on_delta_humanized", "slice_name", "url"],
"filters": [
{"col": "created_by", "opr": "chart_created_by_me", "value": "me"}
],
"keys": ["none"],
"order_column": "changed_on_delta_humanized",
"order_direction": "desc",
"page": 0,
"page_size": 100,
}
url = f"/api/v1/chart/?q={prison.dumps(request_query)}"
resp = self.client.get(url)
assert resp.json["count"] == 1
assert resp.json["result"][0]["slice_name"] == "create_title_test"
resp = self.get_resp(f"/superset/profile/")
self.assertIn('"app"', resp)
def test_user_profile_gamma(self):
self.login(username="gamma")
resp = self.get_resp(f"/superset/profile/")
self.assertIn('"app"', resp)
@pytest.mark.usefixtures("public_role_like_gamma")
def test_user_profile_anonymous(self):
self.logout()
resp = self.client.get("/superset/profile/")
assert resp.status_code == 404
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_slice_id_is_always_logged_correctly_on_web_request(self):
# explore case
@ -1280,6 +1155,11 @@ class TestCore(SupersetTestCase, InsertChartMixin):
is True
)
def test_redirect_new_profile(self):
self.login(username="admin")
resp = self.client.get("/superset/profile/")
assert resp.status_code == 302
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,164 @@
# 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 prison
import pytest
from superset import db
from superset.connectors.sqla.models import SqlaTable
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices,
load_birth_names_data,
)
from tests.integration_tests.fixtures.public_role import public_role_like_gamma
from tests.integration_tests.insert_chart_mixin import InsertChartMixin
from .base_tests import SupersetTestCase
class TestProfile(SupersetTestCase, InsertChartMixin):
def insert_dashboard_created_by(self, username: str) -> Dashboard:
user = self.get_user(username)
dashboard = self.insert_dashboard(
f"create_title_test",
f"create_slug_test",
[user.id],
created_by=user,
)
return dashboard
@pytest.fixture()
def insert_dashboard_created_by_admin(self):
with self.create_app().app_context():
dashboard = self.insert_dashboard_created_by("admin")
yield dashboard
db.session.delete(dashboard)
db.session.commit()
def insert_chart_created_by(self, username: str) -> Slice:
user = self.get_user(username)
dataset = db.session.query(SqlaTable).first()
chart = self.insert_chart(
f"create_title_test",
[user.id],
dataset.id,
created_by=user,
)
return chart
@pytest.fixture()
def insert_chart_created_by_admin(self):
with self.create_app().app_context():
chart = self.insert_chart_created_by("admin")
yield chart
db.session.delete(chart)
db.session.commit()
@pytest.mark.usefixtures("insert_dashboard_created_by_admin")
@pytest.mark.usefixtures("insert_chart_created_by_admin")
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_user_profile(self, username="admin"):
self.login(username=username)
slc = self.get_slice("Girls", db.session)
dashboard = db.session.query(Dashboard).filter_by(slug="births").first()
# Set a favorite dashboard
self.client.post(f"/api/v1/dashboard/{dashboard.id}/favorites/", json={})
# Set a favorite chart
self.client.post(f"/api/v1/chart/{slc.id}/favorites/", json={})
# Get favorite dashboards:
request_query = {
"columns": ["created_on_delta_humanized", "dashboard_title", "url"],
"filters": [{"col": "id", "opr": "dashboard_is_favorite", "value": True}],
"keys": ["none"],
"order_column": "changed_on",
"order_direction": "desc",
"page": 0,
"page_size": 100,
}
url = f"/api/v1/dashboard/?q={prison.dumps(request_query)}"
resp = self.client.get(url)
assert resp.json["count"] == 1
assert resp.json["result"][0]["dashboard_title"] == "USA Births Names"
# Get Favorite Charts
request_query = {
"filters": [{"col": "id", "opr": "chart_is_favorite", "value": True}],
"order_column": "slice_name",
"order_direction": "asc",
"page": 0,
"page_size": 25,
}
url = f"api/v1/chart/?q={prison.dumps(request_query)}"
resp = self.client.get(url)
assert resp.json["count"] == 1
assert resp.json["result"][0]["id"] == slc.id
# Get recent activity
url = "/api/v1/log/recent_activity/?q=(page_size:50)"
resp = self.client.get(url)
# TODO data for recent activity varies for sqlite, we should be able to assert
# the returned data
assert resp.status_code == 200
# Get dashboards created by the user
request_query = {
"columns": ["created_on_delta_humanized", "dashboard_title", "url"],
"filters": [
{"col": "created_by", "opr": "dashboard_created_by_me", "value": "me"}
],
"keys": ["none"],
"order_column": "changed_on",
"order_direction": "desc",
"page": 0,
"page_size": 100,
}
url = f"/api/v1/dashboard/?q={prison.dumps(request_query)}"
resp = self.client.get(url)
assert resp.json["result"][0]["dashboard_title"] == "create_title_test"
# Get charts created by the user
request_query = {
"columns": ["created_on_delta_humanized", "slice_name", "url"],
"filters": [
{"col": "created_by", "opr": "chart_created_by_me", "value": "me"}
],
"keys": ["none"],
"order_column": "changed_on_delta_humanized",
"order_direction": "desc",
"page": 0,
"page_size": 100,
}
url = f"/api/v1/chart/?q={prison.dumps(request_query)}"
resp = self.client.get(url)
assert resp.json["count"] == 1
assert resp.json["result"][0]["slice_name"] == "create_title_test"
resp = self.get_resp(f"/profile/")
self.assertIn('"app"', resp)
def test_user_profile_gamma(self):
self.login(username="gamma")
resp = self.get_resp(f"/profile/")
self.assertIn('"app"', resp)
@pytest.mark.usefixtures("public_role_like_gamma")
def test_user_profile_anonymous(self):
self.logout()
resp = self.client.get("/profile/")
assert resp.status_code == 404