Skip to content

Commit 82cec37

Browse files
authored
Merge pull request #13420 from craftcms/feature/dev-1154-show-other-authors-editing-the-same-element
Show other authors editing the same element
2 parents 37ec494 + 6561c9d commit 82cec37

31 files changed

+747
-25
lines changed

CHANGELOG-WIP.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Release Notes for Craft CMS 4.5 (WIP)
22

33
### Content Management
4+
- Entry and category edit pages now show other authors who are currently editing the same element. ([#13420](https://github.com/craftcms/cms/pull/13420))
5+
- Entry and category edit pages now display a notification when the element has been saved by another author. ([#13420](https://github.com/craftcms/cms/pull/13420))
46
- Table fields can now have a “Row heading” column. ([#13231](https://github.com/craftcms/cms/pull/13231))
57
- Table fields now have a “Static Rows” setting. ([#13231](https://github.com/craftcms/cms/pull/13231))
68
- Table fields no longer show a heading row, if all heading values are blank. ([#13231](https://github.com/craftcms/cms/pull/13231))
@@ -73,6 +75,8 @@
7375
- Added `craft\services\Addresses::$formatter`, which can be used to override the default address formatter. ([#13242](https://github.com/craftcms/cms/pull/13242), [#12615](https://github.com/craftcms/cms/discussions/12615))
7476
- Added `craft\services\Addresses::EVENT_DEFINE_ADDRESS_SUBDIVISIONS`. ([#13361](https://github.com/craftcms/cms/pull/13361))
7577
- Added `craft\services\Addresses::defineAddressSubdivisions()`. ([#13361](https://github.com/craftcms/cms/pull/13361))
78+
- Added `craft\services\Elements::getRecentActivity()`. ([#13420](https://github.com/craftcms/cms/pull/13420))
79+
- Added `craft\services\Elements::trackActivity()`. ([#13420](https://github.com/craftcms/cms/pull/13420))
7680
- Added `craft\services\Structures::ACTION_APPEND`. ([#13429](https://github.com/craftcms/cms/pull/13429))
7781
- Added `craft\services\Structures::ACTION_PLACE_AFTER`. ([#13429](https://github.com/craftcms/cms/pull/13429))
7882
- Added `craft\services\Structures::ACTION_PLACE_BEFORE`. ([#13429](https://github.com/craftcms/cms/pull/13429))
@@ -103,6 +107,7 @@
103107
- Added `Craft.BaseUploader`. ([#13313](https://github.com/craftcms/cms/pull/13313))
104108
- Added `Craft.createUploader()`. ([#13313](https://github.com/craftcms/cms/pull/13313))
105109
- Added `Craft.registerUploaderClass()`. ([#13313](https://github.com/craftcms/cms/pull/13313))
110+
- Added `Craft.Tooltip`.
106111

107112
### System
108113
- Added support for setting environmental values in a “secrets” PHP file, identified by a `CRAFT_SECRETS_PATH` environment variable. ([#13283](https://github.com/craftcms/cms/pull/13283))

src/config/app.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
'id' => 'CraftCMS',
55
'name' => 'Craft CMS',
66
'version' => '4.4.16.1',
7-
'schemaVersion' => '4.4.0.4',
7+
'schemaVersion' => '4.5.0.0',
88
'minVersionRequired' => '3.7.11',
99
'basePath' => dirname(__DIR__), // Defines the @app alias
1010
'runtimePath' => '@storage/runtime', // Defines the @runtime alias

src/controllers/ElementsController.php

+74-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use craft\helpers\ElementHelper;
2626
use craft\helpers\Html;
2727
use craft\helpers\UrlHelper;
28+
use craft\models\ElementActivity;
2829
use craft\models\FieldLayoutForm;
2930
use craft\services\Elements;
3031
use craft\web\Controller;
@@ -425,6 +426,8 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null):
425426
'siteStatuses' => $siteStatuses,
426427
'siteToken' => (!Craft::$app->getIsLive() || !$element->getSite()->enabled) ? $security->hashData((string)$element->siteId) : null,
427428
'visibleLayoutElements' => $form ? $form->getVisibleElements() : [],
429+
'updatedTimestamp' => $element->dateUpdated->getTimestamp(),
430+
'canonicalUpdatedTimestamp' => $canonical->dateUpdated->getTimestamp(),
428431
]
429432
)
430433
);
@@ -695,7 +698,11 @@ private function _additionalButtons(
695698
bool $isUnpublishedDraft,
696699
bool $isDraft,
697700
): string {
698-
$components = [];
701+
$components = [
702+
Html::tag('div', options: [
703+
'class' => ['activity-container'],
704+
]),
705+
];
699706

700707
// Preview (View will be added later by JS)
701708
if ($canSave && $previewTargets) {
@@ -966,6 +973,8 @@ public function actionSave(): ?Response
966973
]));
967974
}
968975

976+
$elementsService->trackActivity($element, ElementActivity::TYPE_SAVE);
977+
969978
// See if the user happens to have a provisional element. If so delete it.
970979
$provisional = $element::find()
971980
->provisionalDrafts()
@@ -1203,6 +1212,8 @@ public function actionSaveDraft(): ?Response
12031212
]));
12041213
}
12051214

1215+
$elementsService->trackActivity($element, ElementActivity::TYPE_SAVE);
1216+
12061217
$creator = $element->getCreator();
12071218

12081219
$data = [
@@ -1275,6 +1286,8 @@ public function actionSaveDraft(): ?Response
12751286
'initialDeltaValues' => $view->getInitialDeltaValues(),
12761287
'headHtml' => $view->getHeadHtml(),
12771288
'bodyHtml' => $view->getBodyHtml(),
1289+
'updatedTimestamp' => $element->dateUpdated->getTimestamp(),
1290+
'canonicalUpdatedTimestamp' => $element->getCanonical()->dateUpdated->getTimestamp(),
12781291
];
12791292
}
12801293

@@ -1354,6 +1367,8 @@ public function actionApplyDraft(): ?Response
13541367
}
13551368
}
13561369

1370+
$elementsService->trackActivity($canonical, ElementActivity::TYPE_SAVE);
1371+
13571372
if (!$this->request->getAcceptsJson()) {
13581373
// Tell all browser windows about the element save
13591374
$session = Craft::$app->getSession();
@@ -1484,6 +1499,7 @@ public function actionRevert(): Response
14841499
}
14851500

14861501
$canonical = Craft::$app->getRevisions()->revertToRevision($element, $user->id);
1502+
Craft::$app->getElements()->trackActivity($canonical, ElementActivity::TYPE_SAVE);
14871503

14881504
return $this->_asSuccess(Craft::t('app', '{type} reverted to past revision.', [
14891505
'type' => $element::displayName(),
@@ -1522,6 +1538,63 @@ public function actionGetElementHtml(): Response
15221538
return $this->asJson(compact('html', 'headHtml'));
15231539
}
15241540

1541+
/**
1542+
* Returns any recent activity for an element, and records that the user is viewing the element.
1543+
*
1544+
* @return Response
1545+
* @since 4.5.0
1546+
*/
1547+
public function actionRecentActivity(): Response
1548+
{
1549+
$element = $this->_element();
1550+
1551+
if (!$element || $element->getIsRevision()) {
1552+
throw new BadRequestHttpException('No element was identified by the request.');
1553+
}
1554+
1555+
$elementsService = Craft::$app->getElements();
1556+
$currentUser = Craft::$app->getUser()->getIdentity();
1557+
$activity = $elementsService->getRecentActivity($element, $currentUser->id);
1558+
$elementsService->trackActivity($element, ElementActivity::TYPE_VIEW, $currentUser);
1559+
1560+
return $this->asJson([
1561+
'activity' => array_map(function(ElementActivity $record) use ($element) {
1562+
$recordIsCanonical = $record->element->getIsCanonical() || $record->element->isProvisionalDraft;
1563+
$recordIsCanonicalAndPublished = $recordIsCanonical && !$record->element->getIsUnpublishedDraft();
1564+
$isSameOrUpstream = $element->id === $record->element->id || $recordIsCanonical;
1565+
1566+
if ($isSameOrUpstream) {
1567+
$messageParams = [
1568+
'user' => $record->user->getName(),
1569+
'type' => $recordIsCanonicalAndPublished ? $element::lowerDisplayName() : Craft::t('app', 'draft'),
1570+
];
1571+
$message = match ($record->type) {
1572+
ElementActivity::TYPE_VIEW => Craft::t('app', '{user} is viewing this {type}.', $messageParams),
1573+
ElementActivity::TYPE_EDIT, ElementActivity::TYPE_SAVE => Craft::t('app', '{user} is editing this {type}.', $messageParams),
1574+
};
1575+
} else {
1576+
$messageParams = [
1577+
'user' => $record->user->getName(),
1578+
'type' => $element::lowerDisplayName(),
1579+
];
1580+
$message = match ($record->type) {
1581+
ElementActivity::TYPE_VIEW => Craft::t('app', '{user} is viewing a draft of this {type}.', $messageParams),
1582+
ElementActivity::TYPE_EDIT, ElementActivity::TYPE_SAVE => Craft::t('app', '{user} is editing a draft of this {type}.', $messageParams),
1583+
};
1584+
}
1585+
1586+
return [
1587+
'userId' => $record->user->id,
1588+
'userName' => $record->user->getName(),
1589+
'userThumb' => $record->user->getThumbHtml(26),
1590+
'message' => $message,
1591+
];
1592+
}, $activity),
1593+
'updatedTimestamp' => $element->dateUpdated->getTimestamp(),
1594+
'canonicalUpdatedTimestamp' => $element->getCanonical()->dateUpdated->getTimestamp(),
1595+
]);
1596+
}
1597+
15251598
/**
15261599
* Returns the requested element, populated with any posted attributes.
15271600
*

src/db/Table.php

+2
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ abstract class Table
4141
public const DEPRECATIONERRORS = '{{%deprecationerrors}}';
4242
/** @since 3.2.0 */
4343
public const DRAFTS = '{{%drafts}}';
44+
/** @since 4.5.0 */
45+
public const ELEMENTACTIVITY = '{{%elementactivity}}';
4446
public const ELEMENTS = '{{%elements}}';
4547
public const ELEMENTS_SITES = '{{%elements_sites}}';
4648
public const RESOURCEPATHS = '{{%resourcepaths}}';

src/migrations/Install.php

+14
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,15 @@ public function createTables(): void
297297
'dateLastMerged' => $this->dateTime(),
298298
'saved' => $this->boolean()->notNull()->defaultValue(true),
299299
]);
300+
$this->createTable(Table::ELEMENTACTIVITY, [
301+
'elementId' => $this->integer()->notNull(),
302+
'userId' => $this->integer()->notNull(),
303+
'siteId' => $this->integer()->notNull(),
304+
'draftId' => $this->integer()->null(),
305+
'type' => $this->string()->notNull(),
306+
'timestamp' => $this->dateTime(),
307+
'PRIMARY KEY([[elementId]], [[userId]], [[type]])',
308+
]);
300309
$this->createTable(Table::ELEMENTS, [
301310
'id' => $this->primaryKey(),
302311
'canonicalId' => $this->integer(),
@@ -815,6 +824,7 @@ public function createIndexes(): void
815824
$this->createIndex(null, Table::DEPRECATIONERRORS, ['key', 'fingerprint'], true);
816825
$this->createIndex(null, Table::DRAFTS, ['creatorId', 'provisional'], false);
817826
$this->createIndex(null, Table::DRAFTS, ['saved'], false);
827+
$this->createIndex(null, Table::ELEMENTACTIVITY, ['elementId', 'timestamp', 'userId'], false);
818828
$this->createIndex(null, Table::ELEMENTS, ['dateDeleted'], false);
819829
$this->createIndex(null, Table::ELEMENTS, ['fieldLayoutId'], false);
820830
$this->createIndex(null, Table::ELEMENTS, ['type'], false);
@@ -1007,6 +1017,10 @@ public function addForeignKeys(): void
10071017
$this->addForeignKey(null, Table::CONTENT, ['siteId'], Table::SITES, ['id'], 'CASCADE', 'CASCADE');
10081018
$this->addForeignKey(null, Table::DRAFTS, ['creatorId'], Table::USERS, ['id'], 'SET NULL', null);
10091019
$this->addForeignKey(null, Table::DRAFTS, ['canonicalId'], Table::ELEMENTS, ['id'], 'CASCADE', null);
1020+
$this->addForeignKey(null, Table::ELEMENTACTIVITY, ['elementId'], Table::ELEMENTS, ['id'], 'CASCADE', null);
1021+
$this->addForeignKey(null, Table::ELEMENTACTIVITY, ['userId'], Table::USERS, ['id'], 'CASCADE', null);
1022+
$this->addForeignKey(null, Table::ELEMENTACTIVITY, ['siteId'], Table::SITES, ['id'], 'CASCADE', null);
1023+
$this->addForeignKey(null, Table::ELEMENTACTIVITY, ['draftId'], Table::DRAFTS, ['id'], 'CASCADE', null);
10101024
$this->addForeignKey(null, Table::ELEMENTS, ['canonicalId'], Table::ELEMENTS, ['id'], 'SET NULL');
10111025
$this->addForeignKey(null, Table::ELEMENTS, ['draftId'], Table::DRAFTS, ['id'], 'CASCADE', null);
10121026
$this->addForeignKey(null, Table::ELEMENTS, ['revisionId'], Table::REVISIONS, ['id'], 'CASCADE', null);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace craft\migrations;
4+
5+
use craft\db\Migration;
6+
use craft\db\Table;
7+
8+
/**
9+
* m230710_162700_element_activity migration.
10+
*/
11+
class m230710_162700_element_activity extends Migration
12+
{
13+
/**
14+
* @inheritdoc
15+
*/
16+
public function safeUp(): bool
17+
{
18+
$this->createTable(Table::ELEMENTACTIVITY, [
19+
'elementId' => $this->integer()->notNull(),
20+
'userId' => $this->integer()->notNull(),
21+
'siteId' => $this->integer()->notNull(),
22+
'draftId' => $this->integer()->null(),
23+
'type' => $this->string()->notNull(),
24+
'timestamp' => $this->dateTime(),
25+
'PRIMARY KEY([[elementId]], [[userId]], [[type]])',
26+
]);
27+
28+
$this->createIndex(null, Table::ELEMENTACTIVITY, ['elementId', 'timestamp', 'userId'], false);
29+
$this->addForeignKey(null, Table::ELEMENTACTIVITY, ['elementId'], Table::ELEMENTS, ['id'], 'CASCADE', null);
30+
$this->addForeignKey(null, Table::ELEMENTACTIVITY, ['userId'], Table::USERS, ['id'], 'CASCADE', null);
31+
$this->addForeignKey(null, Table::ELEMENTACTIVITY, ['siteId'], Table::SITES, ['id'], 'CASCADE', null);
32+
$this->addForeignKey(null, Table::ELEMENTACTIVITY, ['draftId'], Table::DRAFTS, ['id'], 'CASCADE', null);
33+
34+
return true;
35+
}
36+
37+
/**
38+
* @inheritdoc
39+
*/
40+
public function safeDown(): bool
41+
{
42+
echo "m230710_162700_element_activity cannot be reverted.\n";
43+
return false;
44+
}
45+
}

src/models/ElementActivity.php

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
/**
3+
* @link https://craftcms.com/
4+
* @copyright Copyright (c) Pixel & Tonic, Inc.
5+
* @license https://craftcms.github.io/license/
6+
*/
7+
8+
namespace craft\models;
9+
10+
use craft\base\ElementInterface;
11+
use craft\elements\User;
12+
use DateTime;
13+
14+
/**
15+
* Element activity model.
16+
*
17+
* @author Pixel & Tonic, Inc. <[email protected]>
18+
* @since 4.5.0
19+
*/
20+
class ElementActivity
21+
{
22+
public const TYPE_VIEW = 'view';
23+
public const TYPE_EDIT = 'edit';
24+
public const TYPE_SAVE = 'save';
25+
26+
/**
27+
* @param User $user
28+
* @param ElementInterface $element
29+
* @param self::TYPE_* $type
30+
* @param DateTime $timestamp
31+
*/
32+
public function __construct(
33+
public User $user,
34+
public ElementInterface $element,
35+
public string $type,
36+
public DateTime $timestamp,
37+
) {
38+
}
39+
}

0 commit comments

Comments
 (0)