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 #671 See merge request bramw/baserow!505
This commit is contained in:
commit
f92f15704e
14 changed files with 258 additions and 57 deletions
backend/src/baserow/contrib/database
changelog.mdpremium
backend
src/baserow_premium
tests/baserow_premium
web-frontend/modules/baserow_premium
web-frontend/modules/database
|
@ -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"]),
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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"
|
||||
|
|
19
premium/backend/src/baserow_premium/views/signals.py
Normal file
19
premium/backend/src/baserow_premium/views/signals.py
Normal 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",
|
||||
]
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue