Frontend: Validate inline-text input against backend VARCHAR caps #5584

Adds per-entity MaxLength constants on model/{photo,label,subject,album}.js
mirroring the backend VARCHAR widths, and routes every inline-text input
in the Edit Dialog, Batch Edit, sidebar inline editor, Photo Labels /
People tabs, New People page, and the Label / Album / People edit dialogs
through the shared rules.text(...) factory in common/form.js. Deletes the
$config.get('clip') = 160 anti-pattern that rejected valid 200-char titles
and 1024-char rights fields, and drops the bare `hide-details` attribute
that masked the inline error slot.

Adds a $refs.form.validate() gate to the Edit Dialog and Batch Edit save
handlers, mirroring page/settings/account.vue, so an overlength field
blocks the save with $notify.error("Changes could not be saved") instead
of firing photo.update() against invalid input. For the sidebar inline
editor (no parent v-form) confirmField() now checks the field's
fieldRegistry.maxLength imperatively and keeps the editor open on
overflow; navigation arrows then route through confirmDiscardPending,
which opens the existing discard dialog with a "Discard invalid changes?"
label so the user explicitly chooses to revert or stay.

Includes focused vitest cases for the new caps, the form-validate gate
on both dialogs, the sidebar imperative check, and the new discard-dialog
overflow branch.
This commit is contained in:
Michael Mayer 2026-05-16 12:46:15 +00:00
parent 38e7022765
commit 04f5dd922f
19 changed files with 483 additions and 118 deletions

View file

