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 8 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">
import { OrderedProjectRoles, ProjectRoles } from 'nocodb-sdk'
import { onKeyStroke } from '@vueuse/core'
import {
type User,
extractSdkResponseErrorMsg,
message,
onMounted,
@ -9,6 +12,7 @@ import {
useGlobal,
useNuxtApp,
useWorkspace,
validateEmail,
} from '#imports'
interface ShareBase {
@ -17,6 +21,13 @@ interface ShareBase {
role?: string
}
const props = defineProps({
isView: {
type: Boolean,
default: false,
},
})
enum ShareBaseRole {
Editor = 'editor',
Viewer = 'viewer',
@ -26,13 +37,27 @@ const { dashboardUrl } = useDashboard()
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 { 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(() => {
if (!sharedBase.value || !sharedBase.value.uuid) return ''
@ -48,22 +73,6 @@ const url = computed(() => {
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) => {
try {
if (!base.value.id) return
@ -98,12 +107,6 @@ const disableSharedBase = async () => {
$e('a:shared-base:disable')
}
onMounted(() => {
if (!sharedBase.value) {
loadBase()
}
})
const isSharedBaseEnabled = computed(() => !!sharedBase.value?.uuid)
const isToggleBaseLoading = 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 () => {
if (!sharedBase.value) return
if (isRoleToggleLoading.value) return
isRoleToggleLoading.value = true
try {
if (sharedBase.value.role === ShareBaseRole.Viewer) {
@ -142,34 +248,317 @@ const onRoleToggle = async () => {
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>
<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 w-full p-3 border-1 border-gray-100 rounded-md">
<div class="flex flex-row w-full justify-between">
<div class="text-gray-900 font-medium">{{ $t('activity.enablePublicAccess') }}</div>
<a-switch
v-e="['c:share:base:enable:toggle']"
:checked="isSharedBaseEnabled"
:loading="isToggleBaseLoading"
class="ml-2"
@click="toggleSharedBase"
/>
</div>
<div v-if="isSharedBaseEnabled" class="flex flex-col w-full mt-3 border-t-1 pt-3 border-gray-100">
<GeneralCopyUrl 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">
<div class="text-black">{{ $t('activity.editingAccess') }}</div>
<a-switch
v-e="['c:share:base:role:toggle']"
:loading="isRoleToggleLoading"
:checked="sharedBase?.role === ShareBaseRole.Editor"
class="ml-2"
@click="onRoleToggle"
/>
<div class="flex flex-col !h-80 gap-2" data-testid="nc-share-base-sub-modal">
<NcTabs v-model:activeKey="openedBaseShareTab" class="nc-base-share-tab h-full" size="medium">
<a-tab-pane key="members">
<template #tab>
<div class="tab" data-testid="nc-base-share-member">
<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>
</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>
</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>
import type { ColumnType, KanbanType, ViewType } 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 { $e } = useNuxtApp()
@ -11,6 +11,14 @@ const { dashboardUrl } = useDashboard()
const viewStore = useViewsStore()
const { viewsByTable } = storeToRefs(viewStore)
const { showShareModal } = storeToRefs(useShare())
const baseStore = useBase()
const { navigateToProjectPage } = baseStore
const { metas } = useMetas()
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(() => {
return !!activeView.value?.uuid
return !!selectedView.value?.uuid
})
const url = computed(() => {
@ -55,27 +89,27 @@ const url = computed(() => {
const passwordProtectedLocal = ref(false)
const passwordProtected = computed(() => {
return !!activeView.value?.password || passwordProtectedLocal.value
return !!selectedView.value?.password || passwordProtectedLocal.value
})
const password = computed({
get: () => (passwordProtected.value ? activeView.value?.password ?? '' : ''),
get: () => (passwordProtected.value ? selectedView.value?.password ?? '' : ''),
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()
},
})
const viewTheme = computed({
get: () => !!activeView.value?.meta.withTheme,
get: () => !!selectedView.value?.meta.withTheme,
set: (withTheme) => {
if (!activeView.value?.meta) return
if (!selectedView.value?.meta) return
activeView.value.meta = {
...activeView.value.meta,
selectedView.value.meta = {
...selectedView.value.meta,
withTheme,
}
saveTheme()
@ -84,15 +118,15 @@ const viewTheme = computed({
const togglePasswordProtected = async () => {
passwordProtectedLocal.value = !passwordProtected.value
if (!activeView.value) return
if (!selectedView.value) return
if (isUpdating.value.password) return
isUpdating.value.password = true
try {
if (passwordProtected.value) {
activeView.value = { ...(activeView.value as any), password: null }
selectedView.value = { ...(selectedView.value as any), password: null }
} else {
activeView.value = { ...(activeView.value as any), password: '' }
selectedView.value = { ...(selectedView.value as any), password: '' }
}
await updateSharedView()
@ -103,35 +137,35 @@ const togglePasswordProtected = async () => {
const withRTL = computed({
get: () => {
if (!activeView.value?.meta) return false
if (!selectedView.value?.meta) return false
if (typeof activeView.value?.meta === 'string') {
activeView.value.meta = JSON.parse(activeView.value.meta)
if (typeof selectedView.value?.meta === 'string') {
selectedView.value.meta = JSON.parse(selectedView.value.meta)
}
return !!(activeView.value?.meta as any)?.rtl
return !!(selectedView.value?.meta as any)?.rtl
},
set: (rtl) => {
if (!activeView.value?.meta) return
if (!selectedView.value?.meta) return
if (typeof activeView.value?.meta === 'string') {
activeView.value.meta = JSON.parse(activeView.value.meta)
if (typeof selectedView.value?.meta === 'string') {
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()
},
})
const allowCSVDownload = computed({
get: () => !!(activeView.value?.meta as any)?.allowCSVDownload,
get: () => !!(selectedView.value?.meta as any)?.allowCSVDownload,
set: async (allow) => {
if (!activeView.value?.meta) return
if (!selectedView.value?.meta) return
isUpdating.value.download = true
try {
activeView.value.meta = { ...activeView.value.meta, allowCSVDownload: allow }
selectedView.value.meta = { ...selectedView.value.meta, allowCSVDownload: allow }
await saveAllowCSVDownload()
} finally {
isUpdating.value.download = false
@ -140,22 +174,22 @@ const allowCSVDownload = computed({
})
const surveyMode = computed({
get: () => !!activeView.value?.meta.surveyMode,
get: () => !!selectedView.value?.meta.surveyMode,
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()
},
})
const formPreFill = computed({
get: () => ({
preFillEnabled: parseProp(activeView.value?.meta)?.preFillEnabled ?? false,
preFilledMode: parseProp(activeView.value?.meta)?.preFilledMode || PreFilledMode.Default,
preFillEnabled: parseProp(selectedView.value?.meta)?.preFillEnabled ?? false,
preFilledMode: parseProp(selectedView.value?.meta)?.preFilledMode || PreFilledMode.Default,
}),
set: (value) => {
if (!activeView.value?.meta) return
if (!selectedView.value?.meta) return
if (formPreFill.value.preFillEnabled !== value.preFillEnabled) {
$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`)
}
activeView.value.meta = {
...activeView.value.meta,
selectedView.value.meta = {
...selectedView.value.meta,
...value,
}
savePreFilledMode()
@ -181,10 +215,10 @@ const handleChangeFormPreFill = (value: { preFillEnabled?: boolean; preFilledMod
}
function sharedViewUrl() {
if (!activeView.value) return
if (!selectedView.value) return
let viewType
switch (activeView.value.type) {
switch (selectedView.value.type) {
case ViewTypes.FORM:
viewType = 'form'
break
@ -213,30 +247,30 @@ function sharedViewUrl() {
}
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}` : ''
}`,
)
}
const toggleViewShare = async () => {
if (!activeView.value?.id) return
if (!selectedView.value?.id) return
if (activeView.value?.uuid) {
await $api.dbViewShare.delete(activeView.value.id)
if (selectedView.value?.uuid) {
await $api.dbViewShare.delete(selectedView.value.id)
activeView.value = { ...activeView.value, uuid: undefined, password: undefined }
selectedView.value = { ...selectedView.value, uuid: undefined, password: undefined }
} else {
const response = await $api.dbViewShare.create(activeView.value.id)
activeView.value = { ...activeView.value, ...(response as any) }
const response = await $api.dbViewShare.create(selectedView.value.id)
selectedView.value = { ...selectedView.value, ...(response as any) }
if (activeView.value!.type === ViewTypes.KANBAN) {
if (selectedView.value!.type === ViewTypes.KANBAN) {
// extract grouping column meta
const groupingFieldColumn = metas.value[viewStore.activeView!.fk_model_id].columns!.find(
(col: ColumnType) => col.id === ((viewStore.activeView!.view! as KanbanType).fk_grp_col_id! as string),
const groupingFieldColumn = metas.value[selectedView.value!.fk_model_id].columns!.find(
(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()
}
@ -279,12 +313,12 @@ async function saveTheme() {
async function updateSharedView() {
try {
if (!activeView.value?.meta) return
const meta = activeView.value.meta
if (!selectedView.value?.meta) return
const meta = selectedView.value.meta
await $api.dbViewShare.update(activeView.value.id!, {
await $api.dbViewShare.update(selectedView.value.id!, {
meta,
password: activeView.value.password,
password: selectedView.value.password,
})
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
@ -297,31 +331,55 @@ async function savePreFilledMode() {
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>
<template>
<div class="flex flex-col py-2 px-3 mb-1">
<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 w-full justify-between py-0.5">
<div class="text-gray-900 font-medium">{{ $t('activity.enabledPublicViewing') }}</div>
<div class="flex flex-col !h-80 justify-between">
<div class="flex flex-col p-3 border-gray-200 border-1 rounded-lg gap-y-2">
<div class="flex flex-row items-center justify-between">
<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
v-e="['c:share:view:enable:toggle']"
size="small"
:checked="isPublicShared"
:disabled="isLocked"
:loading="isUpdating.public"
class="share-view-toggle !mt-0.25"
data-testid="share-view-toggle"
@click="toggleShare"
@change="toggleShare"
/>
</div>
<template v-if="isPublicShared">
<div class="mt-0.5 border-t-1 border-gray-100 pt-3">
<GeneralCopyUrl v-model:url="url" />
</div>
<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>
<div v-if="isPublicShared" class="space-y-3">
<GeneralCopyUrl v-model:url="url" class="w-[34.625rem]" />
<div class="flex items-center gap-3 h-8 justify-between">
<div class="flex flex-row gap-3 items-center">
<a-switch
v-e="['c:share:view:password:toggle']"
:checked="passwordProtected"
@ -329,52 +387,42 @@ watchEffect(() => {})
class="share-password-toggle !mt-0.25"
data-testid="share-password-toggle"
size="small"
@click="togglePasswordProtected"
@change="togglePasswordProtected"
/>
<div class="flex text-black">{{ $t('activity.restrictAccessWithPassword') }}</div>
</div>
<Transition mode="out-in" name="layout">
<div v-if="passwordProtected" class="flex gap-2 mt-2 w-2/3">
<a-input-password
v-model:value="password"
:placeholder="$t('placeholder.password.enter')"
class="!rounded-lg !py-1 !bg-white"
data-testid="nc-modal-share-view__password"
size="small"
type="password"
/>
</div>
</Transition>
<a-input-password
v-if="passwordProtected"
v-model:value="password"
: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"
data-testid="nc-modal-share-view__password"
size="small"
type="password"
/>
</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 class="flex flex-row items-center justify-between">
<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>
<div
v-if="
selectedView &&
[ViewTypes.GRID, ViewTypes.KANBAN, ViewTypes.GALLERY, ViewTypes.MAP, ViewTypes.CALENDAR].includes(selectedView.type)
"
class="flex flex-row items-center gap-3"
>
<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 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
v-model:checked="surveyMode"
v-e="['c:share:view:surver-mode:toggle']"
@ -382,61 +430,77 @@ watchEffect(() => {})
size="small"
>
</a-switch>
{{ $t('activity.surveyMode') }}
</div>
<div v-if="!isEeUI" class="flex flex-row items-center justify-between">
<div class="text-black">{{ $t('activity.rtlOrientation') }}</div>
<NcTooltip>
<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
v-model:checked="withRTL"
v-e="['c:share:view:rtl-orientation:toggle']"
data-testid="nc-modal-share-view__RTL"
v-e="['c:share:view:surver-mode:toggle']"
:checked="formPreFill.preFillEnabled"
data-testid="nc-modal-share-view__preFill"
size="small"
@update:checked="handleChangeFormPreFill({ preFillEnabled: $event as boolean })"
>
</a-switch>
</div>
</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>
{{ $t('activity.preFilledFields.title') }}
<NcTooltip>
<template #title>
<div class="text-center">
{{ $t('tooltip.preFillFormInfo') }}
<NcSelect
v-if="formPreFill.preFillEnabled"
v-model:value="formPreFill.preFilledMode"
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>
</template>
<GeneralIcon icon="info" class="text-gray-600 cursor-pointer"></GeneralIcon>
</NcTooltip>
</a-select-option>
</NcSelect>
</div>
<a-switch
v-e="['c:share:view:surver-mode:toggle']"
:checked="formPreFill.preFillEnabled"
data-testid="nc-modal-share-view__preFill"
size="small"
@update:checked="handleChangeFormPreFill({ preFillEnabled: $event as boolean })"
>
</a-switch>
<NcTooltip>
<template #title>{{ $t('tooltip.preFillFormInfo') }} </template>
<component :is="iconMap.info" class="text-gray-500" />
</NcTooltip>
</div>
</template>
</div>
</div>
<a-radio-group
v-if="formPreFill.preFillEnabled"
:value="formPreFill.preFilledMode"
class="nc-modal-share-view-preFillMode"
data-testid="nc-modal-share-view__preFillMode"
@update:value="handleChangeFormPreFill({ preFilledMode: $event })"
>
<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 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>
@ -459,18 +523,4 @@ watchEffect(() => {})
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>

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

@ -1,19 +1,12 @@
<script lang="ts" setup>
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<{
isViewToolbar?: boolean
}>()
const { copy } = useCopy()
const { dashboardUrl } = useDashboard()
const baseStore = useBase()
const { base } = storeToRefs(baseStore)
const { navigateToProjectPage } = baseStore
const { activeView } = storeToRefs(useViewsStore())
let view: Ref<ViewType | undefined>
if (isViewToolbar) {
@ -25,53 +18,12 @@ if (isViewToolbar) {
}
}
const { formStatus, showShareModal, invitationUsersData, isInvitationLinkCopied } = storeToRefs(useShare())
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 activeTab = ref<'base' | 'view'>('base')
const copyInvitationLink = async () => {
await copy(inviteUrl.value!)
const { formStatus, showShareModal } = storeToRefs(useShare())
const { resetData } = useShare()
isInvitationLinkCopied.value = true
}
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
}
}
const highlightStyle = ref({ top: isViewToolbar ? '160px' : '4px' })
watch(showShareModal, (val) => {
if (!val) {
@ -80,120 +32,112 @@ watch(showShareModal, (val) => {
}, 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>
<template>
<a-modal
<NcModal
v-model:visible="showShareModal"
class="!top-[1%]"
:centered="false"
:class="{ active: showShareModal }"
wrap-class-name="nc-modal-share-collaborate"
:closable="false"
:mask-closable="formStatus === 'base-collaborateSaving' ? false : true"
:ok-button-props="{ hidden: true } as any"
:cancel-button-props="{ hidden: true } as any"
:footer="null"
:width="formStatus === 'manageCollaborators' ? '60rem' : '40rem'"
:mask-closable="formStatus !== 'base-collaborateSaving'"
:width="formStatus === 'manageCollaborators' ? '60rem' : '48rem'"
>
<div v-if="formStatus === 'base-collaborateSaving'" class="flex flex-row w-full px-5 justify-between items-center py-1">
<div class="flex text-base font-bold">Adding Members</div>
<a-spin :indicator="indicator" />
</div>
<template v-else-if="formStatus === 'base-collaborateSaved'">
<div class="flex flex-col py-1.5">
<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 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 class="flex flex-col gap-6">
<div class="flex text-xl font-bold">{{ $t('activity.share') }}</div>
<div class="flex flex-1 gap-3">
<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
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' }"
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'"
>
{{ 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>
<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
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' }"
:class="{ 'nc-share-active': activeTab === 'view' }"
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>
<LazyDlgShareAndCollaborateShareBase />
</div>
<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 class="flex flex-1 h-full flex-col">
<LazyDlgShareAndCollaborateShareBase v-if="activeTab === 'base'" :is-view="isViewToolbar" />
<LazyDlgShareAndCollaborateSharePage v-else-if="activeTab === 'view'" />
</div>
</div>
</div>
</a-modal>
</NcModal>
</template>
<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 {
@apply !rounded-lg !mb-2 !mt-4 !border-0;
}
@ -201,10 +145,12 @@ watch(showShareModal, (val) => {
.ant-collapse {
@apply !bg-white !border-0;
}
</style>
<style lang="scss">
.nc-modal-share-collaborate {
.ant-modal-content {
@apply !rounded-2xl;
}
.ant-modal {
top: 10vh !important;
}
@ -226,10 +172,6 @@ watch(showShareModal, (val) => {
@apply !p-0;
}
.ant-modal-content {
@apply !rounded-lg !px-1 !py-2;
}
.ant-select-selector {
@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"
>
<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 class="flex flex-row gap-x-1">
<div class="flex !text-gray-700 flex-row gap-x-1">
<NcTooltip>
<template #title>
{{ $t('activity.openInANewTab') }}

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

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

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

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

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

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

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

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

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

@ -2,6 +2,7 @@
import type { RoleLabels } from 'nocodb-sdk'
import { OrderedProjectRoles, ProjectRoles } from 'nocodb-sdk'
import type { User } from '#imports'
const props = defineProps<{
modelValue: boolean
baseId?: string
@ -269,7 +270,7 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
</div>
<RolesSelector
size="lg"
class="nc-invite-role-selector"
class="!min-w-[152px] nc-invite-role-selector"
:role="inviteData.roles"
:roles="allowedRoles"
:on-role-change="onRoleChange"
@ -297,3 +298,9 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
</div>
</NcModal>
</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,
inherit: false,
border: true,
border: false,
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;
}
.badge-text {
@apply text-[14px] pt-1 text-center;
}
:deep(.ant-select-selection-item) {
@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?"
},
"labels": {
"enterMultipleEmails": "Enter multiple E-mails by using commas to separate them",
"selectYear": "Select Year",
"save": "Save",
"cancel": "Cancel",
@ -703,6 +704,9 @@
"clearSelection": "Clear selection"
},
"activity": {
"inviteAs": "Invite as",
"toBase": "to base",
"manageAccess": "Manage Access",
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
@ -737,7 +741,7 @@
"outOfSync": "Out of sync",
"newSource": "New Data Source",
"newWebhook": "New Webhook",
"enablePublicAccess": "Enable Public Access",
"enablePublicAccess": "Enable Public Viewing",
"doYouWantToSaveTheChanges": "Do you want to save the changes ?",
"editingAccess": "Editing access",
"enabledPublicViewing": "Enable Public Viewing",
@ -801,6 +805,8 @@
"invite": "Invite",
"inviteMore": "Invite more",
"inviteTeam": "Invite Team",
"inviteMembers": "Invite Member(s)",
"inviteUsers": "Invite User(s)",
"inviteUser": "Invite User",
"inviteToken": "Invite Token",
"linkedRecords": "Linked Records",

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

@ -11,22 +11,21 @@ export class TopbarSharePage extends BasePage {
}
get() {
return this.rootPage.locator(`.nc-modal-share-collaborate`).locator('.ant-modal-content');
}
async clickShareView() {
await this.get().waitFor();
// collapse header 0: Share Base, 1: Share View
await this.get().locator(`.ant-collapse-header`).nth(1).click();
return this.rootPage.locator(`.nc-modal-share-collaborate`);
}
async clickShareProject() {
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() {
await expect(this.get().locator(`[data-testid="share-view-toggle"]`)).toHaveCount(1);
await this.get().locator(`[data-testid="share-view-toggle"]`).click();
await expect(this.get().getByTestId('share-view-toggle')).toHaveCount(1);
await this.get().getByTestId('share-view-toggle').click();
}
async clickCopyLink() {
@ -35,7 +34,7 @@ export class TopbarSharePage extends BasePage {
async closeModal() {
// 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 }) {
@ -48,7 +47,22 @@ export class TopbarSharePage extends BasePage {
}
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() {
@ -72,6 +86,8 @@ export class TopbarSharePage extends BasePage {
async clickShareBaseEditorAccess() {
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 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) {
await this.clickShare();
// await this.share.clickShareView();
await this.share.clickShareView();
await this.share.clickShareViewPublicAccess();
await this.share.clickCopyLink();
if (surveyMode) {
@ -57,6 +57,9 @@ export class TopbarPage extends BasePage {
async getSharedBaseUrl({ role, enableSharedBase }: { role: string; enableSharedBase: boolean }) {
await this.clickShare();
await this.share.clickShareBase();
await this.share.switchBaseShareTab({ tab: 'public' });
if (enableSharedBase) await this.share.clickShareBasePublicAccess();
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 { ToolbarPage } from '../../../pages/Dashboard/common/Toolbar';
import { LoginPage } from '../../../pages/LoginPage';
import { getDefaultPwd } from '../../../tests/utils/general';
import { getDefaultPwd } from '../../utils/general';
import { isEE } from '../../../setup/db';
// To be enabled after shared base is implemented
@ -62,9 +62,6 @@ test.describe('Shared base', () => {
});
test('#1', async () => {
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
let url = '';
// share button visible only if a table is opened
await dashboard.treeView.openTable({ title: 'Country' });

Loading…
Cancel
Save