mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
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:
parent
38e7022765
commit
04f5dd922f
19 changed files with 483 additions and 118 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue