feat: User-sort browser profiles list (#1839)

Resolves https://github.com/webrecorder/browsertrix/issues/1409

### Changes

- Enables clicking on Browser Profiles column header to sort the table, including by starting URL
- More consistent column widths throughout app

---------

Co-authored-by: Tessa Walsh <tessa@bitarchivist.net>
Co-authored-by: emma <hi@emma.cafe>
Co-authored-by: Henry Wilkinson <henry@wilkinson.graphics>
This commit is contained in:
sua yoo 2024-06-04 10:57:03 -07:00 committed by GitHub
parent c2ce96c057
commit 4d4c8a04d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 276 additions and 106 deletions

View File

@ -287,11 +287,14 @@ class ProfileOps:
aggregate: List[Dict[str, Any]] = [{"$match": match_query}]
if sort_by:
if sort_by not in ("modified", "created", "name"):
if sort_by not in ("modified", "created", "name", "url"):
raise HTTPException(status_code=400, detail="invalid_sort_by")
if sort_direction not in (1, -1):
raise HTTPException(status_code=400, detail="invalid_sort_direction")
if sort_by == "url":
sort_by = "origins.0"
aggregate.extend([{"$sort": {sort_by: sort_direction}}])
aggregate.extend(

View File

@ -512,7 +512,9 @@ def profile_browser_id(admin_auth_headers, default_org_id):
@pytest.fixture(scope="session")
def profile_browser_2_id(admin_auth_headers, default_org_id):
return _create_profile_browser(admin_auth_headers, default_org_id)
return _create_profile_browser(
admin_auth_headers, default_org_id, "https://specs.webrecorder.net"
)
@pytest.fixture(scope="session")
@ -520,11 +522,13 @@ def profile_browser_3_id(admin_auth_headers, default_org_id):
return _create_profile_browser(admin_auth_headers, default_org_id)
def _create_profile_browser(headers: Dict[str, str], oid: UUID):
def _create_profile_browser(
headers: Dict[str, str], oid: UUID, url: str = "https://webrecorder.net"
):
r = requests.post(
f"{API_PREFIX}/orgs/{oid}/profiles/browser",
headers=headers,
json={"url": "https://webrecorder.net"},
json={"url": url},
)
assert r.status_code == 200
browser_id = r.json()["browserid"]

View File

@ -432,6 +432,10 @@ def test_commit_browser_to_existing_profile(
("name", -1, 0, 1),
# Name, ascending
("name", 1, 1, 0),
# URL, descending
("url", -1, 0, 1),
# URL, ascending
("url", 1, 1, 0),
],
)
def test_sort_profiles(

View File

@ -1,10 +1,12 @@
import { css, html, LitElement } from "lit";
import { css, html } from "lit";
import {
customElement,
property,
queryAssignedElements,
} from "lit/decorators.js";
import { TailwindElement } from "@/classes/TailwindElement";
export const ALLOWED_ROW_CLICK_TARGET_TAG = ["a", "label"] as const;
/**
@ -27,7 +29,7 @@ export const ALLOWED_ROW_CLICK_TARGET_TAG = ["a", "label"] as const;
* @cssproperty --btrix-cell-padding-bottom
*/
@customElement("btrix-table-cell")
export class TableCell extends LitElement {
export class TableCell extends TailwindElement {
static styles = css`
:host {
display: flex;

View File

@ -3,15 +3,23 @@ import { customElement, property } from "lit/decorators.js";
import { TableCell } from "./table-cell";
import type { SortDirection as Direction } from "@/types/utils";
export type SortValues = "ascending" | "descending" | "none";
export const SortDirection = new Map<Direction, SortValues>([
[-1, "descending"],
[1, "ascending"],
]);
@customElement("btrix-table-header-cell")
export class TableHeaderCell extends TableCell {
@property({ type: String, reflect: true, noAccessor: true })
role = "columnheader";
@property({ type: String, reflect: true, noAccessor: true })
ariaSort = "none";
@property({ type: String, reflect: true })
ariaSort: SortValues = "none";
render() {
return html`<slot></slot>`;
return html` <slot></slot> `;
}
}

View File

@ -218,7 +218,7 @@ export class ArchivedItemListItem extends TailwindElement {
</label>`
: html`<div>${rowName}</div>`}
</btrix-table-cell>
<btrix-table-cell>
<btrix-table-cell class="tabular-nums">
<sl-tooltip
content=${msg(str`By ${this.item.userName}`)}
@click=${this.onTooltipClick}
@ -231,12 +231,10 @@ export class ArchivedItemListItem extends TailwindElement {
month="2-digit"
day="2-digit"
year="2-digit"
hour="2-digit"
minute="2-digit"
></sl-format-date>
</sl-tooltip>
</btrix-table-cell>
<btrix-table-cell>
<btrix-table-cell class="tabular-nums">
<sl-tooltip
hoist
content=${formatNumber(this.item.fileSize || 0, {
@ -253,7 +251,7 @@ export class ArchivedItemListItem extends TailwindElement {
></sl-format-bytes>
</sl-tooltip>
</btrix-table-cell>
<btrix-table-cell>
<btrix-table-cell class="tabular-nums">
${isUpload
? notApplicable
: html`<sl-tooltip
@ -275,7 +273,7 @@ export class ArchivedItemListItem extends TailwindElement {
</div>
</sl-tooltip>`}
</btrix-table-cell>
<btrix-table-cell>
<btrix-table-cell class="tabular-nums">
${isUpload
? notApplicable
: lastQAStarted && qaRunCount
@ -387,7 +385,7 @@ export class ArchivedItemList extends TailwindElement {
</btrix-table-header-cell>`,
},
{
cssCol: "12rem",
cssCol: "1fr",
cell: html`<btrix-table-header-cell>
${msg("Date Created")}
</btrix-table-header-cell>`,

View File

@ -80,6 +80,7 @@ export class BrowserProfilesDetail extends TailwindElement {
private readonly navigate = new NavigateController(this);
private readonly notify = new NotifyController(this);
private readonly validateNameMax = maxLengthValidator(50);
private readonly validateDescriptionMax = maxLengthValidator(500);
disconnectedCallback() {
@ -139,7 +140,7 @@ export class BrowserProfilesDetail extends TailwindElement {
: none
: nothing}
</btrix-desc-list-item>
<btrix-desc-list-item label=${msg("Created At")}>
<btrix-desc-list-item label=${msg("Created On")}>
${this.profile
? html`
<sl-format-date
@ -434,16 +435,17 @@ export class BrowserProfilesDetail extends TailwindElement {
private renderEditProfile() {
if (!this.profile) return;
const { helpText, validate } = this.validateDescriptionMax;
return html`
<form @submit=${this.onSubmitEdit}>
<div class="mb-5">
<div>
<sl-input
name="name"
class="with-max-help-text"
label=${msg("Name")}
autocomplete="off"
value=${this.profile.name}
help-text=${this.validateNameMax.helpText}
@sl-input=${this.validateNameMax.validate}
required
></sl-input>
</div>
@ -451,13 +453,14 @@ export class BrowserProfilesDetail extends TailwindElement {
<div class="mb-5">
<sl-textarea
name="description"
class="with-max-help-text"
label=${msg("Description")}
value=${this.profile.description || ""}
rows="3"
autocomplete="off"
resize="auto"
help-text=${helpText}
@sl-input=${validate}
help-text=${this.validateDescriptionMax.helpText}
@sl-input=${this.validateDescriptionMax.validate}
></sl-textarea>
</div>

View File

@ -1,5 +1,6 @@
import { localized, msg, str } from "@lit/localize";
import { nothing, type PropertyValues } from "lit";
import clsx from "clsx";
import { css, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { when } from "lit/directives/when.js";
import queryString from "query-string";
@ -8,12 +9,25 @@ import type { Profile } from "./types";
import type { SelectNewDialogEvent } from ".";
import { TailwindElement } from "@/classes/TailwindElement";
import type { PageChangeEvent } from "@/components/ui/pagination";
import type { APIPaginatedList, APIPaginationQuery } from "@/types/api";
import {
SortDirection,
type SortValues,
} from "@/components/ui/table/table-header-cell";
import { APIController } from "@/controllers/api";
import { NavigateController } from "@/controllers/navigate";
import { NotifyController } from "@/controllers/notify";
import type {
APIPaginatedList,
APIPaginationQuery,
APISortQuery,
} from "@/types/api";
import type { Browser } from "@/types/browser";
import type { AuthState } from "@/utils/AuthService";
import LiteElement, { html } from "@/utils/LiteElement";
import { html } from "@/utils/LiteElement";
import { getLocale } from "@/utils/localization";
import { tw } from "@/utils/tailwind";
const INITIAL_PAGE_SIZE = 20;
@ -28,7 +42,7 @@ const INITIAL_PAGE_SIZE = 20;
*/
@localized()
@customElement("btrix-browser-profiles-list")
export class BrowserProfilesList extends LiteElement {
export class BrowserProfilesList extends TailwindElement {
@property({ type: Object })
authState!: AuthState;
@ -41,10 +55,54 @@ export class BrowserProfilesList extends LiteElement {
@state()
browserProfiles?: APIPaginatedList<Profile>;
@state()
sort: Required<APISortQuery> = {
sortBy: "modified",
sortDirection: -1,
};
@state()
private isLoading = true;
static styles = css`
btrix-table {
grid-template-columns:
[clickable-start] minmax(30ch, 50ch) minmax(30ch, 40ch) repeat(2, 1fr)
[clickable-end] min-content;
--btrix-cell-gap: var(--sl-spacing-x-small);
--btrix-cell-padding-left: var(--sl-spacing-small);
--btrix-cell-padding-right: var(--sl-spacing-small);
}
btrix-table-body btrix-table-row:nth-of-type(n + 2) {
--btrix-border-top: 1px solid var(--sl-panel-border-color);
}
btrix-table-body btrix-table-row:first-of-type {
--btrix-border-radius-top: var(--sl-border-radius-medium);
}
btrix-table-body btrix-table-row:last-of-type {
--btrix-border-radius-bottom: var(--sl-border-radius-medium);
}
btrix-table-row {
border-top: var(--btrix-border-top, 0);
border-radius: var(--btrix-border-radius-top, 0)
var(--btrix-border-radius-to, 0) var(--btrix-border-radius-bottom, 0)
var(--btrix-border-radius-bottom, 0);
height: 2.5rem;
}
`;
private readonly api = new APIController(this);
private readonly navigate = new NavigateController(this);
private readonly notify = new NotifyController(this);
protected willUpdate(
changedProperties: PropertyValues<this> & Map<string, unknown>,
) {
if (changedProperties.has("orgId")) {
if (changedProperties.has("orgId") || changedProperties.has("sort")) {
void this.fetchBrowserProfiles();
}
}
@ -76,43 +134,101 @@ export class BrowserProfilesList extends LiteElement {
)}
</div>
</header>
<div class="overflow-auto px-2 pb-1">${this.renderTable()}</div>`;
<div class="pb-1">${this.renderTable()}</div>`;
}
private renderTable() {
const headerCells = [
{
sortBy: "name",
sortDirection: 1,
className: "pl-3",
label: msg("Name"),
},
{ sortBy: "url", sortDirection: 1, label: msg("Visited URLs") },
{ sortBy: "created", sortDirection: -1, label: msg("Created On") },
{ sortBy: "modified", sortDirection: -1, label: msg("Last Updated") },
];
const sortProps: Record<
SortValues,
{ name: string; label: string; className: string }
> = {
none: {
name: "arrow-down-up",
label: msg("Sortable"),
className: tw`text-xs opacity-0 hover:opacity-100 group-hover:opacity-100`,
},
ascending: {
name: "sort-up-alt",
label: msg("Ascending"),
className: tw`text-base`,
},
descending: {
name: "sort-down",
label: msg("Descending"),
className: tw`text-base`,
},
};
const getSortIcon = (sortValue: SortValues) => {
const { name, label, className } = sortProps[sortValue];
return html`
<btrix-table
style="grid-template-columns: [clickable-start] 60ch repeat(2, auto) [clickable-end] min-content; --btrix-cell-padding-left: var(--sl-spacing-x-small); --btrix-cell-padding-right: var(--sl-spacing-x-small);"
>
<sl-icon
class=${clsx(tw`ml-1 text-neutral-900 transition-opacity`, className)}
name=${name}
label=${label}
></sl-icon>
`;
};
return html`
<btrix-table class="-mx-3 overflow-x-auto px-3">
<btrix-table-head class="mb-2">
<btrix-table-header-cell class="pl-3">
${msg("Name")}
</btrix-table-header-cell>
<btrix-table-header-cell>
${msg("Visited URLs")}
</btrix-table-header-cell>
<btrix-table-header-cell>
${msg("Last Updated")}
${headerCells.map(({ sortBy, sortDirection, label, className }) => {
const isSorting = sortBy === this.sort.sortBy;
const sortValue =
(isSorting && SortDirection.get(this.sort.sortDirection)) ||
"none";
// TODO implement sort render logic in table-header-cell
return html`
<btrix-table-header-cell
class="${className} group cursor-pointer rounded transition-colors hover:bg-primary-50"
ariaSort=${sortValue}
@click=${() => {
if (isSorting) {
this.sort = {
...this.sort,
sortDirection: this.sort.sortDirection * -1,
};
} else {
this.sort = {
sortBy,
sortDirection,
};
}
}}
>
${label} ${getSortIcon(sortValue)}
</btrix-table-header-cell>
`;
})}
<btrix-table-header-cell>
<span class="sr-only">${msg("Row Actions")}</span>
</btrix-table-header-cell>
</btrix-table-head>
${when(this.browserProfiles, ({ total, items }) =>
total
? html`
<btrix-table-body
style="--btrix-row-gap: var(--sl-spacing-x-small); --btrix-cell-padding-top: var(--sl-spacing-2x-small); --btrix-cell-padding-bottom: var(--sl-spacing-2x-small);"
>
${items.map(this.renderItem)}
</btrix-table-body>
`
: nothing,
class=${clsx(
"relative rounded border",
this.browserProfiles == null && this.isLoading && tw`min-h-48`,
)}
>
${when(this.browserProfiles, ({ total, items }) =>
total ? html` ${items.map(this.renderItem)} ` : nothing,
)}
${when(this.isLoading, this.renderLoading)}
</btrix-table-body>
</btrix-table>
${when(
this.browserProfiles,
({ total, page, pageSize }) =>
${when(this.browserProfiles, ({ total, page, pageSize }) =>
total
? html`
<footer class="mt-6 flex justify-center">
@ -133,20 +249,21 @@ export class BrowserProfilesList extends LiteElement {
</p>
</div>
`,
this.renderLoading,
)}
`;
}
private readonly renderLoading = () =>
html`<div class="my-24 flex w-full items-center justify-center text-3xl">
html`<div
class="absolute left-0 top-0 z-10 flex h-full w-full items-center justify-center bg-white/50 text-3xl"
>
<sl-spinner></sl-spinner>
</div>`;
private readonly renderItem = (data: Profile) => {
return html`
<btrix-table-row
class="cursor-pointer select-none rounded border shadow transition-all focus-within:bg-neutral-50 hover:bg-neutral-50 hover:shadow-none"
class="cursor-pointer select-none transition-all focus-within:bg-neutral-50 hover:bg-neutral-50 hover:shadow-none"
>
<btrix-table-cell
class="flex-col items-center justify-center pl-3"
@ -154,28 +271,45 @@ export class BrowserProfilesList extends LiteElement {
>
<a
class="flex items-center gap-3"
href=${`${this.orgBasePath}/browser-profiles/profile/${data.id}`}
@click=${this.navLink}
href=${`${this.navigate.orgBasePath}/browser-profiles/profile/${data.id}`}
@click=${this.navigate.link}
>
${data.name}
<span class="truncate">${data.name}</span>
</a>
</btrix-table-cell>
<btrix-table-cell>
${data.origins[0]}${data.origins.length > 1
? html`<sl-tooltip
class="invert-tooltip"
content=${data.origins.slice(1).join(", ")}
<div class="truncate">${data.origins[0]}</div>
${data.origins.length > 1
? html`<sl-tooltip class="invert-tooltip">
<span slot="content" class=" break-words"
>${data.origins.slice(1).join(", ")}</span
>
<sl-tag size="small" class="ml-2">
<btrix-badge class="ml-2">
${msg(str`+${data.origins.length - 1}`)}
</sl-tag>
</btrix-badge>
</sl-tooltip>`
: nothing}
</btrix-table-cell>
<btrix-table-cell class="whitespace-nowrap">
<btrix-table-cell class="whitespace-nowrap tabular-nums">
<sl-tooltip
content=${msg(str`By ${data.createdByName}`)}
?disabled=${!data.createdByName}
>
<sl-format-date
lang=${getLocale()}
date=${`${data.created}Z` /** Z for UTC */}
month="2-digit"
day="2-digit"
year="2-digit"
hour="2-digit"
minute="2-digit"
></sl-format-date>
</sl-tooltip>
</btrix-table-cell>
<btrix-table-cell class="whitespace-nowrap tabular-nums">
<sl-tooltip
content=${msg(str`By ${data.modifiedByName || data.createdByName}`)}
?disabled=${!(data.modifiedByName || data.createdByName)}
?disabled=${!data.createdByName}
>
<sl-format-date
lang=${getLocale()}
@ -232,14 +366,14 @@ export class BrowserProfilesList extends LiteElement {
try {
const data = await this.createBrowser({ url });
this.notify({
this.notify.toast({
message: msg("Starting up browser with selected profile..."),
variant: "success",
icon: "check2-circle",
});
this.navTo(
`${this.orgBasePath}/browser-profiles/profile/browser/${
this.navigate.to(
`${this.navigate.orgBasePath}/browser-profiles/profile/browser/${
data.browserid
}?${queryString.stringify({
url,
@ -250,7 +384,7 @@ export class BrowserProfilesList extends LiteElement {
})}`,
);
} catch (e) {
this.notify({
this.notify.toast({
message: msg("Sorry, couldn't create browser profile at this time."),
variant: "danger",
icon: "exclamation-octagon",
@ -260,7 +394,7 @@ export class BrowserProfilesList extends LiteElement {
private async deleteProfile(profile: Profile) {
try {
const data = await this.apiFetch<Profile & { error?: boolean }>(
const data = await this.api.fetch<Profile & { error?: boolean }>(
`/orgs/${this.orgId}/profiles/${profile.id}`,
this.authState!,
{
@ -269,7 +403,7 @@ export class BrowserProfilesList extends LiteElement {
);
if (data.error && data.crawlconfigs) {
this.notify({
this.notify.toast({
message: msg(
html`Could not delete <strong>${profile.name}</strong>, in use by
<strong
@ -281,7 +415,7 @@ export class BrowserProfilesList extends LiteElement {
duration: 15000,
});
} else {
this.notify({
this.notify.toast({
message: msg(html`Deleted <strong>${profile.name}</strong>.`),
variant: "success",
icon: "check2-circle",
@ -290,7 +424,7 @@ export class BrowserProfilesList extends LiteElement {
void this.fetchBrowserProfiles();
}
} catch (e) {
this.notify({
this.notify.toast({
message: msg("Sorry, couldn't delete browser profile at this time."),
variant: "danger",
icon: "exclamation-octagon",
@ -303,7 +437,7 @@ export class BrowserProfilesList extends LiteElement {
url,
};
return this.apiFetch<Browser>(
return this.api.fetch<Browser>(
`/orgs/${this.orgId}/profiles/browser`,
this.authState!,
{
@ -320,6 +454,7 @@ export class BrowserProfilesList extends LiteElement {
params?: APIPaginationQuery,
): Promise<void> {
try {
this.isLoading = true;
const data = await this.getProfiles({
page: params?.page || this.browserProfiles?.page || 1,
pageSize:
@ -330,20 +465,28 @@ export class BrowserProfilesList extends LiteElement {
this.browserProfiles = data;
} catch (e) {
this.notify({
this.notify.toast({
message: msg("Sorry, couldn't retrieve browser profiles at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
} finally {
this.isLoading = false;
}
}
private async getProfiles(params: APIPaginationQuery) {
const query = queryString.stringify(params, {
const query = queryString.stringify(
{
...params,
...this.sort,
},
{
arrayFormat: "comma",
});
},
);
const data = await this.apiFetch<APIPaginatedList<Profile>>(
const data = await this.api.fetch<APIPaginatedList<Profile>>(
`/orgs/${this.orgId}/profiles?${query}`,
this.authState!,
);

View File

@ -400,7 +400,7 @@ export class CollectionsList extends LiteElement {
if (this.collections?.items.length) {
return html`
<btrix-table
style="grid-template-columns: min-content [clickable-start] 60ch repeat(3, 1fr) 12rem [clickable-end] min-content"
style="grid-template-columns: min-content [clickable-start] 50ch repeat(4, 1fr) [clickable-end] min-content"
>
<btrix-table-head class="mb-2">
<btrix-table-header-cell>

View File

@ -51,7 +51,7 @@ const sortableFields: Record<
defaultDirection: "asc",
},
created: {
label: msg("Created"),
label: msg("Date Created"),
defaultDirection: "desc",
},
};

View File

@ -1,3 +1,5 @@
import type { SortDirection } from "./utils";
/**
* If no generic type is specified, `items` cannot exist.
*/
@ -21,5 +23,5 @@ export type APIPaginationQuery = {
export type APISortQuery = {
sortBy?: string;
sortDirection?: number; // -1 | 1
sortDirection?: SortDirection;
};

View File

@ -21,3 +21,6 @@ export type Range<F extends number, T extends number> = Exclude<
Enumerate<T>,
Enumerate<F>
>;
/** 1 or -1, but will accept any number for easier typing where this is used **/
export type SortDirection = -1 | 1 | (number & {});