fix: Fix collection description (#2065)

Fixes https://github.com/webrecorder/browsertrix/issues/2064

### Changes

- Switches MDE library to one that supports shadow DOM
- Refactors collection components to btrix components
- Fixes collection detail not expanding and contracting correctly
This commit is contained in:
sua yoo 2024-09-05 22:10:14 -07:00 committed by GitHub
parent 4c36c80351
commit b4e34d1c3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 828 additions and 732 deletions

View File

@ -4,6 +4,10 @@
You can create a collection from the Collections page, or the _Create New ..._ shortcut from the org overview.
## Collection Description
The description can be formatted with basic [Markdown](https://github.github.com/gfm/#what-is-markdown-) syntax to include headings, bolded and italicized text, lists, and links. The editor is powered by [ink-mde](https://github.com/davidmyersdev/ink-mde), an open source Markdown editor.
## Sharing Collections
Collections are private by default, but can be made public by marking them as sharable in the Metadata step of collection creation, or by toggling the _Collection is Shareable_ switch in the share collection dialogue.

View File

@ -18,7 +18,6 @@
"@types/sinon": "^10.0.6",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"@wysimark/standalone": "3.0.20",
"@xstate/fsm": "^1.6.2",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
@ -49,6 +48,7 @@
"html-loader": "^3.0.1",
"html-webpack-plugin": "^5.5.0",
"immutable": "^4.1.0",
"ink-mde": "~0.33.0",
"iso-639-1": "^2.1.15",
"lit": "3.1.1",
"lit-shared-state": "^0.2.1",

View File

@ -1,11 +1,11 @@
// cSpell:words wysimark
import { createWysimark } from "@wysimark/standalone";
import { html, LitElement, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import { msg, str } from "@lit/localize";
import { wrap, type AwaitableInstance } from "ink-mde";
import { css, html, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { TailwindElement } from "@/classes/TailwindElement";
import { getHelpText } from "@/utils/form";
import { formatNumber } from "@/utils/localization";
type MarkdownChangeDetail = {
value: string;
@ -15,116 +15,172 @@ export type MarkdownChangeEvent = CustomEvent<MarkdownChangeDetail>;
/**
* Edit and preview text in markdown
*
* @event on-change MarkdownChangeEvent
* @fires btrix-change MarkdownChangeEvent
*/
@customElement("btrix-markdown-editor")
export class MarkdownEditor extends LitElement {
export class MarkdownEditor extends TailwindElement {
static styles = css`
:host {
--ink-border-radius: var(--sl-input-border-radius-medium);
--ink-color: var(--sl-input-color);
--ink-block-background-color: var(--sl-color-neutral-50);
--ink-block-padding: var(--sl-input-spacing-small);
}
.ink-mde {
border: solid var(--sl-input-border-width) var(--sl-input-border-color);
}
.ink-mde-toolbar {
border-top-left-radius: var(--ink-border-radius);
border-top-right-radius: var(--ink-border-radius);
border-bottom: 1px solid var(--sl-panel-border-color);
}
.ink-mde .ink-mde-toolbar .ink-button {
width: 2rem;
height: 2rem;
}
/* TODO check why style wasn't applied */
.cm-announced {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
`;
@property({ type: String })
label = "";
@property({ type: String })
initialValue = "";
@property({ type: String })
name = "markdown";
value = "";
@property({ type: Number })
maxlength?: number;
@state()
value = "";
@query("#editor-textarea")
private readonly textarea?: HTMLTextAreaElement | null;
createRenderRoot() {
// Disable shadow DOM for styles to work
return this;
private editor?: AwaitableInstance;
public checkValidity() {
return this.textarea?.checkValidity();
}
protected updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has("initialValue") && this.initialValue) {
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (
changedProperties.has("initialValue") &&
this.initialValue &&
!this.value
) {
this.value = this.initialValue;
this.initEditor();
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.editor?.destroy();
}
protected firstUpdated(): void {
if (!this.initialValue) {
this.initEditor();
}
this.initEditor();
}
render() {
const isInvalid = this.maxlength && this.value.length > this.maxlength;
return html`
<fieldset
class="markdown-editor-wrapper with-max-help-text"
?data-invalid=${isInvalid}
?data-user-invalid=${isInvalid}
>
<input name=${this.name} type="hidden" value="${this.value}" />
${guard(
[this.initialValue],
() => html`
<style>
.markdown-editor-wrapper[data-user-invalid] {
--select-editor-color: var(--sl-color-danger-400);
}
.markdown-editor-wrapper[data-user-invalid]
.markdown-editor
> div {
border: 1px solid var(--sl-color-danger-400);
}
.markdown-editor {
--blue-100: var(--sl-color-blue-100);
}
/* NOTE wysimark doesn't support customization or
a way of selecting elements as of 2.2.15
https://github.com/portive/wysimark/issues/10 */
/* Editor container: */
.markdown-editor > div {
overflow: hidden;
border-radius: var(--sl-input-border-radius-medium);
font-family: var(--sl-font-sans);
font-size: 1rem;
}
/* Hide unsupported button features */
/* Table, images: */
.markdown-editor > div > div > div > div:nth-child(9),
.markdown-editor > div > div > div > div:nth-child(10) {
display: none !important;
}
.markdown-editor div[role="textbox"] {
font-size: var(--sl-font-size-medium);
padding: var(--sl-spacing-small) var(--sl-spacing-medium);
}
</style>
<div class="markdown-editor font-sm"></div>
`,
)}
${this.maxlength
? html`<div class="form-help-text">
${getHelpText(this.maxlength, this.value.length)}
</div>`
: ""}
<fieldset ?data-invalid=${isInvalid} ?data-user-invalid=${isInvalid}>
<label class="form-label">${this.label}</label>
<textarea id="editor-textarea"></textarea>
<div class="helpText flex items-baseline justify-between">
<p class="text-xs">
${msg(
html`Supports
<a
class="text-blue-500 hover:text-blue-600"
href="https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax"
target="_blank"
rel="noopener noreferrer nofollow"
>GitHub Flavored Markdown</a
>.`,
)}
</p>
${this.maxlength
? html`<div>
<p class="form-help-text">
${getHelpText(this.maxlength, this.value.length)}
</p>
</div>`
: ""}
</div>
</fieldset>
`;
}
private initEditor() {
const editor = createWysimark(this.querySelector(".markdown-editor")!, {
initialMarkdown: this.initialValue,
minHeight: "12rem",
onChange: async () => {
const value = editor.getMarkdown();
const input = this.querySelector<HTMLTextAreaElement>(
`input[name=${this.name}]`,
);
input!.value = value;
this.value = value;
await this.updateComplete;
this.dispatchEvent(
new CustomEvent<MarkdownChangeDetail>("on-change", {
detail: {
value: value,
},
}),
);
if (!this.textarea) return;
if (this.editor) {
this.editor.destroy();
}
this.editor = wrap(this.textarea, {
doc: this.initialValue,
hooks: {
beforeUpdate: (doc: string) => {
if (this.maxlength) {
this.textarea?.setCustomValidity(
doc.length > this.maxlength
? msg(
str`Please shorten the description to ${formatNumber(this.maxlength)} or fewer characters.`,
)
: "",
);
}
},
afterUpdate: async (doc: string) => {
this.value = doc;
await this.updateComplete;
this.dispatchEvent(
new CustomEvent<MarkdownChangeDetail>("btrix-change", {
detail: {
value: doc,
},
}),
);
},
},
interface: {
appearance: "light",
attribution: false,
autocomplete: false,
toolbar: true,
},
toolbar: {
bold: true,
code: false,
codeBlock: false,
heading: true,
image: false,
italic: true,
link: true,
list: true,
orderedList: true,
quote: false,
taskList: false,
upload: false,
},
});
}

View File

@ -1,14 +1,22 @@
import { localized, msg, str } from "@lit/localize";
import { type SlInput } from "@shoelace-style/shoelace";
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
import { customElement, property, queryAsync, state } from "lit/decorators.js";
import { html } from "lit";
import {
customElement,
property,
query,
queryAsync,
state,
} from "lit/decorators.js";
import { when } from "lit/directives/when.js";
import { BtrixElement } from "@/classes/BtrixElement";
import type { Dialog } from "@/components/ui/dialog";
import type { MarkdownEditor } from "@/components/ui/markdown-editor";
import type { Collection } from "@/types/collection";
import { isApiError } from "@/utils/api";
import { maxLengthValidator } from "@/utils/form";
import LiteElement, { html } from "@/utils/LiteElement";
export type CollectionSavedEvent = CustomEvent<{
id: string;
@ -19,77 +27,37 @@ export type CollectionSavedEvent = CustomEvent<{
*/
@localized()
@customElement("btrix-collection-metadata-dialog")
export class CollectionMetadataDialog extends LiteElement {
export class CollectionMetadataDialog extends BtrixElement {
@property({ type: Object })
collection?: Collection;
@property({ type: Boolean })
open = false;
@state()
isDialogVisible = false;
@state()
private isSubmitting = false;
@query("btrix-markdown-editor")
private readonly descriptionEditor?: MarkdownEditor | null;
@queryAsync("#collectionForm")
private readonly form!: Promise<HTMLFormElement>;
private readonly validateNameMax = maxLengthValidator(50);
render() {
return html` <btrix-dialog
label=${this.collection
? msg("Edit Collection Metadata")
: msg("Create a New Collection")}
?open=${this.open}
@sl-show=${() => (this.isDialogVisible = true)}
@sl-after-hide=${() => (this.isDialogVisible = false)}
style="--width: 46rem"
>
<form id="collectionForm" @reset=${this.onReset} @submit=${this.onSubmit}>
<sl-input
class="with-max-help-text mb-2"
id="collectionForm-name-input"
name="name"
label=${msg("Collection Name")}
value=${this.collection?.name || ""}
placeholder=${msg("My Collection")}
autocomplete="off"
required
help-text=${this.validateNameMax.helpText}
@sl-input=${this.validateNameMax.validate}
autofocus
></sl-input>
<fieldset>
<label class="form-label">${msg("Description")}</label>
<btrix-markdown-editor
name="description"
initialValue=${this.collection?.description || ""}
maxlength=${4000}
></btrix-markdown-editor>
</fieldset>
${when(
!this.collection,
() => html`
<label>
<sl-switch name="isPublic"
>${msg("Publicly Accessible")}</sl-switch
>
<sl-tooltip
content=${msg(
"Enable public access to make Collections shareable. Only people with the shared link can view your Collection.",
)}
hoist
@sl-hide=${this.stopProp}
@sl-after-hide=${this.stopProp}
><sl-icon
class="ml-1 inline-block align-middle text-slate-500"
name="info-circle"
></sl-icon
></sl-tooltip>
</label>
`,
)}
<input class="invisible size-0" type="submit" />
</form>
${when(this.isDialogVisible, () => this.renderForm())}
<div slot="footer" class="flex items-center justify-end gap-3">
<sl-button
class="mr-auto"
@ -132,6 +100,57 @@ export class CollectionMetadataDialog extends LiteElement {
</btrix-dialog>`;
}
private renderForm() {
return html`
<form id="collectionForm" @reset=${this.onReset} @submit=${this.onSubmit}>
<sl-input
class="with-max-help-text mb-2"
id="collectionForm-name-input"
name="name"
label=${msg("Collection Name")}
value=${this.collection?.name || ""}
placeholder=${msg("My Collection")}
autocomplete="off"
required
help-text=${this.validateNameMax.helpText}
@sl-input=${this.validateNameMax.validate}
></sl-input>
<sl-divider></sl-divider>
<btrix-markdown-editor
label=${msg("Description")}
name="description"
initialValue=${this.collection?.description || ""}
maxlength=${4000}
></btrix-markdown-editor>
${when(
!this.collection,
() => html`
<sl-divider></sl-divider>
<label>
<sl-switch name="isPublic"
>${msg("Publicly Accessible")}</sl-switch
>
<sl-tooltip
content=${msg(
"Enable public access to make Collections shareable. Only people with the shared link can view your Collection.",
)}
hoist
@sl-hide=${this.stopProp}
@sl-after-hide=${this.stopProp}
><sl-icon
class="ml-1 inline-block align-middle text-slate-500"
name="info-circle"
></sl-icon
></sl-tooltip>
</label>
`,
)}
<input class="invisible size-0" type="submit" />
</form>
`;
}
private async hideDialog() {
void (await this.form).closest<Dialog>("btrix-dialog")!.hide();
}
@ -146,11 +165,16 @@ export class CollectionMetadataDialog extends LiteElement {
const form = event.target as HTMLFormElement;
const nameInput = form.querySelector<SlInput>('sl-input[name="name"]');
if (!nameInput?.checkValidity()) {
if (
!nameInput?.checkValidity() ||
!this.descriptionEditor?.checkValidity()
) {
return;
}
const { name, description, isPublic } = serialize(form);
const { name, isPublic } = serialize(form);
const description = this.descriptionEditor.value;
this.isSubmitting = true;
try {
const body = JSON.stringify({
@ -164,7 +188,7 @@ export class CollectionMetadataDialog extends LiteElement {
path = `/orgs/${this.orgId}/collections/${this.collection.id}`;
method = "PATCH";
}
const data = await this.apiFetch<Collection>(path, {
const data = await this.api.fetch<Collection>(path, {
method,
body,
});
@ -176,7 +200,7 @@ export class CollectionMetadataDialog extends LiteElement {
},
}) as CollectionSavedEvent,
);
this.notify({
this.notify.toast({
message: msg(
str`Successfully saved "${data.name || name}" Collection.`,
),
@ -189,7 +213,7 @@ export class CollectionMetadataDialog extends LiteElement {
if (message === "collection_name_taken") {
message = msg("This name is already taken.");
}
this.notify({
this.notify.toast({
message: message || msg("Something unexpected went wrong"),
variant: "danger",
icon: "exclamation-octagon",

View File

@ -1,7 +1,7 @@
import { localized, msg, str } from "@lit/localize";
import type { SlCheckbox } from "@shoelace-style/shoelace";
import { html, nothing, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { customElement, property, query, state } from "lit/decorators.js";
import { choose } from "lit/directives/choose.js";
import { guard } from "lit/directives/guard.js";
import { repeat } from "lit/directives/repeat.js";
@ -53,6 +53,12 @@ export class CollectionDetail extends BtrixElement {
@state()
private showShareInfo = false;
@query(".description")
private readonly description?: HTMLElement | null;
@query(".descriptionExpandBtn")
private readonly descriptionExpandBtn?: HTMLElement | null;
// Use to cancel requests
private getArchivedItemsController: AbortController | null = null;
@ -726,16 +732,19 @@ export class CollectionDetail extends BtrixElement {
private async checkTruncateDescription() {
await this.updateComplete;
window.requestAnimationFrame(() => {
const description = this.querySelector<HTMLElement>(".description");
if (description?.scrollHeight ?? 0 > (description?.clientHeight ?? 0)) {
this.querySelector(".descriptionExpandBtn")?.classList.remove("hidden");
if (
this.description?.scrollHeight ??
0 > (this.description?.clientHeight ?? 0)
) {
this.descriptionExpandBtn?.classList.remove("hidden");
}
});
}
private readonly toggleTruncateDescription = () => {
const description = this.querySelector<HTMLElement>(".description");
const description = this.description;
if (!description) {
console.debug("no .description");
return;

View File

@ -1,7 +1,7 @@
import { localized, msg, str } from "@lit/localize";
import type { SlInput, SlMenuItem } from "@shoelace-style/shoelace";
import Fuse from "fuse.js";
import { type PropertyValues } from "lit";
import { html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import { when } from "lit/directives/when.js";
@ -10,6 +10,7 @@ import queryString from "query-string";
import type { SelectNewDialogEvent } from ".";
import { BtrixElement } from "@/classes/BtrixElement";
import type { PageChangeEvent } from "@/components/ui/pagination";
import type { CollectionSavedEvent } from "@/features/collections/collection-metadata-dialog";
import { pageHeader } from "@/layouts/pageHeader";
@ -17,7 +18,6 @@ import type { APIPaginatedList, APIPaginationQuery } from "@/types/api";
import type { Collection, CollectionSearchValues } from "@/types/collection";
import type { UnderlyingFunction } from "@/types/utils";
import { isApiError } from "@/utils/api";
import LiteElement, { html } from "@/utils/LiteElement";
import { getLocale } from "@/utils/localization";
import { tw } from "@/utils/tailwind";
import noCollectionsImg from "~assets/images/no-collections-found.webp";
@ -54,7 +54,7 @@ const MIN_SEARCH_LENGTH = 2;
@localized()
@customElement("btrix-collections-list")
export class CollectionsList extends LiteElement {
export class CollectionsList extends BtrixElement {
@property({ type: Boolean })
isCrawler?: boolean;
@ -159,43 +159,51 @@ export class CollectionsList extends LiteElement {
<btrix-dialog
.label=${msg("Delete Collection?")}
.open=${this.openDialogName === "delete"}
?open=${this.openDialogName === "delete"}
@sl-hide=${() => (this.openDialogName = undefined)}
@sl-after-hide=${() => (this.isDialogVisible = false)}
>
${msg(
html`Are you sure you want to delete
<strong>${this.selectedCollection?.name}</strong>?`,
${when(
this.isDialogVisible,
() => html`
${msg(
html`Are you sure you want to delete
<strong>${this.selectedCollection?.name}</strong>?`,
)}
<div slot="footer" class="flex justify-between">
<sl-button
size="small"
@click=${() => (this.openDialogName = undefined)}
>${msg("Cancel")}</sl-button
>
<sl-button
size="small"
variant="primary"
@click=${async () => {
await this.deleteCollection(this.selectedCollection!);
this.openDialogName = undefined;
}}
>${msg("Delete Collection")}</sl-button
>
</div>
`,
)}
<div slot="footer" class="flex justify-between">
<sl-button
size="small"
@click=${() => (this.openDialogName = undefined)}
>${msg("Cancel")}</sl-button
>
<sl-button
size="small"
variant="primary"
@click=${async () => {
await this.deleteCollection(this.selectedCollection!);
this.openDialogName = undefined;
}}
>${msg("Delete Collection")}</sl-button
>
</div>
</btrix-dialog>
<btrix-collection-metadata-dialog
.collection=${this.openDialogName === "create"
? undefined
: this.selectedCollection}
?open=${this.openDialogName === "create" ||
this.openDialogName === "editMetadata"}
.collection=${
this.openDialogName === "create" ? undefined : this.selectedCollection
}
?open=${
this.openDialogName === "create" ||
this.openDialogName === "editMetadata"
}
@sl-hide=${() => (this.openDialogName = undefined)}
@sl-after-hide=${() => (this.selectedCollection = undefined)}
@btrix-collection-saved=${(e: CollectionSavedEvent) => {
if (this.openDialogName === "create") {
this.navTo(
`${this.orgBasePath}/collections/view/${e.detail.id}/items`,
this.navigate.to(
`${this.navigate.orgBasePath}/collections/view/${e.detail.id}/items`,
);
} else {
void this.fetchCollections();
@ -511,8 +519,8 @@ export class CollectionsList extends LiteElement {
<btrix-table-cell rowClickTarget="a">
<a
class="block truncate py-2"
href=${`${this.orgBasePath}/collections/view/${col.id}`}
@click=${this.navLink}
href=${`${this.navigate.orgBasePath}/collections/view/${col.id}`}
@click=${this.navigate.link}
>
${col.name}
</a>
@ -638,7 +646,7 @@ export class CollectionsList extends LiteElement {
});
private async onTogglePublic(coll: Collection, isPublic: boolean) {
await this.apiFetch(`/orgs/${this.orgId}/collections/${coll.id}`, {
await this.api.fetch(`/orgs/${this.orgId}/collections/${coll.id}`, {
method: "PATCH",
body: JSON.stringify({ isPublic }),
});
@ -665,7 +673,7 @@ export class CollectionsList extends LiteElement {
private async deleteCollection(collection: Collection): Promise<void> {
try {
const name = collection.name;
await this.apiFetch(
await this.api.fetch(
`/orgs/${this.orgId}/collections/${collection.id}`,
// FIXME API method is GET right now
{
@ -676,13 +684,13 @@ export class CollectionsList extends LiteElement {
this.selectedCollection = undefined;
void this.fetchCollections();
this.notify({
this.notify.toast({
message: msg(html`Deleted <strong>${name}</strong> Collection.`),
variant: "success",
icon: "check2-circle",
});
} catch {
this.notify({
this.notify.toast({
message: msg("Sorry, couldn't delete Collection at this time."),
variant: "danger",
icon: "exclamation-octagon",
@ -692,7 +700,7 @@ export class CollectionsList extends LiteElement {
private async fetchSearchValues() {
try {
const searchValues: CollectionSearchValues = await this.apiFetch(
const searchValues: CollectionSearchValues = await this.api.fetch(
`/orgs/${this.orgId}/collections/search-values`,
);
const names = searchValues.names;
@ -719,7 +727,7 @@ export class CollectionsList extends LiteElement {
if (isApiError(e)) {
this.fetchErrorStatusCode = e.statusCode;
} else {
this.notify({
this.notify.toast({
message: msg("Sorry, couldn't retrieve Collections at this time."),
variant: "danger",
icon: "exclamation-octagon",
@ -745,7 +753,7 @@ export class CollectionsList extends LiteElement {
},
);
const data = await this.apiFetch<APIPaginatedList<Collection>>(
const data = await this.api.fetch<APIPaginatedList<Collection>>(
`/orgs/${this.orgId}/collections?${query}`,
);

View File

@ -70,9 +70,6 @@ export default {
"@shoelace-style/shoelace/dist/themes/light.css": fileURLToPath(
new URL("./src/__mocks__/_empty.js", import.meta.url),
),
"@wysimark/standalone": fileURLToPath(
new URL("./src/__mocks__/_empty.js", import.meta.url),
),
},
},
},

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1