Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 75 additions & 29 deletions resources/js/components/assets/Browser/Browser.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,35 +37,54 @@
<template #default="{ items }">
<slot name="header" v-bind="{ canUpload, openFileBrowser, canCreateFolders, startCreatingFolder, mode, modeChanged }">
<Header :title="__(container.title)" icon="assets">
<Dropdown v-if="container.can_edit || container.can_delete || container.can_create">
<DropdownMenu>
<DropdownItem
icon="container-add"
v-if="canCreateContainers"
:text="__('Create Container')"
:href="createContainerUrl"
/>
<DropdownItem
icon="cog"
v-if="container.can_edit"
:text="__('Configure Container')"
:href="container.edit_url"
/>
<DropdownItem
icon="blueprint-edit"
:text="__('Edit Blueprint')"
:href="container.blueprint_url"
/>
<DropdownSeparator v-if="container.can_delete" />
<DropdownItem
icon="trash"
variant="destructive"
v-if="container.can_delete"
:text="__('Delete Container')"
@click="$event.preventDefault(); $refs.deleter.confirm()"
/>
</DropdownMenu>
</Dropdown>
<ItemActions
ref="containerActions"
:url="container.actions_url"
:actions="container.actions"
:item="container.id"
@completed="containerActionCompleted"
v-slot="{ actions }"
>
<Dropdown v-if="container.can_edit || container.can_delete || container.can_edit_blueprint || actions.length">
<DropdownMenu>
<DropdownItem
icon="container-add"
v-if="canCreateContainers"
:text="__('Create Container')"
:href="createContainerUrl"
/>
<DropdownItem
icon="cog"
v-if="container.can_edit"
:text="__('Configure Container')"
:href="container.edit_url"
/>
<DropdownItem
icon="blueprint-edit"
v-if="container.can_edit_blueprint"
:text="__('Edit Blueprint')"
:href="container.blueprint_url"
/>
<DropdownSeparator v-if="actions.length" />
<DropdownItem
v-for="action in actions"
:key="action.handle"
:text="__(action.title)"
:icon="action.icon"
:variant="action.dangerous ? 'destructive' : 'default'"
@click="action.run"
/>
<DropdownSeparator v-if="container.can_delete" />
<DropdownItem
icon="trash"
variant="destructive"
v-if="container.can_delete"
:text="__('Delete Container')"
@click="$event.preventDefault(); $refs.deleter.confirm()"
/>
</DropdownMenu>
</Dropdown>
</ItemActions>

<resource-deleter
ref="deleter"
Expand Down Expand Up @@ -192,6 +211,7 @@ import Table from './Table.vue';
import HasPreferences from '../../data-list/HasPreferences';
import Uploader from '../Uploader.vue';
import Uploads from '../Uploads.vue';
import ItemActions from '@/components/actions/ItemActions.vue';
import { debounce, sortBy } from 'lodash-es';
import {
Header,
Expand All @@ -217,6 +237,7 @@ import {
} from '@ui';
import Breadcrumbs from './Breadcrumbs.vue';
import useCheckerboard from '@/composables/checkerboard.js';
import { router } from '@inertiajs/vue3';

export default {
mixins: [HasPreferences],
Expand All @@ -231,6 +252,7 @@ export default {
DropdownSeparator,
AssetThumbnail,
AssetEditor,
ItemActions,
Uploader,
Uploads,
Grid,
Expand Down Expand Up @@ -556,6 +578,19 @@ export default {
this.$refs.listing.refresh();
},

containerActionCompleted(successful, response = {}) {
if (!successful) {
Statamic.$toast.error(response.message || __('Action failed'));
return;
}

if (response.message !== false) {
Statamic.$toast.success(response.message || __('Action completed'));
}

if (!response.redirect) router.reload();
},

assetSaved() {
this.loadAssets();
},
Expand Down Expand Up @@ -809,6 +844,7 @@ export default {
});

Statamic.$commandPalette.add({
when: () => this.container.can_edit_blueprint,
category: Statamic.$commandPalette.category.Actions,
text: __('Edit Blueprint'),
icon: 'blueprint-edit',
Expand All @@ -822,6 +858,16 @@ export default {
icon: 'trash',
action: () => this.$refs.deleter.confirm(),
});

this.container.actions?.forEach(action => Statamic.$commandPalette.add({
when: () => Boolean(this.$refs.containerActions),
category: Statamic.$commandPalette.category.Actions,
text: [__('Container'), action.title],
icon: action.icon,
action: () => this.$refs.containerActions.preparedActions
.find(prepared => prepared.handle === action.handle)
?.run(),
}));
}
},
};
Expand Down
5 changes: 3 additions & 2 deletions resources/js/pages/assets/Browse.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script>
import Head from '@/pages/layout/Head.vue';
import { DocsCallout } from '@ui';
import { toRaw } from 'vue';

