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

Feat/414 large numbers of projects on the home page are difficult to navigate #462

7 changes: 7 additions & 0 deletions frontend/src/lib/app.postcss
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ form {
.text-accent {
@apply !text-accent;
}
.text-success {
@apply !text-success;
}
}

.pale {
Expand Down Expand Up @@ -151,3 +154,7 @@ table tr:nth-last-child(-n + 2):not(:nth-child(-n + 2)) .dropdown {
}
}
}

.header-actions .btn {
@apply btn-sm sm:btn-md;
}
4 changes: 2 additions & 2 deletions frontend/src/lib/components/Dropdown.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@
class:dropdown-right={right}
class:dropdown-hover={hover}>
<button class="contents" on:mousedown={blurIfOpen}>
<slot close={blurIfOpen} />
<slot closeDropdown={blurIfOpen} />
</button>
<div class="dropdown-content bg-base-200 shadow rounded-box z-[2]">
<slot name="content" />
<slot closeDropdown={blurIfOpen} name="content" />
</div>
</div>
4 changes: 2 additions & 2 deletions frontend/src/lib/components/EditableText.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@

<span>
{#if editing || saving}
<span class="inline-flex not-prose space-x-2 relative max-sm:flex-col" class:w-full={multiline}>
<span class="inline-flex not-prose space-x-2 relative max-sm:flex-col max-w-full max-sm:w-full" class:w-full={multiline}>
<!-- svelte-ignore a11y-autofocus -->
<span
class="tooltip-error tooltip-open tooltip-bottom"
Expand All @@ -114,7 +114,7 @@
autofocus
bind:value={$form[id]}
readonly={saving}
class="input input-bordered mt-1 mb-0"
class="input input-bordered mb-0"
/>
{/if}
</Form>
Expand Down
43 changes: 27 additions & 16 deletions frontend/src/lib/components/FilterBar/FilterBar.svelte
Original file line number Diff line number Diff line change
@@ -1,51 +1,62 @@
<script lang="ts" context="module">
export type Filter<T = Record<string, unknown>> = Readonly<
export type Filter<T = Record<string, unknown>> = Readonly<NonNullable<
{
[K in keyof T]: { value: T[K]; key: K & string, clear: () => void };
}[keyof T]
>;
>>;
</script>

<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { ConditionalPick } from 'type-fest';
import { pick } from '$lib/util/object';

import t from '$lib/i18n';
import type { Writable } from 'svelte/store';
import Dropdown from '../Dropdown.svelte';

type Filters = $$Generic<Record<string, unknown>>;

export let search = '';
const dispatch = createEventDispatcher<{
change: Readonly<Filter<Filters>[]>;
}>();

export let searchKey: keyof ConditionalPick<Filters, string>;
export let autofocus: true | undefined = undefined;
let allFilters: Writable<Filters>;
export { allFilters as filters };
let allDefaultValues: Filters;
export { allDefaultValues as defaultValues };
export let hasActiveFilter: boolean;
let allFilterDefaults: Filters;
export { allFilterDefaults as filterDefaults };
export let hasActiveFilter: boolean = false;

/**
* Explicitly specify the filter object keys that should be used from the `filters` (optional)
*/
export let filterKeys: Set<keyof Filters> | undefined = undefined;
export let filterKeys: (keyof Filters)[] | undefined = undefined;

$: filters = Object.freeze(filterKeys ? pick($allFilters, filterKeys) : $allFilters);
$: defaultValues = Object.freeze(filterKeys ? pick(allDefaultValues, filterKeys) : allDefaultValues);
$: activeFilters = pickActiveFilters(filters, defaultValues);
$: filterDefaults = Object.freeze(filterKeys ? pick(allFilterDefaults, filterKeys) : allFilterDefaults);
let activeFilters: Readonly<Filter<Filters>[]>;
$: {
hasActiveFilter = activeFilters.length > 0;
const currFilters = activeFilters;
const newFilters = pickActiveFilters(filters, filterDefaults);
if (JSON.stringify(currFilters) !== JSON.stringify(newFilters)) {
activeFilters = newFilters;
dispatch('change', activeFilters);
}
}
$: hasActiveFilter = activeFilters.length > 0;

function reseFilters(): void {
$allFilters = {
...$allFilters,
...defaultValues,
...filterDefaults,
}
}

function resetFilter(key: string): void {
$allFilters = {
...$allFilters,
[key]: defaultValues[key],
[key]: filterDefaults[key],
};
}

Expand All @@ -61,13 +72,13 @@
}
</script>

