Browse Source

Merge pull request #7890 from nocodb/revert-7845-nc-feat/share-ui

Revert "feat(nc-gui): new share ui"
pull/7894/head
Pranav C 8 months ago committed by GitHub
parent
commit
f9c3939c15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 467
      packages/nc-gui/components/dlg/share-and-collaborate/ShareBase.vue
  2. 312
      packages/nc-gui/components/dlg/share-and-collaborate/SharePage.vue
  3. 242
      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. 21
      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

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

@ -1,8 +1,5 @@
<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,
@ -12,7 +9,6 @@ import {
useGlobal, useGlobal,
useNuxtApp, useNuxtApp,
useWorkspace, useWorkspace,
validateEmail,
} from '#imports' } from '#imports'
interface ShareBase { interface ShareBase {
@ -21,13 +17,6 @@ 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',
@ -37,27 +26,13 @@ const { dashboardUrl } = useDashboard()
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()
const { getBaseUrl, appInfo } = useGlobal() const sharedBase = ref<null | ShareBase>(null)
const workspaceStore = useWorkspace()
const baseStore = useBase()
const basesStore = useBases()
const { activeProjectId } = storeToRefs(basesStore)
const { base } = storeToRefs(useBase()) const { base } = storeToRefs(useBase())
const { navigateToProjectPage } = baseStore const { getBaseUrl, appInfo } = useGlobal()
const { createProjectUser } = basesStore
const { baseRoles } = useRoles()
const sharedBase = ref<null | ShareBase>(null)
const { showShareModal } = storeToRefs(useShare()) const workspaceStore = useWorkspace()
const url = computed(() => { const url = computed(() => {
if (!sharedBase.value || !sharedBase.value.uuid) return '' if (!sharedBase.value || !sharedBase.value.uuid) return ''
@ -73,6 +48,22 @@ 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
@ -107,6 +98,12 @@ 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)
@ -128,113 +125,10 @@ 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) {
@ -248,317 +142,34 @@ 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 !h-80 gap-2" data-testid="nc-share-base-sub-modal"> <div class="flex flex-col py-2 px-3 gap-2 w-full" data-testid="nc-share-base-sub-modal">
<NcTabs v-model:activeKey="openedBaseShareTab" class="nc-base-share-tab h-full" size="medium"> <div class="flex flex-col w-full p-3 border-1 border-gray-100 rounded-md">
<a-tab-pane key="members"> <div class="flex flex-row w-full justify-between">
<template #tab> <div class="text-gray-900 font-medium">{{ $t('activity.enablePublicAccess') }}</div>
<div class="tab" data-testid="nc-base-share-member"> <a-switch
<GeneralIcon :class="{}" class="tab-icon" icon="user" />
<div>Add Members</div>
</div>
</template>
<div class="my-4 space-y-3">
<div class="flex justify-between items-center">
<div class="gap-2 flex items-center text-gray-500">
<span> {{ $t('activity.inviteAs') }} </span>
<RolesSelector
:description="false"
:on-role-change="(role: ProjectRoles) => (inviteData.roles = role)"
:role="inviteData.roles"
:roles="allowedRoles"
class="px-1 !min-w-[152px] nc-invite-role-selector"
size="md"
/>
<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>
</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']" v-e="['c:share:base:enable:toggle']"
:checked="isSharedBaseEnabled" :checked="isSharedBaseEnabled"
:loading="isToggleBaseLoading"
class="ml-2" class="ml-2"
@change="toggleSharedBase" @click="toggleSharedBase"
/> />
</div> </div>
<div v-if="isSharedBaseEnabled" class="space-y-3"> <div v-if="isSharedBaseEnabled" class="flex flex-col w-full mt-3 border-t-1 pt-3 border-gray-100">
<GeneralCopyUrl <GeneralCopyUrl v-model:url="url" />
v-model:url="url" <div v-if="!appInfo.ee" class="flex flex-row justify-between mt-3 bg-gray-50 px-3 py-2 rounded-md">
:class="{ <div class="text-black">{{ $t('activity.editingAccess') }}</div>
'w-[34.625rem]': props.isView,
}"
/>
<div v-if="!appInfo.ee" class="flex flex-row gap-3 items-center">
<a-switch <a-switch
v-e="['c:share:base:role:toggle']" v-e="['c:share:base:role:toggle']"
:checked="sharedBase?.role === ShareBaseRole.Editor"
:loading="isRoleToggleLoading" :loading="isRoleToggleLoading"
class="share-editor-toggle !mt-0.25" :checked="sharedBase?.role === ShareBaseRole.Editor"
data-testid="share-password-toggle" class="ml-2"
size="small"
@click="onRoleToggle" @click="onRoleToggle"
/> />
<div class="flex text-black">{{ $t('activity.editingAccess') }}</div>
</div> </div>
</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>
</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>

312
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, iconMap, message, storeToRefs, useBase, useMetas } from '#imports' import { PreFilledMode, useMetas } from '#imports'
const { view: _view, $api } = useSmartsheetStoreOrThrow() const { view: _view, $api } = useSmartsheetStoreOrThrow()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
@ -11,14 +11,6 @@ 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()
@ -52,34 +44,8 @@ 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 !!selectedView.value?.uuid return !!activeView.value?.uuid
}) })
const url = computed(() => { const url = computed(() => {
@ -89,27 +55,27 @@ const url = computed(() => {
const passwordProtectedLocal = ref(false) const passwordProtectedLocal = ref(false)
const passwordProtected = computed(() => { const passwordProtected = computed(() => {
return !!selectedView.value?.password || passwordProtectedLocal.value return !!activeView.value?.password || passwordProtectedLocal.value
}) })
const password = computed({ const password = computed({
get: () => (passwordProtected.value ? selectedView.value?.password ?? '' : ''), get: () => (passwordProtected.value ? activeView.value?.password ?? '' : ''),
set: async (value) => { set: async (value) => {
if (!selectedView.value) return if (!activeView.value) return
selectedView.value = { ...(selectedView.value as any), password: passwordProtected.value ? value : null } activeView.value = { ...(activeView.value as any), password: passwordProtected.value ? value : null }
updateSharedView() updateSharedView()
}, },
}) })
const viewTheme = computed({ const viewTheme = computed({
get: () => !!selectedView.value?.meta.withTheme, get: () => !!activeView.value?.meta.withTheme,
set: (withTheme) => { set: (withTheme) => {
if (!selectedView.value?.meta) return if (!activeView.value?.meta) return
selectedView.value.meta = { activeView.value.meta = {
...selectedView.value.meta, ...activeView.value.meta,
withTheme, withTheme,
} }
saveTheme() saveTheme()
@ -118,15 +84,15 @@ const viewTheme = computed({
const togglePasswordProtected = async () => { const togglePasswordProtected = async () => {
passwordProtectedLocal.value = !passwordProtected.value passwordProtectedLocal.value = !passwordProtected.value
if (!selectedView.value) return if (!activeView.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) {
selectedView.value = { ...(selectedView.value as any), password: null } activeView.value = { ...(activeView.value as any), password: null }
} else { } else {
selectedView.value = { ...(selectedView.value as any), password: '' } activeView.value = { ...(activeView.value as any), password: '' }
} }
await updateSharedView() await updateSharedView()
@ -137,35 +103,35 @@ const togglePasswordProtected = async () => {
const withRTL = computed({ const withRTL = computed({
get: () => { get: () => {
if (!selectedView.value?.meta) return false if (!activeView.value?.meta) return false
if (typeof selectedView.value?.meta === 'string') { if (typeof activeView.value?.meta === 'string') {
selectedView.value.meta = JSON.parse(selectedView.value.meta) activeView.value.meta = JSON.parse(activeView.value.meta)
} }
return !!(selectedView.value?.meta as any)?.rtl return !!(activeView.value?.meta as any)?.rtl
}, },
set: (rtl) => { set: (rtl) => {
if (!selectedView.value?.meta) return if (!activeView.value?.meta) return
if (typeof selectedView.value?.meta === 'string') { if (typeof activeView.value?.meta === 'string') {
selectedView.value.meta = JSON.parse(selectedView.value.meta) activeView.value.meta = JSON.parse(activeView.value.meta)
} }
selectedView.value.meta = { ...(selectedView.value.meta as any), rtl } activeView.value.meta = { ...(activeView.value.meta as any), rtl }
updateSharedView() updateSharedView()
}, },
}) })
const allowCSVDownload = computed({ const allowCSVDownload = computed({
get: () => !!(selectedView.value?.meta as any)?.allowCSVDownload, get: () => !!(activeView.value?.meta as any)?.allowCSVDownload,
set: async (allow) => { set: async (allow) => {
if (!selectedView.value?.meta) return if (!activeView.value?.meta) return
isUpdating.value.download = true isUpdating.value.download = true
try { try {
selectedView.value.meta = { ...selectedView.value.meta, allowCSVDownload: allow } activeView.value.meta = { ...activeView.value.meta, allowCSVDownload: allow }
await saveAllowCSVDownload() await saveAllowCSVDownload()
} finally { } finally {
isUpdating.value.download = false isUpdating.value.download = false
@ -174,22 +140,22 @@ const allowCSVDownload = computed({
}) })
const surveyMode = computed({ const surveyMode = computed({
get: () => !!selectedView.value?.meta.surveyMode, get: () => !!activeView.value?.meta.surveyMode,
set: (survey) => { set: (survey) => {
if (!selectedView.value?.meta) return if (!activeView.value?.meta) return
selectedView.value.meta = { ...selectedView.value.meta, surveyMode: survey } activeView.value.meta = { ...activeView.value.meta, surveyMode: survey }
saveSurveyMode() saveSurveyMode()
}, },
}) })
const formPreFill = computed({ const formPreFill = computed({
get: () => ({ get: () => ({
preFillEnabled: parseProp(selectedView.value?.meta)?.preFillEnabled ?? false, preFillEnabled: parseProp(activeView.value?.meta)?.preFillEnabled ?? false,
preFilledMode: parseProp(selectedView.value?.meta)?.preFilledMode || PreFilledMode.Default, preFilledMode: parseProp(activeView.value?.meta)?.preFilledMode || PreFilledMode.Default,
}), }),
set: (value) => { set: (value) => {
if (!selectedView.value?.meta) return if (!activeView.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'}`)
@ -199,8 +165,8 @@ const formPreFill = computed({
$e(`a:view:share:${value.preFilledMode}-prefilled-mode`) $e(`a:view:share:${value.preFilledMode}-prefilled-mode`)
} }
selectedView.value.meta = { activeView.value.meta = {
...selectedView.value.meta, ...activeView.value.meta,
...value, ...value,
} }
savePreFilledMode() savePreFilledMode()
@ -215,10 +181,10 @@ const handleChangeFormPreFill = (value: { preFillEnabled?: boolean; preFilledMod
} }
function sharedViewUrl() { function sharedViewUrl() {
if (!selectedView.value) return if (!activeView.value) return
let viewType let viewType
switch (selectedView.value.type) { switch (activeView.value.type) {
case ViewTypes.FORM: case ViewTypes.FORM:
viewType = 'form' viewType = 'form'
break break
@ -247,30 +213,30 @@ function sharedViewUrl() {
} }
return encodeURI( return encodeURI(
`${dashboardUrl1}#/nc/${viewType}/${selectedView.value.uuid}${surveyMode.value ? '/survey' : ''}${ `${dashboardUrl1}#/nc/${viewType}/${activeView.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 (!selectedView.value?.id) return if (!activeView.value?.id) return
if (selectedView.value?.uuid) { if (activeView.value?.uuid) {
await $api.dbViewShare.delete(selectedView.value.id) await $api.dbViewShare.delete(activeView.value.id)
selectedView.value = { ...selectedView.value, uuid: undefined, password: undefined } activeView.value = { ...activeView.value, uuid: undefined, password: undefined }
} else { } else {
const response = await $api.dbViewShare.create(selectedView.value.id) const response = await $api.dbViewShare.create(activeView.value.id)
selectedView.value = { ...selectedView.value, ...(response as any) } activeView.value = { ...activeView.value, ...(response as any) }
if (selectedView.value!.type === ViewTypes.KANBAN) { if (activeView.value!.type === ViewTypes.KANBAN) {
// extract grouping column meta // extract grouping column meta
const groupingFieldColumn = metas.value[selectedView.value!.fk_model_id].columns!.find( const groupingFieldColumn = metas.value[viewStore.activeView!.fk_model_id].columns!.find(
(col: ColumnType) => col.id === ((selectedView.value.view! as KanbanType).fk_grp_col_id! as string), (col: ColumnType) => col.id === ((viewStore.activeView!.view! as KanbanType).fk_grp_col_id! as string),
) )
selectedView.value!.meta = { ...selectedView.value!.meta, groupingFieldColumn } activeView.value!.meta = { ...activeView.value!.meta, groupingFieldColumn }
await updateSharedView() await updateSharedView()
} }
@ -313,12 +279,12 @@ async function saveTheme() {
async function updateSharedView() { async function updateSharedView() {
try { try {
if (!selectedView.value?.meta) return if (!activeView.value?.meta) return
const meta = selectedView.value.meta const meta = activeView.value.meta
await $api.dbViewShare.update(selectedView.value.id!, { await $api.dbViewShare.update(activeView.value.id!, {
meta, meta,
password: selectedView.value.password, password: activeView.value.password,
}) })
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
@ -331,55 +297,31 @@ async function savePreFilledMode() {
await updateSharedView() await updateSharedView()
} }
const openManageAccess = async () => { watchEffect(() => {})
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 !h-80 justify-between"> <div class="flex flex-col py-2 px-3 mb-1">
<div class="flex flex-col p-3 border-gray-200 border-1 rounded-lg gap-y-2"> <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-row items-center justify-between"> <div class="flex flex-row w-full justify-between py-0.5">
<div class="flex items-center gap-3"> <div class="text-gray-900 font-medium">{{ $t('activity.enabledPublicViewing') }}</div>
<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"
@change="toggleShare" @click="toggleShare"
/> />
</div> </div>
<template v-if="isPublicShared">
<div v-if="isPublicShared" class="space-y-3"> <div class="mt-0.5 border-t-1 border-gray-100 pt-3">
<GeneralCopyUrl v-model:url="url" class="w-[34.625rem]" /> <GeneralCopyUrl v-model:url="url" />
<div class="flex items-center gap-3 h-8 justify-between"> </div>
<div class="flex flex-row gap-3 items-center"> <div class="flex flex-col justify-between mt-1 py-2 px-3 bg-gray-50 rounded-md">
<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"
@ -387,28 +329,31 @@ const openManageAccess = async () => {
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"
@change="togglePasswordProtected" @click="togglePasswordProtected"
/> />
<div class="flex text-black">{{ $t('activity.restrictAccessWithPassword') }}</div>
</div> </div>
<Transition mode="out-in" name="layout">
<div v-if="passwordProtected" class="flex gap-2 mt-2 w-2/3">
<a-input-password <a-input-password
v-if="passwordProtected"
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> </div>
</Transition>
</div>
<div class="flex flex-col justify-between gap-y-3 mt-1 py-2 px-3 bg-gray-50 rounded-md">
<div <div
v-if=" v-if="
selectedView && activeView &&
[ViewTypes.GRID, ViewTypes.KANBAN, ViewTypes.GALLERY, ViewTypes.MAP, ViewTypes.CALENDAR].includes(selectedView.type) [ViewTypes.GRID, ViewTypes.KANBAN, ViewTypes.GALLERY, ViewTypes.MAP, ViewTypes.CALENDAR].includes(activeView.type)
" "
class="flex flex-row items-center gap-3" class="flex flex-row items-center justify-between"
> >
<div class="flex text-black">{{ $t('activity.allowDownload') }}</div>
<a-switch <a-switch
v-model:checked="allowCSVDownload" v-model:checked="allowCSVDownload"
v-e="['c:share:view:allow-csv-download:toggle']" v-e="['c:share:view:allow-csv-download:toggle']"
@ -417,12 +362,19 @@ const openManageAccess = async () => {
data-testid="share-download-toggle" data-testid="share-download-toggle"
size="small" size="small"
/> />
<div class="flex text-black">{{ $t('activity.allowDownload') }}</div>
</div> </div>
<template v-if="selectedView?.type === ViewTypes.FORM"> <template v-if="activeView?.type === ViewTypes.FORM">
<div class="flex items-center justify-between"> <div class="flex flex-row items-center justify-between">
<div class="flex items-center gap-3"> <div class="text-black flex items-center space-x-1">
<div>
{{ $t('activity.surveyMode') }}
</div>
<NcTooltip>
<template #title> {{ $t('tooltip.surveyFormInfo') }}</template>
<GeneralIcon icon="info" class="text-gray-600 cursor-pointer"></GeneralIcon>
</NcTooltip>
</div>
<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']"
@ -430,15 +382,9 @@ const openManageAccess = async () => {
size="small" size="small"
> >
</a-switch> </a-switch>
{{ $t('activity.surveyMode') }}
</div>
<NcTooltip>
<template #title>{{ $t('tooltip.surveyFormInfo') }} </template>
<component :is="iconMap.info" class="text-gray-500" />
</NcTooltip>
</div> </div>
<div v-if="!isEeUI" class="flex flex-row items-center justify-between">
<div v-if="!isEeUI" class="flex flex-row items-center gap-3"> <div class="text-black">{{ $t('activity.rtlOrientation') }}</div>
<a-switch <a-switch
v-model:checked="withRTL" v-model:checked="withRTL"
v-e="['c:share:view:rtl-orientation:toggle']" v-e="['c:share:view:rtl-orientation:toggle']"
@ -446,10 +392,28 @@ const openManageAccess = async () => {
size="small" size="small"
> >
</a-switch> </a-switch>
<div class="text-black">{{ $t('activity.rtlOrientation') }}</div>
</div> </div>
<div class="flex items-center h-8 justify-between"> </template>
<div class="flex items-center gap-3"> </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>
<template #title>
<div class="text-center">
{{ $t('tooltip.preFillFormInfo') }}
</div>
</template>
<GeneralIcon icon="info" class="text-gray-600 cursor-pointer"></GeneralIcon>
</NcTooltip>
</div>
<a-switch <a-switch
v-e="['c:share:view:surver-mode:toggle']" v-e="['c:share:view:surver-mode:toggle']"
:checked="formPreFill.preFillEnabled" :checked="formPreFill.preFillEnabled"
@ -458,51 +422,23 @@ const openManageAccess = async () => {
@update:checked="handleChangeFormPreFill({ preFillEnabled: $event as boolean })" @update:checked="handleChangeFormPreFill({ preFillEnabled: $event as boolean })"
> >
</a-switch> </a-switch>
{{ $t('activity.preFilledFields.title') }} </div>
<NcSelect <a-radio-group
v-if="formPreFill.preFillEnabled" v-if="formPreFill.preFillEnabled"
v-model:value="formPreFill.preFilledMode" :value="formPreFill.preFilledMode"
class="w-48" class="nc-modal-share-view-preFillMode"
@change="handleChangeFormPreFill" data-testid="nc-modal-share-view__preFillMode"
> @update:value="handleChangeFormPreFill({ preFilledMode: $event })"
<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"> <a-radio v-for="mode of Object.values(PreFilledMode)" :key="mode" :value="mode">
<div class="truncate flex-1 capitalize">{{ op.label }}</div> <div class="flex-1">{{ $t(`activity.preFilledFields.${mode}`) }}</div>
<component </a-radio>
:is="iconMap.check" </a-radio-group>
v-if="formPreFill.preFilledMode === op.value"
id="nc-selected-item-icon"
class="text-primary w-4 h-4"
/>
</div>
</a-select-option>
</NcSelect>
</div>
<NcTooltip>
<template #title>{{ $t('tooltip.preFillFormInfo') }} </template>
<component :is="iconMap.info" class="text-gray-500" />
</NcTooltip>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
<div class="flex gap-2 items-end justify-end">
<NcButton type="secondary" @click="openManageAccess">
{{ $t('activity.manageAccess') }}
</NcButton>
<NcButton type="secondary" @click="showShareModal = false">
{{ $t('general.finish') }}
</NcButton>
</div>
</div>
</template> </template>
<style lang="scss"> <style lang="scss">
@ -523,4 +459,18 @@ const openManageAccess = async () => {
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>

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

@ -1,12 +1,19 @@
<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) {
@ -18,126 +25,175 @@ if (isViewToolbar) {
} }
} }
const activeTab = ref<'base' | 'view'>('base') const { formStatus, showShareModal, invitationUsersData, isInvitationLinkCopied } = storeToRefs(useShare())
const { formStatus, showShareModal } = storeToRefs(useShare())
const { resetData } = useShare() const { resetData } = useShare()
// const { inviteUser } = useManageUsers()
const highlightStyle = ref({ top: isViewToolbar ? '160px' : '4px' }) // const expandedSharedType = ref<'none' | 'base' | 'view'>('view')
const isOpeningManageAccess = ref(false)
watch(showShareModal, (val) => { const inviteUrl = computed(() =>
if (!val) { invitationUsersData.value.invitationToken ? `${dashboardUrl.value}#/signup/${invitationUsersData.value.invitationToken}` : null,
setTimeout(() => { )
resetData()
}, 500) const indicator = h(LoadingOutlined, {
} style: {
fontSize: '24px',
},
spin: true,
}) })
watch(showShareModal, () => { /*
if (isViewToolbar && view.value) { const onShare = async () => {
activeTab.value = 'view' if (!invitationValid) return
}
await inviteUser({
email: invitationUsersData.value.emails!,
roles: invitationUsersData.value.role!,
}) })
}
*/
const copyInvitationLink = async () => {
await copy(inviteUrl.value!)
const updateHighlightPosition = () => { isInvitationLinkCopied.value = true
nextTick(() => { }
const activeTab = document.querySelector('.nc-share-active') as HTMLElement
if (activeTab) { const openManageAccess = async () => {
highlightStyle.value.top = `${activeTab.offsetTop}px` 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(activeTab, () => { watch(showShareModal, (val) => {
updateHighlightPosition() if (!val) {
setTimeout(() => {
resetData()
}, 500)
}
}) })
</script> </script>
<template> <template>
<NcModal <a-modal
v-model:visible="showShareModal" v-model:visible="showShareModal"
:centered="false" class="!top-[1%]"
:class="{ active: showShareModal }" :class="{ active: showShareModal }"
wrap-class-name="nc-modal-share-collaborate" wrap-class-name="nc-modal-share-collaborate"
:mask-closable="formStatus !== 'base-collaborateSaving'" :closable="false"
:width="formStatus === 'manageCollaborators' ? '60rem' : '48rem'" :mask-closable="formStatus === 'base-collaborateSaving' ? false : true"
> :ok-button-props="{ hidden: true } as any"
<div class="flex flex-col gap-6"> :cancel-button-props="{ hidden: true } as any"
<div class="flex text-xl font-bold">{{ $t('activity.share') }}</div> :footer="null"
<div class="flex flex-1 gap-3"> :width="formStatus === 'manageCollaborators' ? '60rem' : '40rem'"
<div
v-if="isViewToolbar"
class="flex relative flex-col flex-grow-1 cursor-pointer p-1 w-32 rounded-[10px] h-80 bg-gray-200"
>
<div :style="highlightStyle" class="highlight"></div>
<div
data-testid="nc-share-base-tab"
: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'"
>
<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 <div v-if="formStatus === 'base-collaborateSaving'" class="flex flex-row w-full px-5 justify-between items-center py-1">
</span> <div class="flex text-base font-bold">Adding Members</div>
<a-spin :indicator="indicator" />
</div> </div>
<div <template v-else-if="formStatus === 'base-collaborateSaved'">
:class="{ 'nc-share-active': activeTab === 'view' }" <div class="flex flex-col py-1.5">
data-testid="nc-share-view-tab" <div class="flex flex-row w-full px-5 justify-between items-center py-0.5">
class="flex flex-col items-center text-gray-600 z-1 w-full cursor-pointer rounded-lg justify-center h-1/2" <div class="flex text-base font-medium">Members added</div>
@click="activeTab = 'view'" <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 <component
:is="viewIcons[view?.type]?.icon" :is="viewIcons[view?.type]?.icon"
:class="{ class="nc-view-icon group-hover"
'text-gray-500': activeTab !== 'view', :style="{ color: viewIcons[view?.type]?.color }"
}"
:style="{ color: activeTab === 'view' ? viewIcons[view?.type]?.color : '' }"
class="nc-view-icon transition-all !text-2xl group-hover"
/> />
<span <div>{{ $t('activity.shareView') }}</div>
:class="{ <div
'font-semibold': activeTab === 'view', class="max-w-79/100 ml-2 px-2 py-0.5 rounded-md bg-gray-100 capitalize text-ellipsis overflow-hidden"
}" :style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap' }"
> >
View {{ activeView.title }}
</span> </div>
</div> </div>
<DlgShareAndCollaborateSharePage />
</div> </div>
<div class="flex flex-1 h-full flex-col"> <div class="share-base">
<LazyDlgShareAndCollaborateShareBase v-if="activeTab === 'base'" :is-view="isViewToolbar" /> <div class="flex flex-row items-center gap-x-2 px-4 pt-3 pb-3 select-none">
<LazyDlgShareAndCollaborateSharePage v-else-if="activeTab === 'view'" /> <GeneralProjectIcon :color="parseProp(base.meta).iconColor" :type="base.type" class="nc-view-icon group-hover" />
<div>{{ $t('activity.shareBase.label') }}</div>
<div
class="max-w-79/100 ml-2 px-2 py-0.5 rounded-md bg-gray-100 capitalize text-ellipsis overflow-hidden"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap' }"
>
{{ base.title }}
</div> </div>
</div> </div>
<LazyDlgShareAndCollaborateShareBase />
</div> </div>
</NcModal> <div class="flex flex-row justify-end mx-3 mt-1 mb-2 pt-4 gap-x-2">
<NcButton type="secondary" data-testid="docs-cancel-btn" @click="showShareModal = false">
{{ $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>
</a-modal>
</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;
} }
@ -145,12 +201,10 @@ watch(activeTab, () => {
.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;
} }
@ -172,6 +226,10 @@ watch(activeTab, () => {
@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 truncate text-gray-500">{{ url }}</div> <div class="overflow-hidden whitespace-nowrap text-gray-500">{{ url }}</div>
</div> </div>
<div class="flex !text-gray-700 flex-row gap-x-1"> <div class="flex 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,7 +64,8 @@ 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">
<component :is="iconMap.mobileShare" class="h-3.5" /> <MaterialSymbolsPublic v-if="visibility === 'public'" 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,7 +8,6 @@ const props = withDefaults(
maskClosable?: boolean maskClosable?: boolean
showSeparator?: boolean showSeparator?: boolean
wrapClassName?: string wrapClassName?: string
centered?: boolean
}>(), }>(),
{ {
size: 'medium', size: 'medium',
@ -16,7 +15,6 @@ const props = withDefaults(
maskClosable: true, maskClosable: true,
showSeparator: true, showSeparator: true,
wrapClassName: '', wrapClassName: '',
centered: true,
}, },
) )
@ -88,7 +86,7 @@ const slots = useSlots()
v-model:visible="visible" v-model:visible="visible"
:class="{ active: visible }" :class="{ active: visible }"
:width="width" :width="width"
:centered="centered" :centered="true"
:closable="false" :closable="false"
:wrap-class-name="newWrapClassName" :wrap-class-name="newWrapClassName"
:footer="null" :footer="null"

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

@ -1,14 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
const props = withDefaults( const props = withDefaults(defineProps<{ checked: boolean; disabled?: boolean; size?: 'default' | 'small' }>(), {
defineProps<{ checked: boolean; disabled?: boolean; size?: 'default' | 'small'; loading?: boolean }>(),
{
size: 'small', size: 'small',
}, })
)
const emit = defineEmits(['change', 'update:checked']) const emit = defineEmits(['change', 'update:checked'])
const checked = useVModel(props, 'checked', emit)
const loading = computed(() => props.loading) const checked = useVModel(props, 'checked', emit)
const onChange = (e: boolean) => { const onChange = (e: boolean) => {
emit('change', e) emit('change', e)
@ -16,15 +13,7 @@ const onChange = (e: boolean) => {
</script> </script>
<template> <template>
<a-switch <a-switch v-model:checked="checked" :disabled="disabled" class="nc-switch" v-bind="$attrs" :size="size" @change="onChange">
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 mb-0; @apply pl-2.5 mb-0;
} }
.ant-tabs-ink-bar { .ant-tabs-ink-bar {

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

@ -2,7 +2,6 @@
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
@ -270,7 +269,7 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
</div> </div>
<RolesSelector <RolesSelector
size="lg" size="lg"
class="!min-w-[152px] nc-invite-role-selector" class="nc-invite-role-selector"
:role="inviteData.roles" :role="inviteData.roles"
:roles="allowedRoles" :roles="allowedRoles"
:on-role-change="onRoleChange" :on-role-change="onRoleChange"
@ -298,9 +297,3 @@ 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: false, border: true,
size: 'sm', size: 'sm',
}, },
) )

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

@ -268,6 +268,10 @@ 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,7 +433,6 @@
"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",
@ -704,9 +703,6 @@
"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",
@ -741,7 +737,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 Viewing", "enablePublicAccess": "Enable Public Access",
"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",
@ -805,8 +801,6 @@
"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,21 +11,22 @@ export class TopbarSharePage extends BasePage {
} }
get() { get() {
return this.rootPage.locator(`.nc-modal-share-collaborate`); return this.rootPage.locator(`.nc-modal-share-collaborate`).locator('.ant-modal-content');
} }
async clickShareProject() { async clickShareView() {
await this.get().locator(`[data-testid="docs-share-dlg-share-base"]`).click(); await this.get().waitFor();
// collapse header 0: Share Base, 1: Share View
await this.get().locator(`.ant-collapse-header`).nth(1).click();
} }
async switchBaseShareTab({ tab }: { tab: 'public' | 'member' }) { async clickShareProject() {
await this.get().getByTestId(`nc-base-share-${tab}`).waitFor({ state: 'visible' }); await this.get().locator(`[data-testid="docs-share-dlg-share-base"]`).click();
await this.get().getByTestId(`nc-base-share-${tab}`).click();
} }
async clickShareViewPublicAccess() { async clickShareViewPublicAccess() {
await expect(this.get().getByTestId('share-view-toggle')).toHaveCount(1); await expect(this.get().locator(`[data-testid="share-view-toggle"]`)).toHaveCount(1);
await this.get().getByTestId('share-view-toggle').click(); await this.get().locator(`[data-testid="share-view-toggle"]`).click();
} }
async clickCopyLink() { async clickCopyLink() {
@ -34,7 +35,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("Finish")').click(); await this.get().locator('.ant-btn.ant-btn-secondary:has-text("Close")').click();
} }
async clickShareViewWithPassword({ password }: { password: string }) { async clickShareViewWithPassword({ password }: { password: string }) {
@ -47,22 +48,7 @@ export class TopbarSharePage extends BasePage {
} }
async clickShareBase() { async clickShareBase() {
await this.get().waitFor(); await this.get().locator(`[data-testid="db-share-base"]`).click();
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() {
@ -86,8 +72,6 @@ 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,9 +57,6 @@ 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 '../../utils/general'; import { getDefaultPwd } from '../../../tests/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,6 +62,9 @@ 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