From ad090b688ece68c148a8311c58a343db6370a1ce Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 27 Aug 2026 13:44:39 +0200 Subject: [PATCH 1/5] Add activity timeline UI --- CHANGELOG.md | 1 + .../activity/components/ActivityTimeline.vue | 155 ++++ .../components/ActivityTimelineActor.vue | 40 + .../components/ActivityTimelineChangeList.vue | 89 +++ .../ActivityTimelineChangeValue.vue | 66 ++ .../components/ActivityTimelineChanges.vue | 83 +++ .../components/ActivityTimelineComment.vue | 315 ++++++++ .../components/ActivityTimelineEvent.vue | 152 ++++ .../composables/useActivityTimeline.ts | 163 ++++ .../elements/components/ElementChips.vue | 3 + .../components/ElementDetailsTabs.vue | 113 +++ .../elements/components/ElementEditScreen.vue | 97 +-- .../elements/components/ElementEditor.vue | 54 +- .../composables/useElementAutosave.ts | 1 - .../composables/useElementEditor.test.ts | 15 + .../elements/composables/useElementEditor.ts | 14 +- .../js/modules/forms/MarkdownControl.vue | 16 +- .../settings/composables/useSettingsSave.ts | 1 + resources/js/pages/content/Activity.vue | 22 + routes/actions.php | 10 + routes/cp.php | 6 + src/Activity/ActivityComments.php | 45 +- src/Activity/ActivityTimelinePresenter.php | 174 +++++ src/Activity/DraftActivity.php | 10 + src/Activity/ElementWriteActivity.php | 10 +- src/Activity/EntryActivity.php | 54 +- src/Activity/EventTypes/CommentCreated.php | 7 +- src/Activity/EventTypes/CommentDeleted.php | 9 + src/Activity/EventTypes/CommentEdited.php | 7 +- src/Activity/EventTypes/CommentEvent.php | 12 + src/Activity/StructuralElementActivity.php | 2 +- src/Element/Drafts.php | 40 +- .../Elements/ActivityCommentsController.php | 125 ++++ .../ActivityMentionSuggestionsController.php | 62 ++ .../Elements/ActivityTimelineController.php | 47 ++ .../Elements/ElementActivityController.php | 43 ++ src/Http/Requests/ActivityCommentRequest.php | 27 + .../ActivityMentionSuggestionsRequest.php | 18 + src/Http/Requests/ActivityRequest.php | 38 + src/Http/Requests/ActivityTimelineRequest.php | 17 + src/Http/ViewModels/AssetEditViewModel.php | 8 + src/Http/ViewModels/ElementEditViewModel.php | 12 + src/Http/ViewModels/EntryEditViewModel.php | 23 + src/Support/Facades/Drafts.php | 1 + tests/Feature/Activity/EntryActivityTest.php | 110 +++ .../StructuralElementActivityTest.php | 41 ++ .../Assets/EditAssetControllerTest.php | 2 + .../ActivityTimelineControllerTest.php | 694 ++++++++++++++++++ .../Entries/EditEntryControllerTest.php | 6 + workbench/database/seeders/DatabaseSeeder.php | 214 +++++- 50 files changed, 3112 insertions(+), 162 deletions(-) create mode 100644 resources/js/modules/activity/components/ActivityTimeline.vue create mode 100644 resources/js/modules/activity/components/ActivityTimelineActor.vue create mode 100644 resources/js/modules/activity/components/ActivityTimelineChangeList.vue create mode 100644 resources/js/modules/activity/components/ActivityTimelineChangeValue.vue create mode 100644 resources/js/modules/activity/components/ActivityTimelineChanges.vue create mode 100644 resources/js/modules/activity/components/ActivityTimelineComment.vue create mode 100644 resources/js/modules/activity/components/ActivityTimelineEvent.vue create mode 100644 resources/js/modules/activity/composables/useActivityTimeline.ts create mode 100644 resources/js/modules/elements/components/ElementDetailsTabs.vue create mode 100644 resources/js/pages/content/Activity.vue create mode 100644 src/Activity/ActivityTimelinePresenter.php create mode 100644 src/Http/Controllers/Elements/ActivityCommentsController.php create mode 100644 src/Http/Controllers/Elements/ActivityMentionSuggestionsController.php create mode 100644 src/Http/Controllers/Elements/ActivityTimelineController.php create mode 100644 src/Http/Requests/ActivityCommentRequest.php create mode 100644 src/Http/Requests/ActivityMentionSuggestionsRequest.php create mode 100644 src/Http/Requests/ActivityRequest.php create mode 100644 src/Http/Requests/ActivityTimelineRequest.php create mode 100644 tests/Feature/Http/Controllers/Elements/ActivityTimelineControllerTest.php 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..c486f9f3669 --- /dev/null +++ b/resources/js/modules/activity/components/ActivityTimeline.vue @@ -0,0 +1,155 @@ + + + + + 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..045780cf7cb --- /dev/null +++ b/resources/js/modules/activity/components/ActivityTimelineChanges.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/resources/js/modules/activity/components/ActivityTimelineComment.vue b/resources/js/modules/activity/components/ActivityTimelineComment.vue new file mode 100644 index 00000000000..8b837f594a1 --- /dev/null +++ b/resources/js/modules/activity/components/ActivityTimelineComment.vue @@ -0,0 +1,315 @@ + + + + + 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..4bde8b8a003 --- /dev/null +++ b/resources/js/modules/activity/composables/useActivityTimeline.ts @@ -0,0 +1,163 @@ +import {actionClient} from '@craftcms/ui'; +import {computed, nextTick, ref, watch, type Ref} from 'vue'; + +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 events = ref([]); + const status = ref<'idle' | 'loading' | 'loaded' | 'error'>('idle'); + + function requestData() { + return { + elementType: props.elementType, + elementId: props.elementId, + siteId: props.siteId, + }; + } + + async function load(): Promise { + if (props.elementId === null) { + return; + } + + const refreshing = status.value === 'loaded'; + + if (!refreshing) { + status.value = 'loading'; + } + + try { + const {data} = await actionClient.post( + props.url, + requestData() + ); + + events.value = data.events; + status.value = 'loaded'; + await scrollToEnd(); + } catch { + if (!refreshing) { + status.value = 'error'; + } + } + } + + function addOrUpdateEvent(event: ActivityEvent): void { + const index = events.value.findIndex(({id}) => id === event.id); + + if (index === -1) { + events.value.push(event); + + return; + } + + events.value[index] = event; + } + + 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 (status.value === 'loaded') { + 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, + 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'); - }