Browse Source

feat(nc-gui): form logo and banner image upload

pull/7717/head
Ramesh Mane 10 months ago
parent
commit
00312c81a2
  1. 193
      packages/nc-gui/components/smartsheet/Form.vue
  2. 5
      packages/nc-gui/lang/en.json

193
packages/nc-gui/components/smartsheet/Form.vue

@ -4,7 +4,7 @@ import Draggable from 'vuedraggable'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
import { Pane, Splitpanes } from 'splitpanes' import { Pane, Splitpanes } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css' import 'splitpanes/dist/splitpanes.css'
import { RelationTypes, UITypes, ViewTypes, getSystemColumns, isLinksOrLTAR, isVirtualCol } from 'nocodb-sdk' import { RelationTypes, UITypes, ViewTypes, getSystemColumns, isLinksOrLTAR, isVirtualCol, ProjectRoles } from 'nocodb-sdk'
import type { Permission } from '#imports' import type { Permission } from '#imports'
import { import {
ActiveViewInj, ActiveViewInj,
@ -33,6 +33,7 @@ import {
useViewColumnsOrThrow, useViewColumnsOrThrow,
useViewData, useViewData,
watch, watch,
useFileDialog,
} from '#imports' } from '#imports'
provide(IsFormInj, ref(true)) provide(IsFormInj, ref(true))
@ -62,6 +63,10 @@ const { $api, $e } = useNuxtApp()
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const { base } = storeToRefs(useBase())
const { getPossibleAttachmentSrc } = useAttachment()
let formState = reactive<Record<string, any>>({}) let formState = reactive<Record<string, any>>({})
const secondsRemain = ref(0) const secondsRemain = ref(0)
@ -118,6 +123,34 @@ const isTabPressed = ref(false)
const isLoadingFormView = ref(false) const isLoadingFormView = ref(false)
const showCropper = ref(false)
const imageCropperData = ref<{
imageConfig: {
src: string
type: string
name: string
}
cropperConfig: {
aspectRatio?: number
}
uploadConfig?: {
path?: string
}
cropFor: 'banner' | 'logo'
}>({
imageConfig: {
src: '',
type: '',
name: '',
},
cropperConfig: {},
uploadConfig: {
path: '',
},
cropFor: 'banner',
})
const focusLabel: VNodeRef = (el) => { const focusLabel: VNodeRef = (el) => {
return (el as HTMLInputElement)?.focus() return (el as HTMLInputElement)?.focus()
} }
@ -128,6 +161,11 @@ const { t } = useI18n()
const { betaFeatureToggleState } = useBetaFeatureToggle() const { betaFeatureToggleState } = useBetaFeatureToggle()
const { open, onChange: onChangeFile } = useFileDialog({
accept: 'image/*',
multiple: false,
})
const visibleColumns = computed(() => localColumns.value.filter((f) => f.show).sort((a, b) => a.order - b.order)) const visibleColumns = computed(() => localColumns.value.filter((f) => f.show).sort((a, b) => a.order - b.order))
const updateView = useDebounceFn( const updateView = useDebounceFn(
@ -139,6 +177,8 @@ const updateView = useDebounceFn(
) )
async function submitForm() { async function submitForm() {
if (isLocked.value || !isUIAllowed('dataInsert')) return
try { try {
await formRef.value?.validateFields() await formRef.value?.validateFields()
} catch (e: any) { } catch (e: any) {
@ -158,6 +198,8 @@ async function submitForm() {
} }
async function clearForm() { async function clearForm() {
if (isLocked.value || !isUIAllowed('dataInsert')) return
formState = reactive<Record<string, any>>({}) formState = reactive<Record<string, any>>({})
state.value = {} state.value = {}
await formRef.value.clearValidate() await formRef.value.clearValidate()
@ -205,8 +247,9 @@ function onMoveCallback(event: any) {
} }
} }
// Todo: reorder visible form fields
function onMove(event: any, isVisibleFormFields = false) { function onMove(event: any, isVisibleFormFields = false) {
if (isLocked.value || !isEditable) return
const { oldIndex } = event.moved const { oldIndex } = event.moved
let { newIndex, element } = event.moved let { newIndex, element } = event.moved
@ -239,6 +282,8 @@ function onMove(event: any, isVisibleFormFields = false) {
} }
async function showOrHideColumn(column: Record<string, any>, show: boolean, isSidePannel = false) { async function showOrHideColumn(column: Record<string, any>, show: boolean, isSidePannel = false) {
if (isLocked.value || !isEditable) return
if (shouldSkipColumn(column)) { if (shouldSkipColumn(column)) {
// Required field can't be moved // Required field can't be moved
!isSidePannel && message.info(t('msg.info.requriedFieldsCantBeMoved')) !isSidePannel && message.info(t('msg.info.requriedFieldsCantBeMoved'))
@ -273,6 +318,8 @@ function shouldSkipColumn(col: Record<string, any>) {
} }
async function handleAddOrRemoveAllColumns(value: boolean) { async function handleAddOrRemoveAllColumns(value: boolean) {
if (isLocked.value || !isEditable) return
if (value) { if (value) {
for (const col of (localColumns as Record<string, any>)?.value) { for (const col of (localColumns as Record<string, any>)?.value) {
col.show = true col.show = true
@ -383,11 +430,14 @@ const columnSupportsScanning = (elementType: UITypes) =>
const onFormItemClick = (element: any) => { const onFormItemClick = (element: any) => {
if (isLocked.value || !isEditable) return if (isLocked.value || !isEditable) return
activeRow.value = element.title activeRow.value = element.title
isTabPressed.value = false isTabPressed.value = false
} }
const handleChangeBackground = (color: string) => { const handleChangeBackground = (color: string) => {
if (isLocked.value || !isEditable) return
const tcolor = tinycolor(color) const tcolor = tinycolor(color)
if (tcolor.isValid()) { if (tcolor.isValid()) {
;(formViewData.value?.meta as Record<string, any>).background_color = color ;(formViewData.value?.meta as Record<string, any>).background_color = color
@ -395,12 +445,66 @@ const handleChangeBackground = (color: string) => {
} }
} }
const openUploadImage = (isUploadBanner: boolean) => {
if (!isEditable) return
imageCropperData.value.uploadConfig = {
path: [NOCO, base.value.id, meta.value?.id, formViewData.value?.id].join('/'),
}
if (isUploadBanner) {
imageCropperData.value.cropperConfig = {
aspectRatio: 4 / 1,
}
imageCropperData.value.cropFor = 'banner'
} else {
imageCropperData.value.cropperConfig = {
aspectRatio: undefined,
}
imageCropperData.value.cropFor = 'logo'
}
open()
}
onChangeFile((files) => {
if (files && files[0]) {
// 1. Revoke the object URL, to allow the garbage collector to destroy the uploaded before file
if (imageCropperData.value.imageConfig.src) {
URL.revokeObjectURL(imageCropperData.value.imageConfig.src)
}
// 2. Create the blob link to the file to optimize performance:
const blob = URL.createObjectURL(files[0])
// 3. Update the image. The type will be derived from the extension
imageCropperData.value.imageConfig = {
src: blob,
type: files[0].type,
name: files[0].name,
}
showCropper.value = true
}
})
const handleOnUploadImage = (data: Record<string, any> = {}) => {
if (imageCropperData.value.cropFor === 'banner') {
formViewData.value!.banner_image_url = stringifyProp(data) ?? ''
} else {
formViewData.value!.logo_url = stringifyProp(data) ?? ''
}
updateView()
}
onClickOutside(draggableRef, () => { onClickOutside(draggableRef, () => {
activeRow.value = '' activeRow.value = ''
isTabPressed.value = false isTabPressed.value = false
}) })
onMounted(async () => { onMounted(async () => {
if (imageCropperData.value.src) {
URL.revokeObjectURL(imageCropperData.value.imageConfig.src)
}
isLoadingFormView.value = true isLoadingFormView.value = true
await loadFormView() await loadFormView()
setFormData() setFormData()
@ -480,7 +584,7 @@ useEventListener(
</script> </script>
<template> <template>
<div class="h-full"> <div class="h-full relative">
<template v-if="isMobileMode"> <template v-if="isMobileMode">
<div class="pl-6 pr-[120px] py-6 bg-white flex-col justify-start items-start gap-2.5 inline-flex"> <div class="pl-6 pr-[120px] py-6 bg-white flex-col justify-start items-start gap-2.5 inline-flex">
<div class="text-gray-500 text-5xl font-semibold leading-16"> <div class="text-gray-500 text-5xl font-semibold leading-16">
@ -550,16 +654,39 @@ useEventListener(
class="flex-1 h-full overflow-auto nc-form-scrollbar p-6" class="flex-1 h-full overflow-auto nc-form-scrollbar p-6"
:style="{background:(formViewData?.meta as Record<string,any>).background_color || '#F9F9FA'}" :style="{background:(formViewData?.meta as Record<string,any>).background_color || '#F9F9FA'}"
> >
<div :class="isEditable ? 'min-w-[616px] overflow-x-auto nc-form-scrollbar' : ''"> <div class="min-w-[616px] overflow-x-auto nc-form-scrollbar">
<GeneralImageCropper
v-model:show-cropper="showCropper"
:imageConfig="imageCropperData.imageConfig"
:cropperConfig="imageCropperData.cropperConfig"
:uploadConfig="imageCropperData.uploadConfig"
@submit="handleOnUploadImage"
></GeneralImageCropper>
<!-- for future implementation of cover image --> <!-- for future implementation of cover image -->
<!-- Todo: cover image uploader and image cropper to crop image in fixed aspect ratio --> <!-- Todo: cover image uploader and image cropper to crop image in fixed aspect ratio -->
<div class="relative max-w-screen-xl mx-auto">
<GeneralFormBanner <GeneralFormBanner
v-if=" v-if="
formViewData.banner_image_url || !(parseProp(formViewData?.meta).hide_branding && !formViewData.banner_image_url) formViewData.banner_image_url ||
!(parseProp(formViewData?.meta).hide_branding && !formViewData.banner_image_url)
" "
:banner-image-url="formViewData.banner_image_url" :banner-image-url="formViewData.banner_image_url"
/> />
<div class="absolute bottom-0 right-0">
<NcButton
type="secondary"
size="small"
class="nc-form-upload-banner-btn m-3"
data-testid="nc-form-upload-banner-btn"
@click="openUploadImage(true)"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.upload" class="w-4 h-4" />
<span> Upload Banner </span>
</div>
</NcButton>
</div>
</div>
<a-card <a-card
class="!py-8 !lg:py-12 !border-gray-200 !rounded-3xl !mt-6 max-w-[688px] !mx-auto" class="!py-8 !lg:py-12 !border-gray-200 !rounded-3xl !mt-6 max-w-[688px] !mx-auto"
:body-style="{ :body-style="{
@ -571,16 +698,31 @@ useEventListener(
<!-- form header --> <!-- form header -->
<div class="flex flex-col px-4 lg:px-6"> <div class="flex flex-col px-4 lg:px-6">
<!-- Form logo --> <!-- Form logo -->
<!-- <div v-if="isEditable"> <div class="mb-4">
<div class="inline-block rounded-xl bg-gray-100 p-3"> <div
<NcButton type="secondary" size="small" class="nc-form-upload-logo" data-testid="nc-form-upload-log"> class="nc-form-logo-wrapper mx-6 group relative inline-block rounded-xl bg-gray-100 h-56px w-156px overflow-hidden"
>
<LazyCellAttachmentImage
v-if="formViewData.logo_url"
:srcs="getPossibleAttachmentSrc(parseProp(formViewData.logo_url))"
class="nc-form-logo group object-contain w-full m-auto"
/>
<NcButton
v-if="isEditable"
type="secondary"
size="small"
class="nc-form-upload-logo-btn m-3"
:class="formViewData.logo_url ? 'hidden group-hover:(block absolute top-0 left-0)' : 'block'"
data-testid="nc-form-upload-log-btn"
@click="openUploadImage(false)"
>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<component :is="iconMap.upload" class="w-4 h-4" /> <component :is="iconMap.upload" class="w-4 h-4" />
<span> Upload Logo </span> <span> Upload Logo </span>
</div> </div>
</NcButton> </NcButton>
</div> </div>
</div> --> </div>
<!-- form title --> <!-- form title -->
<div <div
class="border-transparent px-4 lg:px-6" class="border-transparent px-4 lg:px-6"
@ -944,7 +1086,7 @@ useEventListener(
</a-card> </a-card>
</div> </div>
</div> </div>
<div v-if="isEditable" class="h-full flex-1 max-w-[384px] nc-form-left-drawer border-l border-gray-200"> <div class="h-full flex-1 max-w-[384px] nc-form-left-drawer border-l border-gray-200">
<Splitpanes horizontal class="w-full nc-form-right-splitpane"> <Splitpanes horizontal class="w-full nc-form-right-splitpane">
<Pane min-size="30" size="50" class="nc-form-right-splitpane-item p-4 flex flex-col space-y-4 !min-h-200px"> <Pane min-size="30" size="50" class="nc-form-right-splitpane-item p-4 flex flex-col space-y-4 !min-h-200px">
<div class="flex flex-wrap justify-between items-center gap-2"> <div class="flex flex-wrap justify-between items-center gap-2">
@ -1036,6 +1178,7 @@ useEventListener(
item-key="id" item-key="id"
ghost-class="nc-form-field-ghost" ghost-class="nc-form-field-ghost"
:style="{ height: 'calc(100% - 64px)' }" :style="{ height: 'calc(100% - 64px)' }"
:disabled="isLocked || !isEditable"
@change="onMove($event)" @change="onMove($event)"
@start="drag = true" @start="drag = true"
@end="drag = false" @end="drag = false"
@ -1081,7 +1224,7 @@ useEventListener(
<span v-if="isRequired(field, field.required)" class="text-red-500 text-sm align-top">&nbsp;*</span> <span v-if="isRequired(field, field.required)" class="text-red-500 text-sm align-top">&nbsp;*</span>
</div> </div>
</div> </div>
<NcSwitch :checked="!!field.show" :disabled="field.required" /> <NcSwitch :checked="!!field.show" :disabled="field.required || isLocked || !isEditable" />
</div> </div>
</div> </div>
</template> </template>
@ -1098,7 +1241,7 @@ useEventListener(
</div> </div>
</Pane> </Pane>
<Pane <Pane
v-if="isEditable && !isLocked && formViewData" v-if="formViewData"
min-size="20" min-size="20"
size="50" size="50"
class="nc-form-right-splitpane-item !overflow-y-auto nc-form-scrollbar" class="nc-form-right-splitpane-item !overflow-y-auto nc-form-scrollbar"
@ -1107,7 +1250,7 @@ useEventListener(
<!-- Appearance Settings --> <!-- Appearance Settings -->
<div class="text-base font-bold text-gray-900">{{ $t('labels.appearanceSettings') }}</div> <div class="text-base font-bold text-gray-900">{{ $t('labels.appearanceSettings') }}</div>
<div> <div :class="isLocked || !isEditable ? 'pointer-events-none' : ''">
<div class="text-gray-800">{{ $t('labels.backgroundColor') }}</div> <div class="text-gray-800">{{ $t('labels.backgroundColor') }}</div>
<div class="flex justify-start"> <div class="flex justify-start">
<LazyGeneralColorPicker <LazyGeneralColorPicker
@ -1150,8 +1293,11 @@ useEventListener(
size="small" size="small"
class="nc-form-hide-branding" class="nc-form-hide-branding"
data-testid="nc-form-hide-branding" data-testid="nc-form-hide-branding"
:disabled="isLocked || !isEditable"
@change=" @change="
(value) => { (value) => {
if (isLocked || !isEditable) return
(formViewData!.meta as Record<string,any>).hide_branding = value (formViewData!.meta as Record<string,any>).hide_branding = value
updateView() updateView()
} }
@ -1184,6 +1330,7 @@ useEventListener(
hide-details hide-details
class="nc-form-after-submit-msg !rounded-lg !px-3 !py-1" class="nc-form-after-submit-msg !rounded-lg !px-3 !py-1"
data-testid="nc-form-after-submit-msg" data-testid="nc-form-after-submit-msg"
:disabled="isLocked || !isEditable"
@change="updateView" @change="updateView"
/> />
</div> </div>
@ -1198,6 +1345,7 @@ useEventListener(
size="small" size="small"
class="nc-form-checkbox-submit-another-form" class="nc-form-checkbox-submit-another-form"
data-testid="nc-form-checkbox-submit-another-form" data-testid="nc-form-checkbox-submit-another-form"
:disabled="isLocked || !isEditable"
@change="updateView" @change="updateView"
/> />
<span class="ml-4">{{ $t('msg.info.submitAnotherForm') }}</span> <span class="ml-4">{{ $t('msg.info.submitAnotherForm') }}</span>
@ -1211,6 +1359,7 @@ useEventListener(
size="small" size="small"
class="nc-form-checkbox-show-blank-form" class="nc-form-checkbox-show-blank-form"
data-testid="nc-form-checkbox-show-blank-form" data-testid="nc-form-checkbox-show-blank-form"
:disabled="isLocked || !isEditable"
@change="updateView" @change="updateView"
/> />
@ -1224,6 +1373,7 @@ useEventListener(
size="small" size="small"
class="nc-form-checkbox-send-email" class="nc-form-checkbox-send-email"
data-testid="nc-form-checkbox-send-email" data-testid="nc-form-checkbox-send-email"
:disabled="isLocked || !isEditable"
@change="onEmailChange" @change="onEmailChange"
/> />
@ -1240,6 +1390,21 @@ useEventListener(
</div> </div>
</div> </div>
</template> </template>
<div
v-if="user?.base_roles?.viewer || user?.base_roles?.commenter"
class="absolute inset-0 bg-black/40 z-500 grid place-items-center"
>
<div class="text-center bg-white px-6 py-8 rounded-xl max-w-lg">
<div class="text-2xl text-gray-800 font-bold">
{{ $t('msg.info.yourCurrentRoleIs') }}
'<span class="capitalize"> {{ Object.keys(user.base_roles)?.[0] ?? ProjectRoles.NO_ACCESS }}</span
>'.
</div>
<div class="text-sm text-gray-700 pt-6">
{{ $t('msg.info.pleaseRequestAccessForView', { viewName: 'form view' }) }}
</div>
</div>
</div>
</div> </div>
</template> </template>

5
packages/nc-gui/lang/en.json

@ -1294,7 +1294,10 @@
"notAvailableAtTheMoment": "Not available at the moment", "notAvailableAtTheMoment": "Not available at the moment",
"groupPasteIsNotSupportedOnLinksColumn": "Group paste operation is not supported on Links/LinkToAnotherRecord column", "groupPasteIsNotSupportedOnLinksColumn": "Group paste operation is not supported on Links/LinkToAnotherRecord column",
"groupClearIsNotSupportedOnLinksColumn": "Group clear operation is not supported on Links/LinkToAnotherRecord column", "groupClearIsNotSupportedOnLinksColumn": "Group clear operation is not supported on Links/LinkToAnotherRecord column",
"upgradeToEnterpriseEdition": "Upgrade to Enterprise Edition {extraInfo}" "upgradeToEnterpriseEdition": "Upgrade to Enterprise Edition {extraInfo}",
"yourCurrentRoleIs": "Your current role is",
"pleaseRequestAccessForView": "Please request for higher permission from the Admin / Base owner / Workspace owner to get access to this {viewName}"
}, },
"error": { "error": {
"fetchingCalendarData": "Error fetching calendar data", "fetchingCalendarData": "Error fetching calendar data",

Loading…
Cancel
Save