antares/src/renderer/components/WorkspaceTabQueryTableRow.vue

719 lines
22 KiB
Vue

<template>
<div
class="tr"
:style="{height: itemHeight+'px'}"
>
<div
v-for="(col, cKey) in row"
v-show="cKey !== '_antares_id'"
:key="cKey"
class="td p-0"
:class="{selected: selectedCell === cKey}"
@click="selectRow($event, cKey)"
@contextmenu.prevent="openContext($event, {
id: row._antares_id,
orgField: cKey,
type: fields[cKey].type,
length: fields[cKey].charLength || fields[cKey].length
})"
>
<template v-if="cKey !== '_antares_id'">
<span
v-if="!isInlineEditor[cKey] && fields[cKey]"
class="cell-content"
:class="`${isNull(col)} ${typeClass(fields[cKey].type)}`"
@dblclick="editON(cKey)"
>{{ cutText(typeFormat(col, fields[cKey].type.toLowerCase(), fields[cKey].length) as string, 250) }}</span>
<ForeignKeySelect
v-else-if="isForeignKey(cKey)"
v-model="editingContent"
class="editable-field"
:key-usage="getKeyUsage(cKey)"
size="small"
@blur="editOFF"
/>
<template v-else>
<input
v-if="inputProps.mask"
ref="editField"
v-model="editingContent"
v-mask="inputProps.mask"
:type="inputProps.type"
autofocus
class="editable-field form-input input-sm px-1"
@blur="editOFF"
@keyup.delete.stop
>
<BaseSelect
v-else-if="inputProps.type === 'boolean'"
v-model="editingContent"
:options="['true', 'false']"
class="form-select small-select editable-field"
@blur="editOFF"
@keyup.delete.stop
/>
<BaseSelect
v-else-if="enumArray"
v-model="editingContent"
:options="enumArray"
class="form-select small-select editable-field"
dropdown-class="small-select"
@blur="editOFF"
@keyup.delete.stop
/>
<input
v-else
ref="editField"
v-model="editingContent"
:type="inputProps.type"
autofocus
class="editable-field form-input input-sm px-1"
@blur="editOFF"
@keyup.delete.stop
>
</template>
</template>
</div>
<ConfirmModal
v-if="isTextareaEditor"
:confirm-text="t('application.update')"
size="medium"
:disable-autofocus="true"
@confirm="editOFF"
@hide="hideEditorModal"
>
<template #header>
<div class="d-flex">
<BaseIcon
icon-name="mdiPlaylistEdit"
class="mr-1"
:size="24"
/> <span class="cut-text">{{ t('general.edit') }} "{{ editingField }}"</span>
</div>
</template>
<template #body>
<div class="mb-2">
<div>
<TextEditor
v-model="editingContent"
editor-class="textarea-editor"
:mode="editorMode"
/>
</div>
<div class="editor-field-info p-vcentered">
<div class="d-flex p-vcentered">
<label for="editorMode" class="form-label mr-2">
<b>{{ t('general.content') }}</b>:
</label>
<BaseSelect
id="editorMode"
v-model="editorMode"
:options="availableLanguages"
option-label="name"
option-track-by="slug"
class="form-select select-sm"
/>
</div>
<div class="d-flex">
<div class="p-vcentered">
<div class="mr-4">
<b>{{ t('general.size') }}</b>: {{ editingContent ? editingContent.length : 0 }}
</div>
<div v-if="editingType">
<b>{{ t('database.type') }}</b>: {{ editingType.toUpperCase() }}
</div>
</div>
</div>
</div>
</div>
</template>
</ConfirmModal>
<ConfirmModal
v-if="isMapModal"
:hide-footer="true"
size="medium"
@hide="hideEditorModal"
>
<template #header>
<div class="d-flex">
<BaseIcon
icon-name="mdiMap"
class="mr-1"
:size="24"
/> <span class="cut-text">"{{ editingField }}"</span>
</div>
</template>
<template #body>
<BaseMap :points="editingContent" :is-multi-spatial="isMultiSpatial" />
</template>
</ConfirmModal>
<ConfirmModal
v-if="isBlobEditor"
:confirm-text="t('application.update')"
@confirm="editOFF"
@hide="hideEditorModal"
>
<template #header>
<div class="d-flex">
<BaseIcon
icon-name="mdiPlaylistEdit"
class="mr-1"
:size="24"
/>
<span class="cut-text">{{ t('general.edit') }} "{{ editingField }}"</span>
</div>
</template>
<template #body>
<div class="mb-2">
<Transition name="jump-down">
<div v-if="contentInfo.size">
<img
v-if="isImage"
:src="`data:${contentInfo.mime};base64, ${bufferToBase64(editingContent)}`"
class="img-responsive p-centered bg-checkered"
>
<div v-else class="text-center">
<BaseIcon icon-name="mdiFile" :size="36" />
</div>
<div class="editor-buttons mt-2">
<button class="btn btn-link btn-sm" @click="downloadFile">
<span>{{ t('general.download') }}</span>
<BaseIcon
icon-name="mdiDownload"
class="ml-1"
:size="24"
/>
</button>
<button class="btn btn-link btn-sm" @click="prepareToDelete">
<span>{{ t('general.delete') }}</span>
<BaseIcon
icon-name="mdiDeleteForever"
class="ml-1"
:size="24"
/>
</button>
</div>
</div>
</Transition>
<div class="editor-field-info">
<div>
<b>{{ t('general.size') }}</b>: {{ formatBytes(editingContent.length) }}<br>
<b>{{ t('general.mimeType') }}</b>: {{ contentInfo.mime }}
</div>
<div v-if="editingType">
<b>{{ t('database.type') }}</b>: {{ editingType.toUpperCase() }}
</div>
</div>
<div class="mt-3">
<label>{{ t('general.uploadFile') }}</label>
<input
class="form-input"
type="file"
@change="filesChange($event as any)"
>
</div>
</div>
</template>
</ConfirmModal>
</div>
</template>
<script setup lang="ts">
import {
ARRAY,
BINARY,
BIT,
BLOB,
BOOLEAN,
DATE,
DATETIME,
FLOAT,
HAS_TIMEZONE,
IS_BIGINT,
IS_MULTI_SPATIAL,
LONG_TEXT,
NUMBER,
SPATIAL,
TEXT,
TEXT_SEARCH,
TIME } from 'common/fieldTypes';
import { QueryForeign, TableField } from 'common/interfaces/antares';
import { bufferToBase64 } from 'common/libs/bufferToBase64';
import { formatBytes } from 'common/libs/formatBytes';
import hexToBinary, { HexChar } from 'common/libs/hexToBinary';
import { langDetector } from 'common/libs/langDetector';
import { mimeFromHex } from 'common/libs/mimeFromHex';
import * as moment from 'moment';
import { computed, nextTick, onBeforeUnmount, Prop, Ref, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import ConfirmModal from '@/components/BaseConfirmModal.vue';
import BaseIcon from '@/components/BaseIcon.vue';
import BaseMap from '@/components/BaseMap.vue';
import BaseSelect from '@/components/BaseSelect.vue';
import TextEditor from '@/components/BaseTextEditor.vue';
import ForeignKeySelect from '@/components/ForeignKeySelect.vue';
import { useFilters } from '@/composables/useFilters';
const { t } = useI18n();
const { cutText } = useFilters();
const props = defineProps({
row: Object,
fields: Object as Prop<{
[key: string]: TableField;
}>,
keyUsage: Array as Prop<QueryForeign[]>,
itemHeight: Number,
elementType: { type: String, default: 'table' },
selected: { type: Boolean, default: false },
selectedCell: { type: String, default: null }
});
const emit = defineEmits(['update-field', 'select-row', 'contextmenu', 'start-editing', 'stop-editing']);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isInlineEditor: Ref<any> = ref({});
const isTextareaEditor = ref(false);
const isBlobEditor = ref(false);
const isMapModal = ref(false);
const isMultiSpatial = ref(false);
const willBeDeleted = ref(false);
const originalContent = ref(null);
const editingContent = ref(null);
const editingType = ref(null);
const editingField = ref(null);
const editingLength = ref(null);
const editorMode = ref('text');
const contentInfo = ref({
ext: '',
mime: '',
size: null
});
const fileToUpload = ref(null);
const availableLanguages = ref([
{ name: 'TEXT', slug: 'text', id: 'text' },
{ name: 'HTML', slug: 'html', id: 'html' },
{ name: 'XML', slug: 'xml', id: 'xml' },
{ name: 'JSON', slug: 'json', id: 'json' },
{ name: 'SVG', slug: 'svg', id: 'svg' },
{ name: 'INI', slug: 'ini', id: 'ini' },
{ name: 'MARKDOWN', slug: 'markdown', id: 'md' },
{ name: 'YAML', slug: 'yaml', id: 'yaml' }
]);
const inputProps = computed(() => {
if ([...TEXT, ...LONG_TEXT].includes(editingType.value))
return { type: 'text', mask: false };
if ([...NUMBER, ...FLOAT].includes(editingType.value)) {
if (IS_BIGINT.includes(editingType.value))
return { type: 'text', mask: false };
else
return { type: 'number', mask: false };
}
if (TIME.includes(editingType.value)) {
let timeMask = '##:##:##';
const precision = props.fields[editingField.value].length;
for (let i = 0; i < Number(precision); i++)
timeMask += i === 0 ? '.#' : '#';
if (HAS_TIMEZONE.includes(editingType.value))
timeMask += 'X##';
return { type: 'text', mask: timeMask };
}
if (DATE.includes(editingType.value))
return { type: 'text', mask: '####-##-##' };
if (DATETIME.includes(editingType.value)) {
let datetimeMask = '####-##-## ##:##:##';
const precision = props.fields[editingField.value].length;
for (let i = 0; i < Number(precision); i++)
datetimeMask += i === 0 ? '.#' : '#';
if (HAS_TIMEZONE.includes(editingType.value))
datetimeMask += 'X##';
return { type: 'text', mask: datetimeMask };
}
if (BLOB.includes(editingType.value))
return { type: 'file', mask: false };
if (BOOLEAN.includes(editingType.value))
return { type: 'boolean', mask: false };
if (SPATIAL.includes(editingType.value))
return { type: 'map', mask: false };
return { type: 'text', mask: false };
});
const isImage = computed(() => {
return ['gif', 'jpg', 'png', 'bmp', 'ico', 'tif'].includes(contentInfo.value.ext);
});
const foreignKeys = computed(() => {
return props.keyUsage.map(key => key.field);
});
const isEditable = computed(() => {
if (props.elementType === 'view') return false;
if (props.fields) {
const nElements = Object.keys(props.fields).reduce((acc, curr) => {
acc.add(props.fields[curr].table);
acc.add(props.fields[curr].schema);
return acc;
}, new Set([]));
if (nElements.size > 2) return false;
return !!(props.fields[Object.keys(props.fields)[0]].schema && props.fields[Object.keys(props.fields)[0]].table);
}
return false;
});
const isBaseSelectField = computed(() => {
return isForeignKey(editingField.value) || inputProps.value.type === 'boolean' || enumArray.value;
});
const enumArray = computed(() => {
if (props.fields[editingField.value] && props.fields[editingField.value].enumValues && props.fields[editingField.value].type !== 'SET')
return props.fields[editingField.value].enumValues.replaceAll('\'', '').split(',');
return false;
});
const isForeignKey = (key: string) => {
if (key) {
if (key.includes('.'))
key = key.split('.').pop();
return foreignKeys.value.includes(key);
}
};
const isNull = (value: null | string | number) => {
return value === null ? ' is-null' : '';
};
const typeClass = (type: string) => {
if (type)
return `type-${type.toLowerCase().replaceAll(' ', '_').replaceAll('"', '')}`;
return '';
};
const editON = async (field: string) => {
if (!isEditable.value || editingType.value === 'none') return;
const content = props.row[field];
const type = props.fields[field].type.toUpperCase();
if (BINARY.includes(type)) return;
originalContent.value = typeFormat(content, type, props.fields[field].length);
editingType.value = type;
editingField.value = field;
editingLength.value = props.fields[field].length;
if ([...LONG_TEXT, ...ARRAY, ...TEXT_SEARCH].includes(type)) {
isTextareaEditor.value = true;
editingContent.value = typeFormat(content, type);
emit('start-editing', field);
return;
}
if (SPATIAL.includes(type)) {
if (content) {
isMultiSpatial.value = IS_MULTI_SPATIAL.includes(type);
isMapModal.value = true;
editingContent.value = typeFormat(content, type);
}
emit('start-editing', field);
return;
}
if (BLOB.includes(type)) {
isBlobEditor.value = true;
editingContent.value = content || '';
fileToUpload.value = null;
willBeDeleted.value = false;
if (content !== null) {
const buff = Buffer.from(editingContent.value);
if (buff.length) {
const hex = buff.toString('hex').substring(0, 8).toUpperCase();
const { ext, mime } = mimeFromHex(hex);
contentInfo.value = {
ext,
mime,
size: editingContent.value.length
};
}
}
emit('start-editing', field);
return;
}
// Inline editable fields
editingContent.value = originalContent.value;
const obj = { [field]: true };
isInlineEditor.value = { ...isInlineEditor.value, ...obj };
nextTick(() => {
document.querySelector<HTMLInputElement>('.editable-field').focus();
});
emit('start-editing', field);
};
const editOFF = () => {
if (!editingField.value) return;
isInlineEditor.value[editingField.value] = false;
let content;
if (!BLOB.includes(editingType.value)) {
if ([...DATETIME, ...TIME].includes(editingType.value)) {
if (editingContent.value !== null && editingContent.value.substring(editingContent.value.length - 1) === '.')
editingContent.value = editingContent.value.slice(0, -1);
}
// If not changed
if (editingContent.value === typeFormat(originalContent.value, editingType.value, editingLength.value)) {
editingType.value = null;
editingField.value = null;
emit('stop-editing', editingField.value);
return;
}
content = editingContent.value;
}
else { // Handle file upload
if (willBeDeleted.value) {
content = '';
willBeDeleted.value = false;
}
else {
if (!fileToUpload.value) return;
content = fileToUpload.value.file.path;
}
}
emit('update-field', {
field: props.fields[editingField.value].name,
type: editingType.value,
content
});
emit('stop-editing', editingField.value);
editingType.value = null;
editingField.value = null;
};
const hideEditorModal = () => {
isTextareaEditor.value = false;
isBlobEditor.value = false;
isMapModal.value = false;
isMultiSpatial.value = false;
emit('stop-editing', editingField.value);
};
const downloadFile = () => {
const downloadLink = document.createElement('a');
downloadLink.href = `data:${contentInfo.value.mime};base64, ${bufferToBase64(editingContent.value)}`;
downloadLink.setAttribute('download', `${editingField.value}.${contentInfo.value.ext}`);
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
};
const filesChange = (event: Event & {target: {files: {name: string}[]}}) => {
const { files } = event.target;
if (!files.length) return;
fileToUpload.value = { name: files[0].name, file: files[0] };
willBeDeleted.value = false;
};
const prepareToDelete = () => {
editingContent.value = '';
contentInfo.value = {
ext: '',
mime: '',
size: null
};
willBeDeleted.value = true;
};
const selectRow = (event: Event, field: string) => {
emit('select-row', event, props.row, field);
};
const getKeyUsage = (keyName: string) => {
if (keyName.includes('.'))
return props.keyUsage.find(key => key.field === keyName.split('.').pop());
return props.keyUsage.find(key => key.field === keyName);
};
const openContext = (event: MouseEvent, payload: {
id: string;
field?: string;
orgField: string;
isEditable?: boolean;
type: string;
length: number | false;
}) => {
payload.field = props.fields[payload.orgField].name;// Ensures field name only
payload.isEditable = isEditable.value;
emit('contextmenu', event, payload);
};
const onKey = (e: KeyboardEvent) => {
e.stopPropagation();
if (!editingField.value && e.key === 'Enter')
return editON(props.selectedCell);
if (editingField.value && e.key === 'Enter' && !isBaseSelectField.value && !isTextareaEditor.value)
return editOFF();
if (editingField.value && e.key === 'Escape') {
isInlineEditor.value[editingField.value] = false;
editingField.value = null;
emit('stop-editing', editingField.value);
}
};
const typeFormat = (val: string | number | Date | number[], type: string, precision?: number | false) => {
if (!val) return val;
type = type.toUpperCase();
if (DATE.includes(type))
return moment(val).isValid() ? moment(val).format('YYYY-MM-DD') : val;
if (DATETIME.includes(type)) {
if (typeof val === 'string')
return val;
let datePrecision = '';
for (let i = 0; i < Number(precision); i++)
datePrecision += i === 0 ? '.S' : 'S';
return moment(val).isValid() ? moment(val).format(`YYYY-MM-DD HH:mm:ss${datePrecision}`) : val;
}
if (BLOB.includes(type)) {
if (typeof val === 'string') return val;
const buff = Buffer.from(val as unknown as ArrayBuffer);
if (!buff.length) return '';
const hex = buff.toString('hex').substring(0, 8).toUpperCase();
return `${mimeFromHex(hex).mime} (${formatBytes(buff.length)})`;
}
if (BIT.includes(type)) {
if (typeof val === 'number') val = [val] as number[];
const hex = Buffer.from(val as number[]).toString('hex') as unknown as HexChar[];
const bitString = hexToBinary(hex);
return parseInt(bitString).toString().padStart(Number(precision), '0');
}
if (BINARY.includes(type))
return Buffer.from(val as number[]).toString('hex');
if (ARRAY.includes(type)) {
if (Array.isArray(val))
return JSON.stringify(val).replaceAll('[', '{').replaceAll(']', '}');
return val;
}
if (SPATIAL.includes(type))
return val;
return typeof val === 'object' ? JSON.stringify(val) : val;
};
watch(() => props.fields, () => {
Object.keys(props.fields).forEach(field => {
isInlineEditor.value[field] = false;
});
});
watch(isTextareaEditor, (val) => {
if (val)
editorMode.value = langDetector(editingContent.value);
});
watch(() => props.selected, (isSelected) => {
if (isSelected)
window.addEventListener('keydown', onKey);
else {
editOFF();
window.removeEventListener('keydown', onKey);
}
});
onBeforeUnmount(() => {
if (props.selected)
window.removeEventListener('keydown', onKey);
});
</script>
<style lang="scss">
.editable-field {
margin: 0;
border: none;
line-height: 1;
width: 100%;
max-height: 21px;
border-radius: 3px;
font-size: 0.7rem;
position: absolute;
left: 0;
right: 0;
}
.cell-content {
display: block;
padding: 0 0.2rem;
min-height: 0.8rem;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.textarea-editor {
height: 50vh !important;
}
.editor-field-info {
margin-top: 0.4rem;
display: flex;
justify-content: space-between;
white-space: normal;
}
.editor-buttons {
display: flex;
justify-content: space-evenly;
.btn {
display: flex;
align-items: center;
}
}
</style>