1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-15 09:34:13 +00:00

Merge branch '671-cover-image-kanban' into 'develop'

Resolve "Add cover image to the Kanban view cards"

Closes 

See merge request 
This commit is contained in:
Petr Stribny 2022-01-12 15:14:08 +00:00
commit f92f15704e
14 changed files with 258 additions and 57 deletions
backend/src/baserow/contrib/database
api/views
views
changelog.md
premium
backend
web-frontend/modules/baserow_premium
web-frontend/modules/database

View file

@ -219,7 +219,11 @@ class ViewsView(APIView):
view_type_registry, ViewSerializer
),
400: get_error_schema(
["ERROR_USER_NOT_IN_GROUP", "ERROR_REQUEST_BODY_VALIDATION"]
[
"ERROR_USER_NOT_IN_GROUP",
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_FIELD_NOT_IN_TABLE",
]
),
404: get_error_schema(["ERROR_TABLE_DOES_NOT_EXIST"]),
},
@ -345,7 +349,11 @@ class ViewView(APIView):
view_type_registry, ViewSerializer
),
400: get_error_schema(
["ERROR_USER_NOT_IN_GROUP", "ERROR_REQUEST_BODY_VALIDATION"]
[
"ERROR_USER_NOT_IN_GROUP",
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_FIELD_NOT_IN_TABLE",
]
),
404: get_error_schema(["ERROR_VIEW_DOES_NOT_EXIST"]),
},

View file

@ -1,6 +1,6 @@
from django.dispatch import Signal, receiver
from baserow.contrib.database.fields.signals import field_deleted
from baserow.contrib.database.fields import signals as field_signals
from baserow.contrib.database.fields.models import FileField
from .models import GalleryView
@ -21,8 +21,8 @@ view_sort_deleted = Signal()
view_field_options_updated = Signal()
@receiver(field_deleted)
def field_updated(sender, field, **kwargs):
@receiver(field_signals.field_deleted)
def field_deleted(sender, field, **kwargs):
if isinstance(field, FileField):
GalleryView.objects.filter(card_cover_image_field_id=field.id).update(
card_cover_image_field_id=None

View file

@ -29,6 +29,7 @@
* Fix the ability to make filters and sorts on invalid formula and lookup fields.
* Fixed bug preventing trash cleanup job from running after a lookup field was converted
to another field type.
* Added cover field to the Kanban view.
## Released (2021-11-25)

View file

@ -37,3 +37,4 @@ class BaserowPremiumConfig(AppConfig):
# The signals must always be imported last because they use the registries
# which need to be filled first.
import baserow_premium.ws.signals # noqa: F403, F401
import baserow_premium.views.signals # noqa: F403, F401

View file

@ -0,0 +1,30 @@
# Generated by Django 3.2.6 on 2022-01-11 12:08
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("database", "0057_fix_invalid_type_filters_and_sorts"),
("baserow_premium", "0003_kanban_view"),
]
operations = [
migrations.AddField(
model_name="kanbanview",
name="card_cover_image_field",
field=models.ForeignKey(
blank=True,
help_text=(
"References a file field of which the first image"
"must be shown as card cover image."
),
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="kanban_view_card_cover_field",
to="database.filefield",
),
),
]

View file

@ -1,6 +1,6 @@
from django.db import models
from baserow.contrib.database.fields.models import Field, SingleSelectField
from baserow.contrib.database.fields.models import Field, FileField, SingleSelectField
from baserow.contrib.database.views.models import View
from baserow.contrib.database.mixins import ParentFieldTrashableModelMixin
@ -16,6 +16,15 @@ class KanbanView(View):
help_text="The single select field related to the options where rows should "
"be stacked by.",
)
card_cover_image_field = models.ForeignKey(
FileField,
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name="kanban_view_card_cover_field",
help_text="References a file field of which the first image must be shown as "
"card cover image.",
)
class Meta:
db_table = "database_kanbanview"

View file

@ -0,0 +1,19 @@
from django.dispatch import receiver
from baserow.contrib.database.fields import signals as field_signals
from baserow.contrib.database.fields.models import FileField
from .models import KanbanView
@receiver(field_signals.field_deleted)
def field_deleted(sender, field, **kwargs):
if isinstance(field, FileField):
KanbanView.objects.filter(card_cover_image_field_id=field.id).update(
card_cover_image_field_id=None
)
__all__ = [
"field_deleted",
]

