Browse Source

Merge pull request #2972 from nocodb/feat/attachment-cell

feat(gui-v2): implement attachment cell
pull/2983/head
navi 2 years ago committed by GitHub
parent
commit
b0c2a3faa4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      packages/nc-gui-v2/components.d.ts
  2. 338
      packages/nc-gui-v2/components/cell/Attachment.vue
  3. 34
      packages/nc-gui-v2/components/cell/Currency.vue
  4. 11
      packages/nc-gui-v2/components/cell/Email.vue
  5. 13
      packages/nc-gui-v2/components/cell/Float.vue
  6. 15
      packages/nc-gui-v2/components/cell/Integer.vue
  7. 13
      packages/nc-gui-v2/components/cell/JsonEditableCell.vue
  8. 54
      packages/nc-gui-v2/components/cell/MultiSelect.vue
  9. 35
      packages/nc-gui-v2/components/cell/SingleSelect.vue
  10. 17
      packages/nc-gui-v2/components/cell/Text.vue
  11. 17
      packages/nc-gui-v2/components/cell/TextArea.vue
  12. 18
      packages/nc-gui-v2/components/cell/Url.vue
  13. 148
      packages/nc-gui-v2/components/cell/attachment/Carousel.vue
  14. 211
      packages/nc-gui-v2/components/cell/attachment/Modal.vue
  15. 168
      packages/nc-gui-v2/components/cell/attachment/index.vue
  16. 65
      packages/nc-gui-v2/components/cell/attachment/sort.ts
  17. 157
      packages/nc-gui-v2/components/cell/attachment/utils.ts
  18. 58
      packages/nc-gui-v2/components/general/Overlay.vue
  19. 195
      packages/nc-gui-v2/components/smartsheet/Cell.vue
  20. 84
      packages/nc-gui-v2/components/smartsheet/Grid.vue
  21. 2
      packages/nc-gui-v2/components/smartsheet/sidebar/index.vue
  22. 101
      packages/nc-gui-v2/composables/useViewColumns.ts
  23. 1
      packages/nc-gui-v2/context/index.ts
  24. 2
      packages/nc-gui-v2/nuxt.config.ts
  25. 1245
      packages/nc-gui-v2/package-lock.json
  26. 10
      packages/nc-gui-v2/package.json
  27. 3
      packages/nc-gui-v2/tsconfig.json
  28. 4
      packages/nc-gui-v2/utils/urlUtils.ts

8
packages/nc-gui-v2/components.d.ts vendored

