Add support for bulk record edit

This commit is contained in:
Kelani Tolulope
2023-03-08 17:51:22 +01:00
parent 7b46f10493
commit 6bc1f3110e
8 changed files with 321 additions and 2 deletions
@@ -128,7 +128,7 @@
</b-button>
</div>
<div class="ml-auto">
<div class="d-flex align-items-center ml-auto mr-2">
<automation-buttons
class="d-inline m-0"
:buttons="options.selectionButtons"
@@ -137,11 +137,20 @@
v-bind="$props"
@refresh="refresh()"
/>
<bulk-edit-modal
v-if="options.bulkRecordEditEnabled && canUpdateSelectedRecords"
:module="recordListModule"
:namespace="namespace"
:selected-records="selected"
class="ml-1"
/>
<template v-if="canDeleteSelectedRecords && !areAllRowsDeleted">
<c-input-confirm
v-if="!inlineEditing"
:tooltip="$t('recordList.tooltip.deleteSelected')"
class="ml-2"
class="mr-1"
@confirmed="handleDeleteSelectedRecords()"
/>
<b-button
@@ -682,6 +691,7 @@ import { components, url } from '@cortezaproject/corteza-vue'
import draggable from 'vuedraggable'
import RecordListFilter from 'corteza-webapp-compose/src/components/Common/RecordListFilter'
import ColumnPicker from 'corteza-webapp-compose/src/components/Admin/Module/Records/ColumnPicker'
import BulkEditModal from 'corteza-webapp-compose/src/components/Public/Record/BulkEdit'
const { CInputSearch } = components
@@ -700,6 +710,7 @@ export default {
RecordListFilter,
ColumnPicker,
CInputSearch,
BulkEditModal,
},
extends: base,
@@ -890,6 +901,10 @@ export default {
return this.items.filter(({ id, r }) => this.selected.includes(id) && r.canDeleteRecord).length
},
canUpdateSelectedRecords () {
return this.items.filter(({ id, r }) => this.selected.includes(id) && r.canUpdateRecord).length
},
canUndeleteSelectedRecords () {
return this.items.filter(({ id, r }) => this.selected.includes(id) && r.canUndeleteRecord).length
},
@@ -1580,6 +1595,10 @@ export default {
this.isDeleteActionVisible(record),
].some(v => v)
},
onBulkUpdateSuccessful () {
this.refresh(true)
},
},
}
</script>
@@ -263,6 +263,9 @@
<b-form-checkbox v-model="options.allowExport">
{{ $t('recordList.export.allow') }}
</b-form-checkbox>
<b-form-checkbox v-model="options.bulkRecordEditEnabled">
{{ $t('recordList.record.enableBulkRecordEdit') }}
</b-form-checkbox>
</b-form-group>
<b-form-group
@@ -0,0 +1,202 @@
<template>
<div>
<b-button
:title="$t('recordList.bulkRecord.title')"
variant="outline-light"
class="text-primary border-0"
size="sm"
@click="showModal = true"
>
<font-awesome-icon
:icon="['far', 'edit']"
/>
</b-button>
<b-modal
:visible="showModal"
size="md"
:title="$t('recordList.bulkRecord.title')"
body-class="p-0"
centered
scrollable
footer-class="d-flex justify-content-between align-items-center"
@hide="onModalHide"
>
<b-card class="pt-0">
<div
v-for="(field, index) in fields"
:key="index"
class="mb-4"
>
<field-editor
:namespace="namespace"
:module="module"
:field="field"
:errors="fieldErrors(field.name)"
:record="record"
class="field-editor mb-0"
/>
</div>
<hr
v-if="fields.length"
class="mb-4"
>
<vue-select
:placeholder="$t('recordList.bulkRecord.searchFields')"
:get-option-label="getFieldLabel"
:options="moduleFields"
append-to-body
:calculate-position="calculatePosition"
:selectable="option => !selectedFields.includes(option.name)"
class="bg-white position-relative"
@input="addField"
/>
</b-card>
<template #modal-footer>
<b-button
variant="light"
:disabled="processing"
@click="onReset"
>
{{ $t('recordList.bulkRecord.reset') }}
</b-button>
<div>
<b-button
variant="link"
rounded
class="text-decoration-none text-primary"
@click="onModalHide"
>
{{ $t('general.label.cancel') }}
</b-button>
<b-button
variant="primary"
:disabled="!fields.length || processing"
@click="handleBulkUpdateSelectedRecords()"
>
{{ $t('general.label.save') }}
</b-button>
</div>
</template>
</b-modal>
</div>
</template>
<script>
import FieldEditor from 'corteza-webapp-compose/src/components/ModuleFields/Editor'
import { VueSelect } from 'vue-select'
import { compose } from '@cortezaproject/corteza-js'
import calculatePosition from 'corteza-webapp-compose/src/mixins/vue-select-position'
import record from 'corteza-webapp-compose/src/mixins/record.js'
export default {
i18nOptions: {
namespaces: 'block',
},
name: 'BulkEdit',
components: {
VueSelect,
FieldEditor,
},
mixins: [
calculatePosition,
record,
],
props: {
namespace: {
type: compose.Namespace,
required: true,
},
module: {
type: compose.Module,
required: true,
},
selectedRecords: {
type: Array,
required: true,
},
},
data () {
return {
showModal: false,
fields: [],
}
},
computed: {
selectedFields () {
return this.fields.map(({ name }) => name)
},
moduleFields () {
return [
...[...this.module.fields].sort((a, b) =>
(a.label || a.name).localeCompare(b.label || b.name),
),
...this.module.systemFields().filter(sf => sf.name === 'ownedBy'),
].filter((field) => this.isFieldEditable(field))
},
},
created () {
this.record = new compose.Record(this.module, {})
},
methods: {
onModalHide () {
this.showModal = false
},
onClose () {
this.showModal = false
},
getFieldLabel ({ kind, label, name }) {
return label || name || kind
},
addField (field) {
if (!field) return
this.fields.push(field)
},
onReset () {
this.record = new compose.Record(this.module, {})
this.fields = []
},
isFieldEditable (field) {
if (!field) return false
const { canCreateOwnedRecord } = this.module || {}
const { createdAt, canManageOwnerOnRecord } = this.record || {}
const { name, canUpdateRecordValue, isSystem, expressions = {} } = field || {}
if (!canUpdateRecordValue) return false
if (isSystem) {
// Make ownedBy field editable if correct permissions
if (name === 'ownedBy') {
// If not created we check module permissions, otherwise the canManageOwnerOnRecord
return createdAt ? canManageOwnerOnRecord : canCreateOwnedRecord
}
return false
}
return !expressions.value
},
},
}
</script>
+42
View File
@@ -262,6 +262,48 @@ export default {
})
}, 500),
handleBulkUpdateSelectedRecords: throttle(function () {
const { moduleID, namespaceID } = this.module
const values = this.record.values.toJSON()
const records = this.selectedRecords
this.processing = true
return this
.dispatchUiEvent('beforeFormSubmit')
.then(() => this.$ComposeAPI.recordPatch({ moduleID, namespaceID, records, values }))
.catch(err => {
const { details = undefined } = err
if (!!details && Array.isArray(details) && details.length > 0) {
this.errors = new validator.Validated()
this.errors.push(...details)
throw new Error(this.$t('notification:record.validationErrors'))
}
throw err
})
.then(({ records }) => {
return records[0]
})
.then((record) => {
this.updatePrompts()
return record
})
.then((record) => {
if (record.valueErrors && record.valueErrors.set) {
this.toastWarning(this.$t('notification:record.validationWarnings'))
} else {
this.toastSuccess(this.$t('notification:record.bulkRecordUpdateSuccess'))
this.onReset()
this.onModalHide()
}
})
.catch(this.toastErrorHandler(this.$t('notification:record.deleteBulkRecordUpdateFailed')))
.finally(() => {
this.processing = false
})
}, 500),
/**
* Validates record and dispatches onFormSubmitError
*
+36
View File
@@ -1655,6 +1655,42 @@ export default class Compose {
return `/namespace/${namespaceID}/module/${moduleID}/record/${recordID}`
}
// Partially update record values
async recordPatch (a: KV, extra: AxiosRequestConfig = {}): Promise<KV> {
const {
namespaceID,
moduleID,
records,
values,
} = (a as KV) || {}
if (!namespaceID) {
throw Error('field namespaceID is empty')
}
if (!moduleID) {
throw Error('field moduleID is empty')
}
const cfg: AxiosRequestConfig = {
...extra,
method: 'patch',
url: this.recordPatchEndpoint({
namespaceID, moduleID,
}),
}
cfg.data = {
records,
values,
}
return this.api().request(cfg).then(result => stdResolve(result))
}
recordPatchEndpoint (a: KV): string {
const {
namespaceID,
moduleID,
} = a || {}
return `/namespace/${namespaceID}/module/${moduleID}/record/`
}
// Delete record row from module section
async recordBulkDelete (a: KV, extra: AxiosRequestConfig = {}): Promise<KV> {
const {
@@ -55,6 +55,8 @@ interface Options {
// Ordered list of buttons to display in the block
selectionButtons: Array<Button>;
bulkRecordEditEnabled: boolean;
}
const defaults: Readonly<Options> = Object.freeze({
@@ -101,6 +103,8 @@ const defaults: Readonly<Options> = Object.freeze({
selectionButtons: [],
refreshRate: 0,
showRefresh: false,
bulkRecordEditEnabled: true
})
export class PageBlockRecordList extends PageBlock {
@@ -156,6 +160,7 @@ export class PageBlockRecordList extends PageBlock {
'draggable',
'linkToParent',
'showRefresh',
'bulkRecordEditEnabled'
)
if (o.selectionButtons) {
@@ -143,6 +143,7 @@ general:
permissions: Permissions
remove: Remove
save: Save
cancel: Cancel
search: Search
refresh: Refresh block
magnify: Magnify block
@@ -237,6 +238,14 @@ record:
recordList:
addRecord: Add
cancelSelection: Cancel
bulkRecord:
title: Update selected records
field: Field
value: Value
type: Type
add: Add
searchFields: Select field to update
reset: Reset
drillDown:
filter:
remove: Remove drill down filter
@@ -395,6 +404,7 @@ recordList:
prefilterPlaceholder: field1 = 1 AND field2 = 232
presortFootnote: Simplified SQL condition (ORDER BY ...) syntax is supported
presortToggleInput: Toggle input type
enableBulkRecordEdit: Enable bulk record edit
presortHideSort: Hide sorting
presortLabel: Presort records
presortPlaceholder: field1 DESC, field2 ASC
@@ -90,7 +90,9 @@ record:
deleteFailed: Could not delete record
undeleteFailed: Could not undelete record
deleteBulkFailed: Could not delete selected records
deleteBulkRecordUpdateFailed: Could not update selected records
deleteBulkSuccess: Successfully deleted selected records
bulkRecordUpdateSuccess: Successfully updated selected records
undeleteBulkFailed: Could not restore selected records
undeleteBulkSuccess: Successfully restored selected records
invalidOwnerVar: Can not use ${ownerID} variable in non-record pages