Crawl config tag editor UI (#422)

Allow users to set tags on a crawl config. Resolves #362
This commit is contained in:
sua yoo 2023-01-12 13:59:42 -08:00 committed by GitHub
parent 52d9ae9661
commit e3d34ff08f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 460 additions and 15 deletions

View File

@ -52,6 +52,14 @@ export class ConfigDetails extends LiteElement {
>
<btrix-desc-list>
${this.renderSetting(msg("Name"), crawlConfig?.name)}
${this.renderSetting(
msg("Tags"),
crawlConfig?.tags?.length
? crawlConfig.tags.map(
(tag) => html`<btrix-tag class="mt-1 mr-2">${tag}</btrix-tag>`
)
: undefined
)}
</btrix-desc-list>
</section>
<section id="crawler-settings" class="mb-8">

View File

@ -95,6 +95,12 @@ import("./section-heading").then(({ SectionHeading }) => {
import("./config-details").then(({ ConfigDetails }) => {
customElements.define("btrix-config-details", ConfigDetails);
});
import("./tag-input").then(({ TagInput }) => {
customElements.define("btrix-tag-input", TagInput);
});
import("./tag").then(({ Tag }) => {
customElements.define("btrix-tag", Tag);
});
customElements.define("btrix-alert", Alert);
customElements.define("btrix-input", Input);

View File

@ -0,0 +1,355 @@
import { LitElement, html, css } from "lit";
import { state, property, query } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize";
import type { SlInput, SlMenu } from "@shoelace-style/shoelace";
import inputCss from "@shoelace-style/shoelace/dist/components/input/input.styles.js";
import union from "lodash/fp/union";
import debounce from "lodash/fp/debounce";
export type Tags = string[];
export type TagsChangeEvent = CustomEvent<{
tags: string[];
}>;
/**
* Usage:
* ```ts
* <btrix-tag-input
* initialTags=${[]}
* @tags-change=${console.log}
* ></btrix-tag-input>
* ```
*
* @events tags-change
*/
@localized()
export class TagInput extends LitElement {
static styles = css`
:host {
--tag-height: 1.5rem;
}
${inputCss}
.input {
flex-wrap: wrap;
height: auto;
overflow: visible;
min-height: calc(var(--tag-height) + 1rem);
}
.input__control {
align-self: center;
width: 100%;
}
.dropdownWrapper {
flex-grow: 1;
flex-shrink: 0;
}
.dropdownWrapper:not(:first-child) .input__control {
padding-left: var(--sl-spacing-small);
padding-right: var(--sl-spacing-small);
}
btrix-tag {
margin-left: var(--sl-spacing-x-small);
margin-top: calc(0.5rem - 1px);
max-width: calc(
100% - var(--sl-spacing-x-small) - var(--sl-spacing-x-small)
);
}
.dropdown {
position: absolute;
z-index: 9999;
margin-top: -0.25rem;
margin-left: 0.25rem;
transform-origin: top left;
}
.hidden {
display: none;
}
.animateShow {
animation: dropdownShow 100ms ease forwards;
}
.animateHide {
animation: dropdownHide 100ms ease forwards;
}
@keyframes dropdownShow {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dropdownHide {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.9);
display: none;
}
}
`;
@property({ type: Array })
initialTags?: Tags;
@property({ type: Boolean })
disabled = false;
// TODO validate required
@property({ type: Boolean })
required = false;
@state()
private tags: Tags = [];
@state()
private inputValue = "";
@state()
private dropdownIsOpen?: boolean;
@state()
private tagOptions: Tags = [];
@query("#input")
private input?: HTMLInputElement;
@query("sl-menu")
private menu!: SlMenu;
connectedCallback() {
if (this.initialTags) {
this.tags = this.initialTags;
}
super.connectedCallback();
}
willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("tags") && this.required) {
if (this.tags.length) {
this.removeAttribute("data-invalid");
} else {
this.setAttribute("data-invalid", "");
}
}
}
reportValidity() {
this.input?.reportValidity();
}
render() {
const placeholder = msg("Tags separated by comma");
return html`
<div class="form-control form-control--has-label">
<label
class="form-control__label"
part="form-control-label"
for="input"
>
<slot name="label">${msg("Tags")}</slot>
</label>
<div
class="input input--medium input--standard"
@click=${this.onInputWrapperClick}
>
${this.renderTags()}
<div
class="dropdownWrapper"
style="min-width: ${placeholder.length}ch"
>
<input
slot="trigger"
id="input"
class="input__control"
@focus=${this.onFocus}
@blur=${this.onBlur}
@keydown=${this.onKeydown}
@input=${this.onInput}
@keyup=${this.onKeyup}
@paste=${this.onPaste}
?required=${this.required && !this.tags.length}
placeholder=${placeholder}
role="combobox"
aria-controls="dropdown"
aria-expanded="${this.dropdownIsOpen === true}"
/>
<div
id="dropdown"
class="dropdown ${this.dropdownIsOpen === true
? "animateShow"
: this.dropdownIsOpen === false
? "animateHide"
: "hidden"}"
>
<sl-menu
role="listbox"
@keydown=${(e: KeyboardEvent) => {
e.stopPropagation();
}}
@keyup=${(e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === "Escape") {
this.dropdownIsOpen = false;
this.input?.focus();
}
}}
@sl-select=${this.onSelect}
>
${this.tagOptions
.slice(0, 3)
.map(
(tag) => html`
<sl-menu-item role="option" value=${tag}
>${tag}</sl-menu-item
>
`
)}
${this.tagOptions.length ? html`<sl-divider></sl-divider>` : ""}
<sl-menu-item role="option" value=${this.inputValue}>
${msg(str`Add “${this.inputValue.toLocaleLowerCase()}`)}
</sl-menu-item>
</sl-menu>
</div>
</div>
</div>
</div>
`;
}
private renderTags() {
return this.tags.map(this.renderTag);
}
private renderTag = (content: string) => {
const removeTag = () => {
this.tags = this.tags.filter((v) => v !== content);
this.dispatchChange();
};
return html`
<btrix-tag
variant="primary"
removable
@sl-remove=${removeTag}
title=${content}
>${content}</btrix-tag
>
`;
};
private onSelect(e: CustomEvent) {
this.addTags([e.detail.item.value]);
}
private onFocus(e: FocusEvent) {
const input = e.target as HTMLInputElement;
(input.parentElement as HTMLElement).classList.add("input--focused");
if (input.value) {
this.dropdownIsOpen = true;
}
}
private onBlur(e: FocusEvent) {
if (this.menu?.contains(e.relatedTarget as HTMLElement)) {
// Keep focus on form control if moving to menu selection
return;
}
const input = e.target as HTMLInputElement;
(input.parentElement as HTMLElement).classList.remove("input--focused");
this.addTags([input.value]);
}
private onKeydown(e: KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
this.menu?.querySelector("sl-menu-item")?.focus();
return;
}
if (e.key === "," || e.key === "Enter") {
e.preventDefault();
const input = e.target as HTMLInputElement;
const value = input.value.trim();
if (value) {
this.addTags([value]);
}
}
}
private onInput = debounce(200)(async (e: InputEvent) => {
const input = (e as any).originalTarget as HTMLInputElement;
this.inputValue = input.value;
if (input.value.length) {
this.dropdownIsOpen = true;
this.tagOptions = await this.getOptions();
}
}) as any;
private onKeyup(e: KeyboardEvent) {
const input = e.target as HTMLInputElement;
if (e.key === "Escape") {
(input.parentElement as HTMLElement).classList.remove("input--focused");
this.dropdownIsOpen = false;
this.inputValue = "";
input.value = "";
}
}
private onPaste(e: ClipboardEvent) {
const text = e.clipboardData?.getData("text");
if (text) {
this.addTags(text.split(","));
}
}
private onInputWrapperClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
this.input?.focus();
}
}
private async addTags(tags: Tags) {
await this.updateComplete;
this.tags = union(
tags.map((v) => v.trim().toLocaleLowerCase()).filter((v) => v),
this.tags
);
this.dispatchChange();
this.dropdownIsOpen = false;
this.input!.value = "";
}
private async dispatchChange() {
await this.updateComplete;
this.dispatchEvent(
<TagsChangeEvent>new CustomEvent("tags-change", {
detail: { tags: this.tags },
})
);
}
private async getOptions() {
// TODO actual API call
// https://github.com/webrecorder/browsertrix-cloud/issues/453
return [];
}
}

