Browse Source

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

feat: email badging on bulk email send
pull/6447/head
Raju Udava 12 months 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>
<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 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>

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

@ -95,13 +95,13 @@ watch(showShareModal, (val) => {
: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 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" />
</div>
<template v-else-if="formStatus === 'project-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" :style="{ fontWeight: 500 }">Collaborators added</div>
<div class="flex text-base font-medium">Members added</div>
<div class="flex">
<MdiCheck />
</div>

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

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

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

@ -50,6 +50,7 @@ const onChange = (value: string) => {
<style lang="scss">
.nc-select.ant-select {
height: fit-content;
.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);
@apply border-1 border-gray-200 !rounded-lg;

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

@ -155,14 +155,12 @@ onMounted(async () => {
<template>
<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">
<GeneralLoader size="xlarge" />
</div>
<template v-else>
<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>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template>
@ -176,7 +174,7 @@ onMounted(async () => {
v-else-if="!collaborators?.length"
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 v-else class="nc-collaborators-list nc-scrollbar-md">
<div class="nc-collaborators-list-header">

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

@ -9,6 +9,78 @@ const inviteData = reactive({
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 { inviteUser } = useManageUsers()
@ -31,18 +103,28 @@ const inviteCollaborator = async () => {
isInvitingCollaborators.value = true
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.roles = inviteData.roles
if (usersData.value) {
message.success('Invitation sent successfully')
inviteData.email = ''
emailBadges.value = []
emit('invited')
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
inviteData.email = ''
isInvitingCollaborators.value = false
}
isInvitingCollaborators.value = false
}
const inviteUrl = computed(() =>
@ -76,11 +158,60 @@ const copyUrl = async () => {
// Copied shareable base url to clipboard!
message.success(t('msg.success.shareableURLCopied'))
inviteData.email = ''
emailBadges.value = []
} catch (e: any) {
message.error(e.message)
}
$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>
<template>
@ -128,12 +259,44 @@ const copyUrl = async () => {
<div class="text-xl mb-4">Invite</div>
<a-form>
<div class="flex gap-2">
<a-input
id="email"
v-model:value="inviteData.email"
placeholder="Enter emails to send invitation"
class="!max-w-130 !rounded"
/>
<div class="flex flex-col">
<div
ref="divRef"
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"
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
class="px-1"
@ -146,13 +309,13 @@ const copyUrl = async () => {
<a-button
type="primary"
class="!rounded-md"
:disabled="!inviteData.email?.length || isInvitingCollaborators"
:disabled="!emailBadges.length || isInvitingCollaborators || emailValidation.isError"
@click="inviteCollaborator"
>
<div class="flex flex-row items-center gap-x-2 pr-1">
<GeneralLoader v-if="isInvitingCollaborators" class="flex" />
<MdiPlus v-else />
{{ isInvitingCollaborators ? 'Adding' : 'Add' }} User/s
{{ isInvitingCollaborators ? 'Adding' : 'Add' }} User(s)
</div>
</a-button>
</div>

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

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

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

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

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

@ -10,26 +10,29 @@ const props = withDefaults(
description?: boolean
inherit?: string
onRoleChange: (role: keyof typeof RoleLabels) => void
size: 'sm' | 'md'
}>(),
{
description: true,
size: 'sm',
},
)
const roleRef = toRef(props, 'role')
const inheritRef = toRef(props, 'inherit')
const descriptionRef = toRef(props, 'description')
const sizeRef = toRef(props, 'size')
</script>
<template>
<NcDropdown>
<RolesBadge class="border-1" data-testid="roles" :role="roleRef" :inherit="inheritRef === role" clickable />
<NcDropdown size="lg" class="nc-roles-selector">
<RolesBadge data-testid="roles" :role="roleRef" :inherit="inheritRef === role" clickable :size="sizeRef" />
<template #overlay>
<div class="nc-role-select-dropdown flex flex-col gap-[4px] p-1">
<div class="flex flex-col gap-[4px]">
<div class="nc-role-select-dropdown flex flex-col gap-1 p-2">
<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
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="{
'w-[350px]': descriptionRef,
'w-[200px]': !descriptionRef,

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

@ -44,18 +44,18 @@ onMounted(async () => {
<template>
<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 flex flex-row justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2">
<div class="text-xl">Collaborators</div>
<a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md mr-4" placeholder="Search collaborators">
<!-- <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="text-xl">Invite Members By Email</div>
<a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md mr-4" placeholder="Search members">
<template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template>
</a-input>
</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">
<a-empty description="No collaborators found" />
<Empty description="No members found" />
</div>
<table v-else class="nc-collaborators-list-table !nc-scrollbar-md">
<thead>
@ -98,21 +98,19 @@ onMounted(async () => {
</td>
<td class="w-1/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
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>
<a-menu>
<a-menu-item @click="removeCollaborator(collab.id)">
<div class="flex flex-row items-center py-2 text-s gap-1.5 text-red-500 cursor-pointer">
<MaterialSymbolsDeleteOutlineRounded />
Remove user
</div>
</a-menu-item>
</a-menu>
<NcMenu>
<NcMenuItem class="!text-red-500 !hover:bg-red-50" @click="removeCollaborator(collab.id)">
<MaterialSymbolsDeleteOutlineRounded />
Remove user
</NcMenuItem>
</NcMenu>
</template>
</a-dropdown>
</NcDropdown>
</div>
</td>
<td class="w-1/5 padding"></td>

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

@ -1,28 +1,106 @@
<script lang="ts" setup>
import { onKeyStroke } from '@vueuse/core'
import { OrderedWorkspaceRoles, WorkspaceUserRoles } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, useWorkspace } from '#imports'
import { validateEmail } from '~/utils/validation'
const inviteData = reactive({
email: '',
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 { inviteCollaborator: _inviteCollaborator } = workspaceStore
const { isInvitingCollaborators } = storeToRefs(workspaceStore)
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 () => {
try {
await _inviteCollaborator(inviteData.email, inviteData.roles)
const payloadData = emailBadges.value.join(',')
await _inviteCollaborator(payloadData, inviteData.roles)
message.success('Invitation sent successfully')
inviteData.email = ''
emailBadges.value = []
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
// allow only lower roles to be assigned
const allowedRoles = ref<WorkspaceUserRoles[]>([])
@ -38,42 +116,110 @@ onMounted(async () => {
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>
<template>
<div class="my-2 pt-3 ml-2" data-testid="invite">
<div class="text-xl mb-4">Invite</div>
<a-form>
<div class="flex gap-2">
<a-input
id="email"
v-model:value="inviteData.email"
placeholder="Enter emails to send invitation"
class="!max-w-130 !rounded"
/>
<RolesSelector
class="px-1"
: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 gap-2">
<div class="flex flex-col">
<div
ref="divRef"
class="flex w-130 border-1 gap-1 items-center flex-wrap min-h-8 max-h-30 rounded-lg nc-scrollbar-md"
tabindex="0"
:class="{
'border-primary/100': isDivFocused,
'p-1': emailBadges.length > 1,
}"
@click="focusOnDiv"
@blur="isDivFocused = false"
>
<div class="flex flex-row items-center gap-x-2 pr-1">
<GeneralLoader v-if="isInvitingCollaborators" class="flex" />
<MdiPlus v-else />
{{ isInvitingCollaborators ? 'Adding' : 'Add' }} User/s
</div>
</a-button>
<span
v-for="(email, index) in emailBadges"
:key="email"
class="leading-4 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 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>
</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>
</template>

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

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

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

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

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

@ -173,7 +173,7 @@ export class BulkUpdatePage extends BasePage {
awaitResponse?: boolean;
} = {}) {
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();
if (!awaitResponse) {

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

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

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

@ -55,7 +55,7 @@ export class ContainerPage extends BasePage {
// tabs
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.settings = this.get().locator('.ant-tabs-tab:has-text("Settings")');

Loading…
Cancel
Save