1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-07 14:25:37 +00:00

Resolve "Edit a row in a popup"

This commit is contained in:
Bram Wiepjes 2020-05-11 17:27:35 +00:00
parent 0d528b8d22
commit 1202e2b9c2
19 changed files with 592 additions and 116 deletions

View file

@ -2,6 +2,8 @@
## Unreleased
* Added row modal editing feature to the grid view.
* Made it possible to resize the field width per view.
* Added validation and formatting for the number field.
* Cancel the editing state of a fields when the escape key is pressed.
* The next field is now selected when the tab character is pressed when a field is

View file

@ -15,6 +15,10 @@
}
}
.control-label-icon {
margin-right: 6px;
}
.control-context {
color: $color-primary-900;
margin-left: 6px;

View file

@ -0,0 +1,69 @@
<template>
<Context ref="context">
<ul class="context-menu">
<li>
<a
ref="updateFieldContextLink"
class="grid-view-description-options"
@click="
$refs.updateFieldContext.toggle(
$refs.updateFieldContextLink,
'bottom',
'left',
0
)
"
>
<i class="context-menu-icon fas fa-fw fa-pen"></i>
Edit field
</a>
<UpdateFieldContext
ref="updateFieldContext"
:field="field"
@update="$refs.context.hide()"
></UpdateFieldContext>
</li>
<li v-if="!field.primary">
<a @click="deleteField(field)">
<i class="context-menu-icon fas fa-fw fa-trash"></i>
Delete field
</a>
</li>
</ul>
</Context>
</template>
<script>
import { notifyIf } from '@baserow/modules/core/utils/error'
import context from '@baserow/modules/core/mixins/context'
import UpdateFieldContext from '@baserow/modules/database/components/field/UpdateFieldContext'
export default {
name: 'FieldContext',
components: { UpdateFieldContext },
mixins: [context],
props: {
field: {
type: Object,
required: true,
},
},
methods: {
setLoading(field, value) {
this.$store.dispatch('field/setItemLoading', { field, value })
},
async deleteField(field) {
this.$refs.context.hide()
this.setLoading(field, true)
try {
await this.$store.dispatch('field/delete', field)
} catch (error) {
notifyIf(error, 'field')
}
this.setLoading(field, false)
},
},
}
</script>

View file

@ -0,0 +1,26 @@
<template>
<div class="control-elements">
<div
class="field-boolean-checkbox"
:class="{ active: value }"
@click="toggle(value)"
>
<i class="fas fa-check check"></i>
</div>
</div>
</template>
<script>
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
export default {
mixins: [rowEditField],
methods: {
toggle(value) {
const oldValue = !!value
const newValue = !value
this.$emit('update', newValue, oldValue)
},
},
}
</script>

View file

@ -0,0 +1,27 @@
<template>
<div class="control-elements">
<input
ref="input"
v-model="copy"
type="text"
class="input input-large field-number"
:class="{ 'input-error': !isValid() }"
@keyup.enter="$refs.input.blur()"
@focus="select()"
@blur="unselect()"
/>
<div v-show="!isValid()" class="error">
{{ getError() }}
</div>
</div>
</template>
<script>
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
import rowEditFieldInput from '@baserow/modules/database/mixins/rowEditFieldInput'
import numberField from '@baserow/modules/database/mixins/numberField'
export default {
mixins: [rowEditField, rowEditFieldInput, numberField],
}
</script>

View file

@ -0,0 +1,22 @@
<template>
<div class="control-elements">
<input
ref="input"
v-model="copy"
type="text"
class="input input-large"
@keyup.enter="$refs.input.blur()"
@focus="select()"
@blur="unselect()"
/>
</div>
</template>
<script>
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
import rowEditFieldInput from '@baserow/modules/database/mixins/rowEditFieldInput'
export default {
mixins: [rowEditField, rowEditFieldInput],
}
</script>

View file

@ -0,0 +1,92 @@
<template>
<Modal>
<h2 v-if="primary !== undefined" class="box-title">
{{ getHeading(primary, row) }}
</h2>
<form>
<RowEditModalField
v-for="field in getFields(fields, primary)"
:ref="'field-' + field.id"
:key="'row-edit-field-' + field.id"
:field="field"
:row="row"
@update="update"
></RowEditModalField>
<div class="actions">
<a
ref="createFieldContextLink"
@click="$refs.createFieldContext.toggle($refs.createFieldContextLink)"
>
<i class="fas fa-plus"></i>
add field
</a>
<CreateFieldContext
ref="createFieldContext"
:table="table"
></CreateFieldContext>
</div>
</form>
</Modal>
</template>
<script>
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'
export default {
name: 'RowEditModal',
components: {
RowEditModalField,
CreateFieldContext,
},
mixins: [modal],
props: {
table: {
type: Object,
required: true,
},
primary: {
type: Object,
required: false,
default: undefined,
},
fields: {
type: Array,
required: true,
},
},
data() {
return {
row: {},
}
},
methods: {
show(row, ...args) {
this.row = row
this.getRootModal().show(...args)
},
/**
* Because the modal can't update values by himself, an event will be called to
* notify the parent component to actually update the value.
*/
update(context) {
context.table = this.table
this.$emit('update', context)
},
getFields(fields, primary) {
return primary !== undefined ? [primary].concat(fields) : fields
},
getHeading(primary, row) {
const name = `field_${primary.id}`
if (Object.prototype.hasOwnProperty.call(row, name)) {
return this.$registry
.get('field', primary.type)
.toHumanReadableString(primary, row[name])
} else {
return null
}
},
},
}
</script>

View file

@ -0,0 +1,58 @@
<template>
<div class="control">
<label class="control-label">
<i
class="fas control-label-icon"
:class="'fa-' + field._.type.iconClass"
></i>
{{ field.name }}
<a
ref="contextLink"
class="control-context"
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 0)"
>
<i class="fas fa-caret-down"></i>
</a>
</label>
<FieldContext ref="context" :field="field"></FieldContext>
<component
:is="getFieldComponent(field.type)"
ref="field"
:field="field"
:value="row['field_' + field.id]"
@update="update"
/>
</div>
</template>
<script>
import FieldContext from '@baserow/modules/database/components/field/FieldContext'
export default {
name: 'RowEditModalField',
components: { FieldContext },
props: {
field: {
type: Object,
required: true,
},
row: {
type: Object,
required: true,
},
},
methods: {
getFieldComponent(type) {
return this.$registry.get('field', type).getRowEditFieldComponent()
},
update(value, oldValue) {
this.$emit('update', {
row: this.row,
field: this.field,
value,
oldValue,
})
},
},
}
</script>

