Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

- Added fluent APIs for creating and modifying `CraftCms\Cms\FieldLayout\FieldLayout`, `CraftCms\Cms\FieldLayout\FieldLayoutTab`, and field layout elements, including dependency-injected closure values.
- Added support for Markdown-based custom Dashboard widgets in the application's `resources/widgets/` directory.
- Added support for configuring field layout field instruction positions.
- Changed `craft:resave:all` to discover registered `craft:resave:*` Artisan commands directly, rather than relying on a resolving event. ([#19270](https://github.com/craftcms/cms/pull/19270))
- Changed `CraftCms\Cms\Search\Events\SearchPerformed` to be a readonly, immutable event; its `$results` and `$scores` properties can no longer be overridden by listeners. `CraftCms\Cms\Search\Events\SearchScoresResolving` should be used to override scores instead. ([#19308](https://github.com/craftcms/cms/pull/19308))
Expand Down
8 changes: 8 additions & 0 deletions src/Dashboard/Contracts/WidgetInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ public static function icon(): ?string;
*/
public static function maxColspan(): ?int;

public function getType(): string;

public function getIcon(): ?string;

public function getDisplayName(): string;
Comment thread
riasvdv marked this conversation as resolved.

public function getMaxColspan(): ?int;

/**
* Returns the widget’s title.
*
Expand Down
127 changes: 127 additions & 0 deletions src/Dashboard/CustomWidgets.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

declare(strict_types=1);

namespace CraftCms\Cms\Dashboard;

use CraftCms\Cms\Dashboard\Data\CustomWidgetDefinition;
use Illuminate\Container\Attributes\Singleton;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use InvalidArgumentException;
use League\CommonMark\Extension\FrontMatter\Data\SymfonyYamlFrontMatterParser;
use League\CommonMark\Extension\FrontMatter\FrontMatterParser;
use Symfony\Component\Finder\SplFileInfo;

#[Singleton]
class CustomWidgets
{
/** @var Collection<string, CustomWidgetDefinition>|null */
private ?Collection $definitions = null;

private readonly FrontMatterParser $frontMatterParser;

public function __construct()
{
$this->frontMatterParser = new FrontMatterParser(new SymfonyYamlFrontMatterParser);
}

/**
* @return Collection<string, CustomWidgetDefinition>
*/
public function all(): Collection
{
if ($this->definitions !== null) {
return $this->definitions;
}

$path = resource_path('widgets');

if (! is_dir($path)) {
return $this->definitions = Collection::make();
}

$definitions = Collection::make();
$handles = [];

foreach (File::files($path, hidden: true) as $file) {
if (strtolower($file->getExtension()) !== 'md') {
continue;
}

$definition = $this->definition($file);

if ($definition->handle !== null) {
$handle = strtolower($definition->handle);

if (isset($handles[$handle])) {
throw new InvalidArgumentException("Custom widget files [{$handles[$handle]}] and [{$definition->filename}] have the same handle.");
}

$handles[$handle] = $definition->filename;
}

$definitions->put($definition->id, $definition);
}

return $this->definitions = $definitions;
}

public function find(string $id): ?CustomWidgetDefinition
{
return $this->all()->get($id);
}

public function fromType(string $type): ?CustomWidgetDefinition
{
return $this->all()->first(fn (CustomWidgetDefinition $definition) => $definition->type() === $type);
}

private function definition(SplFileInfo $file): CustomWidgetDefinition
{
$filename = $file->getFilename();
$contents = File::get($file->getPathname());

$parsed = $this->frontMatterParser->parse($contents);
$metadata = (array) $parsed->getFrontMatter();

$string = function (string $property) use ($filename, $metadata): ?string {
$value = $metadata[$property] ?? null;

if ($value === null || is_scalar($value)) {
return $value === null ? null : (string) $value;
}

throw new InvalidArgumentException("Custom widget file [$filename] frontmatter property [$property] must be a string or null.");
};

$maxColspan = $metadata['maxColspan'] ?? null;

if ($maxColspan !== null) {
$maxColspan = filter_var($maxColspan, FILTER_VALIDATE_INT);

if ($maxColspan === false) {
throw new InvalidArgumentException("Custom widget file [$filename] frontmatter property [maxColspan] must be an integer between 1 and 4, or null.");
}
}

$showByDefault = filter_var($metadata['showByDefault'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);

if ($showByDefault === null) {
throw new InvalidArgumentException("Custom widget file [$filename] frontmatter property [showByDefault] must be a boolean.");
}

return new CustomWidgetDefinition(
filename: $filename,
handle: $string('handle'),
label: $string('label'),
icon: $string('icon'),
maxColspan: $maxColspan,
title: $string('title'),
titleFromLabel: ! array_key_exists('title', $metadata),
subtitle: $string('subtitle'),
showByDefault: $showByDefault,
body: $parsed->getContent(),
);
}
}
17 changes: 17 additions & 0 deletions src/Dashboard/Dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
namespace CraftCms\Cms\Dashboard;

use CraftCms\Cms\Dashboard\Contracts\WidgetInterface;
use CraftCms\Cms\Dashboard\Data\CustomWidgetDefinition;
use CraftCms\Cms\Dashboard\Events\WidgetDeleted;
use CraftCms\Cms\Dashboard\Events\WidgetDeleting;
use CraftCms\Cms\Dashboard\Events\WidgetSaved;
use CraftCms\Cms\Dashboard\Events\WidgetSaving;
use CraftCms\Cms\Dashboard\Widgets\CraftSupport as CraftSupportWidget;
use CraftCms\Cms\Dashboard\Widgets\Custom as CustomWidget;
use CraftCms\Cms\Dashboard\Widgets\Feed as FeedWidget;
use CraftCms\Cms\Dashboard\Widgets\RecentEntries as RecentEntriesWidget;
use CraftCms\Cms\Dashboard\Widgets\Updates as UpdatesWidget;
Expand All @@ -34,6 +36,10 @@
#[Singleton]
readonly class Dashboard
{
public function __construct(
private CustomWidgets $customWidgets,
) {}

/**
* Creates a widget with a given config.
*
Expand Down Expand Up @@ -266,6 +272,8 @@ public function changeWidgetColspan(int $widgetId, int $colspan): bool
private function addDefaultUserWidgets(): void
{
$user = currentUser();
$customWidgets = $this->customWidgets->all()
->filter(fn (CustomWidgetDefinition $definition) => $definition->showByDefault);

// Recent Entries widget
$this->saveWidget($this->createWidget(RecentEntriesWidget::class));
Expand All @@ -289,6 +297,15 @@ private function addDefaultUserWidgets(): void
],
]));

$customWidgets->each(function (CustomWidgetDefinition $definition) {
$this->saveWidget($this->createWidget([
'type' => CustomWidget::class,
'settings' => [
'definitionId' => $definition->id,
],
]));
});

User::where('id', $user->getCraftUserId())->update([
'hasDashboard' => true,
]);
Expand Down
41 changes: 41 additions & 0 deletions src/Dashboard/Data/CustomWidgetDefinition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace CraftCms\Cms\Dashboard\Data;

use CraftCms\Cms\Validation\Rules\HandleRule;
use InvalidArgumentException;

readonly class CustomWidgetDefinition
{
public string $id;

public function __construct(
public string $filename,
public ?string $handle,
public ?string $label,
public ?string $icon,
public ?int $maxColspan,
public ?string $title,
public bool $titleFromLabel,
public ?string $subtitle,
public bool $showByDefault,
public string $body,
) {
if ($handle !== null) {
new HandleRule()->validate('handle', $handle, fn () => throw new InvalidArgumentException("Custom widget file [$filename] has an invalid handle."));
}

if ($maxColspan !== null && ($maxColspan < 1 || $maxColspan > 4)) {
throw new InvalidArgumentException("Custom widget file [$filename] frontmatter property [maxColspan] must be an integer between 1 and 4, or null.");
}

$this->id = $handle ? "handle:$handle" : "path:$filename";
}

public function type(): string
{
return self::class.':'.$this->id;
}
}
110 changes: 110 additions & 0 deletions src/Dashboard/Widgets/Custom.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

declare(strict_types=1);

namespace CraftCms\Cms\Dashboard\Widgets;

use CraftCms\Cms\Dashboard\CustomWidgets;
use CraftCms\Cms\Dashboard\Data\CustomWidgetDefinition;
use CraftCms\Cms\Support\Facades\Markdown;
use CraftCms\Cms\Support\Str;
use CraftCms\Cms\Twig\Twig;
use Override;

class Custom extends Widget
{
public string $definitionId = '';

public function __construct(
private readonly CustomWidgets $customWidgets,
private readonly Twig $twig,
array|object $config = [],
) {
parent::__construct($config);
}

#[Override]
public function getType(): string
{
return $this->definition()?->type() ?? static::class;
}

#[Override]
public function getDisplayName(): string
{
$definition = $this->definition();

if (! $definition) {
return static::displayName();
}

$label = $definition->label === null ? '' : trim($this->render($definition->label, false));

return $label !== '' ? $label : Str::headline(pathinfo($definition->filename, PATHINFO_FILENAME));
}

#[Override]
public function getIcon(): ?string
{
return $this->definition()?->icon;
}

#[Override]
public function getMaxColspan(): ?int
{
return $this->definition()?->maxColspan;
}

#[Override]
public function getTitle(): ?string
{
$definition = $this->definition();

if (! $definition) {
return null;
}

if ($definition->titleFromLabel) {
return $this->getDisplayName();
}

return $definition->title === null ? null : trim($this->render($definition->title, false));
}

#[Override]
public function getSubtitle(): ?string
{
$subtitle = $this->definition()?->subtitle;

return $subtitle === null ? null : trim($this->render($subtitle, false));
}

#[Override]
public function getBodyHtml(): ?string
{
$definition = $this->definition();

if (! $definition) {
return null;
}

return Markdown::parse($this->render($definition->body, 'html'));
}

private function definition(): ?CustomWidgetDefinition
{
return $this->customWidgets->find($this->definitionId);
}

private function render(string $template, string|false $escaper): string
{
$twig = $this->twig->get();
$twig->setDefaultEscaperStrategy($escaper);

try {
return $twig->createTemplate($template, $this->definition()?->filename)->render();
} finally {
$twig->setDefaultEscaperStrategy();
}
}
}
24 changes: 24 additions & 0 deletions src/Dashboard/Widgets/Widget.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,30 @@ public static function displayName(): string
return array_pop($classNameParts);
}

#[Override]
public function getType(): string
{
return static::class;
}

#[Override]
public function getIcon(): ?string
{
return static::icon();
}

#[Override]
public function getDisplayName(): string
{
return static::displayName();
}

#[Override]
public function getMaxColspan(): ?int
{
return static::maxColspan();
}

/**
* Returns the widget’s title.
*
Expand Down
Loading
Loading