Browse Source

feat(nc-gui): new share ui (#7845)

* feat(nc-gui): new share ui

* fix(nc-gui): hide view, base tab when opened from base

* fix(nc-gui): update prefill form labels

* fix(nc-gui): invite user to base

* fix(playwright): update share tests

* fix(nc-gui): enable shared base edit access in oss

* fix(nc-gui): truncate text if overflows

* fix(nc-gui): show tooltips if truncated

* fix(nc-gui): animate icons

* fix(playwright): oss tests fix

* fix(nc-gui): review changes

* fix(nc-gui): minor changes

* fix(nc-gui): update tabs

* fix(nc-gui): update translation

* fix(nc-gui): update heading size

* feat(nc-gui): support sharing any views from share modal

* fix(nc-gui): review changes

* fix(nc-gui): pr review changes
nc-revert-2521-feat/share-ui
Anbarasu 9 months ago committed by GitHub
parent
commit
f3c626abc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 489
      packages/nc-gui/components/dlg/share-and-collaborate/ShareBase.vue
  2. 376
      packages/nc-gui/components/dlg/share-and-collaborate/SharePage.vue
  3. 248
      packages/nc-gui/components/dlg/share-and-collaborate/View.vue
  4. 4
      packages/nc-gui/components/general/CopyUrl.vue
  5. 3
      packages/nc-gui/components/general/ShareProject.vue
  6. 4
      packages/nc-gui/components/nc/Modal.vue
  7. 23
      packages/nc-gui/components/nc/Switch.vue
  8. 2
      packages/nc-gui/components/nc/Tabs.vue
  9. 9
      packages/nc-gui/components/project/ShareBaseDlg.vue
  10. 2
      packages/nc-gui/components/roles/Badge.vue
  11. 4
      packages/nc-gui/components/workspace/InviteSection.vue
  12. 8
      packages/nc-gui/lang/en.json
  13. 38
      tests/playwright/pages/Dashboard/common/Topbar/Share.ts
  14. 5
      tests/playwright/pages/Dashboard/common/Topbar/index.ts
  15. 5
      tests/playwright/tests/db/features/baseShare.spec.ts

489
packages/nc-gui/components/dlg/share-and-collaborate/ShareBase.vue

@ -1,5 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { OrderedProjectRoles, ProjectRoles } from 'nocodb-sdk'
import { onKeyStroke } from '@vueuse/core'
import { import {
type User,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
message, message,
onMounted, onMounted,
@ -9,6 +12,7 @@ import {
useGlobal, useGlobal,
useNuxtApp, useNuxtApp,
useWorkspace, useWorkspace,
validateEmail,
} from '#imports' } from '#imports'
interface ShareBase { interface ShareBase {
@ -17,6 +21,13 @@ interface ShareBase {
role?: string role?: string
} }
const props = defineProps({
isView: {
type: Boolean,
default: false,
},
})
enum ShareBaseRole { enum ShareBaseRole {
Editor = 'editor', Editor = 'editor',
Viewer = 'viewer', Viewer = 'viewer',
@ -26,13 +37,27 @@ const { dashboardUrl } = useDashboard()
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()
const sharedBase = ref<null | ShareBase>(null) const { getBaseUrl, appInfo } = useGlobal()
const workspaceStore = useWorkspace()
const baseStore = useBase()
const basesStore = useBases()
const { activeProjectId } = storeToRefs(basesStore)
const { base } = storeToRefs(useBase()) const { base } = storeToRefs(useBase())
const { getBaseUrl, appInfo } = useGlobal() const { navigateToProjectPage } = baseStore
const workspaceStore = useWorkspace() const { createProjectUser } = basesStore
const { baseRoles } = useRoles()
const sharedBase = ref<null | ShareBase>(null)
const { showShareModal } = storeToRefs(useShare())
const url = computed(() => { const url = computed(() => {
if (!sharedBase.value || !sharedBase.value.uuid) return '' if (!sharedBase.value || !sharedBase.value.uuid) return ''
@ -48,22 +73,6 @@ const url = computed(() => {
return encodeURI(`${dashboardUrl1}#/base/${sharedBase.value.uuid}`) return encodeURI(`${dashboardUrl1}#/base/${sharedBase.value.uuid}`)
}) })
const loadBase = async () => {
try {
if (!base.value.id) return
const res = await $api.base.sharedBaseGet(base.value.id)
sharedBase.value = {
uuid: res.uuid,
url: res.url,
role: res.roles,
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const createShareBase = async (role = ShareBaseRole.Viewer) => { const createShareBase = async (role = ShareBaseRole.Viewer) => {
try { try {
if (!base.value.id) return if (!base.value.id) return
@ -98,12 +107,6 @@ const disableSharedBase = async () => {
$e('a:shared-base:disable') $e('a:shared-base:disable')
} }
onMounted(() => {
if (!sharedBase.value) {
loadBase()
}
})
const isSharedBaseEnabled = computed(() => !!sharedBase.value?.uuid) const isSharedBaseEnabled = computed(() => !!sharedBase.value?.uuid)
const isToggleBaseLoading = ref(false) const isToggleBaseLoading = ref(false)
const isRoleToggleLoading = ref(false) const isRoleToggleLoading = ref(false)
@ -125,10 +128,113 @@ const toggleSharedBase = async () => {
} }
} }
const openedBaseShareTab = ref<'members' | 'public'>('members')
const allowedRoles = ref<ProjectRoles[]>([])
const inviteData = reactive({
email: '',
roles: ProjectRoles.VIEWER,
})
const focusRef = ref<HTMLInputElement>()
const isDivFocused = ref(false)
const divRef = ref<HTMLDivElement>()
const emailBadges = ref<Array<string>>([])
const emailValidation = reactive({
isError: true,
message: '',
})
const singleEmailValue = ref('')
const focusOnDiv = () => {
focusRef.value?.focus()
isDivFocused.value = true
}
const isInviteButtonDisabled = computed(() => {
if (!emailBadges.value.length && !singleEmailValue.value.length) {
return true
}
if (emailBadges.value.length && inviteData.email) {
return true
}
})
// remove one email per backspace
onKeyStroke('Backspace', () => {
if (isDivFocused.value && inviteData.email.length < 1) {
emailBadges.value.pop()
}
})
onMounted(async () => {
try {
const currentRoleIndex = OrderedProjectRoles.findIndex(
(role) => baseRoles.value && Object.keys(baseRoles.value).includes(role),
)
if (currentRoleIndex !== -1) {
allowedRoles.value = OrderedProjectRoles.slice(currentRoleIndex + 1).filter((r) => r)
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
})
const insertOrUpdateString = (str: string) => {
// Check if the string already exists in the array
const index = emailBadges.value.indexOf(str)
if (index !== -1) {
// If the string exists, remove it
emailBadges.value.splice(index, 1)
}
// Add the new string to the array
emailBadges.value.push(str)
}
const emailInputValidation = (input: string): boolean => {
if (!input.length) {
emailValidation.isError = true
emailValidation.message = 'Email should not be empty'
return false
}
if (!validateEmail(input.trim())) {
emailValidation.isError = true
emailValidation.message = 'Invalid Email'
return false
}
return true
}
const handleEnter = () => {
const isEmailIsValid = emailInputValidation(inviteData.email)
if (!isEmailIsValid) return
inviteData.email += ' '
emailValidation.isError = false
emailValidation.message = ''
}
const loadBase = async () => {
try {
if (!base.value.id) return
const res = await $api.base.sharedBaseGet(base.value.id)
sharedBase.value = {
uuid: res.uuid,
url: res.url,
role: res.roles,
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const onRoleToggle = async () => { const onRoleToggle = async () => {
if (!sharedBase.value) return if (!sharedBase.value) return
if (isRoleToggleLoading.value) return if (isRoleToggleLoading.value) return
isRoleToggleLoading.value = true isRoleToggleLoading.value = true
try { try {
if (sharedBase.value.role === ShareBaseRole.Viewer) { if (sharedBase.value.role === ShareBaseRole.Viewer) {
@ -142,34 +248,317 @@ const onRoleToggle = async () => {
isRoleToggleLoading.value = false isRoleToggleLoading.value = false
} }
} }
const inviteProjectCollaborator = async () => {
try {
const payloadData = singleEmailValue.value || emailBadges.value.join(',')
if (!payloadData.includes(',')) {
const validationStatus = validateEmail(payloadData)
if (!validationStatus) {
emailValidation.isError = true
emailValidation.message = 'invalid email'
}
}
await createProjectUser(activeProjectId.value!, {
email: payloadData,
roles: inviteData.roles,
} as unknown as User)
message.success('Invitation sent successfully')
inviteData.email = ''
emailBadges.value = []
showShareModal.value = false
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
singleEmailValue.value = ''
}
}
const onPaste = (e: ClipboardEvent) => {
const pastedText = e.clipboardData?.getData('text')
if (!pastedText) return
let inputArray
if (pastedText?.includes(',')) {
inputArray = pastedText?.split(',')
} else if (pastedText?.includes(' ')) {
inputArray = pastedText?.split(' ')
} else {
inputArray = pastedText?.split('\n')
}
// if data is pasted to a already existing text in input
// we add existingInput + pasted data
if (inputArray?.length === 1 && inviteData.email.length) {
inputArray[0] = inviteData.email += inputArray[0]
}
inputArray?.forEach((el) => {
const isEmailIsValid = emailInputValidation(el)
if (!isEmailIsValid) return
/**
if email is already enterd we delete the already
existing email and add new one
**/
if (emailBadges.value.includes(el)) {
insertOrUpdateString(el)
return
}
emailBadges.value.push(el)
inviteData.email = ''
})
inviteData.email = ''
}
watch(inviteData, (newVal) => {
// when user only want to enter a single email
// we dont convert that as badge
const isSingleEmailValid = validateEmail(newVal.email)
if (isSingleEmailValid && !emailBadges.value.length) {
singleEmailValue.value = newVal.email
emailValidation.isError = false
return
}
singleEmailValue.value = ''
// when user enters multiple emails comma sepearted or space sepearted
const isNewEmail = newVal.email.charAt(newVal.email.length - 1) === ',' || newVal.email.charAt(newVal.email.length - 1) === ' '
if (isNewEmail && newVal.email.trim().length) {
const emailToAdd = newVal.email.split(',')[0].trim() || newVal.email.split(' ')[0].trim()
if (!validateEmail(emailToAdd)) {
emailValidation.isError = true
emailValidation.message = 'Invalid Email'
return
}
/**
if email is already enterd we delete the already
existing email and add new one
**/
if (emailBadges.value.includes(emailToAdd)) {
insertOrUpdateString(emailToAdd)
inviteData.email = ''
return
}
emailBadges.value.push(emailToAdd)
inviteData.email = ''
singleEmailValue.value = ''
}
if (!newVal.email.length && emailValidation.isError) {
emailValidation.isError = false
}
})
const openManageAccess = async () => {
try {
await navigateToProjectPage({ page: 'collaborator' })
showShareModal.value = false
} catch (e) {
console.error(e)
message.error('Failed to open manage access')
}
}
onMounted(() => {
if (!sharedBase.value) {
loadBase()
}
})
</script> </script>
<template> <template>
<div class="flex flex-col py-2 px-3 gap-2 w-full" data-testid="nc-share-base-sub-modal"> <div class="flex flex-col !h-80 gap-2" data-testid="nc-share-base-sub-modal">
<div class="flex flex-col w-full p-3 border-1 border-gray-100 rounded-md"> <NcTabs v-model:activeKey="openedBaseShareTab" class="nc-base-share-tab h-full" size="medium">
<div class="flex flex-row w-full justify-between"> <a-tab-pane key="members">
<div class="text-gray-900 font-medium">{{ $t('activity.enablePublicAccess') }}</div> <template #tab>
<a-switch <div class="tab" data-testid="nc-base-share-member">
v-e="['c:share:base:enable:toggle']" <GeneralIcon :class="{}" class="tab-icon" icon="user" />
:checked="isSharedBaseEnabled" <div>Add Members</div>
:loading="isToggleBaseLoading" </div>
class="ml-2" </template>
@click="toggleSharedBase"
/> <div class="my-4 space-y-3">
</div> <div class="flex justify-between items-center">
<div v-if="isSharedBaseEnabled" class="flex flex-col w-full mt-3 border-t-1 pt-3 border-gray-100"> <div class="gap-2 flex items-center text-gray-500">
<GeneralCopyUrl v-model:url="url" /> <span> {{ $t('activity.inviteAs') }} </span>
<div v-if="!appInfo.ee" class="flex flex-row justify-between mt-3 bg-gray-50 px-3 py-2 rounded-md"> <RolesSelector
<div class="text-black">{{ $t('activity.editingAccess') }}</div> :description="false"
<a-switch :on-role-change="(role: ProjectRoles) => (inviteData.roles = role)"
v-e="['c:share:base:role:toggle']" :role="inviteData.roles"
:loading="isRoleToggleLoading" :roles="allowedRoles"
:checked="sharedBase?.role === ShareBaseRole.Editor" class="px-1 !min-w-[152px] nc-invite-role-selector"
class="ml-2" size="md"
@click="onRoleToggle" />
/> <span>
{{ $t('activity.toBase') }}
</span>
<span class="flex text-gray-600 items-center py-1 px-2 h-[1.75rem] gap-2 bg-gray-100 rounded-lg text-md">
<GeneralProjectIcon
:color="parseProp(base.meta).iconColor"
:type="base.type"
class="nc-view-icon w-4 h-4 group-hover"
/>
<span class="max-w-72 truncate">
<NcTooltip show-on-truncate-only>
<template #title> {{ base.title }} </template>
<span class="ellipsis max-w-64">
{{ base.title }}
</span>
</NcTooltip>
</span>
</span>
</div>
<NcTooltip>
<template #title>{{ $t('labels.enterMultipleEmails') }} </template>
<component :is="iconMap.info" class="text-gray-500" />
</NcTooltip>
</div>
<div class="flex flex-col">
<div
ref="divRef"
:class="{
'border-gray-200': isDivFocused,
}"
class="flex py-2 px-2 border-1 gap-1 items-center max-h-46 flex-wrap rounded-lg nc-scrollbar-md"
tabindex="0"
@blur="isDivFocused = false"
@click="focusOnDiv"
>
<span
v-for="(email, index) in emailBadges"
:key="email"
class="leading-4 border-1 text-gray-800 bg-gray-100 rounded-md ml-1 px-2 py-1"
>
{{ email }}
<component
:is="iconMap.close"
class="ml-0.5 hover:cursor-pointer w-3.5 h-3.5"
@click="emailBadges.splice(index, 1)"
/>
</span>
<input
id="email"
ref="focusRef"
v-model="inviteData.email"
:placeholder="emailBadges.length < 1 ? 'Enter emails to send invitation' : ''"
class="min-w-60 outline-0 ml-2 mr-3 flex-grow-1"
data-testid="email-input"
@blur="isDivFocused = false"
@keyup.enter="handleEnter"
@paste.prevent="onPaste"
/>
</div>
<span v-if="emailValidation.isError && emailValidation.message" class="ml-2 text-red-500 text-[10px] mt-1.5">{{
emailValidation.message
}}</span>
</div>
</div> </div>
</div> </a-tab-pane>
<a-tab-pane key="public">
<template #tab>
<div class="tab" data-testid="nc-base-share-public">
<MaterialSymbolsPublic />
<div>Share Publicly</div>
</div>
</template>
<div class="border-1 my-4 p-3 border-1 space-y-3 border-gray-200 rounded-lg">
<div class="flex flex-row items-center w-full">
<div class="flex text-gray-700 !w-full items-center gap-2 px-2">
<span class="font-medium">
{{ $t('activity.enablePublicAccess') }}
</span>
<span class="flex items-center py-1 px-2 h-[1.75rem] gap-2 rounded-lg bg-gray-100 text-md">
<GeneralProjectIcon
:color="parseProp(base.meta).iconColor"
:type="base.type"
class="nc-view-icon w-4 h-4 group-hover"
/>
<span class="max-w-72 truncate">
<NcTooltip show-on-truncate-only>
<template #title> {{ base.title }} </template>
<span class="ellipsis max-w-64">
{{ base.title }}
</span>
</NcTooltip>
</span>
</span>
</div>
<NcSwitch
v-e="['c:share:base:enable:toggle']"
:checked="isSharedBaseEnabled"
class="ml-2"
@change="toggleSharedBase"
/>
</div>
<div v-if="isSharedBaseEnabled" class="space-y-3">
<GeneralCopyUrl
v-model:url="url"
:class="{
'w-[34.625rem]': props.isView,
}"
/>
<div v-if="!appInfo.ee" class="flex flex-row gap-3 items-center">
<a-switch
v-e="['c:share:base:role:toggle']"
:checked="sharedBase?.role === ShareBaseRole.Editor"
:loading="isRoleToggleLoading"
class="share-editor-toggle !mt-0.25"
data-testid="share-password-toggle"
size="small"
@click="onRoleToggle"
/>
<div class="flex text-black">{{ $t('activity.editingAccess') }}</div>
</div>
</div>
</div>
</a-tab-pane>
</NcTabs>
<div class="flex gap-2 items-end justify-end">
<NcButton v-if="openedBaseShareTab === 'members'" type="secondary" @click="showShareModal = false">
{{ $t('general.cancel') }}
</NcButton>
<NcButton type="secondary" @click="openManageAccess">
{{ $t('activity.manageAccess') }}
</NcButton>
<NcButton v-if="openedBaseShareTab === 'members'" :disabled="isInviteButtonDisabled" @click="inviteProjectCollaborator">
{{ $t('activity.inviteMembers') }}
</NcButton>
<NcButton v-else-if="openedBaseShareTab === 'public'" type="secondary" @click="showShareModal = false">
{{ $t('general.finish') }}
</NcButton>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
:deep(.nc-invite-role-selector .nc-role-badge) {
@apply w-full;
}
.tab {
@apply flex flex-row items-center gap-x-2;
}
:deep(.ant-tabs-nav-list) {
@apply !gap-5;
}
:deep(.ant-tabs-tab) {
@apply !pt-0 !pb-2.5 !ml-0;
}
.ant-tabs-content {
@apply !h-full;
}
.ant-tabs-content-top {
@apply !h-full;
}
</style>

376
packages/nc-gui/components/dlg/share-and-collaborate/SharePage.vue

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ColumnType, KanbanType, ViewType } from 'nocodb-sdk' import type { ColumnType, KanbanType, ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
import { PreFilledMode, useMetas } from '#imports' import { PreFilledMode, iconMap, message, storeToRefs, useBase, useMetas } from '#imports'
const { view: _view, $api } = useSmartsheetStoreOrThrow() const { view: _view, $api } = useSmartsheetStoreOrThrow()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
@ -11,6 +11,14 @@ const { dashboardUrl } = useDashboard()
const viewStore = useViewsStore() const viewStore = useViewsStore()
const { viewsByTable } = storeToRefs(viewStore)
const { showShareModal } = storeToRefs(useShare())
const baseStore = useBase()
const { navigateToProjectPage } = baseStore
const { metas } = useMetas() const { metas } = useMetas()
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()
@ -44,8 +52,34 @@ const activeView = computed<(ViewType & { meta: object & Record<string, any> })
}, },
}) })
const viewsInTable = computed({
get: () => {
if (!activeView.value) return []
return viewsByTable.value.get(activeView.value?.fk_model_id) || []
},
set: (val) => {
viewsByTable.value.set(activeView.value?.fk_model_id, val)
},
})
const selectedViewId = ref<string | undefined>(activeView.value?.id)
watch(activeView, (val) => {
selectedViewId.value = val?.id
})
const selectedView = computed<(ViewType & { meta: object & Record<string, any> }) | undefined>({
get: () => {
if (!selectedViewId.value) return
return viewsInTable.value.find((v) => v.id === selectedViewId.value)
},
set: (val) => {
viewsInTable.value = viewsInTable.value.map((v) => (v.id === selectedViewId.value ? val : v))
},
})
const isPublicShared = computed(() => { const isPublicShared = computed(() => {
return !!activeView.value?.uuid return !!selectedView.value?.uuid
}) })
const url = computed(() => { const url = computed(() => {
@ -55,27 +89,27 @@ const url = computed(() => {
const passwordProtectedLocal = ref(false) const passwordProtectedLocal = ref(false)
const passwordProtected = computed(() => { const passwordProtected = computed(() => {
return !!activeView.value?.password || passwordProtectedLocal.value return !!selectedView.value?.password || passwordProtectedLocal.value
}) })
const password = computed({ const password = computed({
get: () => (passwordProtected.value ? activeView.value?.password ?? '' : ''), get: () => (passwordProtected.value ? selectedView.value?.password ?? '' : ''),
set: async (value) => { set: async (value) => {
if (!activeView.value) return if (!selectedView.value) return
activeView.value = { ...(activeView.value as any), password: passwordProtected.value ? value : null } selectedView.value = { ...(selectedView.value as any), password: passwordProtected.value ? value : null }
updateSharedView() updateSharedView()
}, },
}) })
const viewTheme = computed({ const viewTheme = computed({
get: () => !!activeView.value?.meta.withTheme, get: () => !!selectedView.value?.meta.withTheme,
set: (withTheme) => { set: (withTheme) => {
if (!activeView.value?.meta) return if (!selectedView.value?.meta) return
activeView.value.meta = { selectedView.value.meta = {
...activeView.value.meta, ...selectedView.value.meta,
withTheme, withTheme,
} }
saveTheme() saveTheme()
@ -84,15 +118,15 @@ const viewTheme = computed({
const togglePasswordProtected = async () => { const togglePasswordProtected = async () => {
passwordProtectedLocal.value = !passwordProtected.value passwordProtectedLocal.value = !passwordProtected.value
if (!activeView.value) return if (!selectedView.value) return
if (isUpdating.value.password) return if (isUpdating.value.password) return
isUpdating.value.password = true isUpdating.value.password = true
try { try {
if (passwordProtected.value) { if (passwordProtected.value) {
activeView.value = { ...(activeView.value as any), password: null } selectedView.value = { ...(selectedView.value as any), password: null }
} else { } else {
activeView.value = { ...(activeView.value as any), password: '' } selectedView.value = { ...(selectedView.value as any), password: '' }
} }
await updateSharedView() await updateSharedView()
@ -103,35 +137,35 @@ const togglePasswordProtected = async () => {
const withRTL = computed({ const withRTL = computed({
get: () => { get: () => {
if (!activeView.value?.meta) return false if (!selectedView.value?.meta) return false
if (typeof activeView.value?.meta === 'string') { if (typeof selectedView.value?.meta === 'string') {
activeView.value.meta = JSON.parse(activeView.value.meta) selectedView.value.meta = JSON.parse(selectedView.value.meta)
} }
return !!(activeView.value?.meta as any)?.rtl return !!(selectedView.value?.meta as any)?.rtl
}, },
set: (rtl) => { set: (rtl) => {
if (!activeView.value?.meta) return if (!selectedView.value?.meta) return
if (typeof activeView.value?.meta === 'string') { if (typeof selectedView.value?.meta === 'string') {
activeView.value.meta = JSON.parse(activeView.value.meta) selectedView.value.meta = JSON.parse(selectedView.value.meta)
} }
activeView.value.meta = { ...(activeView.value.meta as any), rtl } selectedView.value.meta = { ...(selectedView.value.meta as any), rtl }
updateSharedView() updateSharedView()
}, },
}) })
const allowCSVDownload = computed({ const allowCSVDownload = computed({
get: () => !!(activeView.value?.meta as any)?.allowCSVDownload, get: () => !!(selectedView.value?.meta as any)?.allowCSVDownload,
set: async (allow) => { set: async (allow) => {
if (!activeView.value?.meta) return if (!selectedView.value?.meta) return
isUpdating.value.download = true isUpdating.value.download = true
try { try {
activeView.value.meta = { ...activeView.value.meta, allowCSVDownload: allow } selectedView.value.meta = { ...selectedView.value.meta, allowCSVDownload: allow }
await saveAllowCSVDownload() await saveAllowCSVDownload()
} finally { } finally {
isUpdating.value.download = false isUpdating.value.download = false
@ -140,22 +174,22 @@ const allowCSVDownload = computed({
}) })
const surveyMode = computed({ const surveyMode = computed({
get: () => !!activeView.value?.meta.surveyMode, get: () => !!selectedView.value?.meta.surveyMode,
set: (survey) => { set: (survey) => {
if (!activeView.value?.meta) return if (!selectedView.value?.meta) return
activeView.value.meta = { ...activeView.value.meta, surveyMode: survey } selectedView.value.meta = { ...selectedView.value.meta, surveyMode: survey }
saveSurveyMode() saveSurveyMode()
}, },
}) })
const formPreFill = computed({ const formPreFill = computed({
get: () => ({ get: () => ({
preFillEnabled: parseProp(activeView.value?.meta)?.preFillEnabled ?? false, preFillEnabled: parseProp(selectedView.value?.meta)?.preFillEnabled ?? false,
preFilledMode: parseProp(activeView.value?.meta)?.preFilledMode || PreFilledMode.Default, preFilledMode: parseProp(selectedView.value?.meta)?.preFilledMode || PreFilledMode.Default,
}), }),
set: (value) => { set: (value) => {
if (!activeView.value?.meta) return if (!selectedView.value?.meta) return
if (formPreFill.value.preFillEnabled !== value.preFillEnabled) { if (formPreFill.value.preFillEnabled !== value.preFillEnabled) {
$e(`a:view:share:prefilled-mode-${value.preFillEnabled ? 'enabled' : 'disabled'}`) $e(`a:view:share:prefilled-mode-${value.preFillEnabled ? 'enabled' : 'disabled'}`)
@ -165,8 +199,8 @@ const formPreFill = computed({
$e(`a:view:share:${value.preFilledMode}-prefilled-mode`) $e(`a:view:share:${value.preFilledMode}-prefilled-mode`)
} }
activeView.value.meta = { selectedView.value.meta = {
...activeView.value.meta, ...selectedView.value.meta,
...value, ...value,
} }
savePreFilledMode() savePreFilledMode()
@ -181,10 +215,10 @@ const handleChangeFormPreFill = (value: { preFillEnabled?: boolean; preFilledMod
} }
function sharedViewUrl() { function sharedViewUrl() {
if (!activeView.value) return if (!selectedView.value) return
let viewType let viewType
switch (activeView.value.type) { switch (selectedView.value.type) {
case ViewTypes.FORM: case ViewTypes.FORM:
viewType = 'form' viewType = 'form'
break break
@ -213,30 +247,30 @@ function sharedViewUrl() {
} }
return encodeURI( return encodeURI(
`${dashboardUrl1}#/nc/${viewType}/${activeView.value.uuid}${surveyMode.value ? '/survey' : ''}${ `${dashboardUrl1}#/nc/${viewType}/${selectedView.value.uuid}${surveyMode.value ? '/survey' : ''}${
viewStore.preFillFormSearchParams && formPreFill.value.preFillEnabled ? `?${viewStore.preFillFormSearchParams}` : '' viewStore.preFillFormSearchParams && formPreFill.value.preFillEnabled ? `?${viewStore.preFillFormSearchParams}` : ''
}`, }`,
) )
} }
const toggleViewShare = async () => { const toggleViewShare = async () => {
if (!activeView.value?.id) return if (!selectedView.value?.id) return
if (activeView.value?.uuid) { if (selectedView.value?.uuid) {
await $api.dbViewShare.delete(activeView.value.id) await $api.dbViewShare.delete(selectedView.value.id)
activeView.value = { ...activeView.value, uuid: undefined, password: undefined } selectedView.value = { ...selectedView.value, uuid: undefined, password: undefined }
} else { } else {
const response = await $api.dbViewShare.create(activeView.value.id) const response = await $api.dbViewShare.create(selectedView.value.id)
activeView.value = { ...activeView.value, ...(response as any) } selectedView.value = { ...selectedView.value, ...(response as any) }
if (activeView.value!.type === ViewTypes.KANBAN) { if (selectedView.value!.type === ViewTypes.KANBAN) {
// extract grouping column meta // extract grouping column meta
const groupingFieldColumn = metas.value[viewStore.activeView!.fk_model_id].columns!.find( const groupingFieldColumn = metas.value[selectedView.value!.fk_model_id].columns!.find(
(col: ColumnType) => col.id === ((viewStore.activeView!.view! as KanbanType).fk_grp_col_id! as string), (col: ColumnType) => col.id === ((selectedView.value.view! as KanbanType).fk_grp_col_id! as string),
) )
activeView.value!.meta = { ...activeView.value!.meta, groupingFieldColumn } selectedView.value!.meta = { ...selectedView.value!.meta, groupingFieldColumn }
await updateSharedView() await updateSharedView()
} }
@ -279,12 +313,12 @@ async function saveTheme() {
async function updateSharedView() { async function updateSharedView() {
try { try {
if (!activeView.value?.meta) return if (!selectedView.value?.meta) return
const meta = activeView.value.meta const meta = selectedView.value.meta
await $api.dbViewShare.update(activeView.value.id!, { await $api.dbViewShare.update(selectedView.value.id!, {
meta, meta,
password: activeView.value.password, password: selectedView.value.password,
}) })
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
@ -297,31 +331,55 @@ async function savePreFilledMode() {
await updateSharedView() await updateSharedView()
} }
watchEffect(() => {}) const openManageAccess = async () => {
try {
await navigateToProjectPage({ page: 'collaborator' })
showShareModal.value = false
} catch (e) {
console.error(e)
message.error('Failed to open manage access')
}
}
</script> </script>
<template> <template>
<div class="flex flex-col py-2 px-3 mb-1"> <div class="flex flex-col !h-80 justify-between">
<div class="flex flex-col w-full mt-2.5 px-3 py-2.5 border-gray-200 border-1 rounded-md gap-y-2"> <div class="flex flex-col p-3 border-gray-200 border-1 rounded-lg gap-y-2">
<div class="flex flex-row w-full justify-between py-0.5"> <div class="flex flex-row items-center justify-between">
<div class="text-gray-900 font-medium">{{ $t('activity.enabledPublicViewing') }}</div> <div class="flex items-center gap-3">
<span class="text-gray-900 font-medium">{{ $t('activity.enabledPublicViewing') }}</span>
<NcSelect v-model:value="selectedViewId" class="w-48" size="medium">
<a-select-option v-for="view in viewsInTable" :key="view.id" :value="view.id">
<div class="flex items-center w-full justify-between w-full gap-2">
<GeneralViewIcon :meta="view" class="!text-md mt-0.5" />
<span class="truncate !w-36 flex-1 capitalize">{{ view.title }}</span>
<component
:is="iconMap.check"
v-if="view.id === selectedViewId"
id="nc-selected-item-icon"
class="text-primary w-4 h-4"
/>
</div>
</a-select-option>
</NcSelect>
</div>
<a-switch <a-switch
v-e="['c:share:view:enable:toggle']" v-e="['c:share:view:enable:toggle']"
size="small"
:checked="isPublicShared" :checked="isPublicShared"
:disabled="isLocked" :disabled="isLocked"
:loading="isUpdating.public" :loading="isUpdating.public"
class="share-view-toggle !mt-0.25" class="share-view-toggle !mt-0.25"
data-testid="share-view-toggle" data-testid="share-view-toggle"
@click="toggleShare" @change="toggleShare"
/> />
</div> </div>
<template v-if="isPublicShared">
<div class="mt-0.5 border-t-1 border-gray-100 pt-3"> <div v-if="isPublicShared" class="space-y-3">
<GeneralCopyUrl v-model:url="url" /> <GeneralCopyUrl v-model:url="url" class="w-[34.625rem]" />
</div> <div class="flex items-center gap-3 h-8 justify-between">
<div class="flex flex-col justify-between mt-1 py-2 px-3 bg-gray-50 rounded-md"> <div class="flex flex-row gap-3 items-center">
<div class="flex flex-row items-center justify-between">
<div class="flex text-black">{{ $t('activity.restrictAccessWithPassword') }}</div>
<a-switch <a-switch
v-e="['c:share:view:password:toggle']" v-e="['c:share:view:password:toggle']"
:checked="passwordProtected" :checked="passwordProtected"
@ -329,52 +387,42 @@ watchEffect(() => {})
class="share-password-toggle !mt-0.25" class="share-password-toggle !mt-0.25"
data-testid="share-password-toggle" data-testid="share-password-toggle"
size="small" size="small"
@click="togglePasswordProtected" @change="togglePasswordProtected"
/> />
<div class="flex text-black">{{ $t('activity.restrictAccessWithPassword') }}</div>
</div> </div>
<Transition mode="out-in" name="layout"> <a-input-password
<div v-if="passwordProtected" class="flex gap-2 mt-2 w-2/3"> v-if="passwordProtected"
<a-input-password v-model:value="password"
v-model:value="password" :placeholder="$t('placeholder.password.enter')"
:placeholder="$t('placeholder.password.enter')" class="!rounded-lg flex-1 !focus:border-brand-500 !w-72 !focus:ring-0 !focus:shadow-none !border-gray-200 !py-1 !bg-white"
class="!rounded-lg !py-1 !bg-white" data-testid="nc-modal-share-view__password"
data-testid="nc-modal-share-view__password" size="small"
size="small" type="password"
type="password" />
/>
</div>
</Transition>
</div> </div>
<div class="flex flex-col justify-between gap-y-3 mt-1 py-2 px-3 bg-gray-50 rounded-md">
<div
v-if="
activeView &&
[ViewTypes.GRID, ViewTypes.KANBAN, ViewTypes.GALLERY, ViewTypes.MAP, ViewTypes.CALENDAR].includes(activeView.type)
"
class="flex flex-row items-center justify-between"
>
<div class="flex text-black">{{ $t('activity.allowDownload') }}</div>
<a-switch
v-model:checked="allowCSVDownload"
v-e="['c:share:view:allow-csv-download:toggle']"
:loading="isUpdating.download"
class="public-password-toggle !mt-0.25"
data-testid="share-download-toggle"
size="small"
/>
</div>
<template v-if="activeView?.type === ViewTypes.FORM"> <div
<div class="flex flex-row items-center justify-between"> v-if="
<div class="text-black flex items-center space-x-1"> selectedView &&
<div> [ViewTypes.GRID, ViewTypes.KANBAN, ViewTypes.GALLERY, ViewTypes.MAP, ViewTypes.CALENDAR].includes(selectedView.type)
{{ $t('activity.surveyMode') }} "
</div> class="flex flex-row items-center gap-3"
<NcTooltip> >
<template #title> {{ $t('tooltip.surveyFormInfo') }}</template> <a-switch
<GeneralIcon icon="info" class="text-gray-600 cursor-pointer"></GeneralIcon> v-model:checked="allowCSVDownload"
</NcTooltip> v-e="['c:share:view:allow-csv-download:toggle']"
</div> :loading="isUpdating.download"
class="public-password-toggle !mt-0.25"
data-testid="share-download-toggle"
size="small"
/>
<div class="flex text-black">{{ $t('activity.allowDownload') }}</div>
</div>
<template v-if="selectedView?.type === ViewTypes.FORM">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a-switch <a-switch
v-model:checked="surveyMode" v-model:checked="surveyMode"
v-e="['c:share:view:surver-mode:toggle']" v-e="['c:share:view:surver-mode:toggle']"
@ -382,61 +430,77 @@ watchEffect(() => {})
size="small" size="small"
> >
</a-switch> </a-switch>
{{ $t('activity.surveyMode') }}
</div> </div>
<div v-if="!isEeUI" class="flex flex-row items-center justify-between"> <NcTooltip>
<div class="text-black">{{ $t('activity.rtlOrientation') }}</div> <template #title>{{ $t('tooltip.surveyFormInfo') }} </template>
<component :is="iconMap.info" class="text-gray-500" />
</NcTooltip>
</div>
<div v-if="!isEeUI" class="flex flex-row items-center gap-3">
<a-switch
v-model:checked="withRTL"
v-e="['c:share:view:rtl-orientation:toggle']"
data-testid="nc-modal-share-view__RTL"
size="small"
>
</a-switch>
<div class="text-black">{{ $t('activity.rtlOrientation') }}</div>
</div>
<div class="flex items-center h-8 justify-between">
<div class="flex items-center gap-3">
<a-switch <a-switch
v-model:checked="withRTL" v-e="['c:share:view:surver-mode:toggle']"
v-e="['c:share:view:rtl-orientation:toggle']" :checked="formPreFill.preFillEnabled"
data-testid="nc-modal-share-view__RTL" data-testid="nc-modal-share-view__preFill"
size="small" size="small"
@update:checked="handleChangeFormPreFill({ preFillEnabled: $event as boolean })"
> >
</a-switch> </a-switch>
</div> {{ $t('activity.preFilledFields.title') }}
</template>
</div>
<div
v-if="activeView?.type === ViewTypes.FORM"
class="nc-pre-filled-mode-wrapper flex flex-col justify-between gap-y-3 mt-1 py-2 px-3 bg-gray-50 rounded-md"
>
<div class="flex flex-row items-center justify-between">
<div class="text-black flex items-center space-x-1">
<div>
{{ $t('activity.preFilledFields.title') }}
</div>
<NcTooltip> <NcSelect
<template #title> v-if="formPreFill.preFillEnabled"
<div class="text-center"> v-model:value="formPreFill.preFilledMode"
{{ $t('tooltip.preFillFormInfo') }} class="w-48"
@change="handleChangeFormPreFill"
>
<a-select-option
v-for="op of Object.values(PreFilledMode).map((v) => {
return { label: $t(`activity.preFilledFields.${v}`), value: v }
})"
:key="op.value"
:value="op.value"
>
<div class="flex items-center w-full justify-between w-full gap-2">
<div class="truncate flex-1 capitalize">{{ op.label }}</div>
<component
:is="iconMap.check"
v-if="formPreFill.preFilledMode === op.value"
id="nc-selected-item-icon"
class="text-primary w-4 h-4"
/>
</div> </div>
</template> </a-select-option>
<GeneralIcon icon="info" class="text-gray-600 cursor-pointer"></GeneralIcon> </NcSelect>
</NcTooltip>
</div> </div>
<a-switch <NcTooltip>
v-e="['c:share:view:surver-mode:toggle']" <template #title>{{ $t('tooltip.preFillFormInfo') }} </template>
:checked="formPreFill.preFillEnabled" <component :is="iconMap.info" class="text-gray-500" />
data-testid="nc-modal-share-view__preFill" </NcTooltip>
size="small"
@update:checked="handleChangeFormPreFill({ preFillEnabled: $event as boolean })"
>
</a-switch>
</div> </div>
</template>
</div>
</div>
<a-radio-group <div class="flex gap-2 items-end justify-end">
v-if="formPreFill.preFillEnabled" <NcButton type="secondary" @click="openManageAccess">
:value="formPreFill.preFilledMode" {{ $t('activity.manageAccess') }}
class="nc-modal-share-view-preFillMode" </NcButton>
data-testid="nc-modal-share-view__preFillMode" <NcButton type="secondary" @click="showShareModal = false">
@update:value="handleChangeFormPreFill({ preFilledMode: $event })" {{ $t('general.finish') }}
> </NcButton>
<a-radio v-for="mode of Object.values(PreFilledMode)" :key="mode" :value="mode">
<div class="flex-1">{{ $t(`activity.preFilledFields.${mode}`) }}</div>
</a-radio>
</a-radio-group>
</div>
</template>
</div> </div>
</div> </div>
</template> </template>
@ -459,18 +523,4 @@ watchEffect(() => {})
line-height: 1rem !important; line-height: 1rem !important;
} }
} }
.nc-modal-share-view-preFillMode {
@apply flex flex-col;
.ant-radio-wrapper {
@apply !m-0 !flex !items-center w-full px-2 py-1 rounded-lg hover:bg-gray-100;
.ant-radio {
@apply !top-0;
}
.ant-radio + span {
@apply !flex !pl-4;
}
}
}
</style> </style>

248
packages/nc-gui/components/dlg/share-and-collaborate/View.vue

@ -1,19 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ViewType } from 'nocodb-sdk' import type { ViewType } from 'nocodb-sdk'
import { LoadingOutlined } from '@ant-design/icons-vue'
import ManageUsers from './ManageUsers.vue'
import { useViewsStore } from '~/store/views'
const { isViewToolbar } = defineProps<{ const { isViewToolbar } = defineProps<{
isViewToolbar?: boolean isViewToolbar?: boolean
}>() }>()
const { copy } = useCopy()
const { dashboardUrl } = useDashboard()
const baseStore = useBase() const baseStore = useBase()
const { base } = storeToRefs(baseStore) const { base } = storeToRefs(baseStore)
const { navigateToProjectPage } = baseStore
const { activeView } = storeToRefs(useViewsStore())
let view: Ref<ViewType | undefined> let view: Ref<ViewType | undefined>
if (isViewToolbar) { if (isViewToolbar) {
@ -25,53 +18,12 @@ if (isViewToolbar) {
} }
} }
const { formStatus, showShareModal, invitationUsersData, isInvitationLinkCopied } = storeToRefs(useShare()) const activeTab = ref<'base' | 'view'>('base')
const { resetData } = useShare()
// const { inviteUser } = useManageUsers()
// const expandedSharedType = ref<'none' | 'base' | 'view'>('view')
const isOpeningManageAccess = ref(false)
const inviteUrl = computed(() =>
invitationUsersData.value.invitationToken ? `${dashboardUrl.value}#/signup/${invitationUsersData.value.invitationToken}` : null,
)
const indicator = h(LoadingOutlined, {
style: {
fontSize: '24px',
},
spin: true,
})
/*
const onShare = async () => {
if (!invitationValid) return
await inviteUser({
email: invitationUsersData.value.emails!,
roles: invitationUsersData.value.role!,
})
}
*/
const copyInvitationLink = async () => { const { formStatus, showShareModal } = storeToRefs(useShare())
await copy(inviteUrl.value!) const { resetData } = useShare()
isInvitationLinkCopied.value = true const highlightStyle = ref({ top: isViewToolbar ? '160px' : '4px' })
}
const openManageAccess = async () => {
isOpeningManageAccess.value = true
try {
await navigateToProjectPage({ page: 'collaborator' })
showShareModal.value = false
} catch (e) {
console.error(e)
message.error('Failed to open manage access')
} finally {
isOpeningManageAccess.value = false
}
}
watch(showShareModal, (val) => { watch(showShareModal, (val) => {
if (!val) { if (!val) {
@ -80,120 +32,112 @@ watch(showShareModal, (val) => {
}, 500) }, 500)
} }
}) })
watch(showShareModal, () => {
if (isViewToolbar && view.value) {
activeTab.value = 'view'
}
})
const updateHighlightPosition = () => {
nextTick(() => {
const activeTab = document.querySelector('.nc-share-active') as HTMLElement
if (activeTab) {
highlightStyle.value.top = `${activeTab.offsetTop}px`
}
})
}
watch(activeTab, () => {
updateHighlightPosition()
})
</script> </script>
<template> <template>
<a-modal <NcModal
v-model:visible="showShareModal" v-model:visible="showShareModal"
class="!top-[1%]" :centered="false"
:class="{ active: showShareModal }" :class="{ active: showShareModal }"
wrap-class-name="nc-modal-share-collaborate" wrap-class-name="nc-modal-share-collaborate"
:closable="false" :mask-closable="formStatus !== 'base-collaborateSaving'"
:mask-closable="formStatus === 'base-collaborateSaving' ? false : true" :width="formStatus === 'manageCollaborators' ? '60rem' : '48rem'"
:ok-button-props="{ hidden: true } as any"
:cancel-button-props="{ hidden: true } as any"
:footer="null"
:width="formStatus === 'manageCollaborators' ? '60rem' : '40rem'"
> >
<div v-if="formStatus === 'base-collaborateSaving'" class="flex flex-row w-full px-5 justify-between items-center py-1"> <div class="flex flex-col gap-6">
<div class="flex text-base font-bold">Adding Members</div> <div class="flex text-xl font-bold">{{ $t('activity.share') }}</div>
<a-spin :indicator="indicator" /> <div class="flex flex-1 gap-3">
</div> <div
<template v-else-if="formStatus === 'base-collaborateSaved'"> v-if="isViewToolbar"
<div class="flex flex-col py-1.5"> class="flex relative flex-col flex-grow-1 cursor-pointer p-1 w-32 rounded-[10px] h-80 bg-gray-200"
<div class="flex flex-row w-full px-5 justify-between items-center py-0.5"> >
<div class="flex text-base font-medium">Members added</div> <div :style="highlightStyle" class="highlight"></div>
<div class="flex">
<MdiCheck />
</div>
</div>
<div class="flex flex-row mx-3 mt-2.5 pt-3.5 border-t-1 border-gray-100 justify-end gap-x-2">
<a-button type="text" class="!border-1 !border-gray-200 !rounded-md" @click="showShareModal = false">Close </a-button>
<a-button
type="text"
class="!border-1 !border-gray-200 !rounded-md"
data-testid="docs-share-invitation-copy"
:data-invite-link="inviteUrl"
@click="copyInvitationLink"
>
<div v-if="isInvitationLinkCopied" class="flex flex-row items-center gap-x-1">
<MdiTick class="h-3.5" />
{{ $t('activity.copiedInviteLink') }}
</div>
<div v-else class="flex flex-row items-center gap-x-1">
<MdiContentCopy class="h-3.3" />
{{ $t('activity.copyInviteLink') }}
</div>
</a-button>
</div>
</div>
</template>
<div v-else-if="formStatus === 'manageCollaborators'">
<ManageUsers v-if="formStatus === 'manageCollaborators'" @close="formStatus = 'collaborate'" />
</div>
<div v-else class="flex flex-col px-1">
<div class="flex flex-row justify-between items-center pb-1 mx-4 mt-3">
<div class="flex text-base font-medium">{{ $t('activity.share') }}</div>
</div>
<div v-if="isViewToolbar && activeView" class="share-view">
<div class="flex flex-row items-center gap-x-2 px-4 pt-3 pb-3 select-none">
<component
:is="viewIcons[view?.type]?.icon"
class="nc-view-icon group-hover"
:style="{ color: viewIcons[view?.type]?.color }"
/>
<div>{{ $t('activity.shareView') }}</div>
<div <div
class="max-w-79/100 ml-2 px-2 py-0.5 rounded-md bg-gray-100 capitalize text-ellipsis overflow-hidden" data-testid="nc-share-base-tab"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap' }" :class="{ 'nc-share-active': activeTab === 'base' }"
class="flex flex-col z-1 text-gray-600 items-center rounded-lg w-full justify-center h-1/2"
@click="activeTab = 'base'"
> >
{{ activeView.title }} <GeneralProjectIcon
:color="parseProp(base.meta).iconColor"
:type="base.type"
:class="{
'!grayscale ': activeTab !== 'base',
}"
:style="{
filter: activeTab !== 'base' ? 'grayscale(100%) brightness(115%)' : '',
}"
class="nc-view-icon transition-all w-6 h-6 group-hover"
/>
<span
:class="{
'font-semibold': activeTab === 'base',
}"
>
Base
</span>
</div> </div>
</div>
<DlgShareAndCollaborateSharePage />
</div>
<div class="share-base">
<div class="flex flex-row items-center gap-x-2 px-4 pt-3 pb-3 select-none">
<GeneralProjectIcon :color="parseProp(base.meta).iconColor" :type="base.type" class="nc-view-icon group-hover" />
<div>{{ $t('activity.shareBase.label') }}</div>
<div <div
class="max-w-79/100 ml-2 px-2 py-0.5 rounded-md bg-gray-100 capitalize text-ellipsis overflow-hidden" :class="{ 'nc-share-active': activeTab === 'view' }"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap' }" data-testid="nc-share-view-tab"
class="flex flex-col items-center text-gray-600 z-1 w-full cursor-pointer rounded-lg justify-center h-1/2"
@click="activeTab = 'view'"
> >
{{ base.title }} <component
:is="viewIcons[view?.type]?.icon"
:class="{
'text-gray-500': activeTab !== 'view',
}"
:style="{ color: activeTab === 'view' ? viewIcons[view?.type]?.color : '' }"
class="nc-view-icon transition-all !text-2xl group-hover"
/>
<span
:class="{
'font-semibold': activeTab === 'view',
}"
>
View
</span>
</div> </div>
</div> </div>
<LazyDlgShareAndCollaborateShareBase /> <div class="flex flex-1 h-full flex-col">
</div> <LazyDlgShareAndCollaborateShareBase v-if="activeTab === 'base'" :is-view="isViewToolbar" />
<div class="flex flex-row justify-end mx-3 mt-1 mb-2 pt-4 gap-x-2"> <LazyDlgShareAndCollaborateSharePage v-else-if="activeTab === 'view'" />
<NcButton type="secondary" data-testid="docs-cancel-btn" @click="showShareModal = false"> </div>
{{ $t('general.close') }}
</NcButton>
<NcButton
data-testid="docs-share-manage-access"
type="secondary"
:loading="isOpeningManageAccess"
@click="openManageAccess"
>{{ $t('activity.manageProjectAccess') }}</NcButton
>
<!-- <a-button
v-if="formStatus === 'base-collaborate'"
data-testid="docs-share-btn"
class="!border-0 !rounded-md"
type="primary"
:disabled="!invitationValid"
@click="onShare"
>
Share
</a-button> -->
</div> </div>
</div> </div>
</a-modal> </NcModal>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.nc-share-active {
@apply bg-transparent !text-gray-900;
}
.highlight {
@apply absolute h-[calc(50%-4px)] w-[calc(8rem-8px)] shadow bg-white rounded-lg transition-all duration-300;
z-index: 0;
}
.share-collapse-item { .share-collapse-item {
@apply !rounded-lg !mb-2 !mt-4 !border-0; @apply !rounded-lg !mb-2 !mt-4 !border-0;
} }
@ -201,10 +145,12 @@ watch(showShareModal, (val) => {
.ant-collapse { .ant-collapse {
@apply !bg-white !border-0; @apply !bg-white !border-0;
} }
</style>
<style lang="scss">
.nc-modal-share-collaborate { .nc-modal-share-collaborate {
.ant-modal-content {
@apply !rounded-2xl;
}
.ant-modal { .ant-modal {
top: 10vh !important; top: 10vh !important;
} }
@ -226,10 +172,6 @@ watch(showShareModal, (val) => {
@apply !p-0; @apply !p-0;
} }
.ant-modal-content {
@apply !rounded-lg !px-1 !py-2;
}
.ant-select-selector { .ant-select-selector {
@apply !rounded-md !border-gray-200 !border-1; @apply !rounded-md !border-gray-200 !border-1;
} }

4
packages/nc-gui/components/general/CopyUrl.vue

@ -37,9 +37,9 @@ const copyUrl = async () => {
class="flex flex-row items-center justify-end text-gray-600 gap-x-1.5 py-1.5 px-1.5 bg-gray-50 rounded-md border-1 border-gray-200" class="flex flex-row items-center justify-end text-gray-600 gap-x-1.5 py-1.5 px-1.5 bg-gray-50 rounded-md border-1 border-gray-200"
> >
<div class="flex flex-row block flex-1 overflow-hidden pl-3 cursor-pointer" @click="copyUrl"> <div class="flex flex-row block flex-1 overflow-hidden pl-3 cursor-pointer" @click="copyUrl">
<div class="overflow-hidden whitespace-nowrap text-gray-500">{{ url }}</div> <div class="overflow-hidden whitespace-nowrap truncate text-gray-500">{{ url }}</div>
</div> </div>
<div class="flex flex-row gap-x-1"> <div class="flex !text-gray-700 flex-row gap-x-1">
<NcTooltip> <NcTooltip>
<template #title> <template #title>
{{ $t('activity.openInANewTab') }} {{ $t('activity.openInANewTab') }}

3
packages/nc-gui/components/general/ShareProject.vue

@ -64,8 +64,7 @@ const copySharedBase = async () => {
@click="showShareModal = true" @click="showShareModal = true"
> >
<div v-if="!isMobileMode" class="flex flex-row items-center w-full gap-x-1"> <div v-if="!isMobileMode" class="flex flex-row items-center w-full gap-x-1">
<MaterialSymbolsPublic v-if="visibility === 'public'" class="h-3.5" /> <component :is="iconMap.mobileShare" class="h-3.5" />
<MaterialSymbolsLockOutline v-else-if="visibility === 'private'" class="h-3.5" />
<div class="flex">{{ $t('activity.share') }}</div> <div class="flex">{{ $t('activity.share') }}</div>
</div> </div>
<GeneralIcon v-else icon="mobileShare" /> <GeneralIcon v-else icon="mobileShare" />

4
packages/nc-gui/components/nc/Modal.vue

@ -8,6 +8,7 @@ const props = withDefaults(
maskClosable?: boolean maskClosable?: boolean
showSeparator?: boolean showSeparator?: boolean
wrapClassName?: string wrapClassName?: string
centered?: boolean
}>(), }>(),
{ {
size: 'medium', size: 'medium',
@ -15,6 +16,7 @@ const props = withDefaults(
maskClosable: true, maskClosable: true,
showSeparator: true, showSeparator: true,
wrapClassName: '', wrapClassName: '',
centered: true,
}, },
) )
@ -86,7 +88,7 @@ const slots = useSlots()
v-model:visible="visible" v-model:visible="visible"
:class="{ active: visible }" :class="{ active: visible }"
:width="width" :width="width"
:centered="true" :centered="centered"
:closable="false" :closable="false"
:wrap-class-name="newWrapClassName" :wrap-class-name="newWrapClassName"
:footer="null" :footer="null"

23
packages/nc-gui/components/nc/Switch.vue

@ -1,19 +1,30 @@
<script lang="ts" setup> <script lang="ts" setup>
const props = withDefaults(defineProps<{ checked: boolean; disabled?: boolean; size?: 'default' | 'small' }>(), { const props = withDefaults(
size: 'small', defineProps<{ checked: boolean; disabled?: boolean; size?: 'default' | 'small'; loading?: boolean }>(),
}) {
size: 'small',
},
)
const emit = defineEmits(['change', 'update:checked']) const emit = defineEmits(['change', 'update:checked'])
const checked = useVModel(props, 'checked', emit) const checked = useVModel(props, 'checked', emit)
const loading = computed(() => props.loading)
const onChange = (e: boolean) => { const onChange = (e: boolean) => {
emit('change', e) emit('change', e)
} }
</script> </script>
<template> <template>
<a-switch v-model:checked="checked" :disabled="disabled" class="nc-switch" v-bind="$attrs" :size="size" @change="onChange"> <a-switch
v-model:checked="checked"
:disabled="disabled"
:loading="loading"
:size="size"
class="nc-switch"
v-bind="$attrs"
@change="onChange"
>
</a-switch> </a-switch>
<span v-if="$slots.default" class="cursor-pointer pl-2" @click="checked = !checked"> <span v-if="$slots.default" class="cursor-pointer pl-2" @click="checked = !checked">
<slot /> <slot />

2
packages/nc-gui/components/nc/Tabs.vue

@ -43,7 +43,7 @@ const props = defineProps<{
} }
.ant-tabs-nav { .ant-tabs-nav {
@apply pl-2.5 mb-0; @apply mb-0;
} }
.ant-tabs-ink-bar { .ant-tabs-ink-bar {

9
packages/nc-gui/components/project/ShareBaseDlg.vue

@ -2,6 +2,7 @@
import type { RoleLabels } from 'nocodb-sdk' import type { RoleLabels } from 'nocodb-sdk'
import { OrderedProjectRoles, ProjectRoles } from 'nocodb-sdk' import { OrderedProjectRoles, ProjectRoles } from 'nocodb-sdk'
import type { User } from '#imports' import type { User } from '#imports'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
baseId?: string baseId?: string
@ -269,7 +270,7 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
</div> </div>
<RolesSelector <RolesSelector
size="lg" size="lg"
class="nc-invite-role-selector" class="!min-w-[152px] nc-invite-role-selector"
:role="inviteData.roles" :role="inviteData.roles"
:roles="allowedRoles" :roles="allowedRoles"
:on-role-change="onRoleChange" :on-role-change="onRoleChange"
@ -297,3 +298,9 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
</div> </div>
</NcModal> </NcModal>
</template> </template>
<style lang="scss" scoped>
:deep(.nc-invite-role-selector .nc-role-badge) {
@apply w-full;
}
</style>

2
packages/nc-gui/components/roles/Badge.vue

@ -13,7 +13,7 @@ const props = withDefaults(
{ {
clickable: false, clickable: false,
inherit: false, inherit: false,
border: true, border: false,
size: 'sm', size: 'sm',
}, },
) )

4
packages/nc-gui/components/workspace/InviteSection.vue

@ -268,10 +268,6 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
@apply rounded; @apply rounded;
} }
.badge-text {
@apply text-[14px] pt-1 text-center;
}
:deep(.ant-select-selection-item) { :deep(.ant-select-selection-item) {
@apply mt-0.75; @apply mt-0.75;
} }

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

@ -433,6 +433,7 @@
"surveyFormSubmitConfirmMsg": "Are you sure you want to submit this form?" "surveyFormSubmitConfirmMsg": "Are you sure you want to submit this form?"
}, },
"labels": { "labels": {
"enterMultipleEmails": "Enter multiple E-mails by using commas to separate them",
"selectYear": "Select Year", "selectYear": "Select Year",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@ -703,6 +704,9 @@
"clearSelection": "Clear selection" "clearSelection": "Clear selection"
}, },
"activity": { "activity": {
"inviteAs": "Invite as",
"toBase": "to base",
"manageAccess": "Manage Access",
"addMembers": "Add Members", "addMembers": "Add Members",
"enterEmail": "Enter email addresses", "enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base", "inviteToBase": "Invite to Base",
@ -737,7 +741,7 @@
"outOfSync": "Out of sync", "outOfSync": "Out of sync",
"newSource": "New Data Source", "newSource": "New Data Source",
"newWebhook": "New Webhook", "newWebhook": "New Webhook",
"enablePublicAccess": "Enable Public Access", "enablePublicAccess": "Enable Public Viewing",
"doYouWantToSaveTheChanges": "Do you want to save the changes ?", "doYouWantToSaveTheChanges": "Do you want to save the changes ?",
"editingAccess": "Editing access", "editingAccess": "Editing access",
"enabledPublicViewing": "Enable Public Viewing", "enabledPublicViewing": "Enable Public Viewing",
@ -801,6 +805,8 @@
"invite": "Invite", "invite": "Invite",
"inviteMore": "Invite more", "inviteMore": "Invite more",
"inviteTeam": "Invite Team", "inviteTeam": "Invite Team",
"inviteMembers": "Invite Member(s)",
"inviteUsers": "Invite User(s)",
"inviteUser": "Invite User", "inviteUser": "Invite User",
"inviteToken": "Invite Token", "inviteToken": "Invite Token",
"linkedRecords": "Linked Records", "linkedRecords": "Linked Records",

38
tests/playwright/pages/Dashboard/common/Topbar/Share.ts

@ -11,22 +11,21 @@ export class TopbarSharePage extends BasePage {
} }
get() { get() {
return this.rootPage.locator(`.nc-modal-share-collaborate`).locator('.ant-modal-content'); return this.rootPage.locator(`.nc-modal-share-collaborate`);
}
async clickShareView() {
await this.get().waitFor();
// collapse header 0: Share Base, 1: Share View
await this.get().locator(`.ant-collapse-header`).nth(1).click();
} }
async clickShareProject() { async clickShareProject() {
await this.get().locator(`[data-testid="docs-share-dlg-share-base"]`).click(); await this.get().locator(`[data-testid="docs-share-dlg-share-base"]`).click();
} }
async switchBaseShareTab({ tab }: { tab: 'public' | 'member' }) {
await this.get().getByTestId(`nc-base-share-${tab}`).waitFor({ state: 'visible' });
await this.get().getByTestId(`nc-base-share-${tab}`).click();
}
async clickShareViewPublicAccess() { async clickShareViewPublicAccess() {
await expect(this.get().locator(`[data-testid="share-view-toggle"]`)).toHaveCount(1); await expect(this.get().getByTestId('share-view-toggle')).toHaveCount(1);
await this.get().locator(`[data-testid="share-view-toggle"]`).click(); await this.get().getByTestId('share-view-toggle').click();
} }
async clickCopyLink() { async clickCopyLink() {
@ -35,7 +34,7 @@ export class TopbarSharePage extends BasePage {
async closeModal() { async closeModal() {
// await this.rootPage.keyboard.press('Escape'); // await this.rootPage.keyboard.press('Escape');
await this.get().locator('.ant-btn.ant-btn-secondary:has-text("Close")').click(); await this.get().locator('.ant-btn.ant-btn-secondary:has-text("Finish")').click();
} }
async clickShareViewWithPassword({ password }: { password: string }) { async clickShareViewWithPassword({ password }: { password: string }) {
@ -48,7 +47,22 @@ export class TopbarSharePage extends BasePage {
} }
async clickShareBase() { async clickShareBase() {
await this.get().locator(`[data-testid="db-share-base"]`).click(); await this.get().waitFor();
const shareBase = this.get().getByTestId('nc-share-base-tab');
if (await shareBase.isVisible()) {
await shareBase.click();
}
}
async clickShareView() {
await this.get().waitFor();
const shareView = this.get().getByTestId('nc-share-view-tab');
if (await shareView.isVisible()) {
await shareView.click();
}
} }
async clickShareBasePublicAccess() { async clickShareBasePublicAccess() {
@ -72,6 +86,8 @@ export class TopbarSharePage extends BasePage {
async clickShareBaseEditorAccess() { async clickShareBaseEditorAccess() {
await this.rootPage.waitForTimeout(1000); await this.rootPage.waitForTimeout(1000);
await this.switchBaseShareTab({ tab: 'public' });
const shareBaseSwitch = this.get().locator(`[data-testid="nc-share-base-sub-modal"]`).locator('.ant-switch'); const shareBaseSwitch = this.get().locator(`[data-testid="nc-share-base-sub-modal"]`).locator('.ant-switch');
const count = await shareBaseSwitch.count(); const count = await shareBaseSwitch.count();

5
tests/playwright/pages/Dashboard/common/Topbar/index.ts

@ -36,7 +36,7 @@ export class TopbarPage extends BasePage {
async getSharedViewUrl(surveyMode = false, password = '', download = false) { async getSharedViewUrl(surveyMode = false, password = '', download = false) {
await this.clickShare(); await this.clickShare();
// await this.share.clickShareView(); await this.share.clickShareView();
await this.share.clickShareViewPublicAccess(); await this.share.clickShareViewPublicAccess();
await this.share.clickCopyLink(); await this.share.clickCopyLink();
if (surveyMode) { if (surveyMode) {
@ -57,6 +57,9 @@ export class TopbarPage extends BasePage {
async getSharedBaseUrl({ role, enableSharedBase }: { role: string; enableSharedBase: boolean }) { async getSharedBaseUrl({ role, enableSharedBase }: { role: string; enableSharedBase: boolean }) {
await this.clickShare(); await this.clickShare();
await this.share.clickShareBase();
await this.share.switchBaseShareTab({ tab: 'public' });
if (enableSharedBase) await this.share.clickShareBasePublicAccess(); if (enableSharedBase) await this.share.clickShareBasePublicAccess();
if (role === 'editor' && enableSharedBase) { if (role === 'editor' && enableSharedBase) {

5
tests/playwright/tests/db/features/baseShare.spec.ts

@ -3,7 +3,7 @@ import { DashboardPage } from '../../../pages/Dashboard';
import setup, { NcContext, unsetup } from '../../../setup'; import setup, { NcContext, unsetup } from '../../../setup';
import { ToolbarPage } from '../../../pages/Dashboard/common/Toolbar'; import { ToolbarPage } from '../../../pages/Dashboard/common/Toolbar';
import { LoginPage } from '../../../pages/LoginPage'; import { LoginPage } from '../../../pages/LoginPage';
import { getDefaultPwd } from '../../../tests/utils/general'; import { getDefaultPwd } from '../../utils/general';
import { isEE } from '../../../setup/db'; import { isEE } from '../../../setup/db';
// To be enabled after shared base is implemented // To be enabled after shared base is implemented
@ -62,9 +62,6 @@ test.describe('Shared base', () => {
}); });
test('#1', async () => { test('#1', async () => {
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
let url = ''; let url = '';
// share button visible only if a table is opened // share button visible only if a table is opened
await dashboard.treeView.openTable({ title: 'Country' }); await dashboard.treeView.openTable({ title: 'Country' });

Loading…
Cancel
Save