Skip to content

Commit d90bf1f

Browse files
committed
Frontend: Consolidate sidebar chip-state for labels/albums photoprism#4966
Replace four flat arrays (pendingLabel{Additions,Removals}, pendingAlbum{Additions,Removals}) with one chipState object keyed by field, plus four generic helpers (isChipPendingRemoval, togglePendingChipRemoval, removePendingChipAdd, resetChipState) that take field + key. addPendingLabel / addPendingAlbum stay specialized because their validation differs (string vs object input). Also: route the chip-section header check/pencil through the existing PSidebarInlineToolbar; add meta-labels / meta-albums classes for parity with the per-field hooks; surface a notify.error on confirmLabels / confirmAlbums rejection (was silent); document the local-patch vs evict-and-refind asymmetry between addLabel/removeLabel and the raw album-membership API; suppress the empty .metadata__chips container when entering edit mode with zero existing chips and zero pending additions, and hide the redundant 'Add label/album' prompt while editing. Tests updated to the new state shape and method signatures (140 sidebar/info tests still pass).
1 parent f12109f commit d90bf1f

2 files changed

Lines changed: 153 additions & 155 deletions

File tree

frontend/src/component/sidebar/info.vue

Lines changed: 93 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -238,52 +238,44 @@
238238

239239
<template v-if="!restrictedRole && (editingField === 'labels' || labels.length > 0 || isEditable)">
240240
<v-divider class="my-4"></v-divider>
241-
<v-list-item class="metadata__item">
241+
<v-list-item class="metadata__item meta-labels">
242242
<div class="text-subtitle-2">{{ $gettext("Labels") }}</div>
243243
<template v-if="isEditable" #append>
244-
<v-icon
245-
v-if="editingField === 'labels'"
246-
icon="mdi-check"
247-
size="small"
248-
class="meta-inline-confirm"
249-
@mousedown.prevent
250-
@click.stop="confirmLabels"
251-
></v-icon>
252-
<v-icon v-else icon="mdi-pencil-outline" size="small" class="meta-inline-pencil" @click.stop="startChipEditing('labels')"></v-icon>
244+
<p-sidebar-inline-toolbar :editing="editingField === 'labels'" @confirm="confirmLabels" @start="startChipEditing('labels')" />
253245
</template>
254246
</v-list-item>
255-
<v-list-item v-if="labels.length > 0 || editingField === 'labels'" class="metadata__item metadata__chips">
247+
<v-list-item v-if="labels.length > 0 || chipState.labels.additions.length > 0" class="metadata__item metadata__chips meta-labels">
256248
<div class="d-flex flex-wrap ga-1">
257249
<span
258250
v-for="l in labels"
259251
:key="l.Label.UID"
260252
class="meta-chip meta-chip--primary"
261-
:class="{ 'meta-chip--pending-remove': isLabelPendingRemoval(l) }"
262-
@click.stop.prevent="editingField !== 'labels' ? navigateToLabel(l.Label) : toggleLabelRemoval(l)"
253+
:class="{ 'meta-chip--pending-remove': isChipPendingRemoval('labels', l.Label.ID) }"
254+
@click.stop.prevent="editingField !== 'labels' ? navigateToLabel(l.Label) : togglePendingChipRemoval('labels', l.Label.ID)"
263255
>
264256
{{ l.Label.Name }}
265257
<v-icon
266258
v-if="editingField === 'labels'"
267-
:icon="isLabelPendingRemoval(l) ? 'mdi-undo' : 'mdi-close-circle'"
259+
:icon="isChipPendingRemoval('labels', l.Label.ID) ? 'mdi-undo' : 'mdi-close-circle'"
268260
size="x-small"
269261
class="ml-1"
270262
></v-icon>
271263
</span>
272264
<span
273-
v-for="name in pendingLabelAdditions"
265+
v-for="name in chipState.labels.additions"
274266
:key="'add-' + name"
275267
class="meta-chip meta-chip--pending-add"
276-
@click.stop.prevent="removePendingLabelAdd(name)"
268+
@click.stop.prevent="removePendingChipAdd('labels', name)"
277269
>
278270
{{ name }}
279271
<v-icon icon="mdi-close-circle" size="x-small" class="ml-1"></v-icon>
280272
</span>
281273
</div>
282274
</v-list-item>
283-
<v-list-item v-else-if="isEditable" class="metadata__item">
275+
<v-list-item v-else-if="isEditable && editingField !== 'labels'" class="metadata__item meta-labels">
284276
<div class="meta-add-prompt" @click.stop="startChipEditing('labels')">{{ $gettext("Add label") }}</div>
285277
</v-list-item>
286-
<v-list-item v-if="editingField === 'labels'" class="metadata__item">
278+
<v-list-item v-if="editingField === 'labels'" class="metadata__item meta-labels">
287279
<v-combobox
288280
:key="chipKey"
289281
v-model="chipInput"
@@ -309,52 +301,44 @@
309301

310302
<template v-if="!restrictedRole && (editingField === 'albums' || albums.length > 0 || isEditable)">
311303
<v-divider class="my-4"></v-divider>
312-
<v-list-item class="metadata__item">
304+
<v-list-item class="metadata__item meta-albums">
313305
<div class="text-subtitle-2">{{ $gettext("Albums") }}</div>
314306
<template v-if="isEditable" #append>
315-
<v-icon
316-
v-if="editingField === 'albums'"
317-
icon="mdi-check"
318-
size="small"
319-
class="meta-inline-confirm"
320-
@mousedown.prevent
321-
@click.stop="confirmAlbums"
322-
></v-icon>
323-
<v-icon v-else icon="mdi-pencil-outline" size="small" class="meta-inline-pencil" @click.stop="startChipEditing('albums')"></v-icon>
307+
<p-sidebar-inline-toolbar :editing="editingField === 'albums'" @confirm="confirmAlbums" @start="startChipEditing('albums')" />
324308
</template>
325309
</v-list-item>
326-
<v-list-item v-if="albums.length > 0 || editingField === 'albums'" class="metadata__item metadata__chips">
310+
<v-list-item v-if="albums.length > 0 || chipState.albums.additions.length > 0" class="metadata__item metadata__chips meta-albums">
327311
<div class="d-flex flex-wrap ga-1">
328312
<span
329313
v-for="a in albums"
330314
:key="a.UID"
331315
class="meta-chip meta-chip--primary"
332-
:class="{ 'meta-chip--pending-remove': isAlbumPendingRemoval(a) }"
333-
@click.stop.prevent="editingField !== 'albums' ? navigateToAlbum(a) : toggleAlbumRemoval(a)"
316+
:class="{ 'meta-chip--pending-remove': isChipPendingRemoval('albums', a.UID) }"
317+
@click.stop.prevent="editingField !== 'albums' ? navigateToAlbum(a) : togglePendingChipRemoval('albums', a.UID)"
334318
>
335319
{{ a.Title }}
336320
<v-icon
337321
v-if="editingField === 'albums'"
338-
:icon="isAlbumPendingRemoval(a) ? 'mdi-undo' : 'mdi-close-circle'"
322+
:icon="isChipPendingRemoval('albums', a.UID) ? 'mdi-undo' : 'mdi-close-circle'"
339323
size="x-small"
340324
class="ml-1"
341325
></v-icon>
342326
</span>
343327
<span
344-
v-for="a in pendingAlbumAdditions"
328+
v-for="a in chipState.albums.additions"
345329
:key="'add-' + a.UID"
346330
class="meta-chip meta-chip--pending-add"
347-
@click.stop.prevent="removePendingAlbumAdd(a)"
331+
@click.stop.prevent="removePendingChipAdd('albums', a.UID)"
348332
>
349333
{{ a.Title }}
350334
<v-icon icon="mdi-close-circle" size="x-small" class="ml-1"></v-icon>
351335
</span>
352336
</div>
353337
</v-list-item>
354-
<v-list-item v-else-if="isEditable" class="metadata__item">
338+
<v-list-item v-else-if="isEditable && editingField !== 'albums'" class="metadata__item meta-albums">
355339
<div class="meta-add-prompt" @click.stop="startChipEditing('albums')">{{ $gettext("Add to album") }}</div>
356340
</v-list-item>
357-
<v-list-item v-if="editingField === 'albums'" class="metadata__item">
341+
<v-list-item v-if="editingField === 'albums'" class="metadata__item meta-albums">
358342
<v-autocomplete
359343
:key="chipKey"
360344
v-model="chipInput"
@@ -539,10 +523,14 @@ export default {
539523
chipKey: 0,
540524
labelOptions: [],
541525
albumOptions: [],
542-
pendingLabelRemovals: [],
543-
pendingLabelAdditions: [],
544-
pendingAlbumRemovals: [],
545-
pendingAlbumAdditions: [],
526+
// Pending chip mutations staged during edit mode. Labels are keyed by
527+
// Label.ID for removals and by typed name for additions; albums are
528+
// keyed by Album.UID for removals and stored as full album objects
529+
// for additions (the title is read off the object at confirm time).
530+
chipState: {
531+
labels: { additions: [], removals: [] },
532+
albums: { additions: [], removals: [] },
533+
},
546534
markerDrafts: {},
547535
markerNameRule: (v) => !v || v.length <= this.$config.get("clip") || this.$gettext("Text too long"),
548536
markerMenuProps: {
@@ -1044,8 +1032,7 @@ export default {
10441032
if (!d) continue;
10451033
if (this.unwrapMarkerName(d.current).trim() !== (d.original || "").trim()) return true;
10461034
}
1047-
if (this.pendingLabelAdditions.length || this.pendingLabelRemovals.length) return true;
1048-
if (this.pendingAlbumAdditions.length || this.pendingAlbumRemovals.length) return true;
1035+
if (Object.values(this.chipState).some((s) => s.additions.length || s.removals.length)) return true;
10491036
return false;
10501037
},
10511038
// Async guard used by the lightbox before closing / hiding / navigating.
@@ -1125,10 +1112,7 @@ export default {
11251112
this.editingField = null;
11261113
this.editOriginal = null;
11271114
this._editStartedAt = null;
1128-
this.pendingLabelRemovals = [];
1129-
this.pendingLabelAdditions = [];
1130-
this.pendingAlbumRemovals = [];
1131-
this.pendingAlbumAdditions = [];
1115+
this.resetChipState();
11321116
},
11331117
// Blur handler for inline text fields (title/caption/subject/artist/
11341118
// copyright/license/keywords/notes). Commits the edit instead of
@@ -1209,6 +1193,36 @@ export default {
12091193
this.chipSearch = "";
12101194
this.chipKey++;
12111195
},
1196+
// Generic chip-state helpers. Field is "labels" or "albums"; the key is
1197+
// whatever uniquely identifies a chip in that field's domain (Label.ID
1198+
// for labels, Album.UID for albums on the removals side; the typed name
1199+
// for label additions, Album.UID for album additions).
1200+
isChipPendingRemoval(field, key) {
1201+
const state = this.chipState[field];
1202+
return Boolean(state && key != null && state.removals.includes(key));
1203+
},
1204+
togglePendingChipRemoval(field, key) {
1205+
const state = this.chipState[field];
1206+
if (!state || key == null) return;
1207+
const idx = state.removals.indexOf(key);
1208+
if (idx >= 0) {
1209+
state.removals.splice(idx, 1);
1210+
} else {
1211+
state.removals.push(key);
1212+
}
1213+
},
1214+
removePendingChipAdd(field, key) {
1215+
const state = this.chipState[field];
1216+
if (!state || key == null) return;
1217+
const idx = field === "labels" ? state.additions.indexOf(key) : state.additions.findIndex((a) => a.UID === key);
1218+
if (idx >= 0) state.additions.splice(idx, 1);
1219+
},
1220+
resetChipState() {
1221+
Object.values(this.chipState).forEach((s) => {
1222+
s.additions = [];
1223+
s.removals = [];
1224+
});
1225+
},
12121226
addPendingLabel(rawName) {
12131227
const name = (rawName || "").trim();
12141228
if (!name) return false;
@@ -1218,14 +1232,15 @@ export default {
12181232
}
12191233
const norm = this.$util.normalizeLabelTitle(name);
12201234
if (!norm) return false;
1221-
if (this.pendingLabelAdditions.some((n) => this.$util.normalizeLabelTitle(n) === norm)) return false;
1235+
const additions = this.chipState.labels.additions;
1236+
if (additions.some((n) => this.$util.normalizeLabelTitle(n) === norm)) return false;
12221237
if (this.labels.some((l) => this.$util.normalizeLabelTitle(l?.Label?.Name) === norm)) return false;
1223-
this.pendingLabelAdditions.push(name);
1238+
additions.push(name);
12241239
return true;
12251240
},
12261241
albumTitleConflicts(norm) {
12271242
if (!norm) return true;
1228-
if (this.pendingAlbumAdditions.some((a) => this.$util.normalizeLabelTitle(a?.Title) === norm)) return true;
1243+
if (this.chipState.albums.additions.some((a) => this.$util.normalizeLabelTitle(a?.Title) === norm)) return true;
12291244
if (this.albums.some((a) => this.$util.normalizeLabelTitle(a?.Title) === norm)) return true;
12301245
return false;
12311246
},
@@ -1237,12 +1252,13 @@ export default {
12371252
this.$notify.error(this.$gettext("Name too long"));
12381253
return false;
12391254
}
1255+
const additions = this.chipState.albums.additions;
12401256
if (album.UID) {
1241-
if (this.pendingAlbumAdditions.some((a) => a.UID === album.UID)) return false;
1257+
if (additions.some((a) => a.UID === album.UID)) return false;
12421258
if (this.albums.some((a) => a.UID === album.UID)) return false;
12431259
}
12441260
if (this.albumTitleConflicts(this.$util.normalizeLabelTitle(title))) return false;
1245-
this.pendingAlbumAdditions.push(album);
1261+
additions.push(album);
12461262
return true;
12471263
},
12481264
onLabelSelected(value) {
@@ -1263,54 +1279,33 @@ export default {
12631279
this.clearChipInput();
12641280
}
12651281
},
1266-
removePendingLabelAdd(name) {
1267-
const idx = this.pendingLabelAdditions.indexOf(name);
1268-
if (idx >= 0) this.pendingLabelAdditions.splice(idx, 1);
1269-
},
1270-
isLabelPendingRemoval(label) {
1271-
return this.pendingLabelRemovals.includes(label.Label.ID);
1272-
},
1273-
toggleLabelRemoval(label) {
1274-
if (!label?.Label?.ID) return;
1275-
const id = label.Label.ID;
1276-
const idx = this.pendingLabelRemovals.indexOf(id);
1277-
if (idx >= 0) {
1278-
this.pendingLabelRemovals.splice(idx, 1);
1279-
} else {
1280-
this.pendingLabelRemovals.push(id);
1281-
}
1282-
},
12831282
confirmLabels() {
12841283
if (!this.photo) {
12851284
this.editingField = null;
12861285
return;
12871286
}
12881287
1289-
const removals = this.pendingLabelRemovals.slice();
1290-
const additions = this.pendingLabelAdditions.slice();
1288+
const state = this.chipState.labels;
1289+
const removals = state.removals.slice();
1290+
const additions = state.additions.slice();
12911291
this.editingField = null;
1292-
this.pendingLabelRemovals = [];
1293-
this.pendingLabelAdditions = [];
1292+
state.removals = [];
1293+
state.additions = [];
12941294
12951295
const promises = [];
12961296
removals.forEach((id) => promises.push(this.photo.removeLabel(id)));
12971297
additions.forEach((name) => promises.push(this.photo.addLabel(name)));
12981298
1299+
// Cache freshness: photo.addLabel / removeLabel patch this.photo.Labels
1300+
// locally on success, and the backend publishes photos.updated which
1301+
// evicts the cached entry via evictCachedFromEntities — see
1302+
// model/photo.js. confirmAlbums needs an explicit evict + re-find
1303+
// because Album mutations go through raw $api.delete/post and don't
1304+
// patch this.photo.Albums; that asymmetry is intentional.
12991305
if (promises.length) {
1300-
Promise.all(promises).catch(() => {});
1301-
}
1302-
},
1303-
isAlbumPendingRemoval(album) {
1304-
return this.pendingAlbumRemovals.includes(album.UID);
1305-
},
1306-
toggleAlbumRemoval(album) {
1307-
if (!album?.UID) return;
1308-
const uid = album.UID;
1309-
const idx = this.pendingAlbumRemovals.indexOf(uid);
1310-
if (idx >= 0) {
1311-
this.pendingAlbumRemovals.splice(idx, 1);
1312-
} else {
1313-
this.pendingAlbumRemovals.push(uid);
1306+
Promise.all(promises).catch(() => {
1307+
this.$notify.error(this.$gettext("Failed to save changes"));
1308+
});
13141309
}
13151310
},
13161311
confirmAlbums() {
@@ -1319,11 +1314,12 @@ export default {
13191314
return;
13201315
}
13211316
1322-
const removals = this.pendingAlbumRemovals.slice();
1323-
const additions = this.pendingAlbumAdditions.slice();
1317+
const state = this.chipState.albums;
1318+
const removals = state.removals.slice();
1319+
const additions = state.additions.slice();
13241320
this.editingField = null;
1325-
this.pendingAlbumRemovals = [];
1326-
this.pendingAlbumAdditions = [];
1321+
state.removals = [];
1322+
state.additions = [];
13271323
13281324
const promises = [];
13291325
removals.forEach((uid) => promises.push(this.$api.delete(`albums/${uid}/photos`, { data: { photos: [this.photo.UID] } })));
@@ -1332,13 +1328,19 @@ export default {
13321328
if (promises.length) {
13331329
Promise.all(promises)
13341330
.then(() => {
1331+
// Album mutations don't patch this.photo.Albums locally and the
1332+
// backend publishes only albums.updated (not photos.updated) for
1333+
// membership changes, so we evict + re-find here so the sidebar
1334+
// reflects the saved state without waiting for navigation.
13351335
Photo.evictCache(this.photo.UID);
13361336
return this.photo.find(this.photo.UID);
13371337
})
13381338
.then((photo) => {
13391339
this.photo.setValues(photo.getValues());
13401340
})
1341-
.catch(() => {});
1341+
.catch(() => {
1342+
this.$notify.error(this.$gettext("Failed to save changes"));
1343+
});
13421344
}
13431345
},
13441346
onAlbumSelected(value) {
@@ -1396,10 +1398,6 @@ export default {
13961398
this.clearChipInput();
13971399
});
13981400
},
1399-
removePendingAlbumAdd(album) {
1400-
const idx = this.pendingAlbumAdditions.findIndex((a) => a.UID === album.UID);
1401-
if (idx >= 0) this.pendingAlbumAdditions.splice(idx, 1);
1402-
},
14031401
confirmDateTime(data) {
14041402
this.dateTimeDialog = false;
14051403

0 commit comments

Comments
 (0)