<div class="input filter-bar input-bordered flex items-center gap-2 py-1.5 px-2 mt-4 flex-wrap h-[unset] min-h-12">
<div class="input filter-bar input-bordered flex items-center gap-2 py-1.5 px-2 flex-wrap h-[unset] min-h-12">
<slot name="activeFilters" {activeFilters} />
<div class="flex grow">
<!-- svelte-ignore a11y-autofocus -->
<input
bind:value={search}
placeholder={$t('admin_dashboard.filter_placeholder')}
bind:value={$allFilters[searchKey]}
placeholder={$t('filter.placeholder')}
class="seach-input input border-none h-8 px-1 focus:outline-none min-w-[120px] flex-grow"
{autofocus}
/>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/components/IconButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
export let icon: `i-mdi-${string}`;
export let disabled = false;
export let loading = false;
export let style: CssClassList<'btn-success', 'btn-ghost' | 'btn-outline'> = 'btn-outline';
export let active = false;
export let join = false;
export let style: CssClassList<'btn-success', 'btn-ghost' | 'btn-outline'> = 'btn-outline';

</script>

Expand Down
9 changes: 9 additions & 0 deletions frontend/src/lib/components/Paging/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const DEFAULT_PAGE_SIZE = 100;

export function limit<T>(items: T[], take = DEFAULT_PAGE_SIZE): T[] {
return page(items, 0, take);
}

export function page<T>(item: T[], skip = 0, take = DEFAULT_PAGE_SIZE): T[] {
return item.slice(skip, skip + take);
}
30 changes: 5 additions & 25 deletions frontend/src/lib/components/ProjectList.svelte
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
<script lang="ts">
import type { LoadProjectsQuery } from '$lib/gql/types';
import t from '$lib/i18n';
import { Badge } from './Badges';
import { getProjectTypeIcon } from './ProjectType';
import type { ProjectItem } from './Projects';

export let projects: LoadProjectsQuery['myProjects'];
export let showCreateButton = true;
export let projects: ProjectItem[];
</script>

