Skip to content

Commit

Permalink
Introduce Add my projects to org button and make adding projects smarter
Browse files Browse the repository at this point in the history
  • Loading branch information
myieye committed Oct 24, 2024
1 parent 962597e commit b882a12
Show file tree
Hide file tree
Showing 7 changed files with 204 additions and 34 deletions.
27 changes: 16 additions & 11 deletions frontend/src/lib/components/Users/UserProjects.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
export let projects: Project[] = [];
export let selectedProjects: string[] = [];
export let hideRoleColumn = false;
$: allSelected = projects && selectedProjects && selectedProjects.length === projects.length;
Expand Down Expand Up @@ -58,11 +59,13 @@
{$t('project.table.name')}
</span>
</th>
<th>
<span class="align-middle">
{$t('project_role.label')}
</span>
</th>
{#if !hideRoleColumn}
<th>
<span class="align-middle">
{$t('project_role.label')}
</span>
</th>
{/if}
</tr>
</thead>
<tbody>
Expand All @@ -79,12 +82,14 @@
{proj.name}
</a>
</td>
<td>
<span class:text-primary={isManager} class:font-bold={isManager} class:dark:brightness-150={isManager}>
<FormatUserProjectRole role={proj.memberRole} />
</span>
</td>
</tr>
{#if !hideRoleColumn}
<td>
<span class:text-primary={isManager} class:font-bold={isManager} class:dark:brightness-150={isManager}>
<FormatUserProjectRole role={proj.memberRole} />
</span>
</td>
{/if}
</tr>
{/each}
</tbody>
<tfoot>
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/lib/gql/gql-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ import {
type MutationChangeUserAccountBySelfArgs,
type MutationDeleteUserByAdminOrSelfArgs,
type MutationDeleteDraftProjectArgs, type MutationSoftDeleteProjectArgs, type MutationCreateProjectArgs,
type MutationAddProjectsToOrgArgs,
} from './types';
import type {Readable, Unsubscriber} from 'svelte/store';
import {derived} from 'svelte/store';
import {cacheExchange} from '@urql/exchange-graphcache';
import {devtoolsExchange} from '@urql/devtools';
import type { LexAuthUser } from '$lib/user';
import { isRedirect } from '@sveltejs/kit';
import type {LexAuthUser} from '$lib/user';
import {isRedirect} from '@sveltejs/kit';

let globalClient: GqlClient | null = null;

Expand Down Expand Up @@ -94,6 +95,9 @@ function createGqlClient(_gqlEndpoint?: string): Client {
cache.invalidate({__typename: 'Project', id: args.input.projectId});
}
},
addProjectsToOrg: (result, args: MutationAddProjectsToOrgArgs, cache, _info) => {
cache.invalidate({__typename: 'OrgById', id: args.input.orgId});
},
bulkAddOrgMembers: (result, args: MutationBulkAddOrgMembersArgs, cache, _info) => {
cache.invalidate({__typename: 'OrgById', id: args.input.orgId});
},
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ Lexbox is free and [open source](https://github.com/sillsdev/languageforge-lexbo
"submit_button": "Add Member",
"empty_user_field": "Please enter an email address or login",
"also_add_projects": "Add user's projects to this organization",
"all_projects_already_added": "The {count, plural, one {# project} other {# projects}} this user is a member of {count, plural, one {is} other {are}} already in this organization",
"select_all": "Select all",
"submit_button_email": "Add/Invite Member",
"org_not_found": "Organization not found. Please refresh the page.",
Expand All @@ -276,6 +277,13 @@ Lexbox is free and [open source](https://github.com/sillsdev/languageforge-lexbo
"user_needs_to_relogin": "Added members will need to log out and back in again before they see the new organization.",
"invalid_email_address": "Invalid email address: {email}",
},
"add_my_projects": {
"title": "Add projects I manage to this organization",
"open_button": "Add my projects",
"submit_button": "Add Projects",
"all_projects_already_added": "The {count, plural, one {# project} other {# projects}} you manage {count, plural, one {is} other {are}} already in this organization",
"no_projects_managed": "You don't manage any projects",
},
"bulk_add_members": {
"add_button": "Bulk Add Members",
"explanation": "Adds all the entered logins and emails to this organization. Unlike the bulk-add feature for projects, new accounts will NOT be automatically created.",
Expand Down Expand Up @@ -314,7 +322,8 @@ Lexbox is free and [open source](https://github.com/sillsdev/languageforge-lexbo
"leave_org_error": "An error occurred trying to remove you from organization {name}. Please try again later.",
"describe": "Organization description has been updated.",
"add_member": "{email} has been added to organization.",
"member_invited": "{email} has been sent an invitation email to register and join the organization."
"member_invited": "{email} has been sent an invitation email to register and join the organization.",
"added_projects": "Added {count, plural, one {# project} other {# projects}} to organization",
},
"edit_member_role": "Change Role",
"remove_member": "Remove",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import type { UUID } from 'crypto';
import BulkAddOrgMembers from './BulkAddOrgMembers.svelte';
import Dropdown from '$lib/components/Dropdown.svelte';
import AddMyProjectsToOrgModal from './AddMyProjectsToOrgModal.svelte';
export let data: PageData;
$: user = data.user;
Expand Down Expand Up @@ -108,6 +109,9 @@

<DetailsPage wide title={org.name}>
<svelte:fragment slot="actions">
{#if isMember}
<AddMyProjectsToOrgModal {user} {org} />
{/if}
{#if canManage}
<Button variant="btn-success"
on:click={openAddOrgMemberModal}>
Expand All @@ -116,7 +120,7 @@
</span>
<span class="i-mdi-account-plus-outline text-2xl" />
</Button>
<AddOrgMemberModal bind:this={addOrgMemberModal} orgId={org.id} />
<AddOrgMemberModal bind:this={addOrgMemberModal} {org} />
<BulkAddOrgMembers orgId={org.id} />
{/if}
</svelte:fragment>
Expand Down
62 changes: 56 additions & 6 deletions frontend/src/routes/(authenticated)/org/[org_id]/+page.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import type {
$OpResult,
AddOrgMemberMutation,
AddProjectsToOrgMutation,
BulkAddOrgMembersMutation,
ChangeOrgMemberRoleMutation,
ChangeOrgNameInput,
ChangeOrgNameMutation,
DeleteOrgMutation,
DeleteOrgUserMutation,
LoadMyProjectsQuery,
OrgMemberDto,
OrgPageQuery,
OrgRole,
RemoveProjectFromOrgMutation,
} from '$lib/gql/types';
import { getClient, graphql } from '$lib/gql';
import {getClient, graphql} from '$lib/gql';

import type { OrgTabId } from './OrgTabs.svelte';
import type { PageLoadEvent } from './$types';
import type { UUID } from 'crypto';
import { error } from '@sveltejs/kit';
import { tryMakeNonNullable } from '$lib/util/store';
import type {OrgTabId} from './OrgTabs.svelte';
import type {PageLoadEvent} from './$types';
import type {UUID} from 'crypto';
import {error} from '@sveltejs/kit';
import {tryMakeNonNullable} from '$lib/util/store';

export type Org = NonNullable<OrgPageQuery['orgById']>;
export type OrgUser = Org['members'][number];
Expand Down Expand Up @@ -289,3 +291,51 @@ export async function _deleteOrg(orgId: string): $OpResult<DeleteOrgMutation> {
);
return result;
}

export async function _getMyProjects(): Promise<LoadMyProjectsQuery['myProjects']> {
const client = getClient();
//language=GraphQL
const results = await client.query(graphql(`
query loadMyProjects {
myProjects(orderBy: [
{name: ASC }
]) {
code
id
name
users {
id
userId
role
}
}
}
`), {});
return results.data?.myProjects ?? [];
}

export async function _addProjectsToOrg(orgId: UUID, projectIds: string[]): $OpResult<AddProjectsToOrgMutation> {
//language=GraphQL
return await getClient()
.mutation(
graphql(`
mutation AddProjectsToOrg($input: AddProjectsToOrgInput!) {
addProjectsToOrg(input: $input) {
organization {
id
projects {
id
}
}
errors {
__typename
... on Error {
message
}
}
}
}
`),
{ input: { orgId, projectIds } }
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<script lang="ts">
import {DialogResponse, FormModal} from '$lib/components/modals';
import UserProjects, {type Project} from '$lib/components/Users/UserProjects.svelte';
import Button from '$lib/forms/Button.svelte';
import t from '$lib/i18n';
import {type LexAuthUser} from '$lib/user';
import {z} from 'zod';
import {_addProjectsToOrg, _getMyProjects, type Org} from './+page';
import {ProjectRole} from '$lib/gql/types';
import {useNotifications} from '$lib/notify';
import {type UUID} from 'crypto';
export let user: LexAuthUser;
export let org: Org;
const {notifySuccess} = useNotifications();
const schema = z.object({});
let formModal: FormModal<typeof schema>;
let newProjects: Project[] = [];
let alreadyAddedProjects: Project[] = [];
let selectedProjects: string[] = [];
async function openModal(): Promise<void> {
const myProjects = await _getMyProjects();
const projectsIManage = myProjects.map((project) => ({
id: project.id,
name: project.name,
code: project.code,
memberRole: project.users.find(projUser => projUser.userId === user.id)?.role ?? ProjectRole.Editor,
})).filter(p => p.memberRole === ProjectRole.Manager);
newProjects = [];
alreadyAddedProjects = [];
projectsIManage.forEach(proj => {
if (org.projects.find(p => p.id === proj.id)) {
alreadyAddedProjects.push(proj);
} else {
newProjects.push(proj);
}
})
const { response } = await formModal.open(undefined, async () => {
if (!selectedProjects.length) return 'No projects selected';
const result = await _addProjectsToOrg(org.id as UUID, selectedProjects);
if (result.error?.message) return result.error.message;
});
if (response === DialogResponse.Submit) {
notifySuccess($t('org_page.notifications.added_projects', { count: selectedProjects.length }));
}
}
</script>

<Button variant="btn-success" on:click={openModal}>
{$t('org_page.add_my_projects.open_button')}
<span class="i-mdi-plus text-2xl" />
</Button>

<FormModal bind:this={formModal} {schema}>
<span slot="title">
{$t('org_page.add_my_projects.title')}
</span>
{#if newProjects.length}
<UserProjects projects={newProjects} bind:selectedProjects hideRoleColumn />
{:else if alreadyAddedProjects.length}
<span class="text-secondary">
{$t('org_page.add_my_projects.all_projects_already_added', { count: alreadyAddedProjects.length })}
</span>
{:else}
<span class="text-secondary">
{$t('org_page.add_my_projects.no_projects_managed')}
</span>
{/if}
<span slot="submitText">{$t('org_page.add_my_projects.submit_button')}</span>
</FormModal>
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
import UserTypeahead from '$lib/forms/UserTypeahead.svelte';
import { SupHelp, helpLinks } from '$lib/components/help';
import type { UUID } from 'crypto';
import { _addOrgMember } from './+page';
import { _addOrgMember, type Org } from './+page';
import type { SingleUserInMyOrgTypeaheadResult, SingleUserTypeaheadResult } from '$lib/gql/typeahead-queries';
import UserProjects, { type Project } from '$lib/components/Users/UserProjects.svelte';
export let orgId: string;
export let org: Org;
const schema = z.object({
usernameOrEmail: z.string().trim()
.min(1, $t('org_page.add_user.empty_user_field'))
Expand All @@ -26,25 +27,36 @@
const { notifySuccess } = useNotifications();
let projects: Project[] = [];
let newProjects: Project[] = [];
let alreadyAddedProjects: Project[] = [];
let selectedProjects: string[] = [];
function resetProjects(): void {
newProjects = [];
alreadyAddedProjects = [];
selectedProjects = [];
}
function populateUserProjects(user: SingleUserTypeaheadResult | SingleUserInMyOrgTypeaheadResult | null): void {
if (!user || !('projects' in user)) {
projects = [];
selectedProjects = [];
} else {
projects = [...user.projects.map(p => ({memberRole: p.role, id: p.project.id, code: p.project.code, name: p.project.name}))];
resetProjects();
if (user && 'projects' in user) {
const userProjects = [...user.projects.map(p => ({memberRole: p.role, id: p.project.id, code: p.project.code, name: p.project.name}))];
userProjects.forEach(proj => {
if (org.projects.find(p => p.id === proj.id)) {
alreadyAddedProjects.push(proj);
} else {
newProjects.push(proj);
}
});
}
}
export async function openModal(): Promise<void> {
projects = [];
selectedProjects = [];
resetProjects();
let userInvited = false;
const { response, formState } = await formModal.open(async () => {
const { error } = await _addOrgMember(
orgId as UUID,
org.id as UUID,
$form.usernameOrEmail,
$form.role,
$form.canInvite,
Expand All @@ -68,6 +80,9 @@
if (response === DialogResponse.Submit) {
const message = userInvited ? 'member_invited' : 'add_member';
notifySuccess($t(`org_page.notifications.${message}`, { email: formState.usernameOrEmail.currentValue }));
if (selectedProjects.length) {
notifySuccess($t('org_page.notifications.added_projects', { count: selectedProjects.length }));
}
}
}
</script>
Expand Down Expand Up @@ -99,11 +114,17 @@
/>
{/if}
<OrgRoleSelect bind:value={$form.role} error={errors.role} />
{#if projects && projects.length}
{#if newProjects.length || alreadyAddedProjects.length}
<div class="label label-text">
{$t('org_page.add_user.also_add_projects')}
</div>
<UserProjects {projects} bind:selectedProjects />
{#if newProjects.length}
<UserProjects projects={newProjects} bind:selectedProjects />
{:else}
<span class="text-secondary px-1">
{$t('org_page.add_user.all_projects_already_added', { count: alreadyAddedProjects.length })}
</span>
{/if}
{/if}
<svelte:fragment slot="extraActions">
<Checkbox
Expand Down

0 comments on commit b882a12

Please sign in to comment.