Browse Source

feat: migrate attachment cell

Signed-off-by: Pranav C <pranavxc@gmail.com>
pull/2716/head
Pranav C 2 years ago
parent
commit
befa7dce45
  1. 337
      packages/nc-gui-v2/components/editable-cell/Attachment.vue
  2. 56
      packages/nc-gui-v2/components/editable-cell/Boolean.vue
  3. 542
      packages/nc-gui-v2/components/editable-cell/EditableAttachmentCell.vue
  4. 32
      packages/nc-gui-v2/components/editable-cell/Text.vue
  5. 37
      packages/nc-gui-v2/components/editable-cell/TextArea.vue
  6. 61
      packages/nc-gui-v2/components/smartsheet/EditableCell.vue
  7. 254
      packages/nc-gui-v2/components/smartsheet/Grid.vue
  8. 14
      packages/nc-gui-v2/composables/useAttachment.ts
  9. 5
      packages/nc-gui-v2/composables/useTabs.ts
  10. 2
      packages/nc-gui-v2/pages/nc/[projectId].vue
  11. 37
      packages/nc-gui-v2/pages/projects/create-external.vue
  12. 7
      packages/nc-gui-v2/utils/fileUtils.ts

337
packages/nc-gui-v2/components/editable-cell/Attachment.vue

@ -0,0 +1,337 @@
<script setup lang="ts">
import { useNuxtApp } from "#app";
import { useNuxt } from "@nuxt/kit";
import { inject, watchEffect } from "@vue/runtime-core";
import { ColumnType, TableType } from "nocodb-sdk";
import { Ref } from "vue";
import { useToast } from "vue-toastification";
import useProject from "~/composables/useProject";
// import FileSaver from "file-saver";
import { isImage } from "~/utils/fileUtils";
import MaterialPlusIcon from "~icons/mdi/plus";
import MaterialArrowExpandIcon from "~icons/mdi/arrow-expand";
const { modelValue } = defineProps<{ modelValue: string | Array<any> }>();
const emit = defineEmits(["update:modelValue"]);
const isPublicForm = inject<boolean>("isPublicForm", false);
const isForm = inject<boolean>("isForm", false);
const meta = inject<Ref<TableType>>("meta");
const column = inject<ColumnType>("column");
const localFilesState = reactive([]);
const attachments = ref([]);
const uploading = ref(false);
const fileInput = ref<HTMLInputElement>();
const { $api } = useNuxtApp();
const { project } = useProject();
const toast = useToast();
watchEffect(() => {
if (modelValue) {
attachments.value = ((typeof modelValue === "string" ? JSON.parse(modelValue) : modelValue) || []).filter(Boolean);
}
});
const selectImage = (file: any, i) => {
// todo: implement
};
const openUrl = (url: string, target = "_blank") => {
window.open(url, target);
};
const addFile = () => {
fileInput.value?.click();
};
const onFileSelection = async (e) => {
// if (this.isPublicGrid) {
// return
// }
// if (!this.$refs.file.files || !this.$refs.file.files.length) {
// return
// }
// if (this.isPublicForm) {
// this.localFilesState.push(...Array.from(this.$refs.file.files).map((file) => {
// const res = { file, title: file.name }
// if (isImage(file.name, file.mimetype)) {
// const reader = new FileReader()
// reader.onload = (e) => {
// this.$set(res, 'data', e.target.result)
// }
// reader.readAsDataURL(file)
// }
// return res
// }))
//
// this.$emit('input', this.localFilesState.map(f => f.file))
// return
// }
// todo : move to com
uploading.value = true;
const newAttachments = [];
for (const file of fileInput.value?.files ?? []) {
try {
const data = await $api.storage.upload(
{
path: ["noco", project.value.title, meta?.value?.title, column?.title].join("/")
}, {
files: file,
json: "{}"
}
);
newAttachments.push(...data);
} catch (e: any) {
toast.error((e.message) || "Some internal error occurred");
uploading.value = false;
return;
}
}
uploading.value = false;
emit("update:modelValue", JSON.stringify([...attachments.value, ...newAttachments]));
// this.$emit('input', JSON.stringify(this.localState))
// this.$emit('update')
};
</script>
<template>
<div class="main h-100">
<div class="d-flex align-center img-container">
<div class="d-flex no-overflow">
<div
v-for="(item, i) in isPublicForm ? localFilesState : attachments"
:key="item.url || item.title"
class="thumbnail align-center justify-center d-flex"
>
<!-- <v-tooltip bottom> -->
<!-- <template #activator="{ on }"> -->
<!-- <v-img
v-if="isImage(item.title, item.mimetype)"
lazy-src="https://via.placeholder.com/60.png?text=Loading..."
alt="#"
max-height="99px"
contain
:src="item.url || item.data"
v-on="on"
@click="selectImage(item.url || item.data, i)"
> -->
<img
v-if="isImage(item.title, item.mimetype)"
alt="#"
style="max-height: 30px; max-width: 30px"
:src="item.url || item.data"
@click="selectImage(item.url || item.data, i)"
/>
<!-- <template #placeholder> -->
<!-- <v-skeleton-loader type="image" :height="active ? 33 : 22" :width="active ? 33 : 22" /> -->
<!-- </template> -->
<v-icon v-else-if="item.icon" :size="active ? 33 : 22" v-on="on"
@click="openUrl(item.url || item.data, '_blank')">
{{ item.icon }}
</v-icon>
<v-icon v-else :size="active ? 33 : 22" v-on="on" @click="openUrl(item.url || item.data, '_blank')">
mdi-file
</v-icon>
<!-- </template> -->
<!-- <span>{{ item.title }}</span> -->
<!-- </v-tooltip> -->
</div>
</div>
<!-- todo: hide or toggle based on ancestor -->
<div class="add d-flex align-center justify-center px-1 nc-attachment-add" @click="addFile">
<v-icon v-if="uploading" small color="primary" class="nc-attachment-add-spinner"> mdi-loading mdi-spin</v-icon>
<!-- <v-btn v-else-if="isForm" outlined x-small color="" text class="nc-attachment-add-btn">
<v-icon x-small color="" icon="MaterialPlusIcon"> mdi-plus </v-icon>
Attachment
</v-btn>
<v-icon small color="primary nc-attachment-add-icon">
mdi-plus
</v-icon> -->
<MaterialPlusIcon />
</div>
<v-spacer />
<MaterialArrowExpandIcon @click.stop="dialog = true" />
<!-- <v-icon class="expand-icon mr-1" x-small color="primary" @click.stop="dialog = true"> mdi-arrow-expand </v-icon> -->
</div>
<input ref="fileInput" type="file" multiple class="d-none" @change="onFileSelection" />
</div>
</template>
<style scoped lang="scss">
.thumbnail {
height: 30px;
width: 30px;
margin: 2px;
border-radius: 4px;
img {
max-height: 33px;
max-width: 33px;
}
}
.expand-icon {
margin-left: 8px;
border-radius: 2px;
transition: 0.3s background-color;
}
.expand-icon:hover {
background-color: var(--v-primary-lighten4);
}
/*.img-container {
margin: 0 -2px;
}
.no-overflow {
overflow: hidden;
}
.add {
transition: 0.2s background-color;
!*background-color: #666666ee;*!
border-radius: 4px;
height: 33px;
margin: 5px 2px;
}
.add:hover {
!*background-color: #66666699;*!
}
.thumbnail {
height: 99px;
width: 99px;
margin: 2px;
border-radius: 4px;
}
.thumbnail img {
!*max-height: 33px;*!
max-width: 99px;
}
.main {
min-height: 20px;
position: relative;
height: auto;
}
.expand-icon {
margin-left: 8px;
border-radius: 2px;
!*opacity: 0;*!
transition: 0.3s background-color;
}
.expand-icon:hover {
!*opacity: 1;*!
background-color: var(--v-primary-lighten4);
}
.modal-thumbnail img {
height: 50px;
max-width: 100%;
border-radius: 4px;
}
.modal-thumbnail {
position: relative;
margin: 10px 10px;
}
.remove-icon {
position: absolute;
top: 5px;
right: 5px;
}
.modal-thumbnail-card {
.download-icon {
position: absolute;
bottom: 5px;
right: 5px;
opacity: 0;
transition: 0.4s opacity;
}
&:hover .download-icon {
opacity: 1;
}
}
.image-overlay-container {
max-height: 100vh;
overflow-y: auto;
position: relative;
}
.image-overlay-container .close-icon {
position: fixed;
top: 15px;
right: 15px;
}
.overlay-thumbnail {
transition: 0.4s transform, 0.4s opacity;
opacity: 0.5;
}
.overlay-thumbnail.active {
transform: scale(1.4);
opacity: 1;
}
.overlay-thumbnail:hover {
opacity: 1;
}
.modal-title {
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
overflow: hidden;
}
.modal-thumbnail-card {
transition: 0.4s transform;
}
.modal-thumbnail-card:hover {
transform: scale(1.05);
}
.drop-overlay {
z-index: 5;
position: absolute;
width: 100%;
height: 100%;
left: 0;
right: 0;
top: 0;
bottom: 5px;
background: #aaaaaa44;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
}
.expand-icon {
opacity: 0;
transition: 0.4s opacity;
}
.main:hover .expand-icon {
opacity: 1;
}*/
</style>

