Skip to content

Commit

Permalink
[5.x] Dynamic asset folders (#10808)
Browse files Browse the repository at this point in the history
Co-authored-by: Jack McDade <[email protected]>
  • Loading branch information
jasonvarga and jackmcdade committed Sep 26, 2024
1 parent d475bf3 commit dea9d7f
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 10 deletions.
2 changes: 1 addition & 1 deletion resources/css/components/fieldtypes/assets.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
}

.assets-fieldtype .assets-fieldtype-picker {
@apply flex flex-col @sm:flex-row items-start @sm:items-center px-4 py-2 bg-gray-200 dark:bg-dark-650 border dark:border-dark-900 rounded;
@apply flex items-center px-4 py-2 bg-gray-200 dark:bg-dark-650 border dark:border-dark-900 rounded;

&.is-expanded {
@apply border-b-0 rounded-b-none;
Expand Down
123 changes: 116 additions & 7 deletions resources/js/components/fieldtypes/assets/AssetsFieldtype.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
<template>
<div class="@container">

<div
v-if="hasPendingDynamicFolder"
class="py-3 px-4 text-sm w-full rounded-md border border-dashed text-gray-700 dark:text-dark-175 dark:border-dark-200"
v-html="__('statamic::fieldtypes.assets.dynamic_folder_pending', {field: `<code>${config.dynamic}</code>`})"
/>

<uploader
ref="uploader"
:container="container"
Expand All @@ -19,13 +25,12 @@

<div
v-if="!isReadOnly && showPicker"
class="assets-fieldtype-picker"
class="assets-fieldtype-picker space-x-4"
:class="{
'is-expanded': expanded,
'bard-drag-handle': isInBardField
}"
>

<button
v-if="canBrowse"
:class="{'opacity-0': dragging }"
Expand All @@ -37,14 +42,21 @@
<svg-icon name="folder-image" class="w-4 h-4 text-gray-800 dark:text-dark-150"></svg-icon>
{{ __('Browse') }}
</button>

<p class="asset-upload-control" v-if="canUpload">
<p class="flex-1 asset-upload-control" v-if="canUpload">
<button type="button" class="upload-text-button" @click.prevent="uploadFile">
{{ __('Upload file') }}
</button>
<span v-if="soloAsset" class="drag-drop-text" v-text="__('or drag & drop here to replace.')"></span>
<span v-else class="drag-drop-text" v-text="__('or drag & drop here.')"></span>
</p>
<dropdown-list v-if="meta.rename_folder">
<data-list-inline-actions
:item="folder"
:url="meta.rename_folder.url"
:actions="[meta.rename_folder.action]"
@completed="renameFolderActionCompleted"
/>
</dropdown-list>
</div>

<uploads
Expand Down Expand Up @@ -189,6 +201,7 @@ export default {
uploads: [],
innerDragging: false,
displayMode: 'grid',
lockedDynamicFolder: this.meta.dynamicFolder,
};
},
Expand All @@ -213,15 +226,58 @@ export default {
* The initial folder to be displayed in the selector.
*/
folder() {
let folder = this.configuredFolder;
if (this.isUsingDynamicFolder) {
folder = folder + '/' + (this.lockedDynamicFolder || this.dynamicFolder);
}
return folder.replace(/^\/+/, '');
},
configuredFolder() {
return this.config.folder || '/';
},
isUsingDynamicFolder() {
return !!this.config.dynamic;
},
hasPendingDynamicFolder() {
return this.isUsingDynamicFolder && ! this.lockedDynamicFolder && ! this.dynamicFolder;
},
dynamicFolder() {
const field = this.config.dynamic;
if (! ['id', 'slug', 'author'].includes(field)) {
throw new Error(`Dynamic folder field [${field}] is invalid. Must be one of: id, slug, author`);
}
const value = data_get(this.$store.state.publish[this.store].values, field);
// If value is an array (e.g. a users fieldtype), get the first item.
return Array.isArray(value) ? value[0] : value;
},
store() {
let store;
let parent = this;
while (! parent.storeName) {
parent = parent.$parent;
store = parent.storeName;
if (parent === this.$root) return null;
}
return store;
},
/**
* Whether assets should be restricted to the specified container
* and folder. This will prevent navigation to other places.
*/
restrictNavigation() {
return this.config.restrict || false;
return this.isUsingDynamicFolder || this.config.restrict || false;
},
/**
Expand Down Expand Up @@ -363,11 +419,19 @@ export default {
},
canBrowse() {
return this.can('configure asset containers') || this.can('view '+ this.container +' assets')
const hasPermission = this.can('configure asset containers') || this.can('view '+ this.container +' assets');
if (! hasPermission) return false;
return ! this.hasPendingDynamicFolder;
},
canUpload() {
return this.config.allow_uploads && (this.can('configure asset containers') || this.can('upload '+ this.container +' assets'))
const hasPermission = this.config.allow_uploads && (this.can('configure asset containers') || this.can('upload '+ this.container +' assets'));
if (! hasPermission) return false;
return ! this.hasPendingDynamicFolder;
},
},
Expand Down Expand Up @@ -425,6 +489,7 @@ export default {
*/
assetsSelected(selections) {
this.loadAssets(selections);
this.lockDynamicFolder();
},
/**
Expand Down Expand Up @@ -462,6 +527,8 @@ export default {
*/
uploadComplete(asset) {
this.assets.push(asset);
this.lockDynamicFolder();
},
/**
Expand Down Expand Up @@ -491,6 +558,46 @@ export default {
this.update([...this.value.slice(0, index), newId, ...this.value.slice(index + 1)]);
},
lockDynamicFolder() {
if (this.isUsingDynamicFolder && !this.lockedDynamicFolder) this.lockedDynamicFolder = this.dynamicFolder;
},
syncDynamicFolderFromValue(value) {
if (! this.isUsingDynamicFolder) return;
this.lockedDynamicFolder = null;
if (value.length === 0) {
// If there are no assets, we should get the dynamic folder naturally.
this.lockDynamicFolder();
} else {
// Otherwise, figure it out from the first selected asset.
const first = value[0];
const segments = first.split('::')[1].split('/');
this.lockedDynamicFolder = segments[segments.length - 2];
}
// Set the new folder in the rename action.
const meta = this.meta;
meta.rename_folder.action.context.folder = this.folder;
this.updateMeta(meta);
},
renameFolderActionCompleted(successful=null, response={}) {
if (successful === false) return;
this.$events.$emit('reset-action-modals');
if (response.message !== false) {
this.$toast.success(response.message || __("Action completed"));
}
// Update the folder in the current asset values.
// They will be adjusted in the content but not here automatically since there's no refresh.
const newFolder = response[0].path;
this.update(this.value.map(id => id.replace(`::${this.folder}`, `::${newFolder}`)));
this.lockedDynamicFolder = this.configuredFolder ? newFolder.replace(`${this.configuredFolder}/`, '') : newFolder;
}
},
Expand All @@ -516,6 +623,8 @@ export default {
value(value) {
if (_.isEqual(value, this.assetIds)) return;
this.syncDynamicFolderFromValue(value);
this.loadAssets(value);
},
Expand Down
2 changes: 2 additions & 0 deletions resources/lang/en/fieldtypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
'array.title' => 'Array',
'assets.config.allow_uploads' => 'Allow new file uploads.',
'assets.config.container' => 'Choose which asset container to use for this field.',
'assets.config.dynamic' => 'Assets will be placed in a subfolder based on the value of this field.',
'assets.config.folder' => 'The folder to begin browsing in.',
'assets.config.max_files' => 'Set a maximum number of selectable assets.',
'assets.config.min_files' => 'The minimum number of selectable assets.',
Expand All @@ -18,6 +19,7 @@
'assets.config.show_filename' => 'Show the filename next to the preview image.',
'assets.config.show_set_alt' => 'Show a link to set the Alt Text of any images.',
'assets.config.query_scopes' => 'Choose which query scopes should be applied when retrieving selectable assets.',
'assets.dynamic_folder_pending' => 'This upload folder will be available once :field is set.',
'assets.title' => 'Assets',
'bard.config.allow_source' => 'Enable to view the HTML source code while writing.',
'bard.config.always_show_set_button' => 'Enable to always show the "Add Set" button.',
Expand Down
2 changes: 1 addition & 1 deletion src/Actions/RenameAssetFolder.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class RenameAssetFolder extends Action
{
public static function title()
{
return __('Rename');
return __('Rename Folder');
}

public function visibleTo($item)
Expand Down
85 changes: 84 additions & 1 deletion src/Fieldtypes/Assets/Assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
namespace Statamic\Fieldtypes\Assets;

use Illuminate\Support\Collection;
use Statamic\Actions\RenameAssetFolder;
use Statamic\Assets\OrderedQueryBuilder;
use Statamic\Contracts\Entries\Entry;
use Statamic\Exceptions\AssetContainerNotFoundException;
use Statamic\Facades\Action;
use Statamic\Facades\Asset;
use Statamic\Facades\AssetContainer;
use Statamic\Facades\Blink;
use Statamic\Facades\GraphQL;
use Statamic\Facades\Scope;
use Statamic\Facades\User;
use Statamic\Fields\Fieldtype;
use Statamic\GraphQL\Types\AssetInterface;
use Statamic\Http\Resources\CP\Assets\Asset as AssetResource;
Expand Down Expand Up @@ -69,12 +73,28 @@ protected function configFieldItems(): array
'container' => 'not empty',
],
],
'dynamic' => [
'display' => __('Dynamic Folder'),
'instructions' => __('statamic::fieldtypes.assets.config.dynamic'),
'type' => 'select',
'clearable' => true,
'options' => [
'id' => __('ID'),
'slug' => __('Slug'),
'author' => __('Author'),
],
'validate' => 'in:id,slug,author',
'if' => [
'container' => 'not empty',
],
],
'restrict' => [
'display' => __('Restrict to Folder'),
'instructions' => __('statamic::fieldtypes.assets.config.restrict'),
'type' => 'toggle',
'if' => [
'container' => 'not empty',
'dynamic' => 'not true',
],
],
'allow_uploads' => [
Expand Down Expand Up @@ -150,7 +170,70 @@ public function preload()
{
return [
'data' => $this->getItemData($this->field->value() ?? $this->defaultValue),
'container' => $this->container()->handle(),
'container' => $container = $this->container()->handle(),
'dynamicFolder' => $dynamicFolder = $this->dynamicFolder(),
'rename_folder' => $this->renameFolderAction($dynamicFolder),
];
}

private function dynamicFolder()
{
if (! $this->config('dynamic')) {
return null;
}

// If there's already a value, get the folder from the first asset.
// The user may have renamed the directory to differ from the entry slug.
if (! empty($value = $this->field->value())) {
$folder = ($folder = $this->config('folder')) ? $folder.'/' : '';
$prefix = $this->container()->handle().'::'.$folder;
$file = Str::after($value[0], $prefix);

return Str::beforeLast($file, '/');
}

// Otherwise, use a given field's value as the folder.
if (! in_array($field = $this->config('dynamic'), ['id', 'slug', 'author'])) {
throw new \Exception("Dynamic folder field [$field] is invalid. Must be one of: id, slug, author");
}

$parent = $this->field->parent();

if ($parent instanceof Entry) {
$value = $parent->$field;

// If the author field doesn't have a max_items of 1, it'll be a collection, so grab the first one.
if ($value instanceof Collection) {
$value = $value->first();
}

// If the author field had max_items 1 it would be a user, or since we got it above, use its id.
if (is_object($value)) {
$value = $value->id();
}

return $value;
}
}

private function renameFolderAction($dynamicFolder)
{
if (! $dynamicFolder) {
return null;
}

$container = $this->container();
$folder = (($folder = $this->config('folder')) ? $folder.'/' : '').$dynamicFolder;
$assetFolder = $container->assetFolder($folder);

$action = Action::for($assetFolder, [
'container' => $container->handle(),
'folder' => $folder,
])->first(fn ($action) => get_class($action) === RenameAssetFolder::class)->toArray();

return [
'url' => cp_route('assets.folders.actions.run', $container),
'action' => $action,
];
}

Expand Down

0 comments on commit dea9d7f

Please sign in to comment.