diff --git a/frontend/docs/docs/user-guide/collection.md b/frontend/docs/docs/user-guide/collection.md index 68e7069355..8e624591df 100644 --- a/frontend/docs/docs/user-guide/collection.md +++ b/frontend/docs/docs/user-guide/collection.md @@ -6,6 +6,12 @@ A collection is a specific, user-directed grouping of either crawls or uploaded You can create a collection from the Collections page directly, or the _Create New..._ shortcut from the org dashboard. +### Name and Summary + +Choose a collection name that identifies your collection within the org. The name can contain letters, numbers, and special characters (like emojis.) The collection name should include at least one letter. + +The summary is a short description that summarizes this collection. If the collection is shareable, this will appear next to the collection name. You can add a longer description in the “About” section after creating the collection. + ### Add Collection Content Collections are the primary way of organizing and combining archived items into groups for presentation. Collections also allow you to view a combined replay of any archived items they contain; if a link is present when viewing a collection but the actual page is missing, and another item with that captured page is added to the collection, the link will now work as expected. diff --git a/frontend/src/components/ui/search-combobox.ts b/frontend/src/components/ui/search-combobox.ts index 21f9053608..38b6b8775e 100644 --- a/frontend/src/components/ui/search-combobox.ts +++ b/frontend/src/components/ui/search-combobox.ts @@ -76,6 +76,9 @@ export class SearchCombobox extends TailwindElement { @property({ type: Boolean }) createNew = false; + @property({ attribute: false }) + validateNew?: (value: string) => boolean; + private get hasSearchStr() { return this.searchByValue.length >= MIN_SEARCH_LENGTH; } @@ -232,7 +235,8 @@ export class SearchCombobox extends TailwindElement { ({ value }) => value && value.toLocaleLowerCase() === newName.toLocaleLowerCase(), ), - ); + ) && + (!this.validateNew || this.validateNew(newName)); return html` ${when( diff --git a/frontend/src/features/collections/collection-create-dialog.ts b/frontend/src/features/collections/collection-create-dialog.ts index 2ed1070abd..bbb8d59f9a 100644 --- a/frontend/src/features/collections/collection-create-dialog.ts +++ b/frontend/src/features/collections/collection-create-dialog.ts @@ -1,5 +1,9 @@ import { localized, msg, str } from "@lit/localize"; -import type { SlInput, SlSelectEvent } from "@shoelace-style/shoelace"; +import type { + SlInput, + SlInputEvent, + SlSelectEvent, +} from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; import { html } from "lit"; import { @@ -24,7 +28,8 @@ import { type Collection, } from "@/types/collection"; import { isApiError } from "@/utils/api"; -import { maxLengthValidator } from "@/utils/form"; +import { formValidator, maxLengthValidator } from "@/utils/form"; +import slugifyStrict from "@/utils/slugify"; export type CollectionSavedEvent = CustomEvent<{ id: string; @@ -54,6 +59,7 @@ export class CollectionCreateDialog extends BtrixElement { @queryAsync("#collectionForm") private readonly form!: Promise; + private readonly checkFormValidity = formValidator(this); private readonly validateNameMax = maxLengthValidator( COLLECTION_NAME_MAX_LENGTH, ); @@ -119,7 +125,20 @@ export class CollectionCreateDialog extends BtrixElement { autocomplete="off" required help-text=${this.validateNameMax.helpText} - @sl-input=${this.validateNameMax.validate} + @sl-input=${(e: SlInputEvent) => { + const valid = this.validateNameMax.validate(e); + + if (!valid) return; + + const input = e.target as SlInput; + const value = input.value; + + if (value && !slugifyStrict(value)) { + input.setCustomValidity( + msg("Please include at least one letter or number."), + ); + } + }} > ${msg("Summary")} - + ${msg( "Write a short description that summarizes this collection. If the collection is shareable, this will appear next to the collection name.", @@ -146,7 +165,7 @@ export class CollectionCreateDialog extends BtrixElement { name="info-circle" style="vertical-align: -.175em" > - + @@ -199,19 +218,23 @@ export class CollectionCreateDialog extends BtrixElement { event.stopPropagation(); const form = event.target as HTMLFormElement; - const nameInput = form.querySelector('sl-input[name="name"]'); + const isValid = await this.checkFormValidity(form); - if (!nameInput?.checkValidity()) { + if (!isValid) { return; } - const { name, caption } = serialize(form); + const { name, caption } = serialize(form) as { + name: string; + caption?: string; + }; this.isSubmitting = true; try { const body = JSON.stringify({ name, caption, + slug: slugifyStrict(name), access: this.selectCollectionAccess?.value || CollectionAccess.Private, defaultThumbnailName: DEFAULT_THUMBNAIL, }); diff --git a/frontend/src/features/collections/collection-name-input.ts b/frontend/src/features/collections/collection-name-input.ts index 56afc662f9..e0dcaa69a7 100644 --- a/frontend/src/features/collections/collection-name-input.ts +++ b/frontend/src/features/collections/collection-name-input.ts @@ -20,6 +20,7 @@ import { COLLECTION_NAME_MAX_LENGTH, type Collection, } from "@/types/collection"; +import slugifyStrict from "@/utils/slugify"; import appState from "@/utils/state"; export type CollectionNameInputLoadedEvent = @@ -98,6 +99,10 @@ export class CollectionNameInput extends WithSearchOrgContext( } } + readonly validateNew = (value: string) => { + return value ? slugifyStrict(value).length > 0 : false; + }; + private readonly onSelect = async (e: BtrixSearchComboboxSelectEvent) => { e.stopPropagation(); diff --git a/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts index 5504aed382..6eca074790 100644 --- a/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts +++ b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts @@ -8,6 +8,7 @@ import { type CollectionUpdate, } from "@/types/collection"; import { isApiError } from "@/utils/api"; +import slugifyStrict from "@/utils/slugify"; export default function submitTask( this: CollectionEdit, @@ -75,12 +76,20 @@ export default function submitTask( } if (Object.keys(rest).length) { + const params = { + ...rest, + }; + + if (rest.name) { + params.slug = slugifyStrict(rest.name); + } + tasks.push( await this.api.fetch<{ updated: boolean }>( `/orgs/${this.orgId}/collections/${this.collection.id}`, { method: "PATCH", - body: JSON.stringify(rest), + body: JSON.stringify(params), signal, }, ), diff --git a/frontend/src/features/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts index e80257e44d..2c43e51182 100644 --- a/frontend/src/features/crawl-workflows/workflow-editor.ts +++ b/frontend/src/features/crawl-workflows/workflow-editor.ts @@ -136,6 +136,7 @@ import { isNotEqual } from "@/utils/is-not-equal"; import localize from "@/utils/localize"; import { isArchivingDisabled } from "@/utils/orgs"; import { pluralOf } from "@/utils/pluralize"; +import slugifyStrict from "@/utils/slugify"; import { AppStateService } from "@/utils/state"; import { regexEscape } from "@/utils/string"; import { tw } from "@/utils/tailwind"; @@ -3822,14 +3823,17 @@ https://archiveweb.page/images/${"logo.svg"}`} } private async createCollection( - params: { name: string }, + { name }: { name: string }, signal?: AbortSignal, ) { return this.api.fetch<{ added: boolean; id: string; name: string }>( `/orgs/${this.orgId}/collections`, { method: "POST", - body: JSON.stringify(params), + body: JSON.stringify({ + name, + slug: slugifyStrict(name), + }), signal, }, ); diff --git a/frontend/src/pages/admin/admin.ts b/frontend/src/pages/admin/admin.ts index c5ff3ecc66..6827d6ab32 100644 --- a/frontend/src/pages/admin/admin.ts +++ b/frontend/src/pages/admin/admin.ts @@ -282,12 +282,27 @@ export class Admin extends BtrixElement { this.validateOrgNameMax.validate(e); const input = e.target as SlInput; - const slug = slugifyStrict(input.value); - const isInvalid = this.orgSlugs.includes(slug); + const value = input.value; + const slug = slugifyStrict(value); + let isInvalid = !value; - if (isInvalid) { - input.setCustomValidity(msg("This org name is already taken.")); - } else { + if (value) { + if (!slug) { + isInvalid = true; + + input.setCustomValidity( + msg("Please include at least one letter or number."), + ); + } else { + isInvalid = this.orgSlugs.includes(slug); + + if (isInvalid) { + input.setCustomValidity(msg("This org name is already taken.")); + } + } + } + + if (!isInvalid) { input.setCustomValidity(""); } @@ -300,14 +315,17 @@ export class Admin extends BtrixElement { const formEl = e.target as HTMLFormElement; if (!(await this.checkFormValidity(formEl))) return; - const params = serialize(formEl); + const params = serialize(formEl) as { name: string }; this.isSubmittingNewOrg = true; try { // TODO return entire object from API await this.api.fetch<{ added: true; id: string }>(`/orgs/create`, { method: "POST", - body: JSON.stringify(params), + body: JSON.stringify({ + ...params, + slug: slugifyStrict(params.name), + }), }); await this.fetchOrgs(); const userInfo = await this.getUserInfo(); diff --git a/frontend/src/pages/invite/ui/org-form.ts b/frontend/src/pages/invite/ui/org-form.ts index f76eb2ff45..c97ec13e42 100644 --- a/frontend/src/pages/invite/ui/org-form.ts +++ b/frontend/src/pages/invite/ui/org-form.ts @@ -83,12 +83,21 @@ export class OrgForm extends BtrixElement { autocomplete="off" value=${this.slug} minlength="2" - maxlength="30" + maxlength="50" help-text=${helpText(this.slug)} required @sl-input=${(e: InputEvent) => { const input = e.target as SlInput; + // Ideally this would match against the full character map that slugify uses + // but this'll do for most use cases + const end = input.value.match(/[\s*_+~.,()'"!\-:@]$/g) ? "-" : ""; + input.value = slugifyStrict(input.value) + end; + const slugValue = slugifyStrict(input.value); input.helpText = helpText(slugifyStrict(input.value)); + + input.setCustomValidity( + slugValue.length < 2 ? msg("URL too short") : "", + ); }} > diff --git a/frontend/src/pages/org/collection-detail/collection-detail.ts b/frontend/src/pages/org/collection-detail/collection-detail.ts index ed486b6512..3d72557991 100644 --- a/frontend/src/pages/org/collection-detail/collection-detail.ts +++ b/frontend/src/pages/org/collection-detail/collection-detail.ts @@ -65,6 +65,7 @@ import { isNotEqual } from "@/utils/is-not-equal"; import { pluralOf } from "@/utils/pluralize"; import { formatRwpTimestamp } from "@/utils/replay"; import { richText } from "@/utils/rich-text"; +import slugifyStrict from "@/utils/slugify"; import { tw } from "@/utils/tailwind"; const ABORT_REASON_THROTTLE = "throttled"; @@ -1569,6 +1570,7 @@ export class CollectionDetail extends BtrixElement { method: "PATCH", body: JSON.stringify({ name, + slug: slugifyStrict(name), }), }, ); diff --git a/frontend/src/pages/org/settings/components/crawling-defaults.ts b/frontend/src/pages/org/settings/components/crawling-defaults.ts index acf70d6550..bd320e7301 100644 --- a/frontend/src/pages/org/settings/components/crawling-defaults.ts +++ b/frontend/src/pages/org/settings/components/crawling-defaults.ts @@ -36,6 +36,7 @@ import { dedupeTypeLabelFor } from "@/strings/dedupe"; import { CrawlerChannelImage } from "@/types/crawler"; import { crawlingDefaultsSchema, type CrawlingDefaults } from "@/types/org"; import { formValidator } from "@/utils/form"; +import slugifyStrict from "@/utils/slugify"; import { appDefaults, BYTES_PER_GB, @@ -544,14 +545,17 @@ export class OrgSettingsCrawlWorkflows extends BtrixElement { } private async createCollection( - params: { name: string }, + { name }: { name: string }, signal?: AbortSignal, ) { return this.api.fetch<{ added: boolean; id: string; name: string }>( `/orgs/${this.orgId}/collections`, { method: "POST", - body: JSON.stringify(params), + body: JSON.stringify({ + name, + slug: slugifyStrict(name), + }), signal, }, ); diff --git a/frontend/src/pages/org/settings/components/general.ts b/frontend/src/pages/org/settings/components/general.ts index 65be3c2c52..419acec3df 100644 --- a/frontend/src/pages/org/settings/components/general.ts +++ b/frontend/src/pages/org/settings/components/general.ts @@ -97,7 +97,7 @@ export class OrgSettingsGeneral extends BtrixElement { autocomplete="off" value=${this.orgSlugState || ""} minlength="2" - maxlength="30" + maxlength="50" required @sl-input=${this.handleSlugInput} >