feat: Enable viewing all workflow form sections at once (#2310)
- Displays workflow form as collapsible sections - Combines run now toggle into submit - Fixes exclusion field errors not preventing form submission - Refactors `<btrix-observable>` into new `Observable` controller --------- Co-authored-by: emma <hi@emma.cafe>
This commit is contained in:
		
							parent
							
								
									83211b2f19
								
							
						
					
					
						commit
						18e72262dd
					
				| @ -1,4 +1,3 @@ | ||||
| import clsx from "clsx"; | ||||
| import { html, type PropertyValues } from "lit"; | ||||
| import { | ||||
|   customElement, | ||||
| @ -10,7 +9,7 @@ import type { TabClickDetail, TabGroupTab } from "./tab"; | ||||
| import { type TabGroupPanel } from "./tab-panel"; | ||||
| 
 | ||||
| import { TailwindElement } from "@/classes/TailwindElement"; | ||||
| import { tw } from "@/utils/tailwind"; | ||||
| import { pageSectionsWithNav } from "@/layouts/pageSectionsWithNav"; | ||||
| 
 | ||||
| /** | ||||
|  * @example Usage: | ||||
| @ -33,6 +32,10 @@ export class TabGroup extends TailwindElement { | ||||
|   @property({ type: String }) | ||||
|   placement: "top" | "start" = "top"; | ||||
| 
 | ||||
|   /* Nav sticky */ | ||||
|   @property({ type: Boolean }) | ||||
|   sticky = true; | ||||
| 
 | ||||
|   @property({ type: String, noAccessor: true, reflect: true }) | ||||
|   role = "tablist"; | ||||
| 
 | ||||
| @ -61,29 +64,16 @@ export class TabGroup extends TailwindElement { | ||||
|   } | ||||
| 
 | ||||
|   render() { | ||||
|     return html` | ||||
|       <div | ||||
|         class=${clsx( | ||||
|           tw`flex flex-col`, | ||||
|           this.placement === "start" && tw`gap-8 lg:flex-row`, | ||||
|         )} | ||||
|       > | ||||
|         <div | ||||
|           class=${clsx( | ||||
|             tw`flex flex-1 flex-col gap-2`, | ||||
|             this.placement === "start" | ||||
|               ? tw`lg:sticky lg:top-2 lg:max-w-[16.5rem] lg:self-start` | ||||
|               : tw`lg:flex-row`, | ||||
|           )} | ||||
|           @keydown=${this.onKeyDown} | ||||
|         > | ||||
|           <slot name="nav" @btrix-select-tab=${this.onSelectTab}></slot> | ||||
|         </div> | ||||
|         <div class="flex-1"> | ||||
|           <slot></slot> | ||||
|         </div> | ||||
|       </div> | ||||
|     `;
 | ||||
|     return pageSectionsWithNav({ | ||||
|       nav: html`<slot
 | ||||
|         name="nav" | ||||
|         @btrix-select-tab=${this.onSelectTab} | ||||
|         @keydown=${this.onKeyDown} | ||||
|       ></slot>`,
 | ||||
|       main: html`<slot></slot>`, | ||||
|       placement: this.placement, | ||||
|       sticky: this.sticky, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   private handleActiveChange() { | ||||
|  | ||||
| @ -8,50 +8,10 @@ const DEFAULT_PANEL_ID = "default-panel"; | ||||
| // postcss-lit-disable-next-line
 | ||||
| export const TWO_COL_SCREEN_MIN_CSS = css`64.5rem`; | ||||
| 
 | ||||
| /** | ||||
|  * @deprecated Use `btrix-tab-group` | ||||
|  * | ||||
|  * Tab list | ||||
|  * | ||||
|  * Usage example: | ||||
|  * ```ts
 | ||||
|  * <btrix-tab-list activePanel="one"> | ||||
|  *   <btrix-tab slot="nav" name="one">One</btrix-tab> | ||||
|  *   <btrix-tab slot="nav" name="two">Two</btrix-tab> | ||||
|  * </btrix-tab-list> | ||||
|  * | ||||
|  * <btrix-tab-panel name="one">Tab one content</btrix-tab-panel> | ||||
|  * <btrix-tab-panel name="two">Tab two content</btrix-tab-panel> | ||||
|  * ``` | ||||
|  */ | ||||
| const tabTagName = "btrix-tab-list-tab" as const; | ||||
| 
 | ||||
| @customElement("btrix-tab-panel") | ||||
| export class TabPanel extends TailwindElement { | ||||
|   @property({ type: String }) | ||||
|   name?: string; | ||||
| 
 | ||||
|   @property({ type: Boolean }) | ||||
|   active = false; | ||||
| 
 | ||||
|   render() { | ||||
|     return html` | ||||
|       <div | ||||
|         class="flex-auto aria-hidden:hidden" | ||||
|         role="tabpanel" | ||||
|         id=${ifDefined(this.name)} | ||||
|         aria-hidden=${!this.active} | ||||
|       > | ||||
|         <slot></slot> | ||||
|       </div> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * @deprecated Use `btrix-tab-group` | ||||
|  */ | ||||
| @customElement("btrix-tab") | ||||
| export class Tab extends TailwindElement { | ||||
| @customElement(tabTagName) | ||||
| export class TabListTab extends TailwindElement { | ||||
|   // ID of panel the tab labels/controls
 | ||||
|   @property({ type: String }) | ||||
|   name?: string; | ||||
| @ -65,7 +25,7 @@ export class Tab extends TailwindElement { | ||||
|   render() { | ||||
|     return html` | ||||
|       <li | ||||
|         class="cursor-pointer px-3 py-4 font-semibold leading-tight text-neutral-500 transition-colors duration-fast aria-disabled:cursor-default aria-selected:text-primary-600" | ||||
|         class="cursor-pointer p-3 font-semibold leading-tight text-neutral-500 transition-colors duration-fast aria-disabled:cursor-default aria-selected:text-primary-600" | ||||
|         role="tab" | ||||
|         aria-selected=${this.active} | ||||
|         aria-controls=${ifDefined(this.name)} | ||||
| @ -78,11 +38,18 @@ export class Tab extends TailwindElement { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| type TabElement = Tab & HTMLElement; | ||||
| type TabPanelElement = TabPanel & HTMLElement; | ||||
| type TabElement = TabListTab & HTMLElement; | ||||
| 
 | ||||
| /** | ||||
|  * @deprecated Use `btrix-tab-group` | ||||
|  * Tab list with indicator | ||||
|  * | ||||
|  * Usage example: | ||||
|  * ```ts
 | ||||
|  * <btrix-tab-list tab="one"> | ||||
|  *   <btrix-tab name="one">One</btrix-tab> | ||||
|  *   <btrix-tab name="two">Two</btrix-tab> | ||||
|  * </btrix-tab-list> | ||||
|  * ``` | ||||
|  */ | ||||
| @customElement("btrix-tab-list") | ||||
| export class TabList extends TailwindElement { | ||||
| @ -91,30 +58,6 @@ export class TabList extends TailwindElement { | ||||
|       --track-width: 4px; | ||||
|     } | ||||
| 
 | ||||
|     .btrix-tab-list-container { | ||||
|       display: grid; | ||||
|       grid-template-areas: | ||||
|         "menu" | ||||
|         "header" | ||||
|         "main"; | ||||
|       grid-template-columns: 1fr; | ||||
|       grid-column-gap: 1.5rem; | ||||
|       grid-row-gap: 1rem; | ||||
|     } | ||||
| 
 | ||||
|     @media only screen and (min-width: ${TWO_COL_SCREEN_MIN_CSS}) { | ||||
|       .btrix-tab-list-container { | ||||
|         grid-template-areas: | ||||
|           ". header" | ||||
|           "menu main"; | ||||
|         grid-template-columns: 16.5rem 1fr; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     .navWrapper { | ||||
|       grid-area: menu; | ||||
|     } | ||||
| 
 | ||||
|     @media only screen and (min-width: ${TWO_COL_SCREEN_MIN_CSS}) { | ||||
|       .navWrapper { | ||||
|         overflow: initial; | ||||
| @ -173,7 +116,7 @@ export class TabList extends TailwindElement { | ||||
|       position: absolute; | ||||
|       width: var(--track-width); | ||||
|       border-radius: var(--track-width); | ||||
|       background-color: var(--sl-color-primary-600); | ||||
|       background-color: var(--sl-color-primary-500); | ||||
|     } | ||||
| 
 | ||||
|     @media only screen and (min-width: ${TWO_COL_SCREEN_MIN_CSS}) { | ||||
| @ -184,9 +127,9 @@ export class TabList extends TailwindElement { | ||||
|     } | ||||
|   `;
 | ||||
| 
 | ||||
|   // ID of visible panel
 | ||||
|   // ID of active tab
 | ||||
|   @property({ type: String }) | ||||
|   activePanel: string = DEFAULT_PANEL_ID; | ||||
|   tab: string = DEFAULT_PANEL_ID; | ||||
| 
 | ||||
|   // If panels are linear, the current panel in progress
 | ||||
|   @property({ type: String }) | ||||
| @ -202,8 +145,8 @@ export class TabList extends TailwindElement { | ||||
|   private readonly indicatorElem!: HTMLElement; | ||||
| 
 | ||||
|   updated(changedProperties: PropertyValues<this>) { | ||||
|     if (changedProperties.has("activePanel") && this.activePanel) { | ||||
|       this.onActiveChange(!changedProperties.get("activePanel")); | ||||
|     if (changedProperties.has("tab") && this.tab) { | ||||
|       this.onActiveChange(!changedProperties.get("tab")); | ||||
|     } | ||||
|     if (changedProperties.has("progressPanel") && this.progressPanel) { | ||||
|       this.onProgressChange(!changedProperties.get("progressPanel")); | ||||
| @ -235,15 +178,7 @@ export class TabList extends TailwindElement { | ||||
|   } | ||||
| 
 | ||||
|   render() { | ||||
|     return html` | ||||
|       <div class="btrix-tab-list-container"> | ||||
|         <div class="navWrapper min-w-0">${this.renderNav()}</div> | ||||
|         <div class="header"><slot name="header"></slot></div> | ||||
|         <div class="content"> | ||||
|           <slot></slot> | ||||
|         </div> | ||||
|       </div> | ||||
|     `;
 | ||||
|     return html`<div class="navWrapper min-w-0">${this.renderNav()}</div>`; | ||||
|   } | ||||
| 
 | ||||
|   renderNav() { | ||||
| @ -256,35 +191,26 @@ export class TabList extends TailwindElement { | ||||
|           class="nav ${this.progressPanel ? "linear" : "nonlinear"} ${this | ||||
|             .hideIndicator | ||||
|             ? "hide-indicator" | ||||
|             : "show-indicator"} -m-3 overflow-x-hidden p-3" | ||||
|             : "show-indicator"} -mx-3 overflow-x-hidden px-3" | ||||
|         > | ||||
|           <div class="track" role="presentation"> | ||||
|             <div class="indicator" role="presentation"></div> | ||||
|           </div> | ||||
| 
 | ||||
|           <ul class="tablist -m-3 overflow-x-auto p-3" role="tablist"> | ||||
|             <slot name="nav"></slot> | ||||
|           <ul class="tablist -mx-3 overflow-x-auto px-3" role="tablist"> | ||||
|             <slot></slot> | ||||
|           </ul> | ||||
|         </div> | ||||
|       </sl-resize-observer> | ||||
|     `;
 | ||||
|   } | ||||
| 
 | ||||
|   private getPanels(): TabPanelElement[] { | ||||
|     const slotElems = this.renderRoot | ||||
|       .querySelector<HTMLSlotElement>(".content slot:not([name])")! | ||||
|       .assignedElements(); | ||||
|     return ([...slotElems] as TabPanelElement[]).filter( | ||||
|       (el) => el.tagName.toLowerCase() === "btrix-tab-panel", | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   private getTabs(): TabElement[] { | ||||
|     const slotElems = this.renderRoot | ||||
|       .querySelector<HTMLSlotElement>("slot[name='nav']")! | ||||
|       .querySelector<HTMLSlotElement>("slot")! | ||||
|       .assignedElements(); | ||||
|     return ([...slotElems] as TabElement[]).filter( | ||||
|       (el) => el.tagName.toLowerCase() === "btrix-tab", | ||||
|       (el) => el.tagName.toLowerCase() === tabTagName, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| @ -305,7 +231,7 @@ export class TabList extends TailwindElement { | ||||
| 
 | ||||
|   private onActiveChange(isFirstChange: boolean) { | ||||
|     this.getTabs().forEach((tab) => { | ||||
|       if (tab.name === this.activePanel) { | ||||
|       if (tab.name === this.tab) { | ||||
|         tab.active = true; | ||||
| 
 | ||||
|         if (!this.progressPanel) { | ||||
| @ -315,15 +241,5 @@ export class TabList extends TailwindElement { | ||||
|         tab.active = false; | ||||
|       } | ||||
|     }); | ||||
|     this.getPanels().forEach((panel) => { | ||||
|       panel.active = panel.name === this.activePanel; | ||||
|       if (panel.active) { | ||||
|         panel.style.display = "flex"; | ||||
|         panel.setAttribute("aria-hidden", "false"); | ||||
|       } else { | ||||
|         panel.style.display = "none"; | ||||
|         panel.setAttribute("aria-hidden", "true"); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,53 +1,32 @@ | ||||
| import { html, LitElement } from "lit"; | ||||
| import { customElement, query } from "lit/decorators.js"; | ||||
| import { customElement, property } from "lit/decorators.js"; | ||||
| 
 | ||||
| type IntersectionEventDetail = { | ||||
|   entry: IntersectionObserverEntry; | ||||
| }; | ||||
| export type IntersectEvent = CustomEvent<IntersectionEventDetail>; | ||||
| import { ObservableController } from "@/controllers/observable"; | ||||
| 
 | ||||
| /** | ||||
|  * Observe element with Intersection Observer API. | ||||
|  * | ||||
|  * @example Usage: | ||||
|  * ``` | ||||
|  * <btrix-observable @intersect=${console.log}> | ||||
|  * <btrix-observable @btrix-intersect=${console.log}> | ||||
|  *   Observe me! | ||||
|  * </btrix-observable> | ||||
|  * ``` | ||||
|  * | ||||
|  * @event intersect { entry: IntersectionObserverEntry } | ||||
|  * @fires btrix-intersect IntersectionEventDetail | ||||
|  */ | ||||
| @customElement("btrix-observable") | ||||
| export class Observable extends LitElement { | ||||
|   @query(".target") | ||||
|   private readonly target?: HTMLElement; | ||||
|   @property({ type: Object }) | ||||
|   options?: IntersectionObserverInit; | ||||
| 
 | ||||
|   private observer?: IntersectionObserver; | ||||
| 
 | ||||
|   connectedCallback(): void { | ||||
|     super.connectedCallback(); | ||||
|     this.observer = new IntersectionObserver(this.handleIntersect); | ||||
|   } | ||||
| 
 | ||||
|   disconnectedCallback(): void { | ||||
|     this.observer?.disconnect(); | ||||
|     super.disconnectedCallback(); | ||||
|   } | ||||
|   private readonly observable = new ObservableController(this); | ||||
| 
 | ||||
|   firstUpdated() { | ||||
|     this.observer?.observe(this.target!); | ||||
|     this.observable.observe(this); | ||||
|   } | ||||
| 
 | ||||
|   private readonly handleIntersect = ([entry]: IntersectionObserverEntry[]) => { | ||||
|     this.dispatchEvent( | ||||
|       new CustomEvent<IntersectionEventDetail>("intersect", { | ||||
|         detail: { entry }, | ||||
|       }), | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   render() { | ||||
|     return html`<div class="target"><slot></slot></div>`; | ||||
|     return html`<slot></slot>`; | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										50
									
								
								frontend/src/controllers/observable.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								frontend/src/controllers/observable.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,50 @@ | ||||
| import type { ReactiveController, ReactiveControllerHost } from "lit"; | ||||
| 
 | ||||
| type IntersectionEventDetail = { | ||||
|   entries: IntersectionObserverEntry[]; | ||||
| }; | ||||
| export type IntersectEvent = CustomEvent<IntersectionEventDetail>; | ||||
| 
 | ||||
| /** | ||||
|  * Observe one or more elements with Intersection Observer API. | ||||
|  * | ||||
|  * @fires btrix-intersect IntersectionEventDetail | ||||
|  */ | ||||
| export class ObservableController implements ReactiveController { | ||||
|   private readonly host: ReactiveControllerHost & EventTarget; | ||||
| 
 | ||||
|   private observer?: IntersectionObserver; | ||||
|   private readonly observerOptions?: IntersectionObserverInit; | ||||
| 
 | ||||
|   constructor( | ||||
|     host: ObservableController["host"], | ||||
|     options?: IntersectionObserverInit, | ||||
|   ) { | ||||
|     this.host = host; | ||||
|     this.observerOptions = options; | ||||
|     host.addController(this); | ||||
|   } | ||||
| 
 | ||||
|   hostConnected() { | ||||
|     this.observer = new IntersectionObserver( | ||||
|       this.handleIntersect, | ||||
|       this.observerOptions, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   hostDisconnected() { | ||||
|     this.observer?.disconnect(); | ||||
|   } | ||||
| 
 | ||||
|   public observe(target: Element) { | ||||
|     this.observer?.observe(target); | ||||
|   } | ||||
| 
 | ||||
|   private readonly handleIntersect = (entries: IntersectionObserverEntry[]) => { | ||||
|     this.host.dispatchEvent( | ||||
|       new CustomEvent<IntersectionEventDetail>("btrix-intersect", { | ||||
|         detail: { entries }, | ||||
|       }), | ||||
|     ); | ||||
|   }; | ||||
| } | ||||
| @ -10,6 +10,7 @@ import { when } from "lit/directives/when.js"; | ||||
| import throttle from "lodash/fp/throttle"; | ||||
| 
 | ||||
| import { BtrixElement } from "@/classes/BtrixElement"; | ||||
| import type { IntersectEvent } from "@/controllers/observable"; | ||||
| 
 | ||||
| type Pages = string[]; | ||||
| type ResponseData = { | ||||
| @ -201,7 +202,7 @@ export class CrawlQueue extends BtrixElement { | ||||
|               ${msg("End of queue")} | ||||
|             </div>`,
 | ||||
|           () => html` | ||||
|             <btrix-observable @intersect=${this.onLoadMoreIntersect}> | ||||
|             <btrix-observable @btrix-intersect=${this.onLoadMoreIntersect}> | ||||
|               <div class="py-3"> | ||||
|                 <sl-icon-button | ||||
|                   name="three-dots" | ||||
| @ -232,8 +233,8 @@ export class CrawlQueue extends BtrixElement { | ||||
|     `;
 | ||||
|   } | ||||
| 
 | ||||
|   private readonly onLoadMoreIntersect = throttle(50)((e: CustomEvent) => { | ||||
|     if (!e.detail.entry.isIntersecting) return; | ||||
|   private readonly onLoadMoreIntersect = throttle(50)((e: IntersectEvent) => { | ||||
|     if (!e.detail.entries[0].isIntersecting) return; | ||||
|     this.loadMore(); | ||||
|   }) as (e: CustomEvent) => void; | ||||
| 
 | ||||
|  | ||||
| @ -1,6 +1,8 @@ | ||||
| import { localized, msg, str } from "@lit/localize"; | ||||
| import type { SlInput, SlSelect } from "@shoelace-style/shoelace"; | ||||
| import clsx from "clsx"; | ||||
| import { css, html, type PropertyValues, type TemplateResult } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators.js"; | ||||
| import { customElement, property, queryAll, state } from "lit/decorators.js"; | ||||
| import { ifDefined } from "lit/directives/if-defined.js"; | ||||
| import { when } from "lit/directives/when.js"; | ||||
| import { html as staticHtml, unsafeStatic } from "lit/static-html.js"; | ||||
| @ -12,18 +14,17 @@ import { TailwindElement } from "@/classes/TailwindElement"; | ||||
| import { type PageChangeEvent } from "@/components/ui/pagination"; | ||||
| import type { SeedConfig } from "@/pages/org/types"; | ||||
| import { regexEscape, regexUnescape } from "@/utils/string"; | ||||
| import { tw } from "@/utils/tailwind"; | ||||
| 
 | ||||
| export type ExclusionChangeEvent = CustomEvent<{ | ||||
| export type ExclusionChangeEventDetail = { | ||||
|   index: number; | ||||
|   regex: string; | ||||
| }>; | ||||
|   valid?: boolean; | ||||
| }; | ||||
| 
 | ||||
| export type ExclusionRemoveEvent = CustomEvent<{ | ||||
|   index: number; | ||||
|   regex: string; | ||||
| }>; | ||||
| export type ExclusionChangeEvent = CustomEvent<ExclusionChangeEventDetail>; | ||||
| 
 | ||||
| type SLInputElement = HTMLInputElement & { invalid: boolean }; | ||||
| export type ExclusionRemoveEvent = CustomEvent<ExclusionChangeEventDetail>; | ||||
| 
 | ||||
| const MIN_LENGTH = 2; | ||||
| 
 | ||||
| @ -54,12 +55,6 @@ function formatValue(type: Exclusion["type"], value: Exclusion["value"]) { | ||||
| @localized() | ||||
| export class QueueExclusionTable extends TailwindElement { | ||||
|   static styles = css` | ||||
|     sl-input { | ||||
|       --sl-input-border-radius-medium: 0; | ||||
|       --sl-input-font-family: var(--sl-font-mono); | ||||
|       --sl-input-spacing-medium: var(--sl-spacing-small); | ||||
|     } | ||||
| 
 | ||||
|     sl-input:not([data-invalid]) { | ||||
|       --sl-input-border-width: 0; | ||||
|     } | ||||
| @ -100,6 +95,19 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|   @state() | ||||
|   private exclusionToRemove?: string; | ||||
| 
 | ||||
|   @queryAll("sl-input") | ||||
|   private readonly inputs!: NodeListOf<SlInput>; | ||||
| 
 | ||||
|   public reportValidity() { | ||||
|     this.inputs.forEach((input) => { | ||||
|       input.reportValidity(); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   public checkValidity() { | ||||
|     return ![...this.inputs].some((input) => !input.validity.valid); | ||||
|   } | ||||
| 
 | ||||
|   private get total() { | ||||
|     return this.exclusions?.length; | ||||
|   } | ||||
| @ -187,11 +195,11 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|               ${msg("Exclusion Value")} | ||||
|             </th> | ||||
|             <th class="${actionColClass} w-10 bg-slate-50 px-2 font-normal"> | ||||
|               <span class="sr-only">Row actions</span> | ||||
|               <span class="sr-only">${msg("Row actions")}</span> | ||||
|             </th> | ||||
|           </tr> | ||||
|         </thead> | ||||
|         <tbody> | ||||
|         <tbody class="align-top"> | ||||
|           ${this.results.map(this.renderItem)} | ||||
|         </tbody> | ||||
|       </table> | ||||
| @ -220,12 +228,12 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|       <tr | ||||
|         class="${this.exclusionToRemove === exclusion.value | ||||
|           ? "text-neutral-200" | ||||
|           : "text-neutral-600"} h-10" | ||||
|           : "text-neutral-600"}" | ||||
|       > | ||||
|         <td class="${typeColClass} whitespace-nowrap"> | ||||
|           ${this.renderType({ exclusion, index })} | ||||
|         </td> | ||||
|         <td class="${valueColClass} font-mono"> | ||||
|         <td class="${valueColClass}"> | ||||
|           ${this.renderValue({ exclusion, index })} | ||||
|         </td> | ||||
|         <td class="${actionColClass} text-center text-[1rem]"> | ||||
| @ -266,11 +274,23 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|       return html` | ||||
|         <sl-select | ||||
|           placeholder=${msg("Select Type")} | ||||
|           class="my-1" | ||||
|           size="small" | ||||
|           .value=${exclusion.type} | ||||
|           value=${exclusion.type} | ||||
|           @sl-hide=${this.stopProp} | ||||
|           @sl-after-hide=${this.stopProp} | ||||
|           @sl-change=${(e: Event) => { | ||||
|             const inputElem = (e.target as SlSelect) | ||||
|               .closest("tr") | ||||
|               ?.querySelector("sl-input"); | ||||
| 
 | ||||
|             if (inputElem) { | ||||
|               this.checkInputValidity(inputElem); | ||||
|               this.reportInputValidity(inputElem); | ||||
|             } else { | ||||
|               console.debug("no inputElem for ", e.target); | ||||
|             } | ||||
| 
 | ||||
|             void this.updateExclusion({ | ||||
|               type: (e.target as HTMLSelectElement).value as Exclusion["type"], | ||||
|               value: exclusion.value, | ||||
| @ -302,7 +322,13 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|         <sl-input | ||||
|           name="exclusion-${index}" | ||||
|           placeholder=${msg("Enter value")} | ||||
|           class="m-0" | ||||
|           class=${clsx( | ||||
|             tw`m-0`, | ||||
|             tw`[--sl-input-border-radius-medium:0] [--sl-input-spacing-medium:var(--sl-spacing-small)]`, | ||||
|             tw`part-[form-control-help-text]:mx-1 part-[form-control-help-text]:mb-1`, | ||||
|             exclusion.type === "regex" && | ||||
|               tw`[--sl-input-font-family:var(--sl-font-mono)]`, | ||||
|           )} | ||||
|           value=${exclusion.value} | ||||
|           autocomplete="off" | ||||
|           autocorrect="off" | ||||
| @ -315,16 +341,16 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|             }); | ||||
|           }} | ||||
|           @sl-input=${(e: CustomEvent) => { | ||||
|             const inputElem = e.target as SLInputElement; | ||||
|             const validityMessage = this.getInputValidity(inputElem) || ""; | ||||
| 
 | ||||
|             inputElem.classList.remove("invalid"); | ||||
|             inputElem.setCustomValidity(validityMessage); | ||||
|             const inputElem = e.target as SlInput; | ||||
| 
 | ||||
|             this.checkInputValidity(inputElem); | ||||
|             this.checkSiblingRowValidity(e); | ||||
|           }} | ||||
|           @sl-change=${(e: CustomEvent) => { | ||||
|             const inputElem = e.target as SLInputElement; | ||||
|             const inputElem = e.target as SlInput; | ||||
| 
 | ||||
|             this.reportInputValidity(inputElem); | ||||
| 
 | ||||
|             const values = this.getCurrentValues(inputElem); | ||||
|             const params = { | ||||
|               type: values.type || exclusion.type, | ||||
| @ -332,11 +358,6 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|               index, | ||||
|             }; | ||||
| 
 | ||||
|             if (!inputElem.checkValidity()) { | ||||
|               inputElem.classList.add("invalid"); | ||||
|             } | ||||
|             inputElem.reportValidity(); | ||||
| 
 | ||||
|             void this.updateExclusion(params); | ||||
|           }} | ||||
|         ></sl-input> | ||||
| @ -344,7 +365,7 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|     } | ||||
| 
 | ||||
|     if (exclusion.type === "regex") { | ||||
|       value = staticHtml`<span class="regex">${unsafeStatic( | ||||
|       value = staticHtml`<span class="regex ${tw`font-mono`}">${unsafeStatic( | ||||
|         new RegexColorize().colorizeText(exclusion.value) as string, | ||||
|       )}</span>`;
 | ||||
|     } | ||||
| @ -352,6 +373,27 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|     return value; | ||||
|   } | ||||
| 
 | ||||
|   private checkInputValidity(inputElem: SlInput) { | ||||
|     const validityMessage = this.getInputValidity(inputElem) || ""; | ||||
| 
 | ||||
|     inputElem.setCustomValidity(validityMessage); | ||||
| 
 | ||||
|     if (inputElem.classList.contains("invalid")) { | ||||
|       // Update help text on change
 | ||||
|       this.reportInputValidity(inputElem); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private reportInputValidity(inputElem: SlInput) { | ||||
|     if (inputElem.validationMessage) { | ||||
|       inputElem.classList.add("invalid"); | ||||
|     } else { | ||||
|       inputElem.classList.remove("invalid"); | ||||
|     } | ||||
| 
 | ||||
|     inputElem.helpText = inputElem.validationMessage; | ||||
|   } | ||||
| 
 | ||||
|   private getColumnClassNames( | ||||
|     index: number, | ||||
|     count: number, | ||||
| @ -398,7 +440,7 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|     return [typeColClass, valueColClass, actionColClass]; | ||||
|   } | ||||
| 
 | ||||
|   private getCurrentValues(inputElem: SLInputElement) { | ||||
|   private getCurrentValues(inputElem: SlInput) { | ||||
|     // Get latest exclusion type value from select
 | ||||
|     const typeSelectElem = inputElem.closest("tr")?.querySelector("sl-select"); | ||||
|     const exclusionType = typeSelectElem?.value; | ||||
| @ -408,7 +450,7 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   private getInputDuplicateValidity(inputElem: SLInputElement) { | ||||
|   private getInputDuplicateValidity(inputElem: SlInput) { | ||||
|     const siblingElems = inputElem | ||||
|       .closest("table") | ||||
|       ?.querySelectorAll(`sl-input:not([name="${inputElem.name}"])`); | ||||
| @ -417,7 +459,7 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|       return; | ||||
|     } | ||||
|     const siblingValues = Array.from(siblingElems).map( | ||||
|       (elem) => (elem as SLInputElement).value, | ||||
|       (elem) => (elem as SlInput).value, | ||||
|     ); | ||||
|     const { type, value } = this.getCurrentValues(inputElem); | ||||
|     const formattedValue = formatValue(type!, value); | ||||
| @ -426,10 +468,24 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private getInputValidity(inputElem: SLInputElement): string | void { | ||||
|   private getInputValidity(inputElem: SlInput): string | void { | ||||
|     const { type, value } = this.getCurrentValues(inputElem); | ||||
|     if (!value) return; | ||||
| 
 | ||||
|     const validityMessage = this.getValidityMessage({ type, value }); | ||||
| 
 | ||||
|     if (validityMessage) return validityMessage; | ||||
| 
 | ||||
|     return this.getInputDuplicateValidity(inputElem); | ||||
|   } | ||||
| 
 | ||||
|   private getValidityMessage({ | ||||
|     type, | ||||
|     value, | ||||
|   }: { | ||||
|     type?: Exclusion["type"]; | ||||
|     value: string; | ||||
|   }) { | ||||
|     if (value.length < MIN_LENGTH) { | ||||
|       return msg(str`Please enter ${MIN_LENGTH} or more characters`); | ||||
|     } | ||||
| @ -439,13 +495,9 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|         // Check if valid regex
 | ||||
|         new RegExp(value); | ||||
|       } catch (err) { | ||||
|         return msg( | ||||
|           "Please enter a valid Regular Expression constructor pattern", | ||||
|         ); | ||||
|         return msg("Please enter a valid regular expression"); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return this.getInputDuplicateValidity(inputElem); | ||||
|   } | ||||
| 
 | ||||
|   private checkSiblingRowValidity(e: CustomEvent) { | ||||
| @ -456,9 +508,8 @@ export class QueueExclusionTable extends TailwindElement { | ||||
|     Array.from(table.querySelectorAll("sl-input[data-invalid]")).map((elem) => { | ||||
|       if (elem !== inputElem) { | ||||
|         const validityMessage = | ||||
|           this.getInputDuplicateValidity(elem as SLInputElement) || ""; | ||||
|         (elem as SLInputElement).setCustomValidity(validityMessage); | ||||
|         (elem as SLInputElement).reportValidity(); | ||||
|           this.getInputDuplicateValidity(elem as SlInput) || ""; | ||||
|         (elem as SlInput).setCustomValidity(validityMessage); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| @ -477,11 +528,18 @@ export class QueueExclusionTable extends TailwindElement { | ||||
| 
 | ||||
|     await this.updateComplete; | ||||
| 
 | ||||
|     let valid: boolean | undefined; | ||||
| 
 | ||||
|     if (value.length) { | ||||
|       valid = !this.getValidityMessage({ type, value }); | ||||
|     } | ||||
| 
 | ||||
|     this.dispatchEvent( | ||||
|       new CustomEvent("btrix-remove", { | ||||
|       new CustomEvent<ExclusionChangeEventDetail>("btrix-remove", { | ||||
|         detail: { | ||||
|           index, | ||||
|           regex, | ||||
|           valid, | ||||
|         }, | ||||
|       }) as ExclusionRemoveEvent, | ||||
|     ); | ||||
| @ -517,13 +575,20 @@ export class QueueExclusionTable extends TailwindElement { | ||||
| 
 | ||||
|     await this.updateComplete; | ||||
| 
 | ||||
|     let valid: boolean | undefined; | ||||
| 
 | ||||
|     if (value.length) { | ||||
|       valid = !this.getValidityMessage({ type, value }); | ||||
|     } | ||||
| 
 | ||||
|     this.dispatchEvent( | ||||
|       new CustomEvent("btrix-change", { | ||||
|       new CustomEvent<ExclusionChangeEventDetail>("btrix-change", { | ||||
|         detail: { | ||||
|           index, | ||||
|           regex, | ||||
|           valid, | ||||
|         }, | ||||
|       }) as ExclusionChangeEvent, | ||||
|       }), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|  | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										36
									
								
								frontend/src/layouts/pageSectionsWithNav.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								frontend/src/layouts/pageSectionsWithNav.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,36 @@ | ||||
| import clsx from "clsx"; | ||||
| import { html, type TemplateResult } from "lit"; | ||||
| 
 | ||||
| import { tw } from "@/utils/tailwind"; | ||||
| 
 | ||||
| export function pageSectionsWithNav({ | ||||
|   nav, | ||||
|   main, | ||||
|   placement = "start", | ||||
|   sticky = false, | ||||
| }: { | ||||
|   nav: TemplateResult; | ||||
|   main: TemplateResult; | ||||
|   placement?: "start" | "top"; | ||||
|   sticky?: boolean; | ||||
| }) { | ||||
|   return html` | ||||
|     <div | ||||
|       class=${clsx( | ||||
|         tw`flex flex-col`, | ||||
|         placement === "start" && tw`gap-8 lg:flex-row`, | ||||
|       )} | ||||
|     > | ||||
|       <div | ||||
|         class=${clsx( | ||||
|           tw`flex flex-1 flex-col gap-2`, | ||||
|           sticky && tw`lg:sticky lg:top-2 lg:self-start`, | ||||
|           placement === "start" ? tw`lg:max-w-[16.5rem]` : tw`lg:flex-row`, | ||||
|         )} | ||||
|       > | ||||
|         ${nav} | ||||
|       </div> | ||||
|       <div class="flex-1">${main}</div> | ||||
|     </div> | ||||
|   `;
 | ||||
| } | ||||
							
								
								
									
										44
									
								
								frontend/src/layouts/panel.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								frontend/src/layouts/panel.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,44 @@ | ||||
| import { html, type TemplateResult } from "lit"; | ||||
| import { ifDefined } from "lit/directives/if-defined.js"; | ||||
| 
 | ||||
| import { pageHeading } from "./page"; | ||||
| 
 | ||||
| export function panelHeader({ | ||||
|   heading, | ||||
|   actions, | ||||
| }: { | ||||
|   heading: string | Parameters<typeof pageHeading>[0]; | ||||
|   actions?: TemplateResult; | ||||
| }) { | ||||
|   return html` | ||||
|     <header class="mb-3 flex min-h-8 items-baseline justify-between"> | ||||
|       ${typeof heading === "string" | ||||
|         ? pageHeading({ content: heading }) | ||||
|         : pageHeading(heading)} | ||||
|       ${actions} | ||||
|     </header> | ||||
|   `;
 | ||||
| } | ||||
| 
 | ||||
| export function panelBody({ content }: { content: TemplateResult }) { | ||||
|   return html`<div class="lg:rounded-lg lg:border lg:p-4">${content}</div>`; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * @TODO Refactor components to use panel | ||||
|  */ | ||||
| export function panel({ | ||||
|   heading, | ||||
|   actions, | ||||
|   body, | ||||
|   id, | ||||
|   className, | ||||
| }: { | ||||
|   body: TemplateResult; | ||||
|   id?: string; | ||||
|   className?: string; | ||||
| } & Parameters<typeof panelHeader>[0]) { | ||||
|   return html`<section id=${ifDefined(id)} class=${ifDefined(className)}>
 | ||||
|     ${panelHeader({ heading, actions })} ${body} | ||||
|   </section>`;
 | ||||
| } | ||||
| @ -6,8 +6,9 @@ import { type FormState } from "@/utils/workflow"; | ||||
| type Field = keyof FormState; | ||||
| 
 | ||||
| const infoText: Partial<Record<Field, string | TemplateResult>> = { | ||||
|   exclusions: msg(`Specify exclusion rules for what pages should not be visited.
 | ||||
|   Exclusions apply to all URLs.`),
 | ||||
|   exclusions: msg( | ||||
|     "Specify exclusion rules for what pages should not be visited.", | ||||
|   ), | ||||
|   pageLimit: msg( | ||||
|     "Adds a hard limit on the number of pages that will be crawled.", | ||||
|   ), | ||||
|  | ||||
| @ -183,11 +183,13 @@ | ||||
|   } | ||||
| 
 | ||||
|   /* Validation styles */ | ||||
|   [data-user-invalid]:not([disabled])::part(base) { | ||||
|   sl-input[data-user-invalid]:not([disabled])::part(base), | ||||
|   sl-textarea[data-user-invalid]:not([disabled])::part(base) { | ||||
|     border-color: var(--sl-color-danger-400); | ||||
|   } | ||||
| 
 | ||||
|   [data-user-invalid]:focus-within::part(base) { | ||||
|   sl-input[data-user-invalid]:focus-within::part(base), | ||||
|   sl-textarea[data-user-invalid]:focus-within::part(base) { | ||||
|     box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-100); | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -155,9 +155,6 @@ export function getInitialFormState(params: { | ||||
|   org?: OrgData | null; | ||||
| }): FormState { | ||||
|   const defaultFormState = getDefaultFormState(); | ||||
|   if (!params.configId) { | ||||
|     defaultFormState.runNow = true; | ||||
|   } | ||||
|   if (!params.initialWorkflow) return defaultFormState; | ||||
|   const formState: Partial<FormState> = {}; | ||||
|   const seedsConfig = params.initialWorkflow.config; | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user