Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c6fc505
Initial setup of per asset validation
godismyjudge95 Jul 23, 2025
dd4ebdf
Fixing tests
godismyjudge95 Jul 23, 2025
f85a5ff
Merge branch 'master' into asset-validation-error-per-asset-6.x
godismyjudge95 Jul 23, 2025
bf7d7df
Merge branch 'master' into asset-validation-error-per-asset-6.x
godismyjudge95 Jul 23, 2025
08b2568
Assign asset errors by asset id not index
godismyjudge95 Jul 23, 2025
7817659
Misc fixes
godismyjudge95 Jul 23, 2025
fb9e8a7
Reverting customized validation messages
godismyjudge95 Jul 24, 2025
5fd48d7
Code style
godismyjudge95 Jul 24, 2025
8bf37b8
Fixing to string parameters
godismyjudge95 Jul 24, 2025
eaa177a
Merge branch 'master' into asset-validation-error-per-asset-6.x
godismyjudge95 Aug 15, 2025
42e45df
Merge branch 'master' into asset-validation-error-per-asset-6.x
godismyjudge95 Aug 22, 2025
0b29c8d
Merge branch 'master' into asset-validation-error-per-asset-6.x
godismyjudge95 Sep 17, 2025
27bd17e
Fixing bug with max/min on assets field
godismyjudge95 Sep 22, 2025
2d626a9
Merge branch 'master' into asset-validation-error-per-asset-6.x
godismyjudge95 Nov 14, 2025
17683b1
Merge branch 'master' into asset-validation-error-per-asset-6.x
godismyjudge95 Dec 11, 2025
ba4cfb5
Merge branch '6.x' into asset-validation-error-per-asset-6.x
godismyjudge95 Jan 28, 2026
31dac7c
Merge branch '6.x' into asset-validation-error-per-asset-6.x
godismyjudge95 May 4, 2026
638472d
Merge remote-tracking branch 'upstream/6.x' into asset-validation-err…
godismyjudge95 Aug 19, 2026
8064d63
Fix merged asset validation checks
godismyjudge95 Aug 19, 2026
32bc3ee
Fix asset validation import groups
godismyjudge95 Aug 19, 2026
304e5fc
Merge branch '6.x' into asset-validation-error-per-asset-6.x
godismyjudge95 Sep 2, 2026
8abf706
Merge branch '6.x' into asset-validation-error-per-asset-6.x
godismyjudge95 Sep 11, 2026
e71bab5
Merge branch '6.x' into asset-validation-error-per-asset-6.x
godismyjudge95 Oct 14, 2026
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
1 change: 1 addition & 0 deletions resources/js/components/fieldtypes/assets/Asset.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default {
props: {
asset: Object,
readOnly: Boolean,
errors: Array,
showFilename: {
type: Boolean,
default: true,
Expand Down
7 changes: 5 additions & 2 deletions resources/js/components/fieldtypes/assets/AssetRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,18 @@
<button
v-if="showFilename"
@click="editOrOpen"
class="min-w-0 flex-1 truncate text-start text-sm leading-5"
class="flex min-w-0 flex-col w-full flex-1 justify-center gap-1 truncate text-sm leading-5 text-gray-600 dark:text-gray-400 text-start"
:class="{
'text-gray-600 dark:text-gray-300': !asset.invalid,
'text-gray-500 dark:text-gray-400': asset.invalid
}"
:title="asset.invalid ? invalidLabel : __('Edit')"
:aria-label="__('Edit Asset')"
>
{{ label }}
<div>{{ label }}</div>
<template v-if="errors.length">
<small class="text-xs text-red-500" v-for="(error, i) in errors" :key="i" v-text="error" />
</template>
</button>
<div v-if="readOnly" v-text="asset.size" class="asset-filesize hidden shrink-0 px-2 text-sm leading-5 text-gray-600 dark:text-gray-400 @xs:block" />
</td>
Expand Down
11 changes: 10 additions & 1 deletion resources/js/components/fieldtypes/assets/AssetTile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@
canBeTransparent && checkerboardMode === 'transparent' ? `bg-checkerboard before:opacity-0 hover:before:opacity-100 rounded-lg` : '',
]">
<div class="pb-1 @min-[300px]:p-1 flex flex-col items-center justify-center h-full">
<template v-if="errors.length">
<div class="absolute z-10 inset-0 bg-white/75 dark:bg-dark-800/90 flex flex-col gap-2 items-center justify-center px-1 py-2">
<small
class="text-xs text-red-500 text-center"
v-text="errors[0]"
/>
</div>
</template>

<!-- Solo Bard -->
<template v-if="isImage && isInBardField && !isInAssetBrowser">
<img :src="asset.url" />
Expand All @@ -45,7 +54,7 @@
</template>

</div>
<div class="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 duration-100">
<div class="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 duration-100 z-10">
<div class="flex items-center justify-center gap-2">
<template v-if="!readOnly">
<ui-button size="sm" @click="editOrOpen" :icon="asset.isEditable ? 'pencil' : 'eye'" aria-label="__('Edit')" v-if="asset.isViewable" />
Expand Down
34 changes: 34 additions & 0 deletions resources/js/components/fieldtypes/assets/AssetsFieldtype.vue
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
v-for="asset in assets"
:key="asset.id"
:asset="asset"
:errors="errorsForAsset(asset.id)"
:siblings="assets"
:read-only="isReadOnly"
:show-filename="config.show_filename"
Expand Down Expand Up @@ -176,6 +177,7 @@
v-for="asset in assets"
:key="asset.id"
:asset="asset"
:errors="errorsForAsset(asset.id)"
:siblings="assets"
:read-only="isReadOnly"
:show-filename="config.show_filename"
Expand Down Expand Up @@ -275,6 +277,7 @@ export default {
innerDragging: false,
displayMode: 'grid',
lockedDynamicFolder: this.meta.dynamicFolder,
errorsById: {},
};
},

