From efaa2a34e3800e3397e79c43fcf907acdb46916f Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 27 Jul 2026 14:42:57 +0200 Subject: [PATCH 1/2] Add custom dashboard widgets --- CHANGELOG.md | 1 + src/Dashboard/Contracts/WidgetInterface.php | 8 + src/Dashboard/CustomWidgets.php | 102 ++++++++ src/Dashboard/Dashboard.php | 17 ++ src/Dashboard/Data/CustomWidgetDefinition.php | 41 +++ src/Dashboard/Widgets/Custom.php | 110 ++++++++ src/Dashboard/Widgets/Widget.php | 24 ++ .../Dashboard/DashboardController.php | 80 ++++-- .../Dashboard/InteractsWithWidgets.php | 13 +- .../Dashboard/WidgetsController.php | 29 ++- .../Feature/Dashboard/Widgets/CustomTest.php | 241 ++++++++++++++++++ 11 files changed, 626 insertions(+), 40 deletions(-) create mode 100644 src/Dashboard/CustomWidgets.php create mode 100644 src/Dashboard/Data/CustomWidgetDefinition.php create mode 100644 src/Dashboard/Widgets/Custom.php create mode 100644 tests/Feature/Dashboard/Widgets/CustomTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a2bdb576f98..56e22602691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - Moved `CraftCms\Cms\Cp\Concerns\EvaluatesClosures` to `CraftCms\Cms\Support\Concerns\EvaluatesClosures`. - Changed `CraftCms\Cms\FieldLayout\LayoutElements\BaseField::label()` to accept an optional label and return the field layout element when one is passed. Overrides must accept the new optional argument. diff --git a/src/Dashboard/Contracts/WidgetInterface.php b/src/Dashboard/Contracts/WidgetInterface.php index 905f8e5dbf2..2e47c631c67 100644 --- a/src/Dashboard/Contracts/WidgetInterface.php +++ b/src/Dashboard/Contracts/WidgetInterface.php @@ -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; + + public function getMaxColspan(): ?int; + /** * Returns the widget’s title. * diff --git a/src/Dashboard/CustomWidgets.php b/src/Dashboard/CustomWidgets.php new file mode 100644 index 00000000000..53d326aab44 --- /dev/null +++ b/src/Dashboard/CustomWidgets.php @@ -0,0 +1,102 @@ +|null */ + private ?Collection $definitions = null; + + private readonly FrontMatterParser $frontMatterParser; + + public function __construct() + { + $this->frontMatterParser = new FrontMatterParser(new SymfonyYamlFrontMatterParser); + } + + /** + * @return Collection + */ + 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(); + $handle = $metadata['handle'] ?? null; + + return new CustomWidgetDefinition( + filename: $filename, + handle: $handle, + label: $metadata['label'] ?? null, + icon: $metadata['icon'] ?? null, + maxColspan: $metadata['maxColspan'] ?? null, + title: $metadata['title'] ?? null, + titleFromLabel: ! array_key_exists('title', $metadata), + subtitle: $metadata['subtitle'] ?? null, + showByDefault: $metadata['showByDefault'] ?? false, + body: $parsed->getContent(), + ); + } +} diff --git a/src/Dashboard/Dashboard.php b/src/Dashboard/Dashboard.php index ff56f4f3091..49cfeb7b217 100644 --- a/src/Dashboard/Dashboard.php +++ b/src/Dashboard/Dashboard.php @@ -5,12 +5,14 @@ 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\Events\WidgetTypesResolving; 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\MyDrafts; use CraftCms\Cms\Dashboard\Widgets\NewUsers as NewUsersWidget; @@ -38,6 +40,10 @@ #[Singleton] readonly class Dashboard { + public function __construct( + private CustomWidgets $customWidgets, + ) {} + /** * @return Collection> */ @@ -291,6 +297,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)); @@ -314,6 +322,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, ]); diff --git a/src/Dashboard/Data/CustomWidgetDefinition.php b/src/Dashboard/Data/CustomWidgetDefinition.php new file mode 100644 index 00000000000..6d7461bfd23 --- /dev/null +++ b/src/Dashboard/Data/CustomWidgetDefinition.php @@ -0,0 +1,41 @@ +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; + } +} diff --git a/src/Dashboard/Widgets/Custom.php b/src/Dashboard/Widgets/Custom.php new file mode 100644 index 00000000000..e9835f84f3e --- /dev/null +++ b/src/Dashboard/Widgets/Custom.php @@ -0,0 +1,110 @@ +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(); + } + } +} diff --git a/src/Dashboard/Widgets/Widget.php b/src/Dashboard/Widgets/Widget.php index 84ef5e504bd..4d178605203 100644 --- a/src/Dashboard/Widgets/Widget.php +++ b/src/Dashboard/Widgets/Widget.php @@ -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. * diff --git a/src/Http/Controllers/Dashboard/DashboardController.php b/src/Http/Controllers/Dashboard/DashboardController.php index 80c20411bb8..246b9643aa6 100644 --- a/src/Http/Controllers/Dashboard/DashboardController.php +++ b/src/Http/Controllers/Dashboard/DashboardController.php @@ -6,7 +6,10 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Dashboard\Contracts\WidgetInterface; +use CraftCms\Cms\Dashboard\CustomWidgets; use CraftCms\Cms\Dashboard\Dashboard; +use CraftCms\Cms\Dashboard\Data\CustomWidgetDefinition; +use CraftCms\Cms\Dashboard\Widgets\Custom; use CraftCms\Cms\Support\Facades\InputNamespace; use CraftCms\Cms\Support\Json; use CraftCms\Cms\View\HtmlStack; @@ -24,41 +27,62 @@ public function __construct( private HtmlStack $HtmlStack, private Dashboard $dashboard, + private CustomWidgets $customWidgets, ) {} public function index() { - /** - * @var Collection $widgetTypeInfo - */ - $widgetTypeInfo = $this->dashboard->getAllWidgetTypes() - /** @var class-string $widgetType */ - ->filter(fn (string $widgetType) => $widgetType::isSelectable()) - /** @phpstan-ignore argument.unresolvableType */ - ->mapWithKeys(function (string $widgetType) { - $this->HtmlStack->startJsBuffer(); - $widget = $this->dashboard->createWidget($widgetType); - $settingsHtml = InputNamespace::namespaceInputs(fn () => (string) $widget->getSettingsHtml(), '__NAMESPACE__'); - $settingsJs = (string) $this->HtmlStack->clearJsBuffer(false); - - return [$widget::class => [ - 'iconSvg' => $this->getWidgetIconSvg($widget), - 'name' => $widget::displayName(), - 'maxColspan' => $widget::maxColspan(), - 'settingsHtml' => $settingsHtml, - 'settingsJs' => $settingsJs, - 'selectable' => true, - ]]; - }) - /** @phpstan-ignore argument.unresolvableType */ - ->sortBy('name'); + $widgets = $this->dashboard->getAllWidgets(); + + /** @var Collection|array{type: class-string, settings: array{definitionId: string}}> $widgetConfigs */ + $widgetConfigs = Collection::make(); + + foreach ($this->dashboard->getAllWidgetTypes() as $widgetType) { + if ($widgetType::isSelectable()) { + $widgetConfigs->put($widgetType, $widgetType); + } + } + + $this->customWidgets->all() + ->reject(fn (CustomWidgetDefinition $definition) => $widgets->contains( + fn (WidgetInterface $widget) => $widget instanceof Custom && $widget->definitionId === $definition->id, + )) + ->each(function (CustomWidgetDefinition $definition) use ($widgetConfigs) { + $widgetConfigs->put($definition->type(), [ + 'type' => Custom::class, + 'settings' => [ + 'definitionId' => $definition->id, + ], + ]); + }); + + /** @var Collection $widgetTypeInfo */ + $widgetTypeInfo = Collection::make(); + + foreach ($widgetConfigs as $type => $config) { + $this->HtmlStack->startJsBuffer(); + $widget = $this->dashboard->createWidget($config); + $settingsHtml = InputNamespace::namespaceInputs(fn () => (string) $widget->getSettingsHtml(), '__NAMESPACE__'); + $settingsJs = (string) $this->HtmlStack->clearJsBuffer(false); + + $widgetTypeInfo->put($type, [ + 'iconSvg' => $this->getWidgetIconSvg($widget), + 'name' => $widget->getDisplayName(), + 'maxColspan' => $widget->getMaxColspan(), + 'settingsHtml' => $settingsHtml, + 'settingsJs' => $settingsJs, + 'selectable' => true, + ]); + } + + $widgetTypeInfo = $widgetTypeInfo->sortBy(fn (array $info) => $info['name']); $variables = []; // Assemble the list of existing widgets $variables['widgets'] = []; $allWidgetJs = ''; - $this->dashboard->getAllWidgets() + $widgets ->each(function (WidgetInterface $widget) use ($widgetTypeInfo, &$variables, &$allWidgetJs) { $this->HtmlStack->startJsBuffer(); $info = $this->getWidgetInfo($widget); @@ -71,8 +95,10 @@ public function index() if (! $widgetTypeInfo->has($info['type'])) { $widgetTypeInfo->put($info['type'], [ 'iconSvg' => $this->getWidgetIconSvg($widget), - 'name' => $widget::displayName(), - 'maxColspan' => $widget::maxColspan(), + 'name' => $widget->getDisplayName(), + 'maxColspan' => $widget->getMaxColspan(), + 'settingsHtml' => '', + 'settingsJs' => '', 'selectable' => false, ]); } diff --git a/src/Http/Controllers/Dashboard/InteractsWithWidgets.php b/src/Http/Controllers/Dashboard/InteractsWithWidgets.php index c631ca753d6..ce84dca8a56 100644 --- a/src/Http/Controllers/Dashboard/InteractsWithWidgets.php +++ b/src/Http/Controllers/Dashboard/InteractsWithWidgets.php @@ -13,12 +13,15 @@ trait InteractsWithWidgets { protected function getWidgetIconSvg(WidgetInterface $widget): ?string { - $icon = $widget::icon(); - $label = $widget::displayName(); + $icon = $widget->getIcon(); + $label = $widget->getDisplayName(); return $icon ? Icons::svg($icon, $label) : Icons::fallbackSvg($label); } + /** + * @return array{id: int|null, type: string, colspan: int, title: string|null, subtitle: string|null, name: string, bodyHtml: string, settingsHtml: string, settingsJs: string, settings: array}|false + */ protected function getWidgetInfo(WidgetInterface $widget): array|false { // Get the body HTML @@ -36,17 +39,17 @@ protected function getWidgetInfo(WidgetInterface $widget): array|false // Get the colspan (limited to the widget type's max allowed colspan) $colspan = $widget->colspan ?: 1; - if (($maxColspan = $widget::maxColspan()) && $colspan > $maxColspan) { + if (($maxColspan = $widget->getMaxColspan()) && $colspan > $maxColspan) { $colspan = $maxColspan; } return [ 'id' => $widget->id, - 'type' => $widget::class, + 'type' => $widget->getType(), 'colspan' => $colspan, 'title' => $widget->getTitle(), 'subtitle' => $widget->getSubtitle(), - 'name' => $widget->displayName(), + 'name' => $widget->getDisplayName(), 'bodyHtml' => $widgetBodyHtml, 'settingsHtml' => $settingsHtml, 'settingsJs' => (string) $settingsJs, diff --git a/src/Http/Controllers/Dashboard/WidgetsController.php b/src/Http/Controllers/Dashboard/WidgetsController.php index e835c2d5dca..9c7973f5d80 100644 --- a/src/Http/Controllers/Dashboard/WidgetsController.php +++ b/src/Http/Controllers/Dashboard/WidgetsController.php @@ -5,7 +5,9 @@ namespace CraftCms\Cms\Http\Controllers\Dashboard; use CraftCms\Cms\Dashboard\Contracts\WidgetInterface; +use CraftCms\Cms\Dashboard\CustomWidgets; use CraftCms\Cms\Dashboard\Dashboard; +use CraftCms\Cms\Dashboard\Widgets\Custom; use CraftCms\Cms\Support\Json; use CraftCms\Cms\View\HtmlStack; use Illuminate\Http\JsonResponse; @@ -21,6 +23,7 @@ public function __construct( private HtmlStack $HtmlStack, private Dashboard $dashboard, + private CustomWidgets $customWidgets, ) {} public function store(Request $request): JsonResponse @@ -30,10 +33,11 @@ public function store(Request $request): JsonResponse 'settings' => ['nullable', 'array'], ]); - /** @var class-string $type */ - $type = $data['type']; + $type = (string) $data['type']; + $widgetType = $this->dashboard->getAllWidgetTypes()->first(fn (string $widgetType) => $widgetType === $type); + $customDefinition = $widgetType ? null : $this->customWidgets->fromType($type); - if (! in_array($type, $this->dashboard->getAllWidgetTypes()->all())) { + if (! $widgetType && ! $customDefinition) { throw ValidationException::withMessages([ 'type' => 'Invalid widget type.', ]); @@ -45,10 +49,17 @@ public function store(Request $request): JsonResponse $settings = $request->input($request->input('settingsNamespace')); } - $widget = $this->dashboard->createWidget([ - 'type' => $type, - 'settings' => $settings, - ]); + $widget = $customDefinition + ? $this->dashboard->createWidget([ + 'type' => Custom::class, + 'settings' => [ + 'definitionId' => $customDefinition->id, + ], + ]) + : $this->dashboard->createWidget([ + 'type' => $widgetType, + 'settings' => $settings, + ]); return $this->saveAndReturnWidget($widget); } @@ -66,7 +77,9 @@ public function update(Request $request): JsonResponse $widget = $this->dashboard->getWidgetById($request->integer('widgetId')); // Create a new widget model with the new settings - $settings = $request->input("widget{$widget->id}-settings"); + $settings = $widget instanceof Custom + ? $widget->getSettings() + : $request->input("widget{$widget->id}-settings"); Validator::validate($settings, $widget->getRules()); diff --git a/tests/Feature/Dashboard/Widgets/CustomTest.php b/tests/Feature/Dashboard/Widgets/CustomTest.php new file mode 100644 index 00000000000..4a6b8282518 --- /dev/null +++ b/tests/Feature/Dashboard/Widgets/CustomTest.php @@ -0,0 +1,241 @@ +widgetsPath = resource_path('widgets'); + File::ensureDirectoryExists($this->widgetsPath); + File::cleanDirectory($this->widgetsPath); +}); + +afterEach(function () { + File::deleteDirectory($this->widgetsPath); +}); + +it('discovers top-level Markdown files', function () { + File::put("$this->widgetsPath/welcome.md", '# Welcome'); + File::put("$this->widgetsPath/.hidden.MD", '# Hidden'); + File::put("$this->widgetsPath/ignored.html", '

