Skip to content

Commit

Permalink
Merge pull request #15400 from craftcms/feature/cms-1318-relation-fie…
Browse files Browse the repository at this point in the history
…ld-improvements

Relation field improvements
  • Loading branch information
brandonkelly authored Jul 23, 2024
2 parents 69a743f + 24c3d49 commit 44fe4e9
Show file tree
Hide file tree
Showing 15 changed files with 417 additions and 110 deletions.
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

0 comments on commit 44fe4e9

Please sign in to comment.