View File

@ -0,0 +1,45 @@
import { css } from "lit";
import SLTag from "@shoelace-style/shoelace/dist/components/tag/tag.js";
import tagStyles from "@shoelace-style/shoelace/dist/components/tag/tag.styles.js";
/**
* Customized <sl-tag>
*
* Usage:
* ```ts
* <btrix-tag>Content</btrix-tag>
* ```
*/
export class Tag extends SLTag {
static styles = css`
${tagStyles}
.tag {
height: var(--tag-height, 1.5rem);
background-color: var(--sl-color-blue-100);
border-color: var(--sl-color-blue-500);
color: var(--sl-color-blue-600);
font-family: var(--sl-font-sans);
}
.tag__content {
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag__remove {
color: var(--sl-color-blue-600);
border-radius: 100%;
transition: background-color 0.1s;
}
.tag__remove:hover {
background-color: var(--sl-color-blue-600);
color: #fff;
}
`;
pill = true;
}

View File

@ -35,6 +35,7 @@ import type {
ExclusionChangeEvent,
} from "../../components/queue-exclusion-table";
import type { TimeInputChangeEvent } from "../../components/time-input";
import type { Tags, TagsChangeEvent } from "../../components/tag-input";
import type {
CrawlConfigParams,
Profile,
@ -91,6 +92,7 @@ type FormState = {
runNow: boolean;
jobName: CrawlConfigParams["name"];
browserProfile: Profile | null;
tags: Tags;
};
const getDefaultProgressState = (hasConfigId = false): ProgressState => {
@ -156,6 +158,7 @@ const getDefaultFormState = (): FormState => ({
runNow: false,
jobName: "",
browserProfile: null,
tags: [],
});
const defaultProgressState = getDefaultProgressState();
const orderedTabNames = STEPS.filter(
@ -310,45 +313,48 @@ export class CrawlConfigEditor extends LiteElement {
private getInitialFormState(): Partial<FormState> {
if (!this.initialCrawlConfig) return {};
const seedState: Partial<FormState> = {};
const formState: Partial<FormState> = {};
const { seeds, scopeType } = this.initialCrawlConfig.config;
if (this.initialCrawlConfig.jobType === "seed-crawl") {
seedState.primarySeedUrl =
formState.primarySeedUrl =
typeof seeds[0] === "string" ? seeds[0] : seeds[0].url;
} else {
// Treat "custom" like URL list
seedState.urlList = seeds
formState.urlList = seeds
.map((seed) => (typeof seed === "string" ? seed : seed.url))
.join("\n");
if (this.initialCrawlConfig.jobType === "custom") {
seedState.scopeType = scopeType || "page";
formState.scopeType = scopeType || "page";
}
}
const scheduleState: Partial<FormState> = {};
if (this.initialCrawlConfig.schedule) {
scheduleState.scheduleType = "cron";
scheduleState.scheduleFrequency = getScheduleInterval(
formState.scheduleType = "cron";
formState.scheduleFrequency = getScheduleInterval(
this.initialCrawlConfig.schedule
);
const nextDate = getNextDate(this.initialCrawlConfig.schedule)!;
scheduleState.scheduleDayOfMonth = nextDate.getDate();
scheduleState.scheduleDayOfWeek = nextDate.getDay();
formState.scheduleDayOfMonth = nextDate.getDate();
formState.scheduleDayOfWeek = nextDate.getDay();
const hours = nextDate.getHours();
scheduleState.scheduleTime = {
formState.scheduleTime = {
hour: hours % 12 || 12,
minute: nextDate.getMinutes(),
period: hours > 11 ? "PM" : "AM",
};
} else {
if (this.configId) {
scheduleState.scheduleType = "none";
formState.scheduleType = "none";
} else {
scheduleState.scheduleType = "now";
formState.scheduleType = "now";
}
}
if (this.initialCrawlConfig.tags?.length) {
formState.tags = this.initialCrawlConfig.tags;
}
return {
jobName: this.initialCrawlConfig.name,
browserProfile: this.initialCrawlConfig.profileid
@ -358,8 +364,7 @@ export class CrawlConfigEditor extends LiteElement {
.scopeType as FormState["scopeType"],
exclusions: this.initialCrawlConfig.config.exclude,
includeLinkedPages: Boolean(this.initialCrawlConfig.config.extraHops),
...seedState,
...scheduleState,
...formState,
};
}
@ -1243,6 +1248,24 @@ https://example.net`}
${this.renderHelpTextCol(
html`Try to create a unique name to help keep things organized!`
)}
${this.renderFormCol(
html`
<btrix-tag-input
.initialTags=${this.formState.tags}
@tags-change=${(e: TagsChangeEvent) =>
this.updateFormState(
{
tags: e.detail.tags,
},
true
)}
></btrix-tag-input>
`
)}
${this.renderHelpTextCol(
html`Create or assign this crawl (and its outputs) to one or more tags
to help organize your archived data.`
)}
`;
}
@ -1261,6 +1284,7 @@ https://example.net`}
<btrix-config-details .crawlConfig=${crawlConfig}>
</btrix-config-details>
</div>
${when(this.formHasError, () =>
this.renderErrorAlert(
msg(
@ -1627,6 +1651,7 @@ https://example.net`}
crawlTimeout: this.formState.crawlTimeoutMinutes
? this.formState.crawlTimeoutMinutes * 60
: 0,
tags: this.formState.tags,
config: {
...(this.jobType === "seed-crawl"
? this.parseSeededConfig()

View File

@ -460,6 +460,7 @@ export class CrawlTemplatesDetail extends LiteElement {
profileid: this.crawlConfig.profileid || null,
jobType: this.crawlConfig.jobType,
schedule: this.crawlConfig.schedule,
tags: this.crawlConfig.tags,
};
this.navTo(`/archives/${this.archiveId}/crawl-templates/new`, {

View File

@ -611,6 +611,7 @@ export class CrawlTemplatesList extends LiteElement {
profileid: template.profileid || null,
jobType: template.jobType,
schedule: template.schedule,
tags: template.tags,
};
this.navTo(`/archives/${this.archiveId}/crawl-templates/new`, {

View File

@ -731,6 +731,7 @@ export class CrawlsList extends LiteElement {
profileid: template.profileid || null,
jobType: template.jobType,
schedule: template.schedule,
tags: template.tags,
};
this.navTo(`/archives/${crawl.aid}/crawl-templates/new`, {

View File

@ -61,11 +61,12 @@ export type CrawlConfigParams = {
profileid: string | null;
config: SeedConfig;
crawlTimeout: number | null;
tags?: string[];
};
export type InitialCrawlConfig = Pick<
CrawlConfigParams,
"name" | "profileid" | "schedule"
"name" | "profileid" | "schedule" | "tags"
> & {
jobType?: JobType;
config: Pick<

View File

@ -99,12 +99,14 @@ const theme = css`
}
/* Add more spacing between label, input and help text */
btrix-tag-input::part(form-control-label),
sl-input::part(form-control-label),
sl-textarea::part(form-control-label),
sl-select::part(form-control-label) {
line-height: 1.4;
margin-bottom: 0.375rem;
}
btrix-tag-input::part(form-control-help-text),
sl-input::part(form-control-help-text),
sl-textarea::part(form-control-help-text),
sl-select::part(form-control-help-text) {