diff --git a/.github/workflows/js-ci.yml b/.github/workflows/js-ci.yml index 9052d97a857..075f1f43c93 100644 --- a/.github/workflows/js-ci.yml +++ b/.github/workflows/js-ci.yml @@ -45,7 +45,7 @@ jobs: # `testbench typescript:transform` and `testbench wayfinder:generate` # derive their output from controllers, DTOs, and routes in src/. run: | - echo "value=js-ci-v3-${{ runner.os }}-${{ hashFiles('package-lock.json', 'composer.lock', 'package.json', 'packages/*/package.json', 'packages/craftcms-ui/src/**', 'packages/craftcms-ui/tsconfig*.json', 'packages/craftcms-ui/vite.config.*', 'packages/craftcms-ui/tsdown.config.*', 'packages/craftcms-garnish/src/**', 'packages/craftcms-garnish/tsconfig*.json', 'packages/craftcms-garnish/vite.config.*', 'routes/**/*.php', 'src/**/*.php', 'workbench/**/*.php') }}" >> "$GITHUB_OUTPUT" + echo "value=js-ci-v4-${{ runner.os }}-${{ hashFiles('package-lock.json', 'composer.lock', 'package.json', 'packages/*/package.json', 'packages/craftcms-ui/src/**', 'packages/craftcms-ui/tsconfig*.json', 'packages/craftcms-ui/vite.config.*', 'packages/craftcms-ui/tsdown.config.*', 'packages/craftcms-garnish/src/**', 'packages/craftcms-garnish/tsconfig*.json', 'packages/craftcms-garnish/vite.config.*', 'routes/**/*.php', 'src/**/*.php', 'workbench/**/*.php') }}" >> "$GITHUB_OUTPUT" - name: Restore install + build + generated cache id: cache @@ -57,6 +57,7 @@ jobs: packages/craftcms-ui/dist packages/craftcms-garnish/dist vendor + cms-assets/resources/icons resources/js/actions resources/js/routes resources/js/wayfinder @@ -74,6 +75,10 @@ jobs: env: CRAFT_FONTAWESOME_TOKEN: ${{ secrets.CRAFT_FONTAWESOME_TOKEN }} + - name: Copy icons + if: steps.cache.outputs.cache-hit != 'true' + run: composer copy-icons + # Build the garnish package so its `dist/*.d.ts` exist — `vue-tsc` resolves # `@craftcms/garnish` from `dist` (its `types`), and dist is gitignored. # Without this the typecheck job fails to resolve the module. diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 32ffe1397c2..83322c45cbe 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -59,7 +59,7 @@ jobs: # Keep this in sync with .github/workflows/js-ci.yml so the built assets # and generated files are shared between the two workflows. run: | - echo "value=js-ci-v3-${{ runner.os }}-${{ hashFiles('package-lock.json', 'composer.lock', 'package.json', 'packages/*/package.json', 'packages/craftcms-ui/src/**', 'packages/craftcms-ui/tsconfig*.json', 'packages/craftcms-ui/vite.config.*', 'packages/craftcms-ui/tsdown.config.*', 'packages/craftcms-garnish/src/**', 'packages/craftcms-garnish/tsconfig*.json', 'packages/craftcms-garnish/vite.config.*', 'routes/**/*.php', 'src/**/*.php', 'workbench/**/*.php') }}" >> "$GITHUB_OUTPUT" + echo "value=js-ci-v4-${{ runner.os }}-${{ hashFiles('package-lock.json', 'composer.lock', 'package.json', 'packages/*/package.json', 'packages/craftcms-ui/src/**', 'packages/craftcms-ui/tsconfig*.json', 'packages/craftcms-ui/vite.config.*', 'packages/craftcms-ui/tsdown.config.*', 'packages/craftcms-garnish/src/**', 'packages/craftcms-garnish/tsconfig*.json', 'packages/craftcms-garnish/vite.config.*', 'routes/**/*.php', 'src/**/*.php', 'workbench/**/*.php') }}" >> "$GITHUB_OUTPUT" - name: Restore install + build + generated cache id: cache @@ -71,6 +71,7 @@ jobs: packages/craftcms-ui/dist packages/craftcms-garnish/dist vendor + cms-assets/resources/icons resources/js/actions resources/js/routes resources/js/wayfinder @@ -88,6 +89,10 @@ jobs: env: CRAFT_FONTAWESOME_TOKEN: ${{ secrets.CRAFT_FONTAWESOME_TOKEN }} + - name: Copy icons + if: steps.cache.outputs.cache-hit != 'true' + run: composer copy-icons + # Build the garnish package so its `dist/*.d.ts` exist — `vue-tsc` resolves # `@craftcms/garnish` from `dist` (its `types`), and dist is gitignored. - name: Build @craftcms/garnish @@ -141,6 +146,7 @@ jobs: packages/craftcms-ui/dist packages/craftcms-garnish/dist vendor + cms-assets/resources/icons resources/js/actions resources/js/routes resources/js/wayfinder @@ -184,6 +190,7 @@ jobs: packages/craftcms-ui/dist packages/craftcms-garnish/dist vendor + cms-assets/resources/icons resources/js/actions resources/js/routes resources/js/wayfinder diff --git a/CHANGELOG.md b/CHANGELOG.md index 55eded4334d..380791fc554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Fixed a bug where validating filesystem attributes could resolve `CraftCms\Cms\Filesystem\Filesystems\Filesystem::getRootUrl()`. ([#19535](https://github.com/craftcms/cms/pull/19535)) - Fixed `CraftCms\Cms\Support\Env::parse()` to preserve unknown aliases rather than throw an exception. ([#19535](https://github.com/craftcms/cms/pull/19535)) - Fixed a bug where nested Content Block fields’ content could be lost during a batched resave that included revisions. ([#19543](https://github.com/craftcms/cms/issues/19543)) +- Added Markdown comments to element activity timelines, with support for editing, removing, structured user mentions, and email notifications. ## 6.0.0-alpha.18 - 2026-09-01 diff --git a/resources/js/modules/activity/components/ActivityTimeline.vue b/resources/js/modules/activity/components/ActivityTimeline.vue new file mode 100644 index 00000000000..7ad1a2b7704 --- /dev/null +++ b/resources/js/modules/activity/components/ActivityTimeline.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/resources/js/modules/activity/components/ActivityTimelineActor.vue b/resources/js/modules/activity/components/ActivityTimelineActor.vue new file mode 100644 index 00000000000..f81151b8e8b --- /dev/null +++ b/resources/js/modules/activity/components/ActivityTimelineActor.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/resources/js/modules/activity/components/ActivityTimelineChangeList.vue b/resources/js/modules/activity/components/ActivityTimelineChangeList.vue new file mode 100644 index 00000000000..b67fe3a68ae --- /dev/null +++ b/resources/js/modules/activity/components/ActivityTimelineChangeList.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/resources/js/modules/activity/components/ActivityTimelineChangeValue.vue b/resources/js/modules/activity/components/ActivityTimelineChangeValue.vue new file mode 100644 index 00000000000..97a91fca46e --- /dev/null +++ b/resources/js/modules/activity/components/ActivityTimelineChangeValue.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/resources/js/modules/activity/components/ActivityTimelineChanges.vue b/resources/js/modules/activity/components/ActivityTimelineChanges.vue new file mode 100644 index 00000000000..da1ef720c6e --- /dev/null +++ b/resources/js/modules/activity/components/ActivityTimelineChanges.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/resources/js/modules/activity/components/ActivityTimelineComment.test.ts b/resources/js/modules/activity/components/ActivityTimelineComment.test.ts new file mode 100644 index 00000000000..421d88f34a8 --- /dev/null +++ b/resources/js/modules/activity/components/ActivityTimelineComment.test.ts @@ -0,0 +1,101 @@ +import {actionClient, type CraftTextExpander} from '@craftcms/ui'; +import {createApp, h, nextTick} from 'vue'; +import {afterEach, expect, it, vi} from 'vite-plus/test'; +import ActivityTimelineComment from './ActivityTimelineComment.vue'; + +vi.mock('../../markdown-field/markdown-field', () => { + customElements.define( + 'craft-markdown-field', + class extends HTMLElement { + connectedCallback() { + const textarea = document.createElement('textarea'); + textarea.id = this.id; + this.id += '-editor'; + this.append(textarea); + } + } + ); + + return {}; +}); + +const container = document.createElement('div'); +let app: ReturnType; + +afterEach(() => { + app.unmount(); + container.remove(); + vi.restoreAllMocks(); +}); + +it('inserts mentions into the active composer without changing another comment', async () => { + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(1); + vi.spyOn(actionClient, 'get').mockResolvedValue({ + data: [{label: 'Ada Lovelace', value: '[@ada](craft-user:42)'}], + }); + + document.body.append(container); + app = createApp({ + render: () => + [1, 2].map((elementId) => + h(ActivityTimelineComment, { + elementType: 'CraftCms\\Cms\\Entry\\Elements\\Entry', + elementId, + siteId: 1, + }) + ), + }); + app.mount(container); + await nextTick(); + + const editors = [...container.querySelectorAll('textarea')]; + expect(editors).toHaveLength(2); + expect(new Set(editors.map((editor) => editor.id)).size).toBe(2); + + for (const composer of container.querySelectorAll( + '[data-activity-comment-draft]' + )) { + const editor = composer.querySelector('textarea')!; + const label = composer.querySelector('label')!; + const expander = composer.querySelector( + 'craft-text-expander' + )!; + await expander.updateComplete; + + expect(label.control).toBe(editor); + expect(editor.getAttribute('aria-controls')).toBe( + expander.querySelector('[role="listbox"]')!.id + ); + } + + const [firstEditor, secondEditor] = editors as [ + HTMLTextAreaElement, + HTMLTextAreaElement, + ]; + firstEditor.value = 'Another comment'; + firstEditor.dispatchEvent(new InputEvent('input', {bubbles: true})); + secondEditor.focus(); + secondEditor.value = 'Hello @ad'; + secondEditor.setSelectionRange(9, 9); + secondEditor.dispatchEvent(new InputEvent('input', {bubbles: true})); + + const secondComposer = secondEditor.closest('[data-activity-comment-draft]')!; + await vi.waitFor(() => { + expect( + secondComposer.querySelector('[role="option"]')?.textContent + ).toContain('Ada Lovelace'); + expect( + secondComposer + .querySelector('[role="option"]') + ?.getAttribute('aria-selected') + ).toBe('true'); + }); + + secondEditor.dispatchEvent( + new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}) + ); + await nextTick(); + + expect(secondEditor.value).toBe('Hello [@ada](craft-user:42)'); + expect(firstEditor.value).toBe('Another comment'); +}); diff --git a/resources/js/modules/activity/components/ActivityTimelineComment.vue b/resources/js/modules/activity/components/ActivityTimelineComment.vue new file mode 100644 index 00000000000..78a7e601817 --- /dev/null +++ b/resources/js/modules/activity/components/ActivityTimelineComment.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/resources/js/modules/activity/components/ActivityTimelineEvent.vue b/resources/js/modules/activity/components/ActivityTimelineEvent.vue new file mode 100644 index 00000000000..731bef1ba75 --- /dev/null +++ b/resources/js/modules/activity/components/ActivityTimelineEvent.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/resources/js/modules/activity/composables/useActivityTimeline.ts b/resources/js/modules/activity/composables/useActivityTimeline.ts new file mode 100644 index 00000000000..a084cce9ea3 --- /dev/null +++ b/resources/js/modules/activity/composables/useActivityTimeline.ts @@ -0,0 +1,160 @@ +import {actionClient} from '@craftcms/ui'; +import {computed, nextTick, onScopeDispose, watch, type Ref} from 'vue'; +import {useFetch} from '@/common/composables/useFetch'; + +export interface ActivityTarget { + label: string; + url: string | null; + deleted: boolean; +} + +export interface ActivityChange { + label: string; + old: unknown; + new: unknown; +} + +export interface ActivityComment { + html: string | null; + markdown: string | null; + edited: boolean; + deleted: boolean; + canEdit: boolean; + canDelete: boolean; +} + +export interface ActivityEvent { + id: string; + icon: string | null; + occurredAt: string; + formattedOccurredAt: { + date: string; + dateLabel: string; + time: string; + full: string; + }; + actor: ActivityTarget; + impersonator: ActivityTarget | null; + source: {label: string}; + description: {text: string | null; html: string | null}; + changes: ActivityChange[]; + comment?: ActivityComment | null; +} + +interface ActivityTimelineResponse { + events: ActivityEvent[]; +} + +export interface ActivityTimelineProps { + active: boolean; + url: string; + elementType: string; + elementId: number | null; + siteId: number | null; + pageUrl?: string | null; + refreshToken?: number; +} + +export function useActivityTimeline( + props: ActivityTimelineProps, + timeline: Ref +) { + const { + data, + state: status, + execute, + abort, + } = useFetch( + computed(() => props.url), + { + method: 'post', + axiosInstance: actionClient, + immediate: false, + refetch: false, + onSuccess: () => void scrollToEnd(), + } + ); + const events = computed(() => data.value?.events ?? []); + const hasLoaded = computed(() => data.value !== null); + + onScopeDispose(abort); + + async function load(): Promise { + if (props.elementId === null) { + return; + } + + await execute({ + elementType: props.elementType, + elementId: props.elementId, + siteId: props.siteId, + }); + } + + function addOrUpdateEvent(event: ActivityEvent): void { + const index = events.value.findIndex(({id}) => id === event.id); + const updatedEvents = [...events.value]; + + if (index === -1) { + updatedEvents.push(event); + } else { + updatedEvents[index] = event; + } + + data.value = {events: updatedEvents}; + } + + async function scrollToEnd(): Promise { + await nextTick(); + + if (timeline.value !== null) { + timeline.value.scrollTop = timeline.value.scrollHeight; + } + } + + watch( + () => props.active, + (active) => { + if (active && status.value === 'idle') { + void load(); + } + }, + {immediate: true} + ); + + watch( + () => props.refreshToken, + () => { + if (hasLoaded.value) { + void load(); + } + } + ); + + const dayGroups = computed(() => { + const groups = new Map(); + + for (const event of events.value) { + const key = event.formattedOccurredAt.date; + const group = groups.get(key) ?? { + label: event.formattedOccurredAt.dateLabel, + events: [], + }; + + group.events.push(event); + groups.set(key, group); + } + + return [...groups.entries()].map(([key, group]) => ({key, ...group})); + }); + + return { + addOrUpdateEvent, + dayGroups, + events, + hasLoaded, + load, + scrollToEnd, + status, + }; +} diff --git a/resources/js/modules/elements/components/ElementChips.vue b/resources/js/modules/elements/components/ElementChips.vue index fd2afa51a46..ed471e98935 100644 --- a/resources/js/modules/elements/components/ElementChips.vue +++ b/resources/js/modules/elements/components/ElementChips.vue @@ -233,6 +233,9 @@ diff --git a/resources/js/modules/elements/components/ElementEditScreen.vue b/resources/js/modules/elements/components/ElementEditScreen.vue index 2403c33cec5..a600e4750e2 100644 --- a/resources/js/modules/elements/components/ElementEditScreen.vue +++ b/resources/js/modules/elements/components/ElementEditScreen.vue @@ -16,8 +16,8 @@ import FormRenderer from '@/modules/forms/FormRenderer.vue'; import {useElementEditor} from '@/modules/elements/composables/useElementEditor'; import {useElementActionMenu} from '@/modules/elements/composables/useElementActionMenu'; - import RevisionsList from '@/modules/elements/components/RevisionsList.vue'; import AutosaveMessage from '@/modules/elements/components/AutosaveMessage.vue'; + import ElementDetailsTabs from '@/modules/elements/components/ElementDetailsTabs.vue'; import type {FormValues} from '@/modules/forms/types'; const props = defineProps<{ @@ -37,6 +37,7 @@ const { activity, + activityTimelineVersion, autosave, discardDraft, errors, @@ -99,7 +100,10 @@ ]); const hasDetails = computed( - () => Boolean(sidebarPayload.value) || Boolean(payload.metadataHtml) + () => + Boolean(sidebarPayload.value) || + Boolean(payload.metadataHtml) || + Boolean(payload.activityTimelineUrl) ); // Mirrors the legacy wording: a changed draft names the draft, anything else @@ -280,15 +284,13 @@ v-if="hasDetails || $slots['details-header']" class="element-editor__details" > - - - - -
- + + +
@@ -458,33 +420,4 @@ align-items: start; } } - - craft-tabs::part(base) { - gap: var(--c-spacing-sm); - } - - craft-tabs::part(strip) { - border: 0; - } - - craft-tab { - padding: 0; - width: var(--c-size-touch-target); - background-color: white; - aspect-ratio: 1; - display: grid; - place-items: center; - border-radius: var(--c-radius-md); - border: 1px solid transparent; - } - - craft-tab[selected='true'] { - background-color: var(--c-color-neutral-fill-normal); - border-color: var(--c-color-neutral-border-normal); - color: var(--c-color-neutral-on-normal); - - &:after { - display: none; - } - } diff --git a/resources/js/modules/elements/components/ElementEditor.vue b/resources/js/modules/elements/components/ElementEditor.vue index eb6589a0cae..c402fe91401 100644 --- a/resources/js/modules/elements/components/ElementEditor.vue +++ b/resources/js/modules/elements/components/ElementEditor.vue @@ -16,6 +16,7 @@ import {useElementEditor} from '@/modules/elements/composables/useElementEditor'; import {useElementActionMenu} from '@/modules/elements/composables/useElementActionMenu'; import type {FormValues} from '@/modules/forms/types'; + import ElementDetailsTabs from '@/modules/elements/components/ElementDetailsTabs.vue'; const props = defineProps<{ /** @@ -27,6 +28,7 @@ const { activity, + activityTimelineVersion, autosave, discardDraft, errors, @@ -262,29 +264,41 @@ - - + + + diff --git a/resources/js/modules/elements/composables/useElementAutosave.ts b/resources/js/modules/elements/composables/useElementAutosave.ts index 8c730a9fd98..10c0bd4d240 100644 --- a/resources/js/modules/elements/composables/useElementAutosave.ts +++ b/resources/js/modules/elements/composables/useElementAutosave.ts @@ -198,7 +198,6 @@ export function useElementAutosave( // Re-armed per call, so a pending save always waits out the newest change. const delay = ref(delays.discrete); - // `useDebounceFn` hands back no reference to the timer it arms, so there is // nothing to clear when a save is called off mid-debounce. The callback is // gated instead: `cancel()` disarms it and the timer fires into a no-op. diff --git a/resources/js/modules/elements/composables/useElementEditor.test.ts b/resources/js/modules/elements/composables/useElementEditor.test.ts index 47a35765319..3d4425d6aa1 100644 --- a/resources/js/modules/elements/composables/useElementEditor.test.ts +++ b/resources/js/modules/elements/composables/useElementEditor.test.ts @@ -47,21 +47,36 @@ function payload( canonicalId: 12, elementType: 'craft\\elements\\Entry', siteId: 1, + fieldLayoutId: undefined, + title: undefined, + docTitle: undefined, + crumbs: undefined, + readOnly: undefined, draftId: null, isProvisionalDraft: false, canAutosave: false, form: null, sidebarForm: null, + metadataHtml: undefined, + statusLabelHtml: undefined, saveUrl: '/actions/entries/save-entry', applyDraftUrl: '/actions/elements/apply-draft', autosaveUrl: '/actions/elements/save-draft', discardDraftUrl: '/actions/elements/delete-draft', + notice: undefined, + mergeNotice: undefined, + canDiscardDraft: undefined, + submitButtonLabel: undefined, activityUrl: null, + activityTimelineUrl: undefined, + activityPageUrl: undefined, updatedTimestamps: {element: 1, canonical: 1}, formActions: [], headerActions: [], actionMenu: [], previewTargets: [], + elementDisplayName: undefined, + contextMenu: undefined, ...overrides, }; } diff --git a/resources/js/modules/elements/composables/useElementEditor.ts b/resources/js/modules/elements/composables/useElementEditor.ts index 5272486b8d6..2261ea5ff8f 100644 --- a/resources/js/modules/elements/composables/useElementEditor.ts +++ b/resources/js/modules/elements/composables/useElementEditor.ts @@ -87,6 +87,8 @@ export interface ElementEditPayload { previewTargets: Array<{label: string; url: string}>; elementDisplayName: string; activityUrl: string | null; + activityTimelineUrl: string | null; + activityPageUrl: string | null; updatedTimestamps: {element: number | null; canonical: number | null}; contextMenu: { label: string; @@ -316,6 +318,7 @@ export function useElementEditor({saveData}: Options = {}) { // so the shared save pipeline (elevated sessions, error handling, the // processing flag) is reused rather than reimplemented per action. const pendingAction = ref(null); + const activityTimelineVersion = ref(0); const {save} = useSettingsSave( form, @@ -372,9 +375,13 @@ export function useElementEditor({saveData}: Options = {}) { // applying a provisional draft deletes the draft it would write them to. autosave.cancel(); - // The save itself moved the element's `dateUpdated`; without this the - // next poll would report our own write as someone else's change. - activity.rebase(props.updatedTimestamps); + if (!slideout) { + // The save itself moved the element's `dateUpdated`; without this the + // next poll would report our own write as someone else's change. + activity.rebase(props.updatedTimestamps); + } + + activityTimelineVersion.value++; }, } ); @@ -520,6 +527,7 @@ export function useElementEditor({saveData}: Options = {}) { return { activity, + activityTimelineVersion, autosave, discardDraft, submitAction, diff --git a/resources/js/modules/forms/MarkdownControl.vue b/resources/js/modules/forms/MarkdownControl.vue index 726545a2c97..0c6d8f7d80a 100644 --- a/resources/js/modules/forms/MarkdownControl.vue +++ b/resources/js/modules/forms/MarkdownControl.vue @@ -26,14 +26,6 @@ const emit = defineEmits<{ (event: 'update:value', value: string, kind: 'typing'): void; }>(); - - function onInput(event: Event): void { - if (!(event.target instanceof HTMLTextAreaElement)) { - throw new TypeError('Expected a textarea event target.'); - } - - emit('update:value', event.target.value, 'typing'); - }