Batch Edit: Add batchEdit feature flag in backend & frontend #271 #5324

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-11-19 01:54:04 +01:00
parent 49653d24bb
commit 1a3fdcdad4
11 changed files with 157 additions and 2 deletions

View file

@ -215,6 +215,7 @@ export default {
},
data() {
const features = this.$config.getSettings().features;
const canEdit = this.$config.allow("photos", "update") && features.edit;
return {
selection: this.$clipboard.selection,
@ -225,9 +226,9 @@ export default {
canShare: this.$config.allow("photos", "share") && features.share,
canServiceUpload: this.$config.feature("services") && this.$config.allow("services", "upload"),
canManage: this.$config.allow("photos", "manage") && features.albums,
canEdit: this.$config.allow("photos", "update") && features.edit,
canEdit: canEdit,
canBatchEdit: canEdit && this.$config.allow("photos", "access_all") && features.batchEdit,
canEditAlbum: this.$config.allow("albums", "update") && features.albums,
canBatchEdit: this.$config.allow("photos", "update") && this.$config.allow("photos", "access_all"),
busy: false,
config: this.$config.values,
expanded: false,
@ -425,6 +426,11 @@ export default {
download(path, "photos.zip");
},
edit() {
if (!this.canEdit) {
$notify.error(this.$gettext("Disabled"));
return;
}
// Open Edit or Batch Edit Dialog.
if (!this.canBatchEdit || this.selection.length === 1) {
this.$event.PubSub.publish("dialog.edit", { selection: this.selection, album: this.album, index: 0 });

View file

@ -229,6 +229,21 @@
</v-checkbox>
</v-col>
<v-col cols="12" sm="6" lg="3" class="px-2 pb-2 pt-2">
<v-checkbox
v-model="settings.features.batchEdit"
:disabled="busy || isDemo || !settings.features.edit"
class="ma-0 pa-0 input-batch-edit"
density="compact"
:label="$gettext('Batch Edit')"
:hint="$gettext('Edit the metadata, labels, and albums of multiple pictures at once.')"
prepend-icon="mdi-form-select"
persistent-hint
@update:model-value="onChange"
>
</v-checkbox>
</v-col>
<v-col cols="12" sm="6" lg="3" class="px-2 pb-2 pt-2">
<v-checkbox
v-model="settings.features.archive"

View file

@ -0,0 +1,104 @@
import { describe, it, expect, vi } from "vitest";
import { shallowMount } from "@vue/test-utils";
import PPhotoClipboard from "component/photo/clipboard.vue";
const baseFeatures = {
edit: true,
batchEdit: true,
private: true,
archive: true,
delete: true,
download: true,
share: true,
albums: true,
};
function mountClipboard({ featureOverrides = {}, allowAccessAll = true } = {}) {
const publish = vi.fn();
const clipboard = {
selection: ["pt5y3865st5p3k5l", "pt5y3863oyip9a2d"],
clear: vi.fn(),
};
const features = { ...baseFeatures, ...featureOverrides };
const allowMock = vi.fn((resource, action) => {
if (resource === "photos" && action === "access_all") {
return allowAccessAll;
}
return true;
});
const wrapper = shallowMount(PPhotoClipboard, {
global: {
mocks: {
$config: {
getSettings: () => ({ features }),
allow: allowMock,
feature: vi.fn().mockReturnValue(true),
values: {},
},
$clipboard: clipboard,
$notify: {
success: vi.fn(),
error: vi.fn(),
},
$event: {
PubSub: { publish },
},
$gettext: (msg) => msg,
$pgettext: (_ctx, msg) => msg,
$isRtl: false,
},
stubs: {
"v-speed-dial": { template: "<div><slot></slot></div>" },
"v-btn": { template: "<button><slot></slot></button>" },
"v-icon": { template: "<i></i>" },
"p-photo-archive-dialog": true,
"p-confirm-dialog": true,
"p-photo-album-dialog": true,
"p-service-upload": true,
},
},
});
return { wrapper, publish, clipboard };
}
describe("component/photo/clipboard", () => {
it("publishes dialog.batchedit when the feature flag is enabled and multiple photos are selected", () => {
const { wrapper, publish, clipboard } = mountClipboard();
wrapper.vm.edit();
expect(publish).toHaveBeenCalledWith("dialog.batchedit", {
selection: clipboard.selection,
album: wrapper.vm.album,
index: 0,
});
});
it("falls back to dialog.edit when the batchEdit flag is disabled", () => {
const { wrapper, publish, clipboard } = mountClipboard({ featureOverrides: { batchEdit: false } });
wrapper.vm.edit();
expect(publish).toHaveBeenCalledWith("dialog.edit", {
selection: clipboard.selection,
album: wrapper.vm.album,
index: 0,
});
});
it("does not allow batch edit when access_all permission is missing", () => {
const { wrapper, publish, clipboard } = mountClipboard({ allowAccessAll: false });
wrapper.vm.edit();
expect(publish).toHaveBeenCalledWith("dialog.edit", {
selection: clipboard.selection,
album: wrapper.vm.album,
index: 0,
});
});
});

View file

@ -136,6 +136,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
Delete: true,
Download: true,
Edit: true,
BatchEdit: true,
Estimates: true,
Favorites: true,
Files: true,
@ -175,6 +176,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
Delete: false,
Download: true,
Edit: false,
BatchEdit: false,
Estimates: true,
Favorites: false,
Files: false,
@ -214,6 +216,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
Delete: false,
Download: true,
Edit: false,
BatchEdit: false,
Estimates: true,
Favorites: false,
Files: false,
@ -254,6 +257,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
assert.False(t, f.People)
assert.False(t, f.Settings)
assert.False(t, f.Edit)
assert.False(t, f.BatchEdit)
assert.False(t, f.Private)
assert.False(t, f.Upload)
assert.False(t, f.Download)
@ -297,6 +301,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.True(t, f.People)
assert.True(t, f.Settings)
assert.True(t, f.Edit)
assert.True(t, f.BatchEdit)
assert.True(t, f.Private)
assert.True(t, f.Upload)
assert.True(t, f.Download)
@ -328,6 +333,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.True(t, f.People)
assert.True(t, f.Settings)
assert.True(t, f.Edit)
assert.True(t, f.BatchEdit)
assert.True(t, f.Private)
assert.True(t, f.Upload)
assert.True(t, f.Download)
@ -358,6 +364,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.False(t, f.People)
assert.False(t, f.Settings)
assert.True(t, f.Edit)
assert.True(t, f.BatchEdit)
assert.True(t, f.Private)
assert.True(t, f.Upload)
assert.True(t, f.Download)
@ -390,6 +397,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.False(t, f.People)
assert.False(t, f.Settings)
assert.False(t, f.Edit)
assert.False(t, f.BatchEdit)
assert.False(t, f.Private)
assert.False(t, f.Upload)
assert.True(t, f.Download)
@ -454,6 +462,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.False(t, f.People)
assert.False(t, f.Settings)
assert.False(t, f.Edit)
assert.False(t, f.BatchEdit)
assert.False(t, f.Private)
assert.False(t, f.Upload)
assert.False(t, f.Download)
@ -483,6 +492,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.True(t, f.People)
assert.True(t, f.Settings)
assert.True(t, f.Edit)
assert.True(t, f.BatchEdit)
assert.True(t, f.Private)
assert.True(t, f.Upload)
assert.True(t, f.Download)

View file

@ -27,6 +27,7 @@ func (s *Settings) ApplyACL(list acl.ACL, role acl.Role) *Settings {
m.Features.Archive = s.Features.Archive && list.AllowAny(acl.ResourcePhotos, role, acl.Permissions{acl.ActionDelete})
m.Features.Delete = s.Features.Delete && list.AllowAny(acl.ResourcePhotos, role, acl.Permissions{acl.ActionDelete})
m.Features.Edit = s.Features.Edit && list.AllowAny(acl.ResourcePhotos, role, acl.Permissions{acl.ActionUpdate})
m.Features.BatchEdit = s.Features.BatchEdit && s.Features.Edit && list.AllowAll(acl.ResourcePhotos, role, acl.Permissions{acl.AccessAll, acl.ActionUpdate})
m.Features.Review = s.Features.Review
m.Features.Share = s.Features.Share && list.AllowAny(acl.ResourceShares, role, acl.Permissions{acl.ActionManage})

View file

@ -21,6 +21,7 @@ func TestSettings_ApplyACL(t *testing.T) {
Delete: true,
Download: true,
Edit: true,
BatchEdit: true,
Estimates: true,
Favorites: true,
Files: true,
@ -61,6 +62,7 @@ func TestSettings_ApplyACL(t *testing.T) {
Delete: false,
Download: true,
Edit: false,
BatchEdit: false,
Estimates: true,
Favorites: false,
Files: false,
@ -90,4 +92,12 @@ func TestSettings_ApplyACL(t *testing.T) {
t.Logf("RoleVisitor: %#v", r)
assert.Equal(t, expected, r.Features)
})
t.Run("RoleClient", func(t *testing.T) {
s := NewDefaultSettings()
r := s.ApplyACL(acl.Rules, acl.RoleClient)
assert.True(t, r.Features.BatchEdit)
})
}

View file

@ -8,6 +8,7 @@ type FeatureSettings struct {
Delete bool `json:"delete" yaml:"Delete"`
Download bool `json:"download" yaml:"Download"`
Edit bool `json:"edit" yaml:"Edit"`
BatchEdit bool `json:"batchEdit" yaml:"BatchEdit"`
Estimates bool `json:"estimates" yaml:"Estimates"`
Favorites bool `json:"favorites" yaml:"Favorites"`
Files bool `json:"files" yaml:"Files"`

View file

@ -34,6 +34,7 @@ func (s *Settings) ApplyScope(scope string) *Settings {
m.Features.Archive = s.Features.Archive && scopes.Contains(acl.ResourcePhotos.String())
m.Features.Delete = s.Features.Delete && scopes.Contains(acl.ResourcePhotos.String())
m.Features.Edit = s.Features.Edit && scopes.Contains(acl.ResourcePhotos.String())
m.Features.BatchEdit = s.Features.BatchEdit && s.Features.Edit && scopes.Contains(acl.ResourcePhotos.String())
m.Features.Share = s.Features.Share && scopes.Contains(acl.ResourceShares.String())
// Browse, upload and download files.

View file

@ -25,6 +25,7 @@ func TestSettings_ApplyScope(t *testing.T) {
Delete: true,
Download: true,
Edit: true,
BatchEdit: true,
Estimates: true,
Favorites: true,
Files: true,
@ -65,6 +66,7 @@ func TestSettings_ApplyScope(t *testing.T) {
Delete: true,
Download: true,
Edit: true,
BatchEdit: true,
Estimates: true,
Favorites: false,
Files: false,
@ -105,6 +107,7 @@ func TestSettings_ApplyScope(t *testing.T) {
Delete: false,
Download: true,
Edit: false,
BatchEdit: false,
Estimates: true,
Favorites: false,
Files: false,
@ -144,6 +147,7 @@ func TestSettings_ApplyScope(t *testing.T) {
Delete: false,
Download: true,
Edit: false,
BatchEdit: false,
Estimates: true,
Favorites: false,
Files: false,
@ -183,6 +187,7 @@ func TestSettings_ApplyScope(t *testing.T) {
Delete: false,
Download: false,
Edit: false,
BatchEdit: false,
Estimates: true,
Favorites: false,
Files: false,

View file

@ -86,6 +86,7 @@ func NewSettings(theme, language, timeZone string) *Settings {
Labels: true,
Places: true,
Edit: true,
BatchEdit: true,
Archive: true,
Review: true,
Share: true,

View file

@ -20,6 +20,7 @@ Features:
Delete: true
Download: true
Edit: true
BatchEdit: true
Estimates: true
Favorites: true
Files: true