View file

@ -2,7 +2,9 @@ from django.urls import path, include
from rest_framework.serializers import PrimaryKeyRelatedField
from baserow.contrib.database.fields.models import SingleSelectField
from baserow.contrib.database.fields.models import SingleSelectField, FileField
from baserow.contrib.database.fields.exceptions import FieldNotInTable
from baserow.contrib.database.api.fields.errors import ERROR_FIELD_NOT_IN_TABLE
from baserow.contrib.database.views.registries import ViewType
from baserow_premium.api.views.kanban.serializers import (
KanbanViewFieldOptionsSerializer,
@ -20,20 +22,29 @@ class KanbanViewType(ViewType):
model_class = KanbanView
field_options_model_class = KanbanViewFieldOptions
field_options_serializer_class = KanbanViewFieldOptionsSerializer
allowed_fields = ["single_select_field"]
serializer_field_names = ["single_select_field"]
allowed_fields = ["single_select_field", "card_cover_image_field"]
serializer_field_names = ["single_select_field", "card_cover_image_field"]
serializer_field_overrides = {
"single_select_field": PrimaryKeyRelatedField(
queryset=SingleSelectField.objects.all(),
required=False,
default=None,
allow_null=True,
)
),
"card_cover_image_field": PrimaryKeyRelatedField(
queryset=FileField.objects.all(),
required=False,
default=None,
allow_null=True,
help_text="References a file field of which the first image must be shown "
"as card cover image.",
),
}
api_exceptions_map = {
KanbanViewFieldDoesNotBelongToSameTable: (
ERROR_KANBAN_VIEW_FIELD_DOES_NOT_BELONG_TO_SAME_TABLE
),
FieldNotInTable: ERROR_FIELD_NOT_IN_TABLE,
}
def get_api_urls(self):
@ -46,10 +57,10 @@ class KanbanViewType(ViewType):
def prepare_values(self, values, table, user):
"""
Check if the provided single select option belongs to the same table.
Check if the provided card cover image field belongs to the same table.
"""
name = "single_select_field"
if name in values:
if isinstance(values[name], int):
values[name] = SingleSelectField.objects.get(pk=values[name])
@ -63,6 +74,20 @@ class KanbanViewType(ViewType):
"view's table."
)
name = "card_cover_image_field"
if name in values:
if isinstance(values[name], int):
values[name] = FileField.objects.get(pk=values[name])
if (
isinstance(values[name], FileField)
and values[name].table_id != table.id
):
raise FieldNotInTable(
"The provided file select field id does not belong to the kanban "
"view's table."
)
return values
def export_serialized(self, kanban, files_zip, storage):

View file

