Skip to content

Commit

Permalink
Add admin-only user-search typeahead to AddProjectMember dialog (#692)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
rmunn and myieye authored Apr 22, 2024
1 parent 876ec6e commit 90c9fb4
Show file tree
Hide file tree
Showing 8 changed files with 158 additions and 13 deletions.
3 changes: 3 additions & 0 deletions frontend/src/lib/components/modals/Modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

<script lang="ts">
import t from '$lib/i18n';
import { OverlayContainer } from '$lib/overlay';
import { createEventDispatcher } from 'svelte';
import { writable } from 'svelte/store';
Expand Down Expand Up @@ -98,6 +99,8 @@
on:cancel={cancelModal}
on:close={cancelModal}
>
<OverlayContainer />

<div class="modal-box max-w-3xl">
{#if showCloseButton}
<button class="btn btn-sm btn-circle absolute right-2 top-2 z-10" aria-label={$t('close')} on:click={cancelModal}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/forms/PlainInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
export let placeholder = '';
// Despite the compatibility table, 'new-password' seems to work well in Chrome, Edge & Firefox
// https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete#browser_compatibility
export let autocomplete: 'new-password' | 'current-password' | undefined = undefined;
export let autocomplete: 'new-password' | 'current-password' | 'off' | undefined = undefined;
export let debounce: number | boolean = false;
export let debouncing = false;
export let undebouncedValue: string | undefined | null = undefined;
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/lib/forms/UserTypeahead.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<script lang="ts">
import { FormField, PlainInput, randomFormId } from '$lib/forms';
import { _userTypeaheadSearch, type SingleUserTypeaheadResult } from '$lib/gql/typeahead-queries';
import { overlay } from '$lib/overlay';
import { deriveAsync } from '$lib/util/time';
import { writable } from 'svelte/store';
export let label: string;
export let error: string | string[] | undefined = undefined;
export let id: string = randomFormId();
export let autofocus = false;
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
export let value: string;
export let debounceMs = 200;
let input = writable('');
$: $input = value;
let typeaheadResults = deriveAsync(input, _userTypeaheadSearch, [], debounceMs);
function formatResult(user: SingleUserTypeaheadResult): string {
const extra = user.username && user.email ? ` (${user.username}, ${user.email})`
: user.username ? ` (${user.username})`
: user.email ? ` (${user.email})`
: '';
return `${user.name}${extra}`;
}
</script>

<FormField {id} {label} {error} {autofocus} >
<div use:overlay={{ closeClickSelector: '.menu li'}}>
<PlainInput style="w-full" debounce {id} bind:value type="text" autocomplete="off" />
<div class="overlay-content">
<ul class="menu p-0">
{#each $typeaheadResults as user}
<li class="p-0"><button class="whitespace-nowrap" on:click={() => setTimeout(() => $input = value = user.email ?? user.username ?? '')}>{formatResult(user)}</button></li>
{/each}
</ul>
</div>
</div>
</FormField>
50 changes: 50 additions & 0 deletions frontend/src/lib/gql/typeahead-queries.ts
Original file line number Diff line number Diff line change
@@ -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<NonNullable<LoadAdminUsersTypeaheadQuery['users']>['items']>;
export type SingleUserTypeaheadResult = UserTypeaheadResult[number];

export async function _userTypeaheadSearch(userSearch: string, limit = 10): Promise<UserTypeaheadResult> {
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;
}
5 changes: 5 additions & 0 deletions frontend/src/lib/overlay/OverlayContainer.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script>
import { overlayContainer } from '.';
</script>

<div use:overlayContainer class="bg-base-200 shadow rounded-box z-[2] absolute" />
46 changes: 36 additions & 10 deletions frontend/src/lib/overlay/index.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement, OverlayParams>;

class SharedOverlay {
private containerStack: HTMLElement[] = [];
private containerElem: HTMLElement | undefined;
private activeOverlay: { targetElem: HTMLElement; contentElem: HTMLElement } | undefined;
private cleanupOverlay: (() => void) | undefined;
Expand All @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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();
}
};
}
Expand All @@ -95,6 +103,7 @@ class SharedOverlay {
class OverlayTarget implements ActionReturn<OverlayParams> {
private contentElem: HTMLElement;
private abortController = new AbortController();
private useInputConfig = false;

constructor(private targetElem: HTMLElement,
private disabled: boolean,
Expand All @@ -104,9 +113,25 @@ class OverlayTarget implements ActionReturn<OverlayParams> {
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) => {
Expand Down Expand Up @@ -134,7 +159,8 @@ class OverlayTarget implements ActionReturn<OverlayParams> {

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');
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -18,14 +22,18 @@
let formModal: FormModal<typeof schema>;
$: form = formModal?.form();
let selectedUser: SingleUserTypeaheadResult;
const { notifySuccess } = useNotifications();
async function openModal(): Promise<void> {
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,
});
Expand Down Expand Up @@ -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 }));
}
}
</script>
Expand All @@ -68,6 +76,15 @@

<FormModal bind:this={formModal} {schema} let:errors>
<span slot="title">{$t('project_page.add_user.modal_title')}</span>
{#if isAdmin($page.data.user)}
<UserTypeahead
id="usernameOrEmail"
label={$t('login.label_email')}
bind:value={$form.usernameOrEmail}
error={errors.usernameOrEmail}
autofocus
/>
{:else}
<Input
id="usernameOrEmail"
type="text"
Expand All @@ -76,6 +93,7 @@
error={errors.usernameOrEmail}
autofocus
/>
{/if}
<ProjectRoleSelect bind:value={$form.role} error={errors.role} />
<span slot="submitText">
{#if $form.usernameOrEmail.includes('@')}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/routes/(unauthenticated)/sandbox/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function preFillForm(): void {
let modal: ConfirmModal;
let deleteModal: DeleteModal;
</script>
<PageBreadcrumb>Hello from sandbox</PageBreadcrumb>
<PageBreadcrumb>second value</PageBreadcrumb>
Expand Down

0 comments on commit 90c9fb4

Please sign in to comment.