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

Add “Maintain Hierarchy” setting to Entries and Categories fields #11088

Merged
merged 48 commits into from
Nov 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
468486a
Initial work
timkelty Feb 22, 2022
e17daa4
merge in tims initial work
myleshyson Aug 6, 2022
75bb0ca
begin merging in category markup into element selector field.
myleshyson Aug 6, 2022
48868ae
move CategoryInput logic to BaseElementInput. Move CategoriesControll…
myleshyson Aug 7, 2022
75c43f9
move branch limit next to relateAncestors field in settings
myleshyson Aug 7, 2022
3c0f8d0
a little cleanup. give branch limit a default value. remove entry lan…
myleshyson Aug 7, 2022
a0005bf
Merge branch '4.3' of https://github.com/myleshyson/craft into featur…
myleshyson Aug 15, 2022
28aa6be
merge in 4.3. re-run Unknown command: "build"
myleshyson Aug 15, 2022
e7d7be6
fix conflict. also remove inputs from form data for structured elemen…
myleshyson Aug 23, 2022
cc7cffa
Add Craft.ElementFieldSettings module to handle view logic for elemen…
myleshyson Aug 30, 2022
1fe86b9
merge in 4.3 and fix conflict
myleshyson Aug 30, 2022
1a524c0
allow categories to act like entries
myleshyson Aug 30, 2022
1cc6fa1
address formatting errors. disable instructions when disabling relate…
myleshyson Sep 12, 2022
d4bcd3c
add sources validator for when relateAncestors is checked.
myleshyson Sep 12, 2022
c9f4c65
fix indentation
myleshyson Sep 12, 2022
d17cc6f
force relation save when field relates ancestors
myleshyson Sep 12, 2022
1013dbc
sync with 4.3
myleshyson Sep 12, 2022
8228d6b
updated disabled state for relateAncestors instructions
myleshyson Sep 12, 2022
cb550ec
use translation function for model errors. rename structured input ac…
myleshyson Sep 13, 2022
6da08a8
update source validation message
myleshyson Sep 13, 2022
9c6eb9e
Language tweaks
timkelty Sep 13, 2022
ffb9153
Simplify
timkelty Sep 13, 2022
b1b1945
WS
timkelty Sep 13, 2022
3b3c366
Merge branch '4.3' into feature/entries-with-category-features
timkelty Sep 13, 2022
3cfe1fb
Rename input
timkelty Sep 13, 2022
89430cd
Move to CP bundle
timkelty Sep 13, 2022
80193d5
Changelog
timkelty Sep 13, 2022
f94bfd5
csfix
timkelty Sep 13, 2022
ef214cc
Merge branch '4.4' into feature/category-features-to-entries
brandonkelly Nov 19, 2022
70ea60d
Release note tweaks
brandonkelly Nov 19, 2022
31cbfe8
Fix `@since` tags
brandonkelly Nov 19, 2022
be6ec82
Merge branch '4.4' into feature/category-features-to-entries
brandonkelly Nov 21, 2022
e8e0f3f
Missing translations
brandonkelly Nov 21, 2022
d477c08
Typo
brandonkelly Nov 21, 2022
6679a9c
Type declaration
brandonkelly Nov 21, 2022
3f82d65
Only set relateAncestors to true by default for existing Categories f…
brandonkelly Nov 22, 2022
a868b1b
Relate Ancestors → Maintain Hierarchy
brandonkelly Nov 22, 2022
c98723b
Cleanup
brandonkelly Nov 22, 2022
423b497
Fix elementSelect casing + add extension
brandonkelly Nov 22, 2022
ea824b6
Moved actionStructuredInputHtml() to RelationalFieldsController
brandonkelly Nov 22, 2022
7ce3e9a
Update translations
brandonkelly Nov 22, 2022
d851c0a
Update the field ID
brandonkelly Nov 22, 2022
0cd8a33
JS name updates and limit Maintain Hierarchy setting to structured so…
brandonkelly Nov 22, 2022
57c84c9
ECS
brandonkelly Nov 22, 2022
b5ca9ac
Redundant
brandonkelly Nov 22, 2022
8ff7ae6
JS cleanup + support single source field types (Categories)
brandonkelly Nov 22, 2022
7f4eb66
Release note tweaks
brandonkelly Nov 22, 2022
88263ec
Mention additional discussions
brandonkelly Nov 22, 2022
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
7 changes: 7 additions & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Release Notes for Craft CMS 4.4 (WIP)