Expand Down Expand Up @@ -661,6 +664,14 @@ export default {
this.loadAssets([...this.value, id]);
}
},

errorsForAsset(id) {
if (Object.keys(this.errorsById).length === 0 || !this.errorsById.hasOwnProperty(id)) {
return [];
}

return this.errorsById[id];
},
},

watch: {
Expand All @@ -682,6 +693,29 @@ export default {
}
},

'publishContainer.errors': {
immediate: true,
handler(errors) {
this.errorsById = Object.entries(errors).reduce((acc, [key, value]) => {
const prefix = this.fieldPathKeys || this.handle;

if (!key.startsWith(prefix)) {
return acc;
}

const subKey = key.replace(`${prefix}.`, '');
const assetIndex = subKey.split('.').shift();
const assetId = this.assetIds[assetIndex];

if (assetId) {
acc[assetId] = value;
}

return acc;
}, {});
},
},

loading(loading) {
this.$progress.loading(`assets-fieldtype-${this.$.uid}`, loading);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { expect, test, vi } from 'vitest';
import * as Globals from '@/bootstrap/globals';
import { preferences } from '@api';
import AssetsFieldtype from '@/components/fieldtypes/assets/AssetsFieldtype.vue';
import { publishContextKey } from '@/components/ui';

// Register the global helper functions (data_get, clone, etc.) onto the window
// like the control panel bootstrap does, so the component can use them.
Expand All @@ -25,6 +26,9 @@ const makeField = ({ axiosPost = vi.fn(() => Promise.resolve({ data: [] })), pro
...props,
},
global: {
provide: {
[publishContextKey]: { errors: {} },
},
mocks: {
$axios: { post: axiosPost },
$progress: { loading: () => {} },
Expand Down
22 changes: 20 additions & 2 deletions src/Fields/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,29 @@ public function reserveSpaceWhenHidden()

public function rules()
{
$rules = [$this->handle => $this->addNullableRule(array_merge(
$temp_rules = collect($this->addNullableRule(array_merge(
$this->get('required') ? ['required'] : [],
Validator::explodeRules($this->fieldtype()->fieldRules()),
Validator::explodeRules($this->fieldtype()->rules())
))];
)));

$rules = [];
if ($this->type() === 'assets') {
$rules = $temp_rules->reduce(function ($result, $rule) {
// These rules need to be applied to the field as a whole vs each asset in the field
if (Str::of($rule)->before(':')->is(['array', 'required', 'nullable', 'max', 'min'])) {
$result[$this->handle] ??= [];
$result[$this->handle][] = $rule;
} else {
$result["{$this->handle}.*"] ??= [];
$result["{$this->handle}.*"][] = $rule;
}

return $result;
}, []);
} else {
$rules = [$this->handle => $temp_rules->all()];
}

$extra = collect($this->fieldtype()->extraRules())->map(function ($rules) {
return $this->addNullableRule(Validator::explodeRules($rules));
Expand Down
100 changes: 38 additions & 62 deletions src/Fieldtypes/Assets/DimensionsRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,91 +2,62 @@

namespace Statamic\Fieldtypes\Assets;

use Illuminate\Contracts\Validation\Rule;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Statamic\Contracts\GraphQL\CastableToValidationString;
use Statamic\Facades\Asset;
use Statamic\Statamic;
use Stringable;
use Symfony\Component\HttpFoundation\File\UploadedFile;

use function Statamic\trans as __;

class DimensionsRule implements CastableToValidationString, Rule
class DimensionsRule implements CastableToValidationString, Stringable, ValidationRule
{
protected $parameters;
protected array $raw_parameters;

public function __construct($parameters = null)
public function __construct(protected $parameters)
{
$this->parameters = $parameters;
$this->raw_parameters = $parameters;
$this->parameters = array_reduce($parameters, function ($acc, $item) {
[$key, $value] = array_pad(explode('=', $item, 2), 2, null);
$acc[$key] = $value;

return $acc;
}, []);
}

/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param mixed $value
* @return bool
*/
public function passes($attribute, $value)
public function validate(string $attribute, mixed $value, Closure $fail): void
{
return collect($value)->every(function ($id) {
if ($id instanceof UploadedFile) {
if (in_array($id->getMimeType(), ['image/svg+xml', 'image/svg'])) {
return true;
}

$size = getimagesize($id->getPathname());
} else {
if (! $asset = Asset::find($id)) {
return false;
}

if ($asset->isSvg()) {
return true;
}

$size = $asset->dimensions();
$size = [0, 0];

if ($value instanceof UploadedFile) {
if (in_array($value->getMimeType(), ['image/svg+xml', 'image/svg'])) {
return;
}

[$width, $height] = $size;
$size = getimagesize($value->getPathname());
} elseif ($asset = Asset::find($value)) {
if ($asset->isSvg()) {
return;
}

$parameters = $this->parseNamedParameters($this->parameters);
$size = $asset->dimensions();
}

if ($this->failsBasicDimensionChecks($parameters, $width, $height) ||
$this->failsRatioCheck($parameters, $width, $height)) {
return false;
}
[$width, $height] = $size;

return true;
});
if ($this->failsBasicDimensionChecks($this->parameters, $width, $height) ||
$this->failsRatioCheck($this->parameters, $width, $height)) {
$fail($this->message());
}
}

/**
* Get the validation error message.
*
* @return string
*/
public function message()
public function message(): string
{
return __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.dimensions');
}

/**
* Parse named parameters to $key => $value items.
*
* @param array $parameters
* @return array
*/
protected function parseNamedParameters($parameters)
{
return array_reduce($parameters, function ($result, $item) {
[$key, $value] = array_pad(explode('=', $item, 2), 2, null);

$result[$key] = $value;

return $result;
});
}

/**
* Test if the given width and height fail any conditions.
*
Expand Down Expand Up @@ -128,8 +99,13 @@ protected function failsRatioCheck($parameters, $width, $height)
return abs($numerator / $denominator - $width / $height) > $precision;
}

public function __toString()
{
return 'dimensions:'.implode(',', $this->raw_parameters);
}

public function toGqlValidationString(): string
{
return 'dimensions:'.implode(',', $this->parameters);
return $this->__toString();
}
}
57 changes: 26 additions & 31 deletions src/Fieldtypes/Assets/ImageRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,59 +2,54 @@

namespace Statamic\Fieldtypes\Assets;

use Illuminate\Contracts\Validation\Rule;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Statamic\Contracts\GraphQL\CastableToValidationString;
use Statamic\Facades\Asset;
use Statamic\Statamic;
use Stringable;
use Symfony\Component\HttpFoundation\File\UploadedFile;

use function Statamic\trans as __;

class ImageRule implements CastableToValidationString, Rule
class ImageRule implements CastableToValidationString, Stringable, ValidationRule
{
protected $parameters;
public $extensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'avif'];

public function __construct($parameters = null)
public function __construct(protected $parameters)
{
$this->parameters = $parameters;
if ($this->parameters !== ['image']) {
$this->extensions = array_map(strtolower(...), $this->parameters);
}
}

/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param mixed $value
* @return bool
*/
public function passes($attribute, $value)
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$extensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'avif'];
$extension = '';

return collect($value)->every(function ($id) use ($extensions) {
if ($id instanceof UploadedFile) {
return in_array($id->guessExtension(), $extensions);
}
if ($value instanceof UploadedFile) {
$extension = $value->guessExtension();
} elseif ($asset = Asset::find($value)) {
$extension = $asset->extension();
}

if (! $asset = Asset::find($id)) {
return false;
}
if (! in_array($extension, $this->extensions)) {
$fail($this->message());
}
}

return $asset->guessedExtensionIsOneOf($extensions);
});
public function message(): string
{
return __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.image', ['extensions' => implode(', ', $this->extensions)]);
}

/**
* Get the validation error message.
*
* @return string
*/
public function message()
public function __toString()
{
return __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.image');
return 'image:'.implode(',', $this->extensions);
}

public function toGqlValidationString(): string
{
return 'image:'.implode(',', $this->parameters);
return $this->__toString();
}
}
Loading
Loading