1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-14 17:18:33 +00:00

Resolve "Introduce new chart widget type in the frontend"

This commit is contained in:
Petr Stribny 2025-02-11 22:48:42 +00:00
parent c454cf61bf
commit 76c4dfa236
24 changed files with 393 additions and 14 deletions
backend/src/baserow/contrib/dashboard/widgets
enterprise
backend
src/baserow_enterprise
tests/baserow_enterprise_tests
web-frontend/modules/baserow_enterprise
web-frontend/modules

View file

@ -1,6 +1,7 @@
from abc import ABC from abc import ABC
from decimal import Decimal from decimal import Decimal
from baserow.contrib.dashboard.models import Dashboard
from baserow.contrib.dashboard.types import WidgetDict from baserow.contrib.dashboard.types import WidgetDict
from baserow.core.registry import ( from baserow.core.registry import (
CustomFieldsInstanceMixin, CustomFieldsInstanceMixin,
@ -32,6 +33,17 @@ class WidgetType(
id_mapping_name = DASHBOARD_WIDGETS id_mapping_name = DASHBOARD_WIDGETS
allowed_fields = ["title", "description"] allowed_fields = ["title", "description"]
def before_create(self, dashboard: Dashboard):
"""
This function allows you to perform checks and operations
before a widget is created.
:param dashboard: The dashboard where the widget should be
created.
"""
pass
def prepare_value_for_db(self, values: dict, instance: Widget | None = None): def prepare_value_for_db(self, values: dict, instance: Widget | None = None):
""" """
This function allows you to hook into the moment a widget is created or This function allows you to hook into the moment a widget is created or

View file

@ -115,6 +115,8 @@ class WidgetService:
widget_type_from_registry = widget_type_registry.get(widget_type) widget_type_from_registry = widget_type_registry.get(widget_type)
widget_type_from_registry.before_create(dashboard)
new_widget = self.handler.create_widget( new_widget = self.handler.create_widget(
widget_type_from_registry, widget_type_from_registry,
dashboard, dashboard,

View file

@ -1,3 +1,4 @@
from baserow_premium.license.handler import LicenseHandler
from rest_framework import serializers from rest_framework import serializers
from baserow.contrib.dashboard.data_sources.handler import DashboardDataSourceHandler from baserow.contrib.dashboard.data_sources.handler import DashboardDataSourceHandler
@ -6,6 +7,7 @@ from baserow.contrib.dashboard.types import WidgetDict
from baserow.contrib.dashboard.widgets.models import Widget from baserow.contrib.dashboard.widgets.models import Widget
from baserow.contrib.dashboard.widgets.registries import WidgetType from baserow.contrib.dashboard.widgets.registries import WidgetType
from baserow.core.services.registries import service_type_registry from baserow.core.services.registries import service_type_registry
from baserow_enterprise.features import CHART_WIDGET
from baserow_enterprise.integrations.local_baserow.service_types import ( from baserow_enterprise.integrations.local_baserow.service_types import (
LocalBaserowGroupedAggregateRowsUserServiceType, LocalBaserowGroupedAggregateRowsUserServiceType,
) )
@ -31,6 +33,11 @@ class ChartWidgetType(WidgetType):
class SerializedDict(WidgetDict): class SerializedDict(WidgetDict):
data_source_id: int data_source_id: int
def before_create(self, dashboard):
LicenseHandler.raise_if_workspace_doesnt_have_feature(
CHART_WIDGET, dashboard.workspace
)
def prepare_value_for_db(self, values: dict, instance: Widget | None = None): def prepare_value_for_db(self, values: dict, instance: Widget | None = None):
if instance is None: if instance is None:
# When the widget is being created we want to automatically # When the widget is being created we want to automatically

View file

@ -7,3 +7,4 @@ METRICS = "metrics"
SECURE_FILE_SERVE = "secure_file_serve" SECURE_FILE_SERVE = "secure_file_serve"
ENTERPRISE_SETTINGS = "ENTERPRISE_SETTINGS" ENTERPRISE_SETTINGS = "ENTERPRISE_SETTINGS"
DATA_SYNC = "data_sync" DATA_SYNC = "data_sync"
CHART_WIDGET = "chart_widget"

View file

@ -7,6 +7,7 @@ from baserow_premium.license.registries import LicenseType, SeatUsageSummary
from baserow.core.models import Workspace from baserow.core.models import Workspace
from baserow_enterprise.features import ( from baserow_enterprise.features import (
AUDIT_LOG, AUDIT_LOG,
CHART_WIDGET,
DATA_SYNC, DATA_SYNC,
ENTERPRISE_SETTINGS, ENTERPRISE_SETTINGS,
RBAC, RBAC,
@ -32,6 +33,7 @@ class EnterpriseWithoutSupportLicenseType(LicenseType):
SECURE_FILE_SERVE, SECURE_FILE_SERVE,
ENTERPRISE_SETTINGS, ENTERPRISE_SETTINGS,
DATA_SYNC, DATA_SYNC,
CHART_WIDGET,
] ]
instance_wide = True instance_wide = True
seats_manually_assigned = False seats_manually_assigned = False

View file

@ -1,3 +1,5 @@
from django.test.utils import override_settings
import pytest import pytest
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from rest_framework.status import HTTP_200_OK from rest_framework.status import HTTP_200_OK
@ -6,7 +8,9 @@ from baserow.test_utils.helpers import AnyInt
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(DEBUG=True)
def test_create_chart_widget(api_client, enterprise_data_fixture): def test_create_chart_widget(api_client, enterprise_data_fixture):
enterprise_data_fixture.enable_enterprise()
user, token = enterprise_data_fixture.create_user_and_token() user, token = enterprise_data_fixture.create_user_and_token()
dashboard = enterprise_data_fixture.create_dashboard_application(user=user) dashboard = enterprise_data_fixture.create_dashboard_application(user=user)

View file

@ -1,5 +1,6 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models.deletion import ProtectedError from django.db.models.deletion import ProtectedError
from django.test.utils import override_settings
import pytest import pytest
@ -14,7 +15,9 @@ from baserow_enterprise.integrations.local_baserow.models import (
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(DEBUG=True)
def test_create_chart_widget_creates_data_source(enterprise_data_fixture): def test_create_chart_widget_creates_data_source(enterprise_data_fixture):
enterprise_data_fixture.enable_enterprise()
user = enterprise_data_fixture.create_user() user = enterprise_data_fixture.create_user()
dashboard = enterprise_data_fixture.create_dashboard_application(user=user) dashboard = enterprise_data_fixture.create_dashboard_application(user=user)
widget_type = "chart" widget_type = "chart"

View file

@ -0,0 +1,14 @@
<svg width="72" height="48" viewBox="0 0 72 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.25" y="1.25" width="69.5" height="45.5" rx="4.75" fill="white"/>
<rect x="1.25" y="1.25" width="69.5" height="45.5" rx="4.75" stroke="#E6E6E7" stroke-width="1.5"/>
<rect opacity="0.48" x="8" y="15.5" width="6" height="4" rx="2" fill="#E6E6E7"/>
<rect opacity="0.48" x="8" y="37" width="6" height="4" rx="2" fill="#E6E6E7"/>
<rect opacity="0.48" x="8" y="26.5" width="8" height="4" rx="2" fill="#E6E6E7"/>
<rect x="24" y="8" width="1" height="32" fill="#EDEDED"/>
<rect x="20" y="39" width="44" height="1" fill="#EDEDED"/>
<rect x="20" y="28" width="44" height="1" fill="#EDEDED"/>
<rect x="20" y="17" width="44" height="1" fill="#EDEDED"/>
<path d="M30 37C30 35.8954 30.8954 35 32 35H34C35.1046 35 36 35.8954 36 37V40H30V37Z" fill="#4E5CFE"/>
<path d="M42 25C42 23.8954 42.8954 23 44 23H46C47.1046 23 48 23.8954 48 25V40H42V25Z" fill="#4E5CFE"/>
<path d="M54 13C54 11.8954 54.8954 11 56 11H58C59.1046 11 60 11.8954 60 13V40H54V13Z" fill="#4E5CFE"/>
</svg>

After

(image error) Size: 1.1 KiB

View file

@ -18,3 +18,4 @@
@import 'common_oidc_setting_form'; @import 'common_oidc_setting_form';
@import 'saml_auth_link'; @import 'saml_auth_link';
@import 'oidc_auth_link'; @import 'oidc_auth_link';
@import 'dashboard_chart_widget';

View file

@ -0,0 +1,4 @@
.dashboard-chart-widget {
padding: 0 24px 24px;
}

View file

@ -0,0 +1,29 @@
<template><div></div></template>
<script>
import form from '@baserow/modules/core/mixins/form'
export default {
name: 'GroupedAggregateRowsDataSourceForm',
mixins: [form],
props: {
dashboard: {
type: Object,
required: true,
},
widget: {
type: Object,
required: true,
},
dataSource: {
type: Object,
required: true,
},
storePrefix: {
type: String,
required: false,
default: '',
},
},
}
</script>

View file

@ -0,0 +1,83 @@
<template>
<div class="dashboard-chart-widget">
<div class="widget-header">
<div class="widget-header__main">
<div class="widget-header__title-wrapper">
<div class="widget-header__title">{{ widget.title }}</div>
<div
v-if="dataSourceMisconfigured"
class="widget-header__fix-configuration"
>
<svg
width="5"
height="6"
viewBox="0 0 5 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="2.5" cy="3" r="2.5" fill="#FF5A44" />
</svg>
{{ $t('widget.fixConfiguration') }}
</div>
</div>
<div v-if="widget.description" class="widget-header__description">
{{ widget.description }}
</div>
</div>
<WidgetContextMenu
v-if="isEditMode"
:widget="widget"
:dashboard="dashboard"
@delete-widget="$emit('delete-widget', $event)"
></WidgetContextMenu>
</div>
</div>
</template>
<script>
import WidgetContextMenu from '@baserow/modules/dashboard/components/widget/WidgetContextMenu'
export default {
name: 'ChartWidget',
components: { WidgetContextMenu },
props: {
dashboard: {
type: Object,
required: true,
},
widget: {
type: Object,
required: true,
},
storePrefix: {
type: String,
required: false,
default: '',
},
},
computed: {
dataSource() {
return this.$store.getters[
`${this.storePrefix}dashboardApplication/getDataSourceById`
](this.widget.data_source_id)
},
dataForDataSource() {
return this.$store.getters[
`${this.storePrefix}dashboardApplication/getDataForDataSource`
](this.dataSource?.id)
},
isEditMode() {
return this.$store.getters[
`${this.storePrefix}dashboardApplication/isEditMode`
]
},
dataSourceMisconfigured() {
const data = this.dataForDataSource
if (data) {
return !!data._error
}
return false
},
},
}
</script>

View file

@ -0,0 +1,75 @@
<template>
<GroupedAggregateRowsDataSourceForm
v-if="dataSource"
ref="dataSourceForm"
:dashboard="dashboard"
:widget="widget"
:data-source="dataSource"
:default-values="dataSource"
:store-prefix="storePrefix"
@values-changed="onDataSourceValuesChanged"
/>
</template>
<script>
import GroupedAggregateRowsDataSourceForm from '@baserow_enterprise/dashboard/components/data_source/GroupedAggregateRowsDataSourceForm'
import error from '@baserow/modules/core/mixins/error'
import { notifyIf } from '@baserow/modules/core/utils/error'
export default {
name: 'ChartWidgetSettings',
components: { GroupedAggregateRowsDataSourceForm },
mixins: [error],
props: {
dashboard: {
type: Object,
required: true,
},
widget: {
type: Object,
required: true,
},
storePrefix: {
type: String,
required: false,
default: '',
},
},
data() {
return {
loading: false,
}
},
computed: {
dataSource() {
return this.$store.getters[
`${this.storePrefix}dashboardApplication/getDataSourceById`
](this.widget.data_source_id)
},
integration() {
return this.$store.getters[
`${this.storePrefix}dashboardApplication/getIntegrationById`
](this.dataSource.integration_id)
},
},
methods: {
async onDataSourceValuesChanged(changedDataSourceValues) {
if (this.$refs.dataSourceForm.isFormValid()) {
try {
await this.$store.dispatch(
`${this.storePrefix}dashboardApplication/updateDataSource`,
{
dataSourceId: this.dataSource.id,
values: changedDataSourceValues,
}
)
} catch (error) {
this.$refs.dataSourceForm.reset()
this.$refs.dataSourceForm.touch()
notifyIf(error, 'dashboard')
}
}
},
},
}
</script>

View file

@ -0,0 +1,44 @@
import { WidgetType } from '@baserow/modules/dashboard/widgetTypes'
import ChartWidget from '@baserow_enterprise/dashboard/components/widget/ChartWidget'
import ChartWidgetSettings from '@baserow_enterprise/dashboard/components/widget/ChartWidgetSettings'
import ChartWidgetSvg from '@baserow_enterprise/assets/images/dashboard/widgets/chart_widget.svg'
import EnterpriseFeatures from '@baserow_enterprise/features'
import EnterpriseModal from '@baserow_enterprise/components/EnterpriseModal'
export class ChartWidgetType extends WidgetType {
static getType() {
return 'chart'
}
get name() {
return this.app.i18n.t('chartWidget.name')
}
get createWidgetImage() {
return ChartWidgetSvg
}
get component() {
return ChartWidget
}
get settingsComponent() {
return ChartWidgetSettings
}
isLoading(widget, data) {
const dataSourceId = widget.data_source_id
if (data[dataSourceId] && Object.keys(data[dataSourceId]).length !== 0) {
return false
}
return true
}
isAvailable(workspaceId) {
return this.app.$hasFeature(EnterpriseFeatures.CHART_WIDGET, workspaceId)
}
getDeactivatedModal() {
return EnterpriseModal
}
}

View file

@ -6,6 +6,7 @@ const EnterpriseFeatures = {
AUDIT_LOG: 'AUDIT_LOG', AUDIT_LOG: 'AUDIT_LOG',
ENTERPRISE_SETTINGS: 'ENTERPRISE_SETTINGS', ENTERPRISE_SETTINGS: 'ENTERPRISE_SETTINGS',
DATA_SYNC: 'DATA_SYNC', DATA_SYNC: 'DATA_SYNC',
CHART_WIDGET: 'CHART_WIDGET',
} }
export default EnterpriseFeatures export default EnterpriseFeatures

View file

@ -44,6 +44,7 @@ export class EnterpriseWithoutSupportLicenseType extends LicenseType {
EnterpriseFeaturesObject.AUDIT_LOG, EnterpriseFeaturesObject.AUDIT_LOG,
EnterpriseFeaturesObject.ENTERPRISE_SETTINGS, EnterpriseFeaturesObject.ENTERPRISE_SETTINGS,
EnterpriseFeaturesObject.DATA_SYNC, EnterpriseFeaturesObject.DATA_SYNC,
EnterpriseFeaturesObject.CHART_WIDGET,
] ]
} }

View file

@ -439,5 +439,8 @@
}, },
"oidcAuthLink": { "oidcAuthLink": {
"placeholderWithOIDC": "{login} with {provider}" "placeholderWithOIDC": "{login} with {provider}"
},
"chartWidget": {
"name": "Chart"
} }
} }