@ -8,10 +8,10 @@ export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
AAlert: typeof import('ant-design-vue/es')['Alert']
AAnchorLink: typeof import('ant-design-vue/es')['AnchorLink']
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACarousel: typeof import('ant-design-vue/es')['Carousel']
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
ACol: typeof import('ant-design-vue/es')['Col']
ACollapse: typeof import('ant-design-vue/es')['Collapse']
@ -20,7 +20,6 @@ declare module '@vue/runtime-core' {
ADivider: typeof import('ant-design-vue/es')['Divider']
ADrawer: typeof import('ant-design-vue/es')['Drawer']
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
ADropdownButton: typeof import('ant-design-vue/es')['DropdownButton']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AInput: typeof import('ant-design-vue/es')['Input']
@ -46,20 +45,17 @@ declare module '@vue/runtime-core' {
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
ASpace: typeof import('ant-design-vue/es')['Space']
ASkeletonImage: typeof import('ant-design-vue/es')['SkeletonImage']
ASpin: typeof import('ant-design-vue/es')['Spin']
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATable: typeof import('ant-design-vue/es')['Table']
ATableColumn: typeof import('ant-design-vue/es')['TableColumn']
ATableColumnGroup: typeof import('ant-design-vue/es')['TableColumnGroup']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
RouterLink: typeof import('vue-router')['RouterLink']

338
packages/nc-gui-v2/components/cell/Attachment.vue

@ -1,338 +0,0 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import { inject, ref, useProject, watchEffect } from '#imports'
import { useNuxtApp } from '#app'
import { ColumnInj, MetaInj } from '~/context'
import { NOCO } from '~/lib'
import { isImage } from '~/utils'
import MaterialPlusIcon from '~icons/mdi/plus'
import MaterialArrowExpandIcon from '~icons/mdi/arrow-expand'
interface Props {
modelValue: string | any[] | null
}
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const isPublicForm = inject<boolean>('isPublicForm', false)
const isForm = inject<boolean>('isForm', false)
const meta = inject(MetaInj)
const column = inject(ColumnInj)
const editEnabled = inject<boolean>('editEnabled', false)
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: unknown) => {
// todo: implement
}
const openUrl = (url: string, target = '_blank') => {
window.open(url, target)
}
const addFile = () => {
fileInput.value?.click()
}
const onFileSelection = async (e: unknown) => {
// 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>

34
packages/nc-gui-v2/components/cell/Currency.vue

@ -1,24 +1,22 @@
<script setup lang="ts">
import { computed, inject } from '#imports'
import { ColumnInj } from '~/context'
import { computed, inject, ref, useVModel } from '#imports'
import { ColumnInj, EditModeInj } from '~/context'
const { modelValue: value } = defineProps<Props>()
interface Props {
modelValue: number
}
const props = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const column = inject(ColumnInj)
const editEnabled = inject<boolean>('editEnabled')
const editEnabled = inject(EditModeInj, ref(false))
interface Props {
modelValue: number
}
const root = ref<HTMLInputElement>()
const localState = computed({
get: () => value,
set: (val) => emit('update:modelValue', val),
})
const vModel = useVModel(props, 'modelValue', emit)
const currencyMeta = computed(() => {
return {
@ -29,22 +27,20 @@ const currencyMeta = computed(() => {
})
const currency = computed(() => {
try {
return isNaN(value)
? value
return isNaN(vModel.value)
? vModel.value
: new Intl.NumberFormat(currencyMeta?.value?.currency_locale || 'en-US', {
style: 'currency',
currency: currencyMeta?.value?.currency_code || 'USD',
}).format(value)
}).format(vModel.value)
} catch (e) {
return value
return vModel.value
}
})
</script>
<template>
<input v-if="editEnabled" ref="root" v-model="localState" />
<span v-else-if="value">{{ currency }}</span>
<input v-if="editEnabled" ref="root" v-model="vModel" />
<span v-else-if="vModel">{{ currency }}</span>
<span v-else />
</template>
<style scoped></style>

11
packages/nc-gui-v2/components/cell/Email.vue

@ -1,9 +1,10 @@
<script lang="ts" setup>
import { computed } from '#imports'
import { computed, inject, useVModel } from '#imports'
import { isEmail } from '~/utils'
import { EditModeInj } from '~/context'
interface Props {
modelValue: string
modelValue: string | null
}
interface Emits {
@ -14,13 +15,13 @@ const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const root = ref<HTMLInputElement>()
const editEnabled = inject<boolean>('editEnabled')
const editEnabled = inject(EditModeInj, ref(false))
const vModel = useVModel(props, 'modelValue', emits)
const validEmail = computed(() => isEmail(vModel.value))
const focus = (el: HTMLInputElement) => el.focus()
</script>
<template>

13
packages/nc-gui-v2/components/cell/Float.vue

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { computed, inject, onMounted, ref } from '#imports'
import { inject, ref, useVModel } from '#imports'
import { EditModeInj } from '~/context'
interface Props {
modelValue: number
@ -13,21 +14,17 @@ const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const editEnabled = inject<boolean>('editEnabled')
const root = ref<HTMLInputElement>()
const editEnabled = inject(EditModeInj, ref(false))
const vModel = useVModel(props, 'modelValue', emits)
onMounted(() => {
root.value?.focus()
})
const focus = (el: HTMLInputElement) => el.focus()
</script>
<template>
<input
v-if="editEnabled"
ref="root"
:ref="focus"
v-model="vModel"
class="outline-none pa-0 border-none w-full h-full prose-sm"
type="number"

15
packages/nc-gui-v2/components/cell/Integer.vue

@ -1,4 +1,7 @@
<script setup lang="ts">
import { inject, ref, useVModel } from '#imports'
import { EditModeInj } from '~/context'
interface Props {
modelValue: number
}
@ -11,15 +14,11 @@ const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const editEnabled = inject<boolean>('editEnabled')
const root = ref<HTMLInputElement>()
const editEnabled = inject(EditModeInj, ref(false))
const vModel = useVModel(props, 'modelValue', emits)
const vModel = useVModel(props, 'modelValue', emit)
onMounted(() => {
root.value?.focus()
})
const focus = (el: HTMLInputElement) => el.focus()
function onKeyDown(evt: KeyboardEvent) {
return evt.key === '.' && evt.preventDefault()
@ -29,7 +28,7 @@ function onKeyDown(evt: KeyboardEvent) {
<template>
<input
v-if="editEnabled"
ref="root"
:ref="focus"
v-model="vModel"
class="outline-none pa-0 border-none w-full h-full prose-sm"
type="number"

13
packages/nc-gui-v2/components/cell/JsonEditableCell.vue

@ -1,6 +1,7 @@
<script lang="ts" setup>
import MonacoJsonObjectEditor from '@/components/monaco/Editor.vue'
import { inject } from '#imports'
import { computed, inject } from '#imports'
import { EditModeInj } from '~/context'
interface Props {
modelValue: string | Record<string, any>
@ -11,7 +12,7 @@ const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue', 'cancel'])
const editEnabled = inject('editEnabled')
const editEnabled = inject(EditModeInj)
let expand = $ref(false)
@ -19,7 +20,7 @@ let isValid = $ref(true)
let error = $ref()
const localState = computed({
const vModel = computed({
get: () => (typeof props.modelValue === 'string' ? JSON.parse(props.modelValue) : props.modelValue),
set: (val) => {
if (props.isForm) {
@ -30,7 +31,7 @@ const localState = computed({
function save() {
expand = false
emits('update:modelValue', JSON.stringify(props.modelValue))
emits('update:modelValue', JSON.stringify(vModel.value))
}
function validate(n: boolean, e: any) {
@ -69,14 +70,14 @@ export default {
</div>
<MonacoJsonObjectEditor
v-if="expand"
v-model="localState"
v-model="vModel"
class="text-left caption"
style="width: 300px; min-height: min(600px, 80vh); min-width: 100%"
@validate="validate"
/>
<MonacoJsonObjectEditor
v-else
v-model="localState"
v-model="vModel"
class="text-left caption"
style="width: 300px; min-height: 200px; min-width: 100%"
@validate="validate"

54
packages/nc-gui-v2/components/cell/MultiSelect.vue

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { computed, inject } from '#imports'
import { ColumnInj } from '~/context'
import { ColumnInj, EditModeInj } from '~/context'
interface Props {
modelValue: string
@ -12,7 +12,7 @@ const emit = defineEmits(['update:modelValue'])
const column = inject(ColumnInj)
const isForm = inject<boolean>('isForm', false)
const editEnabled = inject<boolean>('editEnabled', false)
const editEnabled = inject(EditModeInj, ref(false))
const options = computed(() => column?.dtxp?.split(',').map((v) => v.replace(/\\'/g, "'").replace(/^'|'$/g, '')) || [])
@ -24,56 +24,6 @@ const localState = computed({
emit('update:modelValue', val?.filter((v) => options.value.includes(v)).join(','))
},
})
/* import colors from '@/components/project/spreadsheet/helpers/colors'
export default {
name: 'SetListCheckboxCell',
props: {
value: String,
column: Object,
values: Array,
},
data() {},
computed: {
colors() {
return this.$store.state.settings.darkTheme ? colors.dark : colors.light
},
localState: {
get() {
return (this.value && this.value.split(',')) || []
},
set(val) {
this.$emit('input', val.join(','))
this.$emit('update')
},
},
setValues() {
if (this.column && this.column.dtxp) {
return this.column.dtxp.split(',').map((v) => v.replace(/^'|'$/g, ''))
}
return this.values || []
},
parentListeners() {
const $listeners = {}
if (this.$listeners.blur) {
$listeners.blur = this.$listeners.blur
}
if (this.$listeners.focus) {
$listeners.focus = this.$listeners.focus
}
return $listeners
},
},
mounted() {
this.$el.focus()
const event = document.createEvent('MouseEvents')
event.initMouseEvent('mousedown', true, true, window)
this.$el.dispatchEvent(event)
},
} */
</script>
<template>

35
packages/nc-gui-v2/components/cell/SingleSelect.vue

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { computed, inject } from '#imports'
import { ColumnInj } from '~/context'
import { ColumnInj, EditModeInj } from '~/context'
interface Props {
modelValue: string
@ -12,45 +12,18 @@ const emit = defineEmits(['update:modelValue'])
const column = inject(ColumnInj)
const isForm = inject<boolean>('isForm', false)
const editEnabled = inject<boolean>('editEnabled', false)
const editEnabled = inject(EditModeInj, ref(false))
const localState = computed({
const vModel = computed({
get: () => modelValue?.replace(/\\'/g, "'").replace(/^'|'$/g, ''),
set: (val) => emit('update:modelValue', val),
})
const options = computed(() => column?.dtxp?.split(',').map((v) => v.replace(/\\'/g, "'").replace(/^'|'$/g, '')) || [])
/* import colors from '@/mixins/colors'
export default {
name: 'EnumListEditableCell',
mixins: [colors],
props: {
value: String,
column: Object,
isForm: Boolean,
},
computed: {
parentListeners() {
const $listeners = {}
if (this.$listeners.blur) {
$listeners.blur = this.$listeners.blur
}
if (this.$listeners.focus) {
$listeners.focus = this.$listeners.focus
}
return $listeners
},
},
} */
</script>
<template>
<v-select v-model="localState" :items="options" hide-details :clearable="!column.rqd" variation="outlined">
<v-select v-model="vModel" :items="options" hide-details :clearable="!column.rqd" variation="outlined">
<!-- v-on="parentListeners"
<template #selection="{ item }">
<div

17
packages/nc-gui-v2/components/cell/Text.vue

@ -1,28 +1,23 @@
<script setup lang="ts">
import { inject, onMounted, ref } from '#imports'
import { inject, ref, useVModel } from '#imports'
import { EditModeInj } from '~/context'
interface Props {
modelValue: any
modelValue: string
}
const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue'])
const editEnabled = inject<boolean>('editEnabled', false)
const root = ref<HTMLInputElement>()
const editEnabled = inject(EditModeInj, ref(false))
const vModel = useVModel(props, 'modelValue', emits)
const onSetRef = (el: HTMLInputElement) => {
el.focus()
}
const focus = (el: HTMLInputElement) => el.focus()
</script>
<template>
<input v-if="editEnabled" :ref="onSetRef" v-model="vModel" class="h-full w-full outline-none" />
<input v-if="editEnabled" :ref="focus" v-model="vModel" class="h-full w-full outline-none" />
<span v-else>{{ vModel }}</span>
</template>
<style scoped></style>

17
packages/nc-gui-v2/components/cell/TextArea.vue

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref } from '#imports'
import { inject, ref, useVModel } from '#imports'
import { EditModeInj } from '~/context'
interface Props {
modelValue?: string
@ -9,19 +10,17 @@ const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue'])
const editEnabled = inject<boolean>('editEnabled', false)
const editEnabled = inject(EditModeInj, ref(false))
const vModel = useVModel(props, 'modelValue', emits)
const onSetRef = (el: HTMLInputElement) => {
el.focus()
}
const focus = (el: HTMLTextAreaElement) => el.focus()
</script>
<template>
<textarea
v-if="editEnabled"
:ref="onSetRef"
:ref="focus"
v-model="vModel"
rows="4"
class="h-full w-full min-h-[60px] outline-none"
@ -30,9 +29,3 @@ const onSetRef = (el: HTMLInputElement) => {
/>
<span v-else>{{ vModel }}</span>
</template>
<style scoped>
textarea:focus {
@apply ring-transparent;
}
</style>

18
packages/nc-gui-v2/components/cell/Url.vue

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from '#imports'
import { ColumnInj } from '~/context'
import { isValidURL } from '~/utils/urlUtils'
import { computed, inject, onMounted, ref } from '#imports'
import { ColumnInj, EditModeInj } from '~/context'
import { isValidURL } from '~/utils'
interface Props {
modelValue: string
@ -13,7 +13,7 @@ const emit = defineEmits(['update:modelValue'])
const column = inject(ColumnInj)
const editEnabled = inject<boolean>('editEnabled')
const editEnabled = inject(EditModeInj, ref(false))
const vModel = computed({
get: () => value,
@ -26,21 +26,15 @@ const vModel = computed({
const isValid = computed(() => value && isValidURL(value))
const root = ref<HTMLInputElement>()
onMounted(() => {
root.value?.focus()
})
const focus = (el: HTMLInputElement) => el.focus()
</script>
<template>
<input v-if="editEnabled" ref="root" v-model="vModel" class="outline-none" />
<input v-if="editEnabled" :ref="focus" v-model="vModel" class="outline-none" />
<nuxt-link v-else-if="isValid" class="py-2 underline hover:opacity-75" :to="value" target="_blank">{{ value }}</nuxt-link>
<span v-else>{{ value }}</span>
</template>
<style scoped></style>
<!--
/**
* @copyright Copyright (c) 2021, Xgene Cloud Ltd

148
packages/nc-gui-v2/components/cell/attachment/Carousel.vue

@ -0,0 +1,148 @@
<script lang="ts" setup>
import { onKeyDown } from '@vueuse/core'
import { useAttachmentCell } from './utils'
import { isImage } from '~/utils'
import { computed, onClickOutside, ref, watch } from '#imports'
import MaterialSymbolsArrowCircleRightRounded from '~icons/material-symbols/arrow-circle-right-rounded'
import MaterialSymbolsArrowCircleLeftRounded from '~icons/material-symbols/arrow-circle-left-rounded'
import MdiCloseCircle from '~icons/mdi/close-circle'
const { selectedImage, visibleItems, downloadFile } = useAttachmentCell()!
const carouselRef = ref()
const imageItems = computed(() => visibleItems.value.filter((item) => isImage(item.title, item.mimetype)))
/** navigate to previous image on button click */
onKeyDown(
(e) => ['Left', 'ArrowLeft', 'A'].includes(e.key),
() => {
if (carouselRef.value) carouselRef.value.prev()
},
)
/** navigate to next image on button click */
onKeyDown(
(e) => ['Right', 'ArrowRight', 'D'].includes(e.key),
() => {
if (carouselRef.value) carouselRef.value.next()
},
)
/** set our selected image when slide changes */
function onSlideChange(index: number) {
selectedImage.value = imageItems.value[index]
}
/** set our carousel ref and move to initial slide */
const setCarouselRef = (el: Element) => {
carouselRef.value = el
carouselRef.value?.goTo(
imageItems.value.findIndex((item) => item === selectedImage.value),
true,
)
}
/** close overlay view when clicking outside of image */
onClickOutside(carouselRef, () => {
selectedImage.value = false
})
</script>
<template>
<general-overlay v-model="selectedImage">
<template v-if="selectedImage">
<div class="overflow-hidden p-12 text-center relative">
<div class="text-white group absolute top-5 right-5">
<MdiCloseCircle class="group-hover:text-red-500 cursor-pointer text-4xl" @click.stop="selectedImage = false" />
</div>
<div
class="select-none group hover:ring active:ring-pink-500 cursor-pointer leading-8 inline-block px-3 py-1 bg-gray-300 text-white mb-4 text-center rounded shadow"
@click.stop="downloadFile(selectedImage)"
>
<h3 class="group-hover:text-primary">{{ selectedImage && selectedImage.title }}</h3>
</div>
<a-carousel
v-if="!!selectedImage"
:ref="setCarouselRef"
dots-class="slick-dots slick-thumb"
:after-change="onSlideChange"
arrows
>
<template #prevArrow>
<div class="custom-slick-arrow left-2 z-1">
<MaterialSymbolsArrowCircleLeftRounded class="bg-white rounded-full" />
</div>
</template>
<template #nextArrow>
<div class="custom-slick-arrow !right-2 z-1">
<MaterialSymbolsArrowCircleRightRounded class="bg-white rounded-full" />
</div>
</template>
<template #customPaging="props">
<a>
<nuxt-img
class="!block"
:alt="imageItems[props.i].title || `#${props.i}`"
:src="imageItems[props.i].url || imageItems[props.i].data"
/>
</a>
</template>
<div v-for="item of imageItems" :key="item.url">
<div
:style="{ backgroundImage: `url('${item.url}')` }"
class="min-w-70vw min-h-70vh w-full h-full bg-contain bg-center bg-no-repeat"
/>
</div>
</a-carousel>
</div>
</template>
</general-overlay>
</template>
<style scoped>
.ant-carousel :deep(.slick-dots) {
@apply relative mt-4;
}
.ant-carousel :deep(.slick-slide) {
@apply w-full;
}
.ant-carousel :deep(.slick-slide img) {
@apply border-1 m-auto;
}
.ant-carousel :deep(.slick-thumb) {
@apply bottom-2;
}
.ant-carousel :deep(.slick-thumb li) {
@apply w-[60px] h-[45px];
}
.ant-carousel :deep(.slick-thumb li img) {
@apply w-full h-full block;
filter: grayscale(100%);
}
.ant-carousel :deep .slick-thumb li.slick-active img {
filter: grayscale(0%);
}
.ant-carousel :deep(.slick-arrow.custom-slick-arrow) {
@apply text-4xl text-white hover:text-primary active:text-pink-500 opacity-100 cursor-pointer z-1;
}
.ant-carousel :deep(.custom-slick-arrow:before) {
display: none;
}
.ant-carousel :deep(.custom-slick-arrow:hover) {
opacity: 0.5;
}
</style>

211
packages/nc-gui-v2/components/cell/attachment/Modal.vue

@ -0,0 +1,211 @@
<script lang="ts" setup>
import { onKeyDown } from '@vueuse/core'
import { useAttachmentCell } from './utils'
import { useSortable } from './sort'
import { ref, useDropZone, useUIPermission } from '#imports'
import { isImage, openLink } from '~/utils'
import MaterialSymbolsAttachFile from '~icons/material-symbols/attach-file'
import MdiCloseCircle from '~icons/mdi/close-circle'
import MdiDownload from '~icons/mdi/download'
import MaterialSymbolsFileCopyOutline from '~icons/material-symbols/file-copy-outline'
import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file'
const { isUIAllowed } = useUIPermission()
const {
open,
isLoading,
isPublicGrid,
isForm,
isReadonly,
visibleItems,
modalVisible,
column,
FileIcon,
removeFile,
onDrop,
downloadFile,
updateModelValue,
selectedImage,
} = useAttachmentCell()!
// todo: replace placeholder var
const isLocked = ref(false)
const dropZoneRef = ref<HTMLDivElement>()
const sortableRef = ref<HTMLDivElement>()
const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, isReadonly)
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
onKeyDown('Escape', () => {
modalVisible.value = false
isOverDropZone.value = false
})
</script>
<template>
<a-modal v-model:visible="modalVisible" class="nc-attachment-modal" width="80%" :footer="null">
<template #title>
<div class="flex gap-4">
<div
v-if="!isReadonly && (isForm || isUIAllowed('tableAttachment')) && !isPublicGrid && !isLocked"
class="nc-attach-file group"
@click="open"
>
<MaterialSymbolsAttachFile class="transform group-hover:(text-pink-500 scale-120)" />
Attach File
</div>
<div class="flex items-center gap-2">
<div v-if="isReadonly" class="text-gray-400">[Readonly]</div>
Viewing Attachments of
<div class="font-semibold underline">{{ column.title }}</div>
</div>
</div>
</template>
<div ref="dropZoneRef">
<template v-if="!isReadonly && !dragging">
<general-overlay
v-model="isOverDropZone"
inline
class="text-white ring ring-pink-500 bg-gray-700/75 flex items-center justify-center gap-2 backdrop-blur-xl"
>
<MaterialSymbolsFileCopyOutline class="text-pink-500" height="35" width="35" />
<div class="text-white text-3xl">Drop here</div>
</general-overlay>
</template>
<div ref="sortableRef" :class="{ dragging }" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-6 relative p-6">
<div v-for="(item, i) of visibleItems" :key="`${item.title}-${i}`" class="flex flex-col gap-1">
<a-card class="nc-attachment-item group">
<a-tooltip v-if="!isReadonly">
<template #title> Remove File </template>
<MdiCloseCircle
v-if="isUIAllowed('tableAttachment') && !isPublicGrid && !isLocked"
class="nc-attachment-remove"
@click.stop="removeFile(i)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title> Download file </template>
<div class="nc-attachment-download group-hover:(opacity-100)">
<MdiDownload @click.stop="downloadFile(item)" />
</div>
</a-tooltip>
<div
:class="[dragging ? 'cursor-move' : 'cursor-pointer']"
class="nc-attachment h-full w-full flex items-center justify-center"
>
<div
v-if="isImage(item.title, item.mimetype)"
:style="{ backgroundImage: `url('${item.url}')` }"
class="w-full h-full bg-contain bg-center bg-no-repeat"
@click.stop="() => (selectedImage = item) && (modalVisible = false)"
/>
<component
:is="FileIcon(item.icon)"
v-else-if="item.icon"
height="150"
width="150"
@click.stop="openLink(item.url || item.data)"
/>
<IcOutlineInsertDriveFile v-else height="150" width="150" @click.stop="openLink(item.url || item.data)" />
</div>
</a-card>
<div class="truncate" :title="item.title">
{{ item.title }}
</div>
</div>
<div v-if="isLoading" class="flex flex-col gap-1">
<a-card class="nc-attachment-item group">
<div class="nc-attachment h-full w-full flex items-center justify-center">
<a-skeleton-image class />
</div>
</a-card>
</div>
</div>
</div>
</a-modal>
</template>
<style lang="scss">
.nc-attachment-modal {
.nc-attach-file {
@apply select-none cursor-pointer color-transition flex items-center gap-1 border-1 p-2 rounded
@apply hover:(bg-primary/10 text-primary ring);
@apply active:(ring-pink-500 bg-primary/20);
}
.nc-attachment-item {
@apply !h-2/3 !min-h-[200px] flex items-center justify-center relative;
@supports (-moz-appearance: none) {
@apply hover:border-0;
}
&::after {
@apply pointer-events-none rounded absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out;
content: '';
}
@supports (-moz-appearance: none) {
&:hover::after {
@apply ring shadow transform scale-103;
}
&:active::after {
@apply ring ring-pink-500 shadow transform scale-103;
}
}
}
.nc-attachment-download {
@apply bg-white absolute bottom-2 right-2;
@apply transition-opacity duration-150 ease-in opacity-0 hover:ring;
@apply cursor-pointer rounded shadow flex items-center p-1 border-1;
@apply active:(ring border-0 ring-pink-500);
}
.nc-attachment-remove {
@apply absolute top-2 right-2 bg-white;
@apply hover:(ring ring-red-500);
@apply cursor-pointer rounded-full border-2;
@apply active:(ring border-0 ring-red-500);
}
.ant-card-body {
@apply !p-2 w-full h-full;
}
.ant-modal-body {
@apply !p-0;
}
.ghost,
.ghost > * {
@apply !pointer-events-none;
}
.dragging {
.nc-attachment-item {
@apply !pointer-events-none;
}
.ant-tooltip {
@apply !hidden;
}
}
}
</style>

168
packages/nc-gui-v2/components/cell/attachment/index.vue

@ -0,0 +1,168 @@
<script setup lang="ts">
import { onKeyDown } from '@vueuse/core'
import { useProvideAttachmentCell } from './utils'
import Modal from './Modal.vue'
import { useSortable } from './sort'
import Carousel from './Carousel.vue'
import { onMounted, ref, useDropZone, watch } from '#imports'
import { isImage, openLink } from '~/utils'
import MaterialSymbolsAttachFile from '~icons/material-symbols/attach-file'
import MaterialArrowExpandIcon from '~icons/mdi/arrow-expand'
import MaterialSymbolsFileCopyOutline from '~icons/material-symbols/file-copy-outline'
import MdiReload from '~icons/mdi/reload'
import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file'
interface Props {
modelValue: string | Record<string, any>[] | null
}
interface Emits {
(event: 'update:modelValue', value: string | Record<string, any>): void
}
const { modelValue } = defineProps<Props>()
const emits = defineEmits<Emits>()
const dropZoneRef = ref<HTMLTableDataCellElement>()
const sortableRef = ref<HTMLDivElement>()
const { column, modalVisible, attachments, visibleItems, onDrop, isLoading, open, FileIcon, selectedImage, isReadonly } =
useProvideAttachmentCell(updateModelValue)
const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, isReadonly)
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
/** on new value, reparse our stored attachments */
watch(
() => modelValue,
(nextModel) => {
if (nextModel) {
attachments.value = ((typeof nextModel === 'string' ? JSON.parse(nextModel) : nextModel) || []).filter(Boolean)
}
},
{ immediate: true },
)
/** updates attachments array for autosave */
function updateModelValue(data: string | Record<string, any>) {
emits('update:modelValue', typeof data !== 'string' ? JSON.stringify(data) : data)
}
/** Close modal on escape press, disable dropzone as well */
onKeyDown('Escape', () => {
modalVisible.value = false
isOverDropZone.value = false
})
/** if possible, on mounted we try to fetch the relevant `td` cell to use as a dropzone */
onMounted(() => {
if (typeof document !== 'undefined') {
dropZoneRef.value = document.querySelector(`td[data-col="${column.id}"]`) as HTMLTableDataCellElement
}
})
</script>
<template>
<div class="nc-attachment-cell relative flex-1 color-transition flex items-center justify-between gap-1">
<Carousel />
<template v-if="!isReadonly && !dragging && dropZoneRef">
<general-overlay
v-model="isOverDropZone"
inline
:target="`td[data-col='${column.id}']`"
class="text-white text-lg ring ring-pink-500 bg-gray-700/75 flex items-center justify-center gap-2 backdrop-blur-xl"
>
<MaterialSymbolsFileCopyOutline class="text-pink-500" /> Drop here
</general-overlay>
</template>
<div
v-if="!isReadonly"
:class="{ 'mx-auto px-4': !visibleItems.length }"
class="group flex gap-1 items-center active:ring rounded border-1 p-1 hover:bg-primary/10"
@click.stop="open"
>
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
<a-tooltip v-else placement="bottom">
<template #title> Click or drop a file into cell </template>
<div class="flex items-center gap-2">
<MaterialSymbolsAttachFile class="transform group-hover:(text-pink-500 scale-120)" />
<div v-if="!visibleItems.length" class="group-hover:text-primary">Add file(s)</div>
</div>
</a-tooltip>
</div>
<template v-if="visibleItems.length">
<div ref="sortableRef" :class="{ dragging }" class="flex flex-wrap gap-2 p-1 scrollbar-thin-primary">
<div
v-for="(item, i) of visibleItems"
:id="item.url"
:key="item.url || item.title"
style="flex: 1 1 50px"
:class="isImage(item.title, item.mimetype) ? '' : 'border-1 rounded'"
class="nc-attachment flex items-center justify-center"
>
<a-tooltip placement="bottom">
<template #title>
<div class="text-center w-full">{{ item.title }}</div>
</template>
<nuxt-img
v-if="isImage(item.title, item.mimetype)"
placeholder
width="150"
height="150"
:alt="item.title || `#${i}`"
:src="item.url || item.data"
class="ring-1 ring-gray-300 rounded"
@click="selectedImage = item"
/>
<component :is="FileIcon(item.icon)" v-else-if="item.icon" @click="openLink(item.url || item.data)" />
<IcOutlineInsertDriveFile v-else @click.stop="openLink(item.url || item.data)" />
</a-tooltip>
</div>
</div>
<div class="group flex gap-1 items-center border-1 active:ring rounded p-1 hover:bg-primary/10">
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
<a-tooltip v-else placement="bottom">
<template #title> View attachments </template>
<MaterialArrowExpandIcon
class="select-none transform group-hover:(text-pink-500 scale-120)"
@click.stop="modalVisible = true"
/>
</a-tooltip>
</div>
</template>
<Modal />
</div>
</template>
<style lang="scss">
.nc-cell {
.nc-attachment-cell {
.ghost,
.ghost > * {
@apply !pointer-events-none;
}
.dragging {
.ant-tooltip {
@apply !hidden;
}
}
}
}
</style>

65
packages/nc-gui-v2/components/cell/attachment/sort.ts

@ -0,0 +1,65 @@
import type { SortableEvent } from 'sortablejs'
import Sortable from 'sortablejs'
import type { MaybeRef } from '@vueuse/core'
import { watchPostEffect } from '@vue/runtime-core'
import { unref } from '#imports'
export function useSortable(
element: MaybeRef<HTMLElement | undefined>,
items: MaybeRef<any[]>,
updateModelValue: (data: string | Record<string, any>[]) => void,
isReadonly: MaybeRef<boolean> = false,
) {
let dragging = $ref(false)
function onSortStart(evt: SortableEvent) {
evt.stopImmediatePropagation()
evt.preventDefault()
dragging = true
}
async function onSortEnd(evt: SortableEvent) {
evt.stopImmediatePropagation()
evt.preventDefault()
dragging = false
const _items = unref(items)
if (_items.length < 2) return
const { newIndex = 0, oldIndex = 0 } = evt
if (newIndex === oldIndex) return
_items.splice(newIndex, 0, ..._items.splice(oldIndex, 1))
updateModelValue(_items)
}
let sortable: Sortable
// todo: replace with vuedraggable
const initSortable = (el: HTMLElement) => {
sortable = new Sortable(el, {
handle: '.nc-attachment',
ghostClass: 'ghost',
onStart: onSortStart,
onEnd: onSortEnd,
})
}
watchPostEffect((onCleanup) => {
const _element = unref(element)
onCleanup(() => {
if (_element && sortable) sortable.destroy()
})
if (_element && !unref(isReadonly)) initSortable(_element)
})
return {
dragging: $$(dragging),
initSortable,
}
}

157
packages/nc-gui-v2/components/cell/attachment/utils.ts

@ -0,0 +1,157 @@
import { notification } from 'ant-design-vue'
import FileSaver from 'file-saver'
import { computed, inject, ref, useApi, useFileDialog, useInjectionState, useProject, watch } from '#imports'
import { ColumnInj, EditModeInj, MetaInj, ReadonlyInj } from '~/context'
import { isImage } from '~/utils'
import { NOCO } from '~/lib'
import MdiPdfBox from '~icons/mdi/pdf-box'
import MdiFileWordOutline from '~icons/mdi/file-word-outline'
import MdiFilePowerpointBox from '~icons/mdi/file-powerpoint-box'
import MdiFileExcelOutline from '~icons/mdi/file-excel-outline'
import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file'
export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
(updateModelValue: (data: string | Record<string, any>[]) => void) => {
const isReadonly = inject(ReadonlyInj, false)
const isPublicForm = inject('isPublicForm', false)
const isForm = inject('isForm', false)
// todo: replace placeholder var
const isPublicGrid = $ref(false)
const meta = inject(MetaInj)!
const column = inject(ColumnInj)!
const editEnabled = inject(EditModeInj, ref(false))
const storedFiles = ref<{ title: string; file: File }[]>([])
const attachments = ref<File[]>([])
const modalVisible = ref(false)
const selectedImage = ref()
const { project } = useProject()
const { api, isLoading } = useApi()
const { files, open } = useFileDialog()
/** remove a file from our stored attachments (either locally stored or saved ones) */
function removeFile(i: number) {
if (isPublicForm) {
storedFiles.value.splice(i, 1)
updateModelValue(storedFiles.value.map((storedFile) => storedFile.file))
} else {
attachments.value.splice(i, 1)
updateModelValue(attachments.value)
}
}
/** save a file on select / drop, either locally (in-memory) or in the db */
async function onFileSelect(selectedFiles: FileList | File[]) {
if (!selectedFiles.length || isPublicGrid) return
if (isPublicForm) {
storedFiles.value.push(
...Array.from(selectedFiles).map((file) => {
const res = { file, title: file.name }
if (isImage(file.name, (file as any).mimetype)) {
const reader = new FileReader()
reader.readAsDataURL(file)
}
return res
}),
)
return updateModelValue(storedFiles.value.map((storedFile) => storedFile.file))
}
const newAttachments = []
for (const file of selectedFiles) {
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) {
notification.error({
message: e.message || 'Some internal error occurred',
})
}
}
updateModelValue([...attachments.value, ...newAttachments])
}
/** save files on drop */
async function onDrop(droppedFiles: File[] | null) {
if (droppedFiles) {
// set files
await onFileSelect(droppedFiles)
}
}
/** download a file */
async function downloadFile(item: Record<string, any>) {
FileSaver.saveAs(item.url || item.data, item.title)
}
const FileIcon = (icon: string) => {
switch (icon) {
case 'mdi-pdf-box':
return MdiPdfBox
case 'mdi-file-word-outline':
return MdiFileWordOutline
case 'mdi-file-powerpoint-box':
return MdiFilePowerpointBox
case 'mdi-file-excel-outline':
return MdiFileExcelOutline
default:
return IcOutlineInsertDriveFile
}
}
/** our currently visible items, either the locally stored or the ones from db, depending on isPublicForm status */
const visibleItems = computed<any[]>(() => (isPublicForm ? storedFiles.value : attachments.value) || ([] as any[]))
watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles))
return {
attachments,
storedFiles,
visibleItems,
isPublicForm,
isForm,
isPublicGrid,
isReadonly,
meta,
column,
editEnabled,
isLoading,
api,
open,
onDrop,
modalVisible,
FileIcon,
removeFile,
downloadFile,
updateModelValue,
selectedImage,
}
},
'attachmentCell',
)

58
packages/nc-gui-v2/components/general/Overlay.vue

@ -0,0 +1,58 @@
<script lang="ts" setup>
import { onKeyDown } from '@vueuse/core'
import type { TeleportProps } from '@vue/runtime-core'
import { useVModel, watch } from '#imports'
interface Props {
modelValue?: any
/** if true, overlay will use `position: absolute` instead of `position: fixed` */
inline?: boolean
/** target to teleport to */
target?: TeleportProps['to']
teleportDisabled?: TeleportProps['disabled']
transition?: boolean
}
interface Emits {
(event: 'update:modelValue', value: boolean): void
(event: 'close'): void
(event: 'open'): void
}
const { transition = true, teleportDisabled = false, inline = false, target, ...rest } = defineProps<Props>()
const emits = defineEmits<Emits>()
const vModel = useVModel(rest, 'modelValue', emits)
onKeyDown('Escape', () => {
vModel.value = false
})
watch(vModel, (nextVal) => {
if (nextVal) emits('open')
else emits('close')
})
</script>
<script lang="ts">
export default {
inheritAttrs: false,
}
</script>
<template>
<teleport :disabled="teleportDisabled || (inline && !target)" :to="target || 'body'">
<div
v-bind="$attrs"
:class="[
vModel ? 'opacity-100' : 'opacity-0 pointer-events-none',
inline ? 'absolute' : 'fixed',
transition ? 'transition-opacity duration-200 ease-in-out' : '',
]"
class="z-100 top-0 left-0 bottom-0 right-0 bg-gray-700/75"
>
<slot :is-open="vModel" />
</div>
</teleport>
</template>

195
packages/nc-gui-v2/components/smartsheet/Cell.vue

@ -1,30 +1,25 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import { provide } from 'vue'
import { computed, useColumn } from '#imports'
import { useColumn, useVModel } from '#imports'
import { ColumnInj } from '~/context'
interface Props {
column: ColumnType
modelValue: any
editEnabled: boolean
}
const { column, modelValue: value, editEnabled } = defineProps<Props>()
interface Emits {
(event: 'update:modelValue', value: any): void
}
const emit = defineEmits(['update:modelValue'])
const { column, ...rest } = defineProps<Props>()
provide(ColumnInj, column)
const emit = defineEmits<Emits>()
provide(
'editEnabled',
computed(() => editEnabled),
)
provide(ColumnInj, column)
const localState = computed({
get: () => value,
set: (val) => emit('update:modelValue', val),
})
const vModel = useVModel(rest, 'modelValue', emit)
const {
isURL,
@ -53,155 +48,27 @@ const {
<template>
<div class="nc-cell" @keydown.stop.left @keydown.stop.right @keydown.stop.up @keydown.stop.down>
<!--
todo :
JSONCell
-->
<!-- <RatingCell -->
<!-- v-if="isRating" -->
<!-- /> -->
<!-- v-model="localState"
:active="active"
:is-form="isForm"
:column="column"
:is-public-grid="isPublic && !isForm"
:is-public-form="isPublic && isForm"
:is-locked="isLocked"
v-on="$listeners"
/> -->
<!-- <DurationCell -->
<!-- v-else-if="isDuration" -->
<!-- /> -->
<!-- &lt;!&ndash; v-model="localState" -->
<!-- :active="active" -->
<!-- :is-form="isForm" -->
<!-- :column="column" -->
<!-- :is-locked="isLocked" -->
<!-- v-on="parentListeners" -->
<!-- />&ndash;&gt; -->
<!-- <DatePickerCell -->
<!-- v-else-if="isDate" -->
<!-- /> -->
<!-- &lt;!&ndash; v-model="localState" -->
<!-- v-on="parentListeners" -->
<!-- />&ndash;&gt; -->
<!-- <TimePickerCell -->
<!-- v-else-if="isTime" -->
<!-- /> -->
<!-- &lt;!&ndash; v-model="localState" -->
<!-- v-on="parentListeners" -->
<!-- @save="$emit('save')" -->
<!-- />&ndash;&gt; -->
<!-- <DateTimePickerCell -->
<!-- v-else-if="isDateTime" -->
<!-- /> -->
<!-- &lt;!&ndash; v-model="localState" -->
<!-- ignore-focus -->
<!-- v-on="parentListeners" -->
<!-- />&ndash;&gt; -->
<!-- <EnumCell -->
<!-- v-else-if="isEnum && ((!isForm && !active) || isLocked || (isPublic && !isForm))" -->
<!-- /> -->
<!-- &lt;!&ndash; v-model="localState" -->
<!-- :column="column" -->
<!-- v-on="parentListeners" -->
<!-- />&ndash;&gt; -->
<!-- <EnumListCell -->
<!-- v-else-if="isEnum" -->
<!-- /> -->
<!-- &lt;!&ndash; v-model="localState"&ndash;&gt; -->
<!-- &lt;!&ndash; :is-form="isForm"&ndash;&gt; -->
<!-- &lt;!&ndash; :column="column"&ndash;&gt; -->
<!-- &lt;!&ndash; v-on="parentListeners"&ndash;&gt; -->
<!-- &lt;!&ndash; />&ndash;&gt; -->
<!-- <JsonEditableCell -->
<!-- v-else-if="isJSON" -->
<!-- /> -->
<!-- &lt;!&ndash; v-model="localState" -->
<!-- :is-form="isForm" -->
<!-- v-on="parentListeners" -->
<!-- @input="$emit('save')" -->
<!-- />&ndash;&gt; -->
<!-- <SetListEditableCell -->
<!-- v-else-if="isSet && (active || isForm) && !isLocked && !(isPublic && !isForm)" -->
<!-- /> -->
<!-- &lt;!&ndash; v-model="localState" -->
<!-- :column="column" -->
<!-- v-on="parentListeners" -->
<!-- />&ndash;&gt; -->
<!-- <SetListCell -->
<!-- v-else-if="isSet" -->
<!-- /> -->
<!-- &lt;!&ndash; v-model="localState" -->
<!-- :column="column" -->
<!-- v-on="parentListeners" -->
<!-- />&ndash;&gt; -->
<!-- <UrlCell v-else-if="isURL" -->
<!-- /> -->
<!-- &lt;!&ndash; v-model="localState" v-on="parentListeners" &ndash;&gt; -->
<!-- &lt;!&ndash; />&ndash;&gt; -->
<CellTextArea v-if="isTextArea" v-model="localState" />
<!-- v-model="localState"
:is-form="isForm"
v-on="parentListeners"
/> -->
<CellCheckbox v-else-if="isBoolean" v-model="localState" />
<!-- &lt;!&ndash; v-model="localState" -->
<!-- :column="column" -->
<!-- :is-form="isForm" -->
<!-- v-on="parentListeners" -->
<!-- />&ndash;&gt; -->
<CellAttachment v-else-if="isAttachment" v-model="localState" />
<CellSingleSelect v-else-if="isSingleSelect" v-model="localState" />
<CellMultiSelect v-else-if="isMultiSelect" v-model="localState" />
<CellDatePicker v-else-if="isDate" v-model="localState" />
<CellYearPicker v-else-if="isYear" v-model="localState" />
<CellDateTimePicker v-else-if="isDateTime" v-model="localState" />
<CellTimePicker v-else-if="isTime" v-model="localState" />
<CellRating v-else-if="isRating" 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"
/> -->
<CellDuration v-else-if="isDuration" v-model="localState" />
<CellEmail v-else-if="isEmail" v-model="localState" />
<CellUrl v-else-if="isURL" v-model="localState" />
<CellPhoneNumber v-else-if="isPhoneNumber" v-model="localState" />
<!-- v-on="parentListeners"
/>
-->
<CellCurrency v-else-if="isCurrency" v-model="localState" />
<CellDecimal v-else-if="isDecimal" v-model="localState" />
<CellInteger v-else-if="isInt" v-model="localState" />
<CellFloat v-else-if="isFloat" v-model="localState" />
<CellText v-else-if="isString" v-model="localState" />
<!-- v-on="parentListeners"
/>
-->
<CellPercent v-else-if="isPercent" v-model="localState" />
<CellText v-else v-model="localState" />
<!-- v-on="$listeners" <span v-if="hint" class="nc-hint">{{ hint }}</span> -->
<!-- <div v-if="(isLocked || (isPublic && !isForm)) && !isAttachment" class="nc-locked-overlay" /> -->
<CellTextArea v-if="isTextArea" v-model="vModel" />
<CellCheckbox v-else-if="isBoolean" v-model="vModel" />
<CellAttachment v-else-if="isAttachment" v-model="vModel" />
<CellSingleSelect v-else-if="isSingleSelect" v-model="vModel" />
<CellMultiSelect v-else-if="isMultiSelect" v-model="vModel" />
<CellDatePicker v-else-if="isDate" v-model="vModel" />
<CellYearPicker v-else-if="isYear" v-model="vModel" />
<CellDateTimePicker v-else-if="isDateTime" v-model="vModel" />
<CellTimePicker v-else-if="isTime" v-model="vModel" />
<CellRating v-else-if="isRating" v-model="vModel" />
<CellDuration v-else-if="isDuration" v-model="vModel" />
<CellEmail v-else-if="isEmail" v-model="vModel" />
<CellUrl v-else-if="isURL" v-model="vModel" />
<CellPhoneNumber v-else-if="isPhoneNumber" v-model="vModel" />
<CellCurrency v-else-if="isCurrency" v-model="vModel" />
<CellDecimal v-else-if="isDecimal" v-model="vModel" />
<CellInteger v-else-if="isInt" v-model="vModel" />
<CellFloat v-else-if="isFloat" v-model="vModel" />
<CellText v-else-if="isString" v-model="vModel" />
<CellPercent v-else-if="isPercent" v-model="vModel" />
<CellText v-else v-model="vModel" />
</div>
</template>
@ -222,7 +89,9 @@ div {
}
.nc-cell {
position: relative;
@apply relative h-full;
width: inherit;
display: inherit;
}
.nc-locked-overlay {

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

@ -12,6 +12,7 @@ import {
import {
ActiveViewInj,
ChangePageInj,
EditModeInj,
FieldsInj,
IsFormInj,
IsGridInj,
@ -44,6 +45,7 @@ provide(IsFormInj, false)
provide(IsGridInj, true)
provide(PaginationDataInj, paginationData)
provide(ChangePageInj, changePage)
provide(EditModeInj, editEnabled)
const reloadViewDataHook = inject(ReloadViewDataHookInj)
reloadViewDataHook?.on(() => {
@ -142,68 +144,6 @@ if (meta) useProvideColumnCreateStore(meta)
@click="selectCell(rowIndex, colIndex)"
@dblclick="editEnabled = true"
>
<!-- @contextmenu=" -->
<!-- showRowContextMenu($event, rowObj, rowMeta, row, col, columnObj) -->
<!-- " -->
<!-- > -->
<!-- <virtual-cell -->
<!-- v-if="isVirtualCol(columnObj)" -->
<!-- :password="password" -->
<!-- :is-public="isPublicView" -->
<!-- :metas="metas" -->
<!-- :is-locked="isLocked" -->
<!-- :column="columnObj" -->
<!-- :row="rowObj" -->
<!-- :nodes="nodes" -->
<!-- :meta="meta" -->
<!-- :api="api" -->
<!-- :active="selected.col === col && selected.row === row" -->
<!-- :sql-ui="sqlUi" -->
<!-- :is-new="rowMeta.new" -->
<!-- v-on="$listeners" -->
<!-- @updateCol=" -->
<!-- (...args) => -->
<!-- updateCol( -->
<!-- ...args, -->
<!-- columnObj.bt -->
<!-- && meta.columns.find( -->
<!-- (c) => c.column_name === columnObj.bt.column_name, -->
<!-- ), -->
<!-- col, -->
<!-- row, -->
<!-- ) -->
<!-- " -->
<!-- @saveRow="onCellValueChange(col, row, columnObj, true)" -->
<!-- /> -->
<!-- <editable-cell -->
<!-- v-else-if=" -->
<!-- ((isPkAvail || rowMeta.new) -->
<!-- && !isView -->
<!-- && !isLocked -->
<!-- && !isPublicView -->
<!-- && editEnabled.col === col -->
<!-- && editEnabled.row === row) -->
<!-- || enableEditable(columnObj) -->
<!-- " -->
<!-- v-model="rowObj[columnObj.title]" -->
<!-- :column="columnObj" -->
<!-- :meta="meta" -->
<!-- :active="selected.col === col && selected.row === row" -->
<!-- :sql-ui="sqlUi" -->
<!-- :db-alias="nodes.dbAlias" -->
<!-- :is-locked="isLocked" -->
<!-- :is-public="isPublicView" -->
<!-- :view-id="viewId" -->
<!-- @save="editEnabled = {}; onCellValueChange(col, row, columnObj, true);" -->
<!-- @cancel="editEnabled = {}" -->
<!-- @update="onCellValueChange(col, row, columnObj, false)" -->
<!-- @blur="onCellValueChange(col, row, columnObj, true)" -->
<!-- @input="unsaved = true" -->
<!-- @navigateToNext="navigateToNext" -->
<!-- @navigateToPrev="navigateToPrev" -->
<!-- /> -->
<SmartsheetVirtualCell v-if="isVirtualCol(columnObj)" v-model="row[columnObj.title]" :column="columnObj" />
<SmartsheetCell
@ -213,20 +153,6 @@ if (meta) useProvideColumnCreateStore(meta)
:edit-enabled="editEnabled && selected.col === colIndex && selected.row === rowIndex"
@update:model-value="updateRowProperty(row, columnObj.title)"
/>
<!-- <SmartsheetCell v-else :column="columnObj" :value="row[columnObj.title]" /> -->
<!-- :selected="selected.col === col && selected.row === row" -->
<!-- :is-locked="isLocked" -->
<!-- :column="columnObj" -->
<!-- :meta="meta" -->
<!-- :db-alias="nodes.dbAlias" -->
<!-- :value="rowObj[columnObj.title]" -->
<!-- :sql-ui="sqlUi" -->
<!-- @enableedit=" -->
<!-- makeSelected(col, row); -->
<!-- makeEditable(col, row, columnObj.ai, rowMeta); -->
<!-- " -->
<!-- /> -->
</td>
</tr>
</tbody>
@ -249,10 +175,6 @@ if (meta) useProvideColumnCreateStore(meta)
height: 41px !important;
position: relative;
padding: 0 5px;
& > * {
@apply flex align-center h-auto;
}
overflow: hidden;
}
@ -287,7 +209,7 @@ if (meta) useProvideColumnCreateStore(meta)
// todo: replace with css variable
td.active::after {
border: 2px solid #0040bc; /*var(--v-primary-lighten1);*/
@apply border-2 border-solid border-primary;
}
td.active::before {

2
packages/nc-gui-v2/components/smartsheet/sidebar/index.vue

@ -73,7 +73,7 @@ function onCreate(view: GridType | FormType | KanbanType | GalleryType) {
collapsiple
collapsed-width="50"
width="250"
class="shadow !mt-[-9px]"
class="shadow !mt-[-9.5px]"
style="height: calc(100% + 9px)"
theme="light"
>

101
packages/nc-gui-v2/composables/useViewColumns.ts

@ -28,23 +28,30 @@ export function useViewColumns(
if (!meta || !view) return
let order = 1
if (view?.value?.id) {
const data = await $api.dbViewColumn.list(view?.value?.id as string)
const fieldById: Record<string, any> = data.reduce((o: Record<string, any>, f: any) => {
f.show = !!f.show
if (view.value?.id) {
const data = (await $api.dbViewColumn.list(view.value.id)) as any[]
const fieldById = data.reduce<Record<string, any>>((acc, curr) => {
curr.show = !!curr.show
return {
...o,
[f.fk_column_id as string]: f,
...acc,
[curr.fk_column_id]: curr,
}
}, {})
fields.value = meta.value?.columns
?.map((c) => ({
title: c.title,
fk_column_id: c.id,
...(fieldById[c.id as string] ? fieldById[c.id as string] : {}),
order: (fieldById[c.id as string] && fieldById[c.id as string].order) || order++,
system: isSystemColumn(fieldById[c.fk_model_id as string]?.type as ColumnType),
}))
fields.value = meta.value.columns
?.map((column) => {
const currentColumnField = fieldById[column.id!] || {}
return {
title: column.title,
fk_column_id: column.id,
...currentColumnField,
order: currentColumnField.order || order++,
system: isSystemColumn(currentColumnField.type || false),
}
})
.sort((a, b) => a.order - b.order)
} else if (isPublic) {
fields.value = meta.value.columns as any
@ -52,32 +59,37 @@ export function useViewColumns(
}
const showAll = async () => {
await $api.dbView.showAllColumn(view?.value?.id as string)
if (view?.value?.id) await $api.dbView.showAllColumn(view.value.id)
await loadViewColumns()
reloadData?.()
}
const hideAll = async () => {
await $api.dbView.hideAllColumn(view?.value?.id as string)
if (view?.value?.id) await $api.dbView.hideAllColumn(view.value.id)
await loadViewColumns()
reloadData?.()
}
const saveOrUpdate = async (field: any, index: number) => {
if (field.id) {
await $api.dbViewColumn.update(view?.value?.id as string, field.id, field)
} else {
if (fields.value) fields.value[index] = (await $api.dbViewColumn.create(view?.value?.id as string, field)) as any
if (field.id && view?.value?.id) {
await $api.dbViewColumn.update(view.value.id, field.id, field)
} else if (view?.value?.id) {
if (fields.value) fields.value[index] = (await $api.dbViewColumn.create(view.value.id, field)) as any
}
reloadData?.()
}
const metaColumnById = computed(() => {
return meta?.value?.columns?.reduce<Record<string, ColumnType>>((o: Record<string, any>, c: any) => {
return {
...o,
[c.id]: c,
}
}, {})
return (
meta.value.columns?.reduce<Record<string, ColumnType>>((acc, curr) => {
return {
...acc,
[curr.id!]: curr,
}
}, {}) || {}
)
})
const showSystemFields = computed({
@ -86,28 +98,27 @@ export function useViewColumns(
return (view?.value as any)?.show_system_fields || false
},
set(v) {
if (view?.value) {
$api.dbView.update(
view?.value?.id as string,
{
// todo: update swagger
show_system_fields: v,
} as any,
)
if (view?.value?.id) {
$api.dbView.update(view.value.id, {
// todo: update swagger
show_system_fields: v,
} as any)
;(view.value as any).show_system_fields = v
}
},
})
const filteredFieldList = computed(() => {
return fields.value?.filter((field) => {
// hide system columns if not enabled
if (!showSystemFields.value && isSystemColumn(metaColumnById?.value?.[field.fk_column_id as string])) {
return false
}
return (
fields.value?.filter((field) => {
// hide system columns if not enabled
if (!showSystemFields.value && isSystemColumn(metaColumnById?.value?.[field.fk_column_id!])) {
return false
}
return !filterQuery?.value || field.title.toLowerCase().includes(filterQuery.value.toLowerCase())
})
return !filterQuery?.value || field.title.toLowerCase().includes(filterQuery.value.toLowerCase())
}) || {}
)
})
const sortedAndFilteredFields = computed<ColumnType[]>(() => {
@ -117,15 +128,15 @@ export function useViewColumns(
if (
!showSystemFields.value &&
metaColumnById.value &&
metaColumnById?.value?.[c.fk_column_id as string] &&
isSystemColumn(metaColumnById?.value?.[c.fk_column_id as string])
metaColumnById?.value?.[c.fk_column_id!] &&
isSystemColumn(metaColumnById.value?.[c.fk_column_id!])
) {
return false
}
return c.show
})
?.sort((c1, c2) => c1.order - c2.order)
?.map((c) => metaColumnById?.value?.[c.fk_column_id as string]) || []) as ColumnType[]
?.sort((a, b) => a.order - b.order)
?.map((c) => metaColumnById?.value?.[c.fk_column_id!]) || []) as ColumnType[]
})
// reload view columns when table meta changes

1
packages/nc-gui-v2/context/index.ts

@ -21,3 +21,4 @@ export const FieldsInj: InjectionKey<Ref<any[]>> = Symbol('fields-injection')
export const ViewListInj: InjectionKey<Ref<(GridType | FormType | KanbanType | GalleryType)[]>> = Symbol('view-list-injection')
export const RightSidebarInj: InjectionKey<Ref<boolean>> = Symbol('right-sidebar-injection')
export const EditModeInj: InjectionKey<Ref<boolean>> = Symbol('edit-mode-injection')

2
packages/nc-gui-v2/nuxt.config.ts

@ -8,7 +8,7 @@ import monacoEditorPlugin from 'vite-plugin-monaco-editor'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
modules: ['@vueuse/nuxt', 'nuxt-windicss'],
modules: ['@vueuse/nuxt', 'nuxt-windicss', '@nuxt/image-edge'],
ssr: false,

1245
packages/nc-gui-v2/package-lock.json generated

File diff suppressed because it is too large Load Diff

10
packages/nc-gui-v2/package.json

@ -11,9 +11,9 @@
"coverage": "vitest -c test/vite.config.ts run --coverage"
},
"dependencies": {
"@vueuse/core": "^8.7.5",
"@vueuse/integrations": "^8.9.1",
"ant-design-vue": "^3.1.0-rc.6",
"@vueuse/core": "^9.0.2",
"@vueuse/integrations": "^9.0.2",
"ant-design-vue": "^3.2.10",
"dayjs": "^1.11.3",
"file-saver": "^2.0.5",
"jwt-decode": "^3.1.2",
@ -40,14 +40,16 @@
"@iconify-json/mdi": "^1.1.25",
"@iconify-json/ri": "^1.1.3",
"@intlify/vite-plugin-vue-i18n": "^4.0.0",
"@nuxt/image-edge": "^1.0.0-27657146.da85542",
"@types/axios": "^0.14.0",
"@types/file-saver": "^2.0.5",
"@types/papaparse": "^5.3.2",
"@types/sortablejs": "^1.13.0",
"@vitejs/plugin-vue": "^2.3.3",
"@vitest/ui": "^0.18.0",
"@vue/compiler-sfc": "^3.2.37",
"@vue/test-utils": "^2.0.2",
"@vueuse/nuxt": "^8.6.0",
"@vueuse/nuxt": "^9.0.2",
"@windicss/plugin-animations": "^1.0.9",
"@windicss/plugin-question-mark": "^0.1.1",
"@windicss/plugin-scrollbar": "^1.2.3",

3
packages/nc-gui-v2/tsconfig.json

@ -15,7 +15,8 @@
"vue-i18n",
"unplugin-icons/types/vue",
"nuxt-windicss",
"vite/client"
"vite/client",
"@nuxt/image-edge"
]
},
"exclude": [

4
packages/nc-gui-v2/utils/urlUtils.ts

@ -27,3 +27,7 @@ export const isValidURL = (str: string) => {
/^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00A1-\uFFFF0-9]-*)*[a-z\u00A1-\uFFFF0-9]+)(?:\.(?:[a-z\u00A1-\uFFFF0-9]-*)*[a-z\u00A1-\uFFFF0-9]+)*(?:\.(?:[a-z\u00A1-\uFFFF]{2,}))\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/i
return !!pattern.test(str)
}
export const openLink = (url: string, target = '_blank') => {
window.open(url, target)
}

Loading…
Cancel
Save