Browse Source

Merge pull request #6441 from nocodb/nc-fix/meber

feat: email badging on bulk email send
pull/6447/head
Raju Udava 1 year ago committed by GitHub
parent
commit
fb828a44dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/nc-gui/components/dlg/share-and-collaborate/ManageUsers.vue
  2. 4
      packages/nc-gui/components/dlg/share-and-collaborate/View.vue
  3. 7
      packages/nc-gui/components/nc/Badge.vue
  4. 1
      packages/nc-gui/components/nc/Select.vue
  5. 6
      packages/nc-gui/components/project/AccessSettings.vue
  6. 183
      packages/nc-gui/components/project/InviteProjectCollabSection.vue
  7. 2
      packages/nc-gui/components/project/View.vue
  8. 13
      packages/nc-gui/components/roles/Badge.vue
  9. 13
      packages/nc-gui/components/roles/Selector.vue
  10. 32
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  11. 210
      packages/nc-gui/components/workspace/InviteSection.vue
  12. 2
      packages/nc-gui/components/workspace/View.vue
  13. 1
      packages/nc-gui/utils/iconUtils.ts
  14. 2
      tests/playwright/pages/Dashboard/BulkUpdate/index.ts
  15. 3
      tests/playwright/pages/WorkspacePage/CollaborationPage.ts
  16. 2
      tests/playwright/pages/WorkspacePage/ContainerPage.ts

2
packages/nc-gui/components/dlg/share-and-collaborate/ManageUsers.vue