56
packages/nc-gui-v2/components/editable-cell/Boolean.vue

@ -1,33 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import { computed } from 'vue'
import { ColumnType } from "nocodb-sdk"; const { modelValue: value } = defineProps<{ modelValue: any }>()
import { computed } from "vue"; const emit = defineEmits(['update:modelValue'])
const column = inject<ColumnType & { meta?: any }>('column')
const column = inject<ColumnType & { meta?: any }>("column"); const isForm = inject<boolean>('isForm')
const isForm =inject<boolean>('isForm')
const { modelValue:value } = defineProps<{ modelValue: any }>()
const emit = defineEmits(["update:modelValue"]);
const checkboxMeta = computed(() => { const checkboxMeta = computed(() => {
return { return {
icon: { icon: {
checked: "mdi-check-circle-outline", checked: 'mdi-check-circle-outline',
unchecked: "mdi-checkbox-blank-circle-outline" unchecked: 'mdi-checkbox-blank-circle-outline',
}, },
color: "primary", color: 'primary',
...(column?.meta || {}) ...(column?.meta || {}),
}; }
}); })
const localState = computed({ const localState = computed({
get() { get() {
return value; return value
}, set(val) { },
emit("update:modelValue", val); set(val) {
} emit('update:modelValue', val)
}); },
})
const toggle = () => { const toggle = () => {
localState.value = !localState.value localState.value = !localState.value
} }
@ -38,8 +37,7 @@ const toggle = () => {
// return defineAsyncComponent(()=>import('~icons/material-symbols/'+checkboxMeta?.value?.icon?.unchecked)) // return defineAsyncComponent(()=>import('~icons/material-symbols/'+checkboxMeta?.value?.icon?.unchecked))
// }); // });
/* export default {
/*export default {
name: 'BooleanCell', name: 'BooleanCell',
props: { props: {
column: Object, column: Object,
@ -82,17 +80,15 @@ const toggle = () => {
this.localState = !this.localState this.localState = !this.localState
}, },
}, },
}*/ } */
</script> </script>
<template> <template>
<div <div class="d-flex align-center" :class="{ 'justify-center': !isForm, 'nc-cell-hover-show': !localState }">
class="d-flex align-center" :class="{ 'justify-center': !isForm, 'nc-cell-hover-show': !localState }"> <!-- <span :is="localState ? checkedIcon : uncheckedIcon" small :color="checkboxMeta.color" @click="toggle"> -->
<!-- <span :is="localState ? checkedIcon : uncheckedIcon" small :color="checkboxMeta.color" @click="toggle">--> <!-- {{ localState ? checkedIcon : uncheckedIcon }} -->
<!-- {{ localState ? checkedIcon : uncheckedIcon }}--> <!-- </span> -->
<!-- </span>-->
<input type="checkbox" <input v-model="localState" type="checkbox" />
v-model="localState">
</div> </div>
</template> </template>

