formset component

This commit is contained in:
bkfox
2024-03-28 05:21:25 +01:00
parent 6bfcdd06c8
commit b82f8f4527
19 changed files with 1246 additions and 312 deletions

View File

@ -0,0 +1,195 @@
<template>
<div>
<input type="hidden" :name="_prefix + 'TOTAL_FORMS'" :value="items.length || 0"/>
<template v-for="(value,name) in formData.management" v-bind:key="name">
<input type="hidden" :name="_prefix + name.toUpperCase()"
:value="value"/>
</template>
<a-rows ref="rows" :set="set"
:columns="visibleFields" :columnsOrderable="columnsOrderable"
:orderable="orderable" @move="moveItem" @colmove="onMoveColumn"
@cell="e => $emit('cell', e)">
<template #header-head>
<template v-if="orderable">
<th style="max-width:2em" :title="orderField.label"
:aria-label="orderField.label"
:aria-description="orderField.help || ''">
<span class="icon">
<i class="fa fa-arrow-down-1-9"></i>
</span>
</th>
<slot name="rows-header-head"></slot>
</template>
</template>
<template #row-head="data">
<input v-if="orderable" type="hidden"
:name="_prefix + data.row + '-' + orderBy"
:value="data.row"/>
<input type="hidden" :name="_prefix + data.row + '-id'"
:value="data.item ? data.item.id : ''"/>
<template v-for="field of hiddenFields" v-bind:key="field.name">
<input type="hidden"
v-if="!(field.name in ['id', orderBy])"
:name="_prefix + data.row + '-' + field.name"
:value="field.value in [null, undefined] ? data.item.data[name] : field.value"/>
</template>
<slot name="row-head" v-bind="data">
<td v-if="orderable">{{ data.row+1 }}</td>
</slot>
</template>
<template v-for="(field,slot) of fieldSlots" v-bind:key="field.name"
v-slot:[slot]="data">
<slot :name="slot" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name">
<div class="field">
<div class="control">
<slot :name="'control-' + field.name" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name"/>
</div>
<p v-for="[error,index] in data.item.error(field.name)" class="help is-danger" v-bind:key="index">
{{ error }}
</p>
</div>
</slot>
</template>
<template #row-tail="data">
<slot v-if="$slots['row-tail']" name="row-tail" v-bind="data"/>
<td class="align-right pr-0">
<button type="button" class="button square"
@click.stop="removeItem(data.row, data.item)"
:title="labels.remove_item"
:aria-label="labels.remove_item">
<span class="icon"><i class="fa fa-trash" /></span>
</button>
</td>
</template>
</a-rows>
<div class="a-formset-footer flex-row">
<div class="flex-grow-1 flex-row">
<slot name="footer"/>
</div>
<div class="flex-grow-1 align-right">
<button type="button" class="button square is-warning p-2"
@click="reset()"
:title="labels.discard_changes"
:aria-label="labels.discard_changes"
>
<span class="icon"><i class="fa fa-rotate" /></span>
</button>
<button type="button" class="button square is-primary p-2"
@click="onActionAdd"
:title="labels.add_item"
:aria-label="labels.add_item"
>
<span class="icon">
<i class="fa fa-plus"/></span>
</button>
</div>
</div>
</div>
</template>
<script>
import {cloneDeep} from 'lodash'
import Model, {Set} from '../model'
import ARows from './ARows'
export default {
emit: ['cell', 'move', 'colmove', 'load'],
components: {ARows},
props: {
labels: Object,
//! If provided call this function instead of adding an item to rows on "+" button click.
actionAdd: Function,
//! If True, columns can be reordered
columnsOrderable: Boolean,
//! Field name used for ordering
orderBy: String,
//! Formset data as returned by get_formset_data
formData: Object,
//! Model class used for item's set
model: {type: Function, default: Model},
//! initial data set load at mount
initials: Array,
},
data() {
return {
set: new Set(Model),
}
},
computed: {
// ---- fields
_prefix() { return this.formData.prefix ? this.formData.prefix + '-' : '' },
fields() { return this.formData.fields },
orderField() { return this.orderBy && this.fields.find(f => f.name == this.orderBy) },
orderable() { return !!this.orderField },
hiddenFields() { return this.fields.filter(f => f.hidden && !(this.orderable && f == this.orderField)) },
visibleFields() { return this.fields.filter(f => !f.hidden) },
fieldSlots() { return this.visibleFields.reduce(
(slots, f) => ({...slots, ['row-' + f.name]: f}),
{}
)},
items() { return this.set.items },
rows() { return this.$refs.rows },
},
methods: {
onCellEvent(event) { this.$emit('cell', event) },
onMoveColumn(event) { this.$emit('colmove', event) },
onActionAdd() {
if(this.actionAdd)
return this.actionAdd(this)
this.set.push()
},
moveItem(event) {
const {from, to} = event
const set_ = event.set || this.set
set_.move(from, to);
this.$emit('move', {...event, seŧ: set_})
},
removeItem(row) {
const item = this.items[row]
if(item.id) {
// TODO
}
else {
this.items.splice(row,1)
}
},
//! Load items into set
load(items=[], reset=false) {
if(reset)
this.set.items = []
for(var item of items)
this.set.push(cloneDeep(item))
this.$emit('load', items)
},
//! Reset forms to initials
reset() {
this.load(this.initials || [], true)
},
},
mounted() {
this.reset()
}
}
</script>