@ -427,13 +427,14 @@ def test_patch_kanban_view_field_options(api_client, premium_data_fixture):
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_create_kanban_view(api_client, premium_data_fixture):
def test_create_kanban_view(api_client, data_fixture, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
has_active_premium_license=True
)
table = premium_data_fixture.create_database_table(user=user)
single_select_field = premium_data_fixture.create_single_select_field(table=table)
single_select_field_2 = premium_data_fixture.create_single_select_field()
cover_image_file_field = data_fixture.create_file_field(table=table)
response = api_client.post(
reverse("api:database:views:list", kwargs={"table_id": table.id}),
@ -486,6 +487,7 @@ def test_create_kanban_view(api_client, premium_data_fixture):
"filter_type": "AND",
"filters_disabled": False,
"single_select_field": single_select_field.id,
"card_cover_image_field": cover_image_file_field.id,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
@ -497,12 +499,46 @@ def test_create_kanban_view(api_client, premium_data_fixture):
assert response_json["filter_type"] == "AND"
assert response_json["filters_disabled"] is False
assert response_json["single_select_field"] == single_select_field.id
assert response_json["card_cover_image_field"] == cover_image_file_field.id
kanban_view = KanbanView.objects.all().last()
assert kanban_view.id == response_json["id"]
assert kanban_view.single_select_field_id == single_select_field.id
@pytest.mark.django_db
def test_create_kanban_view_invalid_card_cover_image_field(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
text = data_fixture.create_text_field(table=table)
file_field = data_fixture.create_file_field()
response = api_client.post(
reverse("api:database:views:list", kwargs={"table_id": table.id}),
{"name": "Test 2", "type": "kanban", "card_cover_image_field": text.id},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
assert (
response_json["detail"]["card_cover_image_field"][0]["code"] == "does_not_exist"
)
response = api_client.post(
reverse("api:database:views:list", kwargs={"table_id": table.id}),
{"name": "Test 2", "type": "kanban", "card_cover_image_field": file_field.id},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_FIELD_NOT_IN_TABLE"
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_update_kanban_view(api_client, premium_data_fixture):
@ -560,3 +596,30 @@ def test_update_kanban_view(api_client, premium_data_fixture):
kanban_view.refresh_from_db()
assert kanban_view.single_select_field is None
@pytest.mark.django_db
def test_update_kanban_view_card_cover_image_field(
api_client, data_fixture, premium_data_fixture
):
user, token = premium_data_fixture.create_user_and_token(
has_active_premium_license=True
)
table = premium_data_fixture.create_database_table(user=user)
cover_image_file_field = data_fixture.create_file_field(table=table)
kanban_view = premium_data_fixture.create_kanban_view(
table=table, card_cover_image_field=None
)
response = api_client.patch(
reverse("api:database:views:item", kwargs={"view_id": kanban_view.id}),
{
"card_cover_image_field": cover_image_file_field.id,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["card_cover_image_field"] == cover_image_file_field.id

View file

@ -7,6 +7,8 @@ from django.core.files.storage import FileSystemStorage
from baserow.contrib.database.views.registries import view_type_registry
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.fields.handler import FieldHandler
from baserow_premium.views.exceptions import KanbanViewFieldDoesNotBelongToSameTable
from baserow_premium.views.models import KanbanViewFieldOptions
@ -146,3 +148,32 @@ def test_newly_created_view(premium_data_fixture):
.values_list("hidden", flat=True)
)
assert list(all_field_options) == [False, False, False, True]
@pytest.mark.django_db
def test_convert_card_cover_image_field_to_another(premium_data_fixture):
user = premium_data_fixture.create_user()
table = premium_data_fixture.create_database_table(user=user)
file_field = premium_data_fixture.create_file_field(table=table)
kanban_view = premium_data_fixture.create_kanban_view(
table=table, card_cover_image_field=file_field
)
FieldHandler().update_field(user=user, field=file_field, new_type_name="text")
kanban_view.refresh_from_db()
assert kanban_view.card_cover_image_field_id is None
@pytest.mark.django_db
def test_convert_card_cover_image_field_deleted(premium_data_fixture):
user = premium_data_fixture.create_user()
table = premium_data_fixture.create_database_table(user=user)
file_field = premium_data_fixture.create_file_field(table=table)
kanban_view = premium_data_fixture.create_kanban_view(
table=table, card_cover_image_field=file_field
)
FieldHandler().delete_field(user=user, field=file_field)
kanban_view.refresh_from_db()
assert kanban_view.card_cover_image_field_id is None

View file

@ -60,9 +60,12 @@
:fields="allFields"
:read-only="readOnly"
:field-options="fieldOptions"
:cover-image-field="view.card_cover_image_field"
:allow-cover-image-field="true"
@update-all-field-options="updateAllFieldOptions"
@update-field-options-of-field="updateFieldOptionsOfField"
@update-order="orderFieldOptions"
@update-cover-image-field="updateCoverImageField"
></ViewFieldsContext>
</li>
</ul>
@ -176,6 +179,16 @@ export default {
notifyIf(error, 'view')
}
},
async updateCoverImageField(value) {
try {
await this.$store.dispatch('view/update', {
view: this.view,
values: { card_cover_image_field: value },
})
} catch (error) {
notifyIf(error, 'view')
}
},
},
}
</script>

View file

@ -77,6 +77,7 @@
:key="'card-' + slot.id"
:fields="cardFields"
:row="slot.row"
:cover-image-field="coverImageField"
:style="{
transform: `translateY(${
slot.position * cardHeight + bufferTop
@ -203,7 +204,10 @@ export default {
*/
cardHeight() {
// 10 = margin-bottom of kanban.scss.kanban-view__stack-card
return getCardHeight(this.cardFields, null, this.$registry) + 10
return (
getCardHeight(this.cardFields, this.coverImageField, this.$registry) +
10
)
},
/**
* Figure out what the stack id that's used in the store is. The representation is
@ -221,6 +225,14 @@ export default {
this.id
)
},
coverImageField() {
const fieldId = this.view.card_cover_image_field
return (
[this.primary]
.concat(this.fields)
.find((field) => field.id === fieldId) || null
)
},
},
watch: {
cardHeight() {

View file

@ -170,37 +170,20 @@ export class KanbanViewType extends PremiumViewType {
)
}
_setSingleSelectFieldToNull({ rootGetters, dispatch }, field) {
rootGetters['view/getAll']
.filter((view) => view.type === this.type)
.forEach((view) => {
if (view.single_select_field === field.id) {
dispatch(
'view/forceUpdate',
{
view,
values: { single_select_field: null },
},
{ root: true }
)
}
})
}
fieldUpdated(context, field, oldField, fieldType, storePrefix) {
// If the field type has changed from a single select field to something else,
// it could be that there are kanban views that depending on that field. So we
// need to change to type to null if that's the case.
// Make sure that all Kanban views don't depend on fields that
// have been converted to another type
const type = SingleSelectFieldType.getType()
if (oldField.type === type && field.type !== type) {
this._setSingleSelectFieldToNull(context, field)
this._setFieldToNull(context, field, 'single_select_field')
this._setFieldToNull(context, field, 'card_cover_image_field')
}
}
fieldDeleted(context, field, fieldType, storePrefix = '') {
// We want to loop over all kanban views that we have in the store and check if
// they were depending on this deleted field. If that's case, we can set it to null
// because it doesn't exist anymore.
this._setSingleSelectFieldToNull(context, field)
// Make sure that all Kanban views don't depend on fields that
// have been deleted
this._setFieldToNull(context, field, 'single_select_field')
this._setFieldToNull(context, field, 'card_cover_image_field')
}
}

View file

@ -292,6 +292,29 @@ export class ViewType extends Registerable {
isDeactivated() {
return false
}
/**
* Helper function to set a field value to null for all
* views of the same type
* Used when fields are converted or deleted and should no
* longer be set
*/
_setFieldToNull({ rootGetters, dispatch }, field, fieldName) {
rootGetters['view/getAll']
.filter((view) => view.type === this.type)
.forEach((view) => {
if (view[fieldName] === field.id) {
dispatch(
'view/forceUpdate',
{
view,
values: { [fieldName]: null },
},
{ root: true }
)
}
})
}
}
export class GridViewType extends ViewType {
@ -700,30 +723,13 @@ export class GalleryViewType extends BaseBufferedRowView {
}
}
_setCardCoverImageFieldToNull({ rootGetters, dispatch }, field) {
rootGetters['view/getAll']
.filter((view) => view.type === this.type)
.forEach((view) => {
if (view.card_cover_image_field === field.id) {
dispatch(
'view/forceUpdate',
{
view,
values: { card_cover_image_field: null },
},
{ root: true }
)
}
})
}
fieldUpdated(context, field, oldField, fieldType, storePrefix) {
// If the field type has changed from a file field to something else, it could
// be that there are gallery views that depending on that field. So we need to
// change to type to null if that's the case.
const type = FileFieldType.getType()
if (oldField.type === type && field.type !== type) {
this._setCardCoverImageFieldToNull(context, field)
this._setFieldToNull(context, field, 'card_cover_image_field')
}
}
@ -731,7 +737,7 @@ export class GalleryViewType extends BaseBufferedRowView {
// We want to loop over all gallery views that we have in the store and check if
// they were depending on this deleted field. If that's case, we can set it to null
// because it doesn't exist anymore.
this._setCardCoverImageFieldToNull(context, field)
this._setFieldToNull(context, field, 'card_cover_image_field')
}
}