From 90c9fb4c17f987decb24fec77ad3c5b3ea98d831 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 22 Apr 2024 18:52:48 +0700 Subject: [PATCH] Add admin-only user-search typeahead to AddProjectMember dialog (#692) * Create user-search typeahead in sandbox Will want to tweak styling later, but this basically works. Now to extract it into a Svelte component and use it (behind an admin-only flag) in the AddProjectMember component. * Better UI for typeahead * Create UserTypeahead component, use it in demo * Use admin-only typeahead in AddProjectMember * Fix lint errors * Improve UI for typeahead results Some excellent UI suggestions from Tim - thanks! * Simplify user typeahead to just return string * Fix overlay-container for modals and remove browser autocomplete * Fix typo during merge resolution * Fix width of typeahead input * Debounce typeahead results * Better formatting for typeahead results * Fix "selectedUser is null" bug * Allow user to ignore typeahead if desired Now if the user ignores the typeahead results and just submits the form, the value in the input field will correctly be used as it was before. * Remove typeahead from sandbox page, fix lint error * Close input overlay on focusout * Default typeahead limit is now 10 items * Remove leftover console log * Rename _typeaheadSearch to _userTypeaheadSearch * Remove now-unused import * No need for limit+1 in typeahead results --------- Co-authored-by: Tim Haasdyk --- .../src/lib/components/modals/Modal.svelte | 3 ++ frontend/src/lib/forms/PlainInput.svelte | 2 +- frontend/src/lib/forms/UserTypeahead.svelte | 42 ++++++++++++++++ frontend/src/lib/gql/typeahead-queries.ts | 50 +++++++++++++++++++ .../src/lib/overlay/OverlayContainer.svelte | 5 ++ frontend/src/lib/overlay/index.ts | 46 +++++++++++++---- .../[project_code]/AddProjectMember.svelte | 22 +++++++- .../(unauthenticated)/sandbox/+page.svelte | 1 + 8 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 frontend/src/lib/forms/UserTypeahead.svelte create mode 100644 frontend/src/lib/gql/typeahead-queries.ts create mode 100644 frontend/src/lib/overlay/OverlayContainer.svelte diff --git a/frontend/src/lib/components/modals/Modal.svelte b/frontend/src/lib/components/modals/Modal.svelte index 22253217b..c0672a5dd 100644 --- a/frontend/src/lib/components/modals/Modal.svelte +++ b/frontend/src/lib/components/modals/Modal.svelte @@ -7,6 +7,7 @@ + + +
+ +
+ +
+
+
diff --git a/frontend/src/lib/gql/typeahead-queries.ts b/frontend/src/lib/gql/typeahead-queries.ts new file mode 100644 index 000000000..7fa127042 --- /dev/null +++ b/frontend/src/lib/gql/typeahead-queries.ts @@ -0,0 +1,50 @@ +import type { LoadAdminUsersTypeaheadQuery, UserFilterInput } from './types'; + +import { getClient } from './gql-client'; +import { graphql } from './generated'; + +export function userFilter(userSearch: string): UserFilterInput { + return { + or: [ + {name: {icontains: userSearch}}, + {email: {icontains: userSearch}}, + {username: {icontains: userSearch}} + ] + }; +} + +export type UserTypeaheadResult = NonNullable['items']>; +export type SingleUserTypeaheadResult = UserTypeaheadResult[number]; + +export async function _userTypeaheadSearch(userSearch: string, limit = 10): Promise { + if (!userSearch) return Promise.resolve([]); + const client = getClient(); + const result = client.query(graphql(` + query loadAdminUsersTypeahead($filter: UserFilterInput, $take: Int!) { + users( + where: $filter, orderBy: {name: ASC}, take: $take) { + totalCount + items { + id + name + email + username + } + } + } + `), { filter: userFilter(userSearch), take: limit }); + // NOTE: If more properties are needed, copy from loadAdminDashboardUsers to save time + + const users = result.then(users => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const count = users.data?.users?.totalCount ?? 0; + if (0 < count && count <= limit) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return users.data?.users?.items ?? []; + } else { + return []; + } + }); + + return users; +} diff --git a/frontend/src/lib/overlay/OverlayContainer.svelte b/frontend/src/lib/overlay/OverlayContainer.svelte new file mode 100644 index 000000000..f76ce6835 --- /dev/null +++ b/frontend/src/lib/overlay/OverlayContainer.svelte @@ -0,0 +1,5 @@ + + +
diff --git a/frontend/src/lib/overlay/index.ts b/frontend/src/lib/overlay/index.ts index ec2eeacbc..756d6de50 100644 --- a/frontend/src/lib/overlay/index.ts +++ b/frontend/src/lib/overlay/index.ts @@ -1,11 +1,15 @@ import type {Action, ActionReturn} from 'svelte/action'; -import {autoUpdate, computePosition, flip, offset} from '@floating-ui/dom'; -import {browser} from '$app/environment'; +import {autoUpdate, computePosition, flip, offset, type Placement} from '@floating-ui/dom'; + +import { browser } from '$app/environment'; + +export { default as OverlayContainer } from './OverlayContainer.svelte'; type OverlayParams = { disabled?: boolean, closeClickSelector?: string } | undefined; type OverlayAction = Action; class SharedOverlay { + private containerStack: HTMLElement[] = []; private containerElem: HTMLElement | undefined; private activeOverlay: { targetElem: HTMLElement; contentElem: HTMLElement } | undefined; private cleanupOverlay: (() => void) | undefined; @@ -15,7 +19,7 @@ class SharedOverlay { if (browser) document.addEventListener('click', this.closeHandler.bind(this)); } - public openOverlay(targetElem: HTMLElement, contentElem: HTMLElement): void { + public openOverlay(targetElem: HTMLElement, contentElem: HTMLElement, placement: Placement): void { if (this.isActive(targetElem)) return; if (!this.containerElem) throw new Error('No overlay container has been provided'); this.resetDom(); @@ -27,7 +31,7 @@ class SharedOverlay { () => { if (!this.containerElem) return; void computePosition(targetElem, this.containerElem, { - placement: 'bottom-end', + placement, middleware: [offset(2), flip()], }).then(({x, y}) => { if (!this.containerElem) return; @@ -63,15 +67,19 @@ class SharedOverlay { } public overlayContainer(element: HTMLElement): ActionReturn { - if (this.containerElem) console.warn('Overlay container is already set'); + if (this.containerElem) { + this.containerStack.push(this.containerElem); + this.closeOverlay(); + } this.containerElem = element; this.containerElem.classList.add('overlay-container'); this.resetDom(); return { destroy: () => { + this.containerStack = this.containerStack.filter((elem) => elem !== element); if (this.containerElem !== element) return; this.closeOverlay(); - this.containerElem = undefined; + this.containerElem = this.containerStack.pop(); } }; } @@ -95,6 +103,7 @@ class SharedOverlay { class OverlayTarget implements ActionReturn { private contentElem: HTMLElement; private abortController = new AbortController(); + private useInputConfig = false; constructor(private targetElem: HTMLElement, private disabled: boolean, @@ -104,9 +113,25 @@ class OverlayTarget implements ActionReturn { if (!this.contentElem) throw new Error('Overlay target must have a child with class "overlay-content"'); this.contentElem.remove(); - this.targetElem.addEventListener('click', - () => this.isActive() ? this.closeOverlay() : this.openOverlay(), - {signal: this.abortController.signal}); + this.useInputConfig = this.targetElem.matches('input') || !!this.targetElem.querySelector('input'); + + if (this.useInputConfig) { + this.targetElem.addEventListener('focusin', + () => !this.isActive() && this.openOverlay(), + {signal: this.abortController.signal}); + this.targetElem.addEventListener('focusout', + () => { + // When clicking on an element in the content, the focus first goes to the body and only then to the element. + setTimeout(() => { + if (this.isActive() && !this.contentElem.contains(document.activeElement)) this.closeOverlay(); + }); + }, + {signal: this.abortController.signal}); + } else { + this.targetElem.addEventListener('click', + () => this.isActive() ? this.closeOverlay() : this.openOverlay(), + {signal: this.abortController.signal}); + } // clicking on menu items should probably always close the overlay this.contentElem.addEventListener('click', (event) => { @@ -134,7 +159,8 @@ class OverlayTarget implements ActionReturn { private openOverlay(): void { if (!this.disabled) { - this.sharedOverlay.openOverlay(this.targetElem, this.contentElem); + this.sharedOverlay.openOverlay(this.targetElem, this.contentElem, + this.useInputConfig ? 'bottom-start' : 'bottom-end'); } } diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/AddProjectMember.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/AddProjectMember.svelte index bce422cef..02a67f733 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/AddProjectMember.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/AddProjectMember.svelte @@ -7,6 +7,10 @@ import { z } from 'zod'; import { _addProjectMember } from './+page'; import { useNotifications } from '$lib/notify'; + import { isAdmin } from '$lib/user'; + import { page } from '$app/stores' + import UserTypeahead from '$lib/forms/UserTypeahead.svelte'; + import type { SingleUserTypeaheadResult } from '$lib/gql/typeahead-queries'; export let projectId: string; const schema = z.object({ @@ -18,14 +22,18 @@ let formModal: FormModal; $: form = formModal?.form(); + let selectedUser: SingleUserTypeaheadResult; + const { notifySuccess } = useNotifications(); async function openModal(): Promise { let userInvited = false; + let selectedEmail: string = ''; const { response, formState } = await formModal.open(async () => { + selectedEmail = $form.usernameOrEmail ? $form.usernameOrEmail : selectedUser?.email ?? selectedUser?.username ?? ''; const { error } = await _addProjectMember({ projectId, - usernameOrEmail: $form.usernameOrEmail, + usernameOrEmail: selectedEmail, role: $form.role, }); @@ -57,7 +65,7 @@ }); if (response === DialogResponse.Submit) { const message = userInvited ? 'member_invited' : 'add_member'; - notifySuccess($t(`project_page.notifications.${message}`, { email: formState.usernameOrEmail.currentValue })); + notifySuccess($t(`project_page.notifications.${message}`, { email: formState.usernameOrEmail.currentValue ?? selectedEmail })); } } @@ -68,6 +76,15 @@ {$t('project_page.add_user.modal_title')} +{#if isAdmin($page.data.user)} + +{:else} +{/if} {#if $form.usernameOrEmail.includes('@')} diff --git a/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte b/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte index f4528b537..eb3f9d4fc 100644 --- a/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte +++ b/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte @@ -46,6 +46,7 @@ function preFillForm(): void { let modal: ConfirmModal; let deleteModal: DeleteModal; + Hello from sandbox second value