Ignored

'); + File::ensureDirectoryExists("$this->widgetsPath/nested"); + File::put("$this->widgetsPath/nested/ignored.md", '# Ignored'); + + $definitions = app(CustomWidgets::class)->all(); + + expect($definitions) + ->toHaveCount(2) + ->toHaveKeys(['path:.hidden.MD', 'path:welcome.md']); +}); + +it('ignores unsupported frontmatter', function (string $frontmatter) { + File::put("$this->widgetsPath/widget.md", "$frontmatter\n# Widget"); + + $definition = app(CustomWidgets::class)->all()->sole(); + + expect($definition->id)->toBe('path:widget.md') + ->and($definition->label)->toBeNull(); +})->with([ + 'unknown property' => "---\nunknown: true\n---", + 'list' => "---\n- ignored\n---", +]); + +it('renders metadata and Markdown through Twig', function () { + actingAs($user = User::find()->one()); + $user->setFriendlyName(''); + + File::put("$this->widgetsPath/welcome.md", <<<'MD' +--- +handle: welcome +label: "R{{ '&' }}D {{ currentUser.friendlyName }}" +icon: hand-wave +maxColspan: 2 +subtitle: 'Craft edition {{ CraftEdition }}' +--- + +## Welcome {{ currentUser.friendlyName }} + +MD); + + $widget = app(Dashboard::class)->createWidget([ + 'type' => Custom::class, + 'settings' => [ + 'definitionId' => 'handle:welcome', + ], + ]); + + expect($widget) + ->getDisplayName()->toBe('R&D '.currentUser()->friendlyName) + ->getTitle()->toBe('R&D '.currentUser()->friendlyName) + ->getSubtitle()->toStartWith('Craft edition ') + ->getIcon()->toBe('hand-wave') + ->getMaxColspan()->toBe(2) + ->and($widget->getBodyHtml()) + ->toContain('<script>alert(1)</script>') + ->not->toContain('