@ -26,9 +26,8 @@
<v-col v-if="album.Type !== 'month'" cols="12">
<v-text-field
v-model="model.Title"
hide-details
autofocus
:rules="rules.text(false, 0, $config.get('clip'), $gettext('Name'))"
:rules="rules.text(false, 0, AlbumMaxLength.Title, $gettext('Name'))"
:label="$gettext('Name')"
:disabled="disabled"
class="input-title"
@ -95,7 +94,7 @@
</v-dialog>
</template>
<script>
import Album from "model/album";
import Album, { MaxLength as AlbumMaxLength } from "model/album";
import { rules } from "common/form";
export default {
@ -132,6 +131,7 @@ export default {
category: null,
categories: this.$config.albumCategories(),
rules,
AlbumMaxLength,
};
},
watch: {

View file

@ -26,9 +26,8 @@
<v-col cols="12">
<v-text-field
v-model="model.Name"
hide-details
autofocus
:rules="rules.text(false, 0, $config.get('clip'), $gettext('Name'))"
:rules="rules.text(false, 0, LabelMaxLength.Name, $gettext('Name'))"
:label="$gettext('Name')"
:disabled="disabled"
class="input-title"
@ -52,7 +51,7 @@
</v-dialog>
</template>
<script>
import Label from "model/label";
import Label, { MaxLength as LabelMaxLength } from "model/label";
import { rules } from "common/form";
export default {
@ -73,6 +72,7 @@ export default {
disabled: !this.$config.allow("labels", "manage"),
model: new Label(),
rules,
LabelMaxLength,
};
},
watch: {

View file

@ -26,9 +26,8 @@
<v-col cols="12">
<v-text-field
v-model="model.Name"
hide-details
autofocus
:rules="rules.text(false, 0, $config.get('clip'), $gettext('Name'))"
:rules="rules.text(false, 0, SubjectMaxLength.Name, $gettext('Name'))"
:label="$gettext('Name')"
:disabled="disabled"
class="input-title"
@ -55,7 +54,7 @@
</v-dialog>
</template>
<script>
import Subject from "model/subject";
import Subject, { MaxLength as SubjectMaxLength } from "model/subject";
import { rules } from "common/form";
export default {
@ -76,6 +75,7 @@ export default {
disabled: !this.$config.allow("people", "manage"),
model: new Subject(),
rules,
SubjectMaxLength,
};
},
watch: {

View file

@ -155,7 +155,7 @@
<v-col cols="12" class="text-subtitle-2">{{ $gettext(`Description`) }}</v-col>
<v-col cols="12">
<v-text-field
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.Title, $pgettext(`Photo`, `Title`))"
:label="$pgettext(`Photo`, `Title`)"
:model-value="formData.Title.value"
:placeholder="getFieldData('text-field', 'Title').placeholder"
@ -170,7 +170,7 @@
</v-col>
<v-col cols="12">
<v-textarea
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.Caption, $gettext('Caption'))"
autocomplete="off"
auto-grow
:label="$gettext('Caption')"
@ -328,7 +328,7 @@
<v-col cols="12" class="text-subtitle-2">{{ $pgettext(`Edit`, `Content`) }}</v-col>
<v-col cols="12" sm="8">
<v-textarea
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.Subject, $gettext('Subject'))"
autocomplete="off"
auto-grow
:label="$gettext('Subject')"
@ -364,7 +364,7 @@
</v-col>
<v-col cols="12" sm="6">
<v-text-field
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.Copyright, $gettext('Copyright'))"
autocomplete="off"
:label="$gettext('Copyright')"
:model-value="formData.DetailsCopyright.value"
@ -379,7 +379,7 @@
</v-col>
<v-col cols="12" sm="6">
<v-text-field
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.Artist, $gettext('Artist'))"
autocomplete="off"
:label="$gettext('Artist')"
:model-value="formData.DetailsArtist.value"
@ -394,7 +394,7 @@
</v-col>
<v-col cols="12">
<v-textarea
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.License, $gettext('License'))"
autocomplete="off"
auto-grow
:label="$gettext('License')"
@ -500,6 +500,8 @@ import * as contexts from "options/contexts";
import IconLivePhoto from "../icon/live-photo.vue";
import { Batch } from "model/batch";
import Thumb from "model/thumb";
import { MaxLength as PhotoMaxLength } from "model/photo";
import { rules } from "common/form";
import PMetaLocationDialog from "component/meta/location/dialog.vue";
import PMetaLocationInput from "component/meta/location/input.vue";
import PInputChipSelector from "component/input/chip-selector.vue";
@ -553,6 +555,8 @@ export default {
isAllSelected: true,
allSelectedLength: 0,
options,
rules,
PhotoMaxLength,
firstVisibleElementIndex: 0,
lastVisibleElementIndex: 0,
mouseDown: {
@ -797,6 +801,12 @@ export default {
this.values = this.model.values;
this.setFormData();
this.allSelectedLength = this.model.getLengthOfAllSelected();
// Seed validation so the per-field `:rules` are active from
// the first render. Without this, Vuetify's `validate-on=
// "invalid-input"` default keeps rules dormant until the
// first failed validate() and save() can proceed against an
// overlength value. Mirrors page/settings/account.vue.
this.$nextTick(() => this.$refs.form?.validate?.());
})
.catch(() => {
this.values = {};
@ -1416,9 +1426,24 @@ export default {
this.locationDialog = false;
},
save(close) {
const form = this.$refs.form;
const validate = typeof form?.validate === "function" ? form.validate() : Promise.resolve({ valid: true });
return Promise.resolve(validate).then((result) => {
if (result && result.valid === false) {
this.$notify.error(this.$gettext("Changes could not be saved"));
return;
}
return this.persistChanges(close);
});
},
// persistChanges runs the actual batch save once form-level validation
// has passed. Split out so save() can early-return cleanly on invalid
// input without nesting two levels of `.then(...)`.
persistChanges(close) {
this.saving = true;
// Filter form data to only include fields with changes
const filteredFormData = this.getFilteredFormData();
if (!filteredFormData || Object.keys(filteredFormData).length === 0) {
@ -1429,7 +1454,6 @@ export default {
return Promise.resolve();
}
// Get currently selected photo UIDs from the model
const currentlySelectedUIDs = this.model.selection.filter((photo) => photo.selected).map((photo) => photo.id);
return this.model

View file

@ -14,8 +14,7 @@
v-model="view.model.Title"
:append-inner-icon="view.model.TitleSrc === 'manual' ? 'mdi-check' : ''"
:disabled="disabled"
:rules="rules.text(false, 0, $config.get('clip'), $pgettext('Photo', 'Title'))"
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.Title, $pgettext('Photo', 'Title'))"
:label="$pgettext('Photo', 'Title')"
placeholder=""
autocomplete="off"
@ -26,7 +25,7 @@
v-model="view.model.Caption"
:append-inner-icon="view.model.CaptionSrc === 'manual' ? 'mdi-check' : ''"
:disabled="disabled"
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.Caption, $gettext('Caption'))"
autocomplete="off"
auto-grow
:label="$gettext('Caption')"
@ -295,8 +294,7 @@
v-model="view.model.Details.Subject"
:append-inner-icon="view.model.Details.SubjectSrc === 'manual' ? 'mdi-check' : ''"
:disabled="disabled"
:rules="rules.text(false, 0, $config.get('clip'), $gettext('Subject'))"
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.Subject, $gettext('Subject'))"
autocomplete="off"
auto-grow
:label="$gettext('Subject')"
@ -311,8 +309,7 @@
v-model="view.model.Details.Copyright"
:append-inner-icon="view.model.Details.CopyrightSrc === 'manual' ? 'mdi-check' : ''"
:disabled="disabled"
:rules="rules.text(false, 0, $config.get('clip'), $gettext('Copyright'))"
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.Copyright, $gettext('Copyright'))"
autocomplete="off"
:label="$gettext('Copyright')"
placeholder=""
@ -325,8 +322,7 @@
v-model="view.model.Details.Artist"
:append-inner-icon="view.model.Details.ArtistSrc === 'manual' ? 'mdi-check' : ''"
:disabled="disabled"
:rules="rules.text(false, 0, $config.get('clip'), $gettext('Artist'))"
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.Artist, $gettext('Artist'))"
autocomplete="off"
:label="$gettext('Artist')"
placeholder=""
@ -339,8 +335,7 @@
v-model="view.model.Details.License"
:append-inner-icon="view.model.Details.LicenseSrc === 'manual' ? 'mdi-check' : ''"
:disabled="disabled"
:rules="rules.text(false, 0, $config.get('clip'), $gettext('License'))"
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.License, $gettext('License'))"
autocomplete="off"
auto-grow
:label="$gettext('License')"
@ -355,7 +350,7 @@
v-model="view.model.Details.Keywords"
:append-inner-icon="view.model.Details.KeywordsSrc === 'manual' ? 'mdi-check' : ''"
:disabled="disabled"
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.Keywords, $gettext('Keywords'))"
autocomplete="off"
auto-grow
:label="$gettext('Keywords')"
@ -370,7 +365,7 @@
v-model="view.model.Details.Notes"
:append-inner-icon="view.model.Details.NotesSrc === 'manual' ? 'mdi-check' : ''"
:disabled="disabled"
hide-details
:rules="rules.text(false, 0, PhotoMaxLength.Notes, $gettext('Notes'))"
autocomplete="off"
auto-grow
:label="$gettext('Notes')"
@ -421,6 +416,7 @@
<script>
import countries from "options/countries.json";
import Thumb from "model/thumb";
import { MaxLength as PhotoMaxLength } from "model/photo";
import * as options from "options/options";
import { rules } from "common/form";
import PMetaLocationDialog from "component/meta/location/dialog.vue";
@ -450,6 +446,7 @@ export default {
readonly: this.$config.get("readonly"),
options,
rules,
PhotoMaxLength,
countries,
featReview: this.$config.feature("review"),
showDatePicker: false,
@ -481,6 +478,14 @@ export default {
created() {
this.syncData();
},
mounted() {
// Seed validation so the per-field `:rules` are active from the
// first render. Without this, Vuetify's `validate-on="invalid-input"`
// default keeps the rules dormant until the first failed validate()
// which means overlength input renders no error and save() can
// proceed against an invalid value. Mirrors page/settings/account.vue.
this.$refs.form?.validate?.();
},
methods: {
setDay(v) {
if (Number.isInteger(v?.value)) {
@ -626,17 +631,27 @@ export default {
save(close) {
if (this.invalidDate) {
this.$notify.error(this.$gettext("Invalid date"));
return;
return Promise.resolve();
}
this.updateModel();
const form = this.$refs.form;
const validate = typeof form?.validate === "function" ? form.validate() : Promise.resolve({ valid: true });
this.view.model.update().then(() => {
if (close) {
this.$emit("close");
return Promise.resolve(validate).then((result) => {
if (result && result.valid === false) {
this.$notify.error(this.$gettext("Changes could not be saved"));
return;
}
this.syncData();
this.updateModel();
return this.view.model.update().then(() => {
if (close) {
this.$emit("close");
}
this.syncData();
});
});
},
close() {

View file

@ -132,14 +132,13 @@
item-title="Name"
item-value="Name"
return-object
:rules="[nameRule]"
:rules="rules.text(false, 0, LabelMaxLength.Name, $gettext('Name'))"
color="surface-variant"
autocomplete="off"
single-line
flat
variant="plain"
density="compact"
hide-details
hide-no-data
append-icon=""
:menu-icon="null"
@ -176,6 +175,8 @@
<script>
import Thumb from "model/thumb";
import { MaxLength as LabelMaxLength } from "model/label";
import { rules } from "common/form";
import typeaheadCache from "common/typeahead-cache";
export default {
@ -193,6 +194,8 @@ export default {
disabled: !this.$config.feature("edit"),
config: this.$config.values,
readonly: this.$config.get("readonly"),
rules,
LabelMaxLength,
selected: [],
newLabel: "",
newLabelModel: null,
@ -247,15 +250,6 @@ export default {
align: "center",
},
],
// v-combobox with return-object hands the rule one of three
// values: null (initial / cleared), a string (free-text entry),
// or the selected item object (which carries a Name field).
// The rule only meaningfully constrains free-text length, so
// collapse the other two cases to an empty-length check.
nameRule: (v) => {
const name = typeof v === "string" ? v : v && typeof v === "object" ? v.Name || "" : "";
return name.length <= this.$config.get("clip") || this.$gettext("Name too long");
},
};
},
computed: {

View file

@ -48,12 +48,11 @@
<v-text-field
v-else-if="m.SubjUID"
v-model="m.Name"
:rules="[textRule]"
:rules="rules.text(true, 0, SubjectMaxLength.Name, $gettext('Name'))"
:disabled="busy"
:readonly="true"
autocomplete="off"
autocorrect="off"
hide-details
single-line
clearable
persistent-clear
@ -103,7 +102,8 @@
<script>
import Marker from "model/marker";
import Subject from "model/subject";
import Subject, { MaxLength as SubjectMaxLength } from "model/subject";
import { rules } from "common/form";
import PConfirmDialog from "component/confirm/dialog.vue";
import PActionMenu from "component/action/menu.vue";
@ -127,6 +127,8 @@ export default {
disabled: !this.$config.feature("edit"),
config: this.$config.values,
readonly: this.$config.get("readonly"),
rules,
SubjectMaxLength,
confirm: {
visible: false,
model: new Marker(),
@ -149,13 +151,6 @@ export default {
scrollStrategy: "reposition",
origin: "auto",
},
textRule: (v) => {
if (!v || !v.length) {
return this.$gettext("Name");
}
return v.length <= this.$config.get("clip") || this.$gettext("Name too long");
},
};
},
watch: {

View file

@ -246,7 +246,7 @@
:menu-props="markerMenuProps"
:list-props="chipListProps"
:readonly="markersBusy || !!m.SubjUID"
:rules="rules.text(false, 0, $config.get('clip'), $gettext('Name'))"
:rules="rules.text(false, 0, SubjectMaxLength.Name, $gettext('Name'))"
return-object
hide-no-data
hide-details="auto"
@ -463,7 +463,7 @@
<p-confirm-dialog
:visible="discardDialog.visible"
icon="mdi-alert-circle-outline"
:text="$gettext('Discard unsaved changes?')"
:text="discardDialogText"
:action="$gettext('Discard')"
@close="onDiscardCancel"
@confirm="onDiscardConfirm"
@ -487,8 +487,10 @@ import { $faceMarkers } from "common/face-markers";
import * as media from "common/media";
import typeaheadCache from "common/typeahead-cache";
import { rules } from "common/form";
import { Album } from "model/album";
import { Album, MaxLength as AlbumMaxLength } from "model/album";
import { MaxLength as LabelMaxLength } from "model/label";
import { MaxLength as PhotoMaxLength } from "model/photo";
import { MaxLength as SubjectMaxLength } from "model/subject";
import PMap from "component/map.vue";
import PMetaDatetimeDialog from "component/meta/datetime/dialog.vue";
import PMetaCameraDialog from "component/meta/camera/dialog.vue";
@ -529,6 +531,7 @@ export default {
featPeople: this.$config.feature("people"),
featPlaces: this.$config.feature("places"),
rules,
SubjectMaxLength,
dateTimeDialog: false,
cameraDialog: false,
locationDialog: false,
@ -925,6 +928,19 @@ export default {
}
return this.getFieldValue(this.editingField) !== (this.editOriginal ?? "");
},
// Discard-dialog body text. The default "Discard unsaved changes?"
// string covers marker drafts, typed combobox text, and chip
// removals (the existing pending-state shapes). When the only
// pending state is an overlength inline edit (length gate fired,
// editor still open with invalid text) the message shifts to
// "Discard invalid changes?" so the user understands which kind
// of change they're abandoning.
discardDialogText() {
if (this.hasPendingInlineOverflow() && !this.hasPendingNonOverflowEdit()) {
return this.$gettext("Discard invalid changes?");
}
return this.$gettext("Discard unsaved changes?");
},
// Renders the divider between the rights cluster and the Notes row.
// For read-only users we drop it when either side is empty so it
// doesn't appear as an orphan line.
@ -1545,16 +1561,31 @@ export default {
this.addNameDialog = { visible: false, markerUid: "", name: "" };
}
},
// Inline text fields (title/caption/subject/...) are excluded on purpose:
// onInlineFieldBlur() auto-commits them before any navigation source can
// fire, so they can never have pending state at nav time. Chip-section
// removals (`chipState.<field>.removals`) ARE counted here because the
// user can see and toggle them, but `confirmDiscardPending` auto-commits
// them before checking this by the time the dialog gate runs they're
// already gone. The remaining staged inputs that DO open the dialog are
// marker drafts, typed-but-uncommitted combobox text, and the open
// Add-name confirmation.
// Inline text fields (title/caption/subject/...) are usually auto-committed
// by onInlineFieldBlur() before any navigation source can fire, so they
// typically have no pending state at nav time. The exception is overflow:
// confirmField() short-circuits on a value above the field's maxLength
// (toasting the per-label "X is too long" error and keeping the editor
// open), which leaves an invalid edit pending. hasPendingInlineOverflow()
// detects that case so the navigation guard can surface the discard
// dialog instead of letting the typed value silently vanish.
//
// Chip-section removals (`chipState.<field>.removals`) ARE counted here
// because the user can see and toggle them, but `confirmDiscardPending`
// auto-commits them before checking this by the time the dialog gate
// runs they're already gone. The remaining staged inputs that DO open
// the dialog are marker drafts, typed-but-uncommitted combobox text,
// and the open Add-name confirmation.
hasPendingEdit() {
return this.hasPendingInlineOverflow() || this.hasPendingNonOverflowEdit();
},
// Pending state that doesn't come from an overlength inline editor
// i.e. marker drafts, typed-but-uncommitted combobox text, chip
// removals, and the Add-name dialog. Split out so discardDialogText
// can choose its message based on whether the only blocker is an
// invalid inline edit ( "Discard invalid changes?") or any of the
// other shapes ( "Discard unsaved changes?").
hasPendingNonOverflowEdit() {
for (const uid of Object.keys(this.markerDrafts)) {
const d = this.markerDrafts[uid];
if (!d) {
@ -1576,6 +1607,24 @@ export default {
// input until the user picks Add or Cancel.
return !!(this.addNameDialog && this.addNameDialog.visible);
},
// hasPendingInlineOverflow returns true when an inline text editor is
// open and the current value exceeds the field's cap. Pairs with the
// length gate in confirmField() that gate keeps the editor open so
// the user can fix the value; this method ensures navigation sources
// (next/prev arrows, sidebar close, lightbox dismiss) surface the
// discard dialog instead of silently swapping the photo out from under
// the invalid edit.
hasPendingInlineOverflow() {
if (!this.editingField || !this.photo) {
return false;
}
const fieldDef = this.fieldRegistry[this.editingField];
if (!fieldDef || !(fieldDef.maxLength > 0)) {
return false;
}
const currentValue = fieldDef.read(this.photo);
return typeof currentValue === "string" && currentValue.length > fieldDef.maxLength;
},
// Fire-and-forget commit of any pending chip removals. Mirrors the
// inline-text auto-commit on blur: the user's intent (clicking ×) is
// honored on navigation/close instead of being silently discarded.
@ -1666,6 +1715,21 @@ export default {
}
const field = this.editingField;
const fieldDef = this.fieldRegistry[field];
// Length gate before save the inline editor has no parent v-form,
// so the rendered `:rules` only paint an inline error; without this
// imperative check, photo.update() would persist (or silently drop)
// overlength input. Mirrors the addLabelImmediate / addAlbumImmediate
// pattern below.
if (fieldDef && fieldDef.maxLength > 0) {
const currentValue = fieldDef.read(this.photo);
if (typeof currentValue === "string" && currentValue.length > fieldDef.maxLength) {
this.$notify.error(this.$gettext("%{s} is too long", { s: fieldDef.label }));
return;
}
}
this.editingField = null;
this.editOriginal = null;
@ -1904,7 +1968,7 @@ export default {
if (!name) {
return false;
}
if (name.length > this.$config.get("clip")) {
if (name.length > LabelMaxLength.Name) {
this.$notify.error(this.$gettext("Name too long"));
return false;
}
@ -1956,7 +2020,7 @@ export default {
if (!title) {
return false;
}
if (title.length > this.$config.get("clip")) {
if (title.length > AlbumMaxLength.Title) {
this.$notify.error(this.$gettext("Name too long"));
return false;
}
@ -2010,7 +2074,7 @@ export default {
return;
}
if (search.length > this.$config.get("clip")) {
if (search.length > LabelMaxLength.Name) {
this.$notify.error(this.$gettext("Name too long"));
return;
}
@ -2086,7 +2150,7 @@ export default {
return;
}
if (search.length > this.$config.get("clip")) {
if (search.length > AlbumMaxLength.Title) {
this.$notify.error(this.$gettext("Name too long"));
return;
}

View file

@ -7,6 +7,15 @@ import Collection from "model/collection";
export let BatchSize = 180;
// MaxLength mirrors the backend VARCHAR caps on internal/entity/album.go
// so UI validation matches what the server persists.
export const MaxLength = Object.freeze({
Title: 160,
Location: 160,
Caption: 1024,
Description: 2048,
});
// Album models server-managed photo collections, including manual albums and moments.
export class Album extends Collection {
getDefaults() {

View file

@ -6,6 +6,14 @@ import Collection from "model/collection";
export let BatchSize = 180;
// MaxLength mirrors the backend VARCHAR caps on internal/entity/label.go
// so UI validation matches what the server persists.
export const MaxLength = Object.freeze({
Name: 160,
Description: 2048,
Notes: 1024,
});
// Label models user-defined keywords and AI-generated tags.
export class Label extends Collection {
getDefaults() {

View file

@ -8,6 +8,12 @@ const SubjPerson = "person";
export let BatchSize = 60;
// MaxLength mirrors the backend VARCHAR caps on internal/entity/subject.go
// so UI validation matches what the server persists.
export const MaxLength = Object.freeze({
Name: 160,
});
// Subject tracks people and other recognizable subjects derived from face/marker data.
export class Subject extends Collection {
getDefaults() {

View file

@ -48,10 +48,9 @@
<v-text-field
v-if="m.SubjUID"
v-model="m.Name"
:rules="[textRule]"
:rules="rules.text(true, 0, SubjectMaxLength.Name, $gettext('Name'))"
:readonly="readonly"
autocomplete="off"
hide-details
single-line
density="comfortable"
class="input-name pa-0 ma-0"
@ -99,6 +98,8 @@
<script>
import Face from "model/face";
import RestModel from "model/rest";
import { MaxLength as SubjectMaxLength } from "model/subject";
import { rules } from "common/form";
import { MaxItems } from "common/clipboard";
import $notify from "common/notify";
import { ClickLong, ClickShort, Input, InputInvalid } from "common/input";
@ -130,6 +131,8 @@ export default {
return {
view: "all",
config: this.$config.values,
rules,
SubjectMaxLength,
subscriptions: [],
listen: false,
dirty: false,
@ -166,13 +169,6 @@ export default {
scrollStrategy: "reposition",
origin: "auto",
},
textRule: (v) => {
if (!v || !v.length) {
return this.$gettext("Name");
}
return v.length <= this.$config.get("clip") || this.$gettext("Text too long");
},
};
},
computed: {

View file

@ -491,6 +491,35 @@ describe("component/photo/batch-edit", () => {
expect(wrapper.vm.$notify.error).toHaveBeenCalledWith("Failed to save changes");
expect(wrapper.vm.saving).toBe(false);
});
// Vue 3's component proxy intercepts $refs reads; standard assignment
// doesn't stick. Inject the mock into the internal instance's refs
// object (vm.$.refs) so save()'s `this.$refs.form.validate` resolves
// to the spy.
const overrideFormRef = (vm, validate) => {
vm.$.refs.form = { validate };
};
it("blocks the batch save and notifies when form validation fails", async () => {
const validate = vi.fn().mockResolvedValue({ valid: false });
overrideFormRef(wrapper.vm, validate);
await wrapper.vm.save(false);
expect(validate).toHaveBeenCalled();
expect(mockBatchInstance.save).not.toHaveBeenCalled();
expect(wrapper.vm.$notify.error).toHaveBeenCalledWith("Changes could not be saved");
});
it("proceeds with the batch save when form validation passes", async () => {
const validate = vi.fn().mockResolvedValue({ valid: true });
overrideFormRef(wrapper.vm, validate);
await wrapper.vm.save(false);
expect(validate).toHaveBeenCalled();
expect(mockBatchInstance.save).toHaveBeenCalled();
});
});
describe("Form Field Updates", () => {
@ -733,4 +762,49 @@ describe("component/photo/batch-edit", () => {
expect(wrapper.vm.getIcon("input-field", "Altitude")).toBe("mdi-close-circle");
});
});
describe("Validation Rules", () => {
// Locks each inline-text field to the backend VARCHAR cap on
// PhotoMaxLength so a future bare $config.get('clip') regression
// (which would cap at 160 instead of the real ceiling) fails here.
it("exposes PhotoMaxLength and validates each inline-text field at its real cap", () => {
const m = wrapper.vm.PhotoMaxLength;
expect(m).toEqual({
Title: 200,
Caption: 4096,
Subject: 1024,
Artist: 1024,
Copyright: 1024,
License: 1024,
Keywords: 2048,
Notes: 2048,
});
const cases = [
["Title", m.Title],
["Caption", m.Caption],
["Subject", m.Subject],
["Copyright", m.Copyright],
["Artist", m.Artist],
["License", m.License],
];
for (const [label, cap] of cases) {
const [, rule] = wrapper.vm.rules.text(false, 0, cap, label);
expect(rule("a".repeat(cap))).toBe(true);
expect(rule("a".repeat(cap + 1))).toBe(`${label} is too long`);
}
});
// Batch fields can be in Mixed state — getFieldData binds the empty
// string for mixed text inputs. rules.text short-circuits on the
// empty value via maxLen's null-safety, so the rule passes without
// a Mixed-aware branch in the form.
it("passes the rule on empty Mixed-state values", () => {
const [, rule] = wrapper.vm.rules.text(false, 0, wrapper.vm.PhotoMaxLength.Caption, "Caption");
expect(rule("")).toBe(true);
expect(rule(null)).toBe(true);
expect(rule(undefined)).toBe(true);
});
});
});

View file

@ -439,6 +439,38 @@ describe("component/photo/edit/details", () => {
expect(wrapper.emitted("close")).toBeTruthy();
});
// Vue 3's component proxy intercepts $refs reads; standard assignment
// doesn't stick. The internal instance (vm.$) exposes the underlying
// `refs` object that the proxy reads from, so injecting the mock
// there lets `this.$refs.form.validate` resolve to the spy when
// save() runs.
const overrideFormRef = (vm, validate) => {
vm.$.refs.form = { validate };
};
it("blocks save and notifies when form validation fails", async () => {
wrapper.vm.invalidDate = false;
const validate = vi.fn().mockResolvedValue({ valid: false });
overrideFormRef(wrapper.vm, validate);
await wrapper.vm.save(false);
expect(validate).toHaveBeenCalled();
expect(wrapper.vm.view.model.update).not.toHaveBeenCalled();
expect(wrapper.vm.$notify.error).toHaveBeenCalledWith("Changes could not be saved");
});
it("proceeds with save when form validation passes", async () => {
wrapper.vm.invalidDate = false;
const validate = vi.fn().mockResolvedValue({ valid: true });
overrideFormRef(wrapper.vm, validate);
await wrapper.vm.save(false);
expect(validate).toHaveBeenCalled();
expect(wrapper.vm.view.model.update).toHaveBeenCalled();
});
it("validates text length via the centralized rules.text factory", () => {
// After migrating the per-component textRule to the shared
// common/form rules.text(...) factory, each :rules attribute
@ -458,6 +490,43 @@ describe("component/photo/edit/details", () => {
expect(maxLenRule(undefined)).toBe(true);
expect(maxLenRule({ Name: "obj" })).toBe(true);
});
// Per-field caps must come from PhotoMaxLength (backend VARCHAR), not
// the historical $config.get('clip') = 160 ceiling. These cases lock
// each field's exposure to its real backend cap so a future bare
// `clip` regression fails loudly here.
it("exposes PhotoMaxLength and wires the per-field caps", () => {
const m = wrapper.vm.PhotoMaxLength;
expect(m).toEqual({
Title: 200,
Caption: 4096,
Subject: 1024,
Artist: 1024,
Copyright: 1024,
License: 1024,
Keywords: 2048,
Notes: 2048,
});
const cases = [
["Title", "Title", m.Title],
["Caption", "Caption", m.Caption],
["Subject", "Subject", m.Subject],
["Copyright", "Copyright", m.Copyright],
["Artist", "Artist", m.Artist],
["License", "License", m.License],
["Keywords", "Keywords", m.Keywords],
["Notes", "Notes", m.Notes],
];
for (const [label, errorLabel, cap] of cases) {
const [, rule] = wrapper.vm.rules.text(false, 0, cap, label);
// At the cap → passes (200 valid for Title, 4096 for Caption, …).
expect(rule("a".repeat(cap))).toBe(true);
// One char beyond → label-specific error.
expect(rule("a".repeat(cap + 1))).toBe(`${errorLabel} is too long`);
}
});
});
describe("photo viewing", () => {

View file

@ -83,55 +83,58 @@ describe("component/photo/edit/labels", () => {
vi.restoreAllMocks();
});
describe("nameRule", () => {
describe("name validation rule", () => {
// Regression: the v-combobox initializes with newLabelModel = null
// and validates :rules on mount, so nameRule must tolerate null,
// strings (free-text entry), and item objects (return-object).
// Pre-fix the rule did `v.length` and threw "Cannot read
// properties of null (reading 'length')" on every dialog open.
// and Vuetify validates :rules on mount, so the rule must tolerate
// null, strings (free-text entry), and item objects (return-object).
// The shared rules.text(...) factory in common/form.js short-circuits
// on non-string input via maxLen, so all three cases pass without
// throwing the historical "Cannot read properties of null" error.
//
// The global $config mock in tests/vitest/setup.js returns `false`
// for every key, so we stub `get("clip")` per test to exercise the
// realistic numeric limit. 160 matches the production default.
const stubClip = (wrapper, limit = 160) => {
wrapper.vm.$config.get = (key) => (key === "clip" ? limit : false);
// The cap now comes from LabelMaxLength.Name (160, backend VARCHAR)
// — not the legacy $config.get('clip') 160 ceiling — so a backend
// bump propagates without per-component edits.
const maxLen = (wrapper, cap = wrapper.vm.LabelMaxLength.Name) => {
const [, rule] = wrapper.vm.rules.text(false, 0, cap, "Name");
return rule;
};
it("exposes LabelMaxLength.Name from the model", () => {
const { wrapper } = mountPhotoLabels();
expect(wrapper.vm.LabelMaxLength).toEqual({ Name: 160, Description: 2048, Notes: 1024 });
});
it("returns valid for null without throwing (initial / cleared combobox)", () => {
const { wrapper } = mountPhotoLabels();
stubClip(wrapper);
expect(wrapper.vm.nameRule(null)).toBe(true);
expect(maxLen(wrapper)(null)).toBe(true);
});
it("returns valid for undefined", () => {
const { wrapper } = mountPhotoLabels();
stubClip(wrapper);
expect(wrapper.vm.nameRule(undefined)).toBe(true);
expect(maxLen(wrapper)(undefined)).toBe(true);
});
it("validates a short typed string as valid", () => {
const { wrapper } = mountPhotoLabels();
stubClip(wrapper);
expect(wrapper.vm.nameRule("hello")).toBe(true);
expect(maxLen(wrapper)("hello")).toBe(true);
});
it("returns the error message when a typed string exceeds clip", () => {
it("returns the error message when a typed string exceeds the cap", () => {
const { wrapper } = mountPhotoLabels();
stubClip(wrapper, 5);
expect(wrapper.vm.nameRule("toolong")).toBe("Name too long");
expect(maxLen(wrapper, 5)("toolong")).toBe("Name is too long");
});
it("uses .Name for selected item objects (return-object combobox)", () => {
it("short-circuits return-object combobox selections (objects pass)", () => {
// Backend caps existing labels at LabelMaxLength.Name (160), so
// every item-object selection already fits — the factory's
// maxLen short-circuit on non-string input is the correct
// behavior here. Trade-off: a synthetic object with an
// overlength .Name would no longer surface a frontend error,
// but in production such objects can't reach the dropdown.
const { wrapper } = mountPhotoLabels();
stubClip(wrapper, 10);
expect(wrapper.vm.nameRule({ Name: "Flower" })).toBe(true);
expect(wrapper.vm.nameRule({ Name: "a really long name" })).toBe("Name too long");
});
it("treats item objects with no .Name as zero-length (valid)", () => {
const { wrapper } = mountPhotoLabels();
stubClip(wrapper, 10);
expect(wrapper.vm.nameRule({})).toBe(true);
expect(maxLen(wrapper, 10)({ Name: "Flower" })).toBe(true);
expect(maxLen(wrapper, 10)({ Name: "a really long name" })).toBe(true);
expect(maxLen(wrapper, 10)({})).toBe(true);
});
});

View file

@ -1443,10 +1443,13 @@ describe("PSidebarInfo component", () => {
expect(w.vm.editingField).toBeNull();
});
// Inline text fields (title/caption/subject/...) are intentionally NOT
// tracked by hasPendingEdit: onInlineFieldBlur() auto-commits them before
// any nav source can fire, so they can never be pending at nav time.
it("should NOT report hasPendingEdit for a dirty inline text field (auto-commits on blur)", () => {
// Inline text fields (title/caption/subject/...) are usually NOT tracked
// by hasPendingEdit because onInlineFieldBlur() auto-commits them before
// any nav source can fire. The carve-out is hasPendingInlineOverflow:
// confirmField() short-circuits on a value above the field's maxLength,
// leaving the editor open with invalid text — that case (covered below)
// intentionally DOES report pending so the discard dialog can fire.
it("should NOT report hasPendingEdit for a dirty inline text field within the cap (auto-commits on blur)", () => {
const photo = {
...mockPhoto,
Title: "Changed",
@ -1460,6 +1463,53 @@ describe("PSidebarInfo component", () => {
expect(w.vm.hasPendingEdit()).toBe(false);
});
// Navigation guard: if the user typed past the per-field cap (e.g. 5000-
// char Caption against the 4096 cap), confirmField() keeps the editor
// open instead of persisting the value. Without the overflow branch in
// hasPendingEdit, the next/prev arrow would silently swap the photo
// out from under the invalid edit; with it, confirmDiscardPending() can
// surface the discard dialog and the "Discard invalid changes?" label
// tells the user which kind of edit they're abandoning.
it("should report hasPendingEdit when an inline editor's value exceeds the field cap", () => {
const photo = {
...mockPhoto,
Caption: "x".repeat(5000), // PhotoMaxLength.Caption is 4096
wasChanged: () => true,
};
const w = mountSidebar({
props: { modelValue: mockModel, photo, canEdit: true, context: contexts.Photos },
global: { stubs: { PMap: true } },
});
// No active editor → not pending.
expect(w.vm.hasPendingEdit()).toBe(false);
// Editing the overlength field → pending; discard label flips.
w.vm.editingField = "caption";
expect(w.vm.hasPendingInlineOverflow()).toBe(true);
expect(w.vm.hasPendingEdit()).toBe(true);
expect(w.vm.discardDialogText).toBe("Discard invalid changes?");
});
it("should keep the generic discard text when overflow coexists with another pending edit", () => {
const photo = {
...mockPhoto,
Caption: "x".repeat(5000),
wasChanged: () => true,
};
const w = mountSidebar({
props: { modelValue: mockModel, photo, canEdit: true, context: contexts.Photos },
global: { stubs: { PMap: true } },
});
w.vm.editingField = "caption";
w.vm.chipState.labels.removals = [{ Label: { UID: "lbl1" } }];
expect(w.vm.hasPendingInlineOverflow()).toBe(true);
expect(w.vm.hasPendingNonOverflowEdit()).toBe(true);
expect(w.vm.discardDialogText).toBe("Discard unsaved changes?");
});
// Additions go through the instant-save path (addLabelImmediate /
// addAlbumImmediate), so they never enter chipState — only batched
// removals can leave the sidebar in a pending state.
@ -1951,6 +2001,31 @@ describe("PSidebarInfo component", () => {
expect(photo.update).not.toHaveBeenCalled();
expect(w.vm.$notify.error).not.toHaveBeenCalled();
});
it("blocks save and keeps the editor open when the value exceeds the field cap", () => {
// Inline editor has no parent v-form; the rendered `:rules` only
// paint an inline error. The imperative length check inside
// confirmField is the actual gate — without it photo.update()
// would post overlength input and the "successfully saved" toast
// would fire while the server silently drops the value.
const photo = buildInlineEditPhoto();
const w = mountSidebar({
props: { modelValue: mockModel, photo, canEdit: true, context: contexts.Photos },
global: { stubs: { PMap: true } },
});
// Copyright cap is 1024 (PhotoMaxLength.Copyright); push past it.
photo.Details = { Copyright: "x".repeat(1025) };
w.vm.editingField = "copyright";
w.vm.confirmField();
// Editor stays open so the user can fix the overlong input.
expect(w.vm.editingField).toBe("copyright");
expect(photo.update).not.toHaveBeenCalled();
expect(w.vm.$notify.error).toHaveBeenCalledTimes(1);
});
});
// Labels

View file

@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import "../fixtures";
import { Album, BatchSize } from "model/album";
import { Album, BatchSize, MaxLength } from "model/album";
describe("model/album", () => {
let originalBatchSize;
@ -13,6 +13,18 @@ describe("model/album", () => {
Album.setBatchSize(originalBatchSize);
});
// Pins per-field caps to the backend VARCHAR columns on internal/entity/album.go
// so client-side validation moves in lockstep with the server.
it("MaxLength mirrors the backend VARCHAR caps", () => {
expect(MaxLength).toEqual({
Title: 160,
Location: 160,
Caption: 1024,
Description: 2048,
});
expect(Object.isFrozen(MaxLength)).toBe(true);
});
it("should get route view", () => {
const values = { ID: 5, Title: "Christmas 2019", Slug: "christmas-2019" };
const album = new Album(values);

View file

@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import "../fixtures";
import { Label, BatchSize } from "model/label";
import { Label, BatchSize, MaxLength } from "model/label";
describe("model/label", () => {
let originalBatchSize;
@ -13,6 +13,18 @@ describe("model/label", () => {
Label.setBatchSize(originalBatchSize);
});
// Pins per-field caps to the backend VARCHAR columns on internal/entity/label.go.
// A backend bump must move the frontend cap in lockstep so client-side
// validation matches what the server persists.
it("MaxLength mirrors the backend VARCHAR caps", () => {
expect(MaxLength).toEqual({
Name: 160,
Description: 2048,
Notes: 1024,
});
expect(Object.isFrozen(MaxLength)).toBe(true);
});
it("should get route view", () => {
const values = { ID: 5, UID: "ABC123", Name: "Black Cat", Slug: "black-cat" };
const label = new Label(values);

View file

@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import "../fixtures";
import { Subject, BatchSize } from "model/subject";
import { Subject, BatchSize, MaxLength } from "model/subject";
describe("model/subject", () => {
let originalBatchSize;
@ -13,6 +13,15 @@ describe("model/subject", () => {
Subject.setBatchSize(originalBatchSize);
});
// Pins per-field caps to the backend VARCHAR columns on internal/entity/subject.go
// so client-side validation moves in lockstep with the server.
it("MaxLength mirrors the backend VARCHAR caps", () => {
expect(MaxLength).toEqual({
Name: 160,
});
expect(Object.isFrozen(MaxLength)).toBe(true);
});
it("should get face defaults", () => {
const values = {};
const subject = new Subject(values);