Crawl config tag editor UI (#422)
Allow users to set tags on a crawl config. Resolves #362
This commit is contained in:
parent
52d9ae9661
commit
e3d34ff08f
@ -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">
|
||||
|
@ -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);
|
||||
|
355
frontend/src/components/tag-input.ts
Normal file
355
frontend/src/components/tag-input.ts
Normal 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 [];
|
||||
}
|
||||
}
|
45
frontend/src/components/tag.ts
Normal file
45
frontend/src/components/tag.ts
Normal 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;
|
||||
}
|
@ -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()
|
||||
|
@ -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`, {
|
||||
|
@ -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`, {
|
||||
|
@ -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`, {
|
||||
|
@ -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<
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user