feat(fe): Show workspace invitations on onboarding join page

feat(fe): Show workspace invitations on onboarding join page
This commit is contained in:
andrewwallacespeckle
2025-06-19 16:13:06 +02:00
committed by GitHub
5 changed files with 172 additions and 13 deletions
@@ -34,7 +34,7 @@
<script setup lang="ts">
const props = defineProps<{
logo: string
logo?: string
name: string
clickable?: boolean
bannerText?: string | null
@@ -24,6 +24,13 @@
<p class="text-center text-body-sm text-foreground-2 mb-8">
{{ description }}
</p>
<WorkspaceInviteCard
v-for="invite in localInvites"
:key="`invite-${invite.id}`"
:invite="invite"
:is-accepted="invite.isAccepted"
@accepted="onInviteAccepted"
/>
<WorkspaceDiscoverableWorkspacesCard
v-for="workspace in workspacesToShow"
:key="`discoverable-${workspace.id}`"
@@ -34,17 +41,20 @@
@request="moveToTop(workspace.id, WorkspaceJoinRequestStatus.Pending)"
/>
<FormButton
v-if="!showAllWorkspaces && discoverableWorkspacesAndJoinRequestsCount > 3"
v-if="!showAllWorkspaces && totalWorkspaceItems > 3"
color="subtle"
size="lg"
full-width
@click="showAllWorkspaces = true"
>
Show all ({{ discoverableWorkspacesAndJoinRequestsCount }})
Show all ({{ totalWorkspaceItems }})
</FormButton>
<div class="mt-2 w-full flex flex-col gap-2">
<FormButton
v-if="hasDiscoverableJoinRequests && !isWorkspaceNewPlansEnabled"
v-if="
(hasDiscoverableJoinRequests || hasWorkspaceInvites) &&
!isWorkspaceNewPlansEnabled
"
size="lg"
full-width
color="primary"
@@ -65,7 +75,11 @@
Continue to workspace
</FormButton>
<FormButton
v-if="!hasDiscoverableJoinRequests && !isWorkspaceNewPlansEnabled"
v-if="
!hasDiscoverableJoinRequests &&
!hasWorkspaceInvites &&
!isWorkspaceNewPlansEnabled
"
size="lg"
full-width
color="subtle"
@@ -84,6 +98,8 @@ import { workspaceCreateRoute, homeRoute } from '~~/lib/common/helpers/route'
import { useDiscoverableWorkspaces } from '~/lib/workspaces/composables/discoverableWorkspaces'
import type { DiscoverableWorkspace_LimitedWorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import { WorkspaceJoinRequestStatus } from '~/lib/common/generated/gql/graphql'
import { useQuery } from '@vue/apollo-composable'
import { navigationWorkspaceInvitesQuery } from '~~/lib/navigation/graphql/queries'
const { logout } = useAuthManager()
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
@@ -94,16 +110,47 @@ const {
hasDiscoverableJoinRequests
} = useDiscoverableWorkspaces()
const { result: workspaceInviteResult } = useQuery(navigationWorkspaceInvitesQuery)
const showAllWorkspaces = ref(false)
const actionedInvites = ref<
{
id: string
invite: (typeof workspaceInvites.value)[0]
status: 'accepted' | 'declined'
}[]
>([])
const workspaceInvites = computed(() => {
return workspaceInviteResult.value?.activeUser?.workspaceInvites || []
})
const remainingInvites = computed(() => {
const actionedIds = new Set(actionedInvites.value.map((a) => a.id))
return workspaceInvites.value.filter((invite) => !actionedIds.has(invite.id))
})
const localInvites = computed(() => [
...actionedInvites.value
.filter((a) => a.status === 'accepted')
.map((a) => ({ ...a.invite, isAccepted: true })),
...remainingInvites.value.map((invite) => ({ ...invite, isAccepted: false }))
])
const actionedWorkspaces = ref<
(DiscoverableWorkspace_LimitedWorkspaceFragment & { requestStatus: string | null })[]
>([])
const remainingWorkspaces = computed(() => {
const actionedIds = new Set(actionedWorkspaces.value.map((w) => w.id))
const inviteWorkspaceIds = new Set(
localInvites.value.map((invite) => invite.workspaceId)
)
return (discoverableWorkspacesAndJoinRequests.value || []).filter(
(workspace) => !actionedIds.has(workspace.id)
(workspace) =>
!actionedIds.has(workspace.id) && !inviteWorkspaceIds.has(workspace.id)
)
})
@@ -112,20 +159,44 @@ const localWorkspaces = computed(() => [
...remainingWorkspaces.value
])
const hasApprovedWorkspace = computed(() =>
localWorkspaces.value.some(
const hasApprovedWorkspace = computed(() => {
const hasApprovedDiscoverable = localWorkspaces.value.some(
(workspace) => workspace.requestStatus === WorkspaceJoinRequestStatus.Approved
)
)
const hasAcceptedInvite = localInvites.value.some((invite) => invite.isAccepted)
return hasApprovedDiscoverable || hasAcceptedInvite
})
const hasWorkspaceInvites = computed(() => localInvites.value.length > 0)
const workspacesToShow = computed(() => {
return showAllWorkspaces.value
? localWorkspaces.value
: localWorkspaces.value.slice(0, 3)
if (showAllWorkspaces.value) {
return localWorkspaces.value
}
// Show up to 3 total cards (invites + discoverable workspaces)
const inviteCount = localInvites.value.length
const remainingSlots = Math.max(0, 3 - inviteCount)
return localWorkspaces.value.slice(0, remainingSlots)
})
const totalWorkspaceItems = computed(() => {
return localInvites.value.length + discoverableWorkspacesAndJoinRequestsCount.value
})
const description = computed(() => {
if (discoverableWorkspacesAndJoinRequestsCount.value === 1) {
const inviteCount = localInvites.value.length
const discoverableCount = discoverableWorkspacesAndJoinRequestsCount.value
if (inviteCount > 0 && discoverableCount > 0) {
return 'You have workspace invitations and we found workspaces that match your email domain'
} else if (inviteCount > 0) {
return inviteCount === 1
? 'You have a workspace invitation'
: 'You have workspace invitations'
} else if (discoverableCount === 1) {
return 'We found a workspace that matches your email domain'
}
return 'We found workspaces that match your email domain'
@@ -140,4 +211,16 @@ const moveToTop = (workspaceId: string, newStatus: WorkspaceJoinRequestStatus) =
})
}
}
const onInviteAccepted = (inviteId: string) => {
const invite = localInvites.value.find((inv) => inv.id === inviteId)
if (invite) {
actionedInvites.value.unshift({
id: inviteId,
invite,
status: 'accepted'
})
}
}
</script>
@@ -0,0 +1,67 @@
<template>
<WorkspaceCard
:name="invite.workspaceName"
:class="isAccepted ? '' : 'bg-foundation'"
:banner-text="`${invite.invitedBy.name} invited you to join this workspace`"
>
<template #actions>
<FormButton
v-if="isAccepted"
color="outline"
size="sm"
:icon-left="CheckIcon"
disabled
>
Workspace joined
</FormButton>
<div v-else class="flex flex-col gap-2 sm:items-end">
<FormButton color="primary" size="sm" :disabled="loading" @click="onAccept">
Accept invitation
</FormButton>
</div>
</template>
</WorkspaceCard>
</template>
<script setup lang="ts">
import { graphql } from '~/lib/common/generated/gql'
import type { WorkspaceInviteCard_PendingWorkspaceCollaboratorFragment } from '~/lib/common/generated/gql/graphql'
import { useWorkspaceInviteManager } from '~/lib/workspaces/composables/management'
import { CheckIcon } from '@heroicons/vue/20/solid'
graphql(`
fragment WorkspaceInviteCard_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {
id
workspaceId
workspaceSlug
workspaceName
invitedBy {
id
name
}
}
`)
const props = defineProps<{
invite: WorkspaceInviteCard_PendingWorkspaceCollaboratorFragment
isAccepted?: boolean
}>()
const emit = defineEmits<{
(e: 'accepted', inviteId: string): void
}>()
const { loading, accept } = useWorkspaceInviteManager(
{
invite: computed(() => props.invite)
},
{
preventRedirect: true
}
)
const onAccept = async () => {
emit('accepted', props.invite.id)
await accept()
}
</script>
@@ -161,6 +161,7 @@ type Documents = {
"\n fragment WorkspaceDashboardProjectList_Workspace on Workspace {\n ...WorkspaceAddProjectMenu_Workspace\n id\n }\n": typeof types.WorkspaceDashboardProjectList_WorkspaceFragmentDoc,
"\n fragment WorkspaceInviteBanner_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": typeof types.WorkspaceInviteBanner_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspaceInviteBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceName\n token\n user {\n id\n name\n ...LimitedUserAvatar\n }\n title\n email\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": typeof types.WorkspaceInviteBlock_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspaceInviteCard_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceSlug\n workspaceName\n invitedBy {\n id\n name\n }\n }\n": typeof types.WorkspaceInviteCard_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspaceJoinRequestApproveDialog_WorkspaceJoinRequest on WorkspaceJoinRequest {\n id\n user {\n id\n name\n }\n workspace {\n id\n }\n }\n": typeof types.WorkspaceJoinRequestApproveDialog_WorkspaceJoinRequestFragmentDoc,
"\n fragment WorkspaceMoveProjectManager_ProjectBase on Project {\n id\n name\n modelCount: models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n }\n": typeof types.WorkspaceMoveProjectManager_ProjectBaseFragmentDoc,
"\n fragment WorkspaceMoveProjectManager_Project on Project {\n ...WorkspaceMoveProjectManager_ProjectBase\n permissions {\n canMoveToWorkspace(workspaceId: $workspaceId) {\n ...FullPermissionCheckResult\n }\n }\n workspace {\n id\n slug\n permissions {\n canMoveProjectToWorkspace(projectId: $projectId) {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": typeof types.WorkspaceMoveProjectManager_ProjectFragmentDoc,
@@ -611,6 +612,7 @@ const documents: Documents = {
"\n fragment WorkspaceDashboardProjectList_Workspace on Workspace {\n ...WorkspaceAddProjectMenu_Workspace\n id\n }\n": types.WorkspaceDashboardProjectList_WorkspaceFragmentDoc,
"\n fragment WorkspaceInviteBanner_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.WorkspaceInviteBanner_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspaceInviteBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceName\n token\n user {\n id\n name\n ...LimitedUserAvatar\n }\n title\n email\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.WorkspaceInviteBlock_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspaceInviteCard_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceSlug\n workspaceName\n invitedBy {\n id\n name\n }\n }\n": types.WorkspaceInviteCard_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspaceJoinRequestApproveDialog_WorkspaceJoinRequest on WorkspaceJoinRequest {\n id\n user {\n id\n name\n }\n workspace {\n id\n }\n }\n": types.WorkspaceJoinRequestApproveDialog_WorkspaceJoinRequestFragmentDoc,
"\n fragment WorkspaceMoveProjectManager_ProjectBase on Project {\n id\n name\n modelCount: models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n }\n": types.WorkspaceMoveProjectManager_ProjectBaseFragmentDoc,
"\n fragment WorkspaceMoveProjectManager_Project on Project {\n ...WorkspaceMoveProjectManager_ProjectBase\n permissions {\n canMoveToWorkspace(workspaceId: $workspaceId) {\n ...FullPermissionCheckResult\n }\n }\n workspace {\n id\n slug\n permissions {\n canMoveProjectToWorkspace(projectId: $projectId) {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": types.WorkspaceMoveProjectManager_ProjectFragmentDoc,
@@ -1516,6 +1518,10 @@ export function graphql(source: "\n fragment WorkspaceInviteBanner_PendingWorks
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment WorkspaceInviteBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceName\n token\n user {\n id\n name\n ...LimitedUserAvatar\n }\n title\n email\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n"): (typeof documents)["\n fragment WorkspaceInviteBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceName\n token\n user {\n id\n name\n ...LimitedUserAvatar\n }\n title\n email\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment WorkspaceInviteCard_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceSlug\n workspaceName\n invitedBy {\n id\n name\n }\n }\n"): (typeof documents)["\n fragment WorkspaceInviteCard_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceSlug\n workspaceName\n invitedBy {\n id\n name\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long