diff --git a/packages/craftcms-legacy/cpcompat/webpack.config.js b/packages/craftcms-legacy/cpcompat/webpack.config.js
index f598918bb0c..51f09c17ba7 100644
--- a/packages/craftcms-legacy/cpcompat/webpack.config.js
+++ b/packages/craftcms-legacy/cpcompat/webpack.config.js
@@ -7,6 +7,10 @@ module.exports = getConfig({
config: {
entry: {
'component-select-input': './component-select-input.js',
+ dashboard: require('path').resolve(
+ __dirname,
+ '../../../yii2-adapter/resources/js/dashboard.js'
+ ),
'cp-compat': './cp-compat.js',
'legacy-html-control': './legacy-html-control.js',
},
diff --git a/packages/craftcms-legacy/dashboard/src/Dashboard.js b/packages/craftcms-legacy/dashboard/src/Dashboard.js
index 1f016a750a5..10f00cd7f85 100644
--- a/packages/craftcms-legacy/dashboard/src/Dashboard.js
+++ b/packages/craftcms-legacy/dashboard/src/Dashboard.js
@@ -409,7 +409,7 @@ import './dashboard.scss';
this.storedSettings = storedSettings;
this.$settingsToggle = this.$container.find('[data-settings-toggle]');
- this.$gridItem = this.$container.parent();
+ this.$gridItem = this.$container.closest('.item');
// Store a reference to this object on the container element
this.$container.data('widget', this);
@@ -425,11 +425,13 @@ import './dashboard.scss';
}
this.$front = this.$container.children('.front');
- this.$settingsBtn = this.$front.find('> .pane > .icon.settings');
- this.$heading = this.$front.find('> .pane > .widget-heading');
+ const $pane = this.$front.children('.pane, craft-pane');
+
+ this.$settingsBtn = $pane.children('.icon.settings, .widget-settings-button');
+ this.$heading = $pane.children('.widget-heading');
this.$title = this.$heading.find('> h2');
this.$subtitle = this.$heading.find('> h5');
- this.$bodyContainer = this.$front.find('> .pane > .body');
+ this.$bodyContainer = $pane.children('.body');
this.setSettings(settingsHtml, initSettingsFn, settingsForm);
diff --git a/packages/craftcms-ui/scripts/generate-vue-wrappers.js b/packages/craftcms-ui/scripts/generate-vue-wrappers.js
index b9ee12a21e6..6fb6c2b9158 100644
--- a/packages/craftcms-ui/scripts/generate-vue-wrappers.js
+++ b/packages/craftcms-ui/scripts/generate-vue-wrappers.js
@@ -175,7 +175,7 @@ const VALUE_COMPONENTS = [
tagName: 'craft-input-file',
className: 'CraftInputFile',
fileName: 'CraftInputFile',
- modelType: 'File[]',
+ modelType: "import('../components/input-file/input-file.ts.mjs').default['modelValue']",
importPath: '../components/input-file/input-file',
slots: [
'label',
diff --git a/resources/js/bootstrap/cp-app.ts b/resources/js/bootstrap/cp-app.ts
index 5840d886b5e..ac72496d61f 100644
--- a/resources/js/bootstrap/cp-app.ts
+++ b/resources/js/bootstrap/cp-app.ts
@@ -18,11 +18,13 @@ import SystemMessages from '@/modules/utilities/components/system-messages/Syste
import CpLink from '@/common/components/CpLink.vue';
import {cpComponentRegistry} from './components';
import {registerFormComponents} from '@/modules/forms/register';
+import {registerWidgetComponents} from '@/modules/dashboard/register';
export const config = ConfigService.getInstance();
export const queue = QueueService.getInstance();
registerFormComponents(cpComponentRegistry);
+registerWidgetComponents(cpComponentRegistry);
export function installCpApp(app: App): void {
app.config.compilerOptions.isCustomElement = (tag) => tag.includes('-');
diff --git a/resources/js/common/components/HtmlFragmentRenderer.test.ts b/resources/js/common/components/HtmlFragmentRenderer.test.ts
new file mode 100644
index 00000000000..74428800522
--- /dev/null
+++ b/resources/js/common/components/HtmlFragmentRenderer.test.ts
@@ -0,0 +1,104 @@
+import {afterEach, expect, it, vi} from 'vite-plus/test';
+import {createApp, h, nextTick, ref, type App} from 'vue';
+import {appendBodyHtml, appendElementHtml} from '@craftcms/ui/utilities/dom';
+import HtmlFragmentRenderer from './HtmlFragmentRenderer.vue';
+
+vi.mock('@craftcms/ui', () => import('@craftcms/ui/utilities/dom'));
+
+let app: App;
+let host: HTMLElement;
+
+afterEach(() => {
+ app?.unmount();
+ host?.remove();
+});
+
+it.each([false, true])(
+ 'replaces displayed content with custom rendering: %s',
+ async (custom) => {
+ const fragment = ref({
+ html: '
Original content
',
+ headHtml: '',
+ bodyHtml: '',
+ });
+ host = document.createElement('div');
+ document.body.append(host);
+ app = createApp({
+ render: () =>
+ h(HtmlFragmentRenderer, {
+ fragment: fragment.value,
+ render: custom
+ ? (value: CraftCms.Cms.View.HtmlFragment, container: HTMLElement) =>
+ appendElementHtml(value.html, container)
+ : undefined,
+ }),
+ });
+ app.mount(host);
+
+ await vi.waitFor(() => expect(host.textContent).toBe('Original content'));
+
+ fragment.value = {
+ ...fragment.value,
+ html: 'View entries',
+ };
+
+ await vi.waitFor(() => expect(host.textContent).toBe('View entries'));
+ expect(host.querySelector('a')?.getAttribute('href')).toBe('/entries');
+ }
+);
+
+it('removes fragment content outside its container when leaving the page', async () => {
+ host = document.createElement('div');
+ document.body.append(host);
+ app = createApp({
+ render: () =>
+ h(HtmlFragmentRenderer, {
+ fragment: {
+ html: 'Widget
',
+ headHtml: '',
+ bodyHtml: '',
+ },
+ }),
+ });
+ app.mount(host);
+
+ await vi.waitFor(() =>
+ expect(document.querySelector('#widget-popup')?.textContent).toBe(
+ 'Widget popup'
+ )
+ );
+ app.unmount();
+
+ expect(document.querySelector('#widget-popup')).toBeNull();
+});
+
+it('does not leave popup content behind when rendering finishes after leaving the page', async () => {
+ let finish!: () => void;
+ const pending = new Promise((resolve) => {
+ finish = resolve;
+ });
+ let rendering!: ReturnType;
+ host = document.createElement('div');
+ document.body.append(host);
+ app = createApp({
+ render: () =>
+ h(HtmlFragmentRenderer, {
+ fragment: {html: 'Widget', headHtml: '', bodyHtml: ''},
+ render: () => {
+ rendering = pending.then(() =>
+ appendBodyHtml('')
+ );
+ return rendering;
+ },
+ }),
+ });
+ app.mount(host);
+ await nextTick();
+
+ app.unmount();
+ finish();
+ await rendering;
+ await nextTick();
+
+ expect(document.querySelector('#late-widget-popup')).toBeNull();
+});
diff --git a/resources/js/common/components/HtmlFragmentRenderer.vue b/resources/js/common/components/HtmlFragmentRenderer.vue
index 4eca2626475..d0280586bcd 100644
--- a/resources/js/common/components/HtmlFragmentRenderer.vue
+++ b/resources/js/common/components/HtmlFragmentRenderer.vue
@@ -10,6 +10,12 @@
const props = withDefaults(
defineProps<{
fragment?: CraftCms.Cms.View.HtmlFragment | null;
+ /** Override loading while retaining stale-run cleanup and ready handling. */
+ render?: (
+ fragment: CraftCms.Cms.View.HtmlFragment,
+ container: HTMLElement,
+ active: () => boolean
+ ) => Promise;
/** Tag name for the container element. */
as?: string;
}>(),
@@ -37,13 +43,13 @@
};
const remember = async (
- promise: Promise,
+ promise: Promise,
currentRunId: number
): Promise => {
const dispose = await promise;
- if (currentRunId !== runId) {
- dispose();
+ if (!dispose || currentRunId !== runId) {
+ dispose?.();
return false;
}
@@ -84,25 +90,40 @@
lastElement = element;
disposeAll();
- if (
- headHtml &&
- !(await remember(appendHeadHtml(headHtml), currentRunId))
- ) {
- return;
- }
-
- if (
- html &&
- !(await remember(appendElementHtml(html, element), currentRunId))
- ) {
- return;
- }
-
- if (
- bodyHtml &&
- !(await remember(appendBodyHtml(bodyHtml), currentRunId))
- ) {
- return;
+ if (props.render) {
+ if (
+ !(await remember(
+ props.render(
+ {html, headHtml, bodyHtml},
+ element,
+ () => currentRunId === runId
+ ),
+ currentRunId
+ ))
+ ) {
+ return;
+ }
+ } else {
+ if (
+ headHtml &&
+ !(await remember(appendHeadHtml(headHtml), currentRunId))
+ ) {
+ return;
+ }
+
+ if (
+ html &&
+ !(await remember(appendElementHtml(html, element), currentRunId))
+ ) {
+ return;
+ }
+
+ if (
+ bodyHtml &&
+ !(await remember(appendBodyHtml(bodyHtml), currentRunId))
+ ) {
+ return;
+ }
}
// Upgrade legacy UI elements (lightswitches, field toggles, menus, …)
diff --git a/resources/js/common/components/MainNav.test.ts b/resources/js/common/components/MainNav.test.ts
index b15a6cd6ee9..18277155357 100644
--- a/resources/js/common/components/MainNav.test.ts
+++ b/resources/js/common/components/MainNav.test.ts
@@ -1,3 +1,4 @@
+import '@craftcms/ui/components/nav-item/nav-item';
import {expect, it, vi} from 'vite-plus/test';
import {createApp, nextTick, reactive} from 'vue';
@@ -6,10 +7,6 @@ const state = vi.hoisted(() => ({
page: null as any,
}));
-vi.mock('@/common/composables/useCraftData', () => ({
- default: () => state.craftData,
-}));
-
vi.mock('@inertiajs/vue3', async () => ({
...(await vi.importActual('@inertiajs/vue3')),
usePage: () => state.page,
@@ -41,6 +38,7 @@ it('updates the active item when the shared navigation changes', async () => {
});
state.page = reactive({
props: {
+ craft: state.craftData,
queue: {
displayedJob: null,
hasReservedJobs: false,
@@ -55,18 +53,26 @@ it('updates the active item when the shared navigation changes', async () => {
app.mount(container);
await nextTick();
- state.craftData.nav = state.craftData.nav.map((item: any) => ({
- ...item,
- selected: item.url === '/assets',
- }));
+ state.page.props.craft = {
+ nav: state.craftData.nav.map((item: any) => ({
+ ...item,
+ selected: item.url === '/assets',
+ })),
+ };
await nextTick();
const items = Array.from(container.querySelectorAll('craft-nav-item'));
const entries = items.find((item) => item.textContent?.includes('Entries'));
const assets = items.find((item) => item.textContent?.includes('Assets'));
- expect((entries as any).active).toBe(false);
- expect((assets as any).active).toBe(true);
+ await vi.waitFor(() => {
+ expect(
+ entries?.shadowRoot?.querySelector('a')?.getAttribute('aria-current')
+ ).toBe('false');
+ expect(
+ assets?.shadowRoot?.querySelector('a')?.getAttribute('aria-current')
+ ).toBe('page');
+ });
app.unmount();
container.remove();
diff --git a/resources/js/common/components/MainNav.vue b/resources/js/common/components/MainNav.vue
index 62df2ee8ac5..dc29cc3c068 100644
--- a/resources/js/common/components/MainNav.vue
+++ b/resources/js/common/components/MainNav.vue
@@ -1,10 +1,11 @@
+
+
+
+
+
+
{{
+ error
+ }}
+
+ -
+ {{
+ item.title
+ }}
+
+ {{ formatDate(item.date) }}
+
+
+
{{
+ t('Loading…')
+ }}
+
+
+
diff --git a/resources/js/modules/dashboard/HtmlWidget.vue b/resources/js/modules/dashboard/HtmlWidget.vue
new file mode 100644
index 00000000000..363016aa166
--- /dev/null
+++ b/resources/js/modules/dashboard/HtmlWidget.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+ {{
+ t('Loading…')
+ }}
+
+
+ {{ t('A server error occurred.') }}
+
+
+
+
+
+
+
diff --git a/resources/js/modules/dashboard/MyDrafts.vue b/resources/js/modules/dashboard/MyDrafts.vue
new file mode 100644
index 00000000000..d27add26b1d
--- /dev/null
+++ b/resources/js/modules/dashboard/MyDrafts.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/resources/js/modules/dashboard/NewUsers.vue b/resources/js/modules/dashboard/NewUsers.vue
new file mode 100644
index 00000000000..ce61698bba6
--- /dev/null
+++ b/resources/js/modules/dashboard/NewUsers.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
{{
+ error
+ }}
+
{{
+ t('Loading…')
+ }}
+
+
+
+
diff --git a/resources/js/modules/dashboard/QuickPost.test.ts b/resources/js/modules/dashboard/QuickPost.test.ts
new file mode 100644
index 00000000000..c801b47b38a
--- /dev/null
+++ b/resources/js/modules/dashboard/QuickPost.test.ts
@@ -0,0 +1,112 @@
+import {afterEach, expect, it, vi} from 'vite-plus/test';
+import {createApp, defineComponent, h, type App} from 'vue';
+import QuickPost from './QuickPost.vue';
+import type {DashboardWidget} from './types';
+import {SlideoutHost, closeAllSlideouts, useSlideout} from '@/common/slideouts';
+import {setSlideoutPageLoader} from '@/common/slideouts/store';
+
+const state = vi.hoisted(() => ({
+ post: vi.fn(),
+ reload: vi.fn(),
+}));
+vi.mock('@craftcms/ui', async () => ({
+ ...(await vi.importActual('@craftcms/ui')),
+ actionClient: {post: state.post},
+ t: (message: string) => message,
+}));
+vi.mock('@inertiajs/vue3', async () => ({
+ ...(await vi.importActual('@inertiajs/vue3')),
+ router: {reload: state.reload},
+}));
+vi.mock('@actions/Entries/CreateEntryController', () => ({
+ default: {
+ '/{cpTrigger?}/{actionTrigger?}/entries/create': {
+ url: () => '/entries/create',
+ },
+ },
+}));
+
+let app: App;
+let host: HTMLElement;
+
+afterEach(() => {
+ closeAllSlideouts();
+ setSlideoutPageLoader();
+ vi.unstubAllGlobals();
+ app?.unmount();
+ host?.remove();
+ vi.resetAllMocks();
+});
+
+it('opens the created entry in a slideout and refreshes the dashboard after publishing', async () => {
+ // Icons are external assets; the editor response is supplied below.
+ vi.stubGlobal(
+ 'fetch',
+ async () => new Response('')
+ );
+ setSlideoutPageLoader(async (href) => ({
+ component: defineComponent({
+ setup() {
+ const slideout = useSlideout()!;
+ return () =>
+ h('div', [
+ h('h1', 'Edit news draft'),
+ h(
+ 'button',
+ {onClick: () => slideout.saved({draft: true})},
+ 'Save draft'
+ ),
+ h('button', {onClick: () => slideout.saved({})}, 'Publish'),
+ ]);
+ },
+ }),
+ props: {},
+ url: href,
+ }));
+ state.post.mockResolvedValue({
+ data: {cpEditUrl: '/entries/news/123?draftId=456'},
+ });
+ const params = {section: 'news', type: 'article', siteId: 1};
+ host = document.createElement('div');
+ document.body.append(host);
+ app = createApp({
+ render: () =>
+ h('div', [
+ h(SlideoutHost, {assetVersion: 'test'}),
+ h(QuickPost, {
+ widget: {
+ id: 1,
+ type: 'QuickPost',
+ name: 'Quick Post',
+ title: 'Create entry',
+ subtitle: null,
+ colspan: 1,
+ maxColspan: 4,
+ settings: {},
+ settingsForm: null,
+ component: 'craft:widget-quick-post',
+ data: {params},
+ fragment: {html: '', headHtml: '', bodyHtml: ''},
+ } satisfies DashboardWidget,
+ }),
+ ]),
+ });
+ app.mount(host);
+
+ const button = host.querySelector('craft-button')!;
+ button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
+
+ await vi.waitFor(() =>
+ expect(document.querySelector('[role=dialog]')?.textContent).toContain(
+ 'Edit news draft'
+ )
+ );
+
+ const dialog = document.querySelector('[role=dialog]')!;
+ const buttons = Array.from(dialog.querySelectorAll('button'));
+ buttons.find((button) => button.textContent === 'Save draft')!.click();
+ expect(state.reload).not.toHaveBeenCalled();
+
+ buttons.find((button) => button.textContent === 'Publish')!.click();
+ expect(state.reload).toHaveBeenCalled();
+});
diff --git a/resources/js/modules/dashboard/QuickPost.vue b/resources/js/modules/dashboard/QuickPost.vue
new file mode 100644
index 00000000000..38e644b6414
--- /dev/null
+++ b/resources/js/modules/dashboard/QuickPost.vue
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
{{ widget.data.message }}
+
{{ t('Create entry') }}
+
{{
+ error
+ }}
+
+
+
diff --git a/resources/js/modules/dashboard/RecentEntries.vue b/resources/js/modules/dashboard/RecentEntries.vue
new file mode 100644
index 00000000000..ee36a366091
--- /dev/null
+++ b/resources/js/modules/dashboard/RecentEntries.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+ -
+ {{ entry.title }}
+
+ {{ entry.dateLabel
+ }}{{ entry.author ? `, ${entry.author}` : '' }}
+
+
+
+
+
+
diff --git a/resources/js/modules/dashboard/Updates.vue b/resources/js/modules/dashboard/Updates.vue
new file mode 100644
index 00000000000..c5fb416cda5
--- /dev/null
+++ b/resources/js/modules/dashboard/Updates.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
{{
+ error
+ }}
+
{{
+ t('Checking for updates…')
+ }}
+
+
+ {{
+ total === 1
+ ? t('One update available!')
+ : t('{total} updates available!', {total})
+ }}
+
+ {{
+ t('Go to Updates')
+ }}
+
+
{{ t('Congrats! You’re up to date.') }}
+ {{ t('Check again') }}
+
+
+
diff --git a/resources/js/modules/dashboard/Widget.vue b/resources/js/modules/dashboard/Widget.vue
new file mode 100644
index 00000000000..29f85fefc5f
--- /dev/null
+++ b/resources/js/modules/dashboard/Widget.vue
@@ -0,0 +1,196 @@
+
+
+
+
+
+
+
diff --git a/resources/js/modules/dashboard/WidgetManager.vue b/resources/js/modules/dashboard/WidgetManager.vue
new file mode 100644
index 00000000000..9c77694b6e6
--- /dev/null
+++ b/resources/js/modules/dashboard/WidgetManager.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
{{ t('Widgets') }}
+
+
+ -
+
+ {{
+ widget.title || widget.name
+ }}
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/modules/dashboard/WidgetSettings.test.ts b/resources/js/modules/dashboard/WidgetSettings.test.ts
new file mode 100644
index 00000000000..6b7c9c2103e
--- /dev/null
+++ b/resources/js/modules/dashboard/WidgetSettings.test.ts
@@ -0,0 +1,89 @@
+import {afterEach, expect, it, vi} from 'vite-plus/test';
+import {createApp, h, ref, type App} from 'vue';
+import {http} from '@inertiajs/vue3';
+import WidgetSettings from './WidgetSettings.vue';
+import type {DashboardWidget} from './types';
+
+vi.mock('@craftcms/ui', () => ({
+ actionClient: {post: vi.fn()},
+ t: (message: string) => message,
+}));
+
+let app: App;
+let host: HTMLElement;
+
+afterEach(() => {
+ app?.unmount();
+ host?.remove();
+ vi.restoreAllMocks();
+});
+
+it('shows validation errors and closes settings after a successful retry', async () => {
+ const widget: DashboardWidget = {
+ id: 1,
+ type: 'Example',
+ name: 'Example',
+ title: 'Example widget',
+ subtitle: null,
+ colspan: 1,
+ maxColspan: 4,
+ settings: {title: 'Example widget'},
+ settingsForm: null,
+ component: null,
+ data: null,
+ fragment: {html: '', headHtml: '', bodyHtml: ''},
+ };
+ const response = {
+ status: 422,
+ statusText: 'Unprocessable Content',
+ headers: {},
+ data: JSON.stringify({
+ errors: {title: ['Title is required.', 'Choose a different title.']},
+ }),
+ };
+ vi.spyOn(http.getClient(), 'request')
+ .mockResolvedValueOnce(response)
+ .mockResolvedValueOnce({
+ ...response,
+ status: 200,
+ data: JSON.stringify({info: widget}),
+ });
+
+ const saved = ref();
+ host = document.createElement('div');
+ document.body.append(host);
+ app = createApp({
+ render: () =>
+ saved.value
+ ? h('h2', saved.value.title!)
+ : h(WidgetSettings, {
+ widget,
+ onSaved: (value: DashboardWidget | false) => {
+ saved.value = value;
+ },
+ }),
+ });
+ app.mount(host);
+
+ host
+ .querySelector('form')!
+ .dispatchEvent(new Event('submit', {bubbles: true, cancelable: true}));
+
+ await vi.waitFor(() => {
+ expect(host.querySelector('[role=alert]')?.textContent).toContain(
+ 'Title is required.'
+ );
+ expect(host.querySelector('[role=alert]')?.textContent).toContain(
+ 'Choose a different title.'
+ );
+ });
+
+ host
+ .querySelector('form')!
+ .dispatchEvent(new Event('submit', {bubbles: true, cancelable: true}));
+
+ await vi.waitFor(() =>
+ expect(host.querySelector('h2')?.textContent).toBe('Example widget')
+ );
+ expect(host.querySelector('form')).toBeNull();
+});
diff --git a/resources/js/modules/dashboard/WidgetSettings.vue b/resources/js/modules/dashboard/WidgetSettings.vue
new file mode 100644
index 00000000000..3fd2941a87b
--- /dev/null
+++ b/resources/js/modules/dashboard/WidgetSettings.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
diff --git a/resources/js/modules/dashboard/craft-support/Index.vue b/resources/js/modules/dashboard/craft-support/Index.vue
new file mode 100644
index 00000000000..57a11edd02a
--- /dev/null
+++ b/resources/js/modules/dashboard/craft-support/Index.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('Get help') }}
+ {{
+ t('How-to’s and other questions')
+ }}
+
+
+
+
+
+ {{ t('Give feedback') }}
+ {{
+ t('Bug reports and feature requests')
+ }}
+
+
+
+
+
+
+ {{
+ t('Cancel')
+ }}
+
+
+
+
+
+
diff --git a/resources/js/modules/dashboard/craft-support/SupportForm.vue b/resources/js/modules/dashboard/craft-support/SupportForm.vue
new file mode 100644
index 00000000000..8152a2f59a0
--- /dev/null
+++ b/resources/js/modules/dashboard/craft-support/SupportForm.vue
@@ -0,0 +1,141 @@
+
+
+
+
+
{{ t('Contact Developer Support') }}
+
+
+
+
diff --git a/resources/js/modules/dashboard/craft-support/SupportSearch.vue b/resources/js/modules/dashboard/craft-support/SupportSearch.vue
new file mode 100644
index 00000000000..89eb61cf009
--- /dev/null
+++ b/resources/js/modules/dashboard/craft-support/SupportSearch.vue
@@ -0,0 +1,183 @@
+
+
+
+
+
{{ search.heading }}
+
+
+
{{ search.resultsHeading }}
+
+
+
+
{{ t('Contact Developer Support') }}
+
{{ t('More Resources') }}
+
+
+
diff --git a/resources/js/modules/dashboard/craft-support/types.ts b/resources/js/modules/dashboard/craft-support/types.ts
new file mode 100644
index 00000000000..ed659ae7cb5
--- /dev/null
+++ b/resources/js/modules/dashboard/craft-support/types.ts
@@ -0,0 +1,8 @@
+export type SupportData = {
+ resources: Array<{url: string; label: string}>;
+ issueTitlePrefix: string;
+ issueParams: Record;
+ showBackupOption: boolean;
+ canContactSupport: boolean;
+ email: string;
+};
diff --git a/resources/js/modules/dashboard/htmlWidgets.test.ts b/resources/js/modules/dashboard/htmlWidgets.test.ts
new file mode 100644
index 00000000000..2ca5bff7e6e
--- /dev/null
+++ b/resources/js/modules/dashboard/htmlWidgets.test.ts
@@ -0,0 +1,63 @@
+import {afterEach, expect, it, vi} from 'vite-plus/test';
+import {createApp, h, nextTick, ref, type App} from 'vue';
+import {provideHtmlWidgets} from './htmlWidgets';
+import HtmlWidget from './HtmlWidget.vue';
+import type {DashboardWidget} from './types';
+
+vi.mock('@craftcms/ui', async () => ({
+ ...(await import('@craftcms/ui/utilities/dom')),
+ t: (message: string) => message,
+}));
+
+let app: App;
+let host: HTMLElement;
+
+afterEach(() => {
+ app?.unmount();
+ host?.remove();
+});
+
+it('keeps remaining HTML widgets styled when the widget providing their stylesheet is removed', async () => {
+ const ids = ref([1, 2]);
+ host = document.createElement('div');
+ document.body.append(host);
+ app = createApp({
+ setup() {
+ provideHtmlWidgets();
+ return () =>
+ h(
+ 'div',
+ ids.value.map((id) =>
+ h(HtmlWidget, {
+ key: id,
+ widget: {
+ fragment: {
+ html: `Widget ${id}
`,
+ headHtml:
+ id === 1
+ ? ''
+ : '',
+ bodyHtml: '',
+ },
+ } as DashboardWidget,
+ })
+ )
+ );
+ },
+ });
+ app.mount(host);
+
+ await vi.waitFor(() => expect(host.querySelectorAll('p')).toHaveLength(2));
+ expect(getComputedStyle(host.querySelectorAll('p')[1]!).color).toBe(
+ 'rgb(12, 34, 56)'
+ );
+
+ ids.value = [2];
+ await nextTick();
+
+ expect(host.textContent).toContain('Widget 2');
+ expect(host.textContent).not.toContain('Widget 1');
+ expect(getComputedStyle(host.querySelector('p')!).color).toBe(
+ 'rgb(12, 34, 56)'
+ );
+});
diff --git a/resources/js/modules/dashboard/htmlWidgets.ts b/resources/js/modules/dashboard/htmlWidgets.ts
new file mode 100644
index 00000000000..0fa34524a67
--- /dev/null
+++ b/resources/js/modules/dashboard/htmlWidgets.ts
@@ -0,0 +1,81 @@
+import {
+ appendBodyHtml,
+ appendHeadHtml,
+ appendElementHtml,
+ type AppendHtmlDisposer,
+} from '@craftcms/ui';
+import {onUnmounted, provide, type InjectionKey} from 'vue';
+
+export type RenderHtmlWidget = (
+ fragment: CraftCms.Cms.View.HtmlFragment,
+ container: HTMLElement,
+ active: () => boolean
+) => Promise;
+
+export const renderHtmlWidget: InjectionKey = Symbol(
+ 'dashboard-html-widgets'
+);
+
+export function provideHtmlWidgets() {
+ const assets: AppendHtmlDisposer[] = [];
+ let queue = Promise.resolve();
+ let mounted = true;
+
+ // Server asset registration is deduplicated across widgets. Initialize them in
+ // order and retain their shared styles/scripts for the lifetime of the page.
+ provide(renderHtmlWidget, (fragment, container, active) => {
+ const render = queue.then(async () => {
+ if (!mounted) return;
+
+ assets.push(await appendHeadHtml(fragment.headHtml));
+ if (!mounted) return;
+
+ const dispose = active()
+ ? await appendElementHtml(fragment.html, container)
+ : undefined;
+ if (!mounted) {
+ dispose?.();
+ return;
+ }
+
+ if (active()) {
+ container.dispatchEvent(
+ new CustomEvent('craft:widget-content-ready', {
+ bubbles: true,
+ })
+ );
+ }
+
+ let bodyHtml = fragment.bodyHtml;
+ if (!active()) {
+ const body = document.createElement('template');
+ body.innerHTML = bodyHtml;
+ bodyHtml = Array.from(body.content.querySelectorAll('script[src]'))
+ .map((script) => script.outerHTML)
+ .join('');
+ }
+
+ assets.push(await appendBodyHtml(bodyHtml));
+ if (!mounted || !active()) {
+ dispose?.();
+ return;
+ }
+
+ return dispose;
+ });
+
+ queue = render.then(
+ () => {},
+ () => {}
+ );
+
+ return render;
+ });
+
+ onUnmounted(() => {
+ mounted = false;
+ void queue.finally(() => {
+ while (assets.length) assets.pop()!();
+ });
+ });
+}
diff --git a/resources/js/modules/dashboard/register.ts b/resources/js/modules/dashboard/register.ts
new file mode 100644
index 00000000000..38ac922d2ef
--- /dev/null
+++ b/resources/js/modules/dashboard/register.ts
@@ -0,0 +1,23 @@
+import type {CpComponentRegistry} from '@/bootstrap/components';
+
+export function registerWidgetComponents(
+ components: Pick
+): void {
+ components.register('craft:html-widget', () => import('./HtmlWidget.vue'));
+ components.register('craft:widget-feed', () => import('./Feed.vue'));
+ components.register('craft:widget-new-users', () => import('./NewUsers.vue'));
+ components.register(
+ 'craft:widget-craft-support',
+ () => import('./craft-support/Index.vue')
+ );
+ components.register(
+ 'craft:widget-quick-post',
+ () => import('./QuickPost.vue')
+ );
+ components.register(
+ 'craft:widget-recent-entries',
+ () => import('./RecentEntries.vue')
+ );
+ components.register('craft:widget-my-drafts', () => import('./MyDrafts.vue'));
+ components.register('craft:widget-updates', () => import('./Updates.vue'));
+}
diff --git a/resources/js/modules/dashboard/types.ts b/resources/js/modules/dashboard/types.ts
new file mode 100644
index 00000000000..42aca7a07e8
--- /dev/null
+++ b/resources/js/modules/dashboard/types.ts
@@ -0,0 +1,16 @@
+import type {FormPayload, FormValues} from '@/modules/forms/types';
+
+export type WidgetType = Omit<
+ CraftCms.Cms.Dashboard.Data.WidgetTypeData,
+ 'settingsForm'
+> & {
+ settingsForm: FormPayload | null;
+};
+
+export type DashboardWidget = Omit<
+ CraftCms.Cms.Dashboard.Data.WidgetData,
+ 'settingsForm' | 'settings'
+> & {
+ settingsForm: FormPayload | null;
+ settings: FormValues;
+};
diff --git a/resources/js/modules/dashboard/useDashboard.test.ts b/resources/js/modules/dashboard/useDashboard.test.ts
new file mode 100644
index 00000000000..427830427e3
--- /dev/null
+++ b/resources/js/modules/dashboard/useDashboard.test.ts
@@ -0,0 +1,119 @@
+import {afterEach, beforeEach, expect, it, vi} from 'vite-plus/test';
+import {createApp, defineComponent, h, type App} from 'vue';
+import {useDashboard} from './useDashboard';
+import {useFlashMessages} from '@/common/composables/useFlashMessages';
+import type {DashboardWidget} from './types';
+
+const state = vi.hoisted(() => ({
+ post: vi.fn(),
+ reload: vi.fn(),
+}));
+vi.mock('@craftcms/ui', () => ({
+ actionClient: {post: state.post},
+ t: (message: string) => message,
+}));
+vi.mock('@inertiajs/vue3', () => ({router: {reload: state.reload}}));
+vi.mock('@/common/utils/jquery', () => ({
+ jq: () => () => ({children: () => ({each: vi.fn()}), data: vi.fn()}),
+}));
+vi.mock('@/modules/grid/grid', () => ({
+ Grid: class {
+ $container = {height: vi.fn()};
+ $items = {each: vi.fn()};
+ items = [];
+ totalCols = 4;
+ setItems() {}
+ refreshCols() {}
+ destroy() {}
+ },
+}));
+
+let app: App;
+let host: HTMLElement;
+let dashboard: ReturnType;
+
+function widget(id: number): DashboardWidget {
+ return {
+ id,
+ type: 'Example',
+ colspan: 1,
+ maxColspan: 4,
+ title: `Widget ${id}`,
+ subtitle: null,
+ name: 'Example',
+ settings: {limit: id},
+ settingsForm: null,
+ component: null,
+ data: null,
+ fragment: {html: '', headHtml: '', bodyHtml: ''},
+ };
+}
+
+function mount(widgets: DashboardWidget[]) {
+ host = document.createElement('div');
+ document.body.append(host);
+ app = createApp(
+ defineComponent({
+ setup() {
+ dashboard = useDashboard({widgets, widgetTypes: {}});
+ return () => h('div', {ref: dashboard.container});
+ },
+ })
+ );
+ app.mount(host);
+}
+
+beforeEach(() => {
+ state.post.mockReset();
+ state.reload.mockClear();
+ useFlashMessages().clearAll();
+});
+
+afterEach(() => {
+ app?.unmount();
+ host?.remove();
+});
+
+it('keeps the saved layout when resizing or reordering fails', async () => {
+ mount([widget(1), widget(2)]);
+ state.post.mockRejectedValue(new Error('offline'));
+
+ await dashboard.resize(dashboard.widgets.value[0]!, 3);
+ expect(useFlashMessages().messages.value.error).toBe('Couldn’t save widget.');
+
+ await dashboard.reorder(0, 1);
+
+ expect(
+ dashboard.widgets.value.map((item) => [item.id, item.colspan])
+ ).toEqual([
+ [1, 1],
+ [2, 1],
+ ]);
+ expect(useFlashMessages().messages.value.error).toBe(
+ 'Couldn’t reorder widgets.'
+ );
+});
+
+it('undo restores a deleted widget at the end', async () => {
+ mount([widget(1), widget(2)]);
+ state.post.mockResolvedValueOnce({data: {}});
+ await dashboard.remove(dashboard.widgets.value[0]!);
+
+ expect(dashboard.widgets.value.map((item) => item.id)).toEqual([2]);
+
+ state.post.mockResolvedValueOnce({data: {info: widget(3)}});
+ await dashboard.undo();
+
+ expect(dashboard.widgets.value.map((item) => item.id)).toEqual([2, 3]);
+ expect(dashboard.deleted.value).toBeUndefined();
+});
+
+it('keeps a widget and its undo state unchanged when deletion fails', async () => {
+ mount([widget(1)]);
+ state.post.mockRejectedValue(new Error('cancelled'));
+
+ await dashboard.remove(dashboard.widgets.value[0]!);
+
+ expect(dashboard.widgets.value.map((item) => item.id)).toEqual([1]);
+ expect(dashboard.deleted.value).toBeUndefined();
+});
diff --git a/resources/js/modules/dashboard/useDashboard.ts b/resources/js/modules/dashboard/useDashboard.ts
new file mode 100644
index 00000000000..7a6b3192f6b
--- /dev/null
+++ b/resources/js/modules/dashboard/useDashboard.ts
@@ -0,0 +1,276 @@
+import {useFlashMessages} from '@/common/composables/useFlashMessages';
+import {provideHtmlWidgets} from './htmlWidgets';
+import {jq} from '@/common/utils/jquery';
+
+import {
+ computed,
+ nextTick,
+ onUnmounted,
+ onMounted,
+ ref,
+ shallowRef,
+ watch,
+} from 'vue';
+import {actionClient, t} from '@craftcms/ui';
+import {router} from '@inertiajs/vue3';
+import {Grid} from '@/modules/grid/grid';
+import {
+ store,
+ deleteMethod,
+ reorder as reorderWidgets,
+ updateColspan,
+} from '@actions/Dashboard/WidgetsController';
+import type {DashboardWidget, WidgetType} from './types';
+
+export function useDashboard(props: {
+ widgets: DashboardWidget[];
+ widgetTypes: Record;
+}) {
+ const {flash} = useFlashMessages();
+
+ provideHtmlWidgets();
+
+ const widgets = shallowRef(props.widgets);
+ const container = ref();
+ const ready = ref(false);
+ const managing = ref(false);
+ const busy = ref(false);
+ const columns = ref(4);
+ const deleted = shallowRef();
+ let nextId = -1;
+ let grid: Grid;
+ let mountedElement: HTMLElement;
+ const actions = computed(() =>
+ Object.entries(props.widgetTypes)
+ .filter(([, type]) => type.selectable)
+ .map(([type, info]) => ({label: info.name, onClick: () => add(type)}))
+ );
+ const savedWidgets = computed(() =>
+ widgets.value.filter((widget) => widget.id > 0)
+ );
+
+ onMounted(async () => {
+ mountedElement = container.value!;
+ grid = new Grid(mountedElement, {
+ maxCols: 4,
+ onRefreshCols: () => {
+ columns.value = grid?.totalCols ?? 4;
+ },
+ });
+ window.dispatchEvent(
+ new CustomEvent('craft:dashboard-mounted', {
+ detail: {
+ element: container.value!,
+ grid,
+ get widgets() {
+ return widgets.value;
+ },
+ get widgetTypes() {
+ return props.widgetTypes;
+ },
+ add,
+ showManager: () => {
+ managing.value = true;
+ },
+ },
+ })
+ );
+ ready.value = true;
+
+ await nextTick();
+ refreshGrid();
+ });
+
+ onUnmounted(() => {
+ ready.value = false;
+ grid?.destroy();
+ window.dispatchEvent(
+ new CustomEvent('craft:dashboard-unmounted', {
+ detail: {element: mountedElement},
+ })
+ );
+ });
+
+ watch(
+ () => props.widgets,
+ (value) => {
+ widgets.value = value;
+ }
+ );
+
+ watch(widgets, async () => {
+ await nextTick();
+ refreshGrid();
+ });
+
+ function refreshGrid() {
+ if (!grid) return;
+
+ grid.$items = jq()!(container.value!).children('.item');
+ grid.$items.each((_: number, element: HTMLElement) =>
+ jq()!(element).data('colspan', Number(element.dataset.colspan))
+ );
+ grid.setItems();
+ grid.refreshCols(true);
+ if (!grid.items.length) grid.$container.height('auto');
+ }
+
+ function refreshTypes() {
+ router.reload({only: ['widgetTypes']});
+ }
+
+ function saved(id: number, widget: DashboardWidget | false) {
+ widgets.value = widgets.value.flatMap((current) =>
+ current.id === id ? (widget ? [widget] : []) : [current]
+ );
+ refreshTypes();
+ }
+
+ function cancel(id: number) {
+ widgets.value = widgets.value.filter((widget) => widget.id !== id);
+ }
+
+ async function add(type: string) {
+ const info = props.widgetTypes[type]!;
+
+ if (info.settingsForm) {
+ const id = nextId--;
+ widgets.value = [
+ ...widgets.value,
+ {
+ id,
+ type,
+ name: info.name,
+ title: info.name,
+ subtitle: null,
+ colspan: 1,
+ maxColspan: info.maxColspan ?? 4,
+ settings: {},
+ settingsForm: JSON.parse(
+ JSON.stringify(info.settingsForm).replaceAll(
+ '__NAMESPACE__',
+ `newwidget${-id}-settings`
+ )
+ ),
+ component: null,
+ data: null,
+ fragment: {html: '', headHtml: '', bodyHtml: ''},
+ },
+ ];
+ return;
+ }
+
+ if (busy.value) return;
+
+ busy.value = true;
+
+ try {
+ const {data} = await actionClient.post(store.url(), {type});
+ if (data.info) widgets.value = [...widgets.value, data.info];
+ refreshTypes();
+ } catch {
+ flash('error', t('Couldn’t save widget.'));
+ } finally {
+ busy.value = false;
+ }
+ }
+
+ async function remove(widget: DashboardWidget) {
+ if (busy.value) return;
+
+ busy.value = true;
+
+ try {
+ await actionClient.post(deleteMethod.url(), {id: widget.id});
+
+ cancel(widget.id);
+ deleted.value = widget;
+ refreshTypes();
+ } catch {
+ flash('error', t('Couldn’t delete widget.'));
+ } finally {
+ busy.value = false;
+ }
+ }
+
+ async function undo() {
+ if (!deleted.value || busy.value) return;
+
+ busy.value = true;
+
+ try {
+ const {data} = await actionClient.post(store.url(), {
+ type: deleted.value.type,
+ settings: deleted.value.settings,
+ });
+
+ if (data.info) widgets.value = [...widgets.value, data.info];
+ deleted.value = undefined;
+ refreshTypes();
+ } catch {
+ flash('error', t('Couldn’t save widget.'));
+ } finally {
+ busy.value = false;
+ }
+ }
+
+ async function reorder(from: number, to: number) {
+ if (busy.value || from === to) return;
+
+ const order = [...savedWidgets.value];
+ order.splice(to, 0, order.splice(from, 1)[0]!);
+ busy.value = true;
+
+ try {
+ await actionClient.post(reorderWidgets.url(), {
+ ids: JSON.stringify(order.map((widget) => widget.id)),
+ });
+
+ widgets.value = [
+ ...order,
+ ...widgets.value.filter((widget) => widget.id < 0),
+ ];
+ } catch {
+ flash('error', t('Couldn’t reorder widgets.'));
+ } finally {
+ busy.value = false;
+ }
+ }
+
+ async function resize(widget: DashboardWidget, colspan: number) {
+ if (busy.value) return;
+
+ busy.value = true;
+
+ try {
+ await actionClient.post(updateColspan.url(), {id: widget.id, colspan});
+
+ widgets.value = widgets.value.map((current) =>
+ current.id === widget.id ? {...current, colspan} : current
+ );
+ } catch {
+ flash('error', t('Couldn’t save widget.'));
+ } finally {
+ busy.value = false;
+ }
+ }
+
+ return {
+ widgets,
+ savedWidgets,
+ container,
+ ready,
+ managing,
+ busy,
+ columns,
+ deleted,
+ actions,
+ saved,
+ cancel,
+ remove,
+ undo,
+ reorder,
+ resize,
+ refreshGrid,
+ };
+}
diff --git a/resources/js/modules/forms/FormRenderer.vue b/resources/js/modules/forms/FormRenderer.vue
index 4769154311d..d75a0ff8bd2 100644
--- a/resources/js/modules/forms/FormRenderer.vue
+++ b/resources/js/modules/forms/FormRenderer.vue
@@ -339,15 +339,29 @@
}
function currentValues(): FormPayload['values'] {
- const result: FormPayload['values'] = {};
+ const groups = new Map();
+ const controlPaths = new Set();
visitControls(payload.value.nodes, (control) => {
- const value = valueAt(values, control.path);
+ groups.set(JSON.stringify(control.deltaGroup), control.deltaGroup);
+ controlPaths.add(JSON.stringify(control.path));
+ });
+
+ const result: FormPayload['values'] = {};
+
+ for (const path of groups.values()) {
+ const value = groupValue(values, path, controlPaths);
+
+ if (path.length === 0 && isRecord(value)) {
+ Object.assign(result, value);
+
+ continue;
+ }
if (value !== undefined) {
- setPathValue(result, control.path, cloneRaw(value));
+ setPathValue(result, path, value);
}
- });
+ }
return result;
}
diff --git a/resources/js/pages/Dashboard.vue b/resources/js/pages/Dashboard.vue
new file mode 100644
index 00000000000..481b33f59d2
--- /dev/null
+++ b/resources/js/pages/Dashboard.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
{{
+ t('New widget')
+ }}
+
+
+
+
+ {{
+ t('“{name}” deleted.', {name: deleted.title || deleted.name})
+ }}
+ {{ t('Undo') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/Dashboard/Contracts/WidgetInterface.php b/src/Dashboard/Contracts/WidgetInterface.php
index 2e47c631c67..ce881b8fd7b 100644
--- a/src/Dashboard/Contracts/WidgetInterface.php
+++ b/src/Dashboard/Contracts/WidgetInterface.php
@@ -34,6 +34,12 @@ public static function icon(): ?string;
*/
public static function maxColspan(): ?int;
+ /** Returns a registered CP component name; the default is craft:html-widget. */
+ public function component(): ?string;
+
+ /** @return array|null Component data, or null to hide the widget. */
+ public function props(): ?array;
+
public function getType(): string;
public function getIcon(): ?string;
diff --git a/src/Dashboard/Data/WidgetData.php b/src/Dashboard/Data/WidgetData.php
new file mode 100644
index 00000000000..cafa2166a69
--- /dev/null
+++ b/src/Dashboard/Data/WidgetData.php
@@ -0,0 +1,30 @@
+ $settings
+ * @param array|null $data
+ */
+ public function __construct(
+ public int $id,
+ public string $type,
+ public int $colspan,
+ public int $maxColspan,
+ public ?string $title,
+ public ?string $subtitle,
+ public string $name,
+ public array $settings,
+ public ?string $component,
+ public ?array $data,
+ public HtmlFragment $fragment,
+ public ?FormPayload $settingsForm,
+ ) {}
+}
diff --git a/src/Dashboard/Data/WidgetTypeData.php b/src/Dashboard/Data/WidgetTypeData.php
new file mode 100644
index 00000000000..4907fb8a0b1
--- /dev/null
+++ b/src/Dashboard/Data/WidgetTypeData.php
@@ -0,0 +1,18 @@
+|null */
+ public function props(): ?array
{
// Only admins get the Craft Support widget.
if (! currentUser()?->isAdmin()) {
return null;
}
- app(InternalAssetRegistry::class)->register(CraftSupportAsset::class);
-
$cmsVersion = Cms::VERSION;
$cmsMajorVersion = (int) $cmsVersion;
@@ -111,33 +109,29 @@ public function getBodyHtml(): ?string
EOD;
- HtmlStack::jsWithVars(fn ($id, $settings) => <<id,
- [
- 'issueTitlePrefix' => sprintf('[%s.x]: ', $cmsMajorVersion),
- 'issueParams' => [
- 'labels' => sprintf('bug,craft%s', $cmsMajorVersion),
- 'template' => sprintf('BUG-REPORT-V%s.yml', $cmsMajorVersion),
- 'body' => $body,
- 'cmsVersion' => sprintf('%s (%s)', $cmsVersion, Edition::get()->name),
- 'phpVersion' => PHP::version(),
- 'os' => sprintf('%s %s', PHP_OS, php_uname('r')),
- 'db' => sprintf('%s %s', $dbDriver, normalizeVersion(DB::getServerVersion())),
- 'imageDriver' => sprintf('%s %s', $imageDriver, $imagesService->getVersion()),
- 'plugins' => implode("\n", $pluginVersions),
- ],
+ return [
+ 'resources' => [
+ ['url' => 'https://craftcms.com/partners', 'label' => t('Find an official Craft Partner')],
+ ['url' => 'https://craftcms.com/discord', 'label' => t('Meet the Craft community')],
+ ['url' => 'https://craftquest.io', 'label' => t('Unlimited video training')],
+ ['url' => 'https://craftcms.com/docs/5.x/', 'label' => t('Documentation')],
+ ['url' => 'https://craftcms.com/knowledge-base', 'label' => t('Knowledge Base')],
],
- ]);
-
- // Only show the DB backup option if DB backups haven't been disabled
- $showBackupOption = $this->generalConfig->backupCommand !== false;
-
- return template('_components/widgets/CraftSupport/body', [
- 'widget' => $this,
- 'showBackupOption' => $showBackupOption,
- 'bundleUrl' => craftAsset('legacy/craftsupport/dist'),
- ]);
+ 'issueTitlePrefix' => sprintf('[%s.x]: ', $cmsMajorVersion),
+ 'issueParams' => [
+ 'labels' => sprintf('bug,craft%s', $cmsMajorVersion),
+ 'template' => sprintf('BUG-REPORT-V%s.yml', $cmsMajorVersion),
+ 'body' => $body,
+ 'cmsVersion' => sprintf('%s (%s)', $cmsVersion, Edition::get()->name),
+ 'phpVersion' => PHP::version(),
+ 'os' => sprintf('%s %s', PHP_OS, php_uname('r')),
+ 'db' => sprintf('%s %s', $dbDriver, normalizeVersion(DB::getServerVersion())),
+ 'imageDriver' => sprintf('%s %s', $imageDriver, $imagesService->getVersion()),
+ 'plugins' => implode("\n", $pluginVersions),
+ ],
+ 'showBackupOption' => $this->generalConfig->backupCommand !== false,
+ 'canContactSupport' => Edition::get()->value >= Edition::Pro->value,
+ 'email' => in_array(currentUser()->asElement()->email, ['support@pixelandtonic.com', 'support@craftcms.com'], true) ? '' : currentUser()->asElement()->email,
+ ];
}
}
diff --git a/src/Dashboard/Widgets/Feed.php b/src/Dashboard/Widgets/Feed.php
index 8bd41346e73..8dc5af648ce 100644
--- a/src/Dashboard/Widgets/Feed.php
+++ b/src/Dashboard/Widgets/Feed.php
@@ -9,16 +9,12 @@
use CraftCms\Cms\Form\Form;
use CraftCms\Cms\Form\FormContext;
use CraftCms\Cms\Form\Nodes\Field;
-use CraftCms\Cms\Support\Facades\HtmlStack;
-use CraftCms\Cms\Support\Json;
-use CraftCms\Cms\View\LegacyAssets\FeedAsset;
-use CraftCms\Cms\View\LegacyAssets\InternalAssetRegistry;
+use CraftCms\Cms\Support\Facades\I18N;
use Illuminate\Support\Facades\Cache;
use Override;
use function CraftCms\Cms\currentUser;
use function CraftCms\Cms\t;
-use function CraftCms\Cms\template;
class Feed extends Widget
{
@@ -72,49 +68,21 @@ public function getTitle(): ?string
return $this->title;
}
- #[Override]
- public function getBodyHtml(): string
+ public function component(): ?string
{
- // See if it's already cached
- $userId = currentUser()?->getCraftUserId();
-
- if ($userId) {
- $key = sprintf('feed:%s:%s', $userId, $this->url);
- $data = Cache::get($key);
- } else {
- $data = null;
- }
-
- if ($data) {
- $data['items'] = array_slice($data['items'] ?? [], 0, $this->limit);
-
- return $this->render($data);
- }
-
- // Fake it for now and fetch it later
- $data = [
- 'direction' => 'ltr',
- 'items' => [],
- ];
-
- for ($i = 0; $i < $this->limit; $i++) {
- $data['items'][] = [];
- }
-
- app(InternalAssetRegistry::class)->register(FeedAsset::class);
- HtmlStack::js(
- "new Craft.FeedWidget($this->id, ".
- Json::encode($this->url).', '.
- Json::encode($this->limit).');'
- );
-
- return $this->render($data);
+ return 'craft:widget-feed';
}
- private function render(mixed $data): string
+ /** @return array{url: ?string, limit: int, feed: mixed, formattingLocale: string} */
+ public function props(): array
{
- return template('_components/widgets/Feed/body', [
- 'feed' => $data,
- ]);
+ $userId = currentUser()?->getCraftUserId();
+
+ return [
+ 'url' => $this->url,
+ 'limit' => $this->limit,
+ 'formattingLocale' => str_replace('_', '-', I18N::getFormattingLocale()->id),
+ 'feed' => $userId ? Cache::get(sprintf('feed:%s:%s', $userId, $this->url)) : null,
+ ];
}
}
diff --git a/src/Dashboard/Widgets/MyDrafts.php b/src/Dashboard/Widgets/MyDrafts.php
index 1b92760eb17..313f161bdc4 100644
--- a/src/Dashboard/Widgets/MyDrafts.php
+++ b/src/Dashboard/Widgets/MyDrafts.php
@@ -5,12 +5,12 @@
namespace CraftCms\Cms\Dashboard\Widgets;
use CraftCms\Cms\Cp\Html\ElementHtml;
+use CraftCms\Cms\Element\ElementCollection;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Form\Controls\Number;
use CraftCms\Cms\Form\Form;
use CraftCms\Cms\Form\FormContext;
use CraftCms\Cms\Form\Nodes\Field;
-use CraftCms\Cms\Support\Html;
use Override;
use function CraftCms\Cms\currentUser;
@@ -59,10 +59,24 @@ public function settingsForm(FormContext $context = new FormContext): Form
]);
}
- #[Override]
- public function getBodyHtml(): string
+ public function component(): ?string
+ {
+ return 'craft:widget-my-drafts';
+ }
+
+ /** @return array{drafts: list} */
+ public function props(): array
+ {
+ return ['drafts' => $this->getDrafts()->map(fn (Entry $draft): array => [
+ 'id' => $draft->id,
+ 'html' => app(ElementHtml::class)->elementChipHtml($draft, ['hyperlink' => true]),
+ ])->values()->all()];
+ }
+
+ /** @return ElementCollection */
+ private function getDrafts(): ElementCollection
{
- $drafts = Entry::find()
+ return Entry::find()
->drafts()
->status(null)
->draftCreator(currentUser()?->getCraftUserId())
@@ -72,27 +86,5 @@ public function getBodyHtml(): string
->orderByDesc('dateUpdated')
->limit($this->limit)
->get();
-
- if ($drafts->isEmpty()) {
- return Html::tag('div', t('You don’t have any active drafts.'), [
- 'class' => ['zilch', 'small'],
- ]);
- }
-
- $html = Html::beginTag('ul', [
- 'class' => 'widget__list chips',
- 'role' => 'list',
- ]);
-
- foreach ($drafts as $draft) {
- $chip = app(ElementHtml::class)->elementChipHtml($draft, [
- 'hyperlink' => true,
- ]);
- $html .= Html::tag('li', $chip, [
- 'class' => 'widget__list-item',
- ]);
- }
-
- return $html.Html::endTag('ul');
}
}
diff --git a/src/Dashboard/Widgets/NewUsers.php b/src/Dashboard/Widgets/NewUsers.php
index 2173ca603d8..6eae044f905 100644
--- a/src/Dashboard/Widgets/NewUsers.php
+++ b/src/Dashboard/Widgets/NewUsers.php
@@ -9,13 +9,8 @@
use CraftCms\Cms\Form\Form;
use CraftCms\Cms\Form\FormContext;
use CraftCms\Cms\Form\Nodes\Field;
-use CraftCms\Cms\Support\Facades\HtmlStack;
-use CraftCms\Cms\Support\Facades\I18N;
use CraftCms\Cms\Support\Facades\UserGroups;
-use CraftCms\Cms\Support\Json;
use CraftCms\Cms\User\Elements\User;
-use CraftCms\Cms\View\LegacyAssets\InternalAssetRegistry;
-use CraftCms\Cms\View\LegacyAssets\NewUsersAsset;
use Override;
use function CraftCms\Cms\t;
@@ -71,20 +66,19 @@ public function getTitle(): ?string
return parent::getTitle();
}
- #[Override]
- public function getBodyHtml(): ?string
+ public function component(): ?string
+ {
+ return 'craft:widget-new-users';
+ }
+
+ /** @return array{userGroupId: ?int, dateRange: string}|null */
+ public function props(): ?array
{
if (Edition::get()->value < Edition::Pro->value) {
return null;
}
- $options = $this->getSettings();
- $options['orientation'] = I18N::getLocale()->getOrientation();
-
- app(InternalAssetRegistry::class)->register(NewUsersAsset::class);
- HtmlStack::js('new Craft.NewUsersWidget('.$this->id.', '.Json::encode($options).');');
-
- return '';
+ return ['userGroupId' => $this->userGroupId, 'dateRange' => $this->dateRange ?? 'd7'];
}
#[Override]
diff --git a/src/Dashboard/Widgets/QuickPost.php b/src/Dashboard/Widgets/QuickPost.php
index 968f7d4c312..16b8f95fc1a 100644
--- a/src/Dashboard/Widgets/QuickPost.php
+++ b/src/Dashboard/Widgets/QuickPost.php
@@ -15,16 +15,13 @@
use CraftCms\Cms\Section\Data\Section;
use CraftCms\Cms\Section\Enums\SectionType;
use CraftCms\Cms\Support\Arr;
-use CraftCms\Cms\Support\Facades\HtmlStack;
use CraftCms\Cms\Support\Facades\Sections;
use CraftCms\Cms\Support\Facades\Sites;
-use CraftCms\Cms\Support\Html;
use Illuminate\Support\Facades\Auth;
use Override;
use function CraftCms\Cms\currentUser;
use function CraftCms\Cms\t;
-use function CraftCms\Cms\template;
class QuickPost extends Widget
{
@@ -180,87 +177,29 @@ public function getTitle(): string
]);
}
- #[Override]
- public function getBodyHtml(): string
+ public function component(): ?string
{
- $section = $this->section();
- if (! $section) {
- return Html::tag('p', t('No section has been selected yet.'));
- }
+ return 'craft:widget-quick-post';
+ }
- $entryType = $this->entryType();
- if (! $entryType) {
- return Html::tag('p', t('No entry types exist for this section.'));
+ /** @return array{message?: string, params?: array{siteId: int, section: string, type: string, authorId: mixed}} */
+ public function props(): array
+ {
+ if (! $section = $this->section()) {
+ return ['message' => t('No section has been selected yet.')];
}
- $siteId = $this->siteId();
- if (! $siteId) {
- return Html::tag('p', t('You’re not permitted to edit any of this section’s sites.'));
+ if (! $entryType = $this->entryType()) {
+ return ['message' => t('No entry types exist for this section.')];
}
- $buttonId = sprintf('quickpost%s', mt_rand());
-
- HtmlStack::jsWithVars(fn ($buttonId, $params, $elementType) => << {
- const button = $('#' + $buttonId);
- button.on('activate', async () => {
- button.addClass('loading');
- let entry;
- try {
- const response = await Craft.sendActionRequest('POST', 'entries/create', {
- data: $params,
- })
- entry = response.data.entry;
- } finally {
- button.removeClass('loading');
- }
- const slideout = Craft.createElementEditor($elementType, {
- siteId: entry.siteId,
- elementId: entry.id,
- draftId: entry.draftId,
- params: {
- fresh: 1,
- },
- })
-
- slideout.on('submit', ({data}) => {
- // Are there any Recent Entries widgets to notify?
- if (typeof Craft.RecentEntriesWidget !== 'undefined') {
- for (const widget of Craft.RecentEntriesWidget.instances) {
- if (
- !widget.params.sectionId ||
- widget.params.sectionId == entry.sectionId
- ) {
- widget.addEntry({
- url: data.cpEditUrl,
- title: data.title,
- dateCreated: data.dateCreated,
- });
- }
+ if (! $siteId = $this->siteId()) {
+ return ['message' => t('You’re not permitted to edit any of this section’s sites.')];
}
- }
- });
- });
-})();
-JS, [
- $buttonId,
- [
- 'siteId' => $this->siteId(),
- 'section' => $section->handle,
- 'type' => $entryType->handle,
- 'authorId' => Auth::id(),
- ],
- Entry::class,
- ]);
- return template('_includes/forms/button', [
- 'id' => $buttonId,
- 'class' => ['huge', 'icon', 'add', 'dashed', 'fullwidth'],
- 'label' => mb_ucfirst(t('Create {type}', [
- 'type' => Entry::lowerDisplayName(),
- ])),
- 'spinner' => true,
- ]);
+ return [
+ 'params' => ['siteId' => $siteId, 'section' => $section->handle, 'type' => $entryType->handle, 'authorId' => Auth::id()],
+ ];
}
private function siteId(): ?int
diff --git a/src/Dashboard/Widgets/RecentEntries.php b/src/Dashboard/Widgets/RecentEntries.php
index 11ca47344fe..80645fb2927 100644
--- a/src/Dashboard/Widgets/RecentEntries.php
+++ b/src/Dashboard/Widgets/RecentEntries.php
@@ -4,6 +4,7 @@
namespace CraftCms\Cms\Dashboard\Widgets;
+use CraftCms\Cms\Edition;
use CraftCms\Cms\Element\ElementCollection;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Form\Controls\Choice;
@@ -12,16 +13,12 @@
use CraftCms\Cms\Form\FormContext;
use CraftCms\Cms\Form\Nodes\Field;
use CraftCms\Cms\Section\Enums\SectionType;
-use CraftCms\Cms\Support\Facades\HtmlStack;
+use CraftCms\Cms\Support\Facades\I18N;
use CraftCms\Cms\Support\Facades\Sections;
use CraftCms\Cms\Support\Facades\Sites;
-use CraftCms\Cms\Support\Json;
-use CraftCms\Cms\View\LegacyAssets\InternalAssetRegistry;
-use CraftCms\Cms\View\LegacyAssets\RecentEntriesAsset;
use Override;
use function CraftCms\Cms\t;
-use function CraftCms\Cms\template;
class RecentEntries extends Widget
{
@@ -134,24 +131,23 @@ public function getTitle(): string
return $title;
}
- #[Override]
- public function getBodyHtml(): string
+ public function component(): ?string
{
- $params = [];
-
- if (is_numeric($this->section)) {
- $params['sectionId'] = (int) $this->section;
- }
-
- app(InternalAssetRegistry::class)->register(RecentEntriesAsset::class);
- $js = 'new Craft.RecentEntriesWidget('.$this->id.', '.Json::encode($params).');';
- HtmlStack::js($js);
-
- $entries = $this->getEntries();
+ return 'craft:widget-recent-entries';
+ }
- return template('_components/widgets/RecentEntries/body', [
- 'entries' => $entries->all(),
- ]);
+ /** @return array{entries: list} */
+ public function props(): array
+ {
+ return [
+ 'entries' => $this->getEntries()->map(fn (Entry $entry): array => [
+ 'url' => $entry->getCpEditUrl(),
+ 'title' => (string) $entry,
+ 'dateCreated' => $entry->dateCreated?->format(DATE_ATOM),
+ 'dateLabel' => I18N::getFormatter()->asTimestamp($entry->dateCreated, 'short'),
+ 'author' => Edition::get() !== Edition::Solo ? $entry->getAuthor()?->username : null,
+ ])->values()->all(),
+ ];
}
/**
diff --git a/src/Dashboard/Widgets/Updates.php b/src/Dashboard/Widgets/Updates.php
index 951edaf4338..20e4323341b 100644
--- a/src/Dashboard/Widgets/Updates.php
+++ b/src/Dashboard/Widgets/Updates.php
@@ -4,15 +4,11 @@
namespace CraftCms\Cms\Dashboard\Widgets;
-use CraftCms\Cms\Support\Facades\HtmlStack;
use CraftCms\Cms\Update\Updates as UpdatesService;
-use CraftCms\Cms\View\LegacyAssets\InternalAssetRegistry;
-use CraftCms\Cms\View\LegacyAssets\UpdatesWidgetAsset;
use Override;
use function CraftCms\Cms\currentUser;
use function CraftCms\Cms\t;
-use function CraftCms\Cms\template;
class Updates extends Widget
{
@@ -49,27 +45,21 @@ public static function icon(): string
return 'certificate';
}
- #[Override]
- public function getBodyHtml(): ?string
+ public function component(): ?string
+ {
+ return 'craft:widget-updates';
+ }
+
+ /** @return array{cached: bool, total: int}|null */
+ public function props(): ?array
{
- // Make sure the user actually has permission to perform updates
if (! currentUser()->can('performUpdates')) {
return null;
}
- $cached = $this->updates->isUpdateInfoCached();
-
- if (! $cached || ! $this->updates->totalAvailableUpdates()) {
- app(InternalAssetRegistry::class)->register(UpdatesWidgetAsset::class);
- HtmlStack::js('new Craft.UpdatesWidget('.$this->id.', '.($cached ? 'true' : 'false').');');
- }
-
- if ($cached) {
- return template('_components/widgets/Updates/body', [
- 'total' => $this->updates->totalAvailableUpdates(),
- ]);
- }
-
- return ''.t('Checking for updates…').'
';
+ return [
+ 'cached' => $this->updates->isUpdateInfoCached(),
+ 'total' => $this->updates->totalAvailableUpdates(),
+ ];
}
}
diff --git a/src/Dashboard/Widgets/Widget.php b/src/Dashboard/Widgets/Widget.php
index 26849218c5e..a5bfaa119e6 100644
--- a/src/Dashboard/Widgets/Widget.php
+++ b/src/Dashboard/Widgets/Widget.php
@@ -27,6 +27,19 @@ abstract class Widget extends Component implements WidgetInterface
public ?int $colspan = null;
+ public function component(): ?string
+ {
+ return 'craft:html-widget';
+ }
+
+ /** @return array|null */
+ public function props(): ?array
+ {
+ $html = $this->getBodyHtml();
+
+ return $html === null ? null : ['html' => $html];
+ }
+
#[Override]
public static function isSelectable(): bool
{
diff --git a/src/Http/Controllers/Dashboard/DashboardController.php b/src/Http/Controllers/Dashboard/DashboardController.php
index c187f0229a1..136f2816e81 100644
--- a/src/Http/Controllers/Dashboard/DashboardController.php
+++ b/src/Http/Controllers/Dashboard/DashboardController.php
@@ -9,30 +9,28 @@
use CraftCms\Cms\Dashboard\CustomWidgets;
use CraftCms\Cms\Dashboard\Dashboard;
use CraftCms\Cms\Dashboard\Data\CustomWidgetDefinition;
+use CraftCms\Cms\Dashboard\Data\WidgetTypeData;
use CraftCms\Cms\Dashboard\Widgets\Custom;
use CraftCms\Cms\Dashboard\WidgetTypes;
-use CraftCms\Cms\Support\Json;
-use CraftCms\Cms\View\HtmlStack;
-use CraftCms\Cms\View\LegacyAssets\DashboardAsset;
-use CraftCms\Cms\View\LegacyAssets\InternalAssetRegistry;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Collection;
-use Illuminate\View\View;
+use Inertia\Inertia;
+use Inertia\Response;
use function CraftCms\Cms\cp_url;
+use function CraftCms\Cms\t;
readonly class DashboardController
{
use InteractsWithWidgets;
public function __construct(
- private HtmlStack $HtmlStack,
private Dashboard $dashboard,
private CustomWidgets $customWidgets,
private WidgetTypes $widgetTypes,
) {}
- public function index(): View
+ public function index(): Response
{
$widgets = $this->dashboard->getAllWidgets();
@@ -69,60 +67,29 @@ public function index(): View
'name' => $widget->getDisplayName(),
'maxColspan' => $widget->getMaxColspan(),
'selectable' => true,
- ...$this->getWidgetSettingsInfo($widget, '__NAMESPACE__'),
+ 'settingsForm' => $this->getWidgetSettingsForm($widget, '__NAMESPACE__'),
]);
}
- $widgetTypeInfo = $widgetTypeInfo->sortBy(fn (array $info) => $info['name']);
-
- $variables = [];
- // Assemble the list of existing widgets
- $variables['widgets'] = [];
- $allWidgetJs = '';
-
- $widgets
- ->each(function (WidgetInterface $widget) use ($widgetTypeInfo, &$variables, &$allWidgetJs) {
- $this->HtmlStack->startJsBuffer();
- $info = $this->getWidgetInfo($widget);
- $widgetJs = $this->HtmlStack->clearJsBuffer(false);
-
- if ($info === false) {
- return;
- }
-
- $widgetTypeInfo[$info['type']] ??= [
+ foreach ($widgets as $widget) {
+ if (! $widgetTypeInfo->has($widget->getType())) {
+ $widgetTypeInfo->put($widget->getType(), [
'iconSvg' => $this->getWidgetIconSvg($widget),
'name' => $widget->getDisplayName(),
'maxColspan' => $widget->getMaxColspan(),
'settingsForm' => null,
- 'settingsHtml' => '',
- 'settingsJs' => '',
'selectable' => false,
- ];
-
- $variables['widgets'][] = $info;
- $allWidgetJs .= 'new Craft.Widget("#widget'.$widget->id.'", '.
- Json::encode($info['settingsHtml']).', '.
- '() => {'.$info['settingsJs'].'},'.
- Json::encode($info['settings']).','.
- Json::encode($info['settingsForm']).
- ");\n";
-
- // Allow any widget JS to execute *after* we've created the Craft.Widget instance
- $allWidgetJs .= $widgetJs ? $widgetJs."\n" : '';
- });
-
- // Include all the JS and CSS stuff
- app(InternalAssetRegistry::class)->register(DashboardAsset::class);
- $this->HtmlStack->jsWithVars(
- fn ($widgetTypeInfo) => "window.dashboard = new Craft.Dashboard($widgetTypeInfo)",
- [$widgetTypeInfo]
- );
- $this->HtmlStack->js($allWidgetJs);
+ ]);
+ }
+ }
- $variables['widgetTypes'] = $widgetTypeInfo;
+ $widgetTypeInfo = $widgetTypeInfo->sortBy(fn (array $info) => $info['name']);
- return view('dashboard/_index', $variables);
+ return Inertia::render('Dashboard', [
+ 'title' => t('Dashboard'),
+ 'widgets' => fn () => $widgets->map(fn (WidgetInterface $widget) => $this->getWidgetData($widget))->filter()->values(),
+ 'widgetTypes' => $widgetTypeInfo->map(fn (array $info) => new WidgetTypeData(...$info)),
+ ]);
}
public function redirect(): RedirectResponse
diff --git a/src/Http/Controllers/Dashboard/InteractsWithWidgets.php b/src/Http/Controllers/Dashboard/InteractsWithWidgets.php
index f5ac242be77..1ad945194c4 100644
--- a/src/Http/Controllers/Dashboard/InteractsWithWidgets.php
+++ b/src/Http/Controllers/Dashboard/InteractsWithWidgets.php
@@ -6,56 +6,72 @@
use CraftCms\Cms\Cp\Icons;
use CraftCms\Cms\Dashboard\Contracts\WidgetInterface;
+use CraftCms\Cms\Dashboard\Data\WidgetData;
use CraftCms\Cms\Form\FormContext;
use CraftCms\Cms\Form\FormPayload;
use CraftCms\Cms\Form\FormResolver;
+use CraftCms\Cms\View\HtmlStack;
+use ReflectionMethod;
trait InteractsWithWidgets
{
- protected function getWidgetIconSvg(WidgetInterface $widget): ?string
+ protected function getWidgetData(WidgetInterface $widget): WidgetData|false
{
- $icon = $widget->getIcon();
- $label = $widget->getDisplayName();
+ $component = $widget->component() ?? 'craft:html-widget';
- return $icon ? Icons::svg($icon, $label) : Icons::fallbackSvg($label);
- }
+ // A plugin's HTML override takes precedence over an inherited Vue component.
+ $htmlOverride = $component !== 'craft:html-widget' && new ReflectionMethod($widget, 'getBodyHtml')->getDeclaringClass()->isSubclassOf(
+ new ReflectionMethod($widget, 'component')->getDeclaringClass()->getName(),
+ );
- /**
- * @return array{id: int|null, type: string, colspan: int, title: string|null, subtitle: string|null, name: string, bodyHtml: string, settingsForm: FormPayload|null, settingsHtml: string|null, settingsJs: string|null, settings: array}|false
- */
- protected function getWidgetInfo(WidgetInterface $widget): array|false
- {
- // Get the body HTML
- $widgetBodyHtml = $widget->getBodyHtml();
+ if ($htmlOverride) {
+ $component = 'craft:html-widget';
+ }
+
+ $htmlStack = app(HtmlStack::class);
+ $data = null;
+ $fragment = $htmlStack->capture(function () use ($widget, $component, $htmlOverride, &$data): string {
+ if ($htmlOverride) {
+ $html = $widget->getBodyHtml();
+ $data = $html === null ? null : ['html' => $html];
+ } else {
+ $data = $widget->props();
+ }
- if ($widgetBodyHtml === null) {
+ return $component === 'craft:html-widget' ? ($data['html'] ?? '') : '';
+ });
+
+ if ($data === null) {
return false;
}
- $settings = $this->getWidgetSettingsInfo($widget, "widget{$widget->id}-settings");
+ $settingsForm = $this->getWidgetSettingsForm($widget, "widget{$widget->id}-settings");
- // Get the colspan (limited to the widget type's max allowed colspan)
- $colspan = $widget->colspan ?: 1;
+ return new WidgetData(
+ id: $widget->id,
+ type: $widget->getType(),
+ colspan: min($widget->colspan ?: 1, $widget->getMaxColspan() ?: 4),
+ maxColspan: $widget->getMaxColspan() ?: 4,
+ title: $widget->getTitle(),
+ subtitle: $widget->getSubtitle(),
+ name: $widget->getDisplayName(),
+ settings: $widget->getSettings(),
+ component: $component,
+ data: $data,
+ fragment: $fragment,
+ settingsForm: $settingsForm,
+ );
+ }
- if (($maxColspan = $widget->getMaxColspan()) && $colspan > $maxColspan) {
- $colspan = $maxColspan;
- }
+ protected function getWidgetIconSvg(WidgetInterface $widget): ?string
+ {
+ $icon = $widget->getIcon();
+ $label = $widget->getDisplayName();
- return [
- 'id' => $widget->id,
- 'type' => $widget->getType(),
- 'colspan' => $colspan,
- 'title' => $widget->getTitle(),
- 'subtitle' => $widget->getSubtitle(),
- 'name' => $widget->getDisplayName(),
- 'bodyHtml' => $widgetBodyHtml,
- 'settings' => $widget->getSettings(),
- ...$settings,
- ];
+ return $icon ? Icons::svg($icon, $label) : Icons::fallbackSvg($label);
}
- /** @return array{settingsForm: FormPayload|null, settingsHtml: string|null, settingsJs: string|null} */
- protected function getWidgetSettingsInfo(WidgetInterface $widget, string $namespace): array
+ protected function getWidgetSettingsForm(WidgetInterface $widget, string $namespace): ?FormPayload
{
$context = new FormContext(
namespace: $namespace,
@@ -65,10 +81,6 @@ protected function getWidgetSettingsInfo(WidgetInterface $widget, string $namesp
);
$form = $widget->settingsForm($context);
- return [
- 'settingsForm' => $form === null ? null : app(FormResolver::class)->resolve($form, $context),
- 'settingsHtml' => null,
- 'settingsJs' => null,
- ];
+ return $form === null ? null : app(FormResolver::class)->resolve($form, $context);
}
}
diff --git a/src/Http/Controllers/Dashboard/Widgets/CraftSupportController.php b/src/Http/Controllers/Dashboard/Widgets/CraftSupportController.php
index 66281062aa7..4073c687dd2 100644
--- a/src/Http/Controllers/Dashboard/Widgets/CraftSupportController.php
+++ b/src/Http/Controllers/Dashboard/Widgets/CraftSupportController.php
@@ -16,10 +16,11 @@
use CraftCms\Cms\Support\Str;
use Exception;
use GuzzleHttp\RequestOptions;
+use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
use RuntimeException;
use Symfony\Component\Yaml\Yaml;
use Throwable;
@@ -27,7 +28,6 @@
use function CraftCms\Cms\maxPowerCaptain;
use function CraftCms\Cms\t;
-use function CraftCms\Cms\template;
readonly class CraftSupportController
{
@@ -38,20 +38,12 @@ public function __construct(
private Backups $backups,
) {}
- public function __invoke(Request $request): string
+ public function __invoke(Request $request): RedirectResponse
{
- $request->validate([
- 'widgetId' => ['required', 'integer'],
- 'namespace' => ['nullable', 'string'],
- ]);
-
maxPowerCaptain();
- $widgetId = $request->input('widgetId');
- $namespace = $request->has('namespace') ? $request->input('namespace').'.' : '';
- $data = $namespace ? $request->input($namespace) : $request->all();
-
- $validator = Validator::make($data, [
+ $data = $request->validate([
+ 'widgetId' => ['required', 'integer'],
'fromEmail' => ['required', 'email', 'min:5', 'max:255'],
'message' => ['required', 'string'],
'attachAdditionalFile' => ['nullable', 'file', 'max:3072'],
@@ -60,14 +52,6 @@ public function __invoke(Request $request): string
'attachTemplates' => ['nullable', 'boolean'],
]);
- if ($validator->fails()) {
- return template('_components/widgets/CraftSupport/response', [
- 'widgetId' => $widgetId,
- 'success' => false,
- 'errors' => $validator->errors()->toArray(),
- ]);
- }
-
$parts = [
[
'name' => 'email',
@@ -90,9 +74,9 @@ public function __invoke(Request $request): string
// Create the SupportAttachment zip
try {
$zipData = $this->createZip(
- $data['attachLogs'],
- $data['attachDbBackup'],
- $data['attachTemplates'],
+ (bool) ($data['attachLogs'] ?? false),
+ (bool) ($data['attachDbBackup'] ?? false),
+ (bool) ($data['attachTemplates'] ?? false),
$attachment,
);
$data['message'] .= $zipData['message'];
@@ -121,24 +105,12 @@ public function __invoke(Request $request): string
RequestOptions::MULTIPART => $parts,
]);
- return template('_components/widgets/CraftSupport/response', [
- 'widgetId' => $widgetId,
- 'success' => true,
- 'errors' => [],
- ]);
+ return to_route('craft.cp.dashboard')->with('success', t('Message sent successfully.'));
} catch (Throwable $requestException) {
Log::error("Unable to send support request: {$requestException->getMessage()}", [__METHOD__]);
report($requestException);
- return template('_components/widgets/CraftSupport/response', [
- 'widgetId' => $widgetId,
- 'success' => false,
- 'errors' => [
- 'Support' => [
- t('A server error occurred.'),
- ],
- ],
- ]);
+ throw ValidationException::withMessages(['support' => t('A server error occurred.')]);
} finally {
// Delete the zip file
if (isset($zipData)) {
diff --git a/src/Http/Controllers/Dashboard/WidgetsController.php b/src/Http/Controllers/Dashboard/WidgetsController.php
index 2ef6d83d086..c0a710b30ac 100644
--- a/src/Http/Controllers/Dashboard/WidgetsController.php
+++ b/src/Http/Controllers/Dashboard/WidgetsController.php
@@ -10,10 +10,7 @@
use CraftCms\Cms\Dashboard\Widgets\Custom;
use CraftCms\Cms\Dashboard\Widgets\Widget;
use CraftCms\Cms\Dashboard\WidgetTypes;
-use CraftCms\Cms\Form\FormContext;
-use CraftCms\Cms\Form\FormResolver;
use CraftCms\Cms\Support\Json;
-use CraftCms\Cms\View\HtmlStack;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
@@ -25,7 +22,6 @@
use InteractsWithWidgets;
public function __construct(
- private HtmlStack $HtmlStack,
private Dashboard $dashboard,
private CustomWidgets $customWidgets,
private WidgetTypes $widgetTypes,
@@ -48,12 +44,18 @@ public function store(Request $request): JsonResponse
]);
}
- $settings = $data['settings'] ?? [];
+ $selectable = $widgetType
+ ? $widgetType::isSelectable()
+ : $this->dashboard->getAllWidgets()->doesntContain(
+ fn (WidgetInterface $widget) => $widget instanceof Custom && $widget->definitionId === $customDefinition->id,
+ );
- if (! $settings && $request->has('settingsNamespace')) {
- $settings = $request->input($request->input('settingsNamespace'));
+ if (! $selectable) {
+ throw ValidationException::withMessages(['type' => 'This widget cannot be added.']);
}
+ $settings = $data['settings'] ?? [];
+
$widget = $customDefinition
? $this->dashboard->createWidget([
'type' => Custom::class,
@@ -72,6 +74,7 @@ public function store(Request $request): JsonResponse
public function update(Request $request): JsonResponse
{
$request->validate([
+ 'settings' => ['sometimes', 'array'],
'widgetId' => [
'required',
'integer',
@@ -84,7 +87,7 @@ public function update(Request $request): JsonResponse
// Create a new widget model with the new settings
$settings = $widget instanceof Custom
? $widget->getSettings()
- : $request->input("widget{$widget->id}-settings");
+ : $request->input('settings', []);
Validator::validate($settings, $widget->getRules());
@@ -119,15 +122,9 @@ public function refreshSettings(Request $request): JsonResponse
'type' => $widgetType,
'settings' => $data['settings'] ?? [],
]);
- $context = new FormContext(
- namespace: $data['namespace'],
- values: [$data['namespace'] => $widget->getSettings()],
- refreshable: true,
- );
- $form = $widget->settingsForm($context);
return new JsonResponse([
- 'form' => $form === null ? null : app(FormResolver::class)->resolve($form, $context),
+ 'form' => $this->getWidgetSettingsForm($widget, $data['namespace']),
]);
}
@@ -175,21 +172,19 @@ public function delete(Request $request): JsonResponse
],
]);
- $this->dashboard->deleteWidgetById($request->integer('id'));
+ if (! $this->dashboard->deleteWidgetById($request->integer('id'))) {
+ throw ValidationException::withMessages(['widget' => 'Couldn’t delete widget.']);
+ }
return new JsonResponse;
}
private function saveAndReturnWidget(WidgetInterface $widget): JsonResponse
{
- $this->dashboard->saveWidget($widget);
-
- $info = $this->getWidgetInfo($widget);
+ if (! $this->dashboard->saveWidget($widget)) {
+ throw ValidationException::withMessages(['widget' => 'Couldn’t save widget.']);
+ }
- return new JsonResponse([
- 'info' => $info,
- 'headHtml' => $this->HtmlStack->headHtml(),
- 'bodyHtml' => $this->HtmlStack->bodyHtml(),
- ]);
+ return new JsonResponse(['info' => $this->getWidgetData($widget)]);
}
}
diff --git a/tests/Feature/Dashboard/Widgets/CustomTest.php b/tests/Feature/Dashboard/Widgets/CustomTest.php
index a6456ff4ed3..1c69afbb2dd 100644
--- a/tests/Feature/Dashboard/Widgets/CustomTest.php
+++ b/tests/Feature/Dashboard/Widgets/CustomTest.php
@@ -10,6 +10,7 @@
use CraftCms\Cms\User\Elements\User;
use CraftCms\Cms\User\Models\User as UserModel;
use Illuminate\Support\Facades\File;
+use Inertia\Testing\AssertableInertia;
use function CraftCms\Cms\currentUser;
use function Pest\Laravel\actingAs;
@@ -18,16 +19,16 @@
beforeEach(function () {
$this->originalBasePath = app()->basePath();
- app()->setBasePath(storage_path('framework/testing/custom-widgets'));
+ $this->fixturePath = sys_get_temp_dir().'/craft-custom-widgets-'.bin2hex(random_bytes(8));
+ app()->setBasePath($this->fixturePath);
$this->widgetsPath = resource_path('widgets');
File::ensureDirectoryExists($this->widgetsPath);
- File::cleanDirectory($this->widgetsPath);
});
afterEach(function () {
- File::deleteDirectory($this->widgetsPath);
app()->setBasePath($this->originalBasePath);
+ File::deleteDirectory($this->fixturePath);
});
it('discovers top-level Markdown files', function () {
@@ -173,7 +174,7 @@
'type' => $type,
])->assertOk();
- $record = WidgetModel::query()->sole();
+ $record = WidgetModel::query()->where('type', Custom::class)->sole();
expect($record)
->type->toBe(Custom::class)
@@ -202,7 +203,9 @@
get(route('craft.cp.dashboard'))
->assertOk()
- ->assertViewHas('widgetTypes', fn ($widgetTypes) => $widgetTypes->get($type)['selectable'] === $selectable);
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('Dashboard')
+ ->where("widgetTypes.$type.selectable", $selectable));
})->with([
'unselected' => [false, true],
'selected' => [true, false],
diff --git a/tests/Feature/Dashboard/Widgets/MyDraftsTest.php b/tests/Feature/Dashboard/Widgets/MyDraftsTest.php
index 8a3ceeaf7bb..300881a2ffa 100644
--- a/tests/Feature/Dashboard/Widgets/MyDraftsTest.php
+++ b/tests/Feature/Dashboard/Widgets/MyDraftsTest.php
@@ -4,17 +4,34 @@
use CraftCms\Cms\Dashboard\Dashboard;
use CraftCms\Cms\Dashboard\Widgets\MyDrafts;
+use CraftCms\Cms\Element\Drafts;
+use CraftCms\Cms\Entry\Models\Entry;
+use CraftCms\Cms\Support\Facades\Elements;
use CraftCms\Cms\User\Elements\User;
use Illuminate\Support\Facades\Session;
+use Symfony\Component\DomCrawler\Crawler;
use function Pest\Laravel\actingAs;
-it('can render', function () {
+it('shows no drafts when the user has none', function () {
actingAs(User::find()->one());
Session::start();
$dashboard = app(Dashboard::class);
$widget = $dashboard->createWidget(MyDrafts::class);
- expect($widget->getBodyHtml())->not()->toBeNull();
+ expect($widget->props())->toBe(['drafts' => []]);
+});
+
+it('links the user’s draft to its editor', function () {
+ actingAs($user = User::find()->one());
+ $entry = Entry::factory()->create();
+ $element = Elements::getElementById($entry->id);
+ $draft = app(Drafts::class)->createDraft($element, $user->id);
+
+ $data = app(Dashboard::class)->createWidget(MyDrafts::class)->props();
+
+ expect($data['drafts'])->toHaveCount(1)
+ ->and($data['drafts'][0]['id'])->toBe($draft->id)
+ ->and(new Crawler($data['drafts'][0]['html'])->filter('a')->attr('href'))->toBe($draft->getCpEditUrl());
});
diff --git a/tests/Feature/Dashboard/Widgets/NewUsersTest.php b/tests/Feature/Dashboard/Widgets/NewUsersTest.php
index 0eaa6ff9f29..22f1f728ec5 100644
--- a/tests/Feature/Dashboard/Widgets/NewUsersTest.php
+++ b/tests/Feature/Dashboard/Widgets/NewUsersTest.php
@@ -2,36 +2,35 @@
declare(strict_types=1);
-use CraftCms\Cms\Dashboard\Dashboard;
+use CraftCms\Cms\Dashboard\Models\Widget;
use CraftCms\Cms\Dashboard\Widgets\NewUsers;
use CraftCms\Cms\Edition;
-
-it('can render', function () {
- $dashboard = app(Dashboard::class);
- $widget = $dashboard->createWidget(NewUsers::class);
-
- Edition::set(Edition::Pro);
- expect($widget->getBodyHtml())->not()->toBeNull();
-});
-
-it('is only selectable when craft is pro or higher', function () {
- $dashboard = app(Dashboard::class);
- $widget = $dashboard->createWidget(NewUsers::class);
-
- Edition::set(Edition::Solo);
- expect(NewUsers::isSelectable())->toBeFalse();
- expect($widget->getBodyHtml())->toBeNull();
-
- Edition::set(Edition::Team);
- expect(NewUsers::isSelectable())->toBeFalse();
- expect($widget->getBodyHtml())->toBeNull();
-
- Edition::set(Edition::Pro);
- expect(NewUsers::isSelectable())->toBeTrue();
- expect($widget->getBodyHtml())->not()->toBeNull();
-
- Edition::set(Edition::Enterprise);
- expect(NewUsers::isSelectable())->toBeTrue();
- expect($widget->getBodyHtml())->not()->toBeNull();
-
-});
+use CraftCms\Cms\User\Elements\User;
+use CraftCms\Cms\User\Models\User as UserModel;
+use Inertia\Testing\AssertableInertia;
+
+use function Pest\Laravel\actingAs;
+use function Pest\Laravel\get;
+
+it('only shows New Users on editions that support it', function (Edition $edition, bool $available) {
+ Edition::set($edition);
+ actingAs($user = User::find()->one());
+ UserModel::query()->whereKey($user->id)->update(['hasDashboard' => true]);
+ Widget::query()->create([
+ 'userId' => $user->id,
+ 'type' => NewUsers::class,
+ 'settings' => [],
+ 'sortOrder' => 1,
+ ]);
+
+ get(route('craft.cp.dashboard'))
+ ->assertOk()
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->has('widgets', $available ? 1 : 0)
+ ->where('widgetTypes', fn ($types) => $types[NewUsers::class]['selectable'] === $available));
+})->with([
+ 'Solo' => [Edition::Solo, false],
+ 'Team' => [Edition::Team, false],
+ 'Pro' => [Edition::Pro, true],
+ 'Enterprise' => [Edition::Enterprise, true],
+]);
diff --git a/tests/Feature/Http/Controllers/Dashboard/DashboardControllerTest.php b/tests/Feature/Http/Controllers/Dashboard/DashboardControllerTest.php
index ce539167a9d..de86e130d77 100644
--- a/tests/Feature/Http/Controllers/Dashboard/DashboardControllerTest.php
+++ b/tests/Feature/Http/Controllers/Dashboard/DashboardControllerTest.php
@@ -4,9 +4,13 @@
use CraftCms\Cms\Cms;
use CraftCms\Cms\Dashboard\Models\Widget;
+use CraftCms\Cms\Dashboard\Widgets\Feed;
use CraftCms\Cms\Dashboard\Widgets\QuickPost;
+use CraftCms\Cms\Dashboard\WidgetTypes;
use CraftCms\Cms\Http\Controllers\Dashboard\DashboardController;
use CraftCms\Cms\User\Elements\User;
+use CraftCms\Cms\User\Models\User as UserModel;
+use Inertia\Testing\AssertableInertia;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
@@ -21,8 +25,11 @@
get(action([DashboardController::class, 'index']))
->assertOk()
- ->assertSee('Dashboard')
- ->assertSee('Widget');
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('Dashboard')
+ ->has('widgets', 4)
+ ->where('widgets.0.name', 'Recent Entries')
+ ->where('widgets.3.name', 'Feed'));
});
it('can render a Quick Post widget with empty settings', function () {
@@ -38,3 +45,94 @@
get(action([DashboardController::class, 'index']))
->assertOk();
});
+
+it('preserves an intentionally empty dashboard', function () {
+ actingAs($user = User::find()->one());
+ UserModel::query()->whereKey($user->id)->update(['hasDashboard' => true]);
+
+ get(route('craft.cp.dashboard'))
+ ->assertInertia(fn (AssertableInertia $page) => $page->has('widgets', 0));
+
+ expect(Widget::query()->where('userId', $user->id)->count())->toBe(0);
+});
+
+it('shows a plugin’s HTML override', function () {
+ actingAs($user = User::find()->one());
+ UserModel::query()->whereKey($user->id)->update(['hasDashboard' => true]);
+ app(WidgetTypes::class)->register(DashboardPluginFeed::class);
+
+ $widget = Widget::query()->create([
+ 'userId' => $user->id,
+ 'type' => DashboardPluginFeed::class,
+ 'settings' => ['title' => 'Plugin feed', 'url' => 'https://example.com/feed'],
+ 'sortOrder' => 1,
+ ]);
+
+ get(route('craft.cp.dashboard'))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('widgets.0.id', $widget->id)
+ ->where('widgets.0.component', 'craft:html-widget')
+ ->where('widgets.0.fragment.html', 'Plugin body
'));
+});
+
+class DashboardPluginFeed extends Feed
+{
+ public function getBodyHtml(): string
+ {
+ return 'Plugin body
';
+ }
+}
+
+it('renders a plugin component without a core widget mapping', function () {
+ actingAs($user = User::find()->one());
+ UserModel::query()->whereKey($user->id)->update(['hasDashboard' => true]);
+ app(WidgetTypes::class)->register(DashboardVueWidget::class);
+
+ Widget::query()->create([
+ 'userId' => $user->id,
+ 'type' => DashboardVueWidget::class,
+ 'settings' => [],
+ 'sortOrder' => 1,
+ ]);
+
+ get(route('craft.cp.dashboard'))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('widgets.0.component', 'example:dashboard-widget')
+ ->where('widgets.0.data.message', 'Plugin component'));
+});
+
+class DashboardVueWidget extends DashboardPluginFeed
+{
+ public function component(): ?string
+ {
+ return 'example:dashboard-widget';
+ }
+
+ public function props(): array
+ {
+ return ['message' => 'Plugin component'];
+ }
+}
+
+it('omits hidden widgets from the dashboard', function () {
+ actingAs($user = User::find()->one());
+ UserModel::query()->whereKey($user->id)->update(['hasDashboard' => true]);
+ app(WidgetTypes::class)->register(HiddenDashboardWidget::class);
+ Widget::query()->create([
+ 'userId' => $user->id,
+ 'type' => HiddenDashboardWidget::class,
+ 'settings' => [],
+ 'sortOrder' => 1,
+ ]);
+
+ get(route('craft.cp.dashboard'))
+ ->assertInertia(fn (AssertableInertia $page) => $page->has('widgets', 0));
+});
+
+class HiddenDashboardWidget extends CraftCms\Cms\Dashboard\Widgets\Widget
+{
+ public function getBodyHtml(): ?string
+ {
+ return null;
+ }
+}
diff --git a/tests/Feature/Http/Controllers/Dashboard/Widgets/CraftSupportControllerTest.php b/tests/Feature/Http/Controllers/Dashboard/Widgets/CraftSupportControllerTest.php
index a3a97b44f36..969956eedae 100644
--- a/tests/Feature/Http/Controllers/Dashboard/Widgets/CraftSupportControllerTest.php
+++ b/tests/Feature/Http/Controllers/Dashboard/Widgets/CraftSupportControllerTest.php
@@ -5,18 +5,23 @@
use CraftCms\Cms\Dashboard\Dashboard;
use CraftCms\Cms\Dashboard\Widgets\CraftSupport;
use CraftCms\Cms\Http\Controllers\Dashboard\Widgets\CraftSupportController;
+use CraftCms\Cms\Support\Api;
use CraftCms\Cms\Support\File;
use CraftCms\Cms\User\Elements\User;
+use GuzzleHttp\Psr7\Response as PsrResponse;
+use Illuminate\Http\Client\Response;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use function Pest\Laravel\actingAs;
+use function Pest\Laravel\from;
use function Pest\Laravel\postJson;
beforeEach(function () {
actingAs(User::find()->one());
$this->dashboard = app(Dashboard::class);
+ $this->mock(Api::class)->shouldReceive('request')->andReturn(new Response(new PsrResponse(200)));
});
it('requires login', function () {
@@ -37,14 +42,12 @@
$response = postJson(action(CraftSupportController::class), array_merge(['widgetId' => $widget->id], $data));
if (count($errors) === 0) {
- $response->assertOk();
+ $response->assertRedirect(route('craft.cp.dashboard'))->assertSessionHas('success');
return;
}
- foreach ($errors as $error) {
- $response->assertSee("errors: {\"$error\"", escape: false);
- }
+ $response->assertUnprocessable()->assertJsonValidationErrors($errors);
})->with([
[
'data' => [
@@ -106,3 +109,25 @@
File::delete($zipData['zipPath']);
}
});
+
+it('redirects back with validation errors for the Inertia support form', function () {
+ from(route('craft.cp.dashboard'))
+ ->post(action(CraftSupportController::class), ['widgetId' => 1])
+ ->assertRedirect(route('craft.cp.dashboard'))
+ ->assertSessionHasErrors(['fromEmail', 'message']);
+});
+
+it('redirects back with an error when sending fails', function () {
+ $this->mock(Api::class)->shouldReceive('request')->andThrow(new RuntimeException('Unavailable'));
+
+ from(route('craft.cp.dashboard'))
+ ->post(action(CraftSupportController::class), [
+ 'widgetId' => 1,
+ 'fromEmail' => 'support@example.com',
+ 'message' => 'Test message',
+ 'attachAdditionalFile' => UploadedFile::fake()->create('details.txt', 1, 'text/plain'),
+ 'attachLogs' => '0',
+ ])
+ ->assertRedirect(route('craft.cp.dashboard'))
+ ->assertSessionHasErrors('support');
+});
diff --git a/tests/Feature/Http/Controllers/Dashboard/WidgetsControllerTest.php b/tests/Feature/Http/Controllers/Dashboard/WidgetsControllerTest.php
index 264e6384bb2..5b3565aba73 100644
--- a/tests/Feature/Http/Controllers/Dashboard/WidgetsControllerTest.php
+++ b/tests/Feature/Http/Controllers/Dashboard/WidgetsControllerTest.php
@@ -3,6 +3,8 @@
declare(strict_types=1);
use CraftCms\Cms\Dashboard\Dashboard;
+use CraftCms\Cms\Dashboard\Events\WidgetDeleting;
+use CraftCms\Cms\Dashboard\Events\WidgetSaving;
use CraftCms\Cms\Dashboard\Models\Widget as WidgetModel;
use CraftCms\Cms\Dashboard\Widgets\CraftSupport;
use CraftCms\Cms\Dashboard\Widgets\Feed;
@@ -10,7 +12,9 @@
use CraftCms\Cms\Dashboard\Widgets\Widget;
use CraftCms\Cms\Http\Controllers\Dashboard\WidgetsController;
use CraftCms\Cms\User\Elements\User;
+use CraftCms\Cms\User\Models\User as UserModel;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Event;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\postJson;
@@ -43,14 +47,7 @@
],
])->assertOk();
- expect($response->json('info'))->not()->toBeEmpty();
- expect($response->json('info.settingsForm.scope'))->toBe([sprintf(
- 'widget%s-settings',
- $response->json('info.id'),
- )]);
- expect($response->json('info.settingsHtml'))->toBeNull();
- expect($response->json('headHtml'))->not()->toBeEmpty();
- expect($response->json('bodyHtml'))->not()->toBeEmpty();
+ $response->assertJsonPath('info.title', 'Craft News');
expect(WidgetModel::count())->toBe(1);
tap(WidgetModel::query()->firstOrFail(), function (WidgetModel $widget) {
@@ -58,7 +55,7 @@
});
});
-it('can refresh native widget settings without saving', function () {
+it('can refresh widget settings without saving', function () {
postJson(action([WidgetsController::class, 'refreshSettings']), [
'type' => Feed::class,
'settings' => [
@@ -81,19 +78,6 @@
->assertJsonValidationErrorFor('type');
});
-it('can store namespaced settings', function () {
- postJson(action([WidgetsController::class, 'store']), [
- 'type' => Feed::class,
- 'settingsNamespace' => 'test',
- 'test' => [
- 'title' => 'Craft News',
- 'url' => 'https://craftcms.com/news.rss',
- ],
- ])->assertOk();
-
- expect(WidgetModel::count())->toBe(1);
-});
-
it('can update a widget with settings', function () {
$dashboard = app(Dashboard::class);
$dashboard->saveWidget($widget = $dashboard->createWidget([
@@ -108,18 +92,17 @@
$response = postJson(action([WidgetsController::class, 'update']), [
'widgetId' => $widget->id,
- "widget{$widget->id}-settings" => [
+ 'settings' => [
'title' => 'Craft News',
'limit' => 10,
'url' => 'https://craftcms.com/feed.rss',
],
])->assertOk();
- expect($response->json('info'))->not()->toBeEmpty();
- expect($response->json('headHtml'))->not()->toBeEmpty();
- expect($response->json('bodyHtml'))->not()->toBeEmpty();
+ $response->assertJsonPath('info.title', 'Craft News')->assertJsonPath('info.data.limit', 10);
- expect(Widget::fromConfig(WidgetModel::first())->url)->toBe('https://craftcms.com/feed.rss');
+ expect(WidgetModel::query()->findOrFail($widget->id)->settings)
+ ->toMatchArray(['title' => 'Craft News', 'url' => 'https://craftcms.com/feed.rss', 'limit' => 10]);
});
it('validates when updating', function () {
@@ -134,7 +117,7 @@
postJson(action([WidgetsController::class, 'update']), [
'widgetId' => $widget->id,
- "widget{$widget->id}-settings" => [],
+ 'settings' => [],
])
->assertJsonValidationErrorFor('title')
->assertJsonValidationErrorFor('url')
@@ -197,3 +180,47 @@
expect(WidgetModel::count())->toBe(0);
});
+
+it('does not report a cancelled widget save or deletion as successful', function (string $operation) {
+ $dashboard = app(Dashboard::class);
+ $dashboard->saveWidget($widget = $dashboard->createWidget(Updates::class));
+
+ $eventClass = $operation === 'update'
+ ? WidgetSaving::class
+ : WidgetDeleting::class;
+
+ Event::listen($eventClass, function ($event) {
+ $event->isValid = false;
+ });
+
+ postJson(action([WidgetsController::class, $operation]), [
+ 'id' => $widget->id, 'widgetId' => $widget->id, 'settings' => [],
+ ])->assertUnprocessable();
+
+ expect(WidgetModel::query()->whereKey($widget->id)->exists())->toBeTrue();
+})->with(['update', 'delete']);
+
+it('rejects changes to another user’s widget', function (string $operation) {
+ $dashboard = app(Dashboard::class);
+ $dashboard->saveWidget($widget = $dashboard->createWidget(Updates::class));
+
+ $before = WidgetModel::query()->findOrFail($widget->id)->getAttributes();
+ actingAs(UserModel::factory()->admin()->create());
+
+ postJson(action([WidgetsController::class, $operation]), [
+ 'id' => $widget->id, 'widgetId' => $widget->id,
+ 'settings' => [], 'colspan' => 2, 'ids' => json_encode([$widget->id]),
+ ])->assertUnprocessable();
+
+ expect(WidgetModel::query()->findOrFail($widget->id)->getAttributes())->toBe($before);
+})->with(['update', 'delete', 'updateColspan', 'reorder']);
+
+it('rejects adding another instance of a singleton widget', function () {
+ UserModel::query()->whereKey(Auth::id())->update(['hasDashboard' => true]);
+ app(Dashboard::class)->saveWidget(app(Dashboard::class)->createWidget(Updates::class));
+
+ postJson(action([WidgetsController::class, 'store']), ['type' => Updates::class])
+ ->assertJsonValidationErrorFor('type');
+
+ expect(WidgetModel::query()->count())->toBe(1);
+});
diff --git a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php
index a7ea5ad19e7..1bf5c13d673 100644
--- a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php
+++ b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php
@@ -8,6 +8,8 @@
use CraftCms\Cms\Cp\Data\NavItem;
use CraftCms\Cms\Cp\Data\NotificationButtonData;
use CraftCms\Cms\Cp\Data\NotificationData;
+use CraftCms\Cms\Dashboard\Data\WidgetData;
+use CraftCms\Cms\Dashboard\Data\WidgetTypeData;
use CraftCms\Cms\Entry\Data\EntryType;
use CraftCms\Cms\Entry\Data\EntryTypeIndexData;
use CraftCms\Cms\Form\ControlPayload;
@@ -65,6 +67,8 @@ protected function configure(TypeScriptTransformerConfigFactory $config): void
ControlMode::class,
ControlPayload::class,
FormPayload::class,
+ WidgetData::class,
+ WidgetTypeData::class,
NodePayload::class,
FilesystemsEditViewModel::class,
NavItem::class,
diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php
index f14495b03f4..8b8afd2e80a 100644
--- a/workbench/app/Providers/WorkbenchServiceProvider.php
+++ b/workbench/app/Providers/WorkbenchServiceProvider.php
@@ -6,6 +6,7 @@
use CraftCms\Cms\Cp\Data\NavItem;
use CraftCms\Cms\Cp\Events\CpNavItemsResolving;
+use CraftCms\Cms\Dashboard\WidgetTypes;
use CraftCms\Cms\Plugin\Plugins;
use CraftCms\Cms\Support\CmsAssets;
use CraftCms\Cms\Support\Str;
@@ -13,6 +14,7 @@
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Workbench\App\Forms\FormKitchenSink;
+use Workbench\App\Widgets\HtmlExample;
use function Orchestra\Testbench\package_path;
@@ -20,6 +22,10 @@ class WorkbenchServiceProvider extends ServiceProvider
{
public function boot(): void
{
+ if (! $this->app->runningUnitTests()) {
+ app(WidgetTypes::class)->register(HtmlExample::class);
+ }
+
Event::listen(function (CommandStarting $event): void {
if (! str_starts_with($event->command, 'boost:')) {
return;
diff --git a/workbench/app/Widgets/HtmlExample.php b/workbench/app/Widgets/HtmlExample.php
new file mode 100644
index 00000000000..589041cf7bd
--- /dev/null
+++ b/workbench/app/Widgets/HtmlExample.php
@@ -0,0 +1,68 @@
+ ['required', 'string']];
+ }
+
+ #[Override]
+ public function settingsForm(FormContext $context = new FormContext): Form
+ {
+ return Form::make([
+ Field::make('Message')
+ ->required()
+ ->control(Text::make('message')->value($this->message)),
+ ]);
+ }
+
+ #[Override]
+ public function getBodyHtml(): string
+ {
+ $id = "html-example-{$this->id}";
+ $message = htmlspecialchars($this->message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+
+ HtmlStack::css('.html-example output { font-weight: bold; font-variant-numeric: tabular-nums; }');
+
+ HtmlStack::js(<< {
+ const container = document.getElementById('$id');
+ const output = container.querySelector('output');
+
+ container.querySelector('craft-button').addEventListener('click', () => {
+ output.value = String(Number(output.value) + 1);
+ });
+ })();
+ JS);
+
+ return <<
+ $message
+ Clicks:
+ Increment
+
+ HTML;
+ }
+}
diff --git a/yii2-adapter/legacy/base/WidgetTrait.php b/yii2-adapter/legacy/base/WidgetTrait.php
index 8e8055a2b8e..237c6a9fe8c 100644
--- a/yii2-adapter/legacy/base/WidgetTrait.php
+++ b/yii2-adapter/legacy/base/WidgetTrait.php
@@ -5,6 +5,8 @@
* @license https://craftcms.github.io/license/
*/
+declare(strict_types=1);
+
namespace craft\base;
/**
@@ -20,4 +22,17 @@ trait WidgetTrait
* @var int|null The user’s chosen colspan for the widget
*/
public ?int $colspan = null;
+
+ public function component(): ?string
+ {
+ return 'craft:html-widget';
+ }
+
+ /** @return array|null */
+ public function props(): ?array
+ {
+ $html = $this->getBodyHtml();
+
+ return $html === null ? null : ['html' => $html];
+ }
}
diff --git a/yii2-adapter/resources/js/dashboard.js b/yii2-adapter/resources/js/dashboard.js
new file mode 100644
index 00000000000..f688d06a9e0
--- /dev/null
+++ b/yii2-adapter/resources/js/dashboard.js
@@ -0,0 +1,67 @@
+(() => {
+ const dashboards = new WeakMap();
+ const widgets = new WeakMap();
+
+ window.addEventListener('craft:dashboard-mounted', ({detail: context}) => {
+ const previous = window.dashboard;
+ const dashboard = {
+ widgets: {},
+ grid: context.grid,
+ get widgetTypes() {
+ return context.widgetTypes;
+ },
+ getTypeInfo(type, property, fallback) {
+ const info = context.widgetTypes[type] ?? context.widgets.find((widget) => widget.type === type);
+
+ return property ? (info?.[property] ?? fallback) : info;
+ },
+ createWidget: context.add,
+ showWidgetManager: context.showManager,
+ };
+
+ dashboards.set(context.element, {previous, dashboard});
+ window.dashboard = dashboard;
+ });
+
+ window.addEventListener('craft:dashboard-unmounted', ({detail: {element}}) => {
+ const {previous, dashboard} = dashboards.get(element);
+
+ if (window.dashboard === dashboard) {
+ window.dashboard = previous;
+ }
+
+ dashboards.delete(element);
+ });
+
+ window.addEventListener('craft:widget-mounted', ({detail: context}) => {
+ widgets.set(context.element, {context, api: null});
+ });
+
+ window.addEventListener('craft:widget-content-ready', ({target}) => {
+ const element = target.closest('.dashboard-widget');
+ const state = widgets.get(element);
+
+ if (!state || state.api) return;
+
+ const {context} = state;
+ const api = new window.Craft.Widget(element, null, () => {}, context.widget.settings, context.widget.settingsForm);
+ api.removeListener(api.$settingsBtn, 'click');
+ api.showSettings = context.showSettings;
+ api.hideSettings = context.hideSettings;
+ api.destroy = () => {
+ if (!state.api) return;
+
+ delete window.dashboard.widgets[context.widget.id];
+ window.Garnish.Base.prototype.destroy.call(api);
+ window.jQuery(element).removeData('widget');
+ state.api = null;
+ };
+ state.api = api;
+ });
+
+ window.addEventListener('craft:widget-unmounting', ({detail: {element}}) => {
+ widgets.get(element).api?.destroy();
+
+ widgets.delete(element);
+ });
+})();
diff --git a/yii2-adapter/resources/js/dashboard.test.ts b/yii2-adapter/resources/js/dashboard.test.ts
new file mode 100644
index 00000000000..48d0b128a7d
--- /dev/null
+++ b/yii2-adapter/resources/js/dashboard.test.ts
@@ -0,0 +1,105 @@
+import {afterEach, expect, it, vi} from 'vite-plus/test';
+import {createApp, h, type App} from 'vue';
+import {useDashboard} from '@/modules/dashboard/useDashboard';
+import Widget from '@/modules/dashboard/Widget.vue';
+import HtmlWidget from '@/modules/dashboard/HtmlWidget.vue';
+import type {DashboardWidget} from '@/modules/dashboard/types';
+import './dashboard.js';
+import $ from 'jquery';
+import {buildGarnishCompat} from '@craftcms/garnish/compat';
+
+vi.mock('@craftcms/ui', async () => ({
+ ...await import('@craftcms/ui/utilities/dom'),
+ actionClient: {post: vi.fn()},
+ t: (message: string, params: Record = {}) =>
+ message.replace(/\{(\w+)\}/g, (_, key) => params[key] ?? key),
+}));
+vi.mock('@/common/utils/jquery', () => ({
+ jq: () => () => ({children: () => ({each() {}}), data() {}}),
+}));
+vi.mock('@/modules/grid/grid', () => ({
+ Grid: class {
+ $container = {height() {}};
+ $items = {each() {}};
+ items = [];
+ totalCols = 4;
+ setItems() {}
+ refreshCols() {}
+ destroy() {}
+ },
+}));
+
+let app: App;
+let host: HTMLElement;
+const legacyWindow = window as any;
+const originalCraft = window.Craft;
+const originalGarnish = legacyWindow.Garnish;
+const originalJquery = window.jQuery;
+
+afterEach(() => {
+ app?.unmount();
+ host?.remove();
+ vi.unstubAllGlobals();
+ delete ($.fn as any).velocity;
+ legacyWindow.Craft = originalCraft;
+ legacyWindow.Garnish = originalGarnish;
+ legacyWindow.jQuery = originalJquery;
+});
+
+it('lets an HTML plugin open and close its settings through the Yii API after revisiting the dashboard', async () => {
+ vi.stubGlobal('fetch', async () => new Response(''));
+ // Complete animations immediately while exercising the real widget handlers.
+ ($.fn as any).velocity = function (_properties: unknown, options: {complete: () => void}) {
+ options.complete.call(this);
+ return this;
+ };
+ legacyWindow.Craft = {};
+ legacyWindow.Garnish = buildGarnishCompat();
+ legacyWindow.jQuery = $;
+ await import('../../../packages/craftcms-legacy/dashboard/src/Dashboard.js');
+ const widget: DashboardWidget = {
+ id: 1, type: 'Example', name: 'Example', title: 'Plugin widget', subtitle: null,
+ colspan: 1, maxColspan: 4, settings: {}, settingsForm: {scope: ['settings'], nodes: [], values: {}, errors: [], globalErrors: [], refreshable: false},
+ component: 'craft:html-widget', data: null,
+ fragment: {html: '', headHtml: '', bodyHtml: ''},
+ };
+
+ for (let visit = 0; visit < 2; visit++) {
+ host = document.createElement('div');
+ document.body.append(host);
+ app = createApp({
+ setup() {
+ const dashboard = useDashboard({widgets: [widget], widgetTypes: {}});
+ return () => h('div', {ref: dashboard.container}, [
+ h(Widget, {widget, ready: dashboard.ready.value}),
+ ]);
+ },
+ });
+ app.component('craft:html-widget', HtmlWidget);
+ app.mount(host);
+
+ await vi.waitFor(() => expect(host.querySelector('button')?.textContent).toBe('Configure plugin'));
+ const configure = host.querySelector('button')!;
+ configure.addEventListener('click', () => legacyWindow.dashboard.widgets[1].showSettings());
+ configure.click();
+
+ await vi.waitFor(() => expect(host.querySelector('form h2')?.textContent).toBe('Example Settings'));
+
+ const cancel = Array.from(host.querySelectorAll('form craft-button')).find(button => button.textContent?.trim() === 'Cancel')!;
+ cancel.dispatchEvent(new MouseEvent('click', {bubbles: true}));
+
+ await vi.waitFor(() => expect(host.querySelector('form')).toBeNull());
+ expect(configure.closest('[inert]')).toBeNull();
+
+ host.querySelector('[aria-label="Widget settings"]')!.dispatchEvent(new MouseEvent('click', {bubbles: true}));
+ await vi.waitFor(() => expect(host.querySelector('form h2')?.textContent).toBe('Example Settings'));
+ // Allow the legacy delayed flip to finish before cancelling.
+ await new Promise(resolve => setTimeout(resolve, 150));
+ Array.from(host.querySelectorAll('form craft-button')).find(button => button.textContent?.trim() === 'Cancel')!.dispatchEvent(new MouseEvent('click', {bubbles: true}));
+ await vi.waitFor(() => expect(host.querySelector('form')).toBeNull());
+ expect(configure.closest('.hidden, [inert]')).toBeNull();
+
+ app.unmount();
+ host.remove();
+ }
+});
diff --git a/yii2-adapter/resources/js/legacy-html-control.test.ts b/yii2-adapter/resources/js/legacy-html-control.test.ts
index 051071c46c0..4bf0b98d8f6 100644
--- a/yii2-adapter/resources/js/legacy-html-control.test.ts
+++ b/yii2-adapter/resources/js/legacy-html-control.test.ts
@@ -69,6 +69,30 @@ describe('Legacy HTML Form Control', () => {
});
});
+ it('submits edited and unchanged HTML widget settings', async () => {
+ const {container, form, submitted} = await mount({
+ html: '',
+ headHtml: '',
+ bodyHtml: '',
+ }, {
+ scope: ['widget1-settings'],
+ path: ['widget1-settings', '__legacySettings'],
+ namespace: 'widget1-settings',
+ values: {'widget1-settings': {__legacySettings: {
+ 'widget1-settings[title]': 'Original',
+ 'widget1-settings[limit]': '5',
+ }}},
+ });
+ const input = container.querySelector('[name="widget1-settings[title]"]')!;
+ input.value = 'Edited';
+ input.dispatchEvent(new InputEvent('input', {bubbles: true}));
+ await nextTick();
+
+ form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true}));
+
+ expect(submitted.value).toEqual({'widget1-settings': {title: 'Edited', limit: '5'}});
+ });
+
it('reports asset failures and prevents submission', async () => {
const appendChild = document.body.appendChild;
let failedScript: HTMLScriptElement | undefined;
@@ -303,6 +327,8 @@ async function mount(
) {
const registry = createCpComponentRegistry();
const mutation = ref>({});
+ const submitted = ref();
+ const renderer = ref>();
const payload: FormPayload = {
scope: options.scope ?? [],
refreshable: options.refreshable ?? false,
@@ -345,6 +371,7 @@ async function mount(
setup() {
return () =>
h(FormRenderer, {
+ ref: renderer,
payload,
refresh: options.refresh
? async (values: FormPayload['values'], scope?: string[]) => {
@@ -360,6 +387,10 @@ async function mount(
},
});
+ form.addEventListener('submit', event => {
+ event.preventDefault();
+ submitted.value = renderer.value!.currentValues();
+ });
form.appendChild(container);
document.body.appendChild(form);
registry.install(app);
@@ -367,5 +398,5 @@ async function mount(
app.mount(container);
await nextTick();
- return {app, container, form, mutation};
+ return {app, container, form, mutation, submitted};
}
diff --git a/yii2-adapter/src/Http/RegisterLegacyCompatAssets.php b/yii2-adapter/src/Http/RegisterLegacyCompatAssets.php
index a460c1b7200..889fe158722 100644
--- a/yii2-adapter/src/Http/RegisterLegacyCompatAssets.php
+++ b/yii2-adapter/src/Http/RegisterLegacyCompatAssets.php
@@ -5,8 +5,11 @@
namespace CraftCms\Yii2Adapter\Http;
use Closure;
+use CraftCms\Cms\Http\Controllers\Dashboard\DashboardController;
+use CraftCms\Cms\Http\Controllers\Dashboard\WidgetsController;
use CraftCms\Cms\View\LegacyAssets\InternalAssetRegistry;
use CraftCms\Yii2Adapter\View\LegacyAssets\CpCompatAsset;
+use CraftCms\Yii2Adapter\View\LegacyAssets\DashboardCompatAsset;
use Illuminate\Http\Request;
/**
@@ -24,6 +27,10 @@ public function handle(Request $request, Closure $next): mixed
app(InternalAssetRegistry::class)->register(CpCompatAsset::class);
}
+ if (in_array($request->route()?->getControllerClass(), [DashboardController::class, WidgetsController::class], true)) {
+ app(InternalAssetRegistry::class)->register(DashboardCompatAsset::class);
+ }
+
return $next($request);
}
}
diff --git a/yii2-adapter/src/View/LegacyAssets/DashboardCompatAsset.php b/yii2-adapter/src/View/LegacyAssets/DashboardCompatAsset.php
new file mode 100644
index 00000000000..f0354da1f24
--- /dev/null
+++ b/yii2-adapter/src/View/LegacyAssets/DashboardCompatAsset.php
@@ -0,0 +1,21 @@
+jsFile(craftAsset('legacy/cpcompat/dist/dashboard.js'));
+ }
+}