Browser profile list and detail minor UX improvements (#1822)

- Paginates browser profile list view
- Moves back-up status icon to list view
- Display last updated date if available (fallback to created date)
- Adds max length validation to description
- Better handle description and crawl workflow list
This commit is contained in:
sua yoo 2024-05-29 12:40:19 -07:00 committed by GitHub
parent 7e5d742fd1
commit 523ad68880
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 260 additions and 148 deletions

View File

@ -3,6 +3,7 @@ import { html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
import { when } from "lit/directives/when.js"; import { when } from "lit/directives/when.js";
import { capitalize } from "lodash/fp";
import queryString from "query-string"; import queryString from "query-string";
import type { Profile } from "./types"; import type { Profile } from "./types";
@ -15,7 +16,8 @@ import { NotifyController } from "@/controllers/notify";
import type { BrowserConnectionChange } from "@/features/browser-profiles/profile-browser"; import type { BrowserConnectionChange } from "@/features/browser-profiles/profile-browser";
import { isApiError } from "@/utils/api"; import { isApiError } from "@/utils/api";
import type { AuthState } from "@/utils/AuthService"; import type { AuthState } from "@/utils/AuthService";
import { getLocale } from "@/utils/localization"; import { maxLengthValidator } from "@/utils/form";
import { formatNumber, getLocale } from "@/utils/localization";
const DESCRIPTION_MAXLENGTH = 500; const DESCRIPTION_MAXLENGTH = 500;
@ -78,6 +80,8 @@ 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 validateDescriptionMax = maxLengthValidator(500);
disconnectedCallback() { disconnectedCallback() {
if (this.browserId) { if (this.browserId) {
void this.deleteBrowser(this.browserId); void this.deleteBrowser(this.browserId);
@ -90,6 +94,9 @@ export class BrowserProfilesDetail extends TailwindElement {
} }
render() { render() {
const isBackedUp =
this.profile?.resource?.replicas &&
this.profile.resource.replicas.length > 0;
const none = html`<span class="text-neutral-400">${msg("None")}</span>`; const none = html`<span class="text-neutral-400">${msg("None")}</span>`;
return html`<div class="mb-7"> return html`<div class="mb-7">
@ -123,25 +130,31 @@ export class BrowserProfilesDetail extends TailwindElement {
</div> </div>
</header> </header>
<section class="mb-5 rounded border p-4"> <section class="mb-5 rounded-lg border px-4 py-2">
<dl class="grid grid-cols-3 gap-5"> <btrix-desc-list horizontal>
<div class="col-span-3 md:col-span-1"> <btrix-desc-list-item label=${msg("Backup Status")}>
<dt class="text-sm text-0-600">${msg("Description")}</dt> <div class="flex items-center gap-2">
<dd> ${isBackedUp
${this.profile ? html`<sl-icon
? this.profile.description name="clouds-fill"
? this.profile.description.slice(0, DESCRIPTION_MAXLENGTH) class="text-success"
: none ></sl-icon>
: ""} ${msg("Backed Up")}`
</dd> : html`<sl-icon
name="cloud-slash-fill"
class="text-neutral-500"
></sl-icon>
${msg("Not Backed Up")}`}
</div> </div>
<div class="col-span-3 md:col-span-1"> </btrix-desc-list-item>
<dt class="text-sm text-0-600"> <btrix-desc-list-item label=${msg("Crawler Release Channel")}>
<span class="inline-block align-middle" ${this.profile
>${msg("Created at")}</span ? this.profile.crawlerChannel
> ? capitalize(this.profile.crawlerChannel)
</dt> : none
<dd> : nothing}
</btrix-desc-list-item>
<btrix-desc-list-item label=${msg("Created At")}>
${this.profile ${this.profile
? html` ? html`
<sl-format-date <sl-format-date
@ -155,43 +168,31 @@ export class BrowserProfilesDetail extends TailwindElement {
time-zone-name="short" time-zone-name="short"
></sl-format-date> ></sl-format-date>
` `
: ""} : nothing}
</dd> </btrix-desc-list-item>
</div> <btrix-desc-list-item label=${msg("Last Updated")}>
<div class="col-span-3 md:col-span-1"> ${this.profile
<dt class="text-sm text-0-600"> ? html` <sl-format-date
<span class="inline-block align-middle" lang=${getLocale()}
>${msg("Crawl Workflows")}</span date=${
> `${
<sl-tooltip content=${msg("Crawl workflows using this profile")}> // NOTE older profiles may not have "modified" data
<sl-icon this.profile.modified || this.profile.created
class="inline-block align-middle" }Z` /** Z for UTC */
name="info-circle" }
></sl-icon> month="2-digit"
</sl-tooltip> day="2-digit"
</dt> year="2-digit"
<dd> hour="numeric"
<ul class="text-sm font-medium"> minute="numeric"
${this.profile?.crawlconfigs?.map( time-zone-name="short"
({ id, name }) => html` ></sl-format-date>`
<li> : nothing}
<a </btrix-desc-list-item>
class="text-neutral-600 hover:underline" </btrix-desc-list>
href=${`${this.navigate.orgBasePath}/workflows/crawl/${id}`}
@click=${this.navigate.link}
>
${name}
</a>
</li>
`,
)}
</ul>
</dd>
</div>
</dl>
</section> </section>
<div class="flex flex-col gap-5 lg:flex-row"> <div class="mb-7 flex flex-col gap-5 lg:flex-row">
<section class="flex-1"> <section class="flex-1">
<h2 class="text-lg font-medium leading-none"> <h2 class="text-lg font-medium leading-none">
${msg("Browser Profile")} ${msg("Browser Profile")}
@ -245,6 +246,46 @@ export class BrowserProfilesDetail extends TailwindElement {
)} )}
</div> </div>
<section class="mb-7">
<header class="flex items-center justify-between">
<h2 class="mb-1 text-lg font-medium leading-none">
${msg("Description")}
</h2>
${when(
this.isCrawler,
() => html`
<sl-icon-button
class="text-base"
name="pencil"
@click=${() => (this.isEditDialogOpen = true)}
label=${msg("Edit description")}
></sl-icon-button>
`,
)}
</header>
<div class="rounded border p-5">
${this.profile
? this.profile.description ||
html`
<div class="text-center text-neutral-400">
${msg("No description added.")}
</div>
`
: nothing}
</div>
</section>
<section class="mb-7">
<h2 class="mb-2 text-lg font-medium leading-none">
${msg("Crawl Workflows")}${this.profile?.crawlconfigs?.length
? html`<span class="font-normal text-neutral-500">
(${formatNumber(this.profile.crawlconfigs.length)})
</span>`
: nothing}
</h2>
${this.renderCrawlWorkflows()}
</section>
<btrix-dialog id="discardChangesDialog" .label=${msg("Cancel Editing?")}> <btrix-dialog id="discardChangesDialog" .label=${msg("Cancel Editing?")}>
${msg( ${msg(
"Are you sure you want to discard changes to this browser profile?", "Are you sure you want to discard changes to this browser profile?",
@ -280,6 +321,33 @@ export class BrowserProfilesDetail extends TailwindElement {
</btrix-dialog> `; </btrix-dialog> `;
} }
private renderCrawlWorkflows() {
if (this.profile?.crawlconfigs?.length) {
return html`<ul>
${this.profile.crawlconfigs.map(
({ id, name }) => html`
<li
class="border-x border-b first:rounded-t first:border-t last:rounded-b"
>
<a
class="block p-2 transition-colors focus-within:bg-neutral-50 hover:bg-neutral-50"
href=${`${this.navigate.orgBasePath}/workflows/crawl/${id}`}
@click=${this.navigate.link}
>
${name ||
html`<span class="text-neutral-400">${msg("(no name)")}</span>`}
</a>
</li>
`,
)}
</ul>`;
}
return html`<div class="rounded border p-5 text-center text-neutral-400">
${msg("Not used in any crawl workflows.")}
</div>`;
}
private readonly renderVisitedSites = () => { private readonly renderVisitedSites = () => {
return html` return html`
<section class="flex-grow-1 flex flex-col lg:w-[60ch]"> <section class="flex-grow-1 flex flex-col lg:w-[60ch]">
@ -356,6 +424,8 @@ 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 class="mb-5">
@ -372,9 +442,12 @@ export class BrowserProfilesDetail extends TailwindElement {
<sl-textarea <sl-textarea
name="description" name="description"
label=${msg("Description")} label=${msg("Description")}
rows="2"
autocomplete="off"
value=${this.profile.description || ""} value=${this.profile.description || ""}
rows="3"
autocomplete="off"
resize="auto"
help-text=${helpText}
@sl-input=${validate}
></sl-textarea> ></sl-textarea>
</div> </div>
@ -628,9 +701,10 @@ export class BrowserProfilesDetail extends TailwindElement {
private async onSubmitEdit(e: SubmitEvent) { private async onSubmitEdit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
this.isSubmittingProfileChange = true; const formEl = e.target as HTMLFormElement;
if (!(await this.checkFormValidity(formEl))) return;
const formData = new FormData(e.target as HTMLFormElement); const formData = new FormData(formEl);
const name = formData.get("name") as string; const name = formData.get("name") as string;
const description = formData.get("description") as string; const description = formData.get("description") as string;
@ -639,6 +713,8 @@ export class BrowserProfilesDetail extends TailwindElement {
description, description,
}; };
this.isSubmittingProfileChange = true;
try { try {
const data = await this.api.fetch<{ updated: boolean }>( const data = await this.api.fetch<{ updated: boolean }>(
`/orgs/${this.orgId}/profiles/${this.profileId}`, `/orgs/${this.orgId}/profiles/${this.profileId}`,
@ -687,12 +763,8 @@ export class BrowserProfilesDetail extends TailwindElement {
this.isSubmittingProfileChange = false; this.isSubmittingProfileChange = false;
} }
/** async checkFormValidity(formEl: HTMLFormElement) {
* Stop propgation of sl-select events. await this.updateComplete;
* Prevents bug where sl-dialog closes when dropdown closes return !formEl.querySelector("[data-invalid]");
* https://github.com/shoelace-style/shoelace/issues/170
*/
private stopProp(e: CustomEvent) {
e.stopPropagation();
} }
} }

View File

@ -1,5 +1,5 @@
import { localized, msg } from "@lit/localize"; import { localized, msg, str } from "@lit/localize";
import { nothing } from "lit"; import { 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 +8,15 @@ import type { Profile } from "./types";
import type { SelectNewDialogEvent } from "."; import type { SelectNewDialogEvent } from ".";
import type { APIPaginatedList } from "@/types/api"; import type { PageChangeEvent } from "@/components/ui/pagination";
import type { APIPaginatedList, APIPaginationQuery } 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 LiteElement, { html } from "@/utils/LiteElement";
import { getLocale } from "@/utils/localization"; import { getLocale } from "@/utils/localization";
const INITIAL_PAGE_SIZE = 20;
/** /**
* Usage: * Usage:
* ```ts * ```ts
@ -32,17 +35,29 @@ export class BrowserProfilesList extends LiteElement {
@property({ type: String }) @property({ type: String })
orgId!: string; orgId!: string;
@state() @property({ type: Boolean })
browserProfiles?: Profile[]; isCrawler = false;
firstUpdated() { @state()
browserProfiles?: APIPaginatedList<Profile>;
protected willUpdate(
changedProperties: PropertyValues<this> & Map<string, unknown>,
) {
if (changedProperties.has("orgId")) {
void this.fetchBrowserProfiles(); void this.fetchBrowserProfiles();
} }
}
render() { render() {
return html`<header> return html`<header>
<div class="mb-4 flex h-8 w-full justify-between"> <div class="mb-3 flex flex-wrap justify-between gap-2 border-b pb-3">
<h1 class="text-xl font-semibold">${msg("Browser Profiles")}</h1> <h1 class="mb-2 text-xl font-semibold leading-8 md:mb-0">
${msg("Browser Profiles")}
</h1>
${when(
this.isCrawler,
() => html`
<sl-button <sl-button
variant="primary" variant="primary"
size="small" size="small"
@ -57,6 +72,8 @@ export class BrowserProfilesList extends LiteElement {
<sl-icon slot="prefix" name="plus-lg"></sl-icon> <sl-icon slot="prefix" name="plus-lg"></sl-icon>
${msg("New Browser Profile")} ${msg("New Browser Profile")}
</sl-button> </sl-button>
`,
)}
</div> </div>
</header> </header>
<div class="overflow-auto px-2 pb-1">${this.renderTable()}</div>`; <div class="overflow-auto px-2 pb-1">${this.renderTable()}</div>`;
@ -65,17 +82,14 @@ export class BrowserProfilesList extends LiteElement {
private renderTable() { private renderTable() {
return html` return html`
<btrix-table <btrix-table
style="grid-template-columns: min-content [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);" 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> <btrix-table-header-cell class="pl-3">
<span class="sr-only">${msg("Backed up status")}</span>
</btrix-table-header-cell>
<btrix-table-header-cell class="pl-0">
${msg("Name")} ${msg("Name")}
</btrix-table-header-cell> </btrix-table-header-cell>
<btrix-table-header-cell> <btrix-table-header-cell>
${msg("Date Created")} ${msg("Last Updated")}
</btrix-table-header-cell> </btrix-table-header-cell>
<btrix-table-header-cell> <btrix-table-header-cell>
${msg("Visited URLs")} ${msg("Visited URLs")}
@ -84,21 +98,34 @@ export class BrowserProfilesList extends LiteElement {
<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>
${this.browserProfiles?.length ${when(this.browserProfiles, ({ total, items }) =>
total
? html` ? html`
<btrix-table-body <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);" 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);"
> >
${this.browserProfiles.map(this.renderItem)} ${items.map(this.renderItem)}
</btrix-table-body> </btrix-table-body>
` `
: nothing} : nothing,
)}
</btrix-table> </btrix-table>
${when( ${when(
this.browserProfiles, this.browserProfiles,
(browserProfiles) => ({ total, page, pageSize }) =>
browserProfiles.length total
? nothing ? html`
<footer class="mt-6 flex justify-center">
<btrix-pagination
page=${page}
totalCount=${total}
size=${pageSize}
@page-change=${async (e: PageChangeEvent) => {
void this.fetchBrowserProfiles({ page: e.detail.page });
}}
></btrix-pagination>
</footer>
`
: html` : html`
<div class="border-b border-t py-5"> <div class="border-b border-t py-5">
<p class="text-center text-0-500"> <p class="text-center text-0-500">
@ -117,24 +144,12 @@ export class BrowserProfilesList extends LiteElement {
</div>`; </div>`;
private readonly renderItem = (data: Profile) => { private readonly renderItem = (data: Profile) => {
const isBackedUp =
data.resource?.replicas && data.resource.replicas.length > 0;
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 rounded border shadow transition-all focus-within:bg-neutral-50 hover:bg-neutral-50 hover:shadow-none"
> >
<btrix-table-cell class="p-3">
<sl-tooltip
content=${isBackedUp ? msg("Backed up") : msg("Not backed up")}
>
<sl-icon
name=${isBackedUp ? "clouds-fill" : "cloud-slash-fill"}
class="${isBackedUp ? "text-success" : "text-neutral-500"}"
></sl-icon>
</sl-tooltip>
</btrix-table-cell>
<btrix-table-cell <btrix-table-cell
class="flex-col items-center justify-center pl-0" class="flex-col items-center justify-center pl-3"
rowClickTarget="a" rowClickTarget="a"
> >
<a <a
@ -144,16 +159,16 @@ export class BrowserProfilesList extends LiteElement {
> >
${data.name} ${data.name}
</a> </a>
${data.description
? html`<div class="w-full text-xs text-neutral-500">
<div class="truncate">${data.description}</div>
</div>`
: nothing}
</btrix-table-cell> </btrix-table-cell>
<btrix-table-cell class="whitespace-nowrap"> <btrix-table-cell class="whitespace-nowrap">
<sl-format-date <sl-format-date
lang=${getLocale()} lang=${getLocale()}
date=${`${data.created}Z`} date=${
`${
// NOTE older profiles may not have "modified" data
data.modified || data.created
}Z` /** Z for UTC */
}
month="2-digit" month="2-digit"
day="2-digit" day="2-digit"
year="2-digit" year="2-digit"
@ -161,7 +176,18 @@ export class BrowserProfilesList extends LiteElement {
minute="2-digit" minute="2-digit"
></sl-format-date> ></sl-format-date>
</btrix-table-cell> </btrix-table-cell>
<btrix-table-cell>${data.origins.join(", ")}</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(", ")}
>
<sl-tag size="small" class="ml-2">
${msg(str`+${data.origins.length - 1}`)}
</sl-tag>
</sl-tooltip>`
: nothing}
</btrix-table-cell>
<btrix-table-cell class="px-1"> <btrix-table-cell class="px-1">
${this.renderActions(data)} ${this.renderActions(data)}
</btrix-table-cell> </btrix-table-cell>
@ -256,9 +282,7 @@ export class BrowserProfilesList extends LiteElement {
icon: "check2-circle", icon: "check2-circle",
}); });
this.browserProfiles = this.browserProfiles!.filter( void this.fetchBrowserProfiles();
(p) => p.id !== profile.id,
);
} }
} catch (e) { } catch (e) {
this.notify({ this.notify({
@ -287,9 +311,17 @@ export class BrowserProfilesList extends LiteElement {
/** /**
* Fetch browser profiles and update internal state * Fetch browser profiles and update internal state
*/ */
private async fetchBrowserProfiles(): Promise<void> { private async fetchBrowserProfiles(
params?: APIPaginationQuery,
): Promise<void> {
try { try {
const data = await this.getProfiles(); const data = await this.getProfiles({
page: params?.page || this.browserProfiles?.page || 1,
pageSize:
params?.pageSize ||
this.browserProfiles?.pageSize ||
INITIAL_PAGE_SIZE,
});
this.browserProfiles = data; this.browserProfiles = data;
} catch (e) { } catch (e) {
@ -301,12 +333,16 @@ export class BrowserProfilesList extends LiteElement {
} }
} }
private async getProfiles() { private async getProfiles(params: APIPaginationQuery) {
const query = queryString.stringify(params, {
arrayFormat: "comma",
});
const data = await this.apiFetch<APIPaginatedList<Profile>>( const data = await this.apiFetch<APIPaginatedList<Profile>>(
`/orgs/${this.orgId}/profiles`, `/orgs/${this.orgId}/profiles?${query}`,
this.authState!, this.authState!,
); );
return data.items; return data;
} }
} }

View File

@ -659,6 +659,7 @@ export class Org extends LiteElement {
return html`<btrix-browser-profiles-list return html`<btrix-browser-profiles-list
.authState=${this.authState!} .authState=${this.authState!}
.orgId=${this.orgId} .orgId=${this.orgId}
?isCrawler=${this.isCrawler}
@select-new-dialog=${this.onSelectNewDialog} @select-new-dialog=${this.onSelectNewDialog}
></btrix-browser-profiles-list>`; ></btrix-browser-profiles-list>`;
} }

View File

@ -98,6 +98,9 @@ export type Profile = {
name: string; name: string;
description: string; description: string;
created: string; created: string;
createdByName: string | null;
modified: string | null;
modifiedByName: string | null;
origins: string[]; origins: string[];
profileId: string; profileId: string;
baseProfileName: string; baseProfileName: string;