export default {
components: {
Expand Down Expand Up @@ -39,7 +40,7 @@ export default {
* navigation back and forth through folders using browser buttons.
*/
bindBrowserNavigation() {
window.history.replaceState({ container: { ...this.container }, path: this.path }, '');
window.history.replaceState({ container: toRaw(this.container), path: this.path }, '');

window.onpopstate = (e) => {
this.path = e.state.path;
Expand Down Expand Up @@ -81,7 +82,7 @@ export default {

window.history.pushState(
{
container: { ...this.container },
container: toRaw(this.container),
path: this.path,
},
'',
Expand Down
12 changes: 11 additions & 1 deletion resources/js/pages/collections/Show.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
:url="actionUrl"
:actions="actions"
:item="handle"
@completed="actionCompleted"
v-slot="{ actions }"
>
<Dropdown v-if="canEdit || canEditBlueprints || actions.length" placement="left-start">
Expand Down Expand Up @@ -408,7 +409,16 @@ export default {
$event.metaKey ? window.open(url) : router.get(url);
},

afterActionSuccessfullyCompleted(response) {
actionCompleted(successful, response = {}) {
if (!successful) {
Statamic.$toast.error(response.message || __('Action failed'));
return;
}

if (response.message !== false) {
Statamic.$toast.success(response.message || __('Action completed'));
}

if (!response.redirect) router.reload();
},

Expand Down
3 changes: 3 additions & 0 deletions routes/cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Statamic\Http\Controllers\CP\Assets\AssetContainersController;
use Statamic\Http\Controllers\CP\Assets\AssetsController;
use Statamic\Http\Controllers\CP\Assets\BrowserController;
use Statamic\Http\Controllers\CP\Assets\ContainerActionController;
use Statamic\Http\Controllers\CP\Assets\FieldtypeController;
use Statamic\Http\Controllers\CP\Assets\FolderActionController;
use Statamic\Http\Controllers\CP\Assets\FoldersController;
Expand Down Expand Up @@ -250,6 +251,8 @@
Route::patch('globals/{global_set}/variables', [GlobalVariablesController::class, 'update'])->name('globals.variables.update');

Route::resource('asset-containers', AssetContainersController::class)->except('index');
Route::post('asset-containers/actions', [ContainerActionController::class, 'run'])->name('asset-containers.actions.run');
Route::post('asset-containers/actions/list', [ContainerActionController::class, 'bulkActions'])->name('asset-containers.actions.bulk');
Route::post('asset-containers/{asset_container}/folders', [FoldersController::class, 'store']);
Route::post('assets/actions', [AssetActionController::class, 'run'])->name('assets.actions.run');
Route::post('assets/actions/list', [AssetActionController::class, 'bulkActions'])->name('assets.actions.bulk');
Expand Down
4 changes: 4 additions & 0 deletions src/Http/Controllers/CP/Assets/BrowserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Statamic\CP\Column;
use Statamic\Exceptions\AuthorizationException;
use Statamic\Exceptions\NotFoundHttpException;
use Statamic\Facades\Action;
use Statamic\Facades\Asset;
use Statamic\Facades\Scope;
use Statamic\Facades\User;
Expand Down Expand Up @@ -85,10 +86,13 @@ protected function browseData($container, $path)
'blueprint_url' => cp_route('blueprints.asset-containers.edit', $container->handle()),
'can_edit' => User::current()->can('edit', $container),
'can_delete' => User::current()->can('delete', $container),
'can_edit_blueprint' => User::current()->can('configure fields'),
'can_upload' => User::current()->can('store', [\Statamic\Contracts\Assets\Asset::class, $container]),
'can_create_folders' => User::current()->can('create', [\Statamic\Contracts\Assets\AssetFolder::class, $container]),
'sort_field' => $container->sortField(),
'sort_direction' => $container->sortDirection(),
'actions' => Action::for($container, ['view' => 'form']),
'actions_url' => cp_route('asset-containers.actions.run'),
],
'folder' => $path,
'columns' => $this->columns,
Expand Down
14 changes: 14 additions & 0 deletions src/Http/Controllers/CP/Assets/ContainerActionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Statamic\Http\Controllers\CP\Assets;

use Statamic\Facades\AssetContainer;
use Statamic\Http\Controllers\CP\ActionController as Controller;

class ContainerActionController extends Controller
{
protected function getSelectedItems($items, $context)
{
return $items->map(fn ($item) => AssetContainer::find($item));
}
}
113 changes: 113 additions & 0 deletions tests/Feature/Assets/BrowserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use Illuminate\Http\UploadedFile;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Actions\Action;
use Statamic\Contracts\Assets\AssetContainer as AssetContainerContract;
use Statamic\Facades\AssetContainer;
use Statamic\Facades\User;
use Tests\FakesRoles;
Expand Down Expand Up @@ -364,6 +366,92 @@ public function it_denies_access_without_permission_to_view_asset()
->assertForbidden();
}

#[Test]
public function it_only_allows_editing_the_blueprint_with_permission()
{
AssetContainer::make('test')->disk('test')->save();

$this->setTestRoles(['test' => ['access cp', 'view test assets', 'configure fields']]);

$this
->actingAs(User::make()->assignRole('test')->save())
->get(cp_route('assets.browse.show', 'test'))
->assertSuccessful()
->assertInertia(fn ($page) => $page->where('container.can_edit_blueprint', true));

$this
->actingAs($this->userWithPermission())
->get(cp_route('assets.browse.show', 'test'))
->assertSuccessful()
->assertInertia(fn ($page) => $page->where('container.can_edit_blueprint', false));
}

#[Test]
public function it_includes_container_actions_in_the_browse_data()
{
TestContainerAction::register();

AssetContainer::make('test')->disk('test')->save();

$this
->actingAs($this->userWithContainerActionPermission())
->get(cp_route('assets.browse.show', 'test'))
->assertSuccessful()
->assertInertia(fn ($page) => $page
->component('assets/Browse')
->where('container.actions_url', 'http://localhost/cp/asset-containers/actions')
->where('container.actions.0.handle', 'test-container-action'));
}

#[Test]
public function it_runs_a_container_action()
{
TestContainerAction::register();
TestContainerAction::$ran = [];

$container = AssetContainer::make('test')->disk('test')->save();

$this
->actingAs($this->userWithContainerActionPermission())
->post(cp_route('asset-containers.actions.run'), [
'action' => 'test-container-action',
'selections' => ['test'],
'values' => [],
])
->assertSuccessful();

$this->assertCount(1, TestContainerAction::$ran);
$this->assertInstanceOf(AssetContainerContract::class, TestContainerAction::$ran[0]);
$this->assertEquals($container->handle(), TestContainerAction::$ran[0]->handle());
}

#[Test]
public function it_doesnt_run_a_container_action_without_permission()
{
TestContainerAction::register();
TestContainerAction::$ran = [];

AssetContainer::make('test')->disk('test')->save();

$this
->actingAs($this->userWithoutPermission())
->post(cp_route('asset-containers.actions.run'), [
'action' => 'test-container-action',
'selections' => ['test'],
'values' => [],
])
->assertForbidden();

$this->assertCount(0, TestContainerAction::$ran);
}

private function userWithContainerActionPermission()
{
$this->setTestRoles(['test' => ['access cp', 'view test assets', 'configure asset containers']]);

return User::make()->assignRole('test')->save();
}

private function userWithPermission()
{
$this->setTestRoles(['test' => ['access cp', 'view test assets', 'view one assets', 'view two assets']]);
Expand Down Expand Up @@ -394,3 +482,28 @@ private function jsonStructure()
];
}
}

class TestContainerAction extends Action
{
public static $ran = [];

public static function handle()
{
return 'test-container-action';
}

public function visibleTo($item)
{
return $item instanceof AssetContainerContract;
}

public function authorize($user, $item)
{
return $user->can('configure asset containers');
}

public function run($items, $values)
{
static::$ran = $items->all();
}
}
Loading