@ -72,7 +72,7 @@ const rolesTypes = [
<template> <template>
<div class="flex flex-col mx-4 h-112"> <div class="flex flex-col mx-4 h-112">
<div class="flex mt-2.5 mb-2.5 border-b-1 border-gray-50 pb-1.5" :style="{ fontWeight: 500 }">Manage Collaborators</div> <div class="flex mt-2.5 mb-2.5 border-b-1 border-gray-50 pb-1.5" :style="{ fontWeight: 500 }">Manage Members</div>
<div class="flex mt-2.5 mb-2.5 text-xs" :style="{ fontWeight: 500 }">Project Owner</div> <div class="flex mt-2.5 mb-2.5 text-xs" :style="{ fontWeight: 500 }">Project Owner</div>
<div v-if="owner" class="flex flex-row px-2 py-2 items-center gap-x-2 border-1 border-gray-100 rounded-md"> <div v-if="owner" class="flex flex-row px-2 py-2 items-center gap-x-2 border-1 border-gray-100 rounded-md">
<a-avatar></a-avatar> <a-avatar></a-avatar>

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

@ -95,13 +95,13 @@ watch(showShareModal, (val) => {
:width="formStatus === 'manageCollaborators' ? '60rem' : '40rem'" :width="formStatus === 'manageCollaborators' ? '60rem' : '40rem'"
> >
<div v-if="formStatus === 'project-collaborateSaving'" class="flex flex-row w-full px-5 justify-between items-center py-1"> <div v-if="formStatus === 'project-collaborateSaving'" class="flex flex-row w-full px-5 justify-between items-center py-1">
<div class="flex text-base" :style="{ fontWeight: 500 }">Adding Collaborators</div> <div class="flex text-base" :style="{ fontWeight: 500 }">Adding Members</div>
<a-spin :indicator="indicator" /> <a-spin :indicator="indicator" />
</div> </div>
<template v-else-if="formStatus === 'project-collaborateSaved'"> <template v-else-if="formStatus === 'project-collaborateSaved'">
<div class="flex flex-col py-1.5"> <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 flex-row w-full px-5 justify-between items-center py-0.5">
<div class="flex text-base" :style="{ fontWeight: 500 }">Collaborators added</div> <div class="flex text-base font-medium">Members added</div>
<div class="flex"> <div class="flex">
<MdiCheck /> <MdiCheck />
</div> </div>

7
packages/nc-gui/components/nc/Badge.vue

@ -3,16 +3,18 @@ const props = withDefaults(
defineProps<{ defineProps<{
color: string color: string
border?: boolean border?: boolean
size?: 'sm' | 'md' | 'lg'
}>(), }>(),
{ {
border: true, border: true,
size: 'sm',
}, },
) )
</script> </script>
<template> <template>
<div <div
class="h-6 rounded-md px-1" class="rounded-md px-1 flex items-center"
:class="{ :class="{
'border-purple-500 bg-purple-100': props.color === 'purple', 'border-purple-500 bg-purple-100': props.color === 'purple',
'border-blue-500 bg-blue-100': props.color === 'blue', 'border-blue-500 bg-blue-100': props.color === 'blue',
@ -22,6 +24,9 @@ const props = withDefaults(
'border-red-500 bg-red-100': props.color === 'red', 'border-red-500 bg-red-100': props.color === 'red',
'border-gray-300': !props.color, 'border-gray-300': !props.color,
'border-1': props.border, 'border-1': props.border,
'!h-6': props.size === 'sm',
'!h-8': props.size === 'md',
'!h-10': props.size === 'lg',
}" }"
> >
<slot /> <slot />

1
packages/nc-gui/components/nc/Select.vue

@ -50,6 +50,7 @@ const onChange = (value: string) => {
<style lang="scss"> <style lang="scss">
.nc-select.ant-select { .nc-select.ant-select {
height: fit-content;
.ant-select-selector { .ant-select-selector {
box-shadow: 0px 5px 3px -2px rgba(0, 0, 0, 0.02), 0px 3px 1px -2px rgba(0, 0, 0, 0.06); box-shadow: 0px 5px 3px -2px rgba(0, 0, 0, 0.02), 0px 3px 1px -2px rgba(0, 0, 0, 0.06);
@apply border-1 border-gray-200 !rounded-lg; @apply border-1 border-gray-200 !rounded-lg;

6
packages/nc-gui/components/project/AccessSettings.vue

@ -155,14 +155,12 @@ onMounted(async () => {
<template> <template>
<div class="nc-collaborator-table-container mt-4 nc-access-settings-view"> <div class="nc-collaborator-table-container mt-4 nc-access-settings-view">
<ProjectInviteProjectCollabSection @invited="reloadCollabs" />
<div v-if="isLoading" class="nc-collaborators-list items-center justify-center"> <div v-if="isLoading" class="nc-collaborators-list items-center justify-center">
<GeneralLoader size="xlarge" /> <GeneralLoader size="xlarge" />
</div> </div>
<template v-else> <template v-else>
<div class="w-full flex flex-row justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2"> <div class="w-full flex flex-row justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2">
<a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md" placeholder="Search collaborators"> <a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md" placeholder="Search members">
<template #prefix> <template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" /> <PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template> </template>
@ -176,7 +174,7 @@ onMounted(async () => {
v-else-if="!collaborators?.length" v-else-if="!collaborators?.length"
class="nc-collaborators-list w-full h-full flex flex-col items-center justify-center mt-36" class="nc-collaborators-list w-full h-full flex flex-col items-center justify-center mt-36"
> >
<a-empty description="No collaborators found" /> <Empty description="No members found" />
</div> </div>
<div v-else class="nc-collaborators-list nc-scrollbar-md"> <div v-else class="nc-collaborators-list nc-scrollbar-md">
<div class="nc-collaborators-list-header"> <div class="nc-collaborators-list-header">

183
packages/nc-gui/components/project/InviteProjectCollabSection.vue

@ -9,6 +9,78 @@ const inviteData = reactive({
roles: ProjectRoles.VIEWER, roles: ProjectRoles.VIEWER,
}) })
const focusRef = ref<HTMLInputElement>()
const isDivFocused = ref(false)
const divRef = ref<HTMLDivElement>()
const emailValidation = reactive({
isError: false,
message: '',
})
const validateEmail = (email: string): boolean => {
const regEx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return regEx.test(email)
}
// all user input emails are stored here
const emailBadges = ref<Array<string>>([])
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)
}
watch(inviteData, (newVal) => {
const isNewEmail = newVal.email.charAt(newVal.email.length - 1) === ',' || newVal.email.charAt(newVal.email.length - 1) === ' '
if (isNewEmail && newVal.email.trim().length > 1) {
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 = ''
}
if (newVal.email.length < 1 && emailValidation.isError) {
emailValidation.isError = false
}
})
const handleEnter = () => {
if (inviteData.email.length < 1) {
emailValidation.isError = true
emailValidation.message = 'EMAIL SHOULD NOT BE EMPTY'
return
}
if (!validateEmail(inviteData.email.trim())) {
emailValidation.isError = true
emailValidation.message = 'INVALID EMAIL'
return
}
inviteData.email += ' '
emailValidation.isError = false
emailValidation.message = ''
}
const { dashboardUrl } = useDashboard() const { dashboardUrl } = useDashboard()
const { inviteUser } = useManageUsers() const { inviteUser } = useManageUsers()
@ -31,18 +103,28 @@ const inviteCollaborator = async () => {
isInvitingCollaborators.value = true isInvitingCollaborators.value = true
try { try {
emailBadges.value.forEach((el, index) => {
// prevent the last email from getting the ","
if (index === emailBadges.value.length - 1) {
inviteData.email += el
} else {
inviteData.email += `${el},`
}
})
usersData.value = await inviteUser(inviteData) usersData.value = await inviteUser(inviteData)
usersData.roles = inviteData.roles usersData.roles = inviteData.roles
if (usersData.value) { if (usersData.value) {
message.success('Invitation sent successfully') message.success('Invitation sent successfully')
inviteData.email = '' inviteData.email = ''
emailBadges.value = []
emit('invited') emit('invited')
} }
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} finally {
inviteData.email = ''
isInvitingCollaborators.value = false
} }
isInvitingCollaborators.value = false
} }
const inviteUrl = computed(() => const inviteUrl = computed(() =>
@ -76,11 +158,60 @@ const copyUrl = async () => {
// Copied shareable base url to clipboard! // Copied shareable base url to clipboard!
message.success(t('msg.success.shareableURLCopied')) message.success(t('msg.success.shareableURLCopied'))
inviteData.email = ''
emailBadges.value = []
} catch (e: any) { } catch (e: any) {
message.error(e.message) message.error(e.message)
} }
$e('c:shared-base:copy-url') $e('c:shared-base:copy-url')
} }
const focusOnDiv = () => {
focusRef.value?.focus()
isDivFocused.value = true
}
// remove one email per backspace
onKeyStroke('Backspace', () => {
if (isDivFocused.value && inviteData.email.length < 1) {
emailBadges.value.pop()
}
})
// when bulk email is pasted
const onPaste = (e: ClipboardEvent) => {
const pastedText = e.clipboardData?.getData('text')
const inputArray = pastedText?.split(',') || pastedText?.split(' ')
// if data is pasted to a already existing text in input
// we add existingInput + pasted data
if (inputArray?.length === 1 && inviteData.email.length > 1) {
inputArray[0] = inviteData.email += inputArray[0]
}
inputArray?.forEach((el) => {
if (el.length < 1) {
emailValidation.isError = true
emailValidation.message = 'EMAIL SHOULD NOT BE EMPTY'
return
}
if (!validateEmail(el.trim())) {
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(el)) {
insertOrUpdateString(el)
return
}
emailBadges.value.push(el)
inviteData.email = ''
})
inviteData.email = ''
}
</script> </script>
<template> <template>
@ -128,12 +259,44 @@ const copyUrl = async () => {
<div class="text-xl mb-4">Invite</div> <div class="text-xl mb-4">Invite</div>
<a-form> <a-form>
<div class="flex gap-2"> <div class="flex gap-2">
<a-input <div class="flex flex-col">
id="email" <div
v-model:value="inviteData.email" ref="divRef"
placeholder="Enter emails to send invitation" class="flex w-130 border-1 gap-1 items-center min-h-8 flex-wrap max-h-30 overflow-y-scroll rounded-lg nc-scrollbar-md"
class="!max-w-130 !rounded" tabindex="0"
/> :class="{
'border-primary/100': isDivFocused,
'p-1': emailBadges.length > 1,
}"
@click="focusOnDiv"
@blur="isDivFocused = false"
>
<span
v-for="(email, index) in emailBadges"
:key="email"
class="text-[14px] border-1 text-brand-500 bg-brand-50 rounded-md ml-1 p-0.5"
>
{{ 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-50 !outline-0 !focus:outline-0 ml-2 mr-3"
data-testid="email-input"
@keyup.enter="handleEnter"
@paste.prevent="onPaste"
@blur="isDivFocused = false"
/>
</div>
<span v-if="emailValidation.isError" class="ml-2 text-red-500 text-[12px] mt-1">{{ emailValidation.message }}</span>
</div>
<RolesSelector <RolesSelector
class="px-1" class="px-1"
@ -146,13 +309,13 @@ const copyUrl = async () => {
<a-button <a-button
type="primary" type="primary"
class="!rounded-md" class="!rounded-md"
:disabled="!inviteData.email?.length || isInvitingCollaborators" :disabled="!emailBadges.length || isInvitingCollaborators || emailValidation.isError"
@click="inviteCollaborator" @click="inviteCollaborator"
> >
<div class="flex flex-row items-center gap-x-2 pr-1"> <div class="flex flex-row items-center gap-x-2 pr-1">
<GeneralLoader v-if="isInvitingCollaborators" class="flex" /> <GeneralLoader v-if="isInvitingCollaborators" class="flex" />
<MdiPlus v-else /> <MdiPlus v-else />
{{ isInvitingCollaborators ? 'Adding' : 'Add' }} User/s {{ isInvitingCollaborators ? 'Adding' : 'Add' }} User(s)
</div> </div>
</a-button> </a-button>
</div> </div>

2
packages/nc-gui/components/project/View.vue

@ -101,7 +101,7 @@ watch(
<template #tab> <template #tab>
<div class="tab-title" data-testid="proj-view-tab__access-settings"> <div class="tab-title" data-testid="proj-view-tab__access-settings">
<GeneralIcon icon="users" class="!h-3.5 !w-3.5" /> <GeneralIcon icon="users" class="!h-3.5 !w-3.5" />
<div>Collaborator</div> <div>Members</div>
</div> </div>
</template> </template>
<ProjectAccessSettings /> <ProjectAccessSettings />

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

@ -8,19 +8,22 @@ const props = withDefaults(
clickable?: boolean clickable?: boolean
inherit?: boolean inherit?: boolean
border?: boolean border?: boolean
size?: 'sm' | 'md'
}>(), }>(),
{ {
clickable: false, clickable: false,
inherit: false, inherit: false,
border: true, border: true,
size: 'sm',
}, },
) )
const roleRef = toRef(props, 'role') const roleRef = toRef(props, 'role')
const clickableRef = toRef(props, 'clickable') const clickableRef = toRef(props, 'clickable')
// const inheritRef = toRef(props, 'inherit')
const borderRef = toRef(props, 'border') const borderRef = toRef(props, 'border')
const sizeSelect = computed(() => props.size)
const roleProperties = computed(() => { const roleProperties = computed(() => {
const role = roleRef.value const role = roleRef.value
@ -38,15 +41,14 @@ const roleProperties = computed(() => {
<template> <template>
<div <div
class="flex items-center !border-0" class="flex items-start"
:class="{ :class="{
'cursor-pointer': clickableRef, 'cursor-pointer': clickableRef,
}" }"
style="width: fit-content"
> >
<NcBadge class="!h-auto !px-[8px]" :color="roleProperties.color" :border="borderRef"> <NcBadge class="!px-2" :color="roleProperties.color" :border="borderRef" :size="sizeSelect">
<div <div
class="badge-text flex items-center gap-[4px]" class="badge-text flex items-center gap-2"
:class="{ :class="{
'text-purple-500': roleProperties.color === 'purple', 'text-purple-500': roleProperties.color === 'purple',
'text-blue-500': roleProperties.color === 'blue', 'text-blue-500': roleProperties.color === 'blue',
@ -55,6 +57,7 @@ const roleProperties = computed(() => {
'text-yellow-500': roleProperties.color === 'yellow', 'text-yellow-500': roleProperties.color === 'yellow',
'text-red-500': roleProperties.color === 'red', 'text-red-500': roleProperties.color === 'red',
'text-gray-300': !roleProperties.color, 'text-gray-300': !roleProperties.color,
sizeSelect,
}" }"
> >
<GeneralIcon :icon="roleProperties.icon" /> <GeneralIcon :icon="roleProperties.icon" />

13
packages/nc-gui/components/roles/Selector.vue

@ -10,26 +10,29 @@ const props = withDefaults(
description?: boolean description?: boolean
inherit?: string inherit?: string
onRoleChange: (role: keyof typeof RoleLabels) => void onRoleChange: (role: keyof typeof RoleLabels) => void
size: 'sm' | 'md'
}>(), }>(),
{ {
description: true, description: true,
size: 'sm',
}, },
) )
const roleRef = toRef(props, 'role') const roleRef = toRef(props, 'role')
const inheritRef = toRef(props, 'inherit') const inheritRef = toRef(props, 'inherit')
const descriptionRef = toRef(props, 'description') const descriptionRef = toRef(props, 'description')
const sizeRef = toRef(props, 'size')
</script> </script>
<template> <template>
<NcDropdown> <NcDropdown size="lg" class="nc-roles-selector">
<RolesBadge class="border-1" data-testid="roles" :role="roleRef" :inherit="inheritRef === role" clickable /> <RolesBadge data-testid="roles" :role="roleRef" :inherit="inheritRef === role" clickable :size="sizeRef" />
<template #overlay> <template #overlay>
<div class="nc-role-select-dropdown flex flex-col gap-[4px] p-1"> <div class="nc-role-select-dropdown flex flex-col gap-1 p-2">
<div class="flex flex-col gap-[4px]"> <div class="flex flex-col gap-1">
<div v-for="rl in props.roles" :key="rl" :value="rl" :selected="rl === roleRef" @click="props.onRoleChange(rl)"> <div v-for="rl in props.roles" :key="rl" :value="rl" :selected="rl === roleRef" @click="props.onRoleChange(rl)">
<div <div
class="flex flex-col py-[3px] px-[8px] gap-[4px] bg-transparent cursor-pointer" class="flex flex-col py-1.5 rounded-lg px-2 gap-1 bg-transparent cursor-pointer hover:bg-gray-100"
:class="{ :class="{
'w-[350px]': descriptionRef, 'w-[350px]': descriptionRef,
'w-[200px]': !descriptionRef, 'w-[200px]': !descriptionRef,

32
packages/nc-gui/components/workspace/CollaboratorsList.vue

@ -44,18 +44,18 @@ onMounted(async () => {
<template> <template>
<div class="nc-collaborator-table-container mt-4 mx-6"> <div class="nc-collaborator-table-container mt-4 mx-6">
<WorkspaceInviteSection v-if="workspaceRoles !== WorkspaceUserRoles.VIEWER" /> <!-- <div class="w-full h-1 border-t-1 border-gray-100 opacity-50 mt-6"></div> -->
<div class="w-full h-1 border-t-1 border-gray-100 opacity-50 mt-6"></div> <div class="w-full flex justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2">
<div class="w-full flex flex-row justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2"> <div class="text-xl">Invite Members By Email</div>
<div class="text-xl">Collaborators</div> <a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md mr-4" placeholder="Search members">
<a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md mr-4" placeholder="Search collaborators">
<template #prefix> <template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" /> <PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template> </template>
</a-input> </a-input>
</div> </div>
<WorkspaceInviteSection v-if="workspaceRole !== WorkspaceUserRoles.VIEWER" />
<div v-if="!filterCollaborators?.length" class="w-full h-full flex flex-col items-center justify-center mt-36"> <div v-if="!filterCollaborators?.length" class="w-full h-full flex flex-col items-center justify-center mt-36">
<a-empty description="No collaborators found" /> <Empty description="No members found" />
</div> </div>
<table v-else class="nc-collaborators-list-table !nc-scrollbar-md"> <table v-else class="nc-collaborators-list-table !nc-scrollbar-md">
<thead> <thead>
@ -98,21 +98,19 @@ onMounted(async () => {
</td> </td>
<td class="w-1/5"> <td class="w-1/5">
<div class="-left-2.5 top-5"> <div class="-left-2.5 top-5">
<a-dropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER" :trigger="['click']"> <NcDropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER" :trigger="['click']">
<MdiDotsVertical <MdiDotsVertical
class="h-5.5 w-5.5 rounded outline-0 p-0.5 nc-workspace-menu transform transition-transform !text-gray-400 cursor-pointer hover:(!text-gray-500 bg-gray-100)" class="border-1 !text-gray-600 h-5.5 w-5.5 rounded outline-0 p-0.5 nc-workspace-menu transform transition-transform !text-gray-400 cursor-pointer hover:(!text-gray-500 bg-gray-100)"
/> />
<template #overlay> <template #overlay>
<a-menu> <NcMenu>
<a-menu-item @click="removeCollaborator(collab.id)"> <NcMenuItem class="!text-red-500 !hover:bg-red-50" @click="removeCollaborator(collab.id)">
<div class="flex flex-row items-center py-2 text-s gap-1.5 text-red-500 cursor-pointer"> <MaterialSymbolsDeleteOutlineRounded />
<MaterialSymbolsDeleteOutlineRounded /> Remove user
Remove user </NcMenuItem>
</div> </NcMenu>
</a-menu-item>
</a-menu>
</template> </template>
</a-dropdown> </NcDropdown>
</div> </div>
</td> </td>
<td class="w-1/5 padding"></td> <td class="w-1/5 padding"></td>

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

@ -1,28 +1,106 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onKeyStroke } from '@vueuse/core'
import { OrderedWorkspaceRoles, WorkspaceUserRoles } from 'nocodb-sdk' import { OrderedWorkspaceRoles, WorkspaceUserRoles } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, useWorkspace } from '#imports' import { extractSdkResponseErrorMsg, useWorkspace } from '#imports'
import { validateEmail } from '~/utils/validation'
const inviteData = reactive({ const inviteData = reactive({
email: '', email: '',
roles: WorkspaceUserRoles.VIEWER, roles: WorkspaceUserRoles.VIEWER,
}) })
const focusRef = ref<HTMLInputElement>()
const isDivFocused = ref(false)
const divRef = ref<HTMLDivElement>()
const emailValidation = reactive({
isError: false,
message: '',
})
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()
const { inviteCollaborator: _inviteCollaborator } = workspaceStore const { inviteCollaborator: _inviteCollaborator } = workspaceStore
const { isInvitingCollaborators } = storeToRefs(workspaceStore) const { isInvitingCollaborators } = storeToRefs(workspaceStore)
const { workspaceRoles } = useRoles() const { workspaceRoles } = useRoles()
// all user input emails are stored here
const emailBadges = ref<Array<string>>([])
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
}
watch(inviteData, (newVal) => {
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 = ''
}
if (!newVal.email.length && emailValidation.isError) {
emailValidation.isError = false
}
})
const handleEnter = () => {
const isEmailIsValid = emailInputValidation(inviteData.email)
if (!isEmailIsValid) return
inviteData.email += ' '
emailValidation.isError = false
emailValidation.message = ''
}
const inviteCollaborator = async () => { const inviteCollaborator = async () => {
try { try {
await _inviteCollaborator(inviteData.email, inviteData.roles) const payloadData = emailBadges.value.join(',')
await _inviteCollaborator(payloadData, inviteData.roles)
message.success('Invitation sent successfully') message.success('Invitation sent successfully')
inviteData.email = '' inviteData.email = ''
emailBadges.value = []
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
} }
// allow only lower roles to be assigned // allow only lower roles to be assigned
const allowedRoles = ref<WorkspaceUserRoles[]>([]) const allowedRoles = ref<WorkspaceUserRoles[]>([])
@ -38,42 +116,110 @@ onMounted(async () => {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
}) })
const focusOnDiv = () => {
focusRef.value?.focus()
isDivFocused.value = true
}
// remove one email per backspace
onKeyStroke('Backspace', () => {
if (isDivFocused.value && inviteData.email.length < 1) {
emailBadges.value.pop()
}
})
// when bulk email is pasted
const onPaste = (e: ClipboardEvent) => {
const pastedText = e.clipboardData?.getData('text')
const inputArray = pastedText?.split(',') || pastedText?.split(' ')
// if data is pasted to a already existing text in input
// we add existingInput + pasted data
if (inputArray?.length === 1 && inviteData.email.length > 1) {
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 = ''
}
</script> </script>
<template> <template>
<div class="my-2 pt-3 ml-2" data-testid="invite"> <div class="my-2 pt-3 ml-2" data-testid="invite">
<div class="text-xl mb-4">Invite</div> <div class="flex gap-2">
<a-form> <div class="flex flex-col">
<div class="flex gap-2"> <div
<a-input ref="divRef"
id="email" class="flex w-130 border-1 gap-1 items-center flex-wrap min-h-8 max-h-30 rounded-lg nc-scrollbar-md"
v-model:value="inviteData.email" tabindex="0"
placeholder="Enter emails to send invitation" :class="{
class="!max-w-130 !rounded" 'border-primary/100': isDivFocused,
/> 'p-1': emailBadges.length > 1,
}"
<RolesSelector @click="focusOnDiv"
class="px-1" @blur="isDivFocused = false"
:role="inviteData.roles"
:roles="allowedRoles"
:on-role-change="(role: WorkspaceUserRoles) => (inviteData.roles = role)"
:description="true"
/>
<a-button
type="primary"
class="!rounded-md"
:disabled="!inviteData.email?.length || isInvitingCollaborators"
@click="inviteCollaborator"
> >
<div class="flex flex-row items-center gap-x-2 pr-1"> <span
<GeneralLoader v-if="isInvitingCollaborators" class="flex" /> v-for="(email, index) in emailBadges"
<MdiPlus v-else /> :key="email"
{{ isInvitingCollaborators ? 'Adding' : 'Add' }} User/s class="leading-4 border-1 text-brand-500 bg-brand-50 rounded-md ml-1 p-0.5"
</div> >
</a-button> {{ 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-50 outline-0 ml-2 mr-3"
data-testid="email-input"
@keyup.enter="handleEnter"
@blur="isDivFocused = false"
@paste.prevent="onPaste"
/>
</div>
<span v-if="emailValidation.isError" class="ml-2 text-red-500 text-[10px] mt-1.5">{{ emailValidation.message }}</span>
</div> </div>
</a-form> <RolesSelector
size="md"
class="px-1"
:role="inviteData.roles"
:roles="allowedRoles"
:on-role-change="(role: WorkspaceUserRoles) => (inviteData.roles = role)"
:description="true"
/>
<NcButton
type="primary"
size="small"
:disabled="!emailBadges.length || isInvitingCollaborators || emailValidation.isError"
:loading="isInvitingCollaborators"
@click="inviteCollaborator"
>
<MdiPlus />
{{ isInvitingCollaborators ? 'Adding' : 'Add' }} Member(s)
</NcButton>
</div>
</div> </div>
</template> </template>

2
packages/nc-gui/components/workspace/View.vue

@ -67,7 +67,7 @@ onMounted(() => {
<template #tab> <template #tab>
<div class="flex flex-row items-center px-2 pb-1 gap-x-1.5"> <div class="flex flex-row items-center px-2 pb-1 gap-x-1.5">
<PhUsersBold /> <PhUsersBold />
Collaborators Members
</div> </div>
</template> </template>
<WorkspaceCollaboratorsList /> <WorkspaceCollaboratorsList />

1
packages/nc-gui/utils/iconUtils.ts

@ -233,6 +233,7 @@ import MaterialSymbolsBlock from '~icons/material-symbols/block'
export const iconMap = { export const iconMap = {
workspaceDefault: MsGroup, workspaceDefault: MsGroup,
search: NcSearch, search: NcSearch,
error: h('span', { class: 'material-symbols' }, 'error'),
info: h(MsInfo, {}, () => 'info'), info: h(MsInfo, {}, () => 'info'),
inbox: h('span', { class: 'material-symbols' }, 'inbox'), inbox: h('span', { class: 'material-symbols' }, 'inbox'),
addOutlineBox: MsAddBoxOutline, addOutlineBox: MsAddBoxOutline,

2
tests/playwright/pages/Dashboard/BulkUpdate/index.ts

@ -173,7 +173,7 @@ export class BulkUpdatePage extends BasePage {
awaitResponse?: boolean; awaitResponse?: boolean;
} = {}) { } = {}) {
await this.bulkUpdateButton.click(); await this.bulkUpdateButton.click();
const confirmModal = this.rootPage.locator('.ant-modal-content'); const confirmModal = this.rootPage.locator('.ant-modal');
const saveRowAction = () => confirmModal.locator('.ant-btn-primary').click(); const saveRowAction = () => confirmModal.locator('.ant-btn-primary').click();
if (!awaitResponse) { if (!awaitResponse) {

3
tests/playwright/pages/WorkspacePage/CollaborationPage.ts

@ -36,6 +36,7 @@ export class CollaborationPage extends BasePage {
// email // email
await this.input_email.fill(email); await this.input_email.fill(email);
await this.rootPage.keyboard.press('Enter');
// role // role
await this.selector_role.click(); await this.selector_role.click();
@ -46,7 +47,7 @@ export class CollaborationPage extends BasePage {
// allow button to be enabled // allow button to be enabled
await this.rootPage.waitForTimeout(500); await this.rootPage.waitForTimeout(500);
await this.rootPage.keyboard.press('Enter');
await this.button_addUser.click(); await this.button_addUser.click();
await this.verifyToast({ message: 'Invitation sent successfully' }); await this.verifyToast({ message: 'Invitation sent successfully' });
await this.rootPage.waitForTimeout(500); await this.rootPage.waitForTimeout(500);

2
tests/playwright/pages/WorkspacePage/ContainerPage.ts

@ -55,7 +55,7 @@ export class ContainerPage extends BasePage {
// tabs // tabs
this.projects = this.get().locator('.ant-tabs-tab:has-text("Projects")'); this.projects = this.get().locator('.ant-tabs-tab:has-text("Projects")');
this.collaborators = this.get().locator('.ant-tabs-tab:has-text("Collaborators")'); this.collaborators = this.get().locator('.ant-tabs-tab:has-text("Members")');
this.billing = this.get().locator('.ant-tabs-tab:has-text("Billing")'); this.billing = this.get().locator('.ant-tabs-tab:has-text("Billing")');
this.settings = this.get().locator('.ant-tabs-tab:has-text("Settings")'); this.settings = this.get().locator('.ant-tabs-tab:has-text("Settings")');

Loading…
Cancel
Save