diff --git a/frontend/src/components/config-details.ts b/frontend/src/components/config-details.ts index 9a2f7b8b..2f89e72c 100644 --- a/frontend/src/components/config-details.ts +++ b/frontend/src/components/config-details.ts @@ -52,6 +52,14 @@ export class ConfigDetails extends LiteElement { > ${this.renderSetting(msg("Name"), crawlConfig?.name)} + ${this.renderSetting( + msg("Tags"), + crawlConfig?.tags?.length + ? crawlConfig.tags.map( + (tag) => html`${tag}` + ) + : undefined + )}
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index de8f23ab..18137f34 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -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); diff --git a/frontend/src/components/tag-input.ts b/frontend/src/components/tag-input.ts new file mode 100644 index 00000000..64be1e80 --- /dev/null +++ b/frontend/src/components/tag-input.ts @@ -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 + * + * ``` + * + * @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) { + 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` +
+ +
+ ${this.renderTags()} + +
+
+ `; + } + + 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` + ${content} + `; + }; + + 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( + new CustomEvent("tags-change", { + detail: { tags: this.tags }, + }) + ); + } + + private async getOptions() { + // TODO actual API call + // https://github.com/webrecorder/browsertrix-cloud/issues/453 + return []; + } +} diff --git a/frontend/src/components/tag.ts b/frontend/src/components/tag.ts new file mode 100644 index 00000000..d272b87f --- /dev/null +++ b/frontend/src/components/tag.ts @@ -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 + * + * Usage: + * ```ts + * Content + * ``` + */ +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; +} diff --git a/frontend/src/pages/archive/crawl-config-editor.ts b/frontend/src/pages/archive/crawl-config-editor.ts index 7b6981db..f7f8b881 100644 --- a/frontend/src/pages/archive/crawl-config-editor.ts +++ b/frontend/src/pages/archive/crawl-config-editor.ts @@ -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 { if (!this.initialCrawlConfig) return {}; - const seedState: Partial = {}; + const formState: Partial = {}; 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 = {}; 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` + + this.updateFormState( + { + tags: e.detail.tags, + }, + true + )} + > + ` + )} + ${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`} + ${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() diff --git a/frontend/src/pages/archive/crawl-templates-detail.ts b/frontend/src/pages/archive/crawl-templates-detail.ts index c24c2436..69f4ba2a 100644 --- a/frontend/src/pages/archive/crawl-templates-detail.ts +++ b/frontend/src/pages/archive/crawl-templates-detail.ts @@ -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`, { diff --git a/frontend/src/pages/archive/crawl-templates-list.ts b/frontend/src/pages/archive/crawl-templates-list.ts index 9d419f68..62d8e1c4 100644 --- a/frontend/src/pages/archive/crawl-templates-list.ts +++ b/frontend/src/pages/archive/crawl-templates-list.ts @@ -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`, { diff --git a/frontend/src/pages/archive/crawls-list.ts b/frontend/src/pages/archive/crawls-list.ts index a4716f9f..f9b3a42b 100644 --- a/frontend/src/pages/archive/crawls-list.ts +++ b/frontend/src/pages/archive/crawls-list.ts @@ -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`, { diff --git a/frontend/src/pages/archive/types.ts b/frontend/src/pages/archive/types.ts index 68172d2e..a4c7e020 100644 --- a/frontend/src/pages/archive/types.ts +++ b/frontend/src/pages/archive/types.ts @@ -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< diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index ae3f4c7f..b7bdadfd 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -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) {