Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(fe2): invite + list workspace invites #2629

Merged
merged 14 commits into from
Aug 12, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
color="foundation"
placeholder="Search Functions..."
show-clear
:model-value="bind.modelValue.value"
full-width
v-bind="bind"
v-on="on"
/>
<div class="mt-4">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
name="search"
placeholder="Search functions..."
show-clear
:model-value="bind.modelValue.value"
color="foundation"
v-bind="bind"
v-on="on"
/>
<FormButton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const { on, bind } = useDebouncedTextInput({
debouncedBy: 2000,
isBasicHtmlInput: true
})
const visibleDescription = computed(() => bind.modelValue.value)
const visibleDescription = computed(() => bind.value.modelValue)

const descriptionInputClasses = computed(() => [
'normal placeholder:text-foreground-2 text-foreground-2 focus:text-foreground',
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend-2/components/common/editable/Title.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const { on, bind } = useDebouncedTextInput({
isBasicHtmlInput: true,
submitOnEnter: true
})
const visibleTitle = computed(() => bind.modelValue.value)
const visibleTitle = computed(() => bind.value.modelValue)

const titleInputClasses = computed(() => {
const classParts = [
Expand Down
8 changes: 7 additions & 1 deletion packages/frontend-2/components/header/NavBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<PortalTarget name="primary-actions"></PortalTarget>
</ClientOnly>
<!-- Notifications dropdown -->
<HeaderNavNotifications />
<HeaderNavNotifications v-if="hasNotifications" />
<FormButton
v-if="!activeUser"
:to="loginUrl.fullPath"
Expand Down Expand Up @@ -59,4 +59,10 @@ const loginUrl = computed(() =>
}
})
)

const hasNotifications = computed(() => {
if (!activeUser.value) return false
if (!activeUser.value?.verified) return true
return false
})
</script>
12 changes: 2 additions & 10 deletions packages/frontend-2/components/header/NavNotifications.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
<template>
<div v-if="hasNotifications">
<div>
<Menu as="div" class="flex items-center">
<MenuButton :id="menuButtonId" v-slot="{ open: menuOpen }" as="div">
<div class="cursor-pointer">
<span class="sr-only">Open notifications menu</span>
<div class="relative">
<div v-if="hasNotifications && !menuOpen" class="scale-75">
<div v-if="!menuOpen" class="scale-75">
<div
class="absolute top-1 right-1 w-3 h-3 rounded-full bg-primary animate-ping"
></div>
Expand Down Expand Up @@ -45,14 +45,6 @@
<script setup lang="ts">
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { XMarkIcon, BellIcon } from '@heroicons/vue/24/outline'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'

const { activeUser } = useActiveUser()
const menuButtonId = useId()

const hasNotifications = computed(() => {
if (!activeUser.value) return false
if (!activeUser.value?.verified) return true
return false
})
</script>
96 changes: 36 additions & 60 deletions packages/frontend-2/components/project/page/InviteDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
</template>
</FormTextInput>
<div
v-if="searchUsers.length || selectedEmails?.length"
v-if="hasTargets"
class="flex flex-col border bg-foundation border-primary-muted mt-2 rounded-md"
>
<template v-if="searchUsers.length">
Expand Down Expand Up @@ -53,21 +53,21 @@
<script setup lang="ts">
import { Roles } from '@speckle/shared'
import type { ServerRoles, StreamRoles } from '@speckle/shared'
import { useUserSearch } from '~~/lib/common/composables/users'
import type { UserSearchItem } from '~~/lib/common/composables/users'
import type {
ProjectInviteCreateInput,
ProjectPageInviteDialog_ProjectFragment
} from '~~/lib/common/generated/gql/graphql'
import type { SetFullyRequired } from '~~/lib/common/helpers/type'
import { isEmail } from '~~/lib/common/helpers/validation'
import { isArray, isString } from 'lodash-es'
import { isString } from 'lodash-es'
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
import { useTeamInternals } from '~~/lib/projects/composables/team'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useServerInfo } from '~~/lib/core/composables/server'
import { graphql } from '~/lib/common/generated/gql/gql'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useResolveInviteTargets } from '~/lib/server/composables/invites'
import { filterInvalidInviteTargets } from '~/lib/workspaces/helpers/invites'

