1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-01-11 19:38:20 +00:00

Resolve "Be able to delete a row in Calendar view"

This commit is contained in:
Przemyslaw Kukulski 2024-07-09 07:01:21 +00:00
parent 700294980b
commit 24395da96d
14 changed files with 2508 additions and 20 deletions
changelog/entries/unreleased/feature
premium/web-frontend
web-frontend/modules/core
assets/scss/components
components

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Add ability to delete row in calendar view",
"issue_number": 2722,
"bullet_points": [],
"created_at": "2024-07-05"
}

View file

@ -1,5 +1,9 @@
<template>
<div class="calendar-card" @click="$emit('edit-row', row)">
<div
class="calendar-card"
@click="$emit('edit-row', row)"
@contextmenu.prevent="$emit('row-context', { row, event: $event })"
>
<RecursiveWrapper
:components="
wrapperDecorations.map((comp) => ({

View file

@ -31,8 +31,7 @@
:read-only="readOnly"
:table="table"
:view="view"
@edit-row="$emit('edit-row', $event)"
@create-row="$emit('create-row', $event)"
v-on="$listeners"
>
</CalendarMonthDay>
</ol>

View file

@ -34,7 +34,7 @@
:fields="fields"
:store-prefix="storePrefix"
:decorations-by-place="decorationsByPlace"
@edit-row="$emit('edit-row', $event)"
v-on="$listeners"
>
</CalendarCard>
</div>
@ -57,7 +57,7 @@
:parent-width="width"
:parent-height="height"
:decorations-by-place="decorationsByPlace"
@edit-row="$emit('edit-row', $event)"
v-on="$listeners"
>
</CalendarMonthDayExpanded>
</li>
@ -146,10 +146,13 @@ export default {
window.removeEventListener('resize', this.updateVisibleRowsCount)
},
methods: {
getClientHeight() {
return this.$refs.calendarMonthDay.clientHeight
},
updateVisibleRowsCount() {
const itemHeight = 28
this.width = this.$refs.calendarMonthDay.clientWidth
this.height = this.$refs.calendarMonthDay.clientHeight
this.height = this.getClientHeight()
let currentHeightWithItems = 30 + 8
let count = 0
while (currentHeightWithItems + itemHeight < this.height - 16 - 8) {

View file

@ -35,10 +35,7 @@
:class="{ last: index == rows.length - 1 }"
:parent-width="contextWidth"
:decorations-by-place="decorationsByPlace"
@edit-row="
$emit('edit-row', $event)
$refs.context.hide()
"
v-on="$listeners"
>
</CalendarCard>
<div v-if="error" class="calendar-month-day-expanded__try-again">

View file

@ -10,6 +10,7 @@
:view="view"
@edit-row="openRowEditModal($event)"
@create-row="openCreateRowModal"
@row-context="showRowContext($event.event, $event.row)"
></CalendarMonth>
<RowCreateModal
ref="rowCreateModal"
@ -67,6 +68,36 @@
@navigate-previous="$emit('navigate-previous', $event)"
@navigate-next="$emit('navigate-next', $event)"
></RowEditModal>
<Context
ref="cardContext"
:overflow-scroll="true"
:hide-on-scroll="true"
:hide-on-resize="true"
:max-height-if-outside-viewport="true"
class="context__menu-wrapper"
>
<ul class="context__menu">
<li
v-if="
!readOnly &&
$hasPermission(
'database.table.delete_row',
table,
database.workspace.id
)
"
class="context__menu-item"
>
<a
class="context__menu-item-link js-ctx-delete-row"
@click="deleteRow(selectedRow)"
>
<i class="context__menu-item-icon iconoir-bin"></i>
{{ $t('gridView.deleteRow') }}
</a>
</li>
</ul>
</Context>
</div>
</template>
<script>
@ -125,6 +156,7 @@ export default {
data() {
return {
showHiddenFieldsInRowModal: false,
selectedRow: null,
}
},
computed: {
@ -255,6 +287,33 @@ export default {
}
this.$refs.rowCreateModal.show(defaults)
},
showRowContext(event, row) {
this.selectedRow = row
this.$refs.cardContext.toggleNextToMouse(event)
},
async deleteRow(row) {
try {
this.$refs.cardContext.hide()
await this.$store.dispatch(
this.storePrefix + 'view/calendar/deleteRow',
{
table: this.table,
view: this.view,
fields: this.fields,
row,
}
)
await this.$store.dispatch('toast/restore', {
trash_item_type: 'row',
parent_trash_item_id: this.table.id,
trash_item_id: row.id,
})
} catch (error) {
notifyIf(error, 'row')
}
},
},
}
</script>

View file

@ -52,12 +52,6 @@
@update-order="orderFieldOptions"
></ViewFieldsContext>
</li>
<li v-if="isDev" class="header__filter-item">
<div>
<Badge color="yellow" indicator>Debug</Badge>
<span>{{ timezone(fields) }}</span>
</div>
</li>
<li class="header__filter-item header__filter-item--right">
<ViewSearch
:view="view"

View file

@ -94,6 +94,9 @@ export const mutations = {
SET_LOADING_ROWS(state, loading) {
state.loadingRows = loading
},
SET_ROW_LOADING(state, { row, value }) {
Vue.set(row._, 'loading', value)
},
SET_ROW_SEARCH_MATCHES(state, { row, matchSearch }) {
row._.matchSearch = matchSearch
},
@ -554,6 +557,30 @@ export const actions = {
// or not because the count is for all the rows and not just the ones in the store.
commit('INCREASE_COUNT', { stackId })
},
/**
* Called when the user wants to delete an existing row in the table.
*/
async deleteRow({ commit, dispatch, getters }, { table, view, row, fields }) {
commit('SET_ROW_LOADING', { row, value: true })
try {
await dispatch('deletedExistingRow', {
view,
fields,
row,
})
await RowService(this.$client).delete(table.id, row.id)
} catch (error) {
await dispatch('createdNewRow', {
view,
values: row,
fields,
})
commit('SET_ROW_LOADING', { row, value: false })
throw error
}
},
/**
* Can be called when a row in the table has been deleted. This action will make
* sure that the state is updated accordingly.

View file

@ -0,0 +1,68 @@
export function createCalendarView(
mock,
application,
table,
{
viewId = 1,
filters = [],
sortings = [],
groupBys = [],
decorations = [],
publicView = false,
singleSelectFieldId = -1,
}
) {
const tableId = table.id
return {
id: viewId,
table_id: tableId,
name: `mock_calendar_${viewId}`,
order: 0,
type: 'calendar',
table: {
id: tableId,
name: table.name,
order: 1,
database_id: application.id,
},
filter_type: 'AND',
filters_disabled: false,
public: publicView,
row_identifier_type: 'id',
filters,
sortings,
group_bys: groupBys,
decorations,
filter_groups: [],
public_view_has_password: false,
show_logo: true,
ownership_type: 'collaborative',
owned_by_id: 20,
single_select_field: singleSelectFieldId,
card_cover_image_field: null,
slug: 'EzWmKdd6skBdTpyWy_uzJBufXwupO1gM3GFpgb7Ub0x',
_: {
type: {
type: 'calendar',
iconClass: 'baserow-icon-calendar',
colorClass: 'color-success',
name: 'Calendar',
canFilter: true,
canSort: false,
canShare: true,
canGroupBy: false,
},
selected: true,
loading: false,
focusFilter: null,
},
}
}
export function thereAreRowsInCalendarView(mock, fieldOptions, rows) {
mock.onGet(/views\/calendar\/.+/).reply(200, {
field_options: fieldOptions,
rows_metadata: {},
rows,
})
}

View file

@ -7,6 +7,7 @@ import {
} from './user'
import { thereAreComments } from './comments'
import { createKanbanView, thereAreRowsInKanbanView } from './kanban'
import { createCalendarView, thereAreRowsInCalendarView } from './calendar'
import { MockServer } from '@baserow/test/fixtures/mockServer'
export default class MockPremiumServer extends MockServer {
@ -32,6 +33,28 @@ export default class MockPremiumServer extends MockServer {
})
}
createCalendarView(
application,
table,
{
filters = [],
sortings = [],
groupBys = [],
decorations = [],
singleSelectFieldId = -1,
...rest
}
) {
return createCalendarView(this.mock, application, table, {
filters,
sortings,
groupBys,
decorations,
singleSelectFieldId,
...rest,
})
}
thereAreUsers(users, page, options = {}) {
createUsersForAdmin(this.mock, users, page, options)
}
@ -40,6 +63,10 @@ export default class MockPremiumServer extends MockServer {
thereAreRowsInKanbanView(this.mock, fieldOptions, rows)
}
thereAreRowsInCalendarView(fieldOptions, rows) {
thereAreRowsInCalendarView(this.mock, fieldOptions, rows)
}
thereAreComments(comments, tableId, rowId) {
thereAreComments(this.mock, comments, tableId, rowId)
}

View file

@ -0,0 +1,264 @@
import { PremiumTestApp } from '@baserow_premium_test/helpers/premiumTestApp'
import CalendarView from '@baserow_premium/components/views/calendar/CalendarView.vue'
import CalendarMonthDay from '@baserow_premium/components/views/calendar/CalendarMonthDay.vue'
import CalendarCard from '@baserow_premium/components/views/calendar/CalendarCard.vue'
describe('CalendarView component', () => {
let testApp = null
let mockServer = null
let store = null
const originalDateNow = Date.now
beforeAll(() => {
testApp = new PremiumTestApp(null)
testApp.giveCurrentUserGlobalPremiumFeatures()
store = testApp.store
mockServer = testApp.mockServer
})
afterEach((done) => {
testApp.afterEach().then(done)
Date.now = originalDateNow
})
const mountComponent = (props, slots = {}) => {
return testApp.mount(CalendarView, { propsData: props, slots })
}
const primary = {
id: 1,
name: 'Name',
order: 0,
type: 'text',
primary: true,
read_only: false,
text_default: '',
_: {
type: {
type: 'text',
iconClass: 'iconoir-text',
name: 'Single line text',
isReadOnly: false,
canImport: true,
},
loading: false,
},
}
const dateField = {
id: 2,
name: 'Date',
order: 1,
type: 'date',
primary: false,
read_only: false,
description: null,
date_format: 'EU',
date_include_time: false,
date_time_format: '24',
date_show_tzinfo: false,
date_force_timezone: null,
}
const fieldData = [primary, dateField]
const rows = [
{ id: 2, order: '2.00', field_1: '1nd field text', field_2: '2024-07-01' },
{ id: 4, order: '2.50', field_1: '3th field text', field_2: '2024-07-04' },
{ id: 3, order: '3.00', field_1: '2rd field text', field_2: '2024-07-04' },
]
const populateStore = async () => {
const table = mockServer.createTable()
const { application } = await mockServer.createAppAndWorkspace(table)
const view = mockServer.createCalendarView(application, table, {
singleSelectFieldId: 2,
})
const fields = mockServer.createFields(application, table, fieldData)
const calendarRecords = {
'2024-07-01': { count: 1, results: [rows[0]] },
'2024-07-04': { count: 2, results: [rows[1], rows[2]] },
}
mockServer.thereAreRowsInCalendarView(
{ 2: { hidden: false, order: 1 } },
calendarRecords
)
store.commit('page/view/calendar/SET_SELECTED_DATE', new Date('2024-07-01'))
store.commit('page/view/calendar/SET_DATE_FIELD_ID', dateField.id)
await store.dispatch('page/view/calendar/fetchInitial', { fields })
return { table, fields, view, application }
}
test('CalendarView allows deleting row with context menu', async () => {
const { table, fields, view, application } = await populateStore()
// CalendarMonthDay can't set properly clientHeight, and it's always 0
// so this mock will overwrite clientHeight
CalendarMonthDay.methods.getClientHeight = jest.fn().mockReturnValue(5000)
Date.now = jest.fn(() => new Date('2024-07-05T12:00:00.000Z'))
const wrapper = await mountComponent({
view,
database: {
id: application.id,
name: 'testing db',
order: 1,
group: {
id: 210,
name: "test's workspace",
generative_ai_models_enabled: {},
},
workspace: application.workspace,
tables: [table],
type: 'database',
_: {
type: {
type: 'database',
iconClass: 'iconoir-db',
name: 'Database',
hasSidebarComponent: true,
},
loading: false,
selected: true,
},
},
table,
fields,
readOnly: false,
storePrefix: 'page/',
loading: false,
})
expect(wrapper.element).toMatchSnapshot()
const mockEventHandler = jest.spyOn(wrapper.vm, 'showRowContext')
const mockDeleteRowHandler = jest.spyOn(wrapper.vm, 'deleteRow')
const calendarCardWrapper = wrapper.findComponent(CalendarCard)
const mockEvent = { preventDefault: jest.fn() }
calendarCardWrapper.trigger('contextmenu', {
row: rows[0],
event: mockEvent,
})
await wrapper.vm.$nextTick()
expect(mockEventHandler).toHaveBeenCalled()
expect(mockEventHandler.mock.calls[0][0].row).toEqual(rows[0])
expect(
store.getters['page/view/calendar/getDateStack']('2024-07-01').count
).toBe(1)
mockServer.deleteGridRow(table.id, rows[0].id)
const ctx = wrapper.find('.js-ctx-delete-row')
ctx.trigger('click')
await wrapper.vm.$nextTick()
expect(mockDeleteRowHandler).toHaveBeenCalled()
//
const expectedRow = {
...rows[0],
_: {
metadata: {},
matchSearch: true,
fieldSearchMatches: [],
loading: true,
},
}
expect(mockDeleteRowHandler.mock.calls[0][0]).toEqual(expectedRow)
await wrapper.vm.$nextTick()
expect(
store.getters['page/view/calendar/getDateStack']('2024-07-01').count
).toBe(0)
})
test('CalendarView row is restored when server fails to delete it', async () => {
const { table, fields, view, application } = await populateStore()
// CalendarMonthDay can't set properly clientHeight, and it's always 0
// so this mock will overwrite clientHeight
CalendarMonthDay.methods.getClientHeight = jest.fn().mockReturnValue(5000)
Date.now = jest.fn(() => new Date('2024-07-05T12:00:00.000Z'))
const wrapper = await mountComponent({
view,
database: {
id: application.id,
name: 'testing db',
order: 1,
group: {
id: 210,
name: "test's workspace",
generative_ai_models_enabled: {},
},
workspace: application.workspace,
tables: [table],
type: 'database',
_: {
type: {
type: 'database',
iconClass: 'iconoir-db',
name: 'Database',
hasSidebarComponent: true,
},
loading: false,
selected: true,
},
},
table,
fields,
readOnly: false,
storePrefix: 'page/',
loading: false,
})
expect(wrapper.element).toMatchSnapshot()
const mockEventHandler = jest.spyOn(wrapper.vm, 'showRowContext')
const mockDeleteRowHandler = jest.spyOn(wrapper.vm, 'deleteRow')
const calendarCardWrapper = wrapper.findComponent(CalendarCard)
const mockEvent = { preventDefault: jest.fn() }
calendarCardWrapper.trigger('contextmenu', {
row: rows[0],
event: mockEvent,
})
await wrapper.vm.$nextTick()
expect(mockEventHandler).toHaveBeenCalled()
expect(mockEventHandler.mock.calls[0][0].row).toEqual(rows[0])
expect(
store.getters['page/view/calendar/getDateStack']('2024-07-01').count
).toBe(1)
testApp.dontFailOnErrorResponses()
mockServer.deleteGridRow(table.id, rows[0].id)
const ctx = wrapper.find('.js-ctx-delete-row')
ctx.trigger('click')
await wrapper.vm.$nextTick()
expect(mockDeleteRowHandler).toHaveBeenCalled()
const expectedRow = {
...rows[0],
_: {
metadata: {},
matchSearch: true,
fieldSearchMatches: [],
loading: true,
},
}
expect(mockDeleteRowHandler.mock.calls[0][0]).toEqual(expectedRow)
await wrapper.vm.$nextTick()
expect(
store.getters['page/view/calendar/getDateStack']('2024-07-01').count
).toBe(0)
})
})

View file

@ -134,6 +134,10 @@
}
}
.context__menu-wrapper {
z-index: $z-index-highlight-overlay;
}
.context__menu {
list-style: none;
padding: 0;

View file

@ -29,6 +29,16 @@ export default {
default: true,
required: false,
},
hideOnScroll: {
type: Boolean,
default: false,
required: false,
},
hideOnResize: {
type: Boolean,
default: false,
required: false,
},
overflowScroll: {
type: Boolean,
default: false,
@ -189,9 +199,11 @@ export default {
})
this.$el.updatePositionViaScrollEvent = (event) => {
// The context menu itself can have a scrollbar, and resizing everytime you
// scroll internally doesn't make sense because it can't influence the position.
if (
if (this.hideOnScroll) {
this.hide()
} else if (
// The context menu itself can have a scrollbar, and resizing everytime you
// scroll internally doesn't make sense because it can't influence the position.
!isElement(this.$el, event.target) &&
// If the scroll was not inside one of the context children of this context
// menu.
@ -209,7 +221,11 @@ export default {
)
this.$el.updatePositionViaResizeEvent = () => {
updatePosition()
if (this.hideOnResize) {
this.hide()
} else {
updatePosition()
}
}
window.addEventListener('resize', this.$el.updatePositionViaResizeEvent)