Skip to content

Commit a892b84

Browse files
committed
feat(ui): add InvitationsMenu component
1 parent d0e126b commit a892b84

File tree

14 files changed

+521
-175
lines changed

14 files changed

+521
-175
lines changed

ui/src/components/AppBar/AppBar.vue

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,17 @@
5959
<span>Need assistance? Click here for support.</span>
6060
</v-tooltip>
6161

62-
<DevicesDropdown v-if="hasNamespaces" />
62+
<DevicesDropdown
63+
v-if="hasNamespaces"
64+
v-model="showDevicesDrawer"
65+
@update:model-value="showInvitationsDrawer = false"
66+
/>
67+
68+
<InvitationsMenu
69+
v-if="isCloud"
70+
v-model="showInvitationsDrawer"
71+
@update:model-value="showDevicesDrawer = false"
72+
/>
6373

6474
<v-menu
6575
scrim
@@ -169,6 +179,7 @@ import { useChatWoot } from "@productdevbook/chatwoot/vue";
169179
import handleError from "@/utils/handleError";
170180
import UserIcon from "../User/UserIcon.vue";
171181
import DevicesDropdown from "./DevicesDropdown.vue";
182+
import InvitationsMenu from "@/components/Invitations/InvitationsMenu.vue";
172183
import PaywallChat from "../User/PaywallChat.vue";
173184
import Namespace from "@/components/Namespace/Namespace.vue";
174185
import { envVariables } from "@/envVariables";
@@ -206,6 +217,7 @@ const layoutStore = useLayoutStore();
206217
const namespacesStore = useNamespacesStore();
207218
const statsStore = useStatsStore();
208219
const supportStore = useSupportStore();
220+
const { isCommunity, isCloud, isEnterprise } = envVariables;
209221
const router = useRouter();
210222
const route = useRoute();
211223
const snackbar = useSnackbar();
@@ -221,6 +233,8 @@ const identifier = computed(() => supportStore.identifier);
221233
const isDarkMode = ref(theme.value === "dark");
222234
const chatSupportPaywall = ref(false);
223235
const showNavigationDrawer = defineModel<boolean>();
236+
const showDevicesDrawer = ref(false);
237+
const showInvitationsDrawer = ref(false);
224238
225239
const triggerClick = async (item: MenuItem) => {
226240
switch (item.type) {
@@ -301,15 +315,15 @@ const redirectToGitHub = (): void => {
301315
302316
const openShellhubHelp = async (): Promise<void> => {
303317
switch (true) {
304-
case envVariables.isCloud && isBillingActive.value:
318+
case isCloud && isBillingActive.value:
305319
await openChatwoot();
306320
break;
307321
308-
case envVariables.isCommunity || (envVariables.isCloud && !isBillingActive.value):
322+
case isCommunity || (isCloud && !isBillingActive.value):
309323
openPaywall();
310324
break;
311325
312-
case envVariables.isEnterprise:
326+
case isEnterprise:
313327
redirectToGitHub();
314328
break;
315329

ui/src/components/AppBar/DevicesDropdown.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ const statsStore = useStatsStore();
335335
const devicesStore = useDevicesStore();
336336
const snackbar = useSnackbar();
337337
338-
const isDrawerOpen = ref(false);
338+
const isDrawerOpen = defineModel<boolean>({ required: true });
339339
const activeTab = ref<"pending" | "recent">("pending");
340340
const pendingDevicesList = ref<IDevice[]>([]);
341341
const pendingDevicesCount = computed(() => pendingDevicesList.value.length);
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<template>
2+
<div>
3+
<slot :open-dialog />
4+
</div>
5+
6+
<MessageDialog
7+
v-model="showDialog"
8+
title="Accept Invitation"
9+
:description="dialogDescription"
10+
icon="mdi-account-check"
11+
icon-color="primary"
12+
confirm-text="Accept"
13+
cancel-text="Cancel"
14+
:data-test="dataTest"
15+
@confirm="handleAccept"
16+
@cancel="showDialog = false"
17+
@close="showDialog = false"
18+
/>
19+
</template>
20+
21+
<script setup lang="ts">
22+
import { computed, ref } from "vue";
23+
import MessageDialog from "@/components/Dialogs/MessageDialog.vue";
24+
import useInvitationsStore from "@/store/modules/invitations";
25+
import useAuthStore from "@/store/modules/auth";
26+
import useNamespacesStore from "@/store/modules/namespaces";
27+
import useSnackbar from "@/helpers/snackbar";
28+
import handleError from "@/utils/handleError";
29+
30+
interface Props {
31+
tenant: string;
32+
namespaceName?: string;
33+
role?: string;
34+
dataTest?: string;
35+
onSuccess?: () => void | Promise<void>;
36+
}
37+
38+
const props = defineProps<Props>();
39+
40+
const showDialog = ref(false);
41+
const invitationsStore = useInvitationsStore();
42+
const authStore = useAuthStore();
43+
const namespacesStore = useNamespacesStore();
44+
const snackbar = useSnackbar();
45+
46+
const dialogDescription = computed(() => {
47+
if (props.namespaceName && props.role) {
48+
return `You are about to accept this invitation to join ${props.namespaceName} as ${props.role}.`;
49+
}
50+
return "Accepting this invitation will allow you to collaborate with the namespace collaborators.";
51+
});
52+
53+
const openDialog = () => { showDialog.value = true; };
54+
55+
const handleAccept = async () => {
56+
try {
57+
await invitationsStore.acceptInvitation(props.tenant);
58+
59+
snackbar.showSuccess("Invitation accepted successfully");
60+
showDialog.value = false;
61+
62+
await authStore.enterInvitedNamespace(props.tenant);
63+
await namespacesStore.fetchNamespaceList();
64+
65+
if (props.onSuccess) { await props.onSuccess(); }
66+
} catch (error: unknown) {
67+
snackbar.showError("Failed to accept invitation");
68+
handleError(error);
69+
}
70+
};
71+
72+
defineExpose({ openDialog });
73+
</script>
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<template>
2+
<div>
3+
<slot
4+
:loading
5+
:open-dialog
6+
/>
7+
</div>
8+
9+
<MessageDialog
10+
v-model="showDialog"
11+
title="Decline Invitation"
12+
:description="dialogDescription"
13+
icon="mdi-account-remove"
14+
icon-color="error"
15+
confirm-text="Decline"
16+
confirm-color="error"
17+
cancel-text="Cancel"
18+
:data-test="dataTest"
19+
@confirm="handleDecline"
20+
@cancel="showDialog = false"
21+
@close="showDialog = false"
22+
/>
23+
</template>
24+
25+
<script setup lang="ts">
26+
import { computed, ref } from "vue";
27+
import MessageDialog from "@/components/Dialogs/MessageDialog.vue";
28+
import useInvitationsStore from "@/store/modules/invitations";
29+
import useSnackbar from "@/helpers/snackbar";
30+
import handleError from "@/utils/handleError";
31+
32+
interface Props {
33+
tenant: string;
34+
namespaceName?: string;
35+
dataTest?: string;
36+
onSuccess?: () => void | Promise<void>;
37+
}
38+
39+
const props = defineProps<Props>();
40+
41+
const showDialog = ref(false);
42+
const invitationsStore = useInvitationsStore();
43+
const snackbar = useSnackbar();
44+
const loading = ref(false);
45+
46+
const dialogDescription = computed(() => {
47+
if (props.namespaceName) {
48+
return `You are about to decline this invitation to join ${props.namespaceName}. This action cannot be undone.`;
49+
}
50+
return "You are about to decline this invitation. This action cannot be undone.";
51+
});
52+
53+
const openDialog = () => {
54+
showDialog.value = true;
55+
};
56+
57+
const handleDecline = async () => {
58+
try {
59+
loading.value = true;
60+
await invitationsStore.declineInvitation(props.tenant);
61+
snackbar.showSuccess("Invitation declined successfully");
62+
showDialog.value = false;
63+
64+
if (props.onSuccess) { await props.onSuccess(); }
65+
} catch (error: unknown) {
66+
snackbar.showError("Failed to decline invitation");
67+
handleError(error);
68+
} finally {
69+
loading.value = false;
70+
}
71+
};
72+
73+
defineExpose({ openDialog });
74+
</script>
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<template>
2+
<v-badge
3+
:model-value="pendingInvitationsCount > 0"
4+
:content="pendingInvitationsCount"
5+
offset-y="-4"
6+
offset-x="-4"
7+
location="top right"
8+
color="success"
9+
size="x-small"
10+
data-test="invitations-menu-badge"
11+
:class="{ 'mr-1': pendingInvitationsCount > 0 }"
12+
>
13+
<v-icon
14+
color="primary"
15+
aria-label="Open pending invitations menu"
16+
icon="mdi-email"
17+
data-test="invitations-menu-icon"
18+
@click="toggleDrawer"
19+
/>
20+
</v-badge>
21+
22+
<Teleport to="body">
23+
<v-navigation-drawer
24+
v-model="isDrawerOpen"
25+
location="right"
26+
temporary
27+
:width="thresholds.sm"
28+
class="bg-v-theme-surface"
29+
data-test="invitations-drawer"
30+
>
31+
<v-card
32+
class="bg-v-theme-surface h-100"
33+
flat
34+
data-test="invitations-card"
35+
>
36+
<v-card-title class="text-h6 py-3">Pending Invitations</v-card-title>
37+
38+
<v-card-text class="pa-4 pt-0">
39+
<div
40+
v-if="isLoading"
41+
class="d-flex justify-center align-center"
42+
style="min-height: 200px"
43+
data-test="loading-state"
44+
>
45+
<v-progress-circular indeterminate />
46+
</div>
47+
48+
<v-list
49+
v-else-if="pendingInvitationsList.length > 0"
50+
density="compact"
51+
class="bg-v-theme-surface pa-0"
52+
data-test="invitations-list"
53+
>
54+
<InvitationsMenuItem
55+
v-for="(invitation, index) in pendingInvitationsList"
56+
:key="index"
57+
:invitation="invitation"
58+
@update="fetchInvitations"
59+
/>
60+
</v-list>
61+
62+
<div
63+
v-else
64+
class="d-flex flex-column justify-center align-center text-center"
65+
style="min-height: 200px"
66+
data-test="empty-state"
67+
>
68+
<v-icon
69+
size="64"
70+
color="medium-emphasis"
71+
class="mb-4"
72+
icon="mdi-email-check-outline"
73+
/>
74+
<div class="text-body-2 text-medium-emphasis">No pending invitations</div>
75+
</div>
76+
</v-card-text>
77+
</v-card>
78+
</v-navigation-drawer>
79+
</Teleport>
80+
</template>
81+
82+
<script setup lang="ts">
83+
import { ref, computed, onBeforeMount, watch } from "vue";
84+
import { useDisplay } from "vuetify";
85+
import useSnackbar from "@/helpers/snackbar";
86+
import handleError from "@/utils/handleError";
87+
import InvitationsMenuItem from "./InvitationsMenuItem.vue";
88+
import useInvitationsStore from "@/store/modules/invitations";
89+
90+
const { thresholds } = useDisplay();
91+
const invitationsStore = useInvitationsStore();
92+
const snackbar = useSnackbar();
93+
94+
const isDrawerOpen = defineModel<boolean>({ required: true });
95+
const isLoading = ref(false);
96+
const pendingInvitationsList = computed(() => invitationsStore.pendingInvitations);
97+
const pendingInvitationsCount = computed(() => pendingInvitationsList.value.length);
98+
99+
const toggleDrawer = () => { isDrawerOpen.value = !isDrawerOpen.value; };
100+
101+
const fetchInvitations = async () => {
102+
try {
103+
isLoading.value = true;
104+
await invitationsStore.fetchUserPendingInvitationList();
105+
} catch (error: unknown) {
106+
snackbar.showError("Failed to fetch pending invitations");
107+
handleError(error);
108+
} finally {
109+
isLoading.value = false;
110+
}
111+
};
112+
113+
onBeforeMount(async () => {
114+
await fetchInvitations();
115+
});
116+
117+
watch(isDrawerOpen, async (newValue) => {
118+
if (newValue) {
119+
await fetchInvitations();
120+
}
121+
});
122+
123+
defineExpose({
124+
isDrawerOpen,
125+
pendingInvitationsList,
126+
isLoading,
127+
toggleDrawer,
128+
fetchInvitations,
129+
});
130+
</script>

0 commit comments

Comments
 (0)