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}] aggregate: List[Dict[str, Any]] = [{"$match": match_query}]
if sort_by: 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") raise HTTPException(status_code=400, detail="invalid_sort_by")
if sort_direction not in (1, -1): if sort_direction not in (1, -1):
raise HTTPException(status_code=400, detail="invalid_sort_direction") 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([{"$sort": {sort_by: sort_direction}}])
aggregate.extend( aggregate.extend(

View File

@ -512,7 +512,9 @@ def profile_browser_id(admin_auth_headers, default_org_id):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def profile_browser_2_id(admin_auth_headers, default_org_id): 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") @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) 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( r = requests.post(
f"{API_PREFIX}/orgs/{oid}/profiles/browser", f"{API_PREFIX}/orgs/{oid}/profiles/browser",
headers=headers, headers=headers,
json={"url": "https://webrecorder.net"}, json={"url": url},
) )
assert r.status_code == 200 assert r.status_code == 200
browser_id = r.json()["browserid"] browser_id = r.json()["browserid"]

View File

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

View File

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

View File

@ -3,15 +3,23 @@ import { customElement, property } from "lit/decorators.js";
import { TableCell } from "./table-cell"; 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") @customElement("btrix-table-header-cell")
export class TableHeaderCell extends TableCell { export class TableHeaderCell extends TableCell {
@property({ type: String, reflect: true, noAccessor: true }) @property({ type: String, reflect: true, noAccessor: true })
role = "columnheader"; role = "columnheader";
@property({ type: String, reflect: true, noAccessor: true }) @property({ type: String, reflect: true })
ariaSort = "none"; ariaSort: SortValues = "none";
render() { render() {
return html`<slot></slot>`; return html` <slot></slot> `;
} }
} }

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { localized, msg, str } from "@lit/localize"; 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 { customElement, property, state } from "lit/decorators.js";
import { when } from "lit/directives/when.js"; import { when } from "lit/directives/when.js";
import queryString from "query-string"; import queryString from "query-string";
@ -8,12 +9,25 @@ import type { Profile } from "./types";
import type { SelectNewDialogEvent } from "."; import type { SelectNewDialogEvent } from ".";
import { TailwindElement } from "@/classes/TailwindElement";
import type { PageChangeEvent } from "@/components/ui/pagination"; 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 { Browser } from "@/types/browser";
import type { AuthState } from "@/utils/AuthService"; import type { AuthState } from "@/utils/AuthService";
import LiteElement, { html } from "@/utils/LiteElement"; import { html } from "@/utils/LiteElement";
import { getLocale } from "@/utils/localization"; import { getLocale } from "@/utils/localization";
import { tw } from "@/utils/tailwind";
const INITIAL_PAGE_SIZE = 20; const INITIAL_PAGE_SIZE = 20;
@ -28,7 +42,7 @@ const INITIAL_PAGE_SIZE = 20;
*/ */
@localized() @localized()
@customElement("btrix-browser-profiles-list") @customElement("btrix-browser-profiles-list")
export class BrowserProfilesList extends LiteElement { export class BrowserProfilesList extends TailwindElement {
@property({ type: Object }) @property({ type: Object })
authState!: AuthState; authState!: AuthState;
@ -41,10 +55,54 @@ export class BrowserProfilesList extends LiteElement {
@state() @state()
browserProfiles?: APIPaginatedList<Profile>; 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( protected willUpdate(
changedProperties: PropertyValues<this> & Map<string, unknown>, changedProperties: PropertyValues<this> & Map<string, unknown>,
) { ) {
if (changedProperties.has("orgId")) { if (changedProperties.has("orgId") || changedProperties.has("sort")) {
void this.fetchBrowserProfiles(); void this.fetchBrowserProfiles();
} }
} }
@ -76,77 +134,136 @@ export class BrowserProfilesList extends LiteElement {
)} )}
</div> </div>
</header> </header>
<div class="overflow-auto px-2 pb-1">${this.renderTable()}</div>`; <div class="pb-1">${this.renderTable()}</div>`;
} }
private renderTable() { 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`
<sl-icon
class=${clsx(tw`ml-1 text-neutral-900 transition-opacity`, className)}
name=${name}
label=${label}
></sl-icon>
`;
};
return html` return html`
<btrix-table <btrix-table class="-mx-3 overflow-x-auto px-3">
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);"
>
<btrix-table-head class="mb-2"> <btrix-table-head class="mb-2">
<btrix-table-header-cell class="pl-3"> ${headerCells.map(({ sortBy, sortDirection, label, className }) => {
${msg("Name")} const isSorting = sortBy === this.sort.sortBy;
</btrix-table-header-cell> const sortValue =
<btrix-table-header-cell> (isSorting && SortDirection.get(this.sort.sortDirection)) ||
${msg("Visited URLs")} "none";
</btrix-table-header-cell> // TODO implement sort render logic in table-header-cell
<btrix-table-header-cell> return html`
${msg("Last Updated")} <btrix-table-header-cell
</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> <btrix-table-header-cell>
<span class="sr-only">${msg("Row Actions")}</span> <span class="sr-only">${msg("Row Actions")}</span>
</btrix-table-header-cell> </btrix-table-header-cell>
</btrix-table-head> </btrix-table-head>
${when(this.browserProfiles, ({ total, items }) => <btrix-table-body
total class=${clsx(
? html` "relative rounded border",
<btrix-table-body this.browserProfiles == null && this.isLoading && tw`min-h-48`,
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)} ${when(this.browserProfiles, ({ total, items }) =>
</btrix-table-body> total ? html` ${items.map(this.renderItem)} ` : nothing,
` )}
: nothing, ${when(this.isLoading, this.renderLoading)}
)} </btrix-table-body>
</btrix-table> </btrix-table>
${when( ${when(this.browserProfiles, ({ total, page, pageSize }) =>
this.browserProfiles, total
({ total, page, pageSize }) => ? html`
total <footer class="mt-6 flex justify-center">
? html` <btrix-pagination
<footer class="mt-6 flex justify-center"> page=${page}
<btrix-pagination totalCount=${total}
page=${page} size=${pageSize}
totalCount=${total} @page-change=${async (e: PageChangeEvent) => {
size=${pageSize} void this.fetchBrowserProfiles({ page: e.detail.page });
@page-change=${async (e: PageChangeEvent) => { }}
void this.fetchBrowserProfiles({ page: e.detail.page }); ></btrix-pagination>
}} </footer>
></btrix-pagination> `
</footer> : html`
` <div class="border-b border-t py-5">
: html` <p class="text-center text-0-500">
<div class="border-b border-t py-5"> ${msg("No browser profiles yet.")}
<p class="text-center text-0-500"> </p>
${msg("No browser profiles yet.")} </div>
</p> `,
</div>
`,
this.renderLoading,
)} )}
`; `;
} }
private readonly 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> <sl-spinner></sl-spinner>
</div>`; </div>`;
private readonly renderItem = (data: Profile) => { private readonly renderItem = (data: Profile) => {
return html` return html`
<btrix-table-row <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 <btrix-table-cell
class="flex-col items-center justify-center pl-3" class="flex-col items-center justify-center pl-3"
@ -154,28 +271,45 @@ export class BrowserProfilesList extends LiteElement {
> >
<a <a
class="flex items-center gap-3" class="flex items-center gap-3"
href=${`${this.orgBasePath}/browser-profiles/profile/${data.id}`} href=${`${this.navigate.orgBasePath}/browser-profiles/profile/${data.id}`}
@click=${this.navLink} @click=${this.navigate.link}
> >
${data.name} <span class="truncate">${data.name}</span>
</a> </a>
</btrix-table-cell> </btrix-table-cell>
<btrix-table-cell> <btrix-table-cell>
${data.origins[0]}${data.origins.length > 1 <div class="truncate">${data.origins[0]}</div>
? html`<sl-tooltip ${data.origins.length > 1
class="invert-tooltip" ? html`<sl-tooltip class="invert-tooltip">
content=${data.origins.slice(1).join(", ")} <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}`)} ${msg(str`+${data.origins.length - 1}`)}
</sl-tag> </btrix-badge>
</sl-tooltip>` </sl-tooltip>`
: nothing} : nothing}
</btrix-table-cell> </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 <sl-tooltip
content=${msg(str`By ${data.modifiedByName || data.createdByName}`)} content=${msg(str`By ${data.modifiedByName || data.createdByName}`)}
?disabled=${!(data.modifiedByName || data.createdByName)} ?disabled=${!data.createdByName}
> >
<sl-format-date <sl-format-date
lang=${getLocale()} lang=${getLocale()}
@ -232,14 +366,14 @@ export class BrowserProfilesList extends LiteElement {
try { try {
const data = await this.createBrowser({ url }); const data = await this.createBrowser({ url });
this.notify({ this.notify.toast({
message: msg("Starting up browser with selected profile..."), message: msg("Starting up browser with selected profile..."),
variant: "success", variant: "success",
icon: "check2-circle", icon: "check2-circle",
}); });
this.navTo( this.navigate.to(
`${this.orgBasePath}/browser-profiles/profile/browser/${ `${this.navigate.orgBasePath}/browser-profiles/profile/browser/${
data.browserid data.browserid
}?${queryString.stringify({ }?${queryString.stringify({
url, url,
@ -250,7 +384,7 @@ export class BrowserProfilesList extends LiteElement {
})}`, })}`,
); );
} catch (e) { } catch (e) {
this.notify({ this.notify.toast({
message: msg("Sorry, couldn't create browser profile at this time."), message: msg("Sorry, couldn't create browser profile at this time."),
variant: "danger", variant: "danger",
icon: "exclamation-octagon", icon: "exclamation-octagon",
@ -260,7 +394,7 @@ export class BrowserProfilesList extends LiteElement {
private async deleteProfile(profile: Profile) { private async deleteProfile(profile: Profile) {
try { try {
const data = await this.apiFetch<Profile & { error?: boolean }>( const data = await this.api.fetch<Profile & { error?: boolean }>(
`/orgs/${this.orgId}/profiles/${profile.id}`, `/orgs/${this.orgId}/profiles/${profile.id}`,
this.authState!, this.authState!,
{ {
@ -269,7 +403,7 @@ export class BrowserProfilesList extends LiteElement {
); );
if (data.error && data.crawlconfigs) { if (data.error && data.crawlconfigs) {
this.notify({ this.notify.toast({
message: msg( message: msg(
html`Could not delete <strong>${profile.name}</strong>, in use by html`Could not delete <strong>${profile.name}</strong>, in use by
<strong <strong
@ -281,7 +415,7 @@ export class BrowserProfilesList extends LiteElement {
duration: 15000, duration: 15000,
}); });
} else { } else {
this.notify({ this.notify.toast({
message: msg(html`Deleted <strong>${profile.name}</strong>.`), message: msg(html`Deleted <strong>${profile.name}</strong>.`),
variant: "success", variant: "success",
icon: "check2-circle", icon: "check2-circle",
@ -290,7 +424,7 @@ export class BrowserProfilesList extends LiteElement {
void this.fetchBrowserProfiles(); void this.fetchBrowserProfiles();
} }
} catch (e) { } catch (e) {
this.notify({ this.notify.toast({
message: msg("Sorry, couldn't delete browser profile at this time."), message: msg("Sorry, couldn't delete browser profile at this time."),
variant: "danger", variant: "danger",
icon: "exclamation-octagon", icon: "exclamation-octagon",
@ -303,7 +437,7 @@ export class BrowserProfilesList extends LiteElement {
url, url,
}; };
return this.apiFetch<Browser>( return this.api.fetch<Browser>(
`/orgs/${this.orgId}/profiles/browser`, `/orgs/${this.orgId}/profiles/browser`,
this.authState!, this.authState!,
{ {
@ -320,6 +454,7 @@ export class BrowserProfilesList extends LiteElement {
params?: APIPaginationQuery, params?: APIPaginationQuery,
): Promise<void> { ): Promise<void> {
try { try {
this.isLoading = true;
const data = await this.getProfiles({ const data = await this.getProfiles({
page: params?.page || this.browserProfiles?.page || 1, page: params?.page || this.browserProfiles?.page || 1,
pageSize: pageSize:
@ -330,20 +465,28 @@ export class BrowserProfilesList extends LiteElement {
this.browserProfiles = data; this.browserProfiles = data;
} catch (e) { } catch (e) {
this.notify({ this.notify.toast({
message: msg("Sorry, couldn't retrieve browser profiles at this time."), message: msg("Sorry, couldn't retrieve browser profiles at this time."),
variant: "danger", variant: "danger",
icon: "exclamation-octagon", icon: "exclamation-octagon",
}); });
} finally {
this.isLoading = false;
} }
} }
private async getProfiles(params: APIPaginationQuery) { private async getProfiles(params: APIPaginationQuery) {
const query = queryString.stringify(params, { const query = queryString.stringify(
arrayFormat: "comma", {
}); ...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}`, `/orgs/${this.orgId}/profiles?${query}`,
this.authState!, this.authState!,
); );

View File

@ -400,7 +400,7 @@ export class CollectionsList extends LiteElement {
if (this.collections?.items.length) { if (this.collections?.items.length) {
return html` return html`
<btrix-table <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-head class="mb-2">
<btrix-table-header-cell> <btrix-table-header-cell>

View File

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

View File

@ -1,3 +1,5 @@
import type { SortDirection } from "./utils";
/** /**
* If no generic type is specified, `items` cannot exist. * If no generic type is specified, `items` cannot exist.
*/ */
@ -21,5 +23,5 @@ export type APIPaginationQuery = {
export type APISortQuery = { export type APISortQuery = {
sortBy?: string; 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<T>,
Enumerate<F> Enumerate<F>
>; >;
/** 1 or -1, but will accept any number for easier typing where this is used **/
export type SortDirection = -1 | 1 | (number & {});