<div class="grid grid-cols-2 sm:grid-cols-3 auto-rows-fr gap-2 md:gap-4">
{#each projects as project}
<a class="card bg-base-200 shadow-base-300 group overflow-hidden" href={`/project/${project.code}`}>
<a class="card aspect-square bg-base-200 shadow-base-300 group overflow-hidden" href={`/project/${project.code}`}>
<div class="bg" style="background-image: url('{getProjectTypeIcon(project.type)}')" />
<div class="card-body z-[1]">
<h2 class="card-title overflow-hidden text-ellipsis" title={project.name}>
Expand Down Expand Up @@ -39,23 +38,8 @@
</div>
</a>
{/each}

{#if showCreateButton}
<a class="card border-4 border-base-200 shadow-base-300" class:in-center-column={!projects.length} href="/project/create">
<div class="card-body mx-auto justify-center items-center text-primary">
<span class="i-mdi-plus text-4xl"/>
<span class="text-xl text-center">{$t('project.create.title')}</span>
</div>
</a>
{/if}
</div>

{#if !showCreateButton && !projects.length}
<div class="text-lg text-secondary flex gap-4 items-center justify-center">
<span class="i-mdi-creation-outline text-xl shrink-0" /> {$t('user_dashboard.no_projects')}
</div>
{/if}

<style lang="postcss">
.card {
@apply shadow-lg
Expand All @@ -72,22 +56,18 @@
h-full
z-0
bg-no-repeat
bottom-[-43%]
right-[-55%]
opacity-50
transition
duration-200;

background-size: auto 120px;
right: calc(-100% + 100px);
bottom: calc(-100% + 120px);
}


&:hover .bg {
@apply opacity-100;
}
}

.in-center-column {
grid-column: 2;
}
</style>
148 changes: 148 additions & 0 deletions frontend/src/lib/components/Projects/ProjectFilter.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<script context="module" lang="ts">
import { type Project, ProjectMigrationStatus, type ProjectType } from '$lib/gql/types';

export type ProjectItem = Pick<Project, 'id' | 'name' | 'code' | 'type'> & Partial<Project>;

export type ProjectFilters = {
projectSearch: string;
projectType: ProjectType | undefined;
showDeletedProjects: boolean;
migrationStatus: ProjectMigrationStatus | 'UNMIGRATED' | undefined;
userEmail: string | undefined;
};

function matchMigrationStatus(
filter: ProjectMigrationStatus | 'UNMIGRATED' | undefined,
status: ProjectMigrationStatus,
): boolean {
return (
!filter ||
filter === status ||
(filter === 'UNMIGRATED' &&
(status === ProjectMigrationStatus.Unknown ||
status === ProjectMigrationStatus.PrivateRedmine ||
status === ProjectMigrationStatus.PublicRedmine))
);
}

export function filterProjects(
projects: ProjectItem[],
projectFilters: Partial<ProjectFilters>,
): ProjectItem[] {
const searchLower = projectFilters.projectSearch?.toLocaleLowerCase();
return projects.filter(
(p) =>
(!searchLower ||
p.name.toLocaleLowerCase().includes(searchLower) ||
p.code.toLocaleLowerCase().includes(searchLower)) &&
(!projectFilters.projectType || p.type === projectFilters.projectType) &&
(!p.migrationStatus || matchMigrationStatus(projectFilters.migrationStatus, p.migrationStatus)),
);
}
</script>

<script lang="ts">
import { FormField, ProjectTypeSelect } from '$lib/forms';
import type { Writable } from 'svelte/store';
import { ProjectTypeIcon } from '../ProjectType';
import ActiveFilter from '../FilterBar/ActiveFilter.svelte';
import FilterBar from '../FilterBar/FilterBar.svelte';
import { AuthenticatedUserIcon, TrashIcon } from '$lib/icons';
import t from '$lib/i18n';
import { bubbleFocusOnDestroy } from '$lib/util/focus';
import IconButton from '../IconButton.svelte';
import MigrationStatusSelect from '$lib/forms/MigrationStatusSelect.svelte';

type Filters = Partial<ProjectFilters> & Pick<ProjectFilters, 'projectSearch'>;
export let filters: Writable<Filters>;
export let filterDefaults: Filters;
export let projects: ProjectItem[];
export let filteredProjects: ProjectItem[];
export let hasActiveFilter: boolean = false;
export let autofocus: true | undefined = undefined;
export let filterKeys: (keyof Filters)[] = ['projectSearch', 'projectType', 'migrationStatus', 'showDeletedProjects', 'userEmail'];

$: filteredProjects = filterProjects(projects, $filters);

function filterEnabled(filter: keyof Filters): boolean {
return filterKeys.includes(filter);
}
</script>

<FilterBar on:change searchKey="projectSearch" {autofocus} {filters} {filterDefaults} bind:hasActiveFilter {filterKeys}>
<svelte:fragment slot="activeFilters" let:activeFilters>
{#each activeFilters as filter}
{#if filter.key === 'projectType'}
<ActiveFilter {filter}>
<ProjectTypeIcon type={filter.value} />
</ActiveFilter>
{:else if filter.key === 'showDeletedProjects'}
<ActiveFilter {filter}>
<TrashIcon color="text-error" />
{$t('project.filter.show_deleted')}
</ActiveFilter>
{:else if filter.key === 'userEmail' && filter.value}
<ActiveFilter {filter}>
<AuthenticatedUserIcon />
{filter.value}
</ActiveFilter>
{:else if filter.key === 'migrationStatus'}
<ActiveFilter {filter}>
{filter.value}
</ActiveFilter>
{/if}
{/each}
</svelte:fragment>
<svelte:fragment slot="filters">
<h2 class="card-title">{$t('project.filter.title')}</h2>
{#if filterEnabled('userEmail')}
<FormField label={$t('project.filter.project_member')}>
{#if $filters.userEmail}
<div class="join" use:bubbleFocusOnDestroy>
<input
class="input input-bordered join-item flex-grow"
readonly
value={$filters.userEmail}
/>
<div class="join-item isolate">
<IconButton icon="i-mdi-close" style="btn-outline" on:click={() => ($filters.userEmail = undefined)} />
</div>
</div>
{:else}
<div class="alert alert-info gap-2">
<span class="i-mdi-info-outline text-xl"></span>
<div class="flex_ items-center gap-2">
<span class="mr-1">{$t('project.filter.select_user_from_table')}</span>
<span class="btn btn-sm btn-square pointer-events-none">
<span class="i-mdi-dots-vertical"></span>
</span>
<span class="i-mdi-chevron-right"></span>
<span class="btn btn-sm pointer-events-none normal-case font-normal">
<span class="i-mdi-filter-outline mr-1"></span>
{$t('project.filter.filter_user_projects')}
</span>
</div>
</div>
{/if}
</FormField>
{/if}
{#if filterEnabled('projectType')}
<div class="form-control">
<ProjectTypeSelect bind:value={$filters.projectType} undefinedOptionLabel={$t('project_type.any')} />
</div>
{/if}
{#if filterEnabled('migrationStatus')}
<div class="form-control">
<MigrationStatusSelect bind:value={$filters.migrationStatus} />
</div>
{/if}
{#if filterEnabled('showDeletedProjects')}
<div class="form-control">
<label class="cursor-pointer label gap-4">
<span class="label-text">{$t('project.filter.show_deleted')}</span>
<input bind:checked={$filters.showDeletedProjects} type="checkbox" class="toggle toggle-error" />
</label>
</div>
{/if}
</svelte:fragment>
</FilterBar>
Loading