diff --git a/enterprise/backend/src/baserow_enterprise/api/integrations/local_baserow/serializers.py b/enterprise/backend/src/baserow_enterprise/api/integrations/local_baserow/serializers.py index d03a09bdf..00dba9b8d 100644 --- a/enterprise/backend/src/baserow_enterprise/api/integrations/local_baserow/serializers.py +++ b/enterprise/backend/src/baserow_enterprise/api/integrations/local_baserow/serializers.py @@ -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: diff --git a/enterprise/backend/src/baserow_enterprise/integrations/local_baserow/models.py b/enterprise/backend/src/baserow_enterprise/integrations/local_baserow/models.py index 34c0fd49e..00f9c1c83 100644 --- a/enterprise/backend/src/baserow_enterprise/integrations/local_baserow/models.py +++ b/enterprise/backend/src/baserow_enterprise/integrations/local_baserow/models.py @@ -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( diff --git a/enterprise/backend/src/baserow_enterprise/integrations/local_baserow/service_types.py b/enterprise/backend/src/baserow_enterprise/integrations/local_baserow/service_types.py index 6ce3b5a05..1a670dd06 100644 --- a/enterprise/backend/src/baserow_enterprise/integrations/local_baserow/service_types.py +++ b/enterprise/backend/src/baserow_enterprise/integrations/local_baserow/service_types.py @@ -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." diff --git a/enterprise/backend/src/baserow_enterprise/migrations/0041_alter_localbaserowtableserviceaggregationseries_field.py b/enterprise/backend/src/baserow_enterprise/migrations/0041_alter_localbaserowtableserviceaggregationseries_field.py new file mode 100644 index 000000000..e46739def --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/migrations/0041_alter_localbaserowtableserviceaggregationseries_field.py @@ -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", + ), + ), + ] diff --git a/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/aggregation_series_form.scss b/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/aggregation_series_form.scss new file mode 100644 index 000000000..bc0d2f204 --- /dev/null +++ b/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/aggregation_series_form.scss @@ -0,0 +1,6 @@ +.aggregation-series-form { + border-radius: 6px; + background: $palette-neutral-50; + padding: 16px 12px; + margin-bottom: 8px; +} diff --git a/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/all.scss b/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/all.scss index 7e9a4aa2d..1c3184f45 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/all.scss +++ b/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/all.scss @@ -19,3 +19,4 @@ @import 'saml_auth_link'; @import 'oidc_auth_link'; @import 'dashboard_chart_widget'; +@import 'aggregation_series_form'; diff --git a/enterprise/web-frontend/modules/baserow_enterprise/dashboard/components/data_source/AggregationSeriesForm.vue b/enterprise/web-frontend/modules/baserow_enterprise/dashboard/components/data_source/AggregationSeriesForm.vue new file mode 100644 index 000000000..2996efc96 --- /dev/null +++ b/enterprise/web-frontend/modules/baserow_enterprise/dashboard/components/data_source/AggregationSeriesForm.vue @@ -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> diff --git a/enterprise/web-frontend/modules/baserow_enterprise/dashboard/components/data_source/GroupedAggregateRowsDataSourceForm.vue b/enterprise/web-frontend/modules/baserow_enterprise/dashboard/components/data_source/GroupedAggregateRowsDataSourceForm.vue index ee41ede54..6b5b7edde 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/dashboard/components/data_source/GroupedAggregateRowsDataSourceForm.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/dashboard/components/data_source/GroupedAggregateRowsDataSourceForm.vue @@ -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> diff --git a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json index 666aded50..e65741b27 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json +++ b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json @@ -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" } } diff --git a/web-frontend/modules/core/assets/scss/components/dashboard/dashboard_sidebar.scss b/web-frontend/modules/core/assets/scss/components/dashboard/dashboard_sidebar.scss index a3b89d76c..4865eec15 100644 --- a/web-frontend/modules/core/assets/scss/components/dashboard/dashboard_sidebar.scss +++ b/web-frontend/modules/core/assets/scss/components/dashboard/dashboard_sidebar.scss @@ -2,4 +2,5 @@ background: #fff; padding: 16px 16px 14px; border-left: 1px solid $palette-neutral-200; + overflow-y: scroll; } diff --git a/web-frontend/modules/core/assets/scss/components/form.scss b/web-frontend/modules/core/assets/scss/components/form.scss index 9b34863f3..e12ce36b0 100644 --- a/web-frontend/modules/core/assets/scss/components/form.scss +++ b/web-frontend/modules/core/assets/scss/components/form.scss @@ -236,3 +236,9 @@ font-size: 13px; font-weight: 500; } + +.form-section__title-container { + display: flex; + flex-direction: row; + justify-content: space-between; +} diff --git a/web-frontend/modules/core/components/FormSection.vue b/web-frontend/modules/core/components/FormSection.vue index dd0c96fa5..ebad66b57 100644 --- a/web-frontend/modules/core/components/FormSection.vue +++ b/web-frontend/modules/core/components/FormSection.vue @@ -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>