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

View File

@ -1,5 +1,5 @@
import { localized, msg } from "@lit/localize";
import { nothing } from "lit";
import { localized, msg, str } from "@lit/localize";
import { 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 +8,15 @@ import type { Profile } from "./types";
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 { AuthState } from "@/utils/AuthService";
import LiteElement, { html } from "@/utils/LiteElement";
import { getLocale } from "@/utils/localization";
const INITIAL_PAGE_SIZE = 20;
/**
* Usage:
* ```ts
@ -32,31 +35,45 @@ export class BrowserProfilesList extends LiteElement {
@property({ type: String })
orgId!: string;
@state()
browserProfiles?: Profile[];
@property({ type: Boolean })
isCrawler = false;
firstUpdated() {
void this.fetchBrowserProfiles();
@state()
browserProfiles?: APIPaginatedList<Profile>;
protected willUpdate(
changedProperties: PropertyValues<this> & Map<string, unknown>,
) {
if (changedProperties.has("orgId")) {
void this.fetchBrowserProfiles();
}
}
render() {
return html`<header>
<div class="mb-4 flex h-8 w-full justify-between">
<h1 class="text-xl font-semibold">${msg("Browser Profiles")}</h1>
<sl-button
variant="primary"
size="small"
@click=${() => {
this.dispatchEvent(
new CustomEvent("select-new-dialog", {
detail: "browser-profile",
}) as SelectNewDialogEvent,
);
}}
>
<sl-icon slot="prefix" name="plus-lg"></sl-icon>
${msg("New Browser Profile")}
</sl-button>
<div class="mb-3 flex flex-wrap justify-between gap-2 border-b pb-3">
<h1 class="mb-2 text-xl font-semibold leading-8 md:mb-0">
${msg("Browser Profiles")}
</h1>
${when(
this.isCrawler,
() => html`
<sl-button
variant="primary"
size="small"
@click=${() => {
this.dispatchEvent(
new CustomEvent("select-new-dialog", {
detail: "browser-profile",
}) as SelectNewDialogEvent,
);
}}
>
<sl-icon slot="prefix" name="plus-lg"></sl-icon>
${msg("New Browser Profile")}
</sl-button>
`,
)}
</div>
</header>
<div class="overflow-auto px-2 pb-1">${this.renderTable()}</div>`;
@ -65,17 +82,14 @@ export class BrowserProfilesList extends LiteElement {
private renderTable() {
return html`
<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-header-cell>
<span class="sr-only">${msg("Backed up status")}</span>
</btrix-table-header-cell>
<btrix-table-header-cell class="pl-0">
<btrix-table-header-cell class="pl-3">
${msg("Name")}
</btrix-table-header-cell>
<btrix-table-header-cell>
${msg("Date Created")}
${msg("Last Updated")}
</btrix-table-header-cell>
<btrix-table-header-cell>
${msg("Visited URLs")}
@ -84,21 +98,34 @@ export class BrowserProfilesList extends LiteElement {
<span class="sr-only">${msg("Row Actions")}</span>
</btrix-table-header-cell>
</btrix-table-head>
${this.browserProfiles?.length
? html`
<btrix-table-body
style="--btrix-row-gap: var(--sl-spacing-x-small); --btrix-cell-padding-top: var(--sl-spacing-2x-small); --btrix-cell-padding-bottom: var(--sl-spacing-2x-small);"
>
${this.browserProfiles.map(this.renderItem)}
</btrix-table-body>
`
: nothing}
${when(this.browserProfiles, ({ total, items }) =>
total
? html`
<btrix-table-body
style="--btrix-row-gap: var(--sl-spacing-x-small); --btrix-cell-padding-top: var(--sl-spacing-2x-small); --btrix-cell-padding-bottom: var(--sl-spacing-2x-small);"
>
${items.map(this.renderItem)}
</btrix-table-body>
`
: nothing,
)}
</btrix-table>
${when(
this.browserProfiles,
(browserProfiles) =>
browserProfiles.length
? nothing
({ total, page, pageSize }) =>
total
? 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`
<div class="border-b border-t py-5">
<p class="text-center text-0-500">
@ -117,24 +144,12 @@ export class BrowserProfilesList extends LiteElement {
</div>`;
private readonly renderItem = (data: Profile) => {
const isBackedUp =
data.resource?.replicas && data.resource.replicas.length > 0;
return html`
<btrix-table-row
class="cursor-pointer select-none rounded border shadow transition-all focus-within:bg-neutral-50 hover:bg-neutral-50 hover:shadow-none"
>
<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
class="flex-col items-center justify-center pl-0"
class="flex-col items-center justify-center pl-3"
rowClickTarget="a"
>
<a
@ -144,16 +159,16 @@ export class BrowserProfilesList extends LiteElement {
>
${data.name}
</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 class="whitespace-nowrap">
<sl-format-date
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"
day="2-digit"
year="2-digit"
@ -161,7 +176,18 @@ export class BrowserProfilesList extends LiteElement {
minute="2-digit"
></sl-format-date>
</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">
${this.renderActions(data)}
</btrix-table-cell>
@ -256,9 +282,7 @@ export class BrowserProfilesList extends LiteElement {
icon: "check2-circle",
});
this.browserProfiles = this.browserProfiles!.filter(
(p) => p.id !== profile.id,
);
void this.fetchBrowserProfiles();
}
} catch (e) {
this.notify({
@ -287,9 +311,17 @@ export class BrowserProfilesList extends LiteElement {
/**
* Fetch browser profiles and update internal state
*/
private async fetchBrowserProfiles(): Promise<void> {
private async fetchBrowserProfiles(
params?: APIPaginationQuery,
): Promise<void> {
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;
} 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>>(
`/orgs/${this.orgId}/profiles`,
`/orgs/${this.orgId}/profiles?${query}`,
this.authState!,
);
return data.items;
return data;
}
}

View File

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

View File

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