graphql(`
fragment ProjectPageInviteDialog_Project on Project {
Expand All @@ -85,26 +85,31 @@ const props = defineProps<{
}>()

const isOpen = defineModel<boolean>('open', { required: true })
const mp = useMixpanel()

const projectId = computed(() => props.projectId as string)
const projectData = computed(() => props.project)
const { collaboratorListItems } = useTeamInternals(projectData)

const loading = ref(false)
const search = ref('')
const role = ref<StreamRoles>(Roles.Stream.Contributor)

const { isGuestMode } = useServerInfo()
const createInvite = useInviteUserToProject()
const { userSearch, searchVariables } = useUserSearch({
variables: computed(() => ({
query: search.value,
limit: 5
}))
const {
users: searchUsers,
emails: selectedEmails,
hasTargets
} = useResolveInviteTargets({
search,
excludeUserIds: computed(() =>
collaboratorListItems.value
.filter((i): i is SetFullyRequired<typeof i, 'user'> => !!i.user?.id)
.map((t) => t.user.id)
)
})

const projectId = computed(() => props.projectId as string)

const projectData = computed(() => props.project)

const { collaboratorListItems } = useTeamInternals(projectData)

const dialogButtons = computed<LayoutDialogButton[]>(() => [
{
text: 'Cancel',
Expand All @@ -115,58 +120,29 @@ const dialogButtons = computed<LayoutDialogButton[]>(() => [
}
])

const selectedEmails = computed(() => {
const query = searchVariables.value?.query || ''
if (isValidEmail(query)) return [query]

const multipleEmails = query.split(',').map((i) => i.trim())
const validEmails = multipleEmails.filter((e) => isValidEmail(e))
return validEmails.length ? validEmails : null
})

const isOwnerSelected = computed(() => role.value === Roles.Stream.Owner)

const searchUsers = computed(() => {
const searchResults = userSearch.value?.userSearch.items || []
const collaboratorIds = new Set(
collaboratorListItems.value
.filter((i): i is SetFullyRequired<typeof i, 'user'> => !!i.user?.id)
.map((t) => t.user.id)
)
return searchResults.filter((r) => !collaboratorIds.has(r.id))
})

const isValidEmail = (val: string) =>
isEmail(val || '', {
field: '',
value: '',
form: {}
}) === true
? true
: false

const mp = useMixpanel()
const onInviteUser = async (
user: InvitableUser | InvitableUser[],
serverRole?: ServerRoles
) => {
const users = (isArray(user) ? user : [user]).filter(
(u) => !isOwnerSelected.value || isString(u) || u.role !== Roles.Server.Guest
)
serverRole = serverRole || Roles.Server.User
const users = filterInvalidInviteTargets(user, {
isTargetResourceOwner: isOwnerSelected.value,
emailTargetServerRole: serverRole
})

const inputs: ProjectInviteCreateInput[] = users
.filter((u) => (isString(u) ? isValidEmail(u) : u))
.map((u) => ({
role: role.value,
...(isString(u)
? {
email: u,
serverRole
}
: {
userId: u.id
})
}))
const inputs: ProjectInviteCreateInput[] = users.map((u) => ({
role: role.value,
...(isString(u)
? {
email: u,
serverRole
}
: {
userId: u.id
})
}))
if (!inputs.length) return

const isEmail = !!inputs.find((u) => !!u.email)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
placeholder="Search automations..."
wrapper-classes="shrink-0"
show-clear
:model-value="bind.modelValue.value"
v-bind="bind"
v-on="on"
/>
<FormButton
Expand Down
62 changes: 32 additions & 30 deletions packages/frontend-2/components/project/page/models/CardView.vue
Original file line number Diff line number Diff line change
@@ -1,35 +1,37 @@
<template>
<template v-if="itemsCount">
<ProjectModelsBasicCardView
:items="items"
:project="project"
:project-id="projectId"
:small-view="smallView"
:show-actions="showActions"
:show-versions="showVersions"
:disable-default-links="disableDefaultLinks"
@model-clicked="$emit('model-clicked', $event)"
<div>
<template v-if="itemsCount">
<ProjectModelsBasicCardView
:items="items"
:project="project"
:project-id="projectId"
:small-view="smallView"
:show-actions="showActions"
:show-versions="showVersions"
:disable-default-links="disableDefaultLinks"
@model-clicked="$emit('model-clicked', $event)"
/>
<FormButtonSecondaryViewAll
v-if="showViewAll"
class="mt-4"
:to="allProjectModelsRoute(projectId)"
/>
</template>
<template v-else-if="!areQueriesLoading">
<CommonEmptySearchState
v-if="isFiltering"
@clear-search="() => $emit('clear-search')"
/>
<div v-else>
<ProjectCardImportFileArea :project-id="projectId" class="h-36 col-span-4" />
</div>
</template>
<InfiniteLoading
v-if="items?.length && !disablePagination"
:settings="{ identifier: infiniteLoaderId }"
@infinite="infiniteLoad"
/>
<FormButtonSecondaryViewAll
v-if="showViewAll"
class="mt-4"
:to="allProjectModelsRoute(projectId)"
/>
</template>
<template v-else-if="!areQueriesLoading">
<CommonEmptySearchState
v-if="isFiltering"
@clear-search="() => $emit('clear-search')"
/>
<div v-else>
<ProjectCardImportFileArea :project-id="projectId" class="h-36 col-span-4" />
</div>
</template>
<InfiniteLoading
v-if="items?.length && !disablePagination"
:settings="{ identifier: infiniteLoaderId }"
@infinite="infiniteLoad"
/>
</div>
</template>
<script setup lang="ts">
import type {
Expand Down
72 changes: 37 additions & 35 deletions packages/frontend-2/components/project/page/models/ListView.vue
Original file line number Diff line number Diff line change
@@ -1,43 +1,45 @@
<template>
<div v-if="topLevelItems.length && project" class="space-y-2 max-w-full">
<div v-for="item in topLevelItems" :key="item.id">
<ProjectPageModelsStructureItem
:item="item"
:project="project"
:can-contribute="canContribute"
:is-search-result="isUsingSearch"
@model-updated="onModelUpdated"
@create-submodel="onCreateSubmodel"
<div>
<div v-if="topLevelItems.length && project" class="space-y-2 max-w-full">
<div v-for="item in topLevelItems" :key="item.id">
<ProjectPageModelsStructureItem
:item="item"
:project="project"
:can-contribute="canContribute"
:is-search-result="isUsingSearch"
@model-updated="onModelUpdated"
@create-submodel="onCreateSubmodel"
/>
</div>
<FormButtonSecondaryViewAll
v-if="showViewAll"
:to="allProjectModelsRoute(projectId)"
/>
</div>
<FormButtonSecondaryViewAll
v-if="showViewAll"
:to="allProjectModelsRoute(projectId)"
<template v-else-if="!areQueriesLoading">
<CommonEmptySearchState
v-if="
!topLevelItems.length &&
isFiltering &&
(baseResult?.project?.modelsTree.items || []).length === 0
"
@clear-search="$emit('clear-search')"
/>
<div v-else>
<ProjectCardImportFileArea :project-id="projectId" class="h-36 col-span-4" />
</div>
</template>
<InfiniteLoading
v-if="topLevelItems?.length && !disablePagination"
:settings="{ identifier: infiniteLoaderId }"
@infinite="infiniteLoad"
/>
</div>
<template v-else-if="!areQueriesLoading">
<CommonEmptySearchState
v-if="
!topLevelItems.length &&
isFiltering &&
(baseResult?.project?.modelsTree.items || []).length === 0
"
@clear-search="$emit('clear-search')"
<ProjectPageModelsNewDialog
v-model:open="showNewDialog"
:project-id="projectId"
:parent-model-name="newSubmodelParent || undefined"
/>
<div v-else>
<ProjectCardImportFileArea :project-id="projectId" class="h-36 col-span-4" />
</div>
</template>
<InfiniteLoading
v-if="topLevelItems?.length && !disablePagination"
:settings="{ identifier: infiniteLoaderId }"
@infinite="infiniteLoad"
/>
<ProjectPageModelsNewDialog
v-model:open="showNewDialog"
:project-id="projectId"
:parent-model-name="newSubmodelParent || undefined"
/>
</div>
</template>
<script setup lang="ts">
import type {
Expand Down
Loading