Add support for bulk record edit
This commit is contained in:
@@ -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>
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user