Skip to content

Commit

Permalink
Merge pull request #16002 from craftcms/feature/cms-1280-nested-entry…
Browse files Browse the repository at this point in the history
…-workflow-improvements

Nested element workflow improvements
  • Loading branch information
brandonkelly authored Nov 4, 2024
2 parents b6cd5ae + 8d946f9 commit 43a73bd
Show file tree
Hide file tree
Showing 11 changed files with 235 additions and 24 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Release Notes for Craft CMS 5.5 (WIP)

### Content Management
- When saving a nested element within a Matrix/Addresses field in card view, the changes are now saved to a draft of the owner element, rather than published immediately. ([#16002](https://github.com/craftcms/cms/pull/16002))
- Nested element cards now show status indicators if they are new or contain unpublished changes. ([#16002](https://github.com/craftcms/cms/pull/16002))
- Improved the styling of element cards with thumbnails. ([#15692](https://github.com/craftcms/cms/pull/15692), [#15673](https://github.com/craftcms/cms/issues/15673))
- Elements within element selection inputs now have “Replace” actions.
- Entry types listed within entry indexes now show their icon and color. ([#15922](https://github.com/craftcms/cms/discussions/15922))
Expand Down Expand Up @@ -89,6 +91,7 @@
- Added `craft\web\View::registerSiteTwigExtension()`.
- `craft\fields\data\LinkData::getLabel()` now has a `$custom` argument.
- `craft\helpers\Console::output()` now prepends an indent to each line of the passed-in string, if `indent()` had been called prior.
- Added the `elements/save-nested-element-for-draft` action. ([#16002](https://github.com/craftcms/cms/pull/16002))
- Improved support for creating log targets for third party logging services. ([#14974](https://github.com/craftcms/cms/pull/14974))
- Deprecated the `enableBasicHttpAuth` config setting. `craft\filters\BasicHttpAuthLogin` should be used instead. ([#15720](https://github.com/craftcms/cms/pull/15720))
- Added the `serializeForm` event to `Craft.ElementEditor`. ([#15794](https://github.com/craftcms/cms/discussions/15794))
Expand Down
125 changes: 125 additions & 0 deletions src/controllers/ElementsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
use craft\base\NestedElementInterface;
use craft\behaviors\DraftBehavior;
use craft\behaviors\RevisionBehavior;
use craft\db\Query;
use craft\db\Table;
use craft\elements\db\ElementQueryInterface;
use craft\elements\db\NestedElementQueryInterface;
use craft\elements\User;
Expand Down Expand Up @@ -1300,6 +1302,129 @@ public function actionSave(): ?Response
]), $element, supportsAddAnother: true);
}

/**
* Saves a nested element for a draft of its owner.
*
* @return Response|null
* @throws BadRequestHttpException
* @throws ForbiddenHttpException
* @throws ServerErrorHttpException
* @since 5.5.0
*/
public function actionSaveNestedElementForDraft(): ?Response
{
$this->requirePostRequest();
$this->requireAcceptsJson();

if (!isset($this->_ownerId)) {
throw new BadRequestHttpException('No new owner was identified by the request.');
}

/** @var Element|null $element */
$element = $this->_element();

if (!$element instanceof NestedElementInterface || $element->getIsRevision()) {
throw new BadRequestHttpException('No element was identified by the request.');
}

$this->element = $element;
$elementsService = Craft::$app->getElements();
$user = static::currentUser();

// Check save permissions before and after applying POST params to the element
// in case the request was tampered with.
if (!$elementsService->canSave($element, $user)) {
throw new ForbiddenHttpException('User not authorized to save this element.');
}

// Get the owner and make sure it's a draft,
// and that its canonical element is the nested element's primary owner
$owner = $elementsService->getElementById($this->_ownerId, siteId: $element->siteId);
if (
!$owner->getIsDraft() ||
$owner->getIsCanonical() ||
$owner->getCanonicalId() !== $element->getPrimaryOwnerId() ||
!$elementsService->canSave($owner, $user)
) {
throw new ForbiddenHttpException('User not authorized to save the owner element.');
}

// Get the old sort order
$sortOrder = (new Query())
->select('sortOrder')
->from(Table::ELEMENTS_OWNERS)
->where([
'elementId' => $element->id,
'ownerId' => $owner->id,
])
->scalar() ?: null;
$element->setSortOrder($sortOrder);

$db = Craft::$app->getDb();
$transaction = $db->beginTransaction();

try {
// Remove existing ownership data for the element within the canonical owner,
// and for its canonical element within the draft
Db::delete(Table::ELEMENTS_OWNERS, [
'or',
['elementId' => $element->id, 'ownerId' => $owner->getCanonicalId()],
['elementId' => $element->getCanonicalId(), 'ownerId' => $owner->id],
]);

if ($element->getIsDraft()) {
// Just remove the draft data, but preserve the canonicalId
$element->setPrimaryOwner($owner);
$element->setOwner($owner);
$elementsService->saveElement($element);
} else {
// Duplicate it
$element = $elementsService->duplicateElement($element, [
'canonicalId' => $element->id,
'primaryOwner' => $owner,
'owner' => $owner,
]);
}

$this->_applyParamsToElement($element);

if (!$elementsService->canSave($element, $user)) {
throw new ForbiddenHttpException('User not authorized to save this element.');
}

if ($element->enabled && $element->getEnabledForSite()) {
$element->setScenario(Element::SCENARIO_LIVE);
}

try {
$success = $elementsService->saveElement($element);
} catch (UnsupportedSiteException $e) {
$element->addError('siteId', $e->getMessage());
$success = false;
}

if (!$success) {
$transaction->rollBack();
return $this->_asFailure($element, Craft::t('app', 'Couldn’t save {type}.', [
'type' => $element::lowerDisplayName(),
]));
}

if ($element->getIsDraft()) {
Craft::$app->getDrafts()->removeDraftData($element);
}

$transaction->commit();
} catch (Throwable $e) {
$transaction->rollBack();
throw $e;
}

return $this->_asSuccess(Craft::t('app', '{type} saved.', [
'type' => $element::displayName(),
]), $element);
}

/**
* Duplicates an element.
*
Expand Down
35 changes: 35 additions & 0 deletions src/helpers/Cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use craft\base\Thumbable;
use craft\behaviors\DraftBehavior;
use craft\elements\Address;
use craft\enums\AttributeStatus;
use craft\enums\CmsEdition;
use craft\enums\Color;
use craft\enums\MenuItemType;
Expand Down Expand Up @@ -632,6 +633,38 @@ public static function elementCardHtml(ElementInterface $element, array $config
]);
}

// is this a nested element that will end up replacing its canonical
// counterpart when the owner is saved?
if (
$element instanceof NestedElementInterface &&
$element->getOwnerId() !== null &&
$element->getOwnerId() === $element->getPrimaryOwnerId() &&
!$element->getIsDraft() &&
!$element->getIsRevision() &&
$element->getOwner()->getIsDraft()
) {
if ($element->getIsCanonical()) {
// this element was created for the owner
$statusLabel = Craft::t('app', 'This is a new {type}.', [
'type' => $element::lowerDisplayName(),
]);
} else {
// this element is a derivative of another element owned by the canonical owner
$statusLabel = Craft::t('app', 'This {type} has been edited.', [
'type' => $element::lowerDisplayName(),
]);
}

$status = Html::beginTag('div', [
'class' => ['status-badge', AttributeStatus::Modified->value],
'title' => $statusLabel,
]) .
Html::tag('span', $statusLabel, [
'class' => 'visually-hidden',
]) .
Html::endTag('div');
}

$thumb = $element->getThumbHtml(128);
if ($thumb === null && $element instanceof Iconic) {
$icon = $element->getIcon();
Expand All @@ -647,6 +680,7 @@ public static function elementCardHtml(ElementInterface $element, array $config
}

$html = Html::beginTag('div', $attributes) .
($status ?? '') .
($thumb ?? '') .
Html::beginTag('div', ['class' => 'card-content']) .
($headingContent !== '' ? Html::tag('div', $headingContent, ['class' => 'card-heading']) : '') .
Expand Down Expand Up @@ -873,6 +907,7 @@ private static function baseElementAttributes(ElementInterface $element, array $
'draft-id' => $element->isProvisionalDraft ? null : $element->draftId,
'revision-id' => $element->revisionId,
'field-id' => $element instanceof NestedElementInterface ? $element->getField()?->id : null,
'primary-owner-id' => $element instanceof NestedElementInterface ? $element->getPrimaryOwnerId() : null,
'owner-id' => $element instanceof NestedElementInterface ? $element->getOwnerId() : null,
'site-id' => $element->siteId,
'status' => $element->getStatus(),
Expand Down
2 changes: 1 addition & 1 deletion src/web/assets/cp/dist/cp.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/web/assets/cp/dist/cp.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/web/assets/cp/dist/css/cp.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/web/assets/cp/dist/css/cp.css.map

Large diffs are not rendered by default.

38 changes: 21 additions & 17 deletions src/web/assets/cp/src/css/_main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3627,6 +3627,7 @@ table {
cursor: default;
user-select: none;
width: 100%;
overflow: hidden;

&::after {
border-radius: var(--large-border-radius);
Expand Down Expand Up @@ -7721,28 +7722,31 @@ fieldset {
}
}

.status-badge {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
width: 2px;
height: 100%;
content: '';
cursor: help;

&.modified {
background-color: var(--blue-600);
box-shadow: 0 0 5px hsl(221deg 83% 53% / 15%);
}

&.outdated {
background-color: var(--pending-color);
box-shadow: 0 0 5px hsl(27deg 96% 61% / 15%);
}
}

.field {
min-inline-size: initial;

& > .status-badge {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
width: 2px;
height: 100%;
border-radius: 1px;
content: '';
cursor: help;

&.modified {
background-color: var(--blue-600);
box-shadow: 0 0 5px hsl(221deg 83% 53% / 15%);
}

&.outdated {
background-color: var(--pending-color);
box-shadow: 0 0 5px hsl(27deg 96% 61% / 15%);
}
}

& > .heading {
Expand Down
7 changes: 5 additions & 2 deletions src/web/assets/cp/src/js/CpScreenSlideout.js
Original file line number Diff line number Diff line change
Expand Up @@ -575,10 +575,12 @@ Craft.CpScreenSlideout = Craft.Slideout.extend(
if (data.modelClass && data.modelId) {
Craft.refreshComponentInstances(data.modelClass, data.modelId);
}
this.trigger('submit', {
const ev = {
response: response,
data: (data.modelName && data[data.modelName]) || {},
});
};
this.trigger('submit', ev);
this.settings.onSubmit(ev);
if (this.settings.closeOnSubmit) {
this.close();
}
Expand Down Expand Up @@ -792,6 +794,7 @@ Craft.CpScreenSlideout = Craft.Slideout.extend(
requestOptions: {},
showHeader: null,
closeOnSubmit: true,
onSubmit: () => {},
},
}
);
6 changes: 6 additions & 0 deletions src/web/assets/cp/src/js/ElementEditorSlideout.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ Craft.ElementEditorSlideout = Craft.CpScreenSlideout.extend(
await this.elementEditor.saveDraft();
}

ev.preventDefault();
ev.stopPropagation();
ev.stopImmediatePropagation();

await this.settings.onBeforeSubmit();
this.elementEditor.handleSubmit(ev);
},

Expand Down Expand Up @@ -205,6 +210,7 @@ Craft.ElementEditorSlideout = Craft.CpScreenSlideout.extend(
validators: [],
expandData: [],
isStatic: false,
onBeforeSubmit: async () => {},
},
}
);
37 changes: 36 additions & 1 deletion src/web/assets/cp/src/js/NestedElementManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,42 @@ Craft.NestedElementManager = Garnish.Base.extend(
// Let the link/button do its thing
return;
}
Craft.createElementEditor(this.elementType, $element);

const slideout = Craft.createElementEditor(
this.elementType,
$element,
{
onBeforeSubmit: async () => {
// If the nested element is primarily owned by the canonical entry being edited,
// then ensure we're working with a draft and save the nested entry changes to the draft
if (
$element.data('primary-owner-id') ==
this.elementEditor.settings.canonicalId
) {
await this.markAsDirty();
if (this.elementEditor.settings.draftId) {
if (!slideout.elementEditor.settings.saveParams) {
slideout.elementEditor.settings.saveParams = {};
}
slideout.elementEditor.settings.saveParams.action =
'elements/save-nested-element-for-draft';
slideout.elementEditor.settings.saveParams.ownerId =
this.settings.ownerId;
}
}
},
onSubmit: (ev) => {
if (ev.data.id != $element.data('id')) {
// swap the element with the new one
$element
.attr('data-id', ev.data.id)
.data('id', ev.data.id)
.data('owner-id', ev.data.ownerId);
Craft.refreshElementInstances(ev.data.id);
}
},
}
);
});
}

Expand Down

0 comments on commit 43a73bd

Please sign in to comment.