diff --git a/backend/btrixcloud/crawlconfigs.py b/backend/btrixcloud/crawlconfigs.py index 8aba75f4..8fb6ae98 100644 --- a/backend/btrixcloud/crawlconfigs.py +++ b/backend/btrixcloud/crawlconfigs.py @@ -25,6 +25,7 @@ from .models import ( ConfigRevision, CrawlConfig, CrawlConfigOut, + CrawlConfigTags, CrawlOut, UpdateCrawlConfig, Organization, @@ -976,8 +977,20 @@ class CrawlConfigOps: async def get_crawl_config_tags(self, org): """get distinct tags from all crawl configs for this org""" - tags = await self.crawl_configs.distinct("tags", {"oid": org.id}) - return list(tags) + return await self.crawl_configs.distinct("tags", {"oid": org.id}) + + async def get_crawl_config_tag_counts(self, org): + """get distinct tags from all crawl configs for this org""" + tags = await self.crawl_configs.aggregate( + [ + {"$match": {"oid": org.id}}, + {"$unwind": "$tags"}, + {"$group": {"_id": "$tags", "count": {"$sum": 1}}}, + {"$project": {"tag": "$_id", "count": "$count", "_id": 0}}, + {"$sort": {"count": -1, "tag": 1}}, + ] + ).to_list() + return tags async def get_crawl_config_search_values(self, org): """List unique names, first seeds, and descriptions from all workflows in org""" @@ -1399,10 +1412,17 @@ def init_crawl_config_api( ) return paginated_format(crawl_configs, total, page, pageSize) - @router.get("/tags", response_model=List[str]) + @router.get("/tags", response_model=List[str], deprecated=True) async def get_crawl_config_tags(org: Organization = Depends(org_viewer_dep)): + """ + Deprecated - prefer /api/orgs/{oid}/crawlconfigs/tagCounts instead. + """ return await ops.get_crawl_config_tags(org) + @router.get("/tagCounts", response_model=CrawlConfigTags) + async def get_crawl_config_tag_counts(org: Organization = Depends(org_viewer_dep)): + return {"tags": await ops.get_crawl_config_tag_counts(org)} + @router.get("/search-values", response_model=CrawlConfigSearchValues) async def get_crawl_config_search_values( org: Organization = Depends(org_viewer_dep), diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index 1f57e310..5475baa1 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -577,11 +577,19 @@ class CrawlConfigAddedResponse(BaseModel): execMinutesQuotaReached: bool +# ============================================================================ +class CrawlConfigTagCount(BaseModel): + """Response model for crawlconfig tag count""" + + tag: str + count: int + + # ============================================================================ class CrawlConfigTags(BaseModel): """Response model for crawlconfig tags""" - tags: List[str] + tags: List[CrawlConfigTagCount] # ============================================================================ diff --git a/backend/test/test_crawl_config_tags.py b/backend/test/test_crawl_config_tags.py index d15d900b..492e2ab1 100644 --- a/backend/test/test_crawl_config_tags.py +++ b/backend/test/test_crawl_config_tags.py @@ -50,6 +50,22 @@ def test_get_config_by_tag_1(admin_auth_headers, default_org_id): assert sorted(data) == ["tag-1", "tag-2", "wr-test-1", "wr-test-2"] +def test_get_config_by_tag_counts_1(admin_auth_headers, default_org_id): + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/crawlconfigs/tagCounts", + headers=admin_auth_headers, + ) + data = r.json() + assert data == { + "tags": [ + {"tag": "wr-test-2", "count": 2}, + {"tag": "tag-1", "count": 1}, + {"tag": "tag-2", "count": 1}, + {"tag": "wr-test-1", "count": 1}, + ] + } + + def test_create_new_config_2(admin_auth_headers, default_org_id): r = requests.post( f"{API_PREFIX}/orgs/{default_org_id}/crawlconfigs/", @@ -84,6 +100,24 @@ def test_get_config_by_tag_2(admin_auth_headers, default_org_id): ] +def test_get_config_by_tag_counts_2(admin_auth_headers, default_org_id): + r = requests.get( + f"{API_PREFIX}/orgs/{default_org_id}/crawlconfigs/tagCounts", + headers=admin_auth_headers, + ) + data = r.json() + assert data == { + "tags": [ + {"tag": "wr-test-2", "count": 2}, + {"tag": "tag-0", "count": 1}, + {"tag": "tag-1", "count": 1}, + {"tag": "tag-2", "count": 1}, + {"tag": "tag-3", "count": 1}, + {"tag": "wr-test-1", "count": 1}, + ] + } + + def test_get_config_2(admin_auth_headers, default_org_id): r = requests.get( f"{API_PREFIX}/orgs/{default_org_id}/crawlconfigs/{new_cid_2}", diff --git a/frontend/src/components/ui/badge.ts b/frontend/src/components/ui/badge.ts index c66fc6c7..ddff3f40 100644 --- a/frontend/src/components/ui/badge.ts +++ b/frontend/src/components/ui/badge.ts @@ -11,7 +11,7 @@ export type BadgeVariant = | "danger" | "neutral" | "primary" - | "blue" + | "cyan" | "high-contrast"; /** @@ -27,6 +27,12 @@ export class Badge extends TailwindElement { @property({ type: String }) variant: BadgeVariant = "neutral"; + @property({ type: Boolean }) + outline = false; + + @property({ type: Boolean }) + pill = false; + @property({ type: String, reflect: true }) role: string | null = "status"; @@ -40,16 +46,32 @@ export class Badge extends TailwindElement { return html` diff --git a/frontend/src/components/ui/tag-input.ts b/frontend/src/components/ui/tag-input.ts index 2d563a9b..bb24b7f9 100644 --- a/frontend/src/components/ui/tag-input.ts +++ b/frontend/src/components/ui/tag-input.ts @@ -17,6 +17,7 @@ import { customElement, property, query, state } from "lit/decorators.js"; import debounce from "lodash/fp/debounce"; import type { UnderlyingFunction } from "@/types/utils"; +import { type WorkflowTag } from "@/types/workflow"; import { dropdown } from "@/utils/css"; export type Tags = string[]; @@ -80,7 +81,7 @@ export class TagInput extends LitElement { } sl-popup::part(popup) { - z-index: 3; + z-index: 5; } .shake { @@ -116,7 +117,7 @@ export class TagInput extends LitElement { initialTags?: Tags; @property({ type: Array }) - tagOptions: Tags = []; + tagOptions: WorkflowTag[] = []; @property({ type: Boolean }) disabled = false; @@ -224,6 +225,7 @@ export class TagInput extends LitElement { @paste=${this.onPaste} ?required=${this.required && !this.tags.length} placeholder=${placeholder} + autocomplete="off" role="combobox" aria-controls="dropdown" aria-expanded="${this.dropdownIsOpen === true}" @@ -258,10 +260,14 @@ export class TagInput extends LitElement { > ${this.tagOptions .slice(0, 3) + .filter(({ tag }) => !this.tags.includes(tag)) .map( - (tag) => html` + ({ tag, count }) => html` ${tag}${tag} + ${count} `, )} diff --git a/frontend/src/features/archived-items/file-uploader.ts b/frontend/src/features/archived-items/file-uploader.ts index 6bf05345..591829df 100644 --- a/frontend/src/features/archived-items/file-uploader.ts +++ b/frontend/src/features/archived-items/file-uploader.ts @@ -17,6 +17,7 @@ import type { TagsChangeEvent, } from "@/components/ui/tag-input"; import { type CollectionsChangeEvent } from "@/features/collections/collections-add"; +import { type WorkflowTag, type WorkflowTags } from "@/types/workflow"; import { APIError } from "@/utils/api"; import { maxLengthValidator } from "@/utils/form"; @@ -70,7 +71,7 @@ export class FileUploader extends BtrixElement { private collectionIds: string[] = []; @state() - private tagOptions: Tags = []; + private tagOptions: WorkflowTag[] = []; @state() private tagsToSave: Tags = []; @@ -85,7 +86,8 @@ export class FileUploader extends BtrixElement { private readonly form!: Promise; // For fuzzy search: - private readonly fuse = new Fuse([], { + private readonly fuse = new Fuse([], { + keys: ["tag"], shouldSort: false, threshold: 0.2, // stricter; default is 0.6 }); @@ -361,8 +363,8 @@ export class FileUploader extends BtrixElement { private async fetchTags() { try { - const tags = await this.api.fetch( - `/orgs/${this.orgId}/crawlconfigs/tags`, + const { tags } = await this.api.fetch( + `/orgs/${this.orgId}/crawlconfigs/tagCounts`, ); // Update search/filter collection diff --git a/frontend/src/features/archived-items/item-metadata-editor.ts b/frontend/src/features/archived-items/item-metadata-editor.ts index aac851e7..1825f0ae 100644 --- a/frontend/src/features/archived-items/item-metadata-editor.ts +++ b/frontend/src/features/archived-items/item-metadata-editor.ts @@ -10,6 +10,7 @@ import type { } from "@/components/ui/tag-input"; import { type CollectionsChangeEvent } from "@/features/collections/collections-add"; import type { ArchivedItem } from "@/types/crawler"; +import { type WorkflowTag, type WorkflowTags } from "@/types/workflow"; import { maxLengthValidator } from "@/utils/form"; import LiteElement, { html } from "@/utils/LiteElement"; @@ -46,7 +47,7 @@ export class CrawlMetadataEditor extends LiteElement { private includeName = false; @state() - private tagOptions: Tags = []; + private tagOptions: WorkflowTag[] = []; @state() private tagsToSave: Tags = []; @@ -55,7 +56,8 @@ export class CrawlMetadataEditor extends LiteElement { private collectionsToSave: string[] = []; // For fuzzy search: - private readonly fuse = new Fuse([], { + private readonly fuse = new Fuse([], { + keys: ["tag"], shouldSort: false, threshold: 0.2, // stricter; default is 0.6 }); @@ -164,8 +166,8 @@ export class CrawlMetadataEditor extends LiteElement { private async fetchTags() { if (!this.crawl) return; try { - const tags = await this.apiFetch( - `/orgs/${this.crawl.oid}/crawlconfigs/tags`, + const { tags } = await this.apiFetch( + `/orgs/${this.crawl.oid}/crawlconfigs/tagCounts`, ); // Update search/filter collection diff --git a/frontend/src/features/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts index 9ebafa31..f2aeb01c 100644 --- a/frontend/src/features/crawl-workflows/workflow-editor.ts +++ b/frontend/src/features/crawl-workflows/workflow-editor.ts @@ -88,7 +88,11 @@ import { type WorkflowParams, } from "@/types/crawler"; import type { UnderlyingFunction } from "@/types/utils"; -import { NewWorkflowOnlyScopeType } from "@/types/workflow"; +import { + NewWorkflowOnlyScopeType, + type WorkflowTag, + type WorkflowTags, +} from "@/types/workflow"; import { track } from "@/utils/analytics"; import { isApiError, isApiErrorDetail } from "@/utils/api"; import { DEPTH_SUPPORTED_SCOPES, isPageScopeType } from "@/utils/crawler"; @@ -258,7 +262,7 @@ export class WorkflowEditor extends BtrixElement { private showCrawlerChannels = false; @state() - private tagOptions: string[] = []; + private tagOptions: WorkflowTag[] = []; @state() private isSubmitting = false; @@ -293,7 +297,8 @@ export class WorkflowEditor extends BtrixElement { }); // For fuzzy search: - private readonly fuse = new Fuse([], { + private readonly fuse = new Fuse([], { + keys: ["tag"], shouldSort: false, threshold: 0.2, // stricter; default is 0.6 }); @@ -2532,8 +2537,8 @@ https://archiveweb.page/images/${"logo.svg"}`} private async fetchTags() { this.tagOptions = []; try { - const tags = await this.api.fetch( - `/orgs/${this.orgId}/crawlconfigs/tags`, + const { tags } = await this.api.fetch( + `/orgs/${this.orgId}/crawlconfigs/tagCounts`, ); // Update search/filter collection diff --git a/frontend/src/features/crawl-workflows/workflow-tag-filter.ts b/frontend/src/features/crawl-workflows/workflow-tag-filter.ts index 934a8cd6..3be316c0 100644 --- a/frontend/src/features/crawl-workflows/workflow-tag-filter.ts +++ b/frontend/src/features/crawl-workflows/workflow-tag-filter.ts @@ -21,6 +21,7 @@ import { isFocusable } from "tabbable"; import { BtrixElement } from "@/classes/BtrixElement"; import type { BtrixChangeEvent } from "@/events/btrix-change"; +import { type WorkflowTag, type WorkflowTags } from "@/types/workflow"; import { tw } from "@/utils/tailwind"; const MAX_TAGS_IN_LABEL = 5; @@ -47,7 +48,9 @@ export class WorkflowTagFilter extends BtrixElement { @queryAll("sl-checkbox") private readonly checkboxes!: NodeListOf; - private readonly fuse = new Fuse([]); + private readonly fuse = new Fuse([], { + keys: ["tag"], + }); private selected = new Map(); @@ -63,8 +66,8 @@ export class WorkflowTagFilter extends BtrixElement { private readonly orgTagsTask = new Task(this, { task: async () => { - const tags = await this.api.fetch( - `/orgs/${this.orgId}/crawlconfigs/tags`, + const { tags } = await this.api.fetch( + `/orgs/${this.orgId}/crawlconfigs/tagCounts`, ); this.fuse.setCollection(tags); @@ -235,18 +238,18 @@ export class WorkflowTagFilter extends BtrixElement { `; } - private renderList(opts: { item: string }[]) { - const tag = (tag: string) => { - const checked = this.selected.get(tag) === true; + private renderList(opts: { item: WorkflowTag }[]) { + const tag = (tag: WorkflowTag) => { + const checked = this.selected.get(tag.tag) === true; return html`
  • ${tag} + >${tag.tag} + ${tag.count}
  • `; @@ -264,36 +267,6 @@ export class WorkflowTagFilter extends BtrixElement { this.selected.set(value, checked); }} - @keydown=${(e: KeyboardEvent) => { - if (!this.checkboxes.length) return; - - // Enable focus trapping - const options = Array.from(this.checkboxes); - const focused = options.findIndex((opt) => opt.matches(":focus")); - - switch (e.key) { - case "ArrowDown": { - e.preventDefault(); - options[ - focused === -1 || focused === options.length - 1 - ? 0 - : focused + 1 - ].focus(); - break; - } - case "ArrowUp": { - e.preventDefault(); - options[ - focused === -1 || focused === 0 - ? options.length - 1 - : focused - 1 - ].focus(); - break; - } - default: - break; - } - }} > ${repeat( opts, diff --git a/frontend/src/types/workflow.ts b/frontend/src/types/workflow.ts index d8b78d5e..16ec01df 100644 --- a/frontend/src/types/workflow.ts +++ b/frontend/src/types/workflow.ts @@ -5,3 +5,12 @@ export enum NewWorkflowOnlyScopeType { } export const WorkflowScopeType = { ...ScopeType, ...NewWorkflowOnlyScopeType }; + +export type WorkflowTag = { + tag: string; + count: number; +}; + +export type WorkflowTags = { + tags: WorkflowTag[]; +};