Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Relation field improvements #15400

Merged
merged 7 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Release Notes for Craft CMS 5.3 (WIP)

### Content Management
- Added the “Link” field type, which replaces “URL”, and can store URLs, `mailto` and `tel` URIs, and entry/asset/category references. ([#15251](https://github.com/craftcms/cms/pull/15251))
- Added the “Link” field type, which replaces “URL”, and can store URLs, `mailto` and `tel` URIs, and entry/asset/category relations. ([#15251](https://github.com/craftcms/cms/pull/15251), [#15400](https://github.com/craftcms/cms/pull/15400))
- Entry and category conditions now have a “Has Descendants” rule. ([#15276](https://github.com/craftcms/cms/discussions/15276))
- “Replace file” actions now display success notices on complete. ([#15217](https://github.com/craftcms/cms/issues/15217))
- Double-clicking on folders within asset indexes and folder selection modals now navigates the index/modal into the folder. ([#15238](https://github.com/craftcms/cms/discussions/15238))
Expand All @@ -18,6 +18,8 @@
- Improved the focus ring styling for dark buttons. ([#15364](https://github.com/craftcms/cms/pull/15364))

### Administration
- Relation fields are now multi-instance. ([#15400](https://github.com/craftcms/cms/pull/15400))
- Relation fields now have Translation Method settings with all the usual options, replacing “Manage relations on a per-site basis” settings. ([#15400](https://github.com/craftcms/cms/pull/15400))
- Icon fields now have an “Include Pro icons” setting, which determines whether Font Awesome Pro icon should be selectable. ([#15242](https://github.com/craftcms/cms/issues/15242))
- New sites’ Base URL settings now default to an environment variable name based on the site name. ([#15347](https://github.com/craftcms/cms/pull/15347))
- Craft now warns against using the `@web` alias for URL settings, regardless of whether it was explicitly defined. ([#15347](https://github.com/craftcms/cms/pull/15347))
Expand All @@ -32,6 +34,9 @@
- Added `craft\base\ElementInterface::addInvalidNestedElementIds()`.
- Added `craft\base\ElementInterface::getInvalidNestedElementIds()`.
- Added `craft\base\FieldLayoutComponent::EVENT_DEFINE_SHOW_IN_FORM`. ([#15260](https://github.com/craftcms/cms/issues/15260))
- Added `craft\base\FieldLayoutElement::$dateAdded`.
- Added `craft\base\RelationFieldInterface`. ([#15400](https://github.com/craftcms/cms/pull/15400))
- Added `craft\base\RelationFieldTrait`. ([#15400](https://github.com/craftcms/cms/pull/15400))
- Added `craft\config\GeneralConfig::addAlias()`. ([#15346](https://github.com/craftcms/cms/pull/15346))
- Added `craft\events\DefineShowFieldLayoutComponentInFormEvent`. ([#15260](https://github.com/craftcms/cms/issues/15260))
- Added `craft\fields\Link`.
Expand All @@ -44,7 +49,10 @@
- Added `craft\fields\linktypes\Email`.
- Added `craft\fields\linktypes\Phone`.
- Added `craft\fields\linktypes\Url`.
- `craft\helpers\DateTimeHelper::toIso8601()` now has a `$setToUtc` argument.
- Deprecated `craft\fields\BaseRelationField::$localizeRelations`.
- Deprecated `craft\fields\Url`, which is now an alias for `craft\fields\Link`.
- Deprecated `craft\services\Relations`.
- Deprecated `craft\web\assets\elementresizedetector\ElementResizeDetectorAsset`.
- Added `Craft.EnvVarGenerator`.
- Added `Craft.endsWith()`.
Expand Down
3 changes: 2 additions & 1 deletion src/base/ApplicationTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@
* @property-read Plugins $plugins The plugins service
* @property-read ProjectConfig $projectConfig The project config service
* @property-read Queue|QueueInterface $queue The job queue
* @property-read Relations $relations The relations service
* @property-read Relations $relations The relations service (deprecated)
* @property-read Revisions $revisions The revisions service
* @property-read Routes $routes The routes service
* @property-read Search $search The search service
Expand Down Expand Up @@ -1343,6 +1343,7 @@ public function getQueue(): Queue
* Returns the relations service.
*
* @return Relations The relations service
* @deprecated in 5.3.0
*/
public function getRelations(): Relations
{
Expand Down
165 changes: 160 additions & 5 deletions src/base/Element.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use craft\behaviors\DraftBehavior;
use craft\behaviors\RevisionBehavior;
use craft\db\CoalesceColumnsExpression;
use craft\db\Command;
use craft\db\Connection;
use craft\db\Query;
use craft\db\Table;
Expand Down Expand Up @@ -5808,6 +5809,9 @@ public function beforeSave(bool $isNew): bool
*/
public function afterSave(bool $isNew): void
{
// Update the element’s relation data
$this->updateRelations();

// Tell the fields about it
foreach ($this->fieldLayoutFields() as $field) {
$field->afterElementSave($this, $isNew);
Expand All @@ -5821,6 +5825,149 @@ public function afterSave(bool $isNew): void
}
}

private function updateRelations(): void
{
if (!$this->hasFieldLayout()) {
return;
}

$fields = $this->relationalFields();

/** @var int[] $skipFieldIds */
$skipFieldIds = [];
/** @var array<int,int|null> $sourceSiteIds */
$sourceSiteIds = [];
/** @var array<int,array<int,int>> $relationData */
$relationData = [];

foreach ($fields as $fieldId => $instances) {
$localizeRelations = $instances[0]->localizeRelations();
$include = false;

foreach ($instances as $field) {
// Skip if nothing changed, or the element is just propagating and we're not localizing relations
if (
($this->duplicateOf || $this->isFieldDirty($field->handle) || $field->forceUpdateRelations($this)) &&
(!$this->propagating || $localizeRelations)
) {
$include = true;
break;
}
}

if ($include) {
// Create target ID => sort order mappings for the field
foreach ($instances as $field) {
$sourceSiteIds[$field->id] = $localizeRelations ? $this->siteId : null;
$relationData[$field->id] ??= [];
foreach ($field->getRelationTargetIds($this) as $targetId) {
if (!isset($relationData[$field->id][$targetId])) {
$relationData[$field->id][$targetId] = count($relationData[$field->id]) + 1;
}
}
}
} else {
$skipFieldIds[] = $fieldId;
}
}

// Get the old relations
$db = Craft::$app->getDb();
$query = (new Query())
->select(['id', 'fieldId', 'sourceSiteId', 'targetId', 'sortOrder'])
->from([Table::RELATIONS])
->where(['sourceId' => $this->id])
->andWhere(['or', ['sourceSiteId' => null], ['sourceSiteId' => $this->siteId]]);
if (!empty(($skipFieldIds))) {
// Exclude the skipped fields rather than listing included fields,
// so we also get any relations for fields that aren't part of the layout
// (https://github.com/craftcms/cms/issues/13956)
$query->andWhere(['not', ['fieldId' => $skipFieldIds]]);
}
$oldRelations = $query->all($db);

/** @var Command[] $updateCommands */
$updateCommands = [];
$deleteIds = [];

foreach ($oldRelations as $relation) {
[$relationId, $fieldId, $oldSourceSiteId, $targetId, $oldSortOrder] = [
$relation['id'],
$relation['fieldId'],
$relation['sourceSiteId'],
$relation['targetId'],
$relation['sortOrder'],
];

// Does this relation still exist?
if (isset($relationData[$fieldId][$targetId])) {
// Anything to update?
if ($oldSourceSiteId != $sourceSiteIds[$fieldId] || $oldSortOrder != $relationData[$fieldId][$targetId]) {
$updateCommands[] = $db->createCommand()->update(Table::RELATIONS, [
'sourceSiteId' => $sourceSiteIds[$fieldId],
'sortOrder' => $relationData[$fieldId][$targetId],
], ['id' => $relationId]);
}

// Avoid re-inserting it
unset($relationData[$fieldId][$targetId]);
if (empty($relationData[$fieldId])) {
unset($relationData[$fieldId]);
}
} else {
$deleteIds[] = $relationId;
}
}

if (empty($updateCommands) && empty($deleteIds) && empty($relationData)) {
// Nothing to do here
return;
}

$db->transaction(function() use ($updateCommands, $deleteIds, $relationData, $sourceSiteIds, $db) {
foreach ($updateCommands as $command) {
$command->execute();
}

// Add the new ones
if (!empty($relationData)) {
$values = [];
foreach ($relationData as $fieldId => $targetIds) {
foreach ($targetIds as $targetId => $sortOrder) {
$values[] = [
$fieldId,
$this->id,
$sourceSiteIds[$fieldId],
$targetId,
$sortOrder,
];
}
}
Db::batchInsert(Table::RELATIONS, ['fieldId', 'sourceId', 'sourceSiteId', 'targetId', 'sortOrder'], $values, $db);
}

if (!empty($deleteIds)) {
Db::delete(Table::RELATIONS, [
'id' => $deleteIds,
], [], $db);
}
});
}

/**
* @return array<int,RelationalFieldInterface[]>
*/
private function relationalFields(): array
{
$fields = [];
foreach ($this->fieldLayoutFields() as $field) {
if ($field instanceof RelationalFieldInterface) {
$fields[$field->id][] = $field;
}
}
return $fields;
}

/**
* @inheritdoc
*/
Expand All @@ -5831,11 +5978,6 @@ public function afterPropagate(bool $isNew): void
$field->afterElementPropagate($this, $isNew);
}

// Delete relations that don’t belong to a relational field on the element's field layout
if (!ElementHelper::isDraftOrRevision($this)) {
Craft::$app->getRelations()->deleteLeftoverRelations($this);
}

// Fire an 'afterPropagate' event
if ($this->hasEventHandlers(self::EVENT_AFTER_PROPAGATE)) {
$this->trigger(self::EVENT_AFTER_PROPAGATE, new ModelEvent([
Expand Down Expand Up @@ -5902,12 +6044,25 @@ public function beforeDeleteForSite(): bool
*/
public function afterDeleteForSite(): void
{
// Delete any site-specific relation data
$this->deleteSiteRelations();

// Tell the fields about it
foreach ($this->fieldLayoutFields() as $field) {
$field->afterElementDeleteForSite($this);
}
}

private function deleteSiteRelations(): void
{
if ($this->hasFieldLayout()) {
Db::delete(Table::RELATIONS, [
'sourceSiteId' => $this->siteId,
'sourceId' => $this->id,
]);
}
}

/**
* @inheritdoc
*/
Expand Down
7 changes: 7 additions & 0 deletions src/base/FieldLayoutElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace craft\base;

use craft\models\FieldLayout;
use DateTime;

/**
* FieldLayoutElement is the base class for classes representing field layout elements in terms of objects.
Expand All @@ -23,6 +24,12 @@ abstract class FieldLayoutElement extends FieldLayoutComponent
*/
public int $width = 100;

/**
* @var DateTime|null The date that the element was added to the field layout.
* @since 5.3.0
*/
public ?DateTime $dateAdded = null;

/**
* @inheritdoc
*/
Expand Down
9 changes: 7 additions & 2 deletions src/base/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ public function datetimeAttributes(): array
$attributes[] = 'dateCreated';
}

if (property_exists($this, 'dateAdded')) {
$attributes[] = 'dateAdded';
}

if (property_exists($this, 'dateUpdated')) {
$attributes[] = 'dateUpdated';
}
Expand Down Expand Up @@ -245,8 +249,9 @@ public function fields(): array
// Have all DateTime attributes converted to ISO-8601 strings
foreach ($datetimeAttributes as $attribute) {
$fields[$attribute] = function($model, $attribute) {
if (!empty($model->$attribute)) {
return DateTimeHelper::toIso8601($model->$attribute);
$date = $model->$attribute;
if ($date) {
return DateTimeHelper::toIso8601($date, true);
}

return $model->$attribute;
Expand Down
43 changes: 43 additions & 0 deletions src/base/RelationalFieldInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license https://craftcms.github.io/license/
*/

namespace craft\base;

/**
* RelationalFieldInterface defines the common interface to be implemented by field classes
* which can store relation data.
*
* @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
* @since 5.3.0
*/
interface RelationalFieldInterface extends FieldInterface
{
/**
* Returns whether relations stored for the field should include the source element’s site ID.
*
* Note that this must be consistent across all instances of the same field.
*
* @return bool
*/
public function localizeRelations(): bool;

/**
* Returns whether relations should be updated for the field.
*
* @param ElementInterface $element
* @return bool
*/
public function forceUpdateRelations(ElementInterface $element): bool;

/**
* Returns the related element IDs for this field.
*
* @param ElementInterface $element
* @return int[]
*/
public function getRelationTargetIds(ElementInterface $element): array;
}
27 changes: 27 additions & 0 deletions src/base/RelationalFieldTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license https://craftcms.github.io/license/
*/

namespace craft\base;

/**
* RelationalFieldTrait provides a base implementation for [[RelationalFieldInterface]].
*
* @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
* @since 5.3.0
*/
trait RelationalFieldTrait
{
public function localizeRelations(): bool
{
return true;
}

public function forceUpdateRelations(ElementInterface $element): bool
{
return false;
}
}
Loading