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:
		
							parent
							
								
									c2ce96c057
								
							
						
					
					
						commit
						4d4c8a04d4
					
				| @ -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( | ||||||
|  | |||||||
| @ -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"] | ||||||
|  | |||||||
| @ -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( | ||||||
|  | |||||||
| @ -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; | ||||||
|  | |||||||
| @ -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> `; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -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>`, | ||||||
|  | |||||||
| @ -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> | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -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!, | ||||||
|     ); |     ); | ||||||
|  | |||||||
| @ -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> | ||||||
|  | |||||||
| @ -51,7 +51,7 @@ const sortableFields: Record< | |||||||
|     defaultDirection: "asc", |     defaultDirection: "asc", | ||||||
|   }, |   }, | ||||||
|   created: { |   created: { | ||||||
|     label: msg("Created"), |     label: msg("Date Created"), | ||||||
|     defaultDirection: "desc", |     defaultDirection: "desc", | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -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; | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -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 & {}); | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user