542
packages/nc-gui-v2/components/editable-cell/EditableAttachmentCell.vue

@ -1,542 +0,0 @@
<script>
import FileSaver from 'file-saver'
import draggable from 'vuedraggable'
import { isImage } from '@/components/project/spreadsheet/helpers/imageExt'
export default {
name: 'EditableAttachmentCell',
components: { Draggable: draggable },
props: ['dbAlias', 'value', 'active', 'isLocked', 'meta', 'column', 'isPublicGrid', 'isForm', 'isPublicForm', 'viewId'],
data: () => ({
carousel: null,
uploading: false,
localState: '',
dialog: false,
showImage: false,
selectedImage: null,
dragOver: false,
localFilesState: [],
urlString: '',
}),
watch: {
value(val, prev) {
try {
this.localState = ((typeof val === 'string' && val !== prev ? JSON.parse(val) : val) || []).filter(Boolean)
} catch (e) {
this.localState = []
}
},
},
created() {
try {
this.localState = ((typeof this.value === 'string' ? JSON.parse(this.value) : this.value) || []).filter(Boolean)
} catch (e) {
this.localState = []
}
document.addEventListener('keydown', this.onArrowDown)
},
beforeUnmount() {
document.removeEventListener('keydown', this.onArrowDown)
},
mounted() {},
methods: {
async uploadByUrl() {
const data = await this.$api.storage.uploadByUrl(
{
path: ['noco', this.projectName, this.meta.title, this.column.title].join('/'),
},
[
{
url: this.urlString,
},
],
)
this.localState.push(...data)
},
openUrl(url, target) {
window.open(url, target)
},
isImage,
hideIfVisible() {
if (this.showImage) {
this.showImage = false
}
},
selectImage(selectedImage, i) {
this.carousel = i
this.selectedImage = selectedImage
this.showImage = true
},
addFile() {
if (!this.isLocked) {
this.$refs.file.click()
}
},
async onFileSelection() {
if (this.isPublicGrid) {
return
}
if (!this.$refs.file.files || !this.$refs.file.files.length) {
return
}
if (this.isPublicForm) {
this.localFilesState.push(
...Array.from(this.$refs.file.files).map((file) => {
const res = { file, title: file.name }
if (isImage(file.name, file.mimetype)) {
const reader = new FileReader()
reader.onload = (e) => {
this.$set(res, 'data', e.target.result)
}
reader.readAsDataURL(file)
}
return res
}),
)
this.$emit(
'input',
this.localFilesState.map((f) => f.file),
)
return
}
this.uploading = true
for (const file of this.$refs.file.files) {
try {
const data = await this.$api.storage.upload(
{
path: ['noco', this.projectName, this.meta.title, this.column.title].join('/'),
},
{
files: file,
json: '{}',
},
)
this.localState.push(...data)
} catch (e) {
this.$toast.error(e.message || 'Some internal error occurred').goAway(3000)
this.uploading = false
return
}
}
this.uploading = false
this.$emit('input', JSON.stringify(this.localState))
this.$emit('update')
},
onOrderUpdate() {
this.$emit('input', JSON.stringify(this.localState))
this.$emit('update')
},
removeItem(i) {
if (this.isPublicForm) {
this.localFilesState.splice(i, 1)
this.$emit(
'input',
this.localFilesState.map((f) => f.file),
)
} else {
this.localState.splice(i, 1)
this.$emit('input', JSON.stringify(this.localState))
}
this.$emit('update')
},
downloadItem(item) {
FileSaver.saveAs(item.url || item.data, item.title)
},
onArrowDown(e) {
if (!this.showImage) {
return
}
e = e || window.event
// eslint-disable-next-line eqeqeq
if (e.keyCode == '37') {
this.carousel = (this.carousel || this.localState.length) - 1
// eslint-disable-next-line eqeqeq
} else if (e.keyCode == '39') {
this.carousel = ++this.carousel % this.localState.length
// eslint-disable-next-line eqeqeq
} else if (e.keyCode == '27') {
this.hideIfVisible()
}
},
async onFileDrop(e) {
this.dragOver = false
this.$refs.file.files = e.dataTransfer.files
await this.onFileSelection()
},
},
}
</script>
<template>
<div
class="main h-100"
@dragover.prevent="dragOver = true"
@dragenter.prevent="dragOver = true"
@dragexit="dragOver = false"
@dragleave="dragOver = false"
@dragend="dragOver = false"
@drop.prevent.stop="onFileDrop"
>
<div v-show="(isForm || _isUIAllowed('tableAttachment')) && dragOver" class="drop-overlay">
<div>
<v-icon small> mdi-cloud-upload-outline </v-icon>
<span class="caption font-weight-bold">Drop here</span>
</div>
</div>
<div class="d-flex align-center img-container">
<div class="d-flex no-overflow">
<div
v-for="(item, i) in isPublicForm ? localFilesState : localState"
:key="item.url || item.title"
class="thumbnail align-center justify-center d-flex"
>
<v-tooltip bottom>
<template #activator="{ on }">
<v-img
v-if="isImage(item.title, item.mimetype)"
lazy-src="https://via.placeholder.com/60.png?text=Loading..."
alt="#"
max-height="99px"
contain
:src="item.url || item.data"
v-on="on"
@click="selectImage(item.url || item.data, i)"
>
<template #placeholder>
<v-skeleton-loader type="image" :height="active ? 33 : 22" :width="active ? 33 : 22" />
</template>
</v-img>
<v-icon v-else-if="item.icon" :size="active ? 33 : 22" v-on="on" @click="openUrl(item.url || item.data, '_blank')">
{{ item.icon }}
</v-icon>
<v-icon v-else :size="active ? 33 : 22" v-on="on" @click="openUrl(item.url || item.data, '_blank')">
mdi-file
</v-icon>
</template>
<span>{{ item.title }}</span>
</v-tooltip>
</div>
</div>
<div
v-if="isForm || (active && !isPublicGrid && !isLocked)"
class="add d-flex align-center justify-center px-1 nc-attachment-add"
@click="addFile"
>
<v-icon v-if="uploading" small color="primary" class="nc-attachment-add-spinner"> mdi-loading mdi-spin </v-icon>
<v-btn v-else-if="isForm" outlined x-small color="" text class="nc-attachment-add-btn">
<v-icon x-small color=""> mdi-plus </v-icon>
Attachment
</v-btn>
<v-icon v-else-if="_isUIAllowed('tableAttachment')" v-show="active" small color="primary nc-attachment-add-icon">
mdi-plus
</v-icon>
</div>
<v-spacer />
<v-icon class="expand-icon mr-1" x-small color="primary" @click.stop="dialog = true"> mdi-arrow-expand </v-icon>
<input ref="file" type="file" multiple class="d-none" @change="onFileSelection" />
</div>
<v-dialog v-if="dialog" v-model="dialog" width="800">
<v-card class="h-100 images-modal">
<v-card-text class="h-100 backgroundColor">
<div class="d-flex mx-2">
<v-btn
v-if="(isForm || _isUIAllowed('tableAttachment')) && !isPublicGrid && !isLocked"
small
class="my-4"
:loading="uploading"
@click="addFile"
>
<v-icon small class="mr-2"> mdi-link-variant </v-icon>
<span class="caption">Attach File</span>
</v-btn>
<!-- <v-text-field v-model="urlString" @keypress.enter="uploadByUrl" /> -->
</div>
<div class="d-flex flex-wrap h-100">
<v-container fluid style="max-height: calc(90vh - 80px); overflow-y: auto">
<Draggable v-model="localState" class="row" @update="onOrderUpdate">
<v-col v-for="(item, i) in isPublicForm ? localFilesState : localState" :key="i" cols="4">
<v-card
class="modal-thumbnail-card align-center justify-center d-flex"
height="200px"
style="position: relative"
>
<v-icon
v-if="_isUIAllowed('tableAttachment') && !isPublicGrid && !isLocked"
small
class="remove-icon"
@click="removeItem(i)"
>
mdi-close-circle
</v-icon>
<v-icon color="grey" class="download-icon" @click.stop="downloadItem(item, i)"> mdi-download </v-icon>
<div class="pa-2 d-flex align-center" style="height: 200px">
<img
v-if="isImage(item.title, item.mimetype)"
style="max-height: 100%; max-width: 100%"
alt="#"
:src="item.url || item.data"
@click="selectImage(item.url, i)"
/>
<v-icon v-else-if="item.icon" size="33" @click="openUrl(item.url || item.data, '_blank')">
{{ item.icon }}
</v-icon>
<v-icon v-else size="33" @click="openUrl(item.url || item.data, '_blank')"> mdi-file </v-icon>
</div>
</v-card>
<p class="caption mt-2 modal-title" :title="item.title">
{{ item.title }}
</p>
</v-col>
</Draggable>
</v-container>
</div>
</v-card-text>
</v-card>
</v-dialog>
<v-overlay v-if="showImage" v-model="showImage" z-index="99999" opacity=".93">
<div v-click-outside="hideIfVisible" class="image-overlay-container">
<template v-if="showImage && selectedImage">
<v-carousel v-model="carousel" height="calc(100vh - 100px)" hide-delimiters>
<v-carousel-item v-for="(item, i) in isPublicForm ? localFilesState : localState" :key="i">
<div class="mx-auto d-flex flex-column justify-center align-center" style="min-height: 100px">
<p class="title text-center">
{{ item.title }}
<v-icon class="ml-3" color="grey" @click.stop="downloadItem(item, i)"> mdi-download </v-icon>
</p>
<div style="width: 90vh; height: calc(100vh - 150px)" class="d-flex align-center justify-center">
<img
v-if="isImage(item.title, item.mimetype)"
style="max-width: 90vh; max-height: calc(100vh - 100px)"
:src="item.url || item.data"
/>
<v-icon v-else-if="item.icon" size="55">
{{ item.icon }}
</v-icon>
<v-icon v-else size="55"> mdi-file </v-icon>
</div>
</div>
</v-carousel-item>
</v-carousel>
</template>
<v-sheet
v-if="showImage"
class="mx-auto align-center justify-center"
max-width="90vw"
height="80px"
style="background: transparent"
>
<v-slide-group multiple show-arrows>
<v-slide-item v-for="(item, i) in isPublicForm ? localFilesState : localState" :key="i">
<v-card
:key="i"
class="ma-2 pa-2 d-flex align-center justify-center overlay-thumbnail"
:class="{ active: carousel === i }"
width="48"
height="48"
@click="carousel = i"
>
<img
v-if="isImage(item.title, item.mimetype)"
style="max-width: 100%; max-height: 100%"
:src="item.url || item.data"
/>
<v-icon v-else-if="item.icon" size="48">
{{ item.icon }}
</v-icon>
<v-icon v-else size="48"> mdi-file </v-icon>
</v-card>
</v-slide-item>
</v-slide-group>
</v-sheet>
<v-icon x-large class="close-icon" @click="showImage = false"> mdi-close-circle </v-icon>
</div>
</v-overlay>
</div>
</template>
<style scoped lang="scss">
.img-container {
margin: 0 -2px;
}
.no-overflow {
overflow: hidden;
}
.add {
transition: 0.2s background-color;
/*background-color: #666666ee;*/
border-radius: 4px;
height: 33px;
margin: 5px 2px;
}
.add:hover {
/*background-color: #66666699;*/
}
.thumbnail {
height: 99px;
width: 99px;
margin: 2px;
border-radius: 4px;
}
.thumbnail img {
/*max-height: 33px;*/
max-width: 99px;
}
.main {
min-height: 20px;
position: relative;
height: auto;
}
.expand-icon {
margin-left: 8px;
border-radius: 2px;
/*opacity: 0;*/
transition: 0.3s background-color;
}
.expand-icon:hover {
/*opacity: 1;*/
background-color: var(--v-primary-lighten4);
}
.modal-thumbnail img {
height: 50px;
max-width: 100%;
border-radius: 4px;
}
.modal-thumbnail {
position: relative;
margin: 10px 10px;
}
.remove-icon {
position: absolute;
top: 5px;
right: 5px;
}
.modal-thumbnail-card {
.download-icon {
position: absolute;
bottom: 5px;
right: 5px;
opacity: 0;
transition: 0.4s opacity;
}
&:hover .download-icon {
opacity: 1;
}
}
.image-overlay-container {
max-height: 100vh;
overflow-y: auto;
position: relative;
}
.image-overlay-container .close-icon {
position: fixed;
top: 15px;
right: 15px;
}
.overlay-thumbnail {
transition: 0.4s transform, 0.4s opacity;
opacity: 0.5;
}
.overlay-thumbnail.active {
transform: scale(1.4);
opacity: 1;
}
.overlay-thumbnail:hover {
opacity: 1;
}
.modal-title {
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
overflow: hidden;
}
.modal-thumbnail-card {
transition: 0.4s transform;
}
.modal-thumbnail-card:hover {
transform: scale(1.05);
}
.drop-overlay {
z-index: 5;
position: absolute;
width: 100%;
height: 100%;
left: 0;
right: 0;
top: 0;
bottom: 5px;
background: #aaaaaa44;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
}
.expand-icon {
opacity: 0;
transition: 0.4s opacity;
}
.main:hover .expand-icon {
opacity: 1;
}
</style>
<!--
/**
* @copyright Copyright (c) 2021, Xgene Cloud Ltd
*
* @author Naveen MR <oof1lab@gmail.com>
* @author Pranav C Balan <pranavxc@gmail.com>
* @author Wing-Kam Wong <wingkwong.code@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
-->

32
packages/nc-gui-v2/components/editable-cell/Text.vue

@ -1,25 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "@vue/reactivity"; import { computed } from '@vue/reactivity'
import { onMounted } from "vue"; import { onMounted } from 'vue'
const root = ref<HTMLInputElement>(); const { modelValue: value } = defineProps<{ modelValue: any }>()
const { modelValue:value } = defineProps<{ modelValue: any }>() const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(["update:modelValue"]);
const root = ref<HTMLInputElement>()
const localState = computed({ const localState = computed({
get() { get() {
return value; return value
}, set(val) { },
emit("update:modelValue", val); set(val) {
} emit('update:modelValue', val)
}); },
})
onMounted(() => { onMounted(() => {
root.value?.focus() root.value?.focus()
}); })
/*export default { /* export default {
name: 'TextCell', name: 'TextCell',
props: { props: {
value: [String, Object, Number, Boolean, Array], value: [String, Object, Number, Boolean, Array],
@ -53,12 +55,12 @@ onMounted(() => {
mounted() { mounted() {
this.$el.focus() this.$el.focus()
}, },
}*/ } */
</script> </script>
<template> <template>
<input v-model="localState" ref="root"/> <input ref="root" v-model="localState" />
<!-- v-on="parentListeners" />--> <!-- v-on="parentListeners" /> -->
</template> </template>
<style scoped> <style scoped>