View File

@ -27,12 +27,15 @@ import {isReactive, toRefs} from 'vue'
import Model from '../model'
export default {
emit: ['move', 'cell'],
emits: ['move', 'cell'],
props: {
//! Item to display in row
item: Object,
item: {type: Object, default: () => ({})},
//! Columns to display, as items' attributes
//! - name: field name / item attribute value
//! - label: display label
//! - help: help text
columns: Array,
//! Default cell's info
cell: {type: Object, default() { return {row: 0}}},

View File

@ -1,34 +1,38 @@
<template>
<table class="table is-stripped is-fullwidth">
<thead>
<a-row :item="labels" :columns="columns" :orderable="orderable"
@move="$emit('colmove', $event)">
<a-row :columns="columnNames"
:orderable="columnsOrderable" cellTag="th"
@move="moveColumn">
<template v-if="$slots['header-head']" v-slot:head="data">
<slot name="header-head" v-bind="data"/>
</template>
<template v-if="$slots['header-tail']" v-slot:tail="data">
<slot name="header-tail" v-bind="data"/>
</template>
<template v-for="column of columns" v-bind:key="column.name"
v-slot:[column.name]="data">
<slot :name="'header-' + column.name" v-bind="data">
{{ column.label }}
<span v-if="column.help" class="icon small"
:title="column.help">
<i class="fa fa-circle-question"/>
</span>
</slot>
</template>
</a-row>
</thead>
<tbody>
<slot name="head"/>
<template v-for="(item,row) in items" :key="row">
<!-- data-index comes from AList component drag & drop -->
<a-row :item="item" :cell="{row}" :columns="columns" :data-index="row"
<a-row :item="item" :cell="{row}" :columns="columnNames" :data-index="row"
:data-row="row"
:draggable="orderable"
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
@cell="onCellEvent(row, $event)">
<template v-for="[name,slot] of rowSlots" :key="slot" v-slot:[slot]="data">
<template v-if="slot == 'head' || slot == 'tail'">
<slot :name="name" v-bind="data"/>
</template>
<template v-else>
<div>
<slot :name="name" v-bind="data"/>
</div>
</template>
<slot :name="name" v-bind="data"/>
</template>
</a-row>
</template>
@ -43,28 +47,32 @@ import ARow from './ARow.vue'
const Component = {
extends: AList,
components: { ARow },
emit: ['cell', 'colmove'],
emits: ['cell', 'colmove'],
props: {
...AList.props,
//! Ordered list of columns, as objects with:
//! - name: item attribute value
//! - label: display label
//! - help: help text
//! - hidden: if true, field is hidden
columns: Array,
labels: Object,
//! If True, columns are orderable
columnsOrderable: Boolean,
},
data() {
return {
...super.data,
// TODO: add observer
columns_: [...this.columns],
extraItem: new this.set.model(),
}
},
computed: {
rowCells() {
const cells = []
for(var row in this.items)
cells.push({row})
},
columnNames() { return this.columns_.map(c => c.name) },
columnLabels() { return this.columns_.map(c => c.label) },
rowSlots() {
return Object.keys(this.$slots).filter(x => x.startsWith('row-'))
.map(x => [x, x.slice(4)])
@ -72,6 +80,17 @@ const Component = {
},
methods: {
/**
* Move column using provided event object (as `{from, to}`)
*/
moveColumn(event) {
const {from, to} = event
const value = this.columns_[from]
this.columns_.splice(from, 1)
this.columns_.splice(to, 0, value)
this.$emit('colmove', event)
},
/**
* React on 'cell' event, re-emitting it with additional values:
* - `set`: data set

View File

@ -21,141 +21,63 @@
</template>
</a-select-file>
<slot name="top" :set="set" :items="set.items"></slot>
<a-rows :set="set" :columns="allColumns"
:labels="allColumnsLabels" :allow-create="true" :orderable="true"
@move="listItemMove">
<a-form-set ref="formset" :form-data="formData" :labels="labels"
:initials="initData.items"
order-by="position"
:action-add="actionAdd">
<template v-for="[name,slot] of rowsSlots" :key="slot"
v-slot:[slot]="data">
<slot v-if="name != 'row-tail'" :name="name" v-bind="data"/>
</template>
<template #row-sound="{item}">
<template #row-sound="{item,inputName}">
<label>{{ item.data.name }}</label><br>
<audio controls :src="item.data.url"/>
<input type="hidden" :name="inputName" :value="item.data.sound"/>
</template>
</a-rows>
<div class="flex-row">
<div class="flex-grow-1 flex-row">
</div>
<div class="flex-grow-1 align-right">
<button type="button" class="button square is-warning p-2"
@click="loadData({items: this.initData.items},true)"
:title="labels.discard_changes"
:aria-label="labels.discard_changes"
>
<span class="icon"><i class="fa fa-rotate" /></span>
</button>
<button type="button" class="button square is-primary p-2"
@click="$refs['select-file'].open()"
:title="labels.add_sound"
:aria-label="labels.add_sound"
>
<span class="icon">
<i class="fa fa-plus"/></span>
</button>
</div>
</div>
</a-form-set>
</div>
</template>
<script>
// import {dropRightWhile, cloneDeep, isEqual} from 'lodash'
import {cloneDeep} from 'lodash'
import Model, {Set} from '../model'
import ARows from './ARows'
//import AFileUpload from "./AFileUpload"
import AFormSet from './AFormSet'
import ASelectFile from "./ASelectFile"
export default {
components: {ARows, ASelectFile},
components: {AFormSet, ASelectFile},
props: {
// default values of items
itemDefaults: Object,
formData: Object,
labels: Object,
// initial datas
initData: Object,
labels: Object,
soundListUrl: String,
soundUploadUrl: String,
soundDeleteUrl: String,
columns: {
type: Array,
default: () => ['name', "broadcast"]
},
},
data() {
return {
set: new Set(Model),
}
},
computed: {
player_() {
return this.player || window.aircox.player
},
allColumns() {
return ["sound", ...this.columns, "delete"]
},
allColumnsLabels() {
return {...this.labels, ...this.initData.fields}
},
items() {
return this.set.items
},
rowsSlots() {
return Object.keys(this.$slots)
.filter(x => x.startsWith('row-') || x.startsWith('rows-'))
.filter(x => x.startsWith('row-') || x.startsWith('rows-') || x.startsWith('control-'))
.map(x => [x, x.startsWith('rows-') ? x.slice(5) : x])
},
},
methods: {
listItemMove({from, to, set}) {
set.move(from, to);
actionAdd() {
this.$refs['select-file'].open()
},
/**
* Load initial data
*/
loadData({items=[] /*, settings=null*/}, reset=false) {
if(reset) {
this.set.items = []
}
for(var index in items)
this.set.push(cloneDeep(items[index]))
// if(settings)
// this.settingsSaved(settings)
},
selected(item) {
selected(item) {
const data = {
...this.itemDefaults,
"sound": item.id,
"name": item.name,
"url": item.url,
"broadcast": item.broadcast,
}
this.set.push(data)
this.$refs.formset.set.push(data)
},
},
watch: {
initData(val) {
this.loadData(val)
},
},
mounted() {
this.initData && this.loadData(this.initData)
},
}
</script>

View File

@ -27,7 +27,6 @@
</div>
</div>
</div>
<slot name="top" :set="set" :columns="allColumns" :items="items"/>
<section v-show="page == Page.Text" class="panel">
<textarea ref="textarea" class="is-fullwidth is-size-6" rows="20"
@change="updateList"
@ -35,60 +34,33 @@
</section>
<section v-show="page == Page.List" class="panel">
<a-rows :set="set" :columns="allColumns" :labels="initData.fields"
:orderable="true" @move="listItemMove" @colmove="columnMove"
@cell="onCellEvent">
<a-form-set ref="formset"
:form-data="formData" :initials="initData.items"
:columnsOrderable="true" :labels="labels"
order-by="position"
@load="updateInput" @colmove="updateInput" @move="updateInput"
@cell="onCellEvent">
<template v-for="[name,slot] of rowsSlots" :key="slot"
v-slot:[slot]="data">
<slot v-if="name != 'row-tail'" :name="name" v-bind="data"/>
</template>
<template v-slot:row-tail="data">
<slot v-if="$slots['row-tail']" :name="row-tail" v-bind="data"/>
<td class="align-right pr-0">
<button type="button" class="button square"
@click.stop="items.splice(data.row,1)"
:title="labels.remove_item"
:aria-label="labels.remove_item">
<span class="icon"><i class="fa fa-trash" /></span>
</button>
</td>
<template #footer>
<div class="field">
<p class="control">
<button type="button" class="button is-info"
@click="$refs.settings.open()">
<span class="icon is-small">
<i class="fa fa-cog"></i>
</span>
<span>Options</span>
</button>
</p>
</div>
</template>
</a-rows>
</a-form-set>
</section>
<div class="flex-row">
<div class="flex-grow-1 flex-row">
<div class="field">
<p class="control">
<button type="button" class="button is-info"
@click="$refs.settings.open()">
<span class="icon is-small">
<i class="fa fa-cog"></i>
</span>
<span>Options</span>
</button>
</p>
</div>
</div>
<div class="flex-grow-1 align-right">
<button type="button" class="button square is-warning p-2"
@click="loadData({items: this.initData.items},true)"
:title="labels.discard_changes"
:aria-label="labels.discard_changes"
>
<span class="icon"><i class="fa fa-rotate" /></span>
</button>
<button type="button" class="button square is-primary p-2" v-if="page == Page.List"
@click="this.set.push(new this.set.model())"
:title="labels.add_item"
:aria-label="labels.add_item"
>
<span class="icon"><i class="fa fa-plus"/></span>
</button>
</div>
</div>
<a-modal ref="settings" title="Options">
<template #default>
<div class="field">
@ -97,12 +69,14 @@
</label>
<table class="table is-bordered"
style="vertical-align: middle">
<tr>
<a-row :columns="allColumns" :item="initData.fields"
@move="formatMove" :orderable="true">
<tr v-if="$refs.formset">
<a-row :columns="$refs.formset.rows.columnNames"
:item="$refs.formset.rows.columnLabels"
@move="$refs.formset.rows.moveColumn"
>
<template v-slot:cell-after="{cell}">
<td style="cursor:pointer;" v-if="cell.col < allColumns.length-1">
<span class="icon" @click="formatMove({from: cell.col, to: cell.col+1})"
<td style="cursor:pointer;" v-if="cell.col < $refs.formset.rows.columns_.length-1">
<span class="icon" @click="$refs.formset.rows.moveColumn({from: cell.col, to: cell.col+1})"
><i class="fa fa-left-right"/>
</span>
</td>
@ -143,16 +117,14 @@
</div>
</template>
</a-modal>
<slot name="bottom" :set="set" :columns="allColumns" :items="items"/>
</div>
</template>
<script>
import {dropRightWhile, cloneDeep, isEqual} from 'lodash'
import Model, {Set} from '../model'
import AActionButton from './AActionButton'
import AFormSet from './AFormSet'
import ARow from './ARow'
import ARows from './ARows'
import AModal from "./AModal"
/// Page display
@ -161,12 +133,14 @@ export const Page = {
}
export default {
components: { AActionButton, ARow, ARows, AModal },
components: { AActionButton, AFormSet, ARow, AModal },
props: {
formData: Object,
labels: Object,
///! initial data as: {items: [], fields: {column_name: label, settings: {}}
initData: Object,
dataPrefix: String,
labels: Object,
settingsUrl: String,
defaultColumns: {
type: Array,
@ -175,13 +149,12 @@ export default {
data() {
const settings = {
tracklist_editor_columns: this.columns,
// tracklist_editor_columns: this.columns,
tracklist_editor_sep: ' -- ',
}
return {
Page: Page,
page: Page.Text,
set: new Set(Model),
extraData: {},
settings,
savedSettings: cloneDeep(settings),
@ -189,6 +162,9 @@ export default {
},
computed: {
rows() { return this.$refs.formset && this.$refs.formset.rows },
columns() { return this.rows && this.rows.columns_ || [] },
settingsChanged() {
var k = Object.keys(this.savedSettings)
.findIndex(k => !isEqual(this.settings[k], this.savedSettings[k]))
@ -204,25 +180,9 @@ export default {
get() { return this.settings.tracklist_editor_sep }
},
allColumns: {
set(value) {
var cols = value.filter(x => x in this.defaultColumns)
var left = this.defaultColumns.filter(x => !(x in cols))
value = cols.concat(left)
this.settings.tracklist_editor_columns = value
},
get() {
return this.settings.tracklist_editor_columns
}
},
items() {
return this.set.items
},
rowsSlots() {
return Object.keys(this.$slots)
.filter(x => x.startsWith('row-') || x.startsWith('rows-'))
.filter(x => x.startsWith('row-') || x.startsWith('rows-') || x.startsWith('control-'))
.map(x => [x, x.startsWith('rows-') ? x.slice(5) : x])
},
},
@ -236,34 +196,30 @@ export default {
},
formatMove({from, to}) {
const value = this.allColumns[from]
this.settings.tracklist_editor_columns.splice(from, 1)
this.settings.tracklist_editor_columns.splice(to, 0, value)
if(this.page == Page.Text)
this.updateList()
else
this.updateInput()
this.$refs.formset.rows.columnMove({from, to})
/*this.settings.tracklist_editor_columns.splice(from, 1)
this.settings.tracklist_editor_columns.splice(to, 0, value)*/
},
columnMove({from, to}) {
const value = this.allColumns[from]
this.allColumns.splice(from, 1)
this.allColumns.splice(to, 0, value)
this.updateInput()
},
listItemMove({from, to, set}) {
moveItem({from, to, set}) {
set.move(from, to);
this.updateInput()
},
onColumnMove() {
if(this.page == this.Page.List)
this.updateInput()
else
this.updateList()
},
updateList() {
const items = this.toList(this.$refs.textarea.value)
this.set.reset(items)
this.$refs.formset.set.reset(items)
},
updateInput() {
const input = this.toText(this.items)
const input = this.toText(this.$refs.formset.items)
this.$refs.textarea.value = input
},
@ -271,6 +227,7 @@ export default {
* From input and separator, return list of items.
*/
toList(input) {
const columns = this.$refs.formset.rows.columns_
var lines = input.split('\n')
var items = []
@ -281,11 +238,11 @@ export default {
var lineBits = line.split(this.separator)
var item = {}
for(var col in this.allColumns) {
for(var col in columns) {
if(col >= lineBits.length)
break
const attr = this.allColumns[col]
item[attr] = lineBits[col].trim()
const column = columns[col]
item[column.name] = lineBits[col].trim()
}
item && items.push(item)
}
@ -296,14 +253,15 @@ export default {
* From items and separator return a string
*/
toText(items) {
const columns = this.$refs.formset.rows.columns_
const sep = ` ${this.separator.trim()} `
const lines = []
for(let item of items) {
if(!item)
continue
var line = []
for(var col of this.allColumns)
line.push(item.data[col] || '')
for(var col of columns)
line.push(item.data[col.name] || '')
line = dropRightWhile(line, x => !x || !('' + x).trim())
line = line.join(sep).trimRight()
lines.push(line)
@ -335,27 +293,17 @@ export default {
/**
* Load initial data
*/
loadData({items=[], settings=null}, reset=false) {
if(reset) {
this.set.items = []
}
for(var index in items)
this.set.push(cloneDeep(items[index]))
/*loadData({items=[], settings=null}, reset=false) {
if(settings)
this.settingsSaved(settings)
this.updateInput()
},
},
watch: {
initData(val) {
this.loadData(val)
},
},*/
},
mounted() {
this.initData && this.loadData(this.initData)
this.page = this.items.length ? Page.List : Page.Text
//this.initData && this.initData.settings &&
//this.initData && this.loadData(this.initData)
this.page = this.initData.items.length ? Page.List : Page.Text
},
}
</script>

View File

@ -16,6 +16,8 @@ import AFileUpload from "./AFileUpload"
import ASelectFile from "./ASelectFile"
import AStatistics from './AStatistics'
import AStreamer from './AStreamer'
import AFormSet from './AFormSet'
import ATrackListEditor from './ATrackListEditor'
import ASoundListEditor from './ASoundListEditor'
@ -37,5 +39,6 @@ export const admin = {
export const dashboard = {
...base,
AActionButton, AFileUpload, ASelectFile, AModal, ATrackListEditor, ASoundListEditor
AActionButton, AFileUpload, ASelectFile, AModal,
AFormSet, ATrackListEditor, ASoundListEditor
}