View file

@ -45,7 +45,12 @@
v-for="row in rows"
:key="'left-row-' + view.id + '-' + row.id"
class="grid-view-row"
:class="{ 'grid-view-row-loading': row._.loading }"
:class="{
'grid-view-row-loading': row._.loading,
'grid-view-row-hover': row._.hover,
}"
@mouseover="setRowHover(row, true)"
@mouseleave="setRowHover(row, false)"
@contextmenu.prevent="showRowContext($event, row)"
>
<div
@ -54,7 +59,10 @@
>
<div class="grid-view-row-info">
<div class="grid-view-row-count">{{ row.id }}</div>
<a href="#" class="grid-view-row-more">
<a
class="grid-view-row-more"
@click="$refs.rowEditModal.show(row)"
>
<i class="fas fa-expand"></i>
</a>
</div>
@ -68,6 +76,7 @@
:style="{ width: widths.fields[primary.id] + 'px' }"
@selected="selectedField(primary, $event.component)"
@selectNext="selectNextField(row, primary, fields, primary)"
@update="updateValue"
></GridViewField>
</div>
</div>
@ -175,7 +184,12 @@
v-for="row in rows"
:key="'right-row-' + view.id + '-' + row.id"
class="grid-view-row"
:class="{ 'grid-view-row-loading': row._.loading }"
:class="{
'grid-view-row-loading': row._.loading,
'grid-view-row-hover': row._.hover,
}"
@mouseover="setRowHover(row, true)"
@mouseleave="setRowHover(row, false)"
@contextmenu.prevent="showRowContext($event, row)"
>
<GridViewField
@ -193,6 +207,7 @@
selectNextField(row, field, fields, primary, true)
"
@selectNext="selectNextField(row, field, fields, primary)"
@update="updateValue"
></GridViewField>
</div>
</div>
@ -225,6 +240,13 @@
</li>
</ul>
</Context>
<RowEditModal
ref="rowEditModal"
:table="table"
:primary="primary"
:fields="fields"
@update="updateValue"
></RowEditModal>
</div>
</template>
@ -235,6 +257,7 @@ import CreateFieldContext from '@baserow/modules/database/components/field/Creat
import GridViewFieldType from '@baserow/modules/database/components/view/grid/GridViewFieldType'
import GridViewField from '@baserow/modules/database/components/view/grid/GridViewField'
import GridViewFieldWidthHandle from '@baserow/modules/database/components/view/grid/GridViewFieldWidthHandle'
import RowEditModal from '@baserow/modules/database/components/row/RowEditModal'
import { notifyIf } from '@baserow/modules/core/utils/error'
import _ from 'lodash'
@ -245,6 +268,7 @@ export default {
GridViewFieldType,
GridViewField,
GridViewFieldWidthHandle,
RowEditModal,
},
props: {
primary: {
@ -273,6 +297,7 @@ export default {
addHover: false,
loading: true,
selectedRow: null,
lastHoveredRow: null,
widths: {
fields: {},
},
@ -308,6 +333,19 @@ export default {
this.calculateWidths(this.primary, this.fields, this.fieldOptions)
},
methods: {
async updateValue({ field, row, value, oldValue }) {
try {
await this.$store.dispatch('view/grid/updateValue', {
table: this.table,
row,
field,
value,
oldValue,
})
} catch (error) {
notifyIf(error, 'field')
}
},
scroll(pixelY, pixelX) {
const $rightBody = this.$refs.rightBody
const $right = this.$refs.right
@ -412,7 +450,8 @@ export default {
try {
await this.$store.dispatch('view/grid/create', {
table: this.table,
fields: this.fields,
// We need a list of all fields including the primary one here.
fields: [this.primary].concat(...this.fields),
values: {},
})
} catch (error) {
@ -523,6 +562,21 @@ export default {
current[0].unselect()
next[0].select()
},
setRowHover(row, value) {
// Sometimes the mouseleave is not triggered, but because you can hover only one
// row at a time we can remember which was hovered last and set the hover state to
// false if it differs.
if (this.lastHoveredRow !== null && this.lastHoveredRow.id !== row.id) {
this.$store.dispatch('view/grid/setRowHover', {
row: this.lastHoveredRow,
value: false,
})
this.lastHoveredRow = true
}
this.$store.dispatch('view/grid/setRowHover', { row, value })
this.lastHoveredRow = row
},
},
}
</script>

View file

@ -2,7 +2,7 @@
<div class="grid-view-column" @click="select()">
<component
:is="getFieldComponent(field.type)"
ref="column"
ref="field"
:field="field"
:value="row['field_' + field.id]"
:selected="selected"
@ -13,15 +13,10 @@
<script>
import { isElement } from '@baserow/modules/core/utils/dom'
import { notifyIf } from '@baserow/modules/core/utils/error'
export default {
name: 'GridViewField',
props: {
table: {
type: Object,
required: true,
},
field: {
type: Object,
required: true,
@ -62,25 +57,12 @@ export default {
* which will actually update the value via the store.
*/
update(value, oldValue) {
this.$store
.dispatch('view/grid/updateValue', {
table: this.table,
row: this.row,
field: this.field,
value,
oldValue,
})
.catch((error) => {
notifyIf(error, 'column')
})
.then(() => {
this.$forceUpdate()
})
// This is needed because in some cases we do have a value yet, so a watcher of
// the value is not guaranteed. This will make sure the component shows the
// latest value.
this.$forceUpdate()
this.$emit('update', {
row: this.row,
field: this.field,
value,
oldValue,
})
},
/**
* Method that is called when a user clicks on the grid field. It wil
@ -97,7 +79,7 @@ export default {
this.clickTimestamp !== null &&
timestamp - this.clickTimestamp < 200
) {
this.$refs.column.doubleClick()
this.$refs.field.doubleClick()
}
} else {
// If the field is not yet selected we can change the state to selected.
@ -105,7 +87,7 @@ export default {
this.$nextTick(() => {
// Call the select method on the next tick because we want to wait for all
// changes to have rendered.
this.$refs.column.select()
this.$refs.field.select()
})
// Register a body click event listener so that we can detect if a user has
@ -146,7 +128,7 @@ export default {
this.clickTimestamp = timestamp
},
unselect() {
this.$refs.column.beforeUnSelect()
this.$refs.field.beforeUnSelect()
this.$nextTick(() => {
this.selected = false
})

View file

@ -18,6 +18,19 @@ import gridField from '@baserow/modules/database/mixins/gridField'
export default {
mixins: [gridField],
methods: {
select() {
// While the field is selected we want to toggle the value by pressing the enter
// key.
this.$el.keydownEvent = (event) => {
if (event.keyCode === 13) {
this.toggle(this.value)
}
}
document.body.addEventListener('keydown', this.$el.keydownEvent)
},
beforeUnSelect() {
document.body.removeEventListener('keydown', this.$el.keydownEvent)
},
toggle(value) {
if (!this.selected) {
return

View file

@ -25,42 +25,17 @@
<script>
import gridField from '@baserow/modules/database/mixins/gridField'
import gridFieldInput from '@baserow/modules/database/mixins/gridFieldInput'
import numberField from '@baserow/modules/database/mixins/numberField'
export default {
mixins: [gridField, gridFieldInput],
mixins: [gridField, gridFieldInput, numberField],
methods: {
getError() {
if (this.copy === null || this.copy === '') {
return null
}
if (isNaN(parseFloat(this.copy)) || !isFinite(this.copy)) {
return 'Invalid number'
}
return null
},
isValid() {
return this.getError() === null
},
afterEdit() {
this.$nextTick(() => {
this.$refs.input.focus()
this.$refs.input.selectionStart = this.$refs.input.selectionEnd = 100000
})
},
beforeSave(value) {
if (value === '' || isNaN(value) || value === undefined) {
return null
}
const decimalPlaces =
this.field.number_type === 'DECIMAL'
? this.field.number_decimal_places
: 0
let number = parseFloat(value)
if (!this.field.number_negative && number < 0) {
number = 0
}
return number.toFixed(decimalPlaces)
},
},
}
</script>

View file

@ -15,72 +15,23 @@
>
<i class="fas fa-caret-down"></i>
</a>
<Context ref="context">
<ul class="context-menu">
<li>
<a
ref="updateFieldContextLink"
class="grid-view-description-options"
@click="
$refs.updateFieldContext.toggle(
$refs.updateFieldContextLink,
'bottom',
'right',
0
)
"
>
<i class="context-menu-icon fas fa-fw fa-pen"></i>
Edit field
</a>
<UpdateFieldContext
ref="updateFieldContext"
:field="field"
@update="$refs.context.hide()"
></UpdateFieldContext>
</li>
<li v-if="!field.primary">
<a @click="deleteField(field)">
<i class="context-menu-icon fas fa-fw fa-trash"></i>
Delete field
</a>
</li>
</ul>
</Context>
<FieldContext ref="context" :field="field"></FieldContext>
<slot></slot>
</div>
</div>
</template>
<script>
import { notifyIf } from '@baserow/modules/core/utils/error'
import UpdateFieldContext from '@baserow/modules/database/components/field/UpdateFieldContext'
import FieldContext from '@baserow/modules/database/components/field/FieldContext'
export default {
name: 'GridViewFieldType',
components: { UpdateFieldContext },
components: { FieldContext },
props: {
field: {
type: Object,
required: true,
},
},
methods: {
setLoading(field, value) {
this.$store.dispatch('field/setItemLoading', { field, value })
},
async deleteField(field) {
this.$refs.context.hide()
this.setLoading(field, true)
try {
await this.$store.dispatch('field/delete', field)
} catch (error) {
notifyIf(error, 'field')
}
this.setLoading(field, false)
},
},
}
</script>

View file

@ -7,6 +7,10 @@ import GridViewFieldText from '@baserow/modules/database/components/view/grid/Gr
import GridViewFieldNumber from '@baserow/modules/database/components/view/grid/GridViewFieldNumber'
import GridViewFieldBoolean from '@baserow/modules/database/components/view/grid/GridViewFieldBoolean'
import RowEditFieldText from '@baserow/modules/database/components/row/RowEditFieldText'
import RowEditFieldNumber from '@baserow/modules/database/components/row/RowEditFieldNumber'
import RowEditFieldBoolean from '@baserow/modules/database/components/row/RowEditFieldBoolean'
export class FieldType extends Registerable {
/**
* The font awesome 5 icon name that is used as convenience for the user to
@ -37,7 +41,9 @@ export class FieldType extends Registerable {
}
/**
* @TODO make this depending on the view type.
* This grid view field component should represent the related row value of this
* type. It will only be used in the grid view and it also responsible for editing
* the value.
*/
getGridViewFieldComponent() {
throw new Error(
@ -45,6 +51,17 @@ export class FieldType extends Registerable {
)
}
/**
* The row edit field should represent a the related row value of this type. It
* will be used in the row edit modal, but can also be used in other forms. It is
* responsible for editing the value.
*/
getRowEditFieldComponent() {
throw new Error(
'Not implement error. This method should return a component.'
)
}
/**
* Because we want to show a new row immediately after creating we need to have an
* empty value to show right away.
@ -90,6 +107,13 @@ export class FieldType extends Registerable {
name: this.name,
}
}
/**
* Should return a for humans readable representation of the value.
*/
toHumanReadableString(field, value) {
return value
}
}
export class TextFieldType extends FieldType {
@ -113,6 +137,10 @@ export class TextFieldType extends FieldType {
return GridViewFieldText
}
getRowEditFieldComponent() {
return RowEditFieldText
}
getEmptyValue(field) {
return field.text_default
}
@ -138,6 +166,10 @@ export class NumberFieldType extends FieldType {
getGridViewFieldComponent() {
return GridViewFieldNumber
}
getRowEditFieldComponent() {
return RowEditFieldNumber
}
}
export class BooleanFieldType extends FieldType {
@ -157,6 +189,10 @@ export class BooleanFieldType extends FieldType {
return GridViewFieldBoolean
}
getRowEditFieldComponent() {
return RowEditFieldBoolean
}
getEmptyValue(field) {
return false
}

View file

@ -8,11 +8,11 @@ export default {
data() {
return {
/**
* Indicates whether of the user is editing the value.
* Indicates whether the user is editing the value.
*/
editing: false,
/**
* A temporary copy of the value when editing.
* A temporary copy of the value when editing.
*/
copy: null,
}

View file

@ -0,0 +1,43 @@
/**
* This mixin contains some method overrides for validating and formatting the
* number field. This mixin is used in both the GridViewFieldNumber and
* RowEditFieldNumber components.
*/
export default {
methods: {
/**
* Generates a human readable error for the user if something is wrong.
*/
getError() {
if (this.copy === null || this.copy === '') {
return null
}
if (isNaN(parseFloat(this.copy)) || !isFinite(this.copy)) {
return 'Invalid number'
}
return null
},
isValid() {
return this.getError() === null
},
/**
* Formats the value based on the field's settings. The number will be rounded
* if to much decimal places are provided and if negative numbers aren't allowed
* they will be set to 0.
*/
beforeSave(value) {
if (value === '' || isNaN(value) || value === undefined) {
return null
}
const decimalPlaces =
this.field.number_type === 'DECIMAL'
? this.field.number_decimal_places
: 0
let number = parseFloat(value)
if (!this.field.number_negative && number < 0) {
number = 0
}
return number.toFixed(decimalPlaces)
},
},
}

View file

@ -0,0 +1,25 @@
/**
* A mixin that can be used by a row edit modal component. It introduces the props that
* will be passed by the RowEditModalField component.
*/
export default {
props: {
/**
* Contains the field type object. Because each field type can have different
* settings you need this in order to render the correct component or implement
* correct validation.
*/
field: {
type: Object,
required: true,
},
/**
* The value of the grid field, this could for example for a number field 10,
* text field 'Random string' etc.
*/
value: {
type: [String, Number, Object, Boolean],
required: false,
},
},
}

View file

@ -0,0 +1,84 @@
/**
* This mixin can be used with a row edit field if the field only needs an input. For
* example for the text and number fields. It depends on the rowEditField mixin.
*/
export default {
data() {
return {
/**
* Indicates whether the user is editing the value.
*/
editing: false,
/**
* A temporary copy of the value when editing.
*/
copy: null,
}
},
watch: {
value(value) {
if (!this.editing) {
this.copy = value
}
},
},
mounted() {
this.copy = this.value
},
methods: {
/**
* Event that is called when the user starts editing the value. In this case we
* will only enable the editing state.
*/
select() {
this.editing = true
},
/**
* Event that is called when the user finishes editing. If the value is not
* valid we aren't going to do anything because it can't be changed anyway and
* we want to give the user a change to fix the value.
*/
unselect() {
if (!this.isValid() || !this.editing) {
return
}
this.editing = false
this.save()
},
/**
* Saves the value if it has changed. Should only be called by the unselect
* method and not directly.
*/
save() {
const newValue = this.beforeSave(this.copy)
// If the value hasn't changed we don't want to do anything.
if (newValue === this.value) {
this.copy = this.value
} else {
this.$emit('update', newValue, this.value)
this.afterSave()
}
},
/**
* This method is called before saving the value. Optionally the value can be
* changed or formatted here if necessary.
*/
beforeSave(value) {
return value
},
/**
* Method that is called after saving the value. This can be overridden in the
* component.
*/
afterSave() {},
/**
* Should return a boolean if the copy that is going to be saved is valid. If it
* returns false unselecting is not possible.
*/
isValid() {
return true
},
},
}

View file

@ -1,3 +1,4 @@
import Vue from 'vue'
import axios from 'axios'
import _ from 'lodash'
@ -5,7 +6,10 @@ import GridService from '@baserow/modules/database/services/view/grid'
import RowService from '@baserow/modules/database/services/row'
export function populateRow(row) {
row._ = { loading: false }
row._ = {
loading: false,
hover: false,
}
return row
}
@ -118,7 +122,10 @@ export const mutations = {
ADD_FIELD(state, { field, value }) {
const name = `field_${field.id}`
state.rows.forEach((row) => {
row[name] = value
// We have to use the Vue.set function here to make it reactive immediately.
// If we don't do this the value in the field components of the grid and modal
// don't have the correct value and will act strange.
Vue.set(row, name, value)
})
},
SET_ROW_LOADING(state, { row, value }) {
@ -136,6 +143,9 @@ export const mutations = {
})
}
},
SET_ROW_HOVER(state, { row, value }) {
row._.hover = value
},
}
// Contains the timeout needed for the delayed delayed scroll top action.
@ -549,6 +559,9 @@ export const actions = {
values,
})
},
setRowHover({ commit }, { row, value }) {
commit('SET_ROW_HOVER', { row, value })
},
}
export const getters = {