View file

@ -50,9 +50,13 @@ import {
GitLabIssuesDataSyncType, GitLabIssuesDataSyncType,
HubspotContactsDataSyncType, HubspotContactsDataSyncType,
} from '@baserow_enterprise/dataSyncTypes' } from '@baserow_enterprise/dataSyncTypes'
import { ChartWidgetType } from '@baserow_enterprise/dashboard/widgetTypes'
import { PeriodicIntervalFieldsConfigureDataSyncType } from '@baserow_enterprise/configureDataSyncTypes' import { PeriodicIntervalFieldsConfigureDataSyncType } from '@baserow_enterprise/configureDataSyncTypes'
import { FF_AB_SSO } from '@baserow/modules/core/plugins/featureFlags' import {
FF_AB_SSO,
FF_DASHBOARDS,
} from '@baserow/modules/core/plugins/featureFlags'
export default (context) => { export default (context) => {
const { app, isDev, store } = context const { app, isDev, store } = context
@ -155,4 +159,8 @@ export default (context) => {
'configureDataSync', 'configureDataSync',
new PeriodicIntervalFieldsConfigureDataSyncType(context) new PeriodicIntervalFieldsConfigureDataSyncType(context)
) )
if (app.$featureFlagIsEnabled(FF_DASHBOARDS)) {
app.$registry.register('dashboardWidget', new ChartWidgetType(context))
}
} }

