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

Resolve "Chart widget: series configuration"

This commit is contained in:
Petr Stribny 2025-02-13 02:17:25 +00:00
parent 5c2511da13
commit b8ce60759e
12 changed files with 486 additions and 31 deletions
enterprise
backend/src/baserow_enterprise
web-frontend/modules/baserow_enterprise
web-frontend/modules/core
assets/scss/components
components

View file

@ -7,7 +7,7 @@ from baserow_enterprise.integrations.local_baserow.models import (
class LocalBaserowTableServiceAggregationSeriesSerializer(serializers.ModelSerializer):
field_id = serializers.IntegerField()
field_id = serializers.IntegerField(allow_null=True)
order = serializers.IntegerField(read_only=True)
class Meta:

View file

@ -83,6 +83,7 @@ class LocalBaserowTableServiceAggregationSeries(models.Model):
field = models.ForeignKey(
"database.Field",
help_text="The aggregated field.",
null=True,
on_delete=models.CASCADE,
)
aggregation_type = models.CharField(

View file

@ -130,36 +130,39 @@ class LocalBaserowGroupedAggregateRowsUserServiceType(
table_field_ids = [field.id for field in table_fields]
def validate_agg_series(agg_series):
if agg_series["field_id"] not in table_field_ids:
raise DRFValidationError(
detail=f"The field with ID {agg_series['field_id']} is not "
"related to the given table.",
code="invalid_field",
)
if agg_series["aggregation_type"]:
try:
agg_type = field_aggregation_registry.get(
agg_series["aggregation_type"]
)
except AggregationTypeDoesNotExist:
raise DRFValidationError(
detail=f"The aggregation type '{agg_series['aggregation_type']}' "
f"doesn't exist",
code="invalid_aggregation_raw_type",
)
try:
agg_type = field_aggregation_registry.get(
agg_series["aggregation_type"]
)
except AggregationTypeDoesNotExist:
raise DRFValidationError(
detail=f"The aggregation type '{agg_series['aggregation_type']}' "
f"doesn't exist",
code="invalid_aggregation_raw_type",
)
field = next(
(
field
for field in table_fields
if field.id == agg_series["field_id"]
)
)
if not agg_type.field_is_compatible(field):
raise DRFValidationError(
detail=f"The field with ID {agg_series['field_id']} is not compatible "
f"with aggregation type {agg_series['aggregation_type']}.",
code="invalid_aggregation_raw_type",
if agg_series["field_id"] is not None:
if agg_series["field_id"] not in table_field_ids:
raise DRFValidationError(
detail=f"The field with ID {agg_series['field_id']} is not "
"related to the given table.",
code="invalid_field",
)
field = next(
(
field
for field in table_fields
if field.id == agg_series["field_id"]
)
)
if not agg_type.field_is_compatible(field):
raise DRFValidationError(
detail=f"The field with ID {agg_series['field_id']} is not compatible "
f"with aggregation type {agg_series['aggregation_type']}.",
code="invalid_aggregation_raw_type",
)
return True
@ -446,6 +449,10 @@ class LocalBaserowGroupedAggregateRowsUserServiceType(
f"There are no aggregation series defined."
)
for agg_series in defined_agg_series:
if agg_series.field is None:
raise ServiceImproperlyConfigured(
f"The aggregation series field has to be set."
)
if agg_series.field.trashed:
raise ServiceImproperlyConfigured(
f"The field with ID {agg_series.field.id} is trashed."

View file

@ -0,0 +1,24 @@
# Generated by Django 5.0.9 on 2025-02-11 10:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("baserow_enterprise", "0040_chartwidget"),
("database", "0180_view_allow_public_export"),
]
operations = [
migrations.AlterField(
model_name="localbaserowtableserviceaggregationseries",
name="field",
field=models.ForeignKey(
help_text="The aggregated field.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="database.field",
),
),
]

View file

@ -0,0 +1,6 @@
.aggregation-series-form {
border-radius: 6px;
background: $palette-neutral-50;
padding: 16px 12px;
margin-bottom: 8px;
}

View file

@ -19,3 +19,4 @@
@import 'saml_auth_link';
@import 'oidc_auth_link';
@import 'dashboard_chart_widget';
@import 'aggregation_series_form';

View file

@ -0,0 +1,167 @@
<template>
<div class="aggregation-series-form">
<FormSection>
<FormGroup
small-label
:label="$t('aggregationSeriesForm.aggregationTypeLabel')"
required
horizontal
horizontal-narrow
class="margin-bottom-2"
>
<Dropdown
v-model="values.aggregation_type"
:error="fieldHasErrors('aggregation_type')"
@change="$v.values.aggregation_type.$touch()"
>
<DropdownItem
v-for="viewAggregation in viewAggregationTypes"
:key="viewAggregation.getType()"
:name="viewAggregation.getName()"
:value="viewAggregation.getType()"
>
</DropdownItem>
</Dropdown>
</FormGroup>
<FormGroup
small-label
:label="$t('aggregationSeriesForm.aggregationFieldLabel')"
required
horizontal
horizontal-narrow
>
<Dropdown
v-model="values.field_id"
:error="fieldHasErrors('field_id')"
:disabled="compatibleFields.length === 0"
@change="$v.values.field_id.$touch()"
>
<DropdownItem
v-for="field in compatibleFields"
:key="field.id"
:name="field.name"
:value="field.id"
:icon="fieldIconClass(field)"
>
</DropdownItem>
</Dropdown>
</FormGroup>
</FormSection>
<FormSection>
<ButtonText
icon="iconoir-bin"
type="secondary"
@click="$emit('delete-series', seriesIndex)"
>{{ $t('aggregationSeriesForm.deleteSeries') }}</ButtonText
>
</FormSection>
</div>
</template>
<script>
import form from '@baserow/modules/core/mixins/form'
import { required } from 'vuelidate/lib/validators'
const includes = (array) => (value) => {
return array.includes(value)
}
export default {
name: 'AggregationSeriesForm',
mixins: [form],
props: {
tableFields: {
type: Array,
required: true,
},
seriesIndex: {
type: Number,
required: true,
},
},
data() {
return {
allowedValues: ['field_id', 'aggregation_type'],
values: {
field_id: null,
aggregation_type: null,
},
emitValuesOnReset: false,
}
},
computed: {
viewAggregationTypes() {
return this.$registry.getOrderedList('viewAggregation')
},
aggregationTypeNames() {
return this.viewAggregationTypes.map((aggType) => aggType.getType())
},
compatibleFields() {
if (!this.values.aggregation_type) {
return []
}
const aggType = this.$registry.get(
'viewAggregation',
this.values.aggregation_type
)
return this.tableFields.filter((tableField) =>
aggType.fieldIsCompatible(tableField)
)
},
compatibleTableFieldIds() {
return this.compatibleFields.map((field) => field.id)
},
},
watch: {
defaultValues: {
handler() {
this.reset(true)
this.$v.$touch(true)
},
immediate: true,
deep: true,
},
'values.aggregation_type': {
handler(aggregationType) {
if (
aggregationType !== null &&
aggregationType !== this.defaultValues.aggregation_type &&
this.values.field_id !== null
) {
// If both the field and aggregation type
// are selected, check if they are still
// compatible.
const aggType = this.$registry.get('viewAggregation', aggregationType)
const field = this.tableFields.filter(
(field) => field.id === this.values.field_id
)
if (!aggType.fieldIsCompatible(field)) {
this.values.field_id = null
}
}
},
immediate: true,
},
},
validations() {
return {
values: {
aggregation_type: {
required,
isValidAggregationType: includes(this.aggregationTypeNames),
},
field_id: {
required,
isValidFieldId: includes(this.compatibleTableFieldIds),
},
},
}
},
methods: {
fieldIconClass(field) {
const fieldType = this.$registry.get('field', field.type)
return fieldType.iconClass
},
},
}
</script>

View file

@ -1,10 +1,115 @@
<template><div></div></template>
<template>
<form @submit.prevent>
<FormSection
:title="$t('groupedAggregateRowsDataSourceForm.data')"
class="margin-bottom-2"
>
<FormGroup
:label="$t('groupedAggregateRowsDataSourceForm.sourceFieldLabel')"
class="margin-bottom-2"
small-label
required
horizontal
horizontal-narrow
>
<Dropdown
v-model="values.table_id"
:show-search="true"
fixed-items
:error="fieldHasErrors('table_id')"
@change="$v.values.table_id.$touch()"
>
<DropdownSection
v-for="database in databases"
:key="database.id"
:title="`${database.name} (${database.id})`"
>
<DropdownItem
v-for="table in database.tables"
:key="table.id"
:name="table.name"
:value="table.id"
:indented="true"
>
{{ table.name }}
</DropdownItem>
</DropdownSection>
</Dropdown>
</FormGroup>
<FormGroup
v-if="values.table_id && !fieldHasErrors('table_id')"
:label="$t('groupedAggregateRowsDataSourceForm.viewFieldLabel')"
class="margin-bottom-2"
small-label
required
horizontal
horizontal-narrow
>
<Dropdown
v-model="values.view_id"
:show-search="false"
fixed-items
:error="fieldHasErrors('view_id')"
@change="$v.values.view_id.$touch()"
>
<DropdownItem
:name="$t('groupedAggregateRowsDataSourceForm.notSelected')"
:value="null"
>{{
$t('groupedAggregateRowsDataSourceForm.notSelected')
}}</DropdownItem
>
<DropdownItem
v-for="view in tableViews"
:key="view.id"
:name="view.name"
:value="view.id"
>
{{ view.name }}
</DropdownItem>
</Dropdown>
</FormGroup>
</FormSection>
<FormSection
v-if="values.table_id && !fieldHasErrors('table_id')"
:title="$t('groupedAggregateRowsDataSourceForm.series')"
class="margin-bottom-2"
>
<template #title-slot>
<ButtonText icon="iconoir-plus" type="secondary" @click="addSeries">{{
$t('groupedAggregateRowsDataSourceForm.addSeries')
}}</ButtonText>
</template>
<div class="margin-bottom-2"></div>
<AggregationSeriesForm
v-for="(series, index) in values.aggregation_series"
:key="index"
:table-fields="tableFields"
:series-index="index"
:default-values="series"
@delete-series="deleteSeries"
@values-changed="onAggregationSeriesUpdated(index, $event)"
>
</AggregationSeriesForm>
</FormSection>
</form>
</template>
<script>
import form from '@baserow/modules/core/mixins/form'
import { required } from 'vuelidate/lib/validators'
import AggregationSeriesForm from '@baserow_enterprise/dashboard/components/data_source/AggregationSeriesForm'
const includesIfSet = (array) => (value) => {
if (value === null || value === undefined) {
return true
}
return array.includes(value)
}
export default {
name: 'GroupedAggregateRowsDataSourceForm',
components: { AggregationSeriesForm },
mixins: [form],
props: {
dashboard: {
@ -25,5 +130,124 @@ export default {
default: '',
},
},
data() {
return {
allowedValues: ['table_id', 'view_id', 'aggregation_series'],
values: {
table_id: null,
view_id: null,
aggregation_series: [],
},
tableLoading: false,
databaseSelectedId: null,
emitValuesOnReset: false,
}
},
computed: {
integration() {
return this.$store.getters[
`${this.storePrefix}dashboardApplication/getIntegrationById`
](this.dataSource.integration_id)
},
databases() {
return this.integration.context_data.databases
},
databaseSelected() {
return this.databases.find(
(database) => database.id === this.databaseSelectedId
)
},
tables() {
return this.databases.map((database) => database.tables).flat()
},
tableIds() {
return this.tables.map((table) => table.id)
},
tableSelected() {
return this.tables.find(({ id }) => id === this.values.table_id)
},
tableFields() {
return this.tableSelected?.fields || []
},
tableFieldIds() {
return this.tableFields.map((field) => field.id)
},
tableViews() {
return (
this.databaseSelected?.views.filter(
(view) => view.table_id === this.values.table_id
) || []
)
},
tableViewIds() {
return this.tableViews.map((view) => view.id)
},
},
watch: {
dataSource: {
handler() {
// Reset the form to set default values
// again after a different widget is selected
this.reset(true)
// Run form validation so that
// problems are highlighted immediately
this.$v.$touch(true)
},
immediate: true,
deep: true,
},
'values.table_id': {
handler(tableId) {
if (tableId !== null) {
const databaseOfTableId = this.databases.find((database) =>
database.tables.some((table) => table.id === tableId)
)
if (databaseOfTableId) {
this.databaseSelectedId = databaseOfTableId.id
}
// If the values are not changed by the user
// we don't want to continue with preselecting
// default values
if (tableId === this.defaultValues.table_id) {
return
}
if (
!this.tableViews.some((view) => view.id === this.values.view_id)
) {
this.values.view_id = null
}
}
},
immediate: true,
},
},
validations() {
return {
values: {
table_id: { required, isValidTableId: includesIfSet(this.tableIds) },
view_id: { isValidViewId: includesIfSet(this.tableViewIds) },
},
}
},
methods: {
addSeries() {
this.values.aggregation_series.push({
field_id: null,
aggregation_type: '',
})
},
deleteSeries(index) {
this.values.aggregation_series.splice(index, 1)
},
onAggregationSeriesUpdated(index, aggregationSeriesValues) {
const updatedAggregationSeries = this.values.aggregation_series
updatedAggregationSeries[index] = aggregationSeriesValues
this.$emit('values-changed', {
aggregation_series: updatedAggregationSeries,
})
},
},
}
</script>

View file

@ -442,5 +442,18 @@
},
"chartWidget": {
"name": "Chart"
},
"groupedAggregateRowsDataSourceForm": {
"series": "Series",
"addSeries": "Add series",
"data": "Data",
"sourceFieldLabel": "Source",
"viewFieldLabel": "View",
"notSelected": "Not selected"
},
"aggregationSeriesForm": {
"aggregationFieldLabel": "Field",
"aggregationTypeLabel": "Summary type",
"deleteSeries": "Delete series"
}
}

View file

@ -2,4 +2,5 @@
background: #fff;
padding: 16px 16px 14px;
border-left: 1px solid $palette-neutral-200;
overflow-y: scroll;
}

View file

@ -236,3 +236,9 @@
font-size: 13px;
font-weight: 500;
}
.form-section__title-container {
display: flex;
flex-direction: row;
justify-content: space-between;
}

View file

@ -1,6 +1,11 @@
<template>
<div class="form-section">
<h3 v-if="title" class="form-section__title">{{ title }}</h3>
<div class="form-section__title-container">
<h3 v-if="title" class="form-section__title">{{ title }}</h3>
<div>
<slot name="title-slot"></slot>
</div>
</div>
<slot />
</div>
</template>