fix: Validate collection page URL (#2291)

- Disables saving collection start page if valid snapshot is not
selected
- Shows full URL in page URL status check mark
- Shows error in page URL status exclamation mark
- Fixes pasting in URL
This commit is contained in:
sua yoo 2025-01-14 12:54:33 -08:00 committed by GitHub
parent 318acaf5b3
commit c53528332b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 169 additions and 53 deletions

View File

@ -17,6 +17,9 @@ enum HomeView {
URL = "url", URL = "url",
} }
/**
* @fires btrix-change
*/
@localized() @localized()
@customElement("btrix-collection-replay-dialog") @customElement("btrix-collection-replay-dialog")
export class CollectionStartPageDialog extends BtrixElement { export class CollectionStartPageDialog extends BtrixElement {
@ -82,13 +85,20 @@ export class CollectionStartPageDialog extends BtrixElement {
} }
render() { render() {
const showTooltip =
this.homeView === HomeView.URL && !this.selectedSnapshot;
return html` return html`
<btrix-dialog <btrix-dialog
.label=${msg("Configure Replay Home")} .label=${msg("Configure Replay Home")}
.open=${this.open} .open=${this.open}
class="[--width:60rem]" class="[--width:60rem]"
@sl-show=${() => (this.showContent = true)} @sl-show=${() => (this.showContent = true)}
@sl-after-hide=${() => (this.showContent = false)} @sl-after-hide=${() => {
this.homeView = this.homeUrl ? HomeView.URL : HomeView.Pages;
this.isSubmitting = false;
this.selectedSnapshot = null;
this.showContent = false;
}}
> >
${this.showContent ? this.renderContent() : nothing} ${this.showContent ? this.renderContent() : nothing}
<div slot="footer" class="flex items-center justify-between gap-3"> <div slot="footer" class="flex items-center justify-between gap-3">
@ -98,17 +108,22 @@ export class CollectionStartPageDialog extends BtrixElement {
@click=${() => void this.dialog?.hide()} @click=${() => void this.dialog?.hide()}
>${msg("Cancel")}</sl-button >${msg("Cancel")}</sl-button
> >
<sl-button <sl-tooltip
variant="primary" content=${msg("Choose a page snapshot")}
size="small" ?disabled=${!showTooltip}
?disabled=${!this.replayLoaded}
?loading=${this.isSubmitting}
@click=${() => {
this.form?.requestSubmit();
}}
> >
${msg("Save")} <sl-button
</sl-button> variant="primary"
size="small"
?disabled=${!this.replayLoaded || showTooltip}
?loading=${this.isSubmitting}
@click=${() => {
this.form?.requestSubmit();
}}
>
${msg("Save")}
</sl-button>
</sl-tooltip>
</div> </div>
</btrix-dialog> </btrix-dialog>
`; `;
@ -144,12 +159,15 @@ export class CollectionStartPageDialog extends BtrixElement {
if (snapshot) { if (snapshot) {
urlPreview = html` urlPreview = html`
<iframe <sl-tooltip hoist>
class="inline-block size-full" <iframe
id="thumbnailPreview" class="inline-block size-full"
src=${`/replay/w/${this.collectionId}/${formatRwpTimestamp(snapshot.ts)}id_/urn:thumbnail:${snapshot.url}`} id="thumbnailPreview"
> src=${`/replay/w/${this.collectionId}/${formatRwpTimestamp(snapshot.ts)}id_/urn:thumbnail:${snapshot.url}`}
</iframe> >
</iframe>
<span slot="content" class="break-all">${snapshot.url}</span>
</sl-tooltip>
`; `;
} }
@ -194,6 +212,16 @@ export class CollectionStartPageDialog extends BtrixElement {
?disabled=${!this.replayLoaded} ?disabled=${!this.replayLoaded}
@sl-change=${(e: SlChangeEvent) => { @sl-change=${(e: SlChangeEvent) => {
this.homeView = (e.currentTarget as SlSelect).value as HomeView; this.homeView = (e.currentTarget as SlSelect).value as HomeView;
if (this.homeView === HomeView.Pages) {
if (
!this.homePageId ||
this.homePageId !== this.selectedSnapshot?.pageId
) {
// Reset unsaved selected snapshot
this.selectedSnapshot = null;
}
}
}} }}
> >
${this.replayLoaded ${this.replayLoaded
@ -280,6 +308,12 @@ export class CollectionStartPageDialog extends BtrixElement {
const form = e.currentTarget as HTMLFormElement; const form = e.currentTarget as HTMLFormElement;
const { homeView, useThumbnail } = serialize(form); const { homeView, useThumbnail } = serialize(form);
if (homeView === HomeView.Pages && !this.homePageId) {
// No changes to save
this.open = false;
return;
}
this.isSubmitting = true; this.isSubmitting = true;
try { try {
@ -288,6 +322,8 @@ export class CollectionStartPageDialog extends BtrixElement {
(homeView === HomeView.URL && this.selectedSnapshot?.pageId) || null, (homeView === HomeView.URL && this.selectedSnapshot?.pageId) || null,
}); });
this.dispatchEvent(new CustomEvent("btrix-change"));
const shouldUpload = const shouldUpload =
homeView === HomeView.URL && homeView === HomeView.URL &&
useThumbnail === "on" && useThumbnail === "on" &&

View File

@ -5,6 +5,7 @@ import type {
SlInput, SlInput,
SlSelect, SlSelect,
} from "@shoelace-style/shoelace"; } from "@shoelace-style/shoelace";
import clsx from "clsx";
import { html, type PropertyValues } from "lit"; import { html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import { when } from "lit/directives/when.js"; import { when } from "lit/directives/when.js";
@ -16,6 +17,7 @@ import { BtrixElement } from "@/classes/BtrixElement";
import type { Combobox } from "@/components/ui/combobox"; import type { Combobox } from "@/components/ui/combobox";
import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api";
import type { UnderlyingFunction } from "@/types/utils"; import type { UnderlyingFunction } from "@/types/utils";
import { tw } from "@/utils/tailwind";
type Snapshot = { type Snapshot = {
pageId: string; pageId: string;
@ -29,8 +31,10 @@ type Page = {
snapshots: Snapshot[]; snapshots: Snapshot[];
}; };
type SnapshotItem = Snapshot & { url: string };
export type SelectSnapshotDetail = { export type SelectSnapshotDetail = {
item: Snapshot & { url: string }; item: SnapshotItem | null;
}; };
const DEFAULT_PROTOCOL = "http"; const DEFAULT_PROTOCOL = "http";
@ -59,12 +63,15 @@ export class SelectCollectionStartPage extends BtrixElement {
private selectedPage?: Page; private selectedPage?: Page;
@state() @state()
private selectedSnapshot?: Snapshot; public selectedSnapshot?: Snapshot;
@state()
private pageUrlError?: string;
@query("btrix-combobox") @query("btrix-combobox")
private readonly combobox?: Combobox | null; private readonly combobox?: Combobox | null;
@query("sl-input") @query("#pageUrlInput")
private readonly input?: SlInput | null; private readonly input?: SlInput | null;
public get page() { public get page() {
@ -83,6 +90,20 @@ export class SelectCollectionStartPage extends BtrixElement {
this.searchQuery = this.homeUrl; this.searchQuery = this.homeUrl;
void this.initSelection(); void this.initSelection();
} }
if (changedProperties.has("selectedSnapshot")) {
this.dispatchEvent(
new CustomEvent<SelectSnapshotDetail>("btrix-select", {
detail: {
item: this.selectedPage?.url
? ({
url: this.selectedPage.url,
...this.selectedSnapshot,
} as SnapshotItem)
: null,
},
}),
);
}
} }
private async initSelection() { private async initSelection() {
@ -123,8 +144,11 @@ export class SelectCollectionStartPage extends BtrixElement {
${this.renderPageSearch()} ${this.renderPageSearch()}
<sl-select <sl-select
label=${msg("Snapshot")} label=${msg("Snapshot")}
placeholder="--" placeholder=${this.selectedPage
? msg("Choose a snapshot")
: msg("Enter a page URL to choose snapshot")}
value=${this.selectedSnapshot?.pageId || ""} value=${this.selectedSnapshot?.pageId || ""}
?required=${this.selectedPage && !this.selectedSnapshot}
?disabled=${!this.selectedPage} ?disabled=${!this.selectedPage}
@sl-change=${async (e: SlChangeEvent) => { @sl-change=${async (e: SlChangeEvent) => {
const { value } = e.currentTarget as SlSelect; const { value } = e.currentTarget as SlSelect;
@ -134,19 +158,6 @@ export class SelectCollectionStartPage extends BtrixElement {
this.selectedSnapshot = this.selectedPage?.snapshots.find( this.selectedSnapshot = this.selectedPage?.snapshots.find(
({ pageId }) => pageId === value, ({ pageId }) => pageId === value,
); );
if (this.selectedSnapshot) {
this.dispatchEvent(
new CustomEvent<SelectSnapshotDetail>("btrix-select", {
detail: {
item: {
url: this.selectedPage!.url,
...this.selectedSnapshot,
},
},
}),
);
}
}} }}
> >
${when( ${when(
@ -179,6 +190,29 @@ export class SelectCollectionStartPage extends BtrixElement {
} }
private renderPageSearch() { private renderPageSearch() {
let prefix: {
icon: string;
tooltip: string;
className?: string;
} = {
icon: "search",
tooltip: msg("Search for a page in this collection"),
};
if (this.pageUrlError) {
prefix = {
icon: "exclamation-lg",
tooltip: this.pageUrlError,
className: tw`text-danger`,
};
} else if (this.selectedPage) {
prefix = {
icon: "check-lg",
tooltip: msg("Page exists in collection"),
className: tw`text-success`,
};
}
return html` return html`
<btrix-combobox <btrix-combobox
@request-close=${() => { @request-close=${() => {
@ -186,26 +220,86 @@ export class SelectCollectionStartPage extends BtrixElement {
}} }}
> >
<sl-input <sl-input
id="pageUrlInput"
label=${msg("Page URL")} label=${msg("Page URL")}
placeholder=${msg("Start typing a URL...")} placeholder=${msg("Start typing a URL...")}
clearable clearable
@sl-focus=${() => { @sl-focus=${() => {
this.resetInputValidity();
this.combobox?.show(); this.combobox?.show();
}} }}
@sl-clear=${async () => { @sl-clear=${async () => {
this.resetInputValidity();
this.searchQuery = ""; this.searchQuery = "";
this.selectedPage = undefined;
this.selectedSnapshot = undefined;
}} }}
@sl-input=${this.onSearchInput as UnderlyingFunction< @sl-input=${this.onSearchInput as UnderlyingFunction<
typeof this.onSearchInput typeof this.onSearchInput
>} >}
@sl-blur=${this.pageUrlOnBlur}
> >
<sl-icon name="search" slot="prefix"></sl-icon> <div slot="prefix" class="inline-flex items-center">
<sl-tooltip
hoist
content=${prefix.tooltip}
placement="bottom-start"
>
<sl-icon
name=${prefix.icon}
class=${clsx(tw`size-4 text-base`, prefix.className)}
></sl-icon>
</sl-tooltip>
</div>
</sl-input> </sl-input>
${this.renderSearchResults()} ${this.renderSearchResults()}
</btrix-combobox> </btrix-combobox>
`; `;
} }
private resetInputValidity() {
this.pageUrlError = undefined;
this.input?.setCustomValidity("");
}
private readonly pageUrlOnBlur = async () => {
if (!this.searchQuery) return;
if (this.selectedPage) {
// Ensure input value matches the URL, e.g. if the user pressed
// backspace on an existing URL
if (this.searchQuery !== this.selectedPage.url && this.input) {
this.input.value = this.selectedPage.url;
}
return;
}
await this.searchResults.taskComplete;
const results = this.searchResults.value;
if (!results) return;
if (results.total === 0) {
if (this.input) {
this.pageUrlError = msg(
"Page not found in collection. Please check the URL and try again",
);
this.input.setCustomValidity(this.pageUrlError);
}
// Clear selection
this.selectedPage = undefined;
this.selectedSnapshot = undefined;
} else if (results.total === 1) {
// Choose only option, e.g. for copy-paste
this.selectedPage = this.searchResults.value.items[0];
this.selectedSnapshot = this.selectedPage.snapshots[0];
}
};
private renderSearchResults() { private renderSearchResults() {
return this.searchResults.render({ return this.searchResults.render({
pending: () => html` pending: () => html`
@ -217,7 +311,7 @@ export class SelectCollectionStartPage extends BtrixElement {
if (!items.length) { if (!items.length) {
return html` return html`
<sl-menu-item slot="menu-item" disabled> <sl-menu-item slot="menu-item" disabled>
${msg("No matching pages found.")} ${msg("No matching page found.")}
</sl-menu-item> </sl-menu-item>
`; `;
} }
@ -241,18 +335,6 @@ export class SelectCollectionStartPage extends BtrixElement {
this.combobox?.hide(); this.combobox?.hide();
this.selectedSnapshot = this.selectedPage.snapshots[0]; this.selectedSnapshot = this.selectedPage.snapshots[0];
await this.updateComplete;
this.dispatchEvent(
new CustomEvent<SelectSnapshotDetail>("btrix-select", {
detail: {
item: {
url: this.selectedPage.url,
...this.selectedSnapshot,
},
},
}),
);
}} }}
>${item.url} >${item.url}
</sl-menu-item> </sl-menu-item>

View File

@ -241,15 +241,13 @@ export class CollectionDetail extends BtrixElement {
<btrix-collection-replay-dialog <btrix-collection-replay-dialog
?open=${this.openDialogName === "editStartPage"} ?open=${this.openDialogName === "editStartPage"}
@sl-hide=${async () => { @btrix-change=${() => {
this.openDialogName = undefined;
// Don't do full refresh of rwp so that rwp-url-change fires // Don't do full refresh of rwp so that rwp-url-change fires
this.isRwpLoaded = false; this.isRwpLoaded = false;
await this.fetchCollection(); void this.fetchCollection();
await this.updateComplete;
}} }}
@sl-hide=${async () => (this.openDialogName = undefined)}
collectionId=${this.collectionId} collectionId=${this.collectionId}
.homeUrl=${this.collection?.homeUrl} .homeUrl=${this.collection?.homeUrl}
.homePageId=${this.collection?.homeUrlPageId} .homePageId=${this.collection?.homeUrlPageId}