1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-07 14:25:37 +00:00

Merge branch '3436-chart-widget-frontend' into 'develop'

Resolve "Introduce new chart widget type in the frontend"

Closes 

See merge request 
This commit is contained in:
Petr Stribny 2025-02-11 22:48:42 +00:00
commit 5c2511da13
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 decimal import Decimal
from baserow.contrib.dashboard.models import Dashboard
from baserow.contrib.dashboard.types import WidgetDict
from baserow.core.registry import (
CustomFieldsInstanceMixin,
@ -32,6 +33,17 @@ class WidgetType(
id_mapping_name = DASHBOARD_WIDGETS
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):
"""
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.before_create(dashboard)
new_widget = self.handler.create_widget(
widget_type_from_registry,
dashboard,

View file

@ -1,3 +1,4 @@
from baserow_premium.license.handler import LicenseHandler
from rest_framework import serializers
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.registries import WidgetType
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 (
LocalBaserowGroupedAggregateRowsUserServiceType,
)
@ -31,6 +33,11 @@ class ChartWidgetType(WidgetType):
class SerializedDict(WidgetDict):
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):
if instance is None:
# When the widget is being created we want to automatically

View file

@ -7,3 +7,4 @@ METRICS = "metrics"
SECURE_FILE_SERVE = "secure_file_serve"
ENTERPRISE_SETTINGS = "ENTERPRISE_SETTINGS"
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_enterprise.features import (
AUDIT_LOG,
CHART_WIDGET,
DATA_SYNC,
ENTERPRISE_SETTINGS,
RBAC,
@ -32,6 +33,7 @@ class EnterpriseWithoutSupportLicenseType(LicenseType):
SECURE_FILE_SERVE,
ENTERPRISE_SETTINGS,
DATA_SYNC,
CHART_WIDGET,
]
instance_wide = True
seats_manually_assigned = False

View file

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

View file

@ -1,5 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models.deletion import ProtectedError
from django.test.utils import override_settings
import pytest
@ -14,7 +15,9 @@ from baserow_enterprise.integrations.local_baserow.models import (
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_create_chart_widget_creates_data_source(enterprise_data_fixture):
enterprise_data_fixture.enable_enterprise()
user = enterprise_data_fixture.create_user()
dashboard = enterprise_data_fixture.create_dashboard_application(user=user)
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 'saml_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',
ENTERPRISE_SETTINGS: 'ENTERPRISE_SETTINGS',
DATA_SYNC: 'DATA_SYNC',
CHART_WIDGET: 'CHART_WIDGET',
}
export default EnterpriseFeatures

View file

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

View file

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

View file

@ -50,9 +50,13 @@ import {
GitLabIssuesDataSyncType,
HubspotContactsDataSyncType,
} from '@baserow_enterprise/dataSyncTypes'
import { ChartWidgetType } from '@baserow_enterprise/dashboard/widgetTypes'
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) => {
const { app, isDev, store } = context
@ -155,4 +159,8 @@ export default (context) => {
'configureDataSync',
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 'widget_header';
@import 'widget_settings_base_form';
@import 'create_widget_modal';

View file

@ -13,6 +13,11 @@
}
.create-widget-card__name {
height: 16px;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
color: $palette-neutral-1200;
font-size: 13px;
font-style: normal;
@ -20,8 +25,17 @@
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 {
padding: 16px 24px;
height: 80px;
padding: 0 24px;
display: flex;
justify-content: center;
align-items: center;
@ -31,7 +45,7 @@
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;
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">
{{ $t('createWidgetModal.title') }}
</h2>
<div>
<a
<div class="create-widget-modal__cards">
<CreateWidgetCard
v-for="widgetType in widgetTypes"
:key="widgetType.type"
class="create-widget-card"
@click="widgetTypeSelected(widgetType.type)"
:dashboard="dashboard"
:widget-type="widgetType"
@widget-type-selected="widgetTypeSelected"
>
<div class="create-widget-card__img-container">
<img :src="widgetType.createWidgetImage" />
</div>
<div class="create-widget-card__name">
{{ widgetType.name }}
</div>
</a>
</CreateWidgetCard>
</div>
</Modal>
</template>
<script>
import modal from '@baserow/modules/core/mixins/modal'
import CreateWidgetCard from '@baserow/modules/dashboard/components/CreateWidgetCard'
export default {
name: 'CreateWidgetModal',
components: { CreateWidgetCard },
mixins: [modal],
props: {
dashboard: {

View file

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