From 6db7d10fd16a18b35225fd8b7e99a573db1d13cd Mon Sep 17 00:00:00 2001 From: Mike Tasset Date: Wed, 11 Dec 2024 15:00:57 +0100 Subject: [PATCH 01/10] Feat: Change server invite to new design --- .../components/header/NavUserMenu.vue | 2 +- .../components/invite/dialog/Server.vue | 174 ++++++++++++++++++ .../components/singleton/ToastManager.vue | 10 +- .../lib/common/composables/toast.ts | 91 ++++++--- .../lib/invites/helpers/constants.ts | 8 + .../frontend-2/lib/invites/helpers/types.ts | 12 ++ .../src/components/form/select/Base.vue | 2 +- .../global/ToastRenderer.stories.ts | 64 +++---- .../src/components/global/ToastRenderer.vue | 34 ++-- .../ui-components/src/helpers/global/toast.ts | 1 + .../src/stories/components/GlobalToast.vue | 10 +- .../src/stories/composables/toast.ts | 95 +++++++--- 12 files changed, 387 insertions(+), 116 deletions(-) create mode 100644 packages/frontend-2/components/invite/dialog/Server.vue create mode 100644 packages/frontend-2/lib/invites/helpers/constants.ts create mode 100644 packages/frontend-2/lib/invites/helpers/types.ts diff --git a/packages/frontend-2/components/header/NavUserMenu.vue b/packages/frontend-2/components/header/NavUserMenu.vue index 04a59f8131..4083b7035f 100644 --- a/packages/frontend-2/components/header/NavUserMenu.vue +++ b/packages/frontend-2/components/header/NavUserMenu.vue @@ -124,7 +124,7 @@ - + + + +
+
+
+
+
+
+
+ +
+ +
+ +
+
+ + + +
+
+ + Invite another user + +
+
+
+ + diff --git a/packages/frontend-2/components/singleton/ToastManager.vue b/packages/frontend-2/components/singleton/ToastManager.vue index 43499b8f70..616aea4125 100644 --- a/packages/frontend-2/components/singleton/ToastManager.vue +++ b/packages/frontend-2/components/singleton/ToastManager.vue @@ -1,6 +1,6 @@ @@ -8,13 +8,13 @@ import { useGlobalToastManager } from '~~/lib/common/composables/toast' import { GlobalToastRenderer } from '@speckle/ui-components' -const { currentNotification, dismiss } = useGlobalToastManager() +const { currentNotifications, dismissAll, dismiss } = useGlobalToastManager() -const notification = computed({ - get: () => currentNotification.value, +const notifications = computed({ + get: () => currentNotifications.value, set: (newVal) => { if (!newVal) { - dismiss() + dismissAll() } } }) diff --git a/packages/frontend-2/lib/common/composables/toast.ts b/packages/frontend-2/lib/common/composables/toast.ts index 657afacac7..58395d9ff0 100644 --- a/packages/frontend-2/lib/common/composables/toast.ts +++ b/packages/frontend-2/lib/common/composables/toast.ts @@ -1,60 +1,95 @@ -import { useTimeoutFn } from '@vueuse/core' import type { Optional } from '@speckle/shared' import type { ToastNotification } from '@speckle/ui-components' +import { useTimeoutFn } from '@vueuse/core' import { ToastNotificationType } from '@speckle/ui-components' import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie' +import { nanoid } from 'nanoid' /** * Persisting toast state between reqs and between CSR & SSR loads so that we can trigger * toasts anywhere and anytime */ const useGlobalToastState = () => - useSynchronizedCookie>('global-toast-state') + useSynchronizedCookie>('global-toast-state') /** * Set up a new global toast manager/renderer (don't use this in multiple components that live at the same time) */ export function useGlobalToastManager() { + type Timeout = { + id: string + stop: () => void + } + const stateNotification = useGlobalToastState() - const currentNotification = ref(stateNotification.value) - const readOnlyNotification = computed(() => currentNotification.value) + const timeouts = ref([]) + const currentNotifications = ref( + Array.isArray(stateNotification.value) ? stateNotification.value : [] + ) + const readOnlyNotification = computed(() => currentNotifications.value) - const dismiss = () => { - currentNotification.value = undefined - stateNotification.value = undefined + // Remove a specific notification from the state + const removeNotification = (id: string) => { + const index = currentNotifications.value.findIndex((n) => n.id === id) + if (index !== -1) { + currentNotifications.value.splice(index, 1) + // Clean up timeout + timeouts.value = timeouts.value.filter((t) => t.id !== id) + } } - const { start, stop } = useTimeoutFn(() => { - dismiss() - }, 4000) + // Create a timeout for a notification + const createTimeout = (notification: ToastNotification) => { + const { stop } = useTimeoutFn(() => { + if (notification.id) { + removeNotification(notification.id) + } + }, 4000) + return stop + } watch( stateNotification, (newVal) => { if (!newVal) return - if (import.meta.server) { - currentNotification.value = newVal - return - } + currentNotifications.value = newVal - // First dismiss old notification, then set a new one on next tick - // this is so that the old one actually disappears from the screen for the user, - // instead of just having its contents replaced - dismiss() + if (import.meta.server) return - nextTick(() => { - currentNotification.value = newVal + // Create timeout for the new notification + const index = currentNotifications.value.length - 1 + const lastNotification = newVal[index] - // (re-)init timeout - stop() - if (newVal.autoClose !== false) start() - }) + if (lastNotification && !lastNotification.autoClose) { + timeouts.value.push({ + id: lastNotification.id as string, + stop: createTimeout(lastNotification) + }) + } }, { deep: true, immediate: true } ) - return { currentNotification: readOnlyNotification, dismiss } + // Function to dismiss a specific notification + const dismiss = (notification: ToastNotification) => { + if (!notification.id) return + + const targetTimeout = timeouts.value.find((t) => t.id === notification.id) + if (targetTimeout) { + targetTimeout.stop() + } + removeNotification(notification.id as string) + } + + // Dismiss all notifications + const dismissAll = () => { + timeouts.value.forEach((timeout) => timeout.stop()) + timeouts.value = [] + currentNotifications.value = [] + } + + return { currentNotifications: readOnlyNotification, dismiss, dismissAll } } /** @@ -68,7 +103,11 @@ export function useGlobalToast() { * Trigger a new toast notification */ const triggerNotification = (notification: ToastNotification) => { - stateNotification.value = notification + const newNotification = { ...notification, id: nanoid() } + + stateNotification.value + ? stateNotification.value.push(newNotification) + : (stateNotification.value = [newNotification]) if (import.meta.server) { logger.info('Queued SSR toast notification', notification) diff --git a/packages/frontend-2/lib/invites/helpers/constants.ts b/packages/frontend-2/lib/invites/helpers/constants.ts new file mode 100644 index 0000000000..d31c358f68 --- /dev/null +++ b/packages/frontend-2/lib/invites/helpers/constants.ts @@ -0,0 +1,8 @@ +import type { InviteServerItem } from '~~/lib/invites/helpers/types' +import { Roles } from '@speckle/shared' + +export const emptyInviteServerItem: InviteServerItem = { + email: '', + serverRole: Roles.Server.User, + project: undefined +} diff --git a/packages/frontend-2/lib/invites/helpers/types.ts b/packages/frontend-2/lib/invites/helpers/types.ts new file mode 100644 index 0000000000..aeae0c16ad --- /dev/null +++ b/packages/frontend-2/lib/invites/helpers/types.ts @@ -0,0 +1,12 @@ +import type { ServerRoles } from '@speckle/shared' +import type { FormSelectProjects_ProjectFragment } from '~~/lib/common/generated/gql/graphql' + +export type InviteServerItem = { + email: string + serverRole: ServerRoles + project?: FormSelectProjects_ProjectFragment +} + +export interface InviteServerForm { + fields: InviteServerItem[] +} diff --git a/packages/ui-components/src/components/form/select/Base.vue b/packages/ui-components/src/components/form/select/Base.vue index 3f988eb635..5c72ffc5f2 100644 --- a/packages/ui-components/src/components/form/select/Base.vue +++ b/packages/ui-components/src/components/form/select/Base.vue @@ -127,7 +127,7 @@ ref="searchInput" v-model="searchValue" type="text" - class="py-1 pl-7 w-full bg-foundation placeholder:font-normal normal placeholder:text-foreground-2 text-[13px]" + class="py-1 pl-7 w-full bg-foundation placeholder:font-normal normal placeholder:text-foreground-2 text-[13px] focus-visible:[box-shadow:none] rounded-md hover:border-outline-5 focus-visible:border-outline-4" :placeholder="searchPlaceholder" @keydown.stop /> diff --git a/packages/ui-components/src/components/global/ToastRenderer.stories.ts b/packages/ui-components/src/components/global/ToastRenderer.stories.ts index ce1e1fe540..fe7b692197 100644 --- a/packages/ui-components/src/components/global/ToastRenderer.stories.ts +++ b/packages/ui-components/src/components/global/ToastRenderer.stories.ts @@ -7,7 +7,7 @@ import { ToastNotificationType } from '~~/src/helpers/global/toast' import type { ToastNotification } from '~~/src/helpers/global/toast' import { useGlobalToast } from '~~/src/stories/composables/toast' -type StoryType = StoryObj<{ notification: ToastNotification }> +type StoryType = StoryObj<{ notifications: ToastNotification[] }> export default { component: ToastRenderer, @@ -20,12 +20,11 @@ export default { } }, argTypes: { - notification: { - description: 'ToastNotification type object, nullable' + notifications: { + description: 'ToastNotification array, nullable' }, - 'update:notification': { - description: - "Notification prop update event. Enables two-way binding through 'v-model:notification'" + dismiss: { + description: 'Dismiss event for a notification' } } } as Meta @@ -35,11 +34,11 @@ export const Default: StoryType = { components: { ToastRenderer, FormButton }, setup() { const { triggerNotification } = useGlobalToast() - const notification = ref(null as Nullable) + const notifications = ref(null as Nullable) const onClick = () => { - triggerNotification(args.notification) + triggerNotification(args.notifications[0]) } - return { args, onClick, notification } + return { args, onClick, notifications } }, template: `
@@ -50,30 +49,34 @@ export const Default: StoryType = { parameters: { docs: { source: { - code: '' + code: '' } } }, args: { - notification: { - type: ToastNotificationType.Info, - title: 'Title', - description: 'Description', + notifications: [ + { + type: ToastNotificationType.Info, + title: 'Title', + description: 'Description', - cta: { - title: 'CTA' + cta: { + title: 'CTA' + } } - } + ] } } export const WithManualClose: StoryType = { ...Default, args: { - notification: { - ...Default.args!.notification!, - autoClose: false - } + notifications: [ + { + ...Default.args!.notifications![0], + autoClose: false + } + ] } } @@ -81,23 +84,20 @@ export const NoCtaOrDescription: StoryObj = { render: (args) => ({ components: { ToastRenderer, FormButton }, setup() { - const notification = ref(null as Nullable) + const { triggerNotification } = useGlobalToast() + const notifications = ref(null as Nullable) const onClick = () => { - // Update notification without cta or description - notification.value = { + triggerNotification({ type: ToastNotificationType.Info, title: 'Displays a toast notification' - } - - // Clear after 2s - setTimeout(() => (notification.value = null), 2000) + }) } - return { args, onClick, notification } + return { args, onClick, notifications } }, template: `
Trigger Title Only - +
` }), @@ -108,8 +108,8 @@ export const NoCtaOrDescription: StoryObj = { }, source: { code: ` -Trigger Title Only - + Trigger Title Only + ` } } diff --git a/packages/ui-components/src/components/global/ToastRenderer.vue b/packages/ui-components/src/components/global/ToastRenderer.vue index 01bdc1a82f..41d68a5016 100644 --- a/packages/ui-components/src/components/global/ToastRenderer.vue +++ b/packages/ui-components/src/components/global/ToastRenderer.vue @@ -5,7 +5,7 @@ >
-
@@ -60,7 +61,7 @@ color="subtle" :to="notification.cta.url" size="sm" - @click="onCtaClick" + @click="(e: MouseEvent) => onCtaClick(notification, e)" > {{ notification.cta.title }} @@ -70,7 +71,7 @@
-
+
@@ -91,29 +92,24 @@ import { InformationCircleIcon, XMarkIcon } from '@heroicons/vue/20/solid' -import { computed } from 'vue' import type { MaybeNullOrUndefined } from '@speckle/shared' import { ToastNotificationType } from '~~/src/helpers/global/toast' import type { ToastNotification } from '~~/src/helpers/global/toast' const emit = defineEmits<{ - (e: 'update:notification', val: MaybeNullOrUndefined): void + (e: 'dismiss', val: ToastNotification): void }>() -const props = defineProps<{ - notification: MaybeNullOrUndefined +defineProps<{ + notifications: MaybeNullOrUndefined }>() -const isTitleOnly = computed( - () => !props.notification?.description && !props.notification?.cta -) - -const dismiss = () => { - emit('update:notification', null) +const dismiss = (notification: ToastNotification) => { + emit('dismiss', notification) } -const onCtaClick = (e: MouseEvent) => { - props.notification?.cta?.onClick?.(e) - dismiss() +const onCtaClick = (notification: ToastNotification, e: MouseEvent) => { + notification.cta?.onClick?.(e) + dismiss(notification) } diff --git a/packages/ui-components/src/helpers/global/toast.ts b/packages/ui-components/src/helpers/global/toast.ts index 7052f86a22..4c070cf69c 100644 --- a/packages/ui-components/src/helpers/global/toast.ts +++ b/packages/ui-components/src/helpers/global/toast.ts @@ -25,4 +25,5 @@ export type ToastNotification = { * Defaults to true */ autoClose?: boolean + id?: string } diff --git a/packages/ui-components/src/stories/components/GlobalToast.vue b/packages/ui-components/src/stories/components/GlobalToast.vue index 38b795ea8e..401583d092 100644 --- a/packages/ui-components/src/stories/components/GlobalToast.vue +++ b/packages/ui-components/src/stories/components/GlobalToast.vue @@ -1,18 +1,18 @@ From 5dee917d3550f2791210d47fe5db84e9cf2e735a Mon Sep 17 00:00:00 2001 From: Mike Tasset Date: Wed, 11 Dec 2024 15:07:20 +0100 Subject: [PATCH 03/10] Updated toasts to support multiple invites --- .../lib/projects/composables/projectManagement.ts | 10 ++++++++-- packages/frontend-2/lib/server/composables/invites.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/frontend-2/lib/projects/composables/projectManagement.ts b/packages/frontend-2/lib/projects/composables/projectManagement.ts index d4520d4677..3049df31f2 100644 --- a/packages/frontend-2/lib/projects/composables/projectManagement.ts +++ b/packages/frontend-2/lib/projects/composables/projectManagement.ts @@ -308,13 +308,19 @@ export function useInviteUserToProject() { if (err) { triggerNotification({ type: ToastNotificationType.Danger, - title: 'Invitation failed', + title: + input.length > 1 + ? "Couldn't send invites" + : `Coudldn't send invite to ${input[0].email}`, description: err }) } else { triggerNotification({ type: ToastNotificationType.Success, - title: 'Invite successfully sent' + title: + input.length > 1 + ? 'Invites successfully send' + : `Invite successfully sent to ${input[0].email}` }) } diff --git a/packages/frontend-2/lib/server/composables/invites.ts b/packages/frontend-2/lib/server/composables/invites.ts index c0b73713dc..cd18c32226 100644 --- a/packages/frontend-2/lib/server/composables/invites.ts +++ b/packages/frontend-2/lib/server/composables/invites.ts @@ -55,13 +55,19 @@ export function useInviteUserToServer() { if (res?.data?.serverInviteBatchCreate) { triggerNotification({ type: ToastNotificationType.Success, - title: `Server invite${finalInput.length > 1 ? 's' : ''} sent` + title: + finalInput.length > 1 + ? 'Server invites sent' + : `Server invite sent to ${finalInput[0].email}` }) } else { const errMsg = getFirstErrorMessage(res?.errors) triggerNotification({ type: ToastNotificationType.Danger, - title: `Couldn't send invite${finalInput.length > 1 ? 's' : ''}`, + title: + finalInput.length > 1 + ? "Couldn't send invites" + : `Couldn't send invite to ${finalInput[0].email}`, description: errMsg }) } From a0e3d5495600d4da1002a385416e6c7b3ca727aa Mon Sep 17 00:00:00 2001 From: Mike Tasset Date: Wed, 11 Dec 2024 15:29:30 +0100 Subject: [PATCH 04/10] Fix input size --- packages/frontend-2/components/invite/dialog/Server.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/frontend-2/components/invite/dialog/Server.vue b/packages/frontend-2/components/invite/dialog/Server.vue index ea7e19a2c2..a23a7cded2 100644 --- a/packages/frontend-2/components/invite/dialog/Server.vue +++ b/packages/frontend-2/components/invite/dialog/Server.vue @@ -12,7 +12,6 @@ v-model="item.value.email" :name="`email-${item.key}`" color="foundation" - size="lg" placeholder="Email address" show-clear full-width From 8dda3d92dcc2b92bbe2fee227062b5993851cde8 Mon Sep 17 00:00:00 2001 From: Mike Tasset Date: Wed, 11 Dec 2024 17:05:30 +0100 Subject: [PATCH 05/10] Fix toast issue --- packages/frontend-2/lib/common/composables/toast.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/frontend-2/lib/common/composables/toast.ts b/packages/frontend-2/lib/common/composables/toast.ts index 58395d9ff0..a2b5911c65 100644 --- a/packages/frontend-2/lib/common/composables/toast.ts +++ b/packages/frontend-2/lib/common/composables/toast.ts @@ -55,8 +55,6 @@ export function useGlobalToastManager() { if (!newVal) return currentNotifications.value = newVal - if (import.meta.server) return - // Create timeout for the new notification const index = currentNotifications.value.length - 1 const lastNotification = newVal[index] From 374b34ea5f16160d45f57e376aa3a2e3717b2b2f Mon Sep 17 00:00:00 2001 From: Mike Tasset Date: Wed, 11 Dec 2024 21:56:43 +0100 Subject: [PATCH 06/10] Strip empty invites --- packages/frontend-2/components/invite/dialog/Server.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/frontend-2/components/invite/dialog/Server.vue b/packages/frontend-2/components/invite/dialog/Server.vue index a23a7cded2..f3be478aea 100644 --- a/packages/frontend-2/components/invite/dialog/Server.vue +++ b/packages/frontend-2/components/invite/dialog/Server.vue @@ -133,7 +133,9 @@ const removeInviteItem = (index: number) => { } const onSubmit = handleSubmit(() => { - fields.value.forEach(async (invite) => { + const invites = fields.value.filter((invite) => invite.value.email) + + invites.forEach(async (invite) => { invite.value.project ? await inviteUserToProject(invite.value.project.id, [ { From 06b8a3dc9312513d97d327a9c92717313b669acb Mon Sep 17 00:00:00 2001 From: Mike Tasset Date: Thu, 12 Dec 2024 13:34:34 +0100 Subject: [PATCH 07/10] Alignment --- packages/frontend-2/components/invite/dialog/Server.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend-2/components/invite/dialog/Server.vue b/packages/frontend-2/components/invite/dialog/Server.vue index f3be478aea..754c5d68fc 100644 --- a/packages/frontend-2/components/invite/dialog/Server.vue +++ b/packages/frontend-2/components/invite/dialog/Server.vue @@ -6,7 +6,7 @@

-
+
Date: Thu, 12 Dec 2024 13:36:48 +0100 Subject: [PATCH 08/10] Remove bottom margin --- packages/frontend-2/components/invite/dialog/Server.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend-2/components/invite/dialog/Server.vue b/packages/frontend-2/components/invite/dialog/Server.vue index 754c5d68fc..679d16be21 100644 --- a/packages/frontend-2/components/invite/dialog/Server.vue +++ b/packages/frontend-2/components/invite/dialog/Server.vue @@ -2,7 +2,7 @@
-
+

From a79b54449208c429479583870812206308cf871b Mon Sep 17 00:00:00 2001 From: Mike Tasset Date: Thu, 12 Dec 2024 13:45:16 +0100 Subject: [PATCH 09/10] Reactivity fix --- packages/frontend-2/components/form/select/ServerRoles.vue | 5 +++-- packages/frontend-2/components/invite/dialog/Server.vue | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/frontend-2/components/form/select/ServerRoles.vue b/packages/frontend-2/components/form/select/ServerRoles.vue index 25028ebd8a..9aa4c2bceb 100644 --- a/packages/frontend-2/components/form/select/ServerRoles.vue +++ b/packages/frontend-2/components/form/select/ServerRoles.vue @@ -7,7 +7,7 @@ :disabled-item-tooltip=" !allowGuest ? 'The Guest role isn\'t enabled on the server' : '' " - name="serverRoles" + :name="name ?? 'serverRoles'" label="Role" :show-label="showLabel" class="min-w-[110px]" @@ -75,7 +75,8 @@ const props = defineProps({ allowAdmin: Boolean, allowArchived: Boolean, fullyControlValue: Boolean, - showLabel: Boolean + showLabel: Boolean, + name: String }) const elementToWatchForChanges = ref(null as Nullable) diff --git a/packages/frontend-2/components/invite/dialog/Server.vue b/packages/frontend-2/components/invite/dialog/Server.vue index 679d16be21..502d497984 100644 --- a/packages/frontend-2/components/invite/dialog/Server.vue +++ b/packages/frontend-2/components/invite/dialog/Server.vue @@ -25,6 +25,7 @@ v-if="allowServerRoleSelect" v-model="item.value.serverRole" label="Select role" + :name="`role-${item.key}`" class="sm:w-48" show-label :disabled="anyMutationsLoading" From 9a9bc180e9254c826071fef6f6924f01b9d096ec Mon Sep 17 00:00:00 2001 From: Mike Tasset Date: Thu, 12 Dec 2024 14:25:09 +0100 Subject: [PATCH 10/10] Change title --- packages/frontend-2/components/invite/dialog/Server.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend-2/components/invite/dialog/Server.vue b/packages/frontend-2/components/invite/dialog/Server.vue index 502d497984..c02350d4bb 100644 --- a/packages/frontend-2/components/invite/dialog/Server.vue +++ b/packages/frontend-2/components/invite/dialog/Server.vue @@ -1,6 +1,6 @@