1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-17 18:32:35 +00:00

Resolve "Show related row by clicking on a link row relationship"

This commit is contained in:
Bram Wiepjes 2022-04-26 13:06:33 +00:00
parent ee3d1161b3
commit fa75fbbd0d
14 changed files with 562 additions and 73 deletions

View file

@ -16,6 +16,8 @@
* Added `is days ago` filter to date field.
* Fixed a bug that made it possible to delete created on/modified by fields on the web frontend.
* Allow the setting of max request page size via environment variable.
* Introduced read only lookup of foreign row by clicking on a link row relationship in
the grid view row modal.
* Boolean field converts the word `checked` to `True` value.
* Fixed a bug where the backend would fail hard updating token permissions for deleted tables.
* Fixed the unchecked percent aggregation calculation

View file

@ -24,12 +24,23 @@
@extend %ellipsis;
max-width: 200px;
color: $color-primary-900;
&:hover {
text-decoration: none;
}
&.field-link-row__name--unnamed {
color: $color-neutral-600;
}
}
.field-link-row__loading {
margin: 5px 0 0 4px;
@include loading(12px);
}
.field-link-row__remove {
color: $color-primary-900;
margin-left: 5px;

View file

@ -29,9 +29,14 @@
background-color: $color-neutral-100;
border-radius: 3px;
display: flex;
color: $color-primary-900;
@include fixed-height(22px, 13px);
&:hover {
text-decoration: none;
}
.grid-field-many-to-many__cell.active & {
background-color: $color-primary-100;
@ -65,6 +70,12 @@
max-width: 140px;
}
.grid-field-many-to-many__loading {
margin: 5px 0 0 4px;
@include loading(12px);
}
.grid-field-many-to-many__remove {
display: none;
color: $color-primary-900;

View file

@ -0,0 +1,93 @@
<template>
<RowEditModal
ref="modal"
:read-only="true"
:table="table"
:rows="[]"
:fields="fields"
:primary="primary"
@hidden="$emit('hidden', $event)"
></RowEditModal>
</template>
<script>
import { DatabaseApplicationType } from '@baserow/modules/database/applicationTypes'
import RowEditModal from '@baserow/modules/database/components/row/RowEditModal'
import FieldService from '@baserow/modules/database/services/field'
import RowService from '@baserow/modules/database/services/row'
import { populateField } from '@baserow/modules/database/store/field'
/**
* This component can open the row edit modal having the fields of that table in the
* fields store. It will make a request to the backend fetching the missing
* information.
*/
export default {
name: 'ForeignRowEditModal',
components: { RowEditModal },
props: {
tableId: {
type: Number,
required: true,
},
},
data() {
return {
fetchedTableAndFields: false,
table: {},
fields: [],
primary: {},
}
},
methods: {
async fetchTableAndFields() {
// Find the table in the applications to prevent a request to the backend and to
// maintain reactivity with the real time updates.
const databaseType = DatabaseApplicationType.getType()
for (const application of this.$store.getters['application/getAll']) {
if (application.type !== databaseType) {
continue
}
const foundTable = application.tables.find(
({ id }) => id === this.tableId
)
if (foundTable) {
this.table = foundTable
break
}
}
// Because we don't have the fields in the store we need to fetch those for this
// table.
const { data: fieldData } = await FieldService(this.$client).fetchAll(
this.tableId
)
fieldData.forEach((part, index) => {
populateField(fieldData[index], this.$registry)
})
const primaryIndex = fieldData.findIndex((item) => item.primary === true)
this.primary =
primaryIndex !== -1 ? fieldData.splice(primaryIndex, 1)[0] : null
this.fields = fieldData
// Mark the table and fields as fetched, so that we don't have to do that a
// second time when the user opens another row.
this.fetchedTableAndFields = true
},
async show(rowId) {
if (!this.fetchedTableAndFields) {
await this.fetchTableAndFields()
}
const { data: rowData } = await RowService(this.$client).get(
this.tableId,
rowId
)
this.$refs.modal.show(rowData.id, rowData)
},
},
}
</script>

View file

@ -2,19 +2,25 @@
<div class="control__elements">
<ul class="field-link-row__items">
<li v-for="item in value" :key="item.id" class="field-link-row__item">
<span
<component
:is="readOnly ? 'span' : 'a'"
class="field-link-row__name"
:class="{
'field-link-row__name--unnamed':
item.value === null || item.value === '',
}"
@click.prevent="showForeignRowModal(item)"
>
{{ item.value || 'unnamed row ' + item.id }}
</span>
</component>
<span
v-if="itemLoadingId === item.id"
class="field-link-row__loading"
></span>
<a
v-if="!readOnly"
v-else-if="!readOnly"
class="field-link-row__remove"
@click.prevent="removeValue($event, value, item.id)"
@click.prevent.stop="removeValue($event, value, item.id)"
>
<i class="fas fa-times"></i>
</a>
@ -34,6 +40,11 @@
:value="value"
@selected="addValue(value, $event)"
></SelectRowModal>
<ForeignRowEditModal
ref="rowEditModal"
:table-id="field.link_row_table"
@hidden="modalOpen = false"
></ForeignRowEditModal>
</div>
</template>
@ -41,10 +52,17 @@
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
import linkRowField from '@baserow/modules/database/mixins/linkRowField'
import SelectRowModal from '@baserow/modules/database/components/row/SelectRowModal'
import ForeignRowEditModal from '@baserow/modules/database/components/row/ForeignRowEditModal'
import { notifyIf } from '@baserow/modules/core/utils/error'
export default {
components: { SelectRowModal },
components: { SelectRowModal, ForeignRowEditModal },
mixins: [rowEditField, linkRowField],
data() {
return {
itemLoadingId: -1,
}
},
methods: {
removeValue(...args) {
linkRowField.methods.removeValue.call(this, ...args)
@ -54,6 +72,19 @@ export default {
linkRowField.methods.addValue.call(this, ...args)
this.touch()
},
async showForeignRowModal(item) {
if (this.readOnly) {
return
}
this.itemLoadingId = item.id
try {
await this.$refs.rowEditModal.show(item.id)
} catch (error) {
notifyIf(error)
}
this.itemLoadingId = -1
},
},
}
</script>

View file

@ -50,8 +50,6 @@
</template>
<script>
import { mapGetters } from 'vuex'
import modal from '@baserow/modules/core/mixins/modal'
import RowEditModalField from '@baserow/modules/database/components/row/RowEditModalField'
import CreateFieldContext from '@baserow/modules/database/components/field/CreateFieldContext'
@ -94,17 +92,24 @@ export default {
}
},
computed: {
...mapGetters({
rowId: 'rowModal/id',
rowExists: 'rowModal/exists',
row: 'rowModal/row',
}),
modalRow() {
return this.$store.getters['rowModal/get'](this._uid)
},
rowId() {
return this.modalRow.id
},
rowExists() {
return this.modalRow.exists
},
row() {
return this.modalRow.row
},
},
watch: {
/**
* It could happen that the view doesn't always have all the rows buffered. When
* the modal is opened, it will find the correct row by looking through all the
* rows of the view. If a filter changes, the existing row could be removed the
* rows of the view. If a filter changes, the existing row could be removed from the
* buffer while the user still wants to edit the row because the modal is open. In
* that case, we will keep a copy in the `rowModal` store, which will also listen
* for real time update events to make sure the latest information is always
@ -114,28 +119,38 @@ export default {
rows(value) {
const row = value.find((r) => r !== null && r.id === this.rowId)
if (row === undefined && this.rowExists) {
this.$store.dispatch('rowModal/doesNotExist')
this.$store.dispatch('rowModal/doesNotExist', {
componentId: this._uid,
})
} else if (row !== undefined && !this.rowExists) {
this.$store.dispatch('rowModal/doesExist', { row })
this.$store.dispatch('rowModal/doesExist', {
componentId: this._uid,
row,
})
} else if (row !== undefined) {
// If the row already exists and it has changed, we need to replace it,
// otherwise we might loose reactivity.
this.$store.dispatch('rowModal/replace', { row })
this.$store.dispatch('rowModal/replace', {
componentId: this._uid,
row,
})
}
},
},
methods: {
show(rowId, ...args) {
show(rowId, rowFallback = {}, ...args) {
const row = this.rows.find((r) => r !== null && r.id === rowId)
this.$store.dispatch('rowModal/open', {
tableId: this.table.id,
componentId: this._uid,
id: rowId,
row: row || {},
row: row || rowFallback,
exists: !!row,
})
this.getRootModal().show(...args)
},
hide(...args) {
this.$store.dispatch('rowModal/clear')
this.$store.dispatch('rowModal/clear', { componentId: this._uid })
this.getRootModal().hide(...args)
},
/**

View file

@ -52,6 +52,7 @@
:field="props.field"
:value="props.row['field_' + props.field.id]"
:selected="parent.isCellSelected(props.field.id)"
:store-prefix="props.storePrefix"
:read-only="props.readOnly"
@update="(...args) => $options.methods.update(listeners, props, ...args)"
@edit="(...args) => $options.methods.edit(listeners, props, ...args)"

View file

@ -76,10 +76,10 @@
</div>
</template>
<!--
Somehow re-declaring all the events instead of using v-on="$listeners" speeds
everything up because the rows don't need to be updated everytime a new one is
rendered, which happens a lot when scrolling.
-->
Somehow re-declaring all the events instead of using v-on="$listeners" speeds
everything up because the rows don't need to be updated everytime a new one is
rendered, which happens a lot when scrolling.
-->
<GridViewCell
v-for="field in fieldsToRender"
:key="'row-field-' + row.id.toString() + '-' + field.id.toString()"
@ -88,6 +88,7 @@
:state="state"
:multi-select-position="getMultiSelectPosition(row.id, field)"
:read-only="readOnly"
:store-prefix="storePrefix"
:style="{
width: fieldWidths[field.id] + 'px',
...getSelectedCellStyle(field),

View file

@ -1,10 +1,12 @@
<template>
<div class="grid-view__cell grid-field-many-to-many__cell active">
<div class="grid-field-many-to-many__list">
<div
<component
:is="publicGrid || readOnly ? 'span' : 'a'"
v-for="item in value"
:key="item.id"
class="grid-field-many-to-many__item"
@click.prevent="showForeignRowModal(item)"
>
<span
class="grid-field-many-to-many__name"
@ -17,14 +19,18 @@
item.value || $t('gridViewFieldLinkRow.unnamed', { value: item.id })
}}
</span>
<span
v-if="itemLoadingId === item.id"
class="grid-field-many-to-many__loading"
></span>
<a
v-if="!readOnly"
v-else-if="!readOnly"
class="grid-field-many-to-many__remove"
@click.prevent="removeValue($event, value, item.id)"
@click.prevent.stop="removeValue($event, value, item.id)"
>
<i class="fas fa-times"></i>
</a>
</div>
</component>
<a
v-if="!readOnly"
class="
@ -42,22 +48,40 @@
@selected="addValue(value, $event)"
@hidden="hideModal"
></SelectRowModal>
<ForeignRowEditModal
ref="rowEditModal"
:table-id="field.link_row_table"
@hidden="hideModal"
></ForeignRowEditModal>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { isElement } from '@baserow/modules/core/utils/dom'
import gridField from '@baserow/modules/database/mixins/gridField'
import linkRowField from '@baserow/modules/database/mixins/linkRowField'
import SelectRowModal from '@baserow/modules/database/components/row/SelectRowModal'
import ForeignRowEditModal from '@baserow/modules/database/components/row/ForeignRowEditModal'
import { notifyIf } from '@baserow/modules/core/utils/error'
export default {
name: 'GridViewFieldLinkRow',
components: { SelectRowModal },
components: { ForeignRowEditModal, SelectRowModal },
mixins: [gridField, linkRowField],
data() {
return {
modalOpen: false,
itemLoadingId: -1,
}
},
beforeCreate() {
this.$options.computed = {
...(this.$options.computed || {}),
...mapGetters({
publicGrid: this.$options.propsData.storePrefix + 'view/grid/isPublic',
}),
}
},
methods: {
@ -81,7 +105,10 @@ export default {
* inside one of these contexts.
*/
canUnselectByClickingOutside(event) {
return !isElement(this.$refs.selectModal.$el, event.target)
return (
!isElement(this.$refs.selectModal.$el, event.target) &&
!isElement(this.$refs.rowEditModal.$refs.modal.$el, event.target)
)
},
/**
* Prevent unselecting the field cell by changing the event. Because the deleted
@ -107,7 +134,7 @@ export default {
* While the modal is open, all key combinations related to the field must be
* ignored.
*/
canKeyDown(event) {
canKeyDown() {
return !this.modalOpen
},
canPaste() {
@ -119,6 +146,22 @@ export default {
canEmpty() {
return !this.modalOpen
},
async showForeignRowModal(item) {
// It's not possible to open the related row when the view is shared publicly
// because the visitor doesn't have the right permissions.
if (this.publicGrid || this.readOnly) {
return
}
this.itemLoadingId = item.id
try {
await this.$refs.rowEditModal.show(item.id)
this.modalOpen = true
} catch (error) {
notifyIf(error)
}
this.itemLoadingId = -1
},
},
}
</script>

View file

@ -21,6 +21,10 @@ export default {
type: Boolean,
required: true,
},
storePrefix: {
type: String,
required: true,
},
},
data() {
return {

View file

@ -180,7 +180,10 @@ export const registerRealtimeEvents = (realtime) => {
)
}
store.dispatch('rowModal/updated', { values: data.row })
store.dispatch('rowModal/updated', {
tableId: data.table_id,
values: data.row,
})
})
realtime.registerEvent('rows_updated', async (context, data) => {

View file

@ -1,5 +1,8 @@
export default (client) => {
return {
get(tableId, rowId) {
return client.get(`/database/rows/table/${tableId}/${rowId}/`)
},
fetchAll({ tableId, page = 1, size = 10, search = null }) {
const config = {
params: {

View file

@ -1,73 +1,123 @@
import Vue from 'vue'
/**
* This store exists to always keep a copy of the row that's being edited via the
* row edit modal. It sometimes happen that row from the original source, where it was
* reactive with doesn't exist anymore. To make sure the modal still works in that
* case, we always store a copy here and if it doesn't exist in the original data
* source it accepts real time updates.
* source it accepts real time updates. This store can handle multiple row edit
* modals being open because the rows are divided by the unique component id.
*/
export const state = () => ({
id: -1,
exists: false,
row: {},
// The key of the rows property is the unique component id indicating to which row
// edit modal the entry is related to. The value looks like:
// {
// tableId: -1,
// // row id
// id: -1,
// // Indicates whether the row exists in the `rows` property in the row edit modal.
// exists: true,
// // The values of the row.
// row: {}
// }
rows: {},
})
export const mutations = {
CLEAR(state) {
state.id = -1
state.exists = false
state.row = {}
CLEAR(state, componentId) {
delete state.rows[componentId]
},
OPEN(state, { id, exists, row }) {
state.id = id
state.exists = exists
state.row = row
OPEN(state, { componentId, tableId, id, exists, row }) {
state.rows = {
...state.rows,
...{
[componentId]: {
tableId,
id,
exists,
row,
},
},
}
},
SET_EXISTS(state, value) {
state.exists = value
SET_EXISTS(state, { componentId, value }) {
state.rows[componentId] = {
...state.rows[componentId],
...{ exists: value },
}
},
REPLACE_ROW(state, row) {
Vue.set(state, 'row', row)
REPLACE_ROW(state, { componentId, row }) {
state.rows[componentId] = {
...state.rows[componentId],
...{ row },
}
},
UPDATE_ROW(state, row) {
Object.assign(state.row, row)
UPDATE_ROW(state, { componentId, row }) {
Object.assign(state.rows[componentId].row, row)
},
}
export const actions = {
clear({ commit }) {
commit('CLEAR')
clear({ commit }, { componentId }) {
commit('CLEAR', componentId)
},
open({ commit }, { id, exists, row }) {
commit('OPEN', { id, exists, row })
/**
* Is called when the row edit modal is being opened. It will register the row
* values in this store so that it can also receive real time updates if it's
* managed by the `rows` prop in the row edit modal.
*/
open({ commit }, { componentId, tableId, id, exists, row }) {
commit('OPEN', { componentId, tableId, id, exists, row })
},
doesNotExist({ commit }) {
commit('SET_EXISTS', false)
/**
* Marking the row as does not exist makes it managed by this store instead of the
* provided rows. This will make sure that it accepts real time update events.
*/
doesNotExist({ commit }, { componentId }) {
commit('SET_EXISTS', { componentId, value: false })
},
doesExist({ commit }, { row }) {
commit('SET_EXISTS', true)
commit('REPLACE_ROW', row)
doesExist({ commit }, { componentId, row }) {
commit('SET_EXISTS', { componentId, value: true })
commit('REPLACE_ROW', { componentId, row })
},
replace({ commit }, { row }) {
commit('REPLACE_ROW', row)
replace({ commit }, { componentId, row }) {
commit('REPLACE_ROW', { componentId, row })
},
updated({ commit, getters }, { values }) {
if (values.id === getters.id && !getters.exists) {
commit('UPDATE_ROW', values)
}
/**
* Called when we receive a real time row update event. It loops over all the rows
* we have in memory here and checks if the updated row exists and if it's not
* managed by the `rows` prop in the row edit modal. If so, it will make the
* update. If the row is managed by the `rows` prop we don't have to do the update
* because it will be done via `rows` property.
*/
updated({ commit, getters }, { tableId, values }) {
const rows = getters.getRows
Object.keys(rows).forEach((key) => {
const value = rows[key]
if (
value.tableId === tableId &&
value.id === values.id &&
!value.exists
) {
commit('UPDATE_ROW', { componentId: key, row: values })
}
})
},
}
export const getters = {
id: (state) => {
return state.id
getRows(state) {
return state.rows
},
exists: (state) => {
return state.exists
},
row: (state) => {
return state.row
get: (state) => (componentId) => {
if (!Object.prototype.hasOwnProperty.call(state.rows, componentId)) {
return {
id: -1,
tableId: -1,
exists: false,
row: {},
}
}
return state.rows[componentId]
},
}

View file

@ -0,0 +1,221 @@
import rowModal from '@baserow/modules/database/store/rowModal'
import { TestApp } from '@baserow/test/helpers/testApp'
describe('rowModal store', () => {
let testApp = null
let store = null
beforeEach(() => {
testApp = new TestApp()
store = testApp.store
})
afterEach(() => {
testApp.afterEach()
})
test('get not existing component id', () => {
const testStore = rowModal
const state = Object.assign(testStore.state(), {})
testStore.state = () => state
store.registerModule('test', testStore)
const values = store.getters['test/get'](-1)
expect(values).toMatchObject({
id: -1,
tableId: -1,
exists: false,
row: {},
})
})
test('open row', async () => {
const testStore = rowModal
const state = Object.assign(testStore.state(), {})
testStore.state = () => state
store.registerModule('test', testStore)
await store.dispatch('test/open', {
componentId: 1,
tableId: 10,
id: 100,
exists: true,
row: { id: 100, field_1: 'Test' },
})
await store.dispatch('test/open', {
componentId: 2,
tableId: 20,
id: 200,
exists: true,
row: { id: 200, field_2: 'Test' },
})
const valuesOfComponent1 = store.getters['test/get'](1)
expect(valuesOfComponent1).toMatchObject({
tableId: 10,
id: 100,
exists: true,
row: { id: 100, field_1: 'Test' },
})
const valuesOfComponent2 = store.getters['test/get'](2)
expect(valuesOfComponent2).toMatchObject({
tableId: 20,
id: 200,
exists: true,
row: { id: 200, field_2: 'Test' },
})
})
test('open row', async () => {
const testStore = rowModal
const state = Object.assign(testStore.state(), {})
testStore.state = () => state
store.registerModule('test', testStore)
await store.dispatch('test/open', {
componentId: 1,
tableId: 10,
id: 100,
exists: true,
row: { id: 100, field_1: 'Test' },
})
await store.dispatch('test/open', {
componentId: 2,
tableId: 20,
id: 200,
exists: true,
row: { id: 200, field_2: 'Test' },
})
const valuesOfComponent1 = store.getters['test/get'](1)
expect(valuesOfComponent1).toMatchObject({
tableId: 10,
id: 100,
exists: true,
row: { id: 100, field_1: 'Test' },
})
const valuesOfComponent2 = store.getters['test/get'](2)
expect(valuesOfComponent2).toMatchObject({
tableId: 20,
id: 200,
exists: true,
row: { id: 200, field_2: 'Test' },
})
})
test('clear row', async () => {
const testStore = rowModal
const state = Object.assign(testStore.state(), {})
testStore.state = () => state
store.registerModule('test', testStore)
await store.dispatch('test/open', {
componentId: 1,
tableId: 10,
id: 100,
exists: true,
row: { id: 100, field_1: 'Test' },
})
await store.dispatch('test/clear', {
componentId: 1,
})
const valuesOfComponent1 = store.getters['test/get'](1)
expect(valuesOfComponent1).toMatchObject({
id: -1,
tableId: -1,
exists: false,
row: {},
})
// Clearing a component id that doesn't exist shouldn't fail.
await store.dispatch('test/clear', {
componentId: 2,
})
})
test('row does not exist', async () => {
const testStore = rowModal
const state = Object.assign(testStore.state(), {})
testStore.state = () => state
store.registerModule('test', testStore)
await store.dispatch('test/open', {
componentId: 1,
tableId: 10,
id: 100,
exists: true,
row: { id: 100, field_1: 'Test' },
})
await store.dispatch('test/doesNotExist', {
componentId: 1,
})
const valuesOfComponent1 = store.getters['test/get'](1)
expect(valuesOfComponent1.exists).toBe(false)
})
test('row exists', async () => {
const testStore = rowModal
const state = Object.assign(testStore.state(), {})
testStore.state = () => state
store.registerModule('test', testStore)
await store.dispatch('test/open', {
componentId: 1,
tableId: 10,
id: 100,
exists: false,
row: { id: 100, field_1: 'Test' },
})
const row = { id: 100, field_1: 'Test' }
await store.dispatch('test/doesExist', {
componentId: 1,
row,
})
// Changing this row object should be reflected in the row in the store because
// it's the same object.
row.field_1 = 'Test 2'
const valuesOfComponent1 = store.getters['test/get'](1)
expect(valuesOfComponent1.row.field_1).toBe('Test 2')
})
test('row exists', async () => {
const testStore = rowModal
const state = Object.assign(testStore.state(), {})
testStore.state = () => state
store.registerModule('test', testStore)
await store.dispatch('test/open', {
componentId: 1,
tableId: 10,
id: 100,
exists: true,
row: { id: 100, field_1: 'Test' },
})
// Because `exists` is true, the row shouldn't be updated because it's managed
// via another process.
await store.dispatch('test/updated', {
tableId: 10,
values: { id: 100, field_1: 'Test 2' },
})
let valuesOfComponent1 = store.getters['test/get'](1)
expect(valuesOfComponent1.row.field_1).toBe('Test')
// Because `exists` is false, the row is managed by the store, so when an update
// action is dispatched it should update the row.
await store.dispatch('test/doesNotExist', { componentId: 1 })
await store.dispatch('test/updated', {
tableId: 10,
values: { id: 100, field_1: 'Test 2' },
})
valuesOfComponent1 = store.getters['test/get'](1)
expect(valuesOfComponent1.row.field_1).toBe('Test 2')
// Because the table id doesn't match, we don't expect the value to be updated.
await store.dispatch('test/updated', {
tableId: 11,
values: { id: 100, field_1: 'Test 3' },
})
valuesOfComponent1 = store.getters['test/get'](1)
expect(valuesOfComponent1.row.field_1).toBe('Test 2')
// Because the row id doesn't match, we dont't expect the value to be updated.
await store.dispatch('test/updated', {
tableId: 10,
values: { id: 101, field_1: 'Test 4' },
})
valuesOfComponent1 = store.getters['test/get'](1)
expect(valuesOfComponent1.row.field_1).toBe('Test 2')
})
})