Include tag counts in tag filter & tag input autocomplete (#2711)

This commit is contained in:
Emma Segal-Grossman 2025-07-08 15:20:41 -04:00 committed by GitHub
parent 80a225c677
commit 74c72ce551
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 153 additions and 72 deletions

View File

@ -25,6 +25,7 @@ from .models import (
ConfigRevision, ConfigRevision,
CrawlConfig, CrawlConfig,
CrawlConfigOut, CrawlConfigOut,
CrawlConfigTags,
CrawlOut, CrawlOut,
UpdateCrawlConfig, UpdateCrawlConfig,
Organization, Organization,
@ -976,8 +977,20 @@ class CrawlConfigOps:
async def get_crawl_config_tags(self, org): async def get_crawl_config_tags(self, org):
"""get distinct tags from all crawl configs for this org""" """get distinct tags from all crawl configs for this org"""
tags = await self.crawl_configs.distinct("tags", {"oid": org.id}) return await self.crawl_configs.distinct("tags", {"oid": org.id})
return list(tags)
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): async def get_crawl_config_search_values(self, org):
"""List unique names, first seeds, and descriptions from all workflows in 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) 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)): 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) 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) @router.get("/search-values", response_model=CrawlConfigSearchValues)
async def get_crawl_config_search_values( async def get_crawl_config_search_values(
org: Organization = Depends(org_viewer_dep), org: Organization = Depends(org_viewer_dep),

View File

@ -577,11 +577,19 @@ class CrawlConfigAddedResponse(BaseModel):
execMinutesQuotaReached: bool execMinutesQuotaReached: bool
# ============================================================================
class CrawlConfigTagCount(BaseModel):
"""Response model for crawlconfig tag count"""
tag: str
count: int
# ============================================================================ # ============================================================================
class CrawlConfigTags(BaseModel): class CrawlConfigTags(BaseModel):
"""Response model for crawlconfig tags""" """Response model for crawlconfig tags"""
tags: List[str] tags: List[CrawlConfigTagCount]
# ============================================================================ # ============================================================================

View File

@ -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"] 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): def test_create_new_config_2(admin_auth_headers, default_org_id):
r = requests.post( r = requests.post(
f"{API_PREFIX}/orgs/{default_org_id}/crawlconfigs/", 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): def test_get_config_2(admin_auth_headers, default_org_id):
r = requests.get( r = requests.get(
f"{API_PREFIX}/orgs/{default_org_id}/crawlconfigs/{new_cid_2}", f"{API_PREFIX}/orgs/{default_org_id}/crawlconfigs/{new_cid_2}",

View File

@ -11,7 +11,7 @@ export type BadgeVariant =
| "danger" | "danger"
| "neutral" | "neutral"
| "primary" | "primary"
| "blue" | "cyan"
| "high-contrast"; | "high-contrast";
/** /**
@ -27,6 +27,12 @@ export class Badge extends TailwindElement {
@property({ type: String }) @property({ type: String })
variant: BadgeVariant = "neutral"; variant: BadgeVariant = "neutral";
@property({ type: Boolean })
outline = false;
@property({ type: Boolean })
pill = false;
@property({ type: String, reflect: true }) @property({ type: String, reflect: true })
role: string | null = "status"; role: string | null = "status";
@ -40,16 +46,32 @@ export class Badge extends TailwindElement {
return html` return html`
<span <span
class=${clsx( class=${clsx(
tw`h-4.5 inline-flex items-center justify-center rounded-sm px-2 align-[1px] text-xs`, tw`inline-flex h-[1.125rem] items-center justify-center align-[1px] text-xs`,
{ this.outline
success: tw`bg-success-500 text-neutral-0`, ? [
warning: tw`bg-warning-600 text-neutral-0`, tw`ring-1`,
danger: tw`bg-danger-500 text-neutral-0`, {
neutral: tw`bg-neutral-100 text-neutral-600`, success: tw`bg-success-500 text-success-500 ring-success-500`,
"high-contrast": tw`bg-neutral-600 text-neutral-0`, warning: tw`bg-warning-600 text-warning-600 ring-warning-600`,
primary: tw`bg-primary text-neutral-0`, danger: tw`bg-danger-500 text-danger-500 ring-danger-500`,
blue: tw`bg-cyan-50 text-cyan-600`, neutral: tw`g-neutral-100 text-neutral-600 ring-neutral-600`,
}[this.variant], "high-contrast": tw`bg-neutral-600 text-neutral-0 ring-neutral-0`,
primary: tw`bg-white text-primary ring-primary`,
cyan: tw`bg-cyan-50 text-cyan-600 ring-cyan-600`,
blue: tw`bg-blue-50 text-blue-600 ring-blue-600`,
}[this.variant],
]
: {
success: tw`bg-success-500 text-neutral-0`,
warning: tw`bg-warning-600 text-neutral-0`,
danger: tw`bg-danger-500 text-neutral-0`,
neutral: tw`bg-neutral-100 text-neutral-600`,
"high-contrast": tw`bg-neutral-600 text-neutral-0`,
primary: tw`bg-primary text-neutral-0`,
cyan: tw`bg-cyan-50 text-cyan-600`,
blue: tw`bg-blue-50 text-blue-600`,
}[this.variant],
this.pill ? tw`min-w-[1.125rem] rounded-full px-1` : tw`rounded px-2`,
)} )}
part="base" part="base"
> >

View File

@ -17,6 +17,7 @@ import { customElement, property, query, state } from "lit/decorators.js";
import debounce from "lodash/fp/debounce"; import debounce from "lodash/fp/debounce";
import type { UnderlyingFunction } from "@/types/utils"; import type { UnderlyingFunction } from "@/types/utils";
import { type WorkflowTag } from "@/types/workflow";
import { dropdown } from "@/utils/css"; import { dropdown } from "@/utils/css";
export type Tags = string[]; export type Tags = string[];
@ -80,7 +81,7 @@ export class TagInput extends LitElement {
} }
sl-popup::part(popup) { sl-popup::part(popup) {
z-index: 3; z-index: 5;
} }
.shake { .shake {
@ -116,7 +117,7 @@ export class TagInput extends LitElement {
initialTags?: Tags; initialTags?: Tags;
@property({ type: Array }) @property({ type: Array })
tagOptions: Tags = []; tagOptions: WorkflowTag[] = [];
@property({ type: Boolean }) @property({ type: Boolean })
disabled = false; disabled = false;
@ -224,6 +225,7 @@ export class TagInput extends LitElement {
@paste=${this.onPaste} @paste=${this.onPaste}
?required=${this.required && !this.tags.length} ?required=${this.required && !this.tags.length}
placeholder=${placeholder} placeholder=${placeholder}
autocomplete="off"
role="combobox" role="combobox"
aria-controls="dropdown" aria-controls="dropdown"
aria-expanded="${this.dropdownIsOpen === true}" aria-expanded="${this.dropdownIsOpen === true}"
@ -258,10 +260,14 @@ export class TagInput extends LitElement {
> >
${this.tagOptions ${this.tagOptions
.slice(0, 3) .slice(0, 3)
.filter(({ tag }) => !this.tags.includes(tag))
.map( .map(
(tag) => html` ({ tag, count }) => html`
<sl-menu-item role="option" value=${tag} <sl-menu-item role="option" value=${tag}
>${tag}</sl-menu-item >${tag}
<btrix-badge pill variant="cyan" slot="suffix"
>${count}</btrix-badge
></sl-menu-item
> >
`, `,
)} )}

View File

@ -17,6 +17,7 @@ import type {
TagsChangeEvent, TagsChangeEvent,
} from "@/components/ui/tag-input"; } from "@/components/ui/tag-input";
import { type CollectionsChangeEvent } from "@/features/collections/collections-add"; import { type CollectionsChangeEvent } from "@/features/collections/collections-add";
import { type WorkflowTag, type WorkflowTags } from "@/types/workflow";
import { APIError } from "@/utils/api"; import { APIError } from "@/utils/api";
import { maxLengthValidator } from "@/utils/form"; import { maxLengthValidator } from "@/utils/form";
@ -70,7 +71,7 @@ export class FileUploader extends BtrixElement {
private collectionIds: string[] = []; private collectionIds: string[] = [];
@state() @state()
private tagOptions: Tags = []; private tagOptions: WorkflowTag[] = [];
@state() @state()
private tagsToSave: Tags = []; private tagsToSave: Tags = [];
@ -85,7 +86,8 @@ export class FileUploader extends BtrixElement {
private readonly form!: Promise<HTMLFormElement>; private readonly form!: Promise<HTMLFormElement>;
// For fuzzy search: // For fuzzy search:
private readonly fuse = new Fuse([], { private readonly fuse = new Fuse<WorkflowTag>([], {
keys: ["tag"],
shouldSort: false, shouldSort: false,
threshold: 0.2, // stricter; default is 0.6 threshold: 0.2, // stricter; default is 0.6
}); });
@ -361,8 +363,8 @@ export class FileUploader extends BtrixElement {
private async fetchTags() { private async fetchTags() {
try { try {
const tags = await this.api.fetch<never>( const { tags } = await this.api.fetch<WorkflowTags>(
`/orgs/${this.orgId}/crawlconfigs/tags`, `/orgs/${this.orgId}/crawlconfigs/tagCounts`,
); );
// Update search/filter collection // Update search/filter collection

View File

@ -10,6 +10,7 @@ import type {
} from "@/components/ui/tag-input"; } from "@/components/ui/tag-input";
import { type CollectionsChangeEvent } from "@/features/collections/collections-add"; import { type CollectionsChangeEvent } from "@/features/collections/collections-add";
import type { ArchivedItem } from "@/types/crawler"; import type { ArchivedItem } from "@/types/crawler";
import { type WorkflowTag, type WorkflowTags } from "@/types/workflow";
import { maxLengthValidator } from "@/utils/form"; import { maxLengthValidator } from "@/utils/form";
import LiteElement, { html } from "@/utils/LiteElement"; import LiteElement, { html } from "@/utils/LiteElement";
@ -46,7 +47,7 @@ export class CrawlMetadataEditor extends LiteElement {
private includeName = false; private includeName = false;
@state() @state()
private tagOptions: Tags = []; private tagOptions: WorkflowTag[] = [];
@state() @state()
private tagsToSave: Tags = []; private tagsToSave: Tags = [];
@ -55,7 +56,8 @@ export class CrawlMetadataEditor extends LiteElement {
private collectionsToSave: string[] = []; private collectionsToSave: string[] = [];
// For fuzzy search: // For fuzzy search:
private readonly fuse = new Fuse<string>([], { private readonly fuse = new Fuse<WorkflowTag>([], {
keys: ["tag"],
shouldSort: false, shouldSort: false,
threshold: 0.2, // stricter; default is 0.6 threshold: 0.2, // stricter; default is 0.6
}); });
@ -164,8 +166,8 @@ export class CrawlMetadataEditor extends LiteElement {
private async fetchTags() { private async fetchTags() {
if (!this.crawl) return; if (!this.crawl) return;
try { try {
const tags = await this.apiFetch<string[]>( const { tags } = await this.apiFetch<WorkflowTags>(
`/orgs/${this.crawl.oid}/crawlconfigs/tags`, `/orgs/${this.crawl.oid}/crawlconfigs/tagCounts`,
); );
// Update search/filter collection // Update search/filter collection

View File

@ -88,7 +88,11 @@ import {
type WorkflowParams, type WorkflowParams,
} from "@/types/crawler"; } from "@/types/crawler";
import type { UnderlyingFunction } from "@/types/utils"; 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 { track } from "@/utils/analytics";
import { isApiError, isApiErrorDetail } from "@/utils/api"; import { isApiError, isApiErrorDetail } from "@/utils/api";
import { DEPTH_SUPPORTED_SCOPES, isPageScopeType } from "@/utils/crawler"; import { DEPTH_SUPPORTED_SCOPES, isPageScopeType } from "@/utils/crawler";
@ -258,7 +262,7 @@ export class WorkflowEditor extends BtrixElement {
private showCrawlerChannels = false; private showCrawlerChannels = false;
@state() @state()
private tagOptions: string[] = []; private tagOptions: WorkflowTag[] = [];
@state() @state()
private isSubmitting = false; private isSubmitting = false;
@ -293,7 +297,8 @@ export class WorkflowEditor extends BtrixElement {
}); });
// For fuzzy search: // For fuzzy search:
private readonly fuse = new Fuse<string>([], { private readonly fuse = new Fuse<WorkflowTag>([], {
keys: ["tag"],
shouldSort: false, shouldSort: false,
threshold: 0.2, // stricter; default is 0.6 threshold: 0.2, // stricter; default is 0.6
}); });
@ -2532,8 +2537,8 @@ https://archiveweb.page/images/${"logo.svg"}`}
private async fetchTags() { private async fetchTags() {
this.tagOptions = []; this.tagOptions = [];
try { try {
const tags = await this.api.fetch<string[]>( const { tags } = await this.api.fetch<WorkflowTags>(
`/orgs/${this.orgId}/crawlconfigs/tags`, `/orgs/${this.orgId}/crawlconfigs/tagCounts`,
); );
// Update search/filter collection // Update search/filter collection

View File

@ -21,6 +21,7 @@ import { isFocusable } from "tabbable";
import { BtrixElement } from "@/classes/BtrixElement"; import { BtrixElement } from "@/classes/BtrixElement";
import type { BtrixChangeEvent } from "@/events/btrix-change"; import type { BtrixChangeEvent } from "@/events/btrix-change";
import { type WorkflowTag, type WorkflowTags } from "@/types/workflow";
import { tw } from "@/utils/tailwind"; import { tw } from "@/utils/tailwind";
const MAX_TAGS_IN_LABEL = 5; const MAX_TAGS_IN_LABEL = 5;
@ -47,7 +48,9 @@ export class WorkflowTagFilter extends BtrixElement {
@queryAll("sl-checkbox") @queryAll("sl-checkbox")
private readonly checkboxes!: NodeListOf<SlCheckbox>; private readonly checkboxes!: NodeListOf<SlCheckbox>;
private readonly fuse = new Fuse<string>([]); private readonly fuse = new Fuse<WorkflowTag>([], {
keys: ["tag"],
});
private selected = new Map<string, boolean>(); private selected = new Map<string, boolean>();
@ -63,8 +66,8 @@ export class WorkflowTagFilter extends BtrixElement {
private readonly orgTagsTask = new Task(this, { private readonly orgTagsTask = new Task(this, {
task: async () => { task: async () => {
const tags = await this.api.fetch<string[]>( const { tags } = await this.api.fetch<WorkflowTags>(
`/orgs/${this.orgId}/crawlconfigs/tags`, `/orgs/${this.orgId}/crawlconfigs/tagCounts`,
); );
this.fuse.setCollection(tags); this.fuse.setCollection(tags);
@ -235,18 +238,18 @@ export class WorkflowTagFilter extends BtrixElement {
`; `;
} }
private renderList(opts: { item: string }[]) { private renderList(opts: { item: WorkflowTag }[]) {
const tag = (tag: string) => { const tag = (tag: WorkflowTag) => {
const checked = this.selected.get(tag) === true; const checked = this.selected.get(tag.tag) === true;
return html` return html`
<li role="option" aria-checked=${checked}> <li role="option" aria-checked=${checked}>
<sl-checkbox <sl-checkbox
class="w-full part-[base]:w-full part-[base]:rounded part-[base]:p-2 part-[base]:hover:bg-primary-50 part-[base]:focus:bg-primary-50" class="w-full part-[label]:flex part-[base]:w-full part-[label]:w-full part-[label]:items-center part-[label]:justify-between part-[base]:rounded part-[base]:p-2 part-[base]:hover:bg-primary-50"
value=${tag} value=${tag.tag}
?checked=${checked} ?checked=${checked}
tabindex="0" >${tag.tag}
>${tag} <btrix-badge pill variant="cyan">${tag.count}</btrix-badge>
</sl-checkbox> </sl-checkbox>
</li> </li>
`; `;
@ -264,36 +267,6 @@ export class WorkflowTagFilter extends BtrixElement {
this.selected.set(value, checked); 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( ${repeat(
opts, opts,

View File

@ -5,3 +5,12 @@ export enum NewWorkflowOnlyScopeType {
} }
export const WorkflowScopeType = { ...ScopeType, ...NewWorkflowOnlyScopeType }; export const WorkflowScopeType = { ...ScopeType, ...NewWorkflowOnlyScopeType };
export type WorkflowTag = {
tag: string;
count: number;
};
export type WorkflowTags = {
tags: WorkflowTag[];
};