Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
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 @@ -23,11 +23,14 @@
<button
v-if="showFilename"
@click="editOrOpen"
class="flex w-full flex-1 items-center truncate text-sm text-gray-600 dark:text-gray-400 text-start"
class="flex flex-col w-full flex-1 justify-center gap-1 truncate text-sm text-gray-600 dark:text-gray-400 text-start"
:title="__('Edit')"
:aria-label="__('Edit Asset')"
>
{{ asset.basename }}
<div>{{ asset.basename }}</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>
<ui-badge
v-if="showSetAlt && needsAlt"
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 @@ -20,6 +20,15 @@

<div class="asset-thumb-container">
<div class="asset-thumb" :class="{ 'bg-checkerboard': canBeTransparent }">
<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 @@ -34,7 +43,7 @@
</template>
</template>

<div class="asset-controls">
<div class="asset-controls z-10">
<div class="flex items-center justify-center space-x-1 rtl:space-x-reverse">
<template v-if="!readOnly">
<button @click="edit" class="btn btn-icon" :title="__('Edit')">
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 @@ -109,6 +109,7 @@
v-for="asset in assets"
:key="asset.id"
:asset="asset"
:errors="errorsForAsset(asset.id)"
:read-only="isReadOnly"
:show-filename="config.show_filename"
:show-set-alt="showSetAlt"
Expand Down Expand Up @@ -139,6 +140,7 @@
v-for="asset in assets"
:key="asset.id"
:asset="asset"
:errors="errorsForAsset(asset.id)"
:read-only="isReadOnly"
:show-filename="config.show_filename"
:show-set-alt="showSetAlt"
Expand Down Expand Up @@ -227,6 +229,7 @@ export default {
innerDragging: false,
displayMode: 'grid',
lockedDynamicFolder: this.meta.dynamicFolder,
errorsById: {},
};
},

Expand Down Expand Up @@ -616,6 +619,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 @@ -635,6 +646,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
14 changes: 12 additions & 2 deletions resources/lang/en/validation.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,17 @@
'different' => 'This field and :other must be different.',
'digits' => 'Must be :digits digits.',
'digits_between' => 'Must be between :min and :max digits.',
'dimensions' => 'Invalid image dimensions.',
'dimensions' => [
'unknown' => 'Image dimensions are unknown.',
'ratio' => 'Must have a ratio of :ratio.',
'same' => 'Must be :comparison :width×:height pixels.',
'different' => 'Must be :comparison_width :width pixels wide and :comparison_height :height pixels tall.',
'width' => 'Must be :comparison_width :width pixels wide.',
'height' => 'Must be :comparison_height :height pixels tall.',
'min' => 'at least',
'max' => 'at most',
'exact' => 'exactly',
],
'distinct' => 'This field has a duplicate value.',
'doesnt_end_with' => 'Must not end with one of the following: :values.',
'doesnt_start_with' => 'Must not start with one of the following: :values.',
Expand All @@ -66,7 +76,7 @@
'numeric' => 'Must be greater than or equal :value.',
'string' => 'Must be greater than or equal :value characters.',
],
'image' => 'Must be an image.',
'image' => 'Must be an image of type: :extensions.',
'in' => 'This is invalid.',
'in_array' => 'This field does not exist in :other.',
'integer' => 'Must be an integer.',
Expand Down
14 changes: 12 additions & 2 deletions src/Fields/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,21 @@ public function alwaysSave()

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 = [
$this->handle.'.*' => $temp_rules->reject(fn ($rule) => in_array($rule, ['array', 'required', 'nullable']))->values()->all(),
$this->handle => $temp_rules->filter(fn ($rule) => in_array($rule, ['array', 'required', 'nullable']))->values()->all(),
];
} else {
$rules = [$this->handle => $temp_rules->all()];
}

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

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;

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

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

$result[$key] = $value;

return $result;
});
}

/**
* 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;
if (! is_int($width) || ! is_int($height)) {
$fail(__('statamic::validation.dimensions.unknown'));

return true;
});
}
return;
}

/**
* Get the validation error message.
*
* @return string
*/
public function message()
{
return __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.dimensions');
if ($message = $this->message($width, $height)) {
$fail($message);
}
}

/**
* Parse named parameters to $key => $value items.
*
* @param array $parameters
* @return array
*/
protected function parseNamedParameters($parameters)
public function message(int $width, int $height): ?string
{
return array_reduce($parameters, function ($result, $item) {
[$key, $value] = array_pad(explode('=', $item, 2), 2, null);
$invalid_ratio = $this->validateRatio($width, $height);
$invalid_width = $this->validateWidth($width);
$invalid_height = $this->validateHeight($height);
$key = match (true) {
$invalid_ratio => 'ratio',
$invalid_width && $invalid_height && $invalid_width === $invalid_height => 'same',
$invalid_width && $invalid_height && $invalid_width !== $invalid_height => 'different',
(bool) $invalid_width => 'width',
(bool) $invalid_height => 'height',
default => null,
};

if (! $key) {
return null;
}

$result[$key] = $value;
$prefix = Statamic::isCpRoute() ? 'statamic::' : '';

$comparisons = [
'min' => __("{$prefix}validation.dimensions.min"),
'max' => __("{$prefix}validation.dimensions.max"),
'exact' => __("{$prefix}validation.dimensions.exact"),
];

return __("{$prefix}validation.dimensions.{$key}", [
'width' => $this->parameters['width'] ?? $this->parameters['min_width'] ?? $this->parameters['max_width'] ?? null,
'height' => $this->parameters['height'] ?? $this->parameters['min_height'] ?? $this->parameters['max_height'] ?? null,
'ratio' => $this->parameters['ratio'] ?? null,
'comparison' => $comparisons[$invalid_width] ?? '',
'comparison_width' => $comparisons[$invalid_width] ?? '',
'comparison_height' => $comparisons[$invalid_height] ?? '',
]);
}

return $result;
});
public function validateWidth(int $width): ?string
{
return match (true) {
isset($this->parameters['width']) && $this->parameters['width'] != $width => 'exact',
isset($this->parameters['min_width']) && $this->parameters['min_width'] > $width => 'min',
isset($this->parameters['max_width']) && $this->parameters['max_width'] < $width => 'max',
default => null,
};
}

/**
* Test if the given width and height fail any conditions.
*
* @param array $parameters
* @param int $width
* @param int $height
* @return bool
*/
protected function failsBasicDimensionChecks($parameters, $width, $height)
public function validateHeight(int $height): ?string
{
return (isset($parameters['width']) && $parameters['width'] != $width) ||
(isset($parameters['min_width']) && $parameters['min_width'] > $width) ||
(isset($parameters['max_width']) && $parameters['max_width'] < $width) ||
(isset($parameters['height']) && $parameters['height'] != $height) ||
(isset($parameters['min_height']) && $parameters['min_height'] > $height) ||
(isset($parameters['max_height']) && $parameters['max_height'] < $height);
return match (true) {
isset($this->parameters['height']) && $this->parameters['height'] != $height => 'exact',
isset($this->parameters['min_height']) && $this->parameters['min_height'] > $height => 'min',
isset($this->parameters['max_height']) && $this->parameters['max_height'] < $height => 'max',
default => null,
};
}

/**
* Determine if the given parameters fail a dimension ratio check.
*
* @param array $parameters
* @param int $width
* @param int $height
* @return bool
*/
protected function failsRatioCheck($parameters, $width, $height)
public function validateRatio(int $width, int $height): bool
{
if (! isset($parameters['ratio'])) {
if (! isset($this->parameters['ratio'])) {
return false;
}

[$numerator, $denominator] = array_replace(
[1, 1], array_filter(sscanf($parameters['ratio'], '%f/%d'))
[1, 1],
array_filter(sscanf($this->parameters['ratio'], '%f/%d'))
);

$precision = 1 / (max($width, $height) + 1);

return abs($numerator / $denominator - $width / $height) > $precision;
}

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

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