Frontend: Merge sidebar text fields, add Undo + commit-on-Enter #4966

Unifies Subject / Artist / Copyright / License / Keywords / Notes
into a single icon-prepended row layout (Option A from the proposal),
adds per-field maxLength sourced from new PhotoMaxLength constants
mirroring the backend VARCHAR caps, fixes a pre-existing textarea
auto-grow regression caused by a static min-height override in
lightbox.css, wires Enter-to-commit on the single-line fields via a
commitOnEnter registry flag, and adds an Undo affordance + new
hideEditUndo / hideEditSave toggles for A/B testing the inline
toolbar chrome.
This commit is contained in:
Michael Mayer 2026-05-14 06:38:53 +00:00
parent 367af097f0
commit ee775026e4
6 changed files with 404 additions and 93 deletions

View file

@ -1,5 +1,8 @@
<template>
<div class="p-sidebar-info bg-background metadata" :class="{ 'hide-edit-pencils': hideEditPencils }">
<div
class="p-sidebar-info bg-background metadata"
:class="{ 'hide-edit-pencils': hideEditPencils, 'hide-edit-undo': hideEditUndo, 'hide-edit-save': hideEditSave }"
>
<v-toolbar density="comfortable" color="background">
<v-btn :icon="$isRtl ? 'mdi-chevron-left' : 'mdi-chevron-right'" :title="$gettext('Close')" @click.stop="close()"></v-btn>
<v-toolbar-title>{{ $gettext(`Information`) }}</v-toolbar-title>
@ -16,7 +19,7 @@
v-if="editingField === 'title'"
:ref="setInlineEditorRef"
v-model="photo.Title"
:rules="rules.text(false, 0, $config.get('clip'), $pgettext('Photo', 'Title'))"
:rules="rules.text(false, 0, fieldRegistry.title.maxLength, fieldRegistry.title.label)"
density="compact"
hide-details="auto"
autocomplete="off"
@ -28,7 +31,14 @@
<div v-else-if="model.Title" class="text-subtitle-2 meta-title">{{ model.Title }}</div>
<div v-else class="meta-add-prompt" @click.stop="startEditing('title')">{{ $pgettext("Photo", "Add a Title") }}</div>
<template v-if="isEditable" #append>
<p-sidebar-inline-toolbar :editing="editingField === 'title'" @confirm="confirmField" @start="startEditing('title')" />
<p-sidebar-inline-toolbar
:editing="editingField === 'title'"
:can-undo="editingField === 'title'"
:undo-disabled="!inlineEditDirty"
@confirm="confirmField"
@undo="undoInlineEdit"
@start="startEditing('title')"
/>
</template>
</v-list-item>
@ -42,9 +52,10 @@
v-if="editingField === 'caption'"
:ref="setInlineEditorRef"
v-model="photo.Caption"
:rows="1"
:max-rows="6"
density="compact"
auto-grow
:max-rows="6"
hide-details="auto"
autocomplete="off"
class="meta-inline-edit meta-inline-caption"
@ -55,7 +66,14 @@
<div v-else-if="model.Caption" class="text-body-2 meta-caption meta-scrollable" v-html="captionHtml"></div>
<div v-else class="meta-add-prompt" @click.stop="startEditing('caption')">{{ $gettext("Add a Caption") }}</div>
<template v-if="isEditable" #append>
<p-sidebar-inline-toolbar :editing="editingField === 'caption'" @confirm="confirmField" @start="startEditing('caption')" />
<p-sidebar-inline-toolbar
:editing="editingField === 'caption'"
:can-undo="editingField === 'caption'"
:undo-disabled="!inlineEditDirty"
@confirm="confirmField"
@undo="undoInlineEdit"
@start="startEditing('caption')"
/>
</template>
</v-list-item>
@ -382,49 +400,12 @@
<template v-if="showDetailsSection">
<v-divider class="my-3"></v-divider>
<v-list-item
v-for="f in detailsFields"
v-show="shouldShowFieldRow(f)"
:key="f.key"
v-tooltip="f.label"
:prepend-icon="f.icon"
:class="['metadata__item', `meta-${f.key}`, { clickable: editingField !== f.key && (isEditable || f.read(photo)) }]"
@click.stop="onTextRowClick(f.key, f.read(photo))"
>
<v-textarea
v-if="editingField === f.key"
:ref="setInlineEditorRef"
:model-value="f.read(photo)"
:placeholder="f.label"
:rules="rules.text(false, 0, $config.get('clip'), f.label)"
density="compact"
auto-grow
hide-details="auto"
autocomplete="off"
class="meta-inline-edit"
:class="`meta-inline-${f.key}`"
@update:model-value="(v) => f.write(photo, v)"
@keydown.escape.stop.prevent="cancelEditing"
@blur="onInlineFieldBlur"
></v-textarea>
<div v-else-if="f.read(photo)" class="text-body-2 meta-scrollable">{{ f.read(photo) }}</div>
<div v-else class="meta-add-prompt" @click.stop="startEditing(f.key)">{{ f.label }}</div>
<template v-if="isEditable" #append>
<p-sidebar-inline-toolbar :editing="editingField === f.key" @confirm="confirmField" @start="startEditing(f.key)" />
</template>
</v-list-item>
</template>
<template v-for="f in textFields" :key="f.key">
<template v-if="!restrictedRole && shouldShowFieldRow(f)">
<v-divider class="my-3"></v-divider>
<v-list-item class="metadata__item" :class="`meta-${f.key}`">
<div class="text-subtitle-2">{{ f.label }}</div>
<template v-if="isEditable" #append>
<p-sidebar-inline-toolbar :editing="editingField === f.key" @confirm="confirmField" @start="startEditing(f.key)" />
</template>
</v-list-item>
<template v-for="f in detailsFields" :key="f.key">
<v-divider v-if="f.key === 'notes' && showRightsDivider" class="my-3 meta-rights-divider"></v-divider>
<v-list-item
v-show="shouldShowFieldRow(f)"
v-tooltip="f.label"
:prepend-icon="f.icon"
:class="['metadata__item', `meta-${f.key}`, { clickable: editingField !== f.key && (isEditable || f.read(photo)) }]"
@click.stop="onTextRowClick(f.key, f.read(photo))"
>
@ -432,7 +413,8 @@
v-if="editingField === f.key"
:ref="setInlineEditorRef"
:model-value="f.read(photo)"
:placeholder="f.label"
:rules="rules.text(false, 0, f.maxLength, f.label)"
:rows="1"
density="compact"
auto-grow
hide-details="auto"
@ -440,15 +422,24 @@
class="meta-inline-edit"
:class="`meta-inline-${f.key}`"
@update:model-value="(v) => f.write(photo, v)"
@keydown.enter="(ev) => onInlineEnter(ev, f)"
@keydown.escape.stop.prevent="cancelEditing"
@blur="onInlineFieldBlur"
></v-textarea>
<!-- eslint-disable-next-line vue/no-v-html -- f.htmlValue references a sanitized computed (e.g. notesHtml) encode-then-sanitize via $util.sanitizeHtml($util.encodeHTML(raw)). -->
<!-- eslint-disable-next-line vue/no-v-html -- f.htmlValue points at a sanitized computed (e.g. notesHtml) which runs $util.sanitizeHtml($util.encodeHTML(raw)). -->
<div v-else-if="f.display === 'html' && fieldHtml(f)" class="text-body-2 meta-scrollable" :class="`meta-${f.key}`" v-html="fieldHtml(f)"></div>
<div v-else-if="f.display !== 'html' && f.read(photo)" class="text-body-2 meta-scrollable" :class="`meta-${f.key}`">
{{ f.read(photo) }}
</div>
<div v-else class="meta-add-prompt" @click.stop="startEditing(f.key)">{{ f.label }}</div>
<div v-else-if="f.display !== 'html' && f.read(photo)" class="text-body-2 meta-scrollable" :class="`meta-${f.key}`">{{ f.read(photo) }}</div>
<div v-else class="meta-add-prompt" @click.stop="startEditing(f.key)">{{ f.placeholder ? f.placeholder : f.label }}</div>
<template v-if="isEditable" #append>
<p-sidebar-inline-toolbar
:editing="editingField === f.key"
:can-undo="editingField === f.key"
:undo-disabled="!inlineEditDirty"
@confirm="confirmField"
@undo="undoInlineEdit"
@start="startEditing(f.key)"
/>
</template>
</v-list-item>
</template>
</template>
@ -490,6 +481,7 @@ import * as media from "common/media";
import typeaheadCache from "common/typeahead-cache";
import { rules } from "common/form";
import { Album } from "model/album";
import { MaxLength as PhotoMaxLength } from "model/photo";
import PMap from "component/map.vue";
import PMetaDatetimeDialog from "component/meta/datetime/dialog.vue";
import PMetaCameraDialog from "component/meta/camera/dialog.vue";
@ -550,9 +542,20 @@ export default {
// click handlers still route to `startEditing` / `open*Dialog`,
// so the pencils are a redundant affordance that can be toggled
// off without affecting reachability. Save (`meta-inline-confirm`)
// and Undo (`meta-inline-undo`) buttons stay visible because they
// commit pending state rather than enter edit mode.
// and Undo (`meta-inline-undo`) buttons have their own toggles
// below.
hideEditPencils: true,
// hideEditUndo / hideEditSave hide the inline Undo and Save
// buttons via the `.hide-edit-undo` / `.hide-edit-save` rules in
// `css/lightbox.css`. Both default to true (hidden) since the
// keyboard paths are first-class: Enter commits on the
// commitOnEnter fields (Subject / Artist / Copyright / License /
// Keywords), blur commits on Caption + Notes, Escape cancels,
// and Ctrl+Z reverts inside the focused textarea. Flip either
// to false (per-user preference or A/B variant) to surface the
// mouse-driven affordances.
hideEditUndo: true,
hideEditSave: true,
editingField: null,
editOriginal: null,
// Per-field combobox state. The combobox/autocomplete row stays
@ -744,9 +747,14 @@ export default {
},
// Single source of truth for inline-text fields. Each entry knows how to
// read/write its raw value, what label to render (tooltip, placeholder,
// add-prompt), and whether the display branch should treat the value as
// sanitized HTML (Caption, Notes) or plain text (everything else).
// detailsFields/textFields below select subsets for the two visual layouts.
// add-prompt), the per-field maxLength (sourced from PhotoMaxLength which
// mirrors the backend VARCHAR + clip caps so UI validation matches what
// the server will persist), and whether the display branch should treat
// the value as sanitized HTML (Caption, Notes) or plain text. The
// detailsFields computed below selects the subset rendered in the merged
// single-row icon-prepended layout (everything except Title and Caption,
// which keep their bespoke header rows above the file/taken/camera
// section).
fieldRegistry() {
return {
title: {
@ -757,6 +765,7 @@ export default {
if (p) p.Title = v;
},
display: "text",
maxLength: PhotoMaxLength.Title,
},
caption: {
key: "caption",
@ -767,26 +776,19 @@ export default {
},
display: "html",
htmlValue: "captionHtml",
maxLength: PhotoMaxLength.Caption,
},
subject: {
key: "subject",
label: this.$gettext("Subject"),
icon: "mdi-text-box-outline",
icon: "mdi-flower-tulip-outline",
read: (p) => p?.Details?.Subject,
write: (p, v) => {
if (p?.Details) p.Details.Subject = v;
},
display: "text",
},
artist: {
key: "artist",
label: this.$gettext("Artist"),
icon: "mdi-palette",
read: (p) => p?.Details?.Artist,
write: (p, v) => {
if (p?.Details) p.Details.Artist = v;
},
display: "text",
maxLength: PhotoMaxLength.Subject,
commitOnEnter: true,
},
copyright: {
key: "copyright",
@ -797,49 +799,92 @@ export default {
if (p?.Details) p.Details.Copyright = v;
},
display: "text",
maxLength: PhotoMaxLength.Copyright,
commitOnEnter: true,
},
artist: {
key: "artist",
label: this.$gettext("Artist"),
icon: "mdi-human-handsdown",
read: (p) => p?.Details?.Artist,
write: (p, v) => {
if (p?.Details) p.Details.Artist = v;
},
display: "text",
maxLength: PhotoMaxLength.Artist,
commitOnEnter: true,
},
license: {
key: "license",
label: this.$gettext("License"),
icon: "mdi-license",
icon: "mdi-scale-balance",
read: (p) => p?.Details?.License,
write: (p, v) => {
if (p?.Details) p.Details.License = v;
},
display: "text",
maxLength: PhotoMaxLength.License,
commitOnEnter: true,
},
keywords: {
key: "keywords",
label: this.$gettext("Keywords"),
icon: "mdi-tag-multiple-outline",
read: (p) => p?.Details?.Keywords,
write: (p, v) => {
if (p?.Details) p.Details.Keywords = v;
},
display: "text",
maxLength: PhotoMaxLength.Keywords,
commitOnEnter: true,
},
notes: {
key: "notes",
label: this.$gettext("Notes"),
placeholder: this.$gettext("Add Notes"),
icon: null,
read: (p) => p?.Details?.Notes,
write: (p, v) => {
if (p?.Details) p.Details.Notes = v;
},
display: "html",
htmlValue: "notesHtml",
maxLength: PhotoMaxLength.Notes,
},
};
},
detailsFields() {
return ["subject", "artist", "copyright", "license"].map((k) => this.fieldRegistry[k]);
},
textFields() {
return ["keywords", "notes"].map((k) => this.fieldRegistry[k]);
return ["subject", "copyright", "artist", "license", "keywords", "notes"].map((k) => this.fieldRegistry[k]);
},
showDetailsSection() {
if (this.restrictedRole) return false;
if (this.isEditable) return true;
return this.detailsFields.some((f) => Boolean(f.read(this.photo)));
},
// inlineEditDirty is true when the active inline-text editor's
// current value differs from the editOriginal captured at
// startEditing time. Drives the disabled state of the Undo button
// when there's nothing to undo, the button stays visible (so the
// affordance is discoverable) but inactive.
inlineEditDirty() {
if (!this.editingField) return false;
return this.getFieldValue(this.editingField) !== (this.editOriginal ?? "");
},
// showRightsDivider gates the single divider that visually splits
// the structured metadata fields (Subject / Artist / Copyright /
// License / Keywords) from the free-form Notes row inside the
// merged details v-for. Template renders the divider just before
// the Notes row (`f.key === 'notes'`). For editable users every
// row is mounted, so the divider is always meaningful; for
// read-only users we suppress it when either side would be empty,
// otherwise the divider appears as an orphan line above or below
// the lone visible row.
showRightsDivider() {
if (this.isEditable) return true;
const hasAbove = ["subject", "artist", "copyright", "license", "keywords"].some((k) => this.shouldShowFieldRow(this.fieldRegistry[k]));
const hasBelow = this.shouldShowFieldRow(this.fieldRegistry.notes);
return hasAbove && hasBelow;
},
placeName() {
if (!this.photo) return "";
return this.photo.locationInfo() || "";
@ -1070,6 +1115,28 @@ export default {
if (f.display === "html") return Boolean(this.fieldHtml(f));
return Boolean(f.read(this.photo));
},
// Enter handler for the merged details textareas. Commits when the
// field opts in via `commitOnEnter` (single-line fields: Subject,
// Artist, Copyright, License, Keywords) and Shift is not held;
// Caption + Notes leave commitOnEnter unset so plain Enter falls
// through to the textarea's default newline insertion. Shift+Enter
// also falls through, giving power users a way to add a line break
// even on the single-line fields.
onInlineEnter(ev, f) {
if (!f?.commitOnEnter || ev.shiftKey) return;
ev.preventDefault();
ev.stopPropagation();
this.confirmField();
},
// undoInlineEdit reverts the active inline editor to its
// editOriginal value without exiting edit mode distinct from
// cancelEditing (Escape) which reverts AND exits. Wired to the
// Undo button on every inline-text toolbar so mouse users get
// parity with the keyboard cancel.
undoInlineEdit() {
if (!this.editingField || this.editOriginal === null) return;
this.setFieldValue(this.editingField, this.editOriginal);
},
startEditing(field) {
if (this.editingField) {
this.cancelEditing();

View file

@ -2,6 +2,7 @@
<div class="p-sidebar-inline-toolbar d-flex">
<v-btn
v-if="editing && canUndo"
:disabled="undoDisabled"
icon="mdi-undo-variant"
density="compact"
variant="plain"
@ -48,6 +49,15 @@ export default {
type: Boolean,
default: false,
},
// Renders the Undo button in a disabled (non-clickable) state used
// by inline-text editors that always show Undo while editing but
// want it inactive until the value differs from the editOriginal.
// Chip toolbars leave this at false (their parent v-if already
// gates mounting on whether Undo would do anything).
undoDisabled: {
type: Boolean,
default: false,
},
},
emits: ["confirm", "start", "undo"],
};

View file

@ -411,7 +411,16 @@
}
.p-lightbox__container > .p-lightbox__sidebar .v-input--density-compact {
--v-input-control-height: 30px;
/* Drives both the static control height for v-text-field rows
(Title) and the floor for auto-grown v-textareas. Vuetify routes
this var through `.v-textarea .v-field__field { --v-input-control-height:
var(--v-textarea-control-height) }` so when auto-grow writes the
calculated control height inline, the v-field__input min-height
(which uses `max(var(--v-input-control-height, ), )`) follows.
Pinning a static `min-height` on .v-field__input would shadow that
inheritance and freeze the textarea at one row see the auto-grow
regression notes in the May 15 sidebar refinement. */
--v-input-control-height: 36px;
}
.p-lightbox__container > .p-lightbox__sidebar .v-input--density-compact .v-field {
@ -426,7 +435,6 @@
}
.p-lightbox__container > .p-lightbox__sidebar .v-input--density-compact .v-field .v-field__input {
min-height: 36px;
font-size: 0.875rem;
}
@ -439,7 +447,16 @@
scrollbar. Drop both if a fresh visual pass shows the OS defaults are
acceptable. */
.p-lightbox__container > .p-lightbox__sidebar .meta-inline-edit textarea.v-field__input {
min-height: unset;
/* min-height intentionally NOT unset: Vuetify's stock rule
`.v-field__input { min-height: max(var(--v-input-control-height, ), ) }`
is what carries the auto-grow signal when `auto-grow` runs, it
writes `--v-textarea-control-height` inline on `.v-field`, and
the `.v-textarea .v-field__field { --v-input-control-height:
var(--v-textarea-control-height) }` rule rewires it for the
textarea's children. Killing min-height here would freeze the
textarea at the .v-field floor (~36px) while the calculated
height grows in the inline style see the May 15 sidebar
regression notes. */
scrollbar-width: thin;
scrollbar-color: rgba(var(--v-theme-on-surface), 0.2) transparent;
}
@ -484,6 +501,23 @@
display: none;
}
/* Sidebar toggles for the inline Undo and Save buttons. Both default
to ON (buttons hidden) since the keyboard paths are first-class:
Enter commits on the commitOnEnter fields, blur commits on Caption
+ Notes, Escape cancels, and Ctrl+Z reverts inside the focused
textarea. Per-user preference or A/B variants flip `hideEditUndo` /
`hideEditSave` on the sidebar root to surface the mouse-driven
affordances. The chip-section toolbars share these class names, so
the same toggles also hide their batched-removal Save / Undo
buttons. */
.p-lightbox__container > .p-lightbox__sidebar > .p-sidebar-info.hide-edit-undo .meta-inline-undo {
display: none;
}
.p-lightbox__container > .p-lightbox__sidebar > .p-sidebar-info.hide-edit-save .meta-inline-confirm {
display: none;
}
/* Add prompt for empty editable fields */
.p-lightbox__container > .p-lightbox__sidebar .meta-add-prompt {
color: rgba(var(--v-theme-on-surface), 0.35);

View file

@ -24,6 +24,24 @@ export const TimeZoneLocal = "Local";
export let BatchSize = 156;
// MaxLength mirrors the backend VARCHAR caps so UI validation matches
// what the server will actually persist. Keep in sync with the GORM
// struct tags in internal/entity/photo.go (PhotoTitle, PhotoCaption)
// and internal/entity/details.go (Subject, Artist, Copyright, License,
// Keywords, Notes). The Set* helpers in details.go further clip via
// txt.ClipShortText (1024) / txt.ClipText (2048); these caps mirror
// that ceiling, not the looser raw VARCHAR length.
export const MaxLength = Object.freeze({
Title: 200,
Caption: 4096,
Subject: 1024,
Artist: 1024,
Copyright: 1024,
License: 1024,
Keywords: 2048,
Notes: 2048,
});
// Photo models core metadata for images and videos shown in the UI.
export class Photo extends RestModel {
constructor(values) {

View file

@ -1352,6 +1352,61 @@ describe("PSidebarInfo component", () => {
expect(w.vm.editingField).toBe("title");
});
// onInlineEnter — Enter commits on single-line fields (commitOnEnter:
// true) and falls through (no preventDefault) for free-form fields
// like Notes / Caption so the textarea can insert a newline. Shift+
// Enter always falls through, even on commitOnEnter fields.
describe("onInlineEnter (commit-on-Enter for single-line fields)", () => {
let w;
beforeEach(() => {
w = mountSidebar({
props: {
modelValue: mockModel,
photo: { ...mockPhoto, Details: { Subject: "", Notes: "", Keywords: "" }, update: vi.fn(), wasChanged: () => false },
canEdit: true,
context: contexts.Photos,
},
global: { stubs: { PMap: true } },
});
});
function makeEvent(shiftKey) {
return { shiftKey, preventDefault: vi.fn(), stopPropagation: vi.fn() };
}
it("commits and suppresses the newline for commitOnEnter fields", () => {
const subject = w.vm.fieldRegistry.subject;
expect(subject.commitOnEnter).toBe(true);
const ev = makeEvent(false);
const confirmSpy = vi.spyOn(w.vm, "confirmField").mockImplementation(() => {});
w.vm.onInlineEnter(ev, subject);
expect(ev.preventDefault).toHaveBeenCalledTimes(1);
expect(ev.stopPropagation).toHaveBeenCalledTimes(1);
expect(confirmSpy).toHaveBeenCalledTimes(1);
});
it("falls through (no commit) for fields without commitOnEnter (Notes)", () => {
const notes = w.vm.fieldRegistry.notes;
expect(notes.commitOnEnter).toBeUndefined();
const ev = makeEvent(false);
const confirmSpy = vi.spyOn(w.vm, "confirmField").mockImplementation(() => {});
w.vm.onInlineEnter(ev, notes);
expect(ev.preventDefault).not.toHaveBeenCalled();
expect(ev.stopPropagation).not.toHaveBeenCalled();
expect(confirmSpy).not.toHaveBeenCalled();
});
it("falls through on Shift+Enter even for commitOnEnter fields", () => {
const keywords = w.vm.fieldRegistry.keywords;
expect(keywords.commitOnEnter).toBe(true);
const ev = makeEvent(true);
const confirmSpy = vi.spyOn(w.vm, "confirmField").mockImplementation(() => {});
w.vm.onInlineEnter(ev, keywords);
expect(ev.preventDefault).not.toHaveBeenCalled();
expect(confirmSpy).not.toHaveBeenCalled();
});
});
it("should still cancel on Escape via cancelEditing", async () => {
const photo = {
...mockPhoto,
@ -1963,6 +2018,19 @@ describe("PSidebarInfo component", () => {
expect(wrapper.vm.notesHtml).toBe("Some notes about this photo");
});
// The merged single-row detailsFields template must route Notes
// through the sanitized v-html branch (display: "html" + htmlValue:
// "notesHtml") rather than plain-text interpolation. Catches any
// future regression where the html-aware ladder is dropped from the
// merged loop and Notes silently falls back to the text branch.
it("renders Notes through the sanitized v-html branch in the merged details row", () => {
const notesRow = wrapper.find(".meta-notes");
expect(notesRow.exists()).toBe(true);
const innerNotes = notesRow.find(".meta-scrollable.meta-notes");
expect(innerNotes.exists()).toBe(true);
expect(innerNotes.html()).toContain("Some notes about this photo");
});
it("should return empty caption HTML when no caption", () => {
const w = mountSidebar({
props: { modelValue: { ...mockModel, Caption: "" }, photo: mockPhoto, context: contexts.Photos },
@ -2038,6 +2106,94 @@ describe("PSidebarInfo component", () => {
openSpy.mockRestore();
});
// inlineEditDirty + undoInlineEdit — Undo affordance for inline-text
// editors. The Undo button is always shown while editing, but
// disabled until inlineEditDirty flips true; clicking it reverts to
// the editOriginal captured at startEditing time without exiting
// edit mode (Escape is the revert-AND-exit gesture).
describe("inline edit Undo", () => {
let w;
let photo;
beforeEach(() => {
photo = {
...mockPhoto,
Title: "Original Title",
Details: { Subject: "Original Subject", Notes: "", Keywords: "" },
update: vi.fn(),
wasChanged: () => true,
};
w = mountSidebar({
props: { modelValue: mockModel, photo, canEdit: true, context: contexts.Photos },
global: { stubs: { PMap: true } },
});
});
it("inlineEditDirty is false when no field is being edited", () => {
expect(w.vm.editingField).toBeNull();
expect(w.vm.inlineEditDirty).toBe(false);
});
it("inlineEditDirty stays false right after startEditing (value unchanged)", async () => {
w.vm.startEditing("subject");
await w.vm.$nextTick();
expect(w.vm.editingField).toBe("subject");
expect(w.vm.inlineEditDirty).toBe(false);
});
it("inlineEditDirty flips true once the field value diverges from the original", async () => {
w.vm.startEditing("subject");
await w.vm.$nextTick();
// Route the mutation through the reactive write helper that the
// textarea's @update:model-value handler uses, so Vue's computed
// dependency tracking picks it up.
w.vm.setFieldValue("subject", "Edited");
await w.vm.$nextTick();
expect(w.vm.getFieldValue("subject")).toBe("Edited");
expect(w.vm.inlineEditDirty).toBe(true);
});
it("undoInlineEdit reverts to editOriginal without exiting edit mode", async () => {
w.vm.startEditing("subject");
await w.vm.$nextTick();
w.vm.setFieldValue("subject", "Edited");
await w.vm.$nextTick();
expect(w.vm.editingField).toBe("subject");
w.vm.undoInlineEdit();
await w.vm.$nextTick();
expect(w.vm.getFieldValue("subject")).toBe("Original Subject");
expect(w.vm.editingField).toBe("subject");
expect(w.vm.inlineEditDirty).toBe(false);
});
it("undoInlineEdit is a no-op when no field is being edited", () => {
expect(w.vm.editingField).toBeNull();
w.vm.undoInlineEdit();
expect(photo.Details.Subject).toBe("Original Subject");
});
// Defaults-agnostic binding check: the test sets each flag to a
// known value before asserting, so flipping the data() default
// (e.g., for an A/B variant that hides Save by default) doesn't
// break the test. What we care about is that the root :class
// binding actually mirrors the flags in both directions.
it("hide-edit-undo / hide-edit-save root classes mirror the data flags in both directions", async () => {
const root = w.find(".p-sidebar-info");
w.vm.hideEditUndo = false;
w.vm.hideEditSave = false;
await w.vm.$nextTick();
expect(root.classes()).not.toContain("hide-edit-undo");
expect(root.classes()).not.toContain("hide-edit-save");
w.vm.hideEditUndo = true;
w.vm.hideEditSave = true;
await w.vm.$nextTick();
expect(root.classes()).toContain("hide-edit-undo");
expect(root.classes()).toContain("hide-edit-save");
});
});
// isEditable
it("should not be editable without canEdit prop", () => {
expect(wrapper.vm.isEditable).toBeFalsy();
@ -3026,8 +3182,6 @@ describe("PSidebarInfo component", () => {
expect(html).not.toContain(">People<");
expect(html).not.toContain(">Labels<");
expect(html).not.toContain(">Albums<");
expect(html).not.toContain(">Keywords<");
expect(html).not.toContain(">Notes<");
expect(html).not.toContain("Jane Doe");
expect(html).not.toContain("Nature");
@ -3133,8 +3287,6 @@ describe("PSidebarInfo component", () => {
peopleHeader: ">People<",
labelsHeader: ">Labels<",
albumsHeader: ">Albums<",
keywordsHeader: ">Keywords<",
notesHeader: ">Notes<",
namedMarker: "Jane Doe",
labelName: "Nature",
albumTitle: "Vacation 2024",
@ -3288,8 +3440,6 @@ describe("PSidebarInfo component", () => {
TEXT.peopleHeader,
TEXT.labelsHeader,
TEXT.albumsHeader,
TEXT.keywordsHeader,
TEXT.notesHeader,
TEXT.namedMarker,
TEXT.labelName,
TEXT.albumTitle,
@ -3308,6 +3458,16 @@ describe("PSidebarInfo component", () => {
const fileRow = w.find(".meta-file");
expect(fileRow.exists()).toBe(true);
expect(fileRow.text()).toContain(TEXT.filename);
// Keywords/Notes are now part of the single-row icon-prepended
// detailsFields layout (no more text-subtitle-2 header rows).
// Confirm the rows render with their prepend icons and value
// content under the canonical .meta-{key} selector.
const keywordsRow = w.find(".meta-keywords");
expect(keywordsRow.exists()).toBe(true);
expect(keywordsRow.text()).toContain(TEXT.keywords);
const notesRow = w.find(".meta-notes");
expect(notesRow.exists()).toBe(true);
expect(notesRow.html()).toContain(TEXT.notes);
});
it("renders inline pencils and only the Edit Faces toggle (no eye toggle for editable users)", () => {
@ -3350,8 +3510,6 @@ describe("PSidebarInfo component", () => {
TEXT.peopleHeader,
TEXT.labelsHeader,
TEXT.albumsHeader,
TEXT.keywordsHeader,
TEXT.notesHeader,
TEXT.namedMarker,
TEXT.labelName,
TEXT.albumTitle,
@ -3383,7 +3541,7 @@ describe("PSidebarInfo component", () => {
// be falsy even for an otherwise-editable session.
expect(w.vm.isEditable).toBeFalsy();
const html = w.html();
for (const needle of [TEXT.camera, TEXT.lens, TEXT.placeName, TEXT.peopleHeader, TEXT.labelsHeader, TEXT.keywordsHeader, TEXT.notesHeader]) {
for (const needle of [TEXT.camera, TEXT.lens, TEXT.placeName, TEXT.peopleHeader, TEXT.labelsHeader]) {
expect(html).not.toContain(needle);
}
expect(w.find(".meta-inline-pencil").exists()).toBe(false);
@ -3423,15 +3581,21 @@ describe("PSidebarInfo component", () => {
// Add-prompt spans are the "click to start editing" placeholders.
const prompts = w.findAll(".meta-add-prompt");
expect(prompts.length).toBeGreaterThanOrEqual(5);
// At least title, caption, keywords, notes, subject are all expected.
// Title/Caption use a "Add a <Field>" affordance label; the others
// surface the bare field name as the prompt.
// Structural assertion for the merged details rows: each empty
// field renders exactly one add-prompt under its canonical
// .meta-{key} row. Avoids coupling to the exact label copy
// (which UX iterates on per field).
for (const key of ["subject", "copyright", "artist", "license", "keywords", "notes"]) {
const row = w.find(`.meta-${key}`);
expect(row.exists(), `missing .meta-${key} row`).toBe(true);
expect(row.find(".meta-add-prompt").exists(), `missing add-prompt in .meta-${key}`).toBe(true);
}
// Title and Caption rows don't carry a per-key class on the row
// itself (legacy hand-coded structure); fall back to label match
// for those, since "Add a Title" / "Add a Caption" are stable.
const texts = prompts.map((p) => p.text());
expect(texts).toContain("Add a Title");
expect(texts).toContain("Add a Caption");
expect(texts).toContain("Keywords");
expect(texts).toContain("Notes");
expect(texts).toContain("Subject");
});
// Explicit share-link (anonymous) case: the sidebar must behave

View file

@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import "../fixtures";
import * as media from "common/media";
import { Photo, BatchSize } from "model/photo";
import { Photo, BatchSize, MaxLength } from "model/photo";
import $event from "common/event";
// Drains the pubsub-js async queue so subscribers configured as `async: true`
@ -9,6 +9,24 @@ import $event from "common/event";
const flushEvents = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("model/photo", () => {
// Pins the per-field length caps to the backend VARCHAR + clip ceilings.
// If a backend bump (internal/entity/photo.go or details.go) changes any
// of these, this test fails on purpose so the frontend cap moves with it.
it("MaxLength mirrors the backend VARCHAR + clip caps", () => {
expect(MaxLength).toEqual({
Title: 200,
Caption: 4096,
Subject: 1024,
Artist: 1024,
Copyright: 1024,
License: 1024,
Keywords: 2048,
Notes: 2048,
});
// Frozen so consumers can't accidentally mutate per-field caps.
expect(Object.isFrozen(MaxLength)).toBe(true);
});
it("should get photo entity name", () => {
const values = { UID: 5, Title: "Crazy Cat" };
const photo = new Photo(values);