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 @@ + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + + + 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: 0

+ 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')); + } +}