### Content Management
- Entries and Categories fields now have “Maintain hierarchy” settings, which become available when a single structured source is selected. ([#8522](https://github.com/craftcms/cms/discussions/8522), [#8748](https://github.com/craftcms/cms/discussions/8748), [#11749](https://github.com/craftcms/cms/pull/11749))
- Entries fields now have a “Branch Limit” setting, which becomes available when “Maintain hierarchy” is enabled, replacing “Min Relations” and “Max Relations”.
- Categories fields now have “Min Relations” and “Max Relations” settings, which become available when “Maintain hierarchy” is disabled, replacing “Branch Limit”.
- Added “Viewable” asset and entry condition rules. ([#12240](https://github.com/craftcms/cms/discussions/12240), [#12266](https://github.com/craftcms/cms/pull/12266))
- Renamed the “Editable” asset and entry condition rules to “Savable”. ([#12266](https://github.com/craftcms/cms/pull/12266))
- Assets, categories, and entries will now redirect to the last-selected source on their index pages when saved. ([#11996](https://github.com/craftcms/cms/discussions/11996))
Expand All @@ -25,8 +28,12 @@
- Added `craft\elements\conditions\entries\ViewableConditionRule`. ([#12266](https://github.com/craftcms/cms/pull/12266))
- Added `craft\events\DefineInputOptionsEvent`. ([#12351](https://github.com/craftcms/cms/pull/12351))
- Added `craft\fields\BaseOptionsField::EVENT_DEFINE_OPTIONS`. ([#12351](https://github.com/craftcms/cms/pull/12351))
- Added `craft\fields\BaseRelationField::$branchLimit`.
- Added `craft\fields\BaseRelationField::$maintainHierarchy`.
- Renamed `craft\elements\conditions\assets\EditableConditionRule` to `SavableConditionRule`, while preserving the original class name with an alias. ([#12266](https://github.com/craftcms/cms/pull/12266))
- Renamed `craft\elements\conditions\entries\EditableConditionRule` to `SavableConditionRule`, while preserving the original class name with an alias. ([#12266](https://github.com/craftcms/cms/pull/12266))
- Added `Craft.ElementFieldSettings`.
- Deprecated `Craft.CategorySelectInput`. `Craft.BaseElementSelectInput` should be used instead. ([#11749](https://github.com/craftcms/cms/pull/11749))

### System
- Improved element deletion performance. ([#12223](https://github.com/craftcms/cms/pull/12223))
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 74 additions & 0 deletions src/controllers/RelationalFieldsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license https://craftcms.github.io/license/
*/

namespace craft\controllers;

use Craft;
use craft\base\ElementInterface;
use craft\web\Controller;
use yii\web\BadRequestHttpException;
use yii\web\ForbiddenHttpException;
use yii\web\Response;

/**
* Relational fields controller.
*
* @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
* @since 4.4.0
*/
class RelationalFieldsController extends Controller
{
/**
* Returns HTML for a structured elements field input based on a given list
* of selected element ids.
*
* @return Response
* @throws BadRequestHttpException
* @throws ForbiddenHttpException
*/
public function actionStructuredInputHtml(): Response
{
$this->requireCpRequest();
$this->requireAcceptsJson();

$elementType = $this->request->getRequiredParam('elementType');
$elementIds = $this->request->getParam('elementIds', []);

$elements = [];

if (!empty($elementIds)) {
/** @var ElementInterface[] $elements */
$elements = $elementType::find()
->id($elementIds)
->siteId($this->request->getParam('siteId'))
->status(null)
->all();

// Fill in the gaps
$structuresService = Craft::$app->getStructures();
$structuresService->fillGapsInElements($elements);

// Enforce the branch limit
if ($branchLimit = $this->request->getParam('branchLimit')) {
$structuresService->applyBranchLimitToElements($elements, $branchLimit);
}
}

$html = $this->getView()->renderTemplate('_includes/forms/elementSelect.twig', [
'elements' => $elements,
'id' => $this->request->getParam('containerId'),
'name' => $this->request->getParam('name'),
'selectionLabel' => $this->request->getParam('selectionLabel'),
'elementType' => $elementType,
'maintainHierarchy' => true,
]);

return $this->asJson([
'html' => $html,
]);
}
}
159 changes: 150 additions & 9 deletions src/fields/BaseRelationField.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,19 @@ public static function valueType(): string
*/
public bool $showSiteMenu = false;

/**
* @var bool Whether to automatically relate structural ancestors.
* @since 4.4.0
*/
public bool $maintainHierarchy = false;

/**
* @var int|null Branch limit
*
* @since 4.4.0
*/
public ?int $branchLimit = null;

/**
* @var string|null The view mode
*/
Expand Down Expand Up @@ -250,6 +263,14 @@ public function __construct(array $config = [])
$config['showSiteMenu'] = true;
}

// if relating ancestors, then clear min/max limits, otherwise clear branch limit
if ($config['maintainHierarchy'] ?? false) {
$config['maxRelations'] = null;
$config['minRelations'] = null;
} else {
$config['branchLimit'] = null;
}

parent::__construct($config);
}

Expand All @@ -260,10 +281,58 @@ public function __construct(array $config = [])
protected function defineRules(): array
{
$rules = parent::defineRules();
$rules[] = [['minRelations', 'maxRelations'], 'number', 'integerOnly' => true];
$rules[] = [['minRelations', 'maxRelations', 'branchLimit'], 'number', 'integerOnly' => true];
$rules[] = [['source', 'sources'], 'validateSources'];
return $rules;
}

/**
* Ensure only one structured source is selected when maintainHierarchy is true.
*
* @param string $attribute
* @since 4.4.0
*/
public function validateSources(string $attribute): void
{
if (!$this->maintainHierarchy) {
return;
}

$inputSources = $this->getInputSources();

if ($inputSources === null) {
$this->addError($attribute, Craft::t('app', 'A source is required when relating ancestors.'));
return;
}

if (is_string($inputSources)) {
$inputSources = [$inputSources];
}

$elementSources = ArrayHelper::whereIn(
Craft::$app->elementSources->getSources(static::elementType()),
'key',
$inputSources
);

if (count($elementSources) > 1) {
$this->addError($attribute, Craft::t('app', 'Only one source is allowed when relating ancestors.'));
}

foreach ($elementSources as $elementSource) {
if (!isset($elementSource['structureId'])) {
$this->addError(
$attribute,
Craft::t(
'app',
'{source} is not a structured source. Only structured sources may be used when relating ancestors.',
['source' => $elementSource['label']]
)
);
}
}
}

/**
* @inheritdoc
*/
Expand All @@ -281,6 +350,9 @@ public function settingsAttributes(): array
$attributes[] = 'targetSiteId';
$attributes[] = 'validateRelatedElements';
$attributes[] = 'viewMode';
$attributes[] = 'allowSelfRelations';
$attributes[] = 'maintainHierarchy';
$attributes[] = 'branchLimit';

return $attributes;
}
Expand All @@ -305,7 +377,22 @@ public function getSettings(): array
public function getSettingsHtml(): ?string
{
$variables = $this->settingsTemplateVariables();
return Craft::$app->getView()->renderTemplate($this->settingsTemplate, $variables);
$view = Craft::$app->getView();

$view->registerJsWithVars(fn($args) => <<<JS
new Craft.ElementFieldSettings(...$args);
JS, [
[
$this->allowMultipleSources,
$view->namespaceInputId('maintain-hierarchy-field'),
$view->namespaceInputId($this->allowMultipleSources ? 'sources-field' : 'source-field'),
$view->namespaceInputId('branch-limit-field'),
$view->namespaceInputId('min-relations-field'),
$view->namespaceInputId('max-relations-field'),
],
]);

return $view->renderTemplate($this->settingsTemplate, $variables);
}

/**
Expand Down Expand Up @@ -362,7 +449,7 @@ public function validateRelationCount(ElementInterface $element): void
*/
public function validateRelatedElements(ElementInterface $element): void
{
// Prevent circular relations from worrying about this entry
// Prevent circular relations from worrying about this element
$sourceId = $element->getCanonicalId();
$sourceValidates = self::$_relatedElementValidates[$sourceId][$element->siteId] ?? null;
self::$_relatedElementValidates[$sourceId][$element->siteId] = true;
Expand Down Expand Up @@ -490,6 +577,25 @@ public function normalizeValue(mixed $value, ?ElementInterface $element = null):
Craft::configure($query, $source['criteria']);
}
}

if ($this->maintainHierarchy) {
$structuresService = Craft::$app->getStructures();

/** @var ElementInterface[] $structureElements */
$structureElements = (clone($query))
->status(null)
->all();

// Fill in any gaps
$structuresService->fillGapsInElements($structureElements);

// Enforce the branch limit
if ($this->branchLimit) {
$structuresService->applyBranchLimitToElements($structureElements, $this->branchLimit);
}

$query->id(ArrayHelper::getColumn($structureElements, 'id'));
}
} else {
$query->id(false);
}
Expand Down Expand Up @@ -758,6 +864,10 @@ public function getEagerLoadingMap(array $sourceElements): array|null|false
}
}

if ($this->maintainHierarchy) {
$criteria['orderBy'] = ['structureelements.lft' => SORT_ASC];
}

return [
'elementType' => static::elementType(),
'map' => $map,
Expand Down Expand Up @@ -806,7 +916,7 @@ public function afterElementSave(ElementInterface $element, bool $isNew): void
{
// Skip if nothing changed, or the element is just propagating and we're not localizing relations
if (
$element->isFieldDirty($this->handle) &&
($element->isFieldDirty($this->handle) || $this->maintainHierarchy) &&
(!$element->propagating || $this->localizeRelations)
) {
/** @var ElementQueryInterface|Collection $value */
Expand All @@ -824,6 +934,32 @@ public function afterElementSave(ElementInterface $element, bool $isNew): void
$targetIds = $this->_all($value, $element)->ids();
}

if ($this->maintainHierarchy) {
$structuresService = Craft::$app->getStructures();

/** @var ElementInterface $class */
$class = static::elementType();

/** @var ElementInterface[] $structureElements */
$structureElements = $class::find()
->id($targetIds)
->drafts(null)
->revisions(null)
->provisionalDrafts(null)
->status(null)
->all();

// Fill in any gaps
$structuresService->fillGapsInElements($structureElements);

// Enforce the branch limit
if ($this->branchLimit) {
$structuresService->applyBranchLimitToElements($structureElements, $this->branchLimit);
}

$targetIds = ArrayHelper::getColumn($structureElements, 'id');
}

/** @var int|int[]|false|null $targetIds */
Craft::$app->getRelations()->saveRelations($this, $element, $targetIds);

Expand Down Expand Up @@ -865,10 +1001,13 @@ public function afterElementSave(ElementInterface $element, bool $isNew): void
*/
public function getSourceOptions(): array
{
$options = array_map(
fn($s) => ['label' => $s['label'], 'value' => $s['key']],
$this->availableSources()
);
$options = array_map(fn($s) => [
'label' => $s['label'],
'value' => $s['key'],
'data' => [
'structure-id' => $s['structureId'] ?? null,
],
], $this->availableSources());
ArrayHelper::multisort($options, 'label', SORT_ASC, SORT_NATURAL | SORT_FLAG_CASE);
return $options;
}
Expand Down Expand Up @@ -1068,7 +1207,9 @@ protected function inputTemplateVariables(array|ElementQueryInterface $value = n
'condition' => $this->getSelectionCondition(),
'criteria' => $selectionCriteria,
'showSiteMenu' => ($this->targetSiteId || !$this->showSiteMenu) ? false : 'auto',
'allowSelfRelations' => $this->allowSelfRelations,
'allowSelfRelations' => (bool)$this->allowSelfRelations,
'maintainHierarchy' => (bool)$this->maintainHierarchy,
'branchLimit' => $this->branchLimit,
'sourceElementId' => !empty($element->id) ? $element->id : null,
'disabledElementIds' => $disabledElementIds,
'limit' => $this->allowLimit ? $this->maxRelations : null,
Expand Down
Loading