37
packages/nc-gui-v2/components/editable-cell/TextArea.vue

@ -1,27 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from '@vue/reactivity'
import { onMounted } from 'vue'
const { modelValue: value } = defineProps<{ modelValue: any }>()
import { computed } from "@vue/reactivity"; const emit = defineEmits(['update:modelValue'])
import { onMounted } from "vue";
const root = ref<HTMLInputElement>(); const root = ref<HTMLInputElement>()
const { modelValue:value } = defineProps<{ modelValue: any }>()
const emit = defineEmits(["update:modelValue"]);
const localState = computed({ const localState = computed({
get() { get() {
return value; return value
}, set(val) { },
emit("update:modelValue", val); set(val) {
} emit('update:modelValue', val)
}); },
})
onMounted(() => { onMounted(() => {
root.value?.focus() root.value?.focus()
}); })
/*export default { /* export default {
name: 'TextAreaCell', name: 'TextAreaCell',
props: { props: {
value: String, value: String,
@ -54,17 +53,11 @@ onMounted(() => {
mounted() { mounted() {
this.$refs.textarea && this.$refs.textarea.focus() this.$refs.textarea && this.$refs.textarea.focus()
}, },
}*/ } */
</script> </script>
<template> <template>
<textarea <textarea ref="root" v-model="localState" rows="4" v-on="parentListeners" @keydown.alt.enter.stop @keydown.shift.enter.stop />
v-model="localState" ref="root"
rows="4"
v-on="parentListeners"
@keydown.alt.enter.stop
@keydown.shift.enter.stop
/>
</template> </template>
<style scoped> <style scoped>

