datatable.directory codebase
https://datatable.directory/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
477 lines
14 KiB
477 lines
14 KiB
<!--
|
|
Complex animated column editor for the table edit page
|
|
-->
|
|
|
|
<template>
|
|
<div>
|
|
<input type="hidden" :name="name" :value="JSON.stringify(columns)" v-if="newTable">
|
|
|
|
<div :class="newTable ? ['col-md-12', 'mt-3'] : []">
|
|
<table :class="[
|
|
{'table': !newTable},
|
|
{'new-table': newTable},
|
|
{'mt-3': !newTable},
|
|
'table-narrow', 'table-sm', 'table-fixed', 'td-va-middle'
|
|
]">
|
|
<thead>
|
|
<tr>
|
|
<th v-if="sortable"></th>
|
|
<th :style="tdWidthStyle('name')">Name</th>
|
|
<th :style="tdWidthStyle('type')">Type</th>
|
|
<th :style="tdWidthStyle('title')">Title</th>
|
|
<th style="width:2rem" v-if="!newTable"><!-- revert icon --></th>
|
|
<th>
|
|
<a href="" type="button" v-if="!newTable && orderChanged"
|
|
@click.prevent="resetOrder()"
|
|
class="text-danger no-decoration">
|
|
Reset Order
|
|
</a>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<transition-group name="col-list" tag="tbody" ref="col-list">
|
|
<tr v-for="(col, i) in columns" :key="col.id" :ref="`col${i}`"
|
|
:class="{
|
|
dragging: col._dragging,
|
|
'text-success': col._new,
|
|
remove: col._remove
|
|
}">
|
|
<td v-if="sortable">
|
|
<span class="btn-group">
|
|
<button type="button" class="btn btn-outline-secondary drag-btn"
|
|
@keyup.up="move(i, -1)"
|
|
@keyup.down="move(i, 1)"
|
|
:ref="`col${i}-sort`"
|
|
:style="{visibility: (columns.length > 1) ? 'visible' : 'hidden'}"
|
|
@mousedown="beginDrag(i, $event)">
|
|
<v-icon class="fa-bars" alt="Drag" />
|
|
</button><!--
|
|
--><button type="button" :class="['btn', 'btn-outline-secondary', {disabled: i==0}]" v-if="manualSort"
|
|
@click.prevent="move(i, -1)">
|
|
<v-icon class="fa-chevron-up" alt="Move Up" />
|
|
</button><!--
|
|
--><button type="button" :class="['btn', 'btn-outline-secondary', {disabled: i == (columns.length-1)}]" v-if="manualSort"
|
|
@click.prevent="move(i, 1)">
|
|
<v-icon class="fa-chevron-down" alt="Move Down" />
|
|
</button>
|
|
</span>
|
|
</td>
|
|
|
|
<template v-if="col._editing || newTable">
|
|
<!-- Editable cells -->
|
|
<td :style="tdWidthStyle('name')">
|
|
<input v-model="col.name"
|
|
:class="['form-control', { 'is-invalid': col._errors && col._errors['name'] }]"
|
|
:title="(col._errors && col._errors['name']) ? col._errors['name'][0] : null"
|
|
type="text">
|
|
</td>
|
|
|
|
<td :style="tdWidthStyle('type')">
|
|
<select v-model="col.type"
|
|
:title="(col._errors && col._errors['type']) ? col._errors['type'][0] : null"
|
|
:class="[
|
|
'form-control',
|
|
'custom-select',
|
|
{ 'is-invalid': col._errors && col._errors['type'] }]">
|
|
<option v-for="t in colTypes" :value="t">{{t}}</option>
|
|
</select>
|
|
</td>
|
|
|
|
<td :style="tdWidthStyle('title')">
|
|
<input v-model="col.title"
|
|
:title="(col._errors && col._errors['title']) ? col._errors['title'][0] : null"
|
|
:class="['form-control', { 'is-invalid': col._errors && col._errors['title'] }]"
|
|
type="text">
|
|
</td>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<!-- Value fields -->
|
|
<td v-for='cell in ["name", "type", "title"]'>
|
|
<span class="text-danger strike" title="Original value" v-if="isChanged(col, cell)">{{col._orig[cell]}}</span>
|
|
<span>{{ col[cell] }}</span>
|
|
<v-icon v-if="isChanged(col, cell)"
|
|
@click="revertCell(col, cell)"
|
|
class="fa-undo text-danger pointer"
|
|
alt="Revert Change" />
|
|
</td>
|
|
</template>
|
|
|
|
<td style="text-align: center;" v-if="!newTable">
|
|
<v-icon @click="discardEdit(col)"
|
|
v-if="col._editing && colNeedsSave(col)"
|
|
class="fa-undo text-danger pointer"
|
|
alt="Revert Change" />
|
|
</td>
|
|
|
|
<td class="text-nowrap"><!--
|
|
Save button
|
|
--><a href="" :class="[
|
|
'mr-1', 'btn',
|
|
col._editing && colNeedsSave(col) ? 'btn-info' : 'btn-outline-secondary',
|
|
{'disabled': col._remove}
|
|
]"
|
|
v-if="!newTable"
|
|
@click.prevent="toggleColEditing(col)">
|
|
<v-icon v-if="col._editing" class="fa-save" alt="Save" />
|
|
<v-icon v-else class="fa-pencil" alt="Edit" />
|
|
</a><!--
|
|
Delete button
|
|
--><button type="button" :class="delBtnClass(col)"
|
|
@click.prevent="toggleColDelete(col)">
|
|
<v-icon v-if="col._remove" class="fa-undo" alt="Undo Remove" />
|
|
<v-icon v-else class="fa-trash-o" alt="Remove" />
|
|
</button><!--
|
|
Add button
|
|
--><button type="button" :class="['x-add-btn', 'btn', 'btn-outline-secondary']"
|
|
v-if="i === columns.length - 1"
|
|
@click.prevent="addCol()">
|
|
<v-icon class="fa-plus" alt="Add Column" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</transition-group>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
@import "base";
|
|
|
|
@media screen and (min-width: 625px) {
|
|
table.new-table {
|
|
margin-left: -53px;
|
|
}
|
|
}
|
|
|
|
td, th {
|
|
@include pr(1);
|
|
@include py(1);
|
|
}
|
|
|
|
th {
|
|
border-top: 0 none;
|
|
}
|
|
|
|
tr.dragging {
|
|
position: relative;
|
|
z-index: 1;
|
|
//background-color: $body-bg;
|
|
}
|
|
tr.dragging .btn {
|
|
background-color: $body-bg; // no see-through buttons
|
|
}
|
|
tr.dragging .drag-btn {
|
|
// fake hover
|
|
background-color: $secondary;
|
|
color: color-yiq($secondary);
|
|
box-shadow: $btn-box-shadow, 0 0 0 $btn-focus-width rgba($secondary, .5);
|
|
}
|
|
|
|
.col-list-enter-active {
|
|
transition: all .3s cubic-bezier(.2, .3, 0, 1);
|
|
}
|
|
|
|
.col-list-enter {
|
|
opacity: 0;
|
|
transform: translateY(100%);
|
|
}
|
|
.col-list-leave {
|
|
// approximate position of delete button
|
|
clip-path: circle(calc(100% + 5em) at calc(100% - 4em) 50%);
|
|
}
|
|
|
|
.col-list-leave-active .delete-btn {
|
|
animation: col-list-leave-delete-btn .3s forwards;
|
|
}
|
|
.col-list-leave-active {
|
|
transition: all .3s cubic-bezier(.4, .1, .6, .9);
|
|
}
|
|
.col-list-leave-to {
|
|
clip-path: circle(0 at calc(100% - 4em) 50%);
|
|
}
|
|
|
|
@keyframes col-list-leave-delete-btn {
|
|
0% {
|
|
transition-timing-function: cubic-bezier(.2, .4, .6, .9);
|
|
}
|
|
50% {
|
|
transition-timing-function: cubic-bezier(.5, 0, .8, .5);
|
|
transform: scale(1.1);
|
|
opacity: 1;
|
|
}
|
|
100% {
|
|
transform: scale(0);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
.col-list-move {
|
|
transition: transform .3s cubic-bezier(.2, .3, 0, 1);
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
import { query } from './table-editor-utils'
|
|
|
|
export default {
|
|
props: {
|
|
route: { type: String, default: null },
|
|
sortable: { type: Boolean, default: true },
|
|
manualSort: { type: Boolean, default: false },
|
|
newTable: { type: Boolean, default: false },
|
|
name: { type: String, required: true },
|
|
xColumns: { type: Array, required: true },
|
|
orderChanged: { type: Boolean, default: false },
|
|
},
|
|
data () {
|
|
return {
|
|
newColNum: 0,
|
|
//orderChanged: this.orderChanged,
|
|
columns: this.xColumns,
|
|
colTypes: ['string', 'int', 'float', 'bool'],
|
|
debouncedSortUpdate: _.debounce(() => this.submitColOrder(), 350)
|
|
}
|
|
},
|
|
methods: {
|
|
/** Send a query to the server */
|
|
query (data, sucfn, erfn) {
|
|
query(this.route, data, sucfn, erfn)
|
|
},
|
|
|
|
submitColOrder() {
|
|
let ids = this.columns.map((c) => c.id)
|
|
this.query ({
|
|
action: 'col.sort',
|
|
order: ids,
|
|
}, (resp) => {
|
|
this.orderChanged = resp.data;
|
|
})
|
|
},
|
|
|
|
colPos(col) {
|
|
for (let n = 0; n < this.columns.length; n++) {
|
|
if (this.columns[n].id == col.id) return n;
|
|
}
|
|
throw `Col ${col.id} not found in list`;
|
|
},
|
|
|
|
/** Save a change */
|
|
submitColChange (col) {
|
|
if (_.isUndefined(col)) return
|
|
let n = this.colPos(col)
|
|
|
|
this.query({
|
|
action: 'col.update',
|
|
data: col
|
|
}, (resp) => {
|
|
this.$set(this.columns, n, resp.data)
|
|
}, (er) => {
|
|
console.log("Col save error: ", er)
|
|
if (!_.isUndefined(er.errors)) {
|
|
this.$set(this.columns[n], '_errors', er.errors)
|
|
}
|
|
})
|
|
},
|
|
|
|
/** Add a column at the end */
|
|
addCol () {
|
|
if (this.newTable) {
|
|
this.columns.push({
|
|
id: 'dummy_' + (this.newColNum++),
|
|
name: '',
|
|
type: 'string',
|
|
title: '',
|
|
})
|
|
} else {
|
|
this.query({
|
|
action: 'col.add',
|
|
}, (resp) => {
|
|
resp.data._editing = true;
|
|
this.columns.push(resp.data)
|
|
this.initColLoadvals(resp.data)
|
|
})
|
|
}
|
|
},
|
|
|
|
/** Move a column (dir is positive or negative) */
|
|
move (pos1, dir) {
|
|
let pos2 = pos1 + dir
|
|
|
|
// clamp
|
|
if (pos2 < 0) {
|
|
pos2 = 0
|
|
} else if (pos2 >= this.columns.length) {
|
|
pos2 = this.columns.length - 1
|
|
}
|
|
|
|
let columns = this.columns
|
|
let col1 = columns.splice(pos1, 1)[0] // unwrap returned 1-element array
|
|
columns.splice(pos2, 0, col1)
|
|
this.columns = columns
|
|
|
|
// Put focus on the new field
|
|
// For some reason, it loses it when moving down
|
|
setTimeout(() => this.$refs[`col${pos2}-sort`][0].focus(), 0)
|
|
|
|
this.debouncedSortUpdate();
|
|
},
|
|
|
|
/** User started dragging a column */
|
|
beginDrag (i, evt) {
|
|
const column = this.columns[i]
|
|
column._dragging = true
|
|
this.$set(this.columns, i, column) // notify vue
|
|
|
|
let currentIndex = i
|
|
|
|
const dragMoveListener = e => {
|
|
let cursorIndex = 0
|
|
|
|
// find cursor index by going through the list and adding the li
|
|
// heights (can’t use their positions because they may be animating at
|
|
// that moment)
|
|
let accumY = this.$refs['col-list'].$el.getBoundingClientRect().top
|
|
for (let i = 0; i < this.columns.length; i++) {
|
|
if (e.clientY > accumY) {
|
|
cursorIndex = i
|
|
}
|
|
accumY += $(this.$refs['col' + i]).outerHeight(true)
|
|
}
|
|
|
|
if (cursorIndex !== currentIndex) {
|
|
this.move(currentIndex, cursorIndex - currentIndex)
|
|
currentIndex = cursorIndex
|
|
}
|
|
}
|
|
|
|
const dragEndListener = e => {
|
|
column._dragging = false
|
|
this.$set(this.columns, currentIndex, column) // notify vue
|
|
|
|
$(window).off('mousemove', dragMoveListener)
|
|
$(window).off('mouseup', dragEndListener)
|
|
}
|
|
|
|
$(window).on('mousemove', dragMoveListener)
|
|
$(window).on('mouseup', dragEndListener)
|
|
},
|
|
|
|
/** Compute classes for the column's delete button */
|
|
delBtnClass(col) {
|
|
return [
|
|
'mr-1',
|
|
'btn',
|
|
'btn-outline-danger',
|
|
'delete-btn',
|
|
{'active': col._remove},
|
|
{disabled: this.newTable && this.columns.length === 1}
|
|
]
|
|
},
|
|
|
|
/** Delete / undelete a column; New columns vanish. */
|
|
toggleColDelete(col) {
|
|
if (_.isUndefined(col)) return
|
|
let n = this.colPos(col)
|
|
|
|
if (this.newTable) {
|
|
if (this.columns.length == 1) return // can't delete the last col
|
|
|
|
// hard delete
|
|
this.columns.splice(n, 1)
|
|
} else {
|
|
let remove = !col._remove
|
|
|
|
if (col._new) {
|
|
if (!confirm(`Delete new column "${col.title}"? Any row data for this column will be lost.`)) return;
|
|
}
|
|
|
|
this.query({
|
|
action: remove ? 'col.remove' : 'col.restore',
|
|
id: col.id
|
|
}, (resp) => {
|
|
// if response is null, this was a New col
|
|
// and it was discarded without a way back - hard drop
|
|
if (_.isEmpty(resp.data)) {
|
|
this.$delete(this.columns, n)
|
|
}
|
|
else {
|
|
this.$set(this.columns, n, resp.data)
|
|
}
|
|
})
|
|
}
|
|
},
|
|
|
|
/** Toggle editing state - edit or save */
|
|
toggleColEditing (col) {
|
|
if (col._remove) return false // can't edit col marked for removal
|
|
let editing = !col._editing
|
|
|
|
let n = this.colPos(col)
|
|
|
|
if (!editing) {
|
|
this.submitColChange(col)
|
|
} else {
|
|
this.initColLoadvals(col)
|
|
this.$set(this.columns[n], '_editing', true)
|
|
}
|
|
},
|
|
|
|
initColLoadvals(col) {
|
|
let n = this.colPos(col)
|
|
|
|
let origvals = {};
|
|
_.each(col, (v, k) => {
|
|
if (k[0] != '_') origvals[k] = v
|
|
})
|
|
this.$set(this.columns[n], '_loadvals', origvals)
|
|
},
|
|
|
|
/** Test if a value cell is changed */
|
|
isChanged (col, cell) {
|
|
return col._changed && col._changed.indexOf(cell) > -1
|
|
},
|
|
|
|
/** Revert a value cell */
|
|
revertCell (col, cell) {
|
|
this.submitColChange(_.merge({}, col, {[cell]: col._orig[cell]}))
|
|
},
|
|
|
|
/** compute styles for a field cell */
|
|
tdWidthStyle(cell) {
|
|
let w = 10;
|
|
if (cell == 'name') w = '14'
|
|
if (cell == 'type') w = '12'
|
|
if (cell == 'title') w = '14'
|
|
|
|
return {width: `${w}rem`};
|
|
},
|
|
|
|
resetOrder() {
|
|
this.query({
|
|
action: 'reset.col-order'
|
|
}, (resp) => {
|
|
this.columns = resp.data
|
|
this.orderChanged = false
|
|
})
|
|
},
|
|
|
|
colNeedsSave(col) {
|
|
if (!col._loadvals) return false;
|
|
for (const [key, value] of Object.entries(col._loadvals)) {
|
|
// changed if differs from orig value and also from previous value from revision
|
|
if (col[key] != value) return true
|
|
}
|
|
return false
|
|
},
|
|
|
|
discardEdit(col) {
|
|
col._editing = false;
|
|
for (const [key, value] of Object.entries(col._loadvals)) {
|
|
col[key] = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|