diff --git a/backend/btrixcloud/profiles.py b/backend/btrixcloud/profiles.py index 41b76bd4..77dd1810 100644 --- a/backend/btrixcloud/profiles.py +++ b/backend/btrixcloud/profiles.py @@ -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( diff --git a/backend/test/conftest.py b/backend/test/conftest.py index fbdec3a9..dbecc950 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -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"] diff --git a/backend/test/test_profiles.py b/backend/test/test_profiles.py index aab8ece4..8dc7e549 100644 --- a/backend/test/test_profiles.py +++ b/backend/test/test_profiles.py @@ -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( diff --git a/frontend/src/components/ui/table/table-cell.ts b/frontend/src/components/ui/table/table-cell.ts index ba64a5ce..560196db 100644 --- a/frontend/src/components/ui/table/table-cell.ts +++ b/frontend/src/components/ui/table/table-cell.ts @@ -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; diff --git a/frontend/src/components/ui/table/table-header-cell.ts b/frontend/src/components/ui/table/table-header-cell.ts index 9841c743..d522af23 100644 --- a/frontend/src/components/ui/table/table-header-cell.ts +++ b/frontend/src/components/ui/table/table-header-cell.ts @@ -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([ + [-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``; + return html` `; } } diff --git a/frontend/src/features/archived-items/archived-item-list.ts b/frontend/src/features/archived-items/archived-item-list.ts index 2f43a8b5..b00bfbc9 100644 --- a/frontend/src/features/archived-items/archived-item-list.ts +++ b/frontend/src/features/archived-items/archived-item-list.ts @@ -218,7 +218,7 @@ export class ArchivedItemListItem extends TailwindElement { ` : html`
${rowName}
`} - + - + - + ${isUpload ? notApplicable : html` `} - + ${isUpload ? notApplicable : lastQAStarted && qaRunCount @@ -387,7 +385,7 @@ export class ArchivedItemList extends TailwindElement { `, }, { - cssCol: "12rem", + cssCol: "1fr", cell: html` ${msg("Date Created")} `, diff --git a/frontend/src/pages/org/browser-profiles-detail.ts b/frontend/src/pages/org/browser-profiles-detail.ts index 8b08e100..e5f203e5 100644 --- a/frontend/src/pages/org/browser-profiles-detail.ts +++ b/frontend/src/pages/org/browser-profiles-detail.ts @@ -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} - + ${this.profile ? html` -
+
@@ -451,13 +453,14 @@ export class BrowserProfilesDetail extends TailwindElement {
diff --git a/frontend/src/pages/org/browser-profiles-list.ts b/frontend/src/pages/org/browser-profiles-list.ts index 1503db63..efd612d3 100644 --- a/frontend/src/pages/org/browser-profiles-list.ts +++ b/frontend/src/pages/org/browser-profiles-list.ts @@ -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; + @state() + sort: Required = { + 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 & Map, ) { - if (changedProperties.has("orgId")) { + if (changedProperties.has("orgId") || changedProperties.has("sort")) { void this.fetchBrowserProfiles(); } } @@ -76,77 +134,136 @@ export class BrowserProfilesList extends LiteElement { )}
-
${this.renderTable()}
`; +
${this.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` + + `; + }; + return html` - + - - ${msg("Name")} - - - ${msg("Visited URLs")} - - - ${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` + { + if (isSorting) { + this.sort = { + ...this.sort, + sortDirection: this.sort.sortDirection * -1, + }; + } else { + this.sort = { + sortBy, + sortDirection, + }; + } + }} + > + ${label} ${getSortIcon(sortValue)} + + `; + })} ${msg("Row Actions")} - ${when(this.browserProfiles, ({ total, items }) => - total - ? html` - - ${items.map(this.renderItem)} - - ` - : nothing, - )} + + ${when(this.browserProfiles, ({ total, items }) => + total ? html` ${items.map(this.renderItem)} ` : nothing, + )} + ${when(this.isLoading, this.renderLoading)} + - ${when( - this.browserProfiles, - ({ total, page, pageSize }) => - total - ? html` -
- { - void this.fetchBrowserProfiles({ page: e.detail.page }); - }} - > -
- ` - : html` -
-

- ${msg("No browser profiles yet.")} -

-
- `, - this.renderLoading, + ${when(this.browserProfiles, ({ total, page, pageSize }) => + total + ? html` +
+ { + void this.fetchBrowserProfiles({ page: e.detail.page }); + }} + > +
+ ` + : html` +
+

+ ${msg("No browser profiles yet.")} +

+
+ `, )} `; } private readonly renderLoading = () => - html`
+ html`
`; private readonly renderItem = (data: Profile) => { return html` - ${data.name} + ${data.name} - ${data.origins[0]}${data.origins.length > 1 - ? html` - +
${data.origins[0]}
+ ${data.origins.length > 1 + ? html` + ${data.origins.slice(1).join(", ")} + ${msg(str`+${data.origins.length - 1}`)} -
+
` : nothing}
- + + + + + + ( + const data = await this.api.fetch( `/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 ${profile.name}, in use by ${profile.name}.`), 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( + return this.api.fetch( `/orgs/${this.orgId}/profiles/browser`, this.authState!, { @@ -320,6 +454,7 @@ export class BrowserProfilesList extends LiteElement { params?: APIPaginationQuery, ): Promise { 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, { - arrayFormat: "comma", - }); + const query = queryString.stringify( + { + ...params, + ...this.sort, + }, + { + arrayFormat: "comma", + }, + ); - const data = await this.apiFetch>( + const data = await this.api.fetch>( `/orgs/${this.orgId}/profiles?${query}`, this.authState!, ); diff --git a/frontend/src/pages/org/collections-list.ts b/frontend/src/pages/org/collections-list.ts index fb5a2f1f..4dd8f039 100644 --- a/frontend/src/pages/org/collections-list.ts +++ b/frontend/src/pages/org/collections-list.ts @@ -400,7 +400,7 @@ export class CollectionsList extends LiteElement { if (this.collections?.items.length) { return html` diff --git a/frontend/src/pages/org/workflows-list.ts b/frontend/src/pages/org/workflows-list.ts index 48cafa46..79b50783 100644 --- a/frontend/src/pages/org/workflows-list.ts +++ b/frontend/src/pages/org/workflows-list.ts @@ -51,7 +51,7 @@ const sortableFields: Record< defaultDirection: "asc", }, created: { - label: msg("Created"), + label: msg("Date Created"), defaultDirection: "desc", }, }; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 781b99ec..8bf8f8d3 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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; }; diff --git a/frontend/src/types/utils.ts b/frontend/src/types/utils.ts index 35df30e3..d65cc735 100644 --- a/frontend/src/types/utils.ts +++ b/frontend/src/types/utils.ts @@ -21,3 +21,6 @@ export type Range = Exclude< Enumerate, Enumerate >; + +/** 1 or -1, but will accept any number for easier typing where this is used **/ +export type SortDirection = -1 | 1 | (number & {});