61
packages/nc-gui-v2/components/smartsheet/EditableCell.vue

@ -1,22 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "@vue/reactivity"; import { computed } from '@vue/reactivity'
import type { ColumnType } from "nocodb-sdk"; import type { ColumnType } from 'nocodb-sdk'
import useColumn from "~/composables/useColumn"; import useColumn from '~/composables/useColumn'
const { column, modelValue: value } = defineProps<{ column: ColumnType; modelValue: any }>(); const { column, modelValue: value } = defineProps<{ column: ColumnType; modelValue: any }>()
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(['update:modelValue'])
provide("column", column); provide('column', column)
const localState = computed({ const localState = computed({
get() { get() {
return value; return value
}, },
set(val) { set(val) {
emit("update:modelValue", val); emit('update:modelValue', val)
} },
}); })
const { const {
isSet, isSet,
@ -33,28 +33,12 @@ const {
isCurrency, isCurrency,
isAttachment, isAttachment,
isTextArea, isTextArea,
isString isString,
} = useColumn(column); } = useColumn(column)
</script> </script>
<template> <template>
<div class="nc-cell" @keydown.stop.left @keydown.stop.right @keydown.stop.up @keydown.stop.down> <div class="nc-cell" @keydown.stop.left @keydown.stop.right @keydown.stop.up @keydown.stop.down>
<!-- <EditableAttachmentCell -->
<!-- v-if="isAttachment" -->
<!-- /> -->
<!-- v-model="localState"
:active="active"
:db-alias="dbAlias"
:meta="meta"
:is-form="isForm"
:column="column"
:is-public-grid="isPublic && !isForm"
:is-public-form="isPublic && isForm"
:view-id="viewId"
:is-locked="isLocked"
v-on="$listeners"
/> -->
<!-- <RatingCell --> <!-- <RatingCell -->
<!-- v-if="isRating" --> <!-- v-if="isRating" -->
<!-- /> --> <!-- /> -->
@ -79,8 +63,6 @@ const {
<!-- v-on="parentListeners" --> <!-- v-on="parentListeners" -->
<!-- />&ndash;&gt; --> <!-- />&ndash;&gt; -->
<!-- <IntegerCell --> <!-- <IntegerCell -->
<!-- v-else-if="isInt" --> <!-- v-else-if="isInt" -->
<!-- /> --> <!-- /> -->
@ -174,17 +156,26 @@ const {
v-on="parentListeners" v-on="parentListeners"
/> --> /> -->
<EditableCellBoolean v-else-if="isBoolean" v-model="localState" />
<EditableCellBoolean
v-else-if="isBoolean"
v-model="localState"
/>
<!-- &lt;!&ndash; v-model="localState" --> <!-- &lt;!&ndash; v-model="localState" -->
<!-- :column="column" --> <!-- :column="column" -->
<!-- :is-form="isForm" --> <!-- :is-form="isForm" -->
<!-- v-on="parentListeners" --> <!-- v-on="parentListeners" -->
<!-- />&ndash;&gt; --> <!-- />&ndash;&gt; -->
<EditableCellAttachment v-if="isAttachment" v-model="localState" />
<!-- v-model="localState"
:active="active"
:db-alias="dbAlias"
:meta="meta"
:is-form="isForm"
:column="column"
:is-public-grid="isPublic && !isForm"
:is-public-form="isPublic && isForm"
:view-id="viewId"
:is-locked="isLocked"
v-on="$listeners"
/> -->
<EditableCellText v-else v-model="localState" /> <EditableCellText v-else v-model="localState" />
<!-- v-on="$listeners" <span v-if="hint" class="nc-hint">{{ hint }}</span> --> <!-- v-on="$listeners" <span v-if="hint" class="nc-hint">{{ hint }}</span> -->

254
packages/nc-gui-v2/components/smartsheet/Grid.vue

@ -1,158 +1,152 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ComputedRef } from "vue"; import { inject, onMounted } from 'vue'
import { inject, onMounted } from "vue"; import { isVirtualCol } from 'nocodb-sdk'
import { isVirtualCol } from "nocodb-sdk"; import type { TableType } from 'nocodb-sdk'
import type { TableType } from "nocodb-sdk"; import useViewData from '~/composables/useViewData'
import useViewData from "~/composables/useViewData";
const meta = inject<ComputedRef<TableType>>("meta"); const meta = inject<TableType>('meta')
// todo: get from parent ( inject or use prop ) // todo: get from parent ( inject or use prop )
const isPublicView = false; const isPublicView = false
const selected = reactive<{ row?: number | null; col?: number | null }>({}); const selected = reactive<{ row?: number | null; col?: number | null }>({})
const editEnabled = ref(false); const editEnabled = ref(false)
provide('isForm', false) provide('isForm', false)
provide('isGrid', true) provide('isGrid', true)
const { loadData, paginationData, formattedData: data } = useViewData(meta); const { loadData, paginationData, formattedData: data } = useViewData(meta)
onMounted(() => loadData({})); onMounted(() => loadData({}))
const selectCell = (row: number, col: number) => { const selectCell = (row: number, col: number) => {
selected.row = row; selected.row = row
selected.col = col; selected.col = col
}; }
onKeyStroke(["Enter"], (e) => { onKeyStroke(['Enter'], (e) => {
if (selected.row !== null && selected.col !== null) { if (selected.row !== null && selected.col !== null) {
editEnabled.value = true; editEnabled.value = true
} }
}); })
</script> </script>
<template> <template>
<table class="xc-row-table nc-grid backgroundColorDefault"> <table class="xc-row-table nc-grid backgroundColorDefault">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
<th v-for="col in meta.columns" :key="col.title"> <th v-for="col in meta.columns" :key="col.title">
{{ col.title }} {{ col.title }}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="({ row }, rowIndex) in data" :key="rowIndex" class="nc-grid-row"> <tr v-for="({ row }, rowIndex) in data" :key="rowIndex" class="nc-grid-row">
<td key="row-index" style="width: 65px" class="caption nc-grid-cell"> <td key="row-index" style="width: 65px" class="caption nc-grid-cell">
<div class="d-flex align-center"> <div class="d-flex align-center">
{{ rowIndex + 1 }} {{ rowIndex + 1 }}
</div> </div>
</td> </td>
<td <td
v-for="(columnObj, colIndex) in meta.columns" v-for="(columnObj, colIndex) in meta.columns"
:key="rowIndex + columnObj.title" :key="rowIndex + columnObj.title"
class="cell pointer nc-grid-cell" class="cell pointer nc-grid-cell"
:class="{ :class="{
active: !isPublicView && selected.col === colIndex && selected.row === rowIndex, active: !isPublicView && selected.col === colIndex && selected.row === rowIndex,
// 'primary-column': primaryValueColumn === columnObj.title, // 'primary-column': primaryValueColumn === columnObj.title,
// 'text-center': isCentrallyAligned(columnObj), // 'text-center': isCentrallyAligned(columnObj),
// 'required': isRequired(columnObj, rowObj), // 'required': isRequired(columnObj, rowObj),
}" }"
:data-col="columnObj.title" :data-col="columnObj.title"
@click="selectCell(rowIndex, colIndex)" @click="selectCell(rowIndex, colIndex)"
@dblclick="editEnabled = true" @dblclick="editEnabled = true"
> >
<!-- @contextmenu=" --> <!-- @contextmenu=" -->
<!-- showRowContextMenu($event, rowObj, rowMeta, row, col, columnObj) --> <!-- showRowContextMenu($event, rowObj, rowMeta, row, col, columnObj) -->
<!-- " --> <!-- " -->
<!-- > --> <!-- > -->
<!-- <virtual-cell --> <!-- <virtual-cell -->
<!-- v-if="isVirtualCol(columnObj)" --> <!-- v-if="isVirtualCol(columnObj)" -->
<!-- :password="password" --> <!-- :password="password" -->
<!-- :is-public="isPublicView" --> <!-- :is-public="isPublicView" -->
<!-- :metas="metas" --> <!-- :metas="metas" -->
<!-- :is-locked="isLocked" --> <!-- :is-locked="isLocked" -->
<!-- :column="columnObj" --> <!-- :column="columnObj" -->
<!-- :row="rowObj" --> <!-- :row="rowObj" -->
<!-- :nodes="nodes" --> <!-- :nodes="nodes" -->
<!-- :meta="meta" --> <!-- :meta="meta" -->
<!-- :api="api" --> <!-- :api="api" -->
<!-- :active="selected.col === col && selected.row === row" --> <!-- :active="selected.col === col && selected.row === row" -->
<!-- :sql-ui="sqlUi" --> <!-- :sql-ui="sqlUi" -->
<!-- :is-new="rowMeta.new" --> <!-- :is-new="rowMeta.new" -->
<!-- v-on="$listeners" --> <!-- v-on="$listeners" -->
<!-- @updateCol=" --> <!-- @updateCol=" -->
<!-- (...args) => --> <!-- (...args) => -->
<!-- updateCol( --> <!-- updateCol( -->
<!-- ...args, --> <!-- ...args, -->
<!-- columnObj.bt --> <!-- columnObj.bt -->
<!-- && meta.columns.find( --> <!-- && meta.columns.find( -->
<!-- (c) => c.column_name === columnObj.bt.column_name, --> <!-- (c) => c.column_name === columnObj.bt.column_name, -->
<!-- ), --> <!-- ), -->
<!-- col, --> <!-- col, -->
<!-- row, --> <!-- row, -->
<!-- ) --> <!-- ) -->
<!-- " --> <!-- " -->
<!-- @saveRow="onCellValueChange(col, row, columnObj, true)" --> <!-- @saveRow="onCellValueChange(col, row, columnObj, true)" -->
<!-- /> --> <!-- /> -->
<!-- <editable-cell --> <!-- <editable-cell -->
<!-- v-else-if=" --> <!-- v-else-if=" -->
<!-- ((isPkAvail || rowMeta.new) --> <!-- ((isPkAvail || rowMeta.new) -->
<!-- && !isView --> <!-- && !isView -->
<!-- && !isLocked --> <!-- && !isLocked -->
<!-- && !isPublicView --> <!-- && !isPublicView -->
<!-- && editEnabled.col === col --> <!-- && editEnabled.col === col -->
<!-- && editEnabled.row === row) --> <!-- && editEnabled.row === row) -->
<!-- || enableEditable(columnObj) --> <!-- || enableEditable(columnObj) -->
<!-- " --> <!-- " -->
<!-- v-model="rowObj[columnObj.title]" --> <!-- v-model="rowObj[columnObj.title]" -->
<!-- :column="columnObj" --> <!-- :column="columnObj" -->
<!-- :meta="meta" --> <!-- :meta="meta" -->
<!-- :active="selected.col === col && selected.row === row" --> <!-- :active="selected.col === col && selected.row === row" -->
<!-- :sql-ui="sqlUi" --> <!-- :sql-ui="sqlUi" -->
<!-- :db-alias="nodes.dbAlias" --> <!-- :db-alias="nodes.dbAlias" -->
<!-- :is-locked="isLocked" --> <!-- :is-locked="isLocked" -->
<!-- :is-public="isPublicView" --> <!-- :is-public="isPublicView" -->
<!-- :view-id="viewId" --> <!-- :view-id="viewId" -->
<!-- @save="editEnabled = {}; onCellValueChange(col, row, columnObj, true);" --> <!-- @save="editEnabled = {}; onCellValueChange(col, row, columnObj, true);" -->
<!-- @cancel="editEnabled = {}" --> <!-- @cancel="editEnabled = {}" -->
<!-- @update="onCellValueChange(col, row, columnObj, false)" --> <!-- @update="onCellValueChange(col, row, columnObj, false)" -->
<!-- @blur="onCellValueChange(col, row, columnObj, true)" --> <!-- @blur="onCellValueChange(col, row, columnObj, true)" -->
<!-- @input="unsaved = true" --> <!-- @input="unsaved = true" -->
<!-- @navigateToNext="navigateToNext" --> <!-- @navigateToNext="navigateToNext" -->
<!-- @navigateToPrev="navigateToPrev" --> <!-- @navigateToPrev="navigateToPrev" -->
<!-- /> --> <!-- /> -->
<span v-if="isVirtualCol(columnObj)" /> <span v-if="isVirtualCol(columnObj)" />
<SmartsheetEditableCell <SmartsheetEditableCell
v-else-if="editEnabled && selected.col === colIndex && selected.row === rowIndex" v-else-if="editEnabled && selected.col === colIndex && selected.row === rowIndex"
:column="columnObj" v-model="row[columnObj.title]"
v-model="row[columnObj.title]" :column="columnObj"
/> />
<SmartsheetCell <SmartsheetCell v-else :column="columnObj" :value="row[columnObj.title]" />
v-else <!-- :selected="selected.col === col && selected.row === row" -->
:column="columnObj" <!-- :is-locked="isLocked" -->
:value="row[columnObj.title]" <!-- :column="columnObj" -->
/> <!-- :meta="meta" -->
<!-- :selected="selected.col === col && selected.row === row" --> <!-- :db-alias="nodes.dbAlias" -->
<!-- :is-locked="isLocked" --> <!-- :value="rowObj[columnObj.title]" -->
<!-- :column="columnObj" --> <!-- :sql-ui="sqlUi" -->
<!-- :meta="meta" --> <!-- @enableedit=" -->
<!-- :db-alias="nodes.dbAlias" --> <!-- makeSelected(col, row); -->
<!-- :value="rowObj[columnObj.title]" --> <!-- makeEditable(col, row, columnObj.ai, rowMeta); -->
<!-- :sql-ui="sqlUi" --> <!-- " -->
<!-- @enableedit=" --> <!-- /> -->
<!-- makeSelected(col, row); --> </td>
<!-- makeEditable(col, row, columnObj.ai, rowMeta); --> </tr>
<!-- " -->
<!-- /> -->
</td>
</tr>
</tbody> </tbody>
</table> </table>
</template> </template>
@ -201,9 +195,7 @@ td.active::after {
} }
td.active::before { td.active::before {
background: #0040bc /*var(--v-primary-base)*/ background: #0040bc /*var(--v-primary-base)*/;
;
opacity: 0.1; opacity: 0.1;
} }
</style> </style>

14
packages/nc-gui-v2/composables/useAttachment.ts

@ -0,0 +1,14 @@
// todo:
export default () => {
const localFilesState = reactive([]);
const attachments = ref([]);
const uploadFile = () => {
};
return { uploadFile, localFilesState, attachments };
}

5
packages/nc-gui-v2/composables/useTabs.ts

@ -11,10 +11,9 @@ export default () => {
const activeTab = useState<number>('activeTab', () => 0) const activeTab = useState<number>('activeTab', () => 0)
const addTab = (tabMeta: TabItem) => { const addTab = (tabMeta: TabItem) => {
const tabIndex = tabs.value.findIndex((tab) => tab.id === tabMeta.id)
const tabIndex = tabs.value.findIndex(tab => tab.id === tabMeta.id)
// if tab already found make it active // if tab already found make it active
if(tabIndex>-1){ if (tabIndex > -1) {
activeTab.value = tabIndex activeTab.value = tabIndex
} }
// if tab not found add it // if tab not found add it

2
packages/nc-gui-v2/pages/nc/[projectId].vue

@ -17,7 +17,7 @@ watch(
async (newVal, oldVal) => { async (newVal, oldVal) => {
if (newVal !== oldVal) { if (newVal !== oldVal) {
clearTabs() clearTabs()
if(newVal) { if (newVal) {
await loadProject(newVal as string) await loadProject(newVal as string)
await loadTables() await loadTables()
} }

37
packages/nc-gui-v2/pages/projects/create-external.vue

@ -10,7 +10,7 @@ import MaterialSymbolsRocketLaunchOutline from '~icons/material-symbols/rocket-l
const name = ref('') const name = ref('')
const loading = ref(false) const loading = ref(false)
const valid = ref(false) const valid = ref(false)
const testSuccess = ref(false) const testSuccess = ref(true)
const projectDatasource = ref(getDefaultConnectionConfig('mysql2')) const projectDatasource = ref(getDefaultConnectionConfig('mysql2'))
const inflection = reactive({ const inflection = reactive({
tableName: 'camelize', tableName: 'camelize',
@ -140,24 +140,23 @@ const testConnection = async () => {
<v-text-field v-model="projectDatasource.connection.database" density="compact" label="Database name" /> <v-text-field v-model="projectDatasource.connection.database" density="compact" label="Database name" />
</v-col> </v-col>
<!-- todo: reimplement? <!-- <v-col cols="6">
<v-col cols="6"> <v-text-field
<v-text-field v-model="inflection.tableName"
v-model="inflection.tableName" density="compact"
density="compact" type="password"
type="password" label="Password"
label="Password" />
/> </v-col>
</v-col> <v-col cols="6">
<v-col cols="6"> <v-text-field
<v-text-field v-model="inflection.columnName"
v-model="inflection.columnName" density="compact"
density="compact" label="Database name"
label="Database name" />
/> </v-col> -->
</v-col> </v-row>
--> </v-container>
</v-row>
<div class="d-flex justify-center" style="gap: 4px"> <div class="d-flex justify-center" style="gap: 4px">
<v-btn :disabled="!testSuccess" large :loading="loading" color="primary" @click="createProject"> <v-btn :disabled="!testSuccess" large :loading="loading" color="primary" @click="createProject">

7
packages/nc-gui-v2/utils/fileUtils.ts

@ -0,0 +1,7 @@
const imageExt = ['jpeg', 'gif', 'png', 'png', 'svg', 'bmp', 'ico', 'jpg', 'webp']
const isImage = (name: string, mimetype?: string) => {
return imageExt.some((e) => name?.toLowerCase().endsWith(`.${e}`)) || mimetype?.startsWith('image/')
}
export { isImage, imageExt }
Loading…
Cancel
Save