View file

@ -9,3 +9,4 @@
@import 'create_widget_button'; @import 'create_widget_button';
@import 'widget_header'; @import 'widget_header';
@import 'widget_settings_base_form'; @import 'widget_settings_base_form';
@import 'create_widget_modal';

View file

@ -13,6 +13,11 @@
} }
.create-widget-card__name { .create-widget-card__name {
height: 16px;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
color: $palette-neutral-1200; color: $palette-neutral-1200;
font-size: 13px; font-size: 13px;
font-style: normal; font-style: normal;
@ -20,8 +25,17 @@
line-height: 20px; line-height: 20px;
} }
.create-widget-card__name-locked {
display: inline-block;
color: #4e5cfe;
padding: 3px;
border-radius: 4px;
background: rgba(78, 92, 254, 0.1);
}
.create-widget-card__img-container { .create-widget-card__img-container {
padding: 16px 24px; height: 80px;
padding: 0 24px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -31,7 +45,7 @@
box-shadow: 0 1px 2px 0 rgba(7, 8, 16, 0.1); box-shadow: 0 1px 2px 0 rgba(7, 8, 16, 0.1);
} }
.create-widget-card:hover .create-widget-card__img-container { .create-widget-card--available:hover .create-widget-card__img-container {
border: 1px solid #d7d8d9; border: 1px solid #d7d8d9;
background: #f7f7f7; background: #f7f7f7;
} }

View file

@ -0,0 +1,5 @@
.create-widget-modal__cards {
display: flex;
flex-direction: row;
gap: 22px;
}

View file

@ -0,0 +1,60 @@
<template>
<a
class="create-widget-card"
:class="{
'create-widget-card--available': isWidgetAvailable,
}"
@click="widgetTypeSelected"
>
<div class="create-widget-card__img-container">
<img :src="widgetType.createWidgetImage" />
</div>
<div class="create-widget-card__name">
<span>{{ widgetType.name }}</span>
<span v-if="!isWidgetAvailable">
<i class="iconoir-lock create-widget-card__name-locked"></i>
</span>
</div>
<component
:is="deactivatedModal"
v-if="deactivatedModal != null"
ref="deactivatedModal"
:name="widgetType.name"
:workspace="dashboard.workspace"
></component>
</a>
</template>
<script>
export default {
name: 'CreateWidgetCard',
props: {
dashboard: {
type: Object,
required: true,
},
widgetType: {
type: Object,
required: true,
},
},
computed: {
isWidgetAvailable() {
return this.widgetType.isAvailable(this.dashboard.workspace.id)
},
deactivatedModal() {
return this.widgetType.getDeactivatedModal()
},
},
methods: {
widgetTypeSelected() {
if (!this.isWidgetAvailable) {
this.$refs.deactivatedModal.show()
return
}
this.$emit('widget-type-selected', this.widgetType.type)
},
},
}
</script>

View file

@ -3,29 +3,26 @@
<h2 class="box__title"> <h2 class="box__title">
{{ $t('createWidgetModal.title') }} {{ $t('createWidgetModal.title') }}
</h2> </h2>
<div> <div class="create-widget-modal__cards">
<a <CreateWidgetCard
v-for="widgetType in widgetTypes" v-for="widgetType in widgetTypes"
:key="widgetType.type" :key="widgetType.type"
class="create-widget-card" :dashboard="dashboard"
@click="widgetTypeSelected(widgetType.type)" :widget-type="widgetType"
@widget-type-selected="widgetTypeSelected"
> >
<div class="create-widget-card__img-container"> </CreateWidgetCard>
<img :src="widgetType.createWidgetImage" />
</div>
<div class="create-widget-card__name">
{{ widgetType.name }}
</div>
</a>
</div> </div>
</Modal> </Modal>
</template> </template>
<script> <script>
import modal from '@baserow/modules/core/mixins/modal' import modal from '@baserow/modules/core/mixins/modal'
import CreateWidgetCard from '@baserow/modules/dashboard/components/CreateWidgetCard'
export default { export default {
name: 'CreateWidgetModal', name: 'CreateWidgetModal',
components: { CreateWidgetCard },
mixins: [modal], mixins: [modal],
props: { props: {
dashboard: { dashboard: {

View file

@ -36,6 +36,14 @@ export class WidgetType extends Registerable {
isLoading(widget, data) { isLoading(widget, data) {
return false return false
} }
isAvailable() {
return true
}
getDeactivatedModal() {
return null
}
} }
export class SummaryWidgetType extends WidgetType { export class SummaryWidgetType extends WidgetType {