Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Org list page #837

Merged
merged 25 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9b8f070
Start of org list page
rmunn May 30, 2024
fdac31e
Record ideas for better formatting of org lists
rmunn May 30, 2024
55c786d
Add basic (very basic) table view to org list
rmunn May 31, 2024
590ba2e
Better margin for page title
rmunn May 31, 2024
e036024
Add visual sorting indicator (doesn't sort yet)
rmunn May 31, 2024
96657e8
Implement org list sorting using local data
rmunn May 31, 2024
bd22c22
Fix Typescript error
rmunn Jun 4, 2024
d9edab0
Table headers should have pointer cursor
rmunn Jun 4, 2024
b9844e4
Add organizations icon
myieye Jun 4, 2024
715428a
And filtering
myieye Jun 4, 2024
e554d51
Use === for comparisons instead of ==
rmunn Jun 6, 2024
0d70aad
Slightly better Typescript syntax
rmunn Jun 6, 2024
35e31fe
Default sort column is now name, not created_at
rmunn Jun 6, 2024
63e3316
Better handling of sort headers
rmunn Jun 6, 2024
275e7cd
Rename "users" column to "members"
rmunn Jun 6, 2024
7baf25b
Clean up leftover commented-out line
rmunn Jun 6, 2024
5c0128f
Add dev-only buttons linking to org list
rmunn Jun 6, 2024
faf4ff1
Add memberCount to GQL query for org list
rmunn Jun 6, 2024
dda8890
No longer query full members list in org list page
rmunn Jun 6, 2024
7ff4ffd
GraphQL query must reference members in order to work
rmunn Jun 6, 2024
87e4e32
Fix more complicated MemberCount version
rmunn Jun 6, 2024
d26f5f0
set page title, translate some strings and use org specific versions …
hahn-kev Jun 6, 2024
ef9ff13
remove useless code comments
hahn-kev Jun 6, 2024
f5080e1
fix lint warning about variable count
hahn-kev Jun 6, 2024
5d8ce0c
Tweak navigation, colors, menus and breadcrumbs
myieye Jun 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using LexCore.Entities;

namespace LexBoxApi.GraphQL.CustomTypes;

[ObjectType]
public class OrgGqlConfiguration : ObjectType<Organization>
{
protected override void Configure(IObjectTypeDescriptor<Organization> descriptor)
{
descriptor.Field(o => o.CreatedDate).IsProjected();
// TODO: Will we want something similar to the following Project code for orgs?
// descriptor.Field(o => o.Id).Use<RefreshJwtProjectMembershipMiddleware>();
// descriptor.Field(o => o.Members).Use<RefreshJwtProjectMembershipMiddleware>();
}
}
11 changes: 10 additions & 1 deletion backend/LexCore/Entities/Organization.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
namespace LexCore.Entities;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq.Expressions;
using EntityFrameworkCore.Projectables;

namespace LexCore.Entities;

public class Organization : EntityBase
{
public required string Name { get; set; }
public required List<OrgMember> Members { get; set; }

[NotMapped]
[Projectable(UseMemberBody = nameof(SqlMemberCount))]
public int MemberCount { get; set; }
private static Expression<Func<Organization, int>> SqlMemberCount => org => org.Members.Count;
}

public class OrgMember : EntityBase
Expand Down
5 changes: 4 additions & 1 deletion frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,11 @@ type OrgMember {
}

type Organization {
createdDate: DateTime!
memberCount: Int!
name: String!
members: [OrgMember!]!
id: UUID!
createdDate: DateTime!
updatedDate: DateTime!
}

Expand Down Expand Up @@ -591,13 +592,15 @@ input OrganizationFilterInput {
or: [OrganizationFilterInput!]
name: StringOperationFilterInput
members: ListFilterInputTypeOfOrgMemberFilterInput
memberCount: IntOperationFilterInput
id: UuidOperationFilterInput
createdDate: DateTimeOperationFilterInput
updatedDate: DateTimeOperationFilterInput
}

input OrganizationSortInput {
name: SortEnumType
memberCount: SortEnumType
id: SortEnumType
createdDate: SortEnumType
updatedDate: SortEnumType
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
},
"appmenu": {
"log_out": "Log out",
"orgs": "Organizations",
"help": "Help"
},
"close": "Close",
Expand Down Expand Up @@ -141,6 +142,14 @@
It is provided as a service to language communities by [SIL Language Technology](https://software.sil.org/) and \
the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chiang Mai, Thailand."
},
"org": {
"table": {
"title": "Organizations",
"name": "Name",
"created_at": "Created",
"members": "Members"
},
},
"project": {
"create": {
"title": "Create Project",
Expand Down Expand Up @@ -172,6 +181,8 @@ the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chia
"migrated": "Migrated",
"type": "Type",
"users": "Users",
"members": "Members",
"__comment": "Move 'members' to 'org.table' once org page PR is merged",
},
"filter": {
"title": "Project filters",
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/lib/icons/HomeIcon.svelte
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
<!-- https://icon-sets.iconify.design/mdi/logout/ -->
<span class="i-mdi-home text-2xl" />
<script lang="ts">
export let size: 'text-2xl' | 'text-xl' = 'text-2xl';
</script>

<span class="i-mdi-home-outline {size}" />
2 changes: 1 addition & 1 deletion frontend/src/lib/layout/AppBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<div>
{#if user}
<!-- using a label means it works before hydration is complete -->
<label for="drawer-toggle" class="btn btn-primary px-2">
<label for="drawer-toggle" class="btn btn-primary glass px-2">
{user.name}
<AuthenticatedUserIcon size="text-4xl" />
</label>
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/lib/layout/AppMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import type {LexAuthUser} from '$lib/user';
import Icon from '$lib/icons/Icon.svelte';
import { helpLinks } from '$lib/components/help';
import DevContent from './DevContent.svelte';

export let serverVersion: string;
export let apiVersion: string | null;
Expand Down Expand Up @@ -45,6 +46,15 @@
</a>
</li>

<DevContent>
<li>
<a href="/org/list" data-sveltekit-preload-data="tap">
{$t('appmenu.orgs')}
<Icon icon="i-mdi-account-group-outline" size="text-2xl" />
</a>
</li>
</DevContent>

<li>
<a href="/user" data-sveltekit-preload-data="tap">
{$t('account_settings.title')}
Expand Down Expand Up @@ -72,4 +82,8 @@
a {
justify-content: flex-end;
}

.menu {
scrollbar-gutter: stable;
}
</style>
6 changes: 1 addition & 5 deletions frontend/src/lib/layout/Breadcrumbs/HomeBreadcrumb.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
<script>
import t from '$lib/i18n';
import PageBreadcrumb from './PageBreadcrumb.svelte';
</script>

<PageBreadcrumb href="/">
<span class="inline-flex gap-1 items-center">
{$t('user_dashboard.home_title')}
<span class="i-mdi-home-outline text-sm mb-[-1px]" />
</span>
<span class="i-mdi-home-outline text-lg mb-[-4px]"></span>
</PageBreadcrumb>
23 changes: 16 additions & 7 deletions frontend/src/lib/layout/Layout.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import EmailVerificationStatus, { initEmailResult, initRequestedEmail } from '$lib/email/EmailVerificationStatus.svelte';
import t from '$lib/i18n';
import { AdminIcon, Icon } from '$lib/icons';
import { AdminIcon, HomeIcon, Icon } from '$lib/icons';
import { AdminContent, AppBar, AppMenu, Breadcrumbs, Content } from '$lib/layout';
import { onMount } from 'svelte';
import { ensureClientMatchesUser } from '$lib/gql';
Expand Down Expand Up @@ -46,24 +46,33 @@
<Breadcrumbs />
<div class="flex gap-4 items-center">
<DevContent>
<a href="/sandbox" class="btn btn-sm btn-secondary">
<span class="max-sm:hidden">
Sandbox
</span>
<a href="/sandbox" class="btn btn-sm btn-neutral glass">
<Icon size="text-2xl" icon="i-mdi-box-variant" />
</a>
</DevContent>
<a href={helpLinks.helpList} target="_blank" rel="external"
class="btn btn-sm btn-info btn-outline max-sm:hidden">
class="btn btn-sm btn-info btn-outline hidden lg:flex">
{$t('appmenu.help')}
<Icon icon="i-mdi-open-in-new" size="text-lg" />
</a>
<DevContent>
<a href="/org/list" class="btn btn-sm btn-secondary hidden lg:flex">
{$t('appmenu.orgs')}
<Icon icon="i-mdi-account-group-outline" size="text-xl" />
</a>
</DevContent>
<a href="/" class="btn btn-sm btn-primary">
<span class="max-sm:hidden">
{$t('user_dashboard.title')}
</span>
<HomeIcon size="text-xl" />
</a>
<AdminContent>
<a href="/admin" class="btn btn-sm btn-accent">
<span class="max-sm:hidden">
{$t('admin_dashboard.title')}
</span>
<AdminIcon />
<AdminIcon size="text-xl" />
</a>
</AdminContent>
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/routes/(authenticated)/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
$: shownProjects = limitResults ? limit(filteredProjects) : filteredProjects;
</script>

<HeaderPage wide title={$t('user_dashboard.title')} setBreadcrumb={false}>
<HeaderPage wide title={$t('user_dashboard.title')}>
<svelte:fragment slot="header-content">
<div class="flex gap-4 w-full">
<div class="grow">
Expand Down
125 changes: 125 additions & 0 deletions frontend/src/routes/(authenticated)/org/list/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<script lang="ts">
import { FilterBar } from '$lib/components/FilterBar';
import type { OrgListPageQuery } from '$lib/gql/types';
import t, { date, number } from '$lib/i18n';
import { Icon } from '$lib/icons';
import { Page } from '$lib/layout';
import { getSearchParams, queryParam } from '$lib/util/query-params';
import type { PageData } from './$types';
import type { OrgListSearchParams } from './+page';

export let data: PageData;
$: orgs = data.orgs;

const queryParams = getSearchParams<OrgListSearchParams>({
search: queryParam.string<string>(''),
});
const { queryParamValues, defaultQueryParamValues } = queryParams;

type OrgList = OrgListPageQuery['orgs']

type Column = 'name' | 'members' | 'created_at';
let sortColumn: Column = 'name';
type Dir = 'ascending' | 'descending';
let sortDir: Dir = 'ascending';

function swapSortDir(): void {
sortDir = sortDir === 'ascending' ? 'descending' : 'ascending';
}

function handleSortClick(clickedColumn: Column): void {
if (sortColumn === clickedColumn) {
swapSortDir();
} else {
sortColumn = clickedColumn;
sortDir = 'ascending';
}
}

function filterOrgs(orgs: OrgList, search: string): OrgList {
return orgs.filter((org) => org.name.toLowerCase().includes(search.toLowerCase()));
}

function sortOrgs(orgs: OrgList, sortColumn: Column, sortDir: Dir): OrgList {
const data = [... orgs];
let mult = sortDir === 'ascending' ? 1 : -1;
data.sort((a, b) => {
if (sortColumn === 'members') {
return (a.memberCount - b.memberCount) * mult;
} else if (sortColumn === 'name') {
const comp = a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
return comp * mult;
} else if (sortColumn === 'created_at') {
const comp = a.createdDate < b.createdDate ? -1 : a.createdDate > b.createdDate ? 1 : 0;
return comp * mult;
}
return 0;
});
return data;
}

$: filteredOrgs = $orgs ? filterOrgs($orgs, $queryParamValues.search) : [];
$: displayOrgs = sortOrgs(filteredOrgs, sortColumn, sortDir);
</script>

<!--
TODO:

* Sort options: name, created date, # users
* Paging
rmunn marked this conversation as resolved.
Show resolved Hide resolved
-->

<Page wide title={$t('org.table.title')}>
<h1 class="text-3xl text-left grow max-w-full mb-4 flex gap-4 items-center">
{$t('org.table.title')}
<Icon icon="i-mdi-account-group-outline" size="text-5xl" />
</h1>

<div class="mt-4">
<FilterBar
searchKey="search"
filterKeys={['search']}
filters={queryParamValues}
filterDefaults={defaultQueryParamValues}
/>
</div>

<div class="divider" />
<div class="overflow-x-auto @container scroll-shadow">
<table class="table table-lg">
<thead>
<tr class="bg-base-200">
<th on:click={() => handleSortClick('name')} class="cursor-pointer hover:bg-base-300">
{$t('org.table.name')}
<span class:invisible={sortColumn !== 'name'} class="{`i-mdi-sort-${sortDir}`} text-xl align-[-5px] ml-2" />
</th>
<th on:click={() => handleSortClick('members')} class="cursor-pointer hover:bg-base-300 hidden @md:table-cell">
{$t('org.table.members')}
<span class:invisible={sortColumn !== 'members'} class="{`i-mdi-sort-${sortDir}`} text-xl align-[-5px] ml-2" />
</th>
<th on:click={() => handleSortClick('created_at')} class="cursor-pointer hover:bg-base-300 hidden @xl:table-cell">
{$t('org.table.created_at')}
<span class:invisible={sortColumn !== 'created_at'} class="{`i-mdi-sort-${sortDir}`} text-xl align-[-5px] ml-2" />
</th>
</tr>
</thead>
<tbody>
{#each displayOrgs as org}
<tr>
<td>
<a class="link" href={`/org/${org.id}`}>
{org.name}
</a>
</td>
<td class="hidden @md:table-cell">
{$number(org.memberCount)}
</td>
<td class="hidden @xl:table-cell">
{$date(org.createdDate)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Page>
28 changes: 28 additions & 0 deletions frontend/src/routes/(authenticated)/org/list/+page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { getClient, graphql } from '$lib/gql';

import type {PageLoadEvent} from './$types';
import { tryMakeNonNullable } from '$lib/util/store';

export type OrgListSearchParams = {
search: string,
};

export async function load(event: PageLoadEvent) {
const client = getClient();
const orgQueryResult = await client
.awaitedQueryStore(event.fetch,
graphql(`
query orgListPage {
orgs {
id
name
createdDate
memberCount
}
}
`),
{}
);
const nonNullableOrgs = tryMakeNonNullable(orgQueryResult.orgs);
return {orgs: nonNullableOrgs};
}
1 change: 1 addition & 0 deletions frontend/tailwind.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module.exports = {
'winter': {
...require("daisyui/src/theming/themes")["winter"],
"warning": "#FFBE00", // warning color from corporate, because it has much better contrast
"primary": "#0058BF", // easier on the eyes
},
},
{
Expand Down
Loading