diff --git a/package.json b/package.json index 3c73b551..e736f027 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.54", + "version": "0.18.55", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index a97e603e..d03eb76f 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -7349,21 +7349,21 @@ context-menu-item-group:not(:last-child) .context-menu-item-group { margin-bottom: 12px; } -.muted-word-field-label { +.field-caption { font-size: 13px; font-weight: 600; color: var(--text-color-muted); margin-bottom: 6px; } -.muted-word-radio-group { +.pill-radio-group { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; } -.muted-word-radio-group label { +.pill-radio-group label { flex: 1; display: flex; align-items: center; @@ -7378,11 +7378,11 @@ context-menu-item-group:not(:last-child) .context-menu-item-group { font-weight: 500; } -.muted-word-radio-group label:has(input:checked) { +.pill-radio-group label:has(input:checked) { background: color-mix(in srgb, var(--highlight-color) 15%, transparent); } -.muted-word-radio-group input[type="radio"] { +.pill-radio-group input[type="radio"] { accent-color: var(--highlight-color); background-color: transparent; margin: 0; @@ -9352,7 +9352,7 @@ tab-bar[full-width] .tab-bar-button { flex: 1; } -.edit-profile-dialog { +.form-dialog { background-color: var(--background-color); overflow: visible; display: flex; @@ -9360,33 +9360,44 @@ tab-bar[full-width] .tab-bar-button { } @media (min-width: 800px) { - .edit-profile-dialog { + .form-dialog { max-width: 600px; max-height: none; top: 50%; } } -.edit-profile-dialog-content { - padding: 16px 20px; +.form-dialog-content { + padding: 0; display: flex; flex-direction: column; - gap: 16px; } @media (min-width: 800px) { - .edit-profile-dialog-content { + .form-dialog-content { max-height: 80vh; } } -.edit-profile-dialog-header { +.form-dialog-header { display: flex; align-items: center; justify-content: space-between; + position: sticky; + top: 0; + z-index: 2; + background-color: var(--background-color); + padding: 16px; + border-radius: var(--sheet-border-radius) var(--sheet-border-radius) 0 0; +} + +@media (min-width: 800px) { + .form-dialog-header { + border-radius: var(--dialog-border-radius) var(--dialog-border-radius) 0 0; + } } -.edit-profile-dialog-header h2 { +.form-dialog-header h2 { margin: 0; font-size: 18px; position: absolute; @@ -9394,7 +9405,7 @@ tab-bar[full-width] .tab-bar-button { transform: translateX(-50%); } -.edit-profile-dialog-header-button { +.form-dialog-header-button { background: none; border: none; font-size: 16px; @@ -9403,32 +9414,46 @@ tab-bar[full-width] .tab-bar-button { padding: 4px 0; } -.edit-profile-dialog-header-button:disabled { +.form-dialog-header-button:disabled { opacity: 0.5; cursor: default; } -.edit-profile-dialog-save-button:enabled, -.edit-profile-dialog-save-button.saving { +.form-dialog-save-button:enabled, +.form-dialog-save-button.saving { color: var(--primary-button-color); } -.edit-profile-dialog-save-button { +.form-dialog-save-button { display: inline-flex; align-items: center; gap: 8px; } -.edit-profile-dialog-save-button .loading-spinner { +.form-dialog-save-button .loading-spinner { border-top-color: var(--primary-button-color); } -.edit-profile-dialog-body { +.form-dialog-body { display: flex; flex-direction: column; + padding: 0 20px 20px; } -.edit-profile-images-section { +.form-dialog-body > .form-dialog-field ~ .form-dialog-field, +.form-dialog-body > .form-dialog-field ~ .form-dialog-error { + margin-top: 16px; +} + +.form-dialog-body .field-caption { + margin-bottom: 0; +} + +.form-dialog-body .pill-radio-group { + margin-bottom: 0; +} + +.form-dialog-images-section { position: relative; padding-bottom: 40px; } @@ -9459,7 +9484,7 @@ tab-bar[full-width] .tab-bar-button { font-size: 14px; } -.edit-profile-image-overlay { +.form-dialog-image-overlay { position: absolute; inset: 0; display: flex; @@ -9474,13 +9499,13 @@ tab-bar[full-width] .tab-bar-button { } @media (hover: hover) { - .edit-profile-banner-preview:hover .edit-profile-image-overlay, - .edit-profile-avatar-wrapper:hover .edit-profile-image-overlay { + .edit-profile-banner-preview:hover .form-dialog-image-overlay, + .form-dialog-avatar-wrapper:hover .form-dialog-image-overlay { opacity: 1; } } -.edit-profile-camera-button { +.form-dialog-camera-button { position: absolute; width: 24px; height: 24px; @@ -9493,30 +9518,30 @@ tab-bar[full-width] .tab-bar-button { pointer-events: none; } -.edit-profile-camera-button .icon { +.form-dialog-camera-button .icon { width: 14px; height: 14px; display: flex; } -.edit-profile-camera-button-banner { +.form-dialog-camera-button-banner { bottom: 8px; right: 8px; } -.edit-profile-camera-button-avatar { +.form-dialog-camera-button-avatar { bottom: 0; right: 0; } -.edit-profile-avatar-wrapper { +.form-dialog-avatar-wrapper { position: absolute; bottom: 0; left: 12px; cursor: pointer; } -.edit-profile-avatar-preview { +.form-dialog-avatar-preview { position: relative; width: 80px; height: 80px; @@ -9526,32 +9551,31 @@ tab-bar[full-width] .tab-bar-button { border: 2px solid light-dark(var(--less-white), var(--dark-gray)); } -.edit-profile-avatar-preview img { +.form-dialog-avatar-preview img { width: 100%; height: 100%; object-fit: cover; } -.edit-profile-avatar-placeholder { +.form-dialog-avatar-placeholder { width: 100%; height: 100%; object-fit: cover; } -.edit-profile-field { +.form-dialog-field { display: flex; flex-direction: column; gap: 4px; - margin-top: 16px; } -.edit-profile-field label { +.form-dialog-field label { font-size: 14px; font-weight: 600; } -.edit-profile-input, -.edit-profile-textarea { +.form-dialog-input, +.form-dialog-textarea { padding: 10px 12px; border: var(--hair) solid var(--post-border-color); border-radius: var(--text-input-border-radius); @@ -9562,24 +9586,24 @@ tab-bar[full-width] .tab-bar-button { resize: vertical; } -.edit-profile-input:focus, -.edit-profile-textarea:focus { +.form-dialog-input:focus, +.form-dialog-textarea:focus { outline: 2px solid var(--highlight-color); outline-offset: -1px; } -.edit-profile-char-count { +.form-dialog-char-count { font-size: 12px; color: var(--text-color-muted); text-align: right; } -.edit-profile-char-count.overflow { +.form-dialog-char-count.overflow { color: var(--danger-color, #dc3545); font-weight: 600; } -.edit-profile-error { +.form-dialog-error { color: var(--danger-color, #dc3545); font-size: 14px; padding: 8px 12px; @@ -9587,19 +9611,22 @@ tab-bar[full-width] .tab-bar-button { background-color: light-dark(#fef2f2, #3b1111); } -.edit-list-details-dialog .edit-list-details-images-section { - padding: 8px 0; +.edit-list-details-dialog .form-dialog-images-section, +.create-list-dialog .form-dialog-images-section { + padding: 0; display: flex; justify-content: flex-start; } -.edit-list-details-dialog .edit-profile-avatar-wrapper { +.edit-list-details-dialog .form-dialog-avatar-wrapper, +.create-list-dialog .form-dialog-avatar-wrapper { position: relative; bottom: auto; left: auto; } -.edit-list-details-dialog .edit-profile-avatar-preview { +.edit-list-details-dialog .form-dialog-avatar-preview, +.create-list-dialog .form-dialog-avatar-preview { border-radius: var(--list-avatar-border-radius); } @@ -9661,11 +9688,11 @@ image-cropper { cursor: pointer; } -.edit-profile-cropper-content { +.form-dialog-cropper-content { max-height: calc(100vh - 56px); } -.edit-profile-cropper-container { +.form-dialog-cropper-container { border-radius: var(--image-preview-border-radius); overflow: hidden; } diff --git a/src/js/api.js b/src/js/api.js index c50ac94b..f4c7c74a 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -1390,6 +1390,21 @@ export class Api { return res.data; } + async createListRecord(record) { + const res = await this.request("com.atproto.repo.createRecord", { + method: "POST", + body: { + repo: this.session.did, + collection: "app.bsky.graph.list", + record: { + $type: "app.bsky.graph.list", + ...record, + }, + }, + }); + return res.data; + } + async getListRecord(rkey) { const res = await this.request("com.atproto.repo.getRecord", { query: { diff --git a/src/js/components/create-list-dialog.js b/src/js/components/create-list-dialog.js new file mode 100644 index 00000000..c9203fce --- /dev/null +++ b/src/js/components/create-list-dialog.js @@ -0,0 +1,465 @@ +import { html, render } from "/js/lib/lit-html.js"; +import { Component } from "/js/components/component.js"; +import { scrollLocks } from "/js/scrollLocks.js"; +import { + closeWithAnimation, + enableDragToDismiss, + resetScrollOnBlur, +} from "/js/dialogHelpers.js"; +import { classnames, graphemeCount, readFileAsDataUrl } from "/js/utils.js"; +import { ImageCompressor } from "/js/imageCompressor.js"; +import "/js/components/image-cropper.js"; +import "/js/components/context-menu.js"; +import "/js/components/context-menu-item.js"; +import "/js/components/context-menu-item-group.js"; +import { cameraIconTemplate } from "/js/templates/icons/cameraIcon.template.js"; +import { confirmModal } from "/js/modals/confirm.modal.js"; + +const MAX_NAME_LENGTH = 64; +const MAX_DESCRIPTION_LENGTH = 300; +const CURATE_LIST_PURPOSE = "app.bsky.graph.defs#curatelist"; +const MOD_LIST_PURPOSE = "app.bsky.graph.defs#modlist"; + +class CreateListDialog extends Component { + connectedCallback() { + if (this.initialized) { + return; + } + this.setAttribute("data-dialog-wrapper", ""); + this.scrollLock = null; + this._name = ""; + this._description = ""; + this._newAvatarDataUrl = null; + this._purpose = CURATE_LIST_PURPOSE; + this._saving = false; + this._error = null; + this._croppingImageSrc = null; + this._isOpen = false; + this.innerHTML = ""; + this.render(); + this.initialized = true; + } + + get _isDirty() { + return ( + this._name.length > 0 || + this._description.length > 0 || + this._newAvatarDataUrl !== null || + this._purpose !== CURATE_LIST_PURPOSE + ); + } + + get _isNameTooLong() { + return graphemeCount(this._name) > MAX_NAME_LENGTH; + } + + get _isNameEmpty() { + return this._name.trim().length === 0; + } + + get _isDescriptionTooLong() { + return graphemeCount(this._description) > MAX_DESCRIPTION_LENGTH; + } + + get _canSave() { + return ( + !this._saving && + !this._isNameEmpty && + !this._isNameTooLong && + !this._isDescriptionTooLong + ); + } + + render() { + const isCropping = !!this._croppingImageSrc; + + const nameCount = graphemeCount(this._name); + const descriptionCount = graphemeCount(this._description); + const avatarSrc = this._newAvatarDataUrl; + + render( + html` { + if (!isCropping && event.target.tagName === "DIALOG") { + if (await this.confirmClose()) { + this.close(); + } + } + }} + @cancel=${async (event) => { + event.preventDefault(); + if (isCropping) { + this._croppingImageSrc = null; + this.render(); + } else if (await this.confirmClose()) { + this.close(); + } + }} + @close=${() => { + this.scrollLock?.release(); + this.scrollLock = null; + this.dispatchEvent(new CustomEvent("create-list-closed")); + }} + > + ${isCropping + ? html` + + { + this._croppingImageSrc = null; + this.render(); + }} + > + Cancel + + Edit image + this._applyCrop()} + > + Apply + + + + + + ` + : html` + + { + if (await this.confirmClose()) { + this.close(); + } + }} + .disabled=${this._saving} + > + Cancel + + New list + this._save()} + .disabled=${!this._canSave} + data-testid="create-list-save-button" + > + Create + ${this._saving + ? html`` + : ""} + + + + + + List Avatar + + this._openAvatarMenu()} + > + + ${avatarSrc + ? html`` + : html``} + + + + ${cameraIconTemplate()} + + + + + + + + this._pickImage()} + > + Upload from Files + + + ${avatarSrc + ? html` + { + this._newAvatarDataUrl = null; + this.render(); + }} + > + Remove Avatar + + ` + : ""} + + + + List Type + { + this._purpose = event.target.value; + this.render(); + }} + > + + + User list + + + + Moderation list + + + + + + List Name + { + this._name = event.target.value; + this.render(); + }} + data-testid="create-list-name" + /> + + ${nameCount}/${MAX_NAME_LENGTH} + + + + + Description + { + this._description = event.target.value; + this.render(); + }} + rows="4" + data-testid="create-list-description" + > + + ${descriptionCount}/${MAX_DESCRIPTION_LENGTH} + + + + ${this._error + ? html`${this._error}` + : ""} + + `} + + this._handleFileSelect(event)} + @cancel=${(event) => { + event.stopPropagation(); + }} + /> + `, + this, + ); + + if (this._isOpen) { + const dialog = this.querySelector(".create-list-dialog"); + if (dialog && !dialog.open) { + dialog.showModal(); + } + } + } + + _openAvatarMenu() { + const menu = this.querySelector(".create-list-avatar-menu"); + const cameraButton = this.querySelector( + ".form-dialog-camera-button-avatar", + ); + if (menu && cameraButton) { + const rect = cameraButton.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.bottom; + menu.open(x, y); + } + } + + _pickImage() { + const input = this.querySelector(".create-list-file-input"); + if (input) { + input.click(); + } + } + + async _handleFileSelect(event) { + const file = event.target.files?.[0]; + if (!file || !file.type.startsWith("image/")) { + event.target.value = ""; + return; + } + + const dataUrl = await readFileAsDataUrl(file); + event.target.value = ""; + + this._croppingImageSrc = dataUrl; + this.render(); + } + + async _applyCrop() { + const cropper = this.querySelector("image-cropper"); + if (!cropper) return; + + const croppedDataUrl = cropper.cropImage(); + if (!croppedDataUrl) return; + + this._newAvatarDataUrl = croppedDataUrl; + this._croppingImageSrc = null; + this.render(); + } + + async _save() { + this._saving = true; + this._error = null; + this.render(); + + try { + let avatarBlob = null; + if (this._newAvatarDataUrl) { + const compressed = await new ImageCompressor().compressImage( + this._newAvatarDataUrl, + ); + avatarBlob = compressed.blob; + } + + const successCallback = () => { + this.close(); + }; + const errorCallback = (error) => { + console.error("Failed to create list:", error); + this._error = "Failed to create list. Please try again."; + this._saving = false; + this.render(); + }; + + this.dispatchEvent( + new CustomEvent("list-create", { + detail: { + listData: { + purpose: this._purpose, + name: this._name, + description: this._description, + avatarBlob, + }, + successCallback, + errorCallback, + }, + }), + ); + } catch (error) { + console.error("Error creating list:", error); + this._error = "Failed to create list. Please try again."; + this._saving = false; + this.render(); + } + } + + open() { + this._isOpen = true; + this.scrollLock ??= scrollLocks.acquire({ target: this }); + const dialog = this.querySelector(".create-list-dialog"); + if (dialog?.open) return; + if (dialog) { + dialog.showModal(); + enableDragToDismiss(dialog, { + confirmDismiss: () => this.confirmClose(), + onClose: () => this.close(), + scrollContainer: this.querySelector(".form-dialog-content"), + ignoreTouchTarget: (el) => + !!el.closest("button") || + el.tagName === "INPUT" || + el.tagName === "TEXTAREA" || + !!el.closest("image-cropper"), + disableWhenKeyboardOpen: true, + }); + + resetScrollOnBlur(dialog, this.querySelector(".form-dialog-content")); + } + } + + async confirmClose() { + if (!this._isDirty || !!this._croppingImageSrc || this._saving) return true; + return confirmModal("Are you sure you want to discard this list?", { + title: "Discard list?", + confirmButtonStyle: "danger", + confirmButtonText: "Discard", + }); + } + + close() { + this._isOpen = false; + return closeWithAnimation(this.querySelector(".create-list-dialog")); + } + + disconnectedCallback() { + this.scrollLock?.release(); + this.scrollLock = null; + } +} + +CreateListDialog.register(); diff --git a/src/js/components/edit-list-details-dialog.js b/src/js/components/edit-list-details-dialog.js index 8fb544d9..0dd6964e 100644 --- a/src/js/components/edit-list-details-dialog.js +++ b/src/js/components/edit-list-details-dialog.js @@ -96,7 +96,7 @@ class EditListDetailsDialog extends Component { render( html` { if (!isCropping && event.target.tagName === "DIALOG") { if (await this.confirmClose()) { @@ -121,11 +121,11 @@ class EditListDetailsDialog extends Component { > ${isCropping ? html` - + { this._croppingImageSrc = null; @@ -136,14 +136,14 @@ class EditListDetailsDialog extends Component { Edit image this._applyCrop()} > Apply - + ` - : html` - + : html` + { if (await this.confirmClose()) { @@ -168,7 +168,7 @@ class EditListDetailsDialog extends Component { Edit list details this._save()} @@ -182,26 +182,24 @@ class EditListDetailsDialog extends Component { - - + + this._openAvatarMenu()} > - + ${avatarSrc ? html`` : html``} - + ${cameraIconTemplate()} @@ -233,12 +231,12 @@ class EditListDetailsDialog extends Component { : ""} - + List Name { this._name = event.target.value; @@ -247,7 +245,7 @@ class EditListDetailsDialog extends Component { data-testid="edit-list-details-name" /> @@ -255,11 +253,11 @@ class EditListDetailsDialog extends Component { - + Description { this._description = event.target.value; @@ -269,7 +267,7 @@ class EditListDetailsDialog extends Component { data-testid="edit-list-details-description" > @@ -278,7 +276,7 @@ class EditListDetailsDialog extends Component { ${this._error - ? html`${this._error}` + ? html`${this._error}` : ""} `} @@ -308,7 +306,7 @@ class EditListDetailsDialog extends Component { _openAvatarMenu() { const menu = this.querySelector(".edit-list-details-avatar-menu"); const cameraButton = this.querySelector( - ".edit-profile-camera-button-avatar", + ".form-dialog-camera-button-avatar", ); if (menu && cameraButton) { const rect = cameraButton.getBoundingClientRect(); @@ -408,7 +406,7 @@ class EditListDetailsDialog extends Component { enableDragToDismiss(dialog, { confirmDismiss: () => this.confirmClose(), onClose: () => this.close(), - scrollContainer: this.querySelector(".edit-profile-dialog-content"), + scrollContainer: this.querySelector(".form-dialog-content"), ignoreTouchTarget: (el) => !!el.closest("button") || el.tagName === "INPUT" || @@ -417,10 +415,7 @@ class EditListDetailsDialog extends Component { disableWhenKeyboardOpen: true, }); - resetScrollOnBlur( - dialog, - this.querySelector(".edit-profile-dialog-content"), - ); + resetScrollOnBlur(dialog, this.querySelector(".form-dialog-content")); } } diff --git a/src/js/components/edit-profile-dialog.js b/src/js/components/edit-profile-dialog.js index 77afecbc..e7b6378d 100644 --- a/src/js/components/edit-profile-dialog.js +++ b/src/js/components/edit-profile-dialog.js @@ -107,7 +107,7 @@ class EditProfileDialog extends Component { render( html` { if (!isCropping && event.target.tagName === "DIALOG") { if (await this.confirmClose()) { @@ -133,11 +133,11 @@ class EditProfileDialog extends Component { > ${isCropping ? html` - + { this._croppingTarget = null; @@ -149,13 +149,13 @@ class EditProfileDialog extends Component { Edit image this._applyCrop()} > Apply - + ` - : html` - + : html` + { if (await this.confirmClose()) { @@ -182,7 +182,7 @@ class EditProfileDialog extends Component { Edit profile this._save()} @@ -196,8 +196,8 @@ class EditProfileDialog extends Component { - - + + this._openImageMenu(event, "banner")} @@ -207,30 +207,30 @@ class EditProfileDialog extends Component { : html``} - + ${cameraIconTemplate()} this._openImageMenu(event, "avatar")} > - + ${avatarSrc ? html`` : html``} - + ${cameraIconTemplate()} @@ -287,12 +287,12 @@ class EditProfileDialog extends Component { : ""} - + Display Name { this._displayName = event.target.value; @@ -301,7 +301,7 @@ class EditProfileDialog extends Component { data-testid="edit-profile-display-name" /> @@ -309,11 +309,11 @@ class EditProfileDialog extends Component { - + Description { this._description = event.target.value; @@ -323,7 +323,7 @@ class EditProfileDialog extends Component { data-testid="edit-profile-description" > @@ -332,7 +332,7 @@ class EditProfileDialog extends Component { ${this._error - ? html`${this._error}` + ? html`${this._error}` : ""} `} @@ -352,7 +352,7 @@ class EditProfileDialog extends Component { ); if (this._isOpen) { - const dialog = this.querySelector(".edit-profile-dialog"); + const dialog = this.querySelector(".form-dialog"); if (dialog && !dialog.open) { dialog.showModal(); } @@ -366,8 +366,8 @@ class EditProfileDialog extends Component { : ".edit-profile-banner-menu"; const buttonClass = target === "avatar" - ? ".edit-profile-camera-button-avatar" - : ".edit-profile-camera-button-banner"; + ? ".form-dialog-camera-button-avatar" + : ".form-dialog-camera-button-banner"; const menu = this.querySelector(menuClass); const cameraButton = this.querySelector(buttonClass); if (menu && cameraButton) { @@ -482,14 +482,14 @@ class EditProfileDialog extends Component { open() { this._isOpen = true; this.scrollLock ??= scrollLocks.acquire({ target: this }); - const dialog = this.querySelector(".edit-profile-dialog"); + const dialog = this.querySelector(".form-dialog"); if (dialog?.open) return; if (dialog) { dialog.showModal(); enableDragToDismiss(dialog, { confirmDismiss: () => this.confirmClose(), onClose: () => this.close(), - scrollContainer: this.querySelector(".edit-profile-dialog-content"), + scrollContainer: this.querySelector(".form-dialog-content"), ignoreTouchTarget: (el) => !!el.closest("button") || el.tagName === "INPUT" || @@ -498,10 +498,7 @@ class EditProfileDialog extends Component { disableWhenKeyboardOpen: true, }); - resetScrollOnBlur( - dialog, - this.querySelector(".edit-profile-dialog-content"), - ); + resetScrollOnBlur(dialog, this.querySelector(".form-dialog-content")); } } @@ -516,7 +513,7 @@ class EditProfileDialog extends Component { close() { this._isOpen = false; - return closeWithAnimation(this.querySelector(".edit-profile-dialog")); + return closeWithAnimation(this.querySelector(".form-dialog")); } disconnectedCallback() { diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index cffe108d..90e9b64e 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -921,6 +921,53 @@ export class Mutations { } } + async createList({ currentUser, purpose, name, description, avatarBlob }) { + const avatarRef = avatarBlob ? await this.api.uploadBlob(avatarBlob) : null; + const record = { + purpose, + name, + description, + createdAt: getCurrentTimestamp(), + }; + if (avatarRef) record.avatar = avatarRef; + + const res = await this.api.createListRecord(record); + + const creator = { + did: currentUser.did, + handle: currentUser.handle, + displayName: currentUser.displayName, + avatar: currentUser.avatar, + }; + const listView = { + $type: "app.bsky.graph.defs#listView", + uri: res.uri, + cid: res.cid, + name, + purpose, + description, + descriptionFacets: [], + avatar: avatarRef?.ref?.$link + ? buildCdnUrl("avatar", creator.did, avatarRef.ref.$link) + : undefined, + creator, + indexedAt: record.createdAt, + listItemCount: 0, + viewer: {}, + }; + this.dataStore.$lists.set(res.uri, listView); + const actorLists = untrack(() => + this.dataStore.$actorLists.get(creator.did), + ); + if (actorLists) { + this.dataStore.$actorLists.set(creator.did, { + ...actorLists, + lists: [listView, ...actorLists.lists], + }); + } + return listView; + } + async updateList(list, { name, description, avatarBlob, removeAvatar }) { const rkey = list.uri.split("/").pop(); const avatarRef = avatarBlob ? await this.api.uploadBlob(avatarBlob) : null; diff --git a/src/js/views/lists.view.js b/src/js/views/lists.view.js index ca26fcb4..862e3f4e 100644 --- a/src/js/views/lists.view.js +++ b/src/js/views/lists.view.js @@ -4,6 +4,9 @@ import { auth } from "/js/auth.js"; import { headerTemplate } from "/js/templates/header.template.js"; import { listFeedTemplate } from "/js/templates/listFeed.template.js"; import { bindToPage, pageEffect } from "/js/router.js"; +import { showToast } from "/js/toasts.js"; +import { parseUri } from "/js/dataHelpers.js"; +import "/js/components/create-list-dialog.js"; class ListsView extends View { async render({ root, layout, context: { dataLayer } }) { @@ -29,7 +32,18 @@ class ListsView extends View { render( html` - ${headerTemplate({ title: "Lists" })} + ${headerTemplate({ + title: "Lists", + rightItemTemplate: () => html` + handleClickNew({ currentUser })} + > + + New + + `, + })} ${listFeedTemplate({ lists: actorLists?.lists, @@ -46,6 +60,30 @@ class ListsView extends View { await dataLayer.requests.loadCurrentUserLists({ reload }); } + async function handleClickNew({ currentUser }) { + const dialog = document.createElement("create-list-dialog"); + dialog.addEventListener("list-create", async (event) => { + const { listData, successCallback, errorCallback } = event.detail; + try { + const list = await dataLayer.mutations.createList({ + ...listData, + currentUser, + }); + showToast("List created"); + successCallback(); + const { rkey } = parseUri(list.uri); + window.router.go(`/profile/${currentUser.handle}/lists/${rkey}`); + } catch (error) { + errorCallback(error); + } + }); + dialog.addEventListener("create-list-closed", () => { + dialog.remove(); + }); + root.querySelector("main").appendChild(dialog); + dialog.open(); + } + root.addEventListener("page-enter", async () => { window.scrollTo(0, 0); await loadLists(); diff --git a/src/js/views/settings/mutedWords.view.js b/src/js/views/settings/mutedWords.view.js index 1f1af605..71c74b2b 100644 --- a/src/js/views/settings/mutedWords.view.js +++ b/src/js/views/settings/mutedWords.view.js @@ -271,8 +271,8 @@ class SettingsMutedWordsView extends View { autocapitalize="none" /> - Duration: - + Duration: + Forever @@ -291,8 +291,8 @@ class SettingsMutedWordsView extends View { - Mute in: - + Mute in: + Text & tags @@ -303,7 +303,7 @@ class SettingsMutedWordsView extends View { - Options: + Options: { await page .locator('#profile-view [data-testid="edit-profile-button"]') .click({ timeout: 10000 }); - const dialog = page.locator("edit-profile-dialog .edit-profile-dialog"); + const dialog = page.locator("edit-profile-dialog .form-dialog"); await expect(dialog).toBeVisible({ timeout: 5000 }); return dialog; } @@ -211,19 +211,19 @@ test.describe("Drag-to-dismiss", () => { test("dragging past threshold dismisses it", async ({ page }) => { await openEditProfileDialog(page); await drag(page, { - eventSourceSelector: "edit-profile-dialog .edit-profile-dialog", + eventSourceSelector: "edit-profile-dialog .form-dialog", startY: 300, endY: 400, }); await expect( - page.locator("edit-profile-dialog .edit-profile-dialog"), + page.locator("edit-profile-dialog .form-dialog"), ).not.toBeVisible({ timeout: 2000 }); }); test("dragging below threshold snaps back", async ({ page }) => { const dialog = await openEditProfileDialog(page); await drag(page, { - eventSourceSelector: "edit-profile-dialog .edit-profile-dialog", + eventSourceSelector: "edit-profile-dialog .form-dialog", startY: 300, endY: 330, }); @@ -233,9 +233,8 @@ test.describe("Drag-to-dismiss", () => { test("drag starting on a button does not dismiss", async ({ page }) => { const dialog = await openEditProfileDialog(page); await drag(page, { - eventSourceSelector: "edit-profile-dialog .edit-profile-dialog", - startTouchTargetSelector: - "edit-profile-dialog .edit-profile-dialog button", + eventSourceSelector: "edit-profile-dialog .form-dialog", + startTouchTargetSelector: "edit-profile-dialog .form-dialog button", startY: 300, endY: 430, }); @@ -246,7 +245,7 @@ test.describe("Drag-to-dismiss", () => { const dialog = await openEditProfileDialog(page); await simulateKeyboardOpen(page); await drag(page, { - eventSourceSelector: "edit-profile-dialog .edit-profile-dialog", + eventSourceSelector: "edit-profile-dialog .form-dialog", startY: 300, endY: 430, }); @@ -261,7 +260,7 @@ test.describe("Drag-to-dismiss", () => { const dialog = await openEditProfileDialog(page); const scrollTop = await dialog - .locator(".edit-profile-dialog-content") + .locator(".form-dialog-content") .evaluate((element) => { element.scrollTop = element.scrollHeight; return element.scrollTop; @@ -269,8 +268,8 @@ test.describe("Drag-to-dismiss", () => { expect(scrollTop).toBeGreaterThan(0); await drag(page, { - eventSourceSelector: "edit-profile-dialog .edit-profile-dialog", - startTouchTargetSelector: "edit-profile-dialog .edit-profile-field", + eventSourceSelector: "edit-profile-dialog .form-dialog", + startTouchTargetSelector: "edit-profile-dialog .form-dialog-field", startY: 200, endY: 380, }); diff --git a/tests/e2e/specs/concerns/listDialogsEdgeCases.test.js b/tests/e2e/specs/concerns/listDialogsEdgeCases.test.js new file mode 100644 index 00000000..75ef0622 --- /dev/null +++ b/tests/e2e/specs/concerns/listDialogsEdgeCases.test.js @@ -0,0 +1,432 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { MockServer } from "../../mockServer.js"; +import { createList } from "../../../shared/factories.js"; +import { userProfile } from "../../testData.js"; + +const OWN_LIST_URI = `at://${userProfile.did}/app.bsky.graph.list/ownlist`; + +function setupOwnList(mockServer, overrides = {}) { + const list = createList({ + uri: OWN_LIST_URI, + name: "My Own List", + creatorHandle: userProfile.handle, + ...overrides, + }); + if (overrides.description !== undefined) { + list.description = overrides.description; + } + mockServer.addLists([list]); + return list; +} + +async function openCreateListDialog(page) { + await page.goto("/lists"); + const listsView = page.locator("#lists-view"); + const newButton = listsView.locator('[data-testid="new-list-button"]'); + await expect(newButton).toBeVisible({ timeout: 10000 }); + await newButton.click(); + const dialog = page.locator("create-list-dialog"); + await expect(dialog.locator('[data-testid="create-list-name"]')).toBeVisible({ + timeout: 10000, + }); + return dialog; +} + +async function openEditListDialog(page) { + await page.goto(`/profile/${userProfile.handle}/lists/ownlist`); + const view = page.locator("#list-detail-view"); + await expect(view.locator(".context-menu-button")).toBeVisible({ + timeout: 10000, + }); + await view.locator(".context-menu-button").click(); + await view.locator('[data-testid="menu-action-list-edit"]').click(); + const dialog = page.locator("edit-list-details-dialog"); + await expect( + dialog.locator('[data-testid="edit-list-details-name"]'), + ).toBeVisible({ timeout: 10000 }); + return dialog; +} + +test.describe("List creation edge cases", () => { + test("name character limit — counter overflow disables Create past 64 chars", async ({ + page, + }) => { + const mockServer = new MockServer(); + await mockServer.setup(page); + + await login(page); + const dialog = await openCreateListDialog(page); + + const nameInput = dialog.locator('[data-testid="create-list-name"]'); + const saveButton = dialog.locator( + '[data-testid="create-list-save-button"]', + ); + const nameCount = dialog + .locator('[data-testid="create-list-name"]') + .locator("..") + .locator(".form-dialog-char-count"); + + await nameInput.fill("a".repeat(60)); + await expect(nameCount).toContainText("60/64"); + await expect(nameCount).not.toHaveClass(/overflow/); + await expect(saveButton).toBeEnabled(); + + await nameInput.fill("a".repeat(65)); + await expect(nameCount).toContainText("65/64"); + await expect(nameCount).toHaveClass(/overflow/); + await expect(saveButton).toBeDisabled(); + + await nameInput.fill("a".repeat(64)); + await expect(nameCount).toContainText("64/64"); + await expect(nameCount).not.toHaveClass(/overflow/); + await expect(saveButton).toBeEnabled(); + }); + + test("description character limit — counter overflow disables Create past 300 chars", async ({ + page, + }) => { + const mockServer = new MockServer(); + await mockServer.setup(page); + + await login(page); + const dialog = await openCreateListDialog(page); + + await dialog.locator('[data-testid="create-list-name"]').fill("Valid name"); + const descInput = dialog.locator('[data-testid="create-list-description"]'); + const saveButton = dialog.locator( + '[data-testid="create-list-save-button"]', + ); + const descCount = descInput + .locator("..") + .locator(".form-dialog-char-count"); + + await descInput.fill("d".repeat(305)); + await expect(descCount).toContainText("305/300"); + await expect(descCount).toHaveClass(/overflow/); + await expect(saveButton).toBeDisabled(); + + await descInput.fill("d".repeat(300)); + await expect(descCount).toContainText("300/300"); + await expect(descCount).not.toHaveClass(/overflow/); + await expect(saveButton).toBeEnabled(); + }); + + test("empty name keeps Create disabled — even after typing then clearing", async ({ + page, + }) => { + const mockServer = new MockServer(); + await mockServer.setup(page); + + await login(page); + const dialog = await openCreateListDialog(page); + + const nameInput = dialog.locator('[data-testid="create-list-name"]'); + const saveButton = dialog.locator( + '[data-testid="create-list-save-button"]', + ); + + await expect(saveButton).toBeDisabled(); + + await nameInput.fill("Temporary"); + await expect(saveButton).toBeEnabled(); + + await nameInput.fill(" "); + await expect(saveButton).toBeDisabled(); + + await nameInput.fill(""); + await expect(saveButton).toBeDisabled(); + }); + + test("creation error — failed createRecord shows inline error and keeps dialog open", async ({ + page, + }) => { + const mockServer = new MockServer(); + await mockServer.setup(page); + + // LIFO — override the createRecord handler for graph.list only. + await page.route("**/xrpc/com.atproto.repo.createRecord*", (route) => { + const body = route.request().postDataJSON(); + if (body?.collection === "app.bsky.graph.list") { + return route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ + error: "InternalServerError", + message: "boom", + }), + }); + } + return route.fallback(); + }); + + await login(page); + const dialog = await openCreateListDialog(page); + + await dialog.locator('[data-testid="create-list-name"]').fill("Will Fail"); + await dialog.locator('[data-testid="create-list-save-button"]').click(); + + const errorBanner = dialog.locator(".form-dialog-error"); + await expect(errorBanner).toBeVisible({ timeout: 10000 }); + await expect(errorBanner).toContainText("Failed to create list"); + + // Dialog stays open and the user is not navigated away. + await expect(dialog.locator(".create-list-dialog")).toBeVisible(); + await expect(page).toHaveURL(/\/lists$/); + + // Save button re-enables so the user can retry. + await expect( + dialog.locator('[data-testid="create-list-save-button"]'), + ).toBeEnabled(); + }); + + test("cancel with dirty form prompts discard confirmation — cancelling keeps dialog open", async ({ + page, + }) => { + const mockServer = new MockServer(); + await mockServer.setup(page); + + await login(page); + const dialog = await openCreateListDialog(page); + + await dialog.locator('[data-testid="create-list-name"]').fill("Draft name"); + + await dialog.locator('[data-testid="create-list-cancel-button"]').click(); + + const confirmModal = page.locator('[data-testid="confirm-modal"]'); + await expect(confirmModal).toBeVisible({ timeout: 10000 }); + + await page.locator('[data-testid="modal-cancel-button"]').click(); + + await expect(confirmModal).toHaveCount(0); + await expect(dialog.locator(".create-list-dialog")).toBeVisible(); + await expect( + dialog.locator('[data-testid="create-list-name"]'), + ).toHaveValue("Draft name"); + }); + + test("cancel with empty form closes immediately without confirmation", async ({ + page, + }) => { + const mockServer = new MockServer(); + await mockServer.setup(page); + + await login(page); + const dialog = await openCreateListDialog(page); + + await dialog.locator('[data-testid="create-list-cancel-button"]').click(); + + await expect(page.locator('[data-testid="confirm-modal"]')).toHaveCount(0); + await expect(dialog.locator(".create-list-dialog")).toHaveCount(0, { + timeout: 10000, + }); + }); +}); + +test.describe("List edit edge cases", () => { + test("save button is disabled when name is edited to whitespace only", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer); + await mockServer.setup(page); + + await login(page); + const dialog = await openEditListDialog(page); + + const nameInput = dialog.locator('[data-testid="edit-list-details-name"]'); + const saveButton = dialog.locator( + '[data-testid="edit-list-details-save-button"]', + ); + + await nameInput.fill(" "); + await expect(saveButton).toBeDisabled(); + }); + + test("description character limit — counter overflow disables Save past 300 chars", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer); + await mockServer.setup(page); + + await login(page); + const dialog = await openEditListDialog(page); + + const descInput = dialog.locator( + '[data-testid="edit-list-details-description"]', + ); + const saveButton = dialog.locator( + '[data-testid="edit-list-details-save-button"]', + ); + const descCount = descInput + .locator("..") + .locator(".form-dialog-char-count"); + + await descInput.fill("d".repeat(305)); + await expect(descCount).toContainText("305/300"); + await expect(descCount).toHaveClass(/overflow/); + await expect(saveButton).toBeDisabled(); + + await descInput.fill("d".repeat(300)); + await expect(descCount).toContainText("300/300"); + await expect(descCount).not.toHaveClass(/overflow/); + await expect(saveButton).toBeEnabled(); + }); + + test("name character limit — counter overflow disables Save past 64 chars", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer); + await mockServer.setup(page); + + await login(page); + const dialog = await openEditListDialog(page); + + const nameInput = dialog.locator('[data-testid="edit-list-details-name"]'); + const saveButton = dialog.locator( + '[data-testid="edit-list-details-save-button"]', + ); + const nameCount = nameInput + .locator("..") + .locator(".form-dialog-char-count"); + + await nameInput.fill("a".repeat(65)); + await expect(nameCount).toContainText("65/64"); + await expect(nameCount).toHaveClass(/overflow/); + await expect(saveButton).toBeDisabled(); + + await nameInput.fill("a".repeat(64)); + await expect(nameCount).not.toHaveClass(/overflow/); + await expect(saveButton).toBeEnabled(); + }); + + test("save error — failed putRecord shows inline error and keeps dialog open", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer, { description: "Original description" }); + await mockServer.setup(page); + + await page.route("**/xrpc/com.atproto.repo.putRecord*", (route) => { + const body = route.request().postDataJSON(); + if (body?.collection === "app.bsky.graph.list") { + return route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ + error: "InternalServerError", + message: "boom", + }), + }); + } + return route.fallback(); + }); + + await login(page); + const dialog = await openEditListDialog(page); + + await dialog + .locator('[data-testid="edit-list-details-name"]') + .fill("Renamed"); + await dialog + .locator('[data-testid="edit-list-details-save-button"]') + .click(); + + const errorBanner = dialog.locator(".form-dialog-error"); + await expect(errorBanner).toBeVisible({ timeout: 10000 }); + await expect(errorBanner).toContainText("Failed to save list"); + + await expect(dialog.locator(".edit-list-details-dialog")).toBeVisible(); + await expect( + dialog.locator('[data-testid="edit-list-details-save-button"]'), + ).toBeEnabled(); + + // On-page name is unchanged because the write failed. + await expect( + page.locator('#list-detail-view [data-testid="list-detail-name"]'), + ).toContainText("My Own List"); + }); + + test("cancel with dirty edit prompts discard confirmation — cancelling keeps dialog open", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer); + await mockServer.setup(page); + + await login(page); + const dialog = await openEditListDialog(page); + + await dialog + .locator('[data-testid="edit-list-details-name"]') + .fill("Renamed"); + + await dialog + .locator('[data-testid="edit-list-details-cancel-button"]') + .click(); + + const confirmModal = page.locator('[data-testid="confirm-modal"]'); + await expect(confirmModal).toBeVisible({ timeout: 10000 }); + + await page.locator('[data-testid="modal-cancel-button"]').click(); + + await expect(confirmModal).toHaveCount(0); + await expect(dialog.locator(".edit-list-details-dialog")).toBeVisible(); + await expect( + dialog.locator('[data-testid="edit-list-details-name"]'), + ).toHaveValue("Renamed"); + }); + + test("cancel without changes closes immediately without confirmation", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer); + await mockServer.setup(page); + + await login(page); + const dialog = await openEditListDialog(page); + + await dialog + .locator('[data-testid="edit-list-details-cancel-button"]') + .click(); + + await expect(page.locator('[data-testid="confirm-modal"]')).toHaveCount(0); + await expect(dialog.locator(".edit-list-details-dialog")).toHaveCount(0, { + timeout: 10000, + }); + }); + + test("confirming discard on dirty edit closes the dialog and drops changes", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer); + await mockServer.setup(page); + + await login(page); + const dialog = await openEditListDialog(page); + + await dialog + .locator('[data-testid="edit-list-details-name"]') + .fill("Renamed"); + + await dialog + .locator('[data-testid="edit-list-details-cancel-button"]') + .click(); + + await expect(page.locator('[data-testid="confirm-modal"]')).toBeVisible({ + timeout: 10000, + }); + await page.locator('[data-testid="modal-confirm-button"]').click(); + + await expect(dialog.locator(".edit-list-details-dialog")).toHaveCount(0, { + timeout: 10000, + }); + await expect( + page.locator('#list-detail-view [data-testid="list-detail-name"]'), + ).toContainText("My Own List"); + }); +}); diff --git a/tests/e2e/specs/flows/editProfile.test.js b/tests/e2e/specs/flows/editProfile.test.js index 3a9c9a5d..822302a3 100644 --- a/tests/e2e/specs/flows/editProfile.test.js +++ b/tests/e2e/specs/flows/editProfile.test.js @@ -144,7 +144,7 @@ test.describe("Edit profile flow", () => { // The character count should show overflow styling await expect( - dialog.locator(".edit-profile-char-count.overflow"), + dialog.locator(".form-dialog-char-count.overflow"), ).toBeVisible(); // Save button should be disabled @@ -186,7 +186,7 @@ test.describe("Edit profile flow", () => { await dialog.locator('[data-testid="edit-profile-cancel-button"]').click(); // Dialog should be closed - await expect(dialog.locator(".edit-profile-dialog[open]")).toHaveCount(0, { + await expect(dialog.locator(".form-dialog[open]")).toHaveCount(0, { timeout: 5000, }); @@ -226,7 +226,7 @@ test.describe("Edit profile flow", () => { ).toBeVisible({ timeout: 5000 }); // Click avatar preview to open context menu - await dialog.locator(".edit-profile-avatar-preview").click(); + await dialog.locator(".form-dialog-avatar-preview").click(); // Context menu should appear with "Upload from Files" and "Remove Avatar" const menu = dialog.locator(".edit-profile-avatar-menu"); @@ -267,7 +267,7 @@ test.describe("Edit profile flow", () => { ).toBeVisible({ timeout: 5000 }); // Click avatar preview to open context menu - await dialog.locator(".edit-profile-avatar-preview").click(); + await dialog.locator(".form-dialog-avatar-preview").click(); // Context menu should only have "Upload from Files" const menu = dialog.locator(".edit-profile-avatar-menu"); @@ -309,12 +309,12 @@ test.describe("Edit profile flow", () => { // Avatar image should be visible in the preview await expect( dialog.locator( - ".edit-profile-avatar-preview img:not(.edit-profile-avatar-placeholder)", + ".form-dialog-avatar-preview img:not(.form-dialog-avatar-placeholder)", ), ).toBeVisible(); // Click avatar preview to open context menu - await dialog.locator(".edit-profile-avatar-preview").click(); + await dialog.locator(".form-dialog-avatar-preview").click(); // Click "Remove Avatar" const menu = dialog.locator(".edit-profile-avatar-menu"); @@ -323,7 +323,7 @@ test.describe("Edit profile flow", () => { // Avatar image should be removed from the preview (placeholder may still exist) await expect( dialog.locator( - ".edit-profile-avatar-preview img:not(.edit-profile-avatar-placeholder)", + ".form-dialog-avatar-preview img:not(.form-dialog-avatar-placeholder)", ), ).toHaveCount(0); @@ -416,7 +416,7 @@ test.describe("Edit profile flow", () => { ).toBeVisible({ timeout: 5000 }); // Click avatar preview to open context menu - await dialog.locator(".edit-profile-avatar-preview").click(); + await dialog.locator(".form-dialog-avatar-preview").click(); // Click "Upload from Files" to trigger file picker const menu = dialog.locator(".edit-profile-avatar-menu"); @@ -508,7 +508,7 @@ test.describe("Edit profile flow", () => { await dialog.locator('[data-testid="edit-profile-save-button"]').click(); // Error message should appear in the dialog - await expect(dialog.locator(".edit-profile-error")).toContainText( + await expect(dialog.locator(".form-dialog-error")).toContainText( "Failed to save profile", { timeout: 5000 }, ); diff --git a/tests/e2e/specs/views/lists.view.test.js b/tests/e2e/specs/views/lists.view.test.js index d554e975..1048cb0f 100644 --- a/tests/e2e/specs/views/lists.view.test.js +++ b/tests/e2e/specs/views/lists.view.test.js @@ -84,6 +84,36 @@ test.describe("Lists view", () => { ); }); + test("should create a new list and navigate to its detail page", async ({ + page, + }) => { + const mockServer = new MockServer(); + await mockServer.setup(page); + + await login(page); + await page.goto("/lists"); + + const listsView = page.locator("#lists-view"); + const newButton = listsView.locator('[data-testid="new-list-button"]'); + await expect(newButton).toBeVisible({ timeout: 10000 }); + await newButton.click(); + + const dialog = page.locator("create-list-dialog"); + await expect( + dialog.locator('[data-testid="create-list-purpose"]'), + ).toBeVisible(); + + await dialog + .locator('[data-testid="create-list-name"]') + .fill("My New List"); + await dialog.locator('[data-testid="create-list-save-button"]').click(); + + await expect(page).toHaveURL( + new RegExp(`/profile/${userProfile.handle}/lists/rkey-\\d+$`), + { timeout: 10000 }, + ); + }); + test.describe("Logged-out behavior", () => { test("should redirect to /login when not authenticated", async ({ page, diff --git a/tests/unit/specs/components/edit-profile-dialog.test.js b/tests/unit/specs/components/edit-profile-dialog.test.js index d324a19f..8d355f9f 100644 --- a/tests/unit/specs/components/edit-profile-dialog.test.js +++ b/tests/unit/specs/components/edit-profile-dialog.test.js @@ -28,7 +28,7 @@ describe("edit-profile-dialog", () => { it("should render dialog element", () => { const element = document.createElement("edit-profile-dialog"); connectElement(element); - const dialog = element.querySelector(".edit-profile-dialog"); + const dialog = element.querySelector(".form-dialog"); assert(dialog !== null, "Dialog should be rendered"); assert.deepEqual(dialog.tagName, "DIALOG"); }); @@ -36,7 +36,7 @@ describe("edit-profile-dialog", () => { it("should render header with title", () => { const element = document.createElement("edit-profile-dialog"); connectElement(element); - const header = element.querySelector(".edit-profile-dialog-header h2"); + const header = element.querySelector(".form-dialog-header h2"); assert(header !== null, "Header should be rendered"); assert.deepEqual(header.textContent, "Edit profile"); }); @@ -73,9 +73,7 @@ describe("edit-profile-dialog", () => { it("should render cancel button", () => { const element = document.createElement("edit-profile-dialog"); connectElement(element); - const cancelButton = element.querySelector( - ".edit-profile-dialog-header-button", - ); + const cancelButton = element.querySelector(".form-dialog-header-button"); assert(cancelButton !== null, "Cancel button should be rendered"); assert.deepEqual(cancelButton.textContent.trim(), "Cancel"); }); @@ -107,7 +105,7 @@ describe("edit-profile-dialog", () => { connectElement(element); element.setProfile(mockProfile); const avatarImg = element.querySelector( - ".edit-profile-avatar-preview img", + ".form-dialog-avatar-preview img", ); assert(avatarImg !== null, "Avatar image should be rendered"); assert.deepEqual(avatarImg.src, "https://example.com/avatar.jpg"); @@ -139,11 +137,11 @@ describe("edit-profile-dialog", () => { connectElement(element); element.setProfile({ ...mockProfile, avatar: null }); const avatarImg = element.querySelector( - ".edit-profile-avatar-preview img:not(.edit-profile-avatar-placeholder)", + ".form-dialog-avatar-preview img:not(.form-dialog-avatar-placeholder)", ); assert.deepEqual(avatarImg, null); const placeholder = element.querySelector( - ".edit-profile-avatar-placeholder", + ".form-dialog-avatar-placeholder", ); assert(placeholder !== null, "Avatar placeholder should be shown"); assert.deepEqual(placeholder.tagName, "IMG"); @@ -159,7 +157,7 @@ describe("edit-profile-dialog", () => { const element = document.createElement("edit-profile-dialog"); connectElement(element); element.setProfile(mockProfile); - const charCount = element.querySelectorAll(".edit-profile-char-count")[0]; + const charCount = element.querySelectorAll(".form-dialog-char-count")[0]; assert(charCount !== null, "Char count should be rendered"); assert(charCount.textContent.includes("/64"), "Should show max of 64"); }); @@ -168,7 +166,7 @@ describe("edit-profile-dialog", () => { const element = document.createElement("edit-profile-dialog"); connectElement(element); element.setProfile(mockProfile); - const charCounts = element.querySelectorAll(".edit-profile-char-count"); + const charCounts = element.querySelectorAll(".form-dialog-char-count"); assert(charCounts.length >= 2, "Should have at least 2 char counts"); assert( charCounts[1].textContent.includes("/256"), @@ -233,7 +231,7 @@ describe("edit-profile-dialog", () => { input.value = "a".repeat(65); input.dispatchEvent(new Event("input", { bubbles: true })); - const charCount = element.querySelectorAll(".edit-profile-char-count")[0]; + const charCount = element.querySelectorAll(".form-dialog-char-count")[0]; assert( charCount.classList.contains("overflow"), "Should have overflow class", @@ -310,11 +308,11 @@ describe("edit-profile-dialog", () => { items[1].click(); const avatarImg = element.querySelector( - ".edit-profile-avatar-preview img:not(.edit-profile-avatar-placeholder)", + ".form-dialog-avatar-preview img:not(.form-dialog-avatar-placeholder)", ); assert.deepEqual(avatarImg, null); const placeholder = element.querySelector( - ".edit-profile-avatar-placeholder", + ".form-dialog-avatar-placeholder", ); assert( placeholder !== null, @@ -486,7 +484,7 @@ describe("edit-profile-dialog", () => { // Cancel button is disabled while saving; Escape key (cancel event) is the // available dismiss path during save. - const dialog = element.querySelector(".edit-profile-dialog"); + const dialog = element.querySelector(".form-dialog"); dialog.dispatchEvent(new Event("cancel", { cancelable: true })); await new Promise((resolve) => setTimeout(resolve, 0));