From d4fc4f213bc4d5966149fbb25d3ca1ddd48fa497 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 12 Feb 2024 22:34:09 -0800 Subject: [PATCH 1/6] Nested entry GraphQL support --- src/gql/arguments/elements/Entry.php | 20 +++++ src/gql/arguments/mutations/NestedEntry.php | 38 +++++++++ src/gql/interfaces/elements/Entry.php | 25 +++++- src/gql/mutations/Entry.php | 87 ++++++++++++++++++++ src/gql/queries/Entry.php | 88 ++++++++++++++++++--- src/gql/resolvers/elements/Entry.php | 30 +++++-- src/gql/resolvers/mutations/Entry.php | 66 +++++++++++++--- src/gql/types/elements/Entry.php | 6 +- src/helpers/Gql.php | 27 +++++++ src/services/Entries.php | 7 +- src/services/Fields.php | 30 +++++++ src/services/Gql.php | 51 ++++++++++++ src/translations/en/app.php | 4 + 13 files changed, 445 insertions(+), 34 deletions(-) create mode 100644 src/gql/arguments/mutations/NestedEntry.php diff --git a/src/gql/arguments/elements/Entry.php b/src/gql/arguments/elements/Entry.php index d4a2c163410..60a05dec7cc 100644 --- a/src/gql/arguments/elements/Entry.php +++ b/src/gql/arguments/elements/Entry.php @@ -43,6 +43,26 @@ public static function getArguments(): array 'type' => Type::listOf(QueryArgument::getType()), 'description' => 'Narrows the query results based on the sections the entries belong to, per the sections’ IDs.', ], + 'field' => [ + 'name' => 'field', + 'type' => Type::listOf(Type::string()), + 'description' => 'Narrows the query results based on the field the entries are contained by.', + ], + 'fieldId' => [ + 'name' => 'fieldId', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the field the entries are contained by, per the fields’ IDs.', + ], + 'primaryOwnerId' => [ + 'name' => 'primaryOwnerId', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the primary owner element of the entries, per the owners’ IDs.', + ], + 'ownerId' => [ + 'name' => 'ownerId', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the owner element of the entries, per the owners’ IDs.', + ], 'type' => [ 'name' => 'type', 'type' => Type::listOf(Type::string()), diff --git a/src/gql/arguments/mutations/NestedEntry.php b/src/gql/arguments/mutations/NestedEntry.php new file mode 100644 index 00000000000..369e2f90726 --- /dev/null +++ b/src/gql/arguments/mutations/NestedEntry.php @@ -0,0 +1,38 @@ + + * @since 5.0.0 + */ +class NestedEntry extends Entry +{ + /** + * @inheritdoc + */ + public static function getArguments(): array + { + return array_merge(parent::getArguments(), [ + 'ownerId' => [ + 'name' => 'ownerId', + 'type' => Type::id(), + 'description' => 'The entry’s owner ID.', + ], + 'sortOrder' => [ + 'name' => 'sortOrder', + 'type' => Type::int(), + 'description' => 'The entry’s sort order.', + ], + ]); + } +} diff --git a/src/gql/interfaces/elements/Entry.php b/src/gql/interfaces/elements/Entry.php index 31828396854..52469a9904e 100644 --- a/src/gql/interfaces/elements/Entry.php +++ b/src/gql/interfaces/elements/Entry.php @@ -94,15 +94,36 @@ public static function getFieldDefinitions(): array ], 'sectionId' => [ 'name' => 'sectionId', - 'type' => Type::nonNull(Type::int()), + 'type' => Type::int(), 'description' => 'The ID of the section that contains the entry.', ], 'sectionHandle' => [ 'name' => 'sectionHandle', - 'type' => Type::nonNull(Type::string()), + 'type' => Type::string(), 'description' => 'The handle of the section that contains the entry.', 'complexity' => Gql::singleQueryComplexity(), ], + 'fieldId' => [ + 'name' => 'fieldId', + 'type' => Type::int(), + 'description' => 'The ID of the field that contains the entry.', + ], + 'fieldHandle' => [ + 'name' => 'fieldHandle', + 'type' => Type::string(), + 'description' => 'The handle of the field that contains the entry.', + 'complexity' => Gql::singleQueryComplexity(), + ], + 'ownerId' => [ + 'name' => 'ownerId', + 'type' => Type::int(), + 'description' => 'The ID of the entry’s owner elementt.', + ], + 'sortOrder' => [ + 'name' => 'sortOrder', + 'type' => Type::int(), + 'description' => 'The entry’s position within the field that contains it.', + ], 'typeId' => [ 'name' => 'typeId', 'type' => Type::nonNull(Type::int()), diff --git a/src/gql/mutations/Entry.php b/src/gql/mutations/Entry.php index 19a621cdbe3..d38d005f9e2 100644 --- a/src/gql/mutations/Entry.php +++ b/src/gql/mutations/Entry.php @@ -8,8 +8,10 @@ namespace craft\gql\mutations; use Craft; +use craft\base\ElementContainerFieldInterface; use craft\gql\arguments\mutations\Draft as DraftMutationArguments; use craft\gql\arguments\mutations\Entry as EntryMutationArguments; +use craft\gql\arguments\mutations\NestedEntry; use craft\gql\arguments\mutations\Structure as StructureArguments; use craft\gql\base\ElementMutationResolver; use craft\gql\base\Mutation; @@ -62,6 +64,35 @@ public static function getMutations(): array } } + $fieldsService = Craft::$app->getFields(); + foreach ($fieldsService->getNestedEntryFieldTypes() as $type) { + foreach ($fieldsService->getFieldsByType($type) as $field) { + /** @var ElementContainerFieldInterface $field */ + $scope = "nestedentryfields.$field->uid"; + $canCreate = Gql::canSchema($scope, 'create'); + $canSave = Gql::canSchema($scope, 'save'); + + if ($canCreate || $canSave) { + // Create a mutation for each entry type + foreach ($field->getFieldLayoutProviders() as $provider) { + if ($provider instanceof EntryTypeModel) { + foreach (static::createSaveMutationsForField($field, $provider, $canSave) as $mutation) { + $mutationList[$mutation['name']] = $mutation; + } + } + } + } + + if (!$createDraftMutations && $canSave) { + $createDraftMutations = true; + } + + if (!$createDeleteMutation && Gql::canSchema($scope, 'delete')) { + $createDeleteMutation = true; + } + } + } + if ($createDeleteMutation || $createDraftMutations) { $resolver = Craft::createObject(EntryMutationResolver::class); @@ -205,4 +236,60 @@ public static function createSaveMutations( return $mutations; } + + /** + * Create the per-entry-type save mutations for a nested entry field. + * + * @param ElementContainerFieldInterface $field + * @param EntryTypeModel $entryType + * @param bool $createSaveDraftMutation + * @return array + * @throws InvalidConfigException + */ + public static function createSaveMutationsForField( + ElementContainerFieldInterface $field, + EntryTypeModel $entryType, + bool $createSaveDraftMutation, + ): array { + $mutations = []; + + $entryMutationArguments = NestedEntry::getArguments(); + $draftMutationArguments = DraftMutationArguments::getArguments(); + $generatedType = EntryType::generateType($entryType); + + /** @var EntryMutationResolver $resolver */ + $resolver = Craft::createObject(EntryMutationResolver::class); + $resolver->setResolutionData('entryType', $entryType); + $resolver->setResolutionData('field', $field); + + static::prepareResolver($resolver, $entryType->getCustomFields()); + + $description = sprintf('Save a “%s” entry in the “%s” %s field.', $entryType->name, $field->name, $field::displayName()); + $draftDescription = sprintf('Save a “%s” entry draft in the “%s” %s field.', $entryType->name, $field->name, $field::displayName()); + + $contentFields = $resolver->getResolutionData(ElementMutationResolver::CONTENT_FIELD_KEY); + $entryMutationArguments = array_merge($entryMutationArguments, $contentFields); + $draftMutationArguments = array_merge($draftMutationArguments, $contentFields); + + $mutations[] = [ + 'name' => "save_{$field->handle}Field_{$entryType->handle}_Entry", + 'description' => $description, + 'args' => $entryMutationArguments, + 'resolve' => [$resolver, 'saveEntry'], + 'type' => $generatedType, + ]; + + // This gets created only if allowed to save entries + if ($createSaveDraftMutation) { + $mutations[] = [ + 'name' => "save_{$field->handle}_{$entryType->handle}_Draft", + 'description' => $draftDescription, + 'args' => $draftMutationArguments, + 'resolve' => [$resolver, 'saveEntry'], + 'type' => $generatedType, + ]; + } + + return $mutations; + } } diff --git a/src/gql/queries/Entry.php b/src/gql/queries/Entry.php index 610c6aa6996..f8413dc0918 100644 --- a/src/gql/queries/Entry.php +++ b/src/gql/queries/Entry.php @@ -12,6 +12,7 @@ use craft\gql\GqlEntityRegistry; use craft\gql\interfaces\elements\Entry as EntryInterface; use craft\gql\resolvers\elements\Entry as EntryResolver; +use craft\gql\types\elements\Entry as EntryGqlType; use craft\gql\types\generators\EntryType as EntryTypeGenerator; use craft\helpers\ArrayHelper; use craft\helpers\Gql as GqlHelper; @@ -37,6 +38,15 @@ public static function getQueries(bool $checkToken = true): array return []; } + /** @var EntryGqlType[] $entryTypeGqlTypes */ + $entryTypeGqlTypes = array_map( + fn(EntryType $entryType) => EntryTypeGenerator::generateType($entryType), + ArrayHelper::index( + GqlHelper::getSchemaContainedEntryTypes(), + fn(EntryType $entryType) => $entryType->id + ), + ); + return [ 'entries' => [ 'type' => Type::listOf(EntryInterface::getType()), @@ -59,26 +69,20 @@ public static function getQueries(bool $checkToken = true): array 'description' => 'This query is used to query for a single entry.', 'complexity' => GqlHelper::singleQueryComplexity(), ], - ...self::getSectionLevelFields(), + ...self::sectionLevelFields($entryTypeGqlTypes), + ...self::nestedEntryFieldLevelFields($entryTypeGqlTypes), ]; } /** * Return the query fields for section level queries. * + * @param EntryGqlType[] $entryTypeGqlTypes * @return array * @throws InvalidConfigException */ - protected static function getSectionLevelFields(): array + private static function sectionLevelFields(array $entryTypeGqlTypes): array { - $entryTypeGqlTypes = array_map( - fn(EntryType $entryType) => EntryTypeGenerator::generateType($entryType), - ArrayHelper::index( - GqlHelper::getSchemaContainedEntryTypes(), - fn(EntryType $entryType) => $entryType->id - ), - ); - $gqlTypes = []; foreach (GqlHelper::getSchemaContainedSections() as $section) { @@ -101,7 +105,13 @@ protected static function getSectionLevelFields(): array // Unset unusable arguments $arguments = EntryArguments::getArguments(); - unset($arguments['section'], $arguments['sectionId']); + unset( + $arguments['section'], + $arguments['sectionId'], + $arguments['field'], + $arguments['fieldId'], + $arguments['ownerId'], + ); // Create the section query field $sectionQueryType = [ @@ -120,4 +130,60 @@ protected static function getSectionLevelFields(): array return $gqlTypes; } + + /** + * Return the query fields for nested entry field queries. + * + * @param EntryGqlType[] $entryTypeGqlTypes + * @return array + * @throws InvalidConfigException + */ + private static function nestedEntryFieldLevelFields(array $entryTypeGqlTypes): array + { + $gqlTypes = []; + + foreach (GqlHelper::getSchemaContainedNestedEntryFields() as $field) { + $typeName = "{$field->handle}NestedEntriesQuery"; + $fieldQueryType = GqlEntityRegistry::getEntity($typeName); + + if (!$fieldQueryType) { + $entryTypesInField = []; + + // Loop through the entry types and create further queries + foreach ($field->getFieldLayoutProviders() as $provider) { + if ($provider instanceof EntryType && isset($entryTypeGqlTypes[$provider->id])) { + $entryTypesInField[] = $entryTypeGqlTypes[$provider->id]; + } + } + + if (empty($entryTypesInField)) { + continue; + } + + // Unset unusable arguments + $arguments = EntryArguments::getArguments(); + unset( + $arguments['section'], + $arguments['sectionId'], + $arguments['field'], + $arguments['fieldId'], + ); + + // Create the query field + $fieldQueryType = [ + 'name' => "{$field->handle}FieldEntries", + 'args' => $arguments, + 'description' => sprintf('Entries within the “%s” %s field.', $field->name, $field::displayName()), + 'type' => Type::listOf(GqlHelper::getUnionType("{$field->handle}FieldEntryUnion", $entryTypesInField)), + // Enforce the section argument and set the source to `null`, to enforce a new element query. + 'resolve' => fn($source, array $arguments, $context, ResolveInfo $resolveInfo) => + EntryResolver::resolve(null, $arguments + ['field' => $field->handle], $context, $resolveInfo), + ]; + } + + $gqlTypes[$field->handle] = $fieldQueryType; + } + + return $gqlTypes; + } } diff --git a/src/gql/resolvers/elements/Entry.php b/src/gql/resolvers/elements/Entry.php index 61b30788a0e..da245a7131b 100644 --- a/src/gql/resolvers/elements/Entry.php +++ b/src/gql/resolvers/elements/Entry.php @@ -33,20 +33,36 @@ public static function prepareQuery(mixed $source, array $arguments, ?string $fi $query = EntryElement::find(); $pairs = GqlHelper::extractAllowedEntitiesFromSchema('read'); - if (!isset($pairs['sections'])) { + if (!isset($pairs['sections']) && !isset($pairs['nestedentryfields'])) { return ElementCollection::empty(); } - $sectionUids = array_flip($pairs['sections']); - $sectionIds = []; + $condition = ['or']; - foreach (Craft::$app->getEntries()->getAllSections() as $section) { - if (isset($sectionUids[$section->uid])) { - $sectionIds[] = $section->id; + if (isset($pairs['sections'])) { + $entriesService = Craft::$app->getEntries(); + $sectionIds = array_filter(array_map( + fn(string $uid) => $entriesService->getSectionByUid($uid)?->id, + $pairs['sections'], + )); + if (!empty($sectionIds)) { + $condition[] = ['in', 'entries.sectionId', $sectionIds]; } } - $query->andWhere(['in', 'entries.sectionId', $sectionIds]); + if (isset($pairs['nestedentryfields'])) { + $fieldsService = Craft::$app->getFields(); + $types = array_flip($fieldsService->getNestedEntryFieldTypes()); + $fieldIds = array_filter(array_map(function(string $uid) use ($fieldsService, $types) { + $field = $fieldsService->getFieldByUid($uid); + return $field && isset($types[$field::class]) ? $field->id : null; + }, $pairs['nestedentryfields'])); + if (!empty($fieldIds)) { + $condition[] = ['in', 'entries.fieldId', $fieldIds]; + } + } + + $query->andWhere($condition); // If not, get the prepared element query } else { diff --git a/src/gql/resolvers/mutations/Entry.php b/src/gql/resolvers/mutations/Entry.php index 7b4c433f678..8a2adaa4986 100644 --- a/src/gql/resolvers/mutations/Entry.php +++ b/src/gql/resolvers/mutations/Entry.php @@ -9,6 +9,7 @@ use Craft; use craft\base\Element; +use craft\base\ElementContainerFieldInterface; use craft\behaviors\DraftBehavior; use craft\elements\db\EntryQuery; use craft\elements\Entry as EntryElement; @@ -161,7 +162,15 @@ public function createDraft(mixed $source, array $arguments, mixed $context, Res } $section = $entry->getSection(); - $this->requireSchemaAction("sections.$section->uid", 'save'); + $field = $entry->getField(); + + if ($section) { + $this->requireSchemaAction("sections.$section->uid", 'save'); + } elseif ($field) { + $this->requireSchemaAction("nestedentryfields.$field->uid", 'save'); + } else { + throw new Error('Unable to perform the action.'); + } $draftName = $arguments['name'] ?? ''; $draftNotes = $arguments['notes'] ?? ''; @@ -199,7 +208,15 @@ public function publishDraft(mixed $source, array $arguments, mixed $context, Re } $section = $draft->getSection(); - $this->requireSchemaAction("sections.$section->uid", 'save'); + $field = $draft->getField(); + + if ($section) { + $this->requireSchemaAction("sections.$section->uid", 'save'); + } elseif ($field) { + $this->requireSchemaAction("nestedentryfields.$field->uid", 'save'); + } else { + throw new Error('Unable to perform the action.'); + } /** @var EntryElement $draft */ $draft = Craft::$app->getDrafts()->applyDraft($draft); @@ -216,16 +233,30 @@ public function publishDraft(mixed $source, array $arguments, mixed $context, Re */ protected function getEntryElement(array $arguments): EntryElement { - /** @var Section $section */ + /** @var Section|null $section */ $section = $this->getResolutionData('section'); + /** @var ElementContainerFieldInterface|null $field */ + $field = $this->getResolutionData('field'); /** @var EntryType $entryType */ $entryType = $this->getResolutionData('entryType'); // Figure out whether the mutation is about an already saved entry - $canIdentify = $section->type === Section::TYPE_SINGLE || !empty($arguments['id']) || !empty($arguments['uid']) || !empty($arguments['draftId']); + $canIdentify = ( + $section?->type === Section::TYPE_SINGLE || + !empty($arguments['id']) || + !empty($arguments['uid']) || + !empty($arguments['draftId']) + ); // Check if relevant schema is present - $this->requireSchemaAction("sections.$section->uid", $canIdentify ? 'save' : 'create'); + $action = $canIdentify ? 'save' : 'create'; + if ($section) { + $this->requireSchemaAction("sections.$section->uid", $action); + } elseif ($field) { + $this->requireSchemaAction("nestedentryfields.$field->uid", $action); + } else { + throw new Error('Unable to perform the action.'); + } $elementService = Craft::$app->getElements(); @@ -245,12 +276,19 @@ protected function getEntryElement(array $arguments): EntryElement $entry = $elementService->createElement(EntryElement::class); } - // If they are identifying a specific entry, don't allow changing the section ID. - if ($canIdentify && $entry->sectionId !== $section->id) { - throw new Error('Impossible to change the section of an existing entry'); + // If they are identifying a specific entry, don't allow changing the section/field ID. + if ($canIdentify) { + if ($section) { + if ($entry->sectionId !== $section->id) { + throw new Error('Impossible to change the section of an existing entry'); + } + } elseif ($entry->fieldId !== $field->id) { + throw new Error('Impossible to change the field of an existing entry'); + } } - $entry->sectionId = $section->id; + $entry->sectionId = $section?->id; + $entry->fieldId = $field?->id; // Null the field layout ID in case the entry type changes. if ($entry->getTypeId() !== $entryType->id) { @@ -271,18 +309,24 @@ protected function getEntryElement(array $arguments): EntryElement */ protected function identifyEntry(EntryQuery $entryQuery, array $arguments): EntryQuery { - /** @var Section $section */ + /** @var Section|null $section */ $section = $this->getResolutionData('section'); + /** @var ElementContainerFieldInterface|null $field */ + $field = $this->getResolutionData('field'); /** @var EntryType $entryType */ $entryType = $this->getResolutionData('entryType'); + if ($field) { + // nested entries won’t be queried if a field param isn’t set + $entryQuery->fieldId($field->id); + } if (!empty($arguments['draftId'])) { $entryQuery->draftId($arguments['draftId']); if (array_key_exists('provisional', $arguments)) { $entryQuery->provisionalDrafts($arguments['provisional']); } - } elseif ($section->type === Section::TYPE_SINGLE) { + } elseif ($section?->type === Section::TYPE_SINGLE) { $entryQuery->typeId($entryType->id); } elseif (!empty($arguments['uid'])) { $entryQuery->uid($arguments['uid']); diff --git a/src/gql/types/elements/Entry.php b/src/gql/types/elements/Entry.php index 26c073a44d8..bb4007bd669 100644 --- a/src/gql/types/elements/Entry.php +++ b/src/gql/types/elements/Entry.php @@ -42,8 +42,12 @@ protected function resolve(mixed $source, array $arguments, mixed $context, Reso return match ($fieldName) { 'sectionId' => $source->sectionId, + 'fieldId' => $source->fieldId, + 'ownerId' => $source->ownerId, + 'sortOrder' => $source->sortOrder, 'typeId' => $source->getTypeId(), - 'sectionHandle' => $source->getSection()->handle, + 'sectionHandle' => $source->getSection()?->handle, + 'fieldHandle' => $source->getField()?->handle, 'typeHandle' => $source->getType()->handle, 'draftName', 'draftNotes' => $source->getIsDraft() ? $source->{$fieldName} : null, 'draftCreator' => $source->getIsDraft() ? $source->getCreator() : null, diff --git a/src/helpers/Gql.php b/src/helpers/Gql.php index aecd217404c..ebc8ce2481a 100644 --- a/src/helpers/Gql.php +++ b/src/helpers/Gql.php @@ -8,6 +8,7 @@ namespace craft\helpers; use Craft; +use craft\base\ElementContainerFieldInterface; use craft\base\ElementInterface; use craft\errors\GqlException; use craft\gql\base\Directive; @@ -582,6 +583,14 @@ public static function getSchemaContainedEntryTypes(?GqlSchema $schema = null): } } + foreach (static::getSchemaContainedNestedEntryFields($schema) as $field) { + foreach ($field->getFieldLayoutProviders() as $provider) { + if ($provider instanceof EntryType) { + $entryTypes[$provider->uid] = $provider; + } + } + } + return array_values($entryTypes); } @@ -599,6 +608,24 @@ public static function getSchemaContainedSections(?GqlSchema $schema = null): ar ); } + /** + * Returns all nested entry fields a given (or loaded) schema contains. + * + * @return ElementContainerFieldInterface[] + * @since 5.0.0 + */ + public static function getSchemaContainedNestedEntryFields(?GqlSchema $schema = null): array + { + $fieldsService = Craft::$app->getFields(); + /** @var ElementContainerFieldInterface[] $fields */ + $fields = array_merge(...array_map( + fn(string $type) => $fieldsService->getFieldsByType($type), + $fieldsService->getNestedEntryFieldTypes() + )); + return array_filter($fields, fn(ElementContainerFieldInterface $field) => + static::isSchemaAwareOf("nestedentryfields.$field->uid", $schema)); + } + /** * @param GqlSchema|null $schema * @return GqlSchema diff --git a/src/services/Entries.php b/src/services/Entries.php index 2fbdd01938e..40a69f2acbc 100644 --- a/src/services/Entries.php +++ b/src/services/Entries.php @@ -1714,8 +1714,11 @@ protected function getEntryTypeUsages(): array } // Fields - foreach (Craft::$app->getFields()->getAllFields() as $field) { - if ($field instanceof ElementContainerFieldInterface) { + $fieldsService = Craft::$app->getFields(); + foreach ($fieldsService->getNestedEntryFieldTypes() as $type) { + /** @var ElementContainerFieldInterface[] $fields */ + $fields = $fieldsService->getFieldsByType($type); + foreach ($fields as $field) { foreach ($field->getFieldLayoutProviders() as $provider) { if ($provider instanceof EntryType) { $entryTypeUsages[$provider->id][] = [ diff --git a/src/services/Fields.php b/src/services/Fields.php index eda6404001a..ec89c494603 100644 --- a/src/services/Fields.php +++ b/src/services/Fields.php @@ -8,6 +8,7 @@ namespace craft\services; use Craft; +use craft\base\ElementContainerFieldInterface; use craft\base\ElementInterface; use craft\base\Field; use craft\base\FieldInterface; @@ -98,6 +99,15 @@ class Fields extends Component */ public const EVENT_REGISTER_FIELD_TYPES = 'registerFieldTypes'; + /** + * @event RegisterComponentTypesEvent The event that is triggered when registering field types which manage nested entries. + * + * These field types must implement [[ElementContainerFieldInterface]]. + * + * @since 5.0.0 + */ + public const EVENT_REGISTER_NESTED_ENTRY_FIELD_TYPES = 'registerNestedEntryFieldTypes'; + /** * @event DefineCompatibleFieldTypesEvent The event that is triggered when defining the compatible field types for a field. * @see getCompatibleFieldTypes() @@ -298,6 +308,26 @@ public function getCompatibleFieldTypes(FieldInterface $field, bool $includeCurr return $types; } + /** + * Returns all field types which manage nested entries. + * + * @return string[] The field type classes which manage nested entries + * @phpstan-return class-string[] + */ + public function getNestedEntryFieldTypes(): array + { + $fieldTypes = [ + MatrixField::class, + ]; + + $event = new RegisterComponentTypesEvent([ + 'types' => $fieldTypes, + ]); + $this->trigger(self::EVENT_REGISTER_NESTED_ENTRY_FIELD_TYPES, $event); + + return $event->types; + } + /** * Creates a field with a given config. * diff --git a/src/services/Gql.php b/src/services/Gql.php index f6088f39169..d2fc2e35042 100644 --- a/src/services/Gql.php +++ b/src/services/Gql.php @@ -8,6 +8,7 @@ namespace craft\services; use Craft; +use craft\base\ElementContainerFieldInterface; use craft\base\ElementInterface as BaseElementInterface; use craft\base\FieldInterface; use craft\base\GqlInlineFragmentFieldInterface; @@ -1551,6 +1552,56 @@ private function entrySchemaComponents(): array ]; } + // Add components for fields that manage nested entries + $fieldsService = Craft::$app->getFields(); + /** @var ElementContainerFieldInterface[] $fields */ + $fields = array_merge(...array_map( + fn(string $type) => $fieldsService->getFieldsByType($type), + $fieldsService->getNestedEntryFieldTypes(), + )); + usort($fields, fn(ElementContainerFieldInterface $a, ElementContainerFieldInterface $b) => + $a::displayName() <=> $b::displayName()); + + foreach ($fields as $field) { + $name = Craft::t('site', $field->name); + $type = $field::displayName(); + $prefix = "nestedentryfields.$field->uid"; + + $queryComponents["$prefix:read"] = [ + 'label' => Craft::t('app', 'Query for entries in the “{name}” {type} field', [ + 'name' => $name, + 'type' => $type, + ]), + ]; + + $mutationComponents["$prefix:edit"] = [ + 'label' => Craft::t('app', 'Edit entries in the “{name}” {type} field', [ + 'name' => $name, + 'type' => $type, + ]), + 'nested' => [ + "$prefix:create" => [ + 'label' => Craft::t('app', 'Create entries in the “{section}” {type} field', [ + 'name' => $name, + 'type' => $type, + ]), + ], + "$prefix:save" => [ + 'label' => Craft::t('app', 'Save entries in the “{section}” {type} field', [ + 'name' => $name, + 'type' => $type, + ]), + ], + "$prefix:delete" => [ + 'label' => Craft::t('app', 'Delete entries in the “{section}” {type} field', [ + 'name' => $name, + 'type' => $type, + ]), + ], + ], + ]; + } + return [$queryComponents, $mutationComponents]; } diff --git a/src/translations/en/app.php b/src/translations/en/app.php index be591e93f6e..c644b24e556 100644 --- a/src/translations/en/app.php +++ b/src/translations/en/app.php @@ -444,6 +444,7 @@ 'Create and continue editing' => 'Create and continue editing', 'Create assets in the “{volume}” volume' => 'Create assets in the “{volume}” volume', 'Create entries in the “{section}” section' => 'Create entries in the “{section}” section', + 'Create entries in the “{section}” {type} field' => 'Create entries in the “{section}” {type} field', 'Create subfolders' => 'Create subfolders', 'Create your account' => 'Create your account', 'Create {type}' => 'Create {type}', @@ -500,6 +501,7 @@ 'Delete categories from the “{categoryGroup}” category group' => 'Delete categories from the “{categoryGroup}” category group', 'Delete custom source' => 'Delete custom source', 'Delete entries in the “{section}” section' => 'Delete entries in the “{section}” section', + 'Delete entries in the “{section}” {type} field' => 'Delete entries in the “{section}” {type} field', 'Delete folder' => 'Delete folder', 'Delete for site' => 'Delete for site', 'Delete heading' => 'Delete heading', @@ -1210,6 +1212,7 @@ 'Query for element revisions' => 'Query for element revisions', 'Query for elements in the “{site}” site' => 'Query for elements in the “{site}” site', 'Query for entries in the “{name}” section' => 'Query for entries in the “{name}” section', + 'Query for entries in the “{name}” {type} field' => 'Query for entries in the “{name}” {type} field', 'Query for non-enabled elements' => 'Query for non-enabled elements', 'Query for tags in the “{name}” tag group' => 'Query for tags in the “{name}” tag group', 'Query for the “{name}” entry' => 'Query for the “{name}” entry', @@ -1331,6 +1334,7 @@ 'Save assets uploaded by other users' => 'Save assets uploaded by other users', 'Save categories in the “{categoryGroup}” category group' => 'Save categories in the “{categoryGroup}” category group', 'Save entries in the “{section}” section' => 'Save entries in the “{section}” section', + 'Save entries in the “{section}” {type} field' => 'Save entries in the “{section}” {type} field', 'Save entries to all sites enabled for this section' => 'Save entries to all sites enabled for this section', 'Save entries to all sites the owner element is saved in' => 'Save entries to all sites the owner element is saved in', 'Save entries to other sites in the same site group' => 'Save entries to other sites in the same site group', From 476ac5690b149d156d7604340979b609535336b3 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 12 Feb 2024 23:00:13 -0800 Subject: [PATCH 2/6] Release notes [ci skip] --- CHANGELOG-WIP.md | 4 ++++ CHANGELOG.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 47c1a0bb9bd..bd6f45a76d6 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -112,6 +112,8 @@ - User queries now have an `authorOf` param. - Nested addresses are now cached by their field ID, and address queries now register cache tags based on their `field` and `fieldId` params. - Nested entries are now cached by their field ID, and entry queries now register cache tags based on their `field` and `fieldId` params. +- GraphQL schemas can now include queries and mutations for nested entries (e.g. within Matrix or CKEditor fields) directly. ([#14366](https://github.com/craftcms/cms/pull/14366)) +- Added the `fieldId`, `fieldHandle`, `ownerId`, and `sortOrder` entry GraphQL fields. ([#14366](https://github.com/craftcms/cms/pull/14366)) - Entries’ GraphQL type names are now formatted as `_Entry`, and are no longer prefixed with their section’s handle. (That goes for Matrix-nested entries as well.) - Entries now have `author` and `authorIds` GraphQL field. - Matrix fields’ GraphQL mutation types now expect nested entries to be defined by an `entries` field rather than `blocks`. @@ -359,7 +361,9 @@ - Added `craft\services\Entries::refreshEntryTypes()`. - Added `craft\services\Entries::saveSection()`. - Added `craft\services\Fields::$fieldContext`, which replaces `craft\services\Content::$fieldContext`. +- Added `craft\services\Fields::EVENT_REGISTER_NESTED_ENTRY_FIELD_TYPES`. - Added `craft\services\Fields::getAllLayouts()`. +- Added `craft\services\Fields::getNestedEntryFieldTypes()`. - Added `craft\services\Gql::defineContentArgumentsForFieldLayouts()`. - Added `craft\services\Gql::defineContentArgumentsForFields()`. - Added `craft\services\Gql::getOrSetContentArguments()`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ec1e524a2..cd11e60eea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,12 @@ - Restored the “Preview” and “Edit Image” buttons on asset image previews. ([#14333](https://github.com/craftcms/cms/discussions/14333)) - Improved the accessibility of the global sidebar. ([#14335](https://github.com/craftcms/cms/pull/14335)) - Improved the contrast of the Dev Mode caution tape indicator. ([#14336](https://github.com/craftcms/cms/pull/14336)) +- GraphQL schemas can now include queries and mutations for nested entries (e.g. within Matrix or CKEditor fields) directly. ([#14366](https://github.com/craftcms/cms/pull/14366)) +- Added the `fieldId`, `fieldHandle`, `ownerId`, and `sortOrder` entry GraphQL fields. ([#14366](https://github.com/craftcms/cms/pull/14366)) - Nested addresses are now cached by their field ID, and address queries now register cache tags based on their `field` and `fieldId` params. - Nested entries are now cached by their field ID, and entry queries now register cache tags based on their `field` and `fieldId` params. +- Added `craft\services\Fields::EVENT_REGISTER_NESTED_ENTRY_FIELD_TYPES`. +- Added `craft\services\Fields::getNestedEntryFieldTypes()`. - `craft\behaviors\SessionBehavior::setSuccess()` and `getSuccess()` use the `success` flash key now, rather than `notice`. ([#14345](https://github.com/craftcms/cms/pull/14345)) - Exception response data no longer includes an `error` key with the exception message. `message` should be used instead. ([#14346](https://github.com/craftcms/cms/pull/14346)) - Fixed an error that occurred when adding a new block to a Matrix field with an overridden handle. ([#14350](https://github.com/craftcms/cms/issues/14350)) From 6478f8df4915f5869ab693e397c08b1feac0d845 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 12 Feb 2024 23:40:18 -0800 Subject: [PATCH 3/6] Fixed test --- tests/unit/gql/PrepareQueryTest.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/unit/gql/PrepareQueryTest.php b/tests/unit/gql/PrepareQueryTest.php index 6eb750e5690..f14213c67f6 100644 --- a/tests/unit/gql/PrepareQueryTest.php +++ b/tests/unit/gql/PrepareQueryTest.php @@ -9,6 +9,7 @@ use Craft; use craft\db\Table; +use craft\enums\PropagationMethod; use craft\gql\resolvers\elements\Asset as AssetResolver; use craft\gql\resolvers\elements\Category as CategoryResolver; use craft\gql\resolvers\elements\Entry as EntryResolver; @@ -27,6 +28,7 @@ use craft\records\TagGroup; use craft\records\UserGroup; use craft\records\Volume; +use craft\services\Entries; use craft\test\TestCase; use UnitTester; @@ -93,6 +95,8 @@ protected function _after(): void $this->_globalSet->delete(); $this->_tagGroup->delete(); $this->_userGroup->delete(); + + Craft::$app->set('entries', new Entries()); } public const VOLUME_UID = 'volume-uid'; @@ -184,7 +188,8 @@ public function relationalFieldQueryPreparationProvider(): array ], [ EntryResolver::class, [null, []], function($result) { - return $result->where === ['in', 'entries.sectionId', []]; + $section = Craft::$app->getEntries()->getSectionByUid(self::SECTION_UID); + return $result->where === ['or', ['in', 'entries.sectionId', [$section->id]]]; }, ], @@ -307,9 +312,10 @@ private function _setupEntries() 'handle' => StringHelper::randomString(), 'type' => 'channel', 'enableVersioning' => true, - 'propagationMethod' => StringHelper::randomString(), + 'propagationMethod' => PropagationMethod::All->value, ]); $this->_section->save(); + Craft::$app->set('entries', new Entries()); Db::insert(Table::SECTIONS_ENTRYTYPES, [ 'sectionId' => $this->_section->id, From 74e18c54f0912982eb817d9adfd26097714aa7e2 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 12 Feb 2024 23:54:15 -0800 Subject: [PATCH 4/6] In case it's not there --- tests/unit/gql/PrepareQueryTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/gql/PrepareQueryTest.php b/tests/unit/gql/PrepareQueryTest.php index f14213c67f6..052d2a136d5 100644 --- a/tests/unit/gql/PrepareQueryTest.php +++ b/tests/unit/gql/PrepareQueryTest.php @@ -189,7 +189,7 @@ public function relationalFieldQueryPreparationProvider(): array [ EntryResolver::class, [null, []], function($result) { $section = Craft::$app->getEntries()->getSectionByUid(self::SECTION_UID); - return $result->where === ['or', ['in', 'entries.sectionId', [$section->id]]]; + return $result->where === ['or', ['in', 'entries.sectionId', array_filter([$section?->id])]]; }, ], From 449ecea226ce963ea679752488b30addf4bdee5d Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 13 Feb 2024 05:53:24 -0800 Subject: [PATCH 5/6] Make sure there are conditions, or return an empty result set --- src/gql/resolvers/elements/Entry.php | 12 +++++------- tests/unit/gql/PrepareQueryTest.php | 7 ++++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/gql/resolvers/elements/Entry.php b/src/gql/resolvers/elements/Entry.php index da245a7131b..aefc51518aa 100644 --- a/src/gql/resolvers/elements/Entry.php +++ b/src/gql/resolvers/elements/Entry.php @@ -32,12 +32,7 @@ public static function prepareQuery(mixed $source, array $arguments, ?string $fi if ($source === null) { $query = EntryElement::find(); $pairs = GqlHelper::extractAllowedEntitiesFromSchema('read'); - - if (!isset($pairs['sections']) && !isset($pairs['nestedentryfields'])) { - return ElementCollection::empty(); - } - - $condition = ['or']; + $condition = []; if (isset($pairs['sections'])) { $entriesService = Craft::$app->getEntries(); @@ -62,8 +57,11 @@ public static function prepareQuery(mixed $source, array $arguments, ?string $fi } } - $query->andWhere($condition); + if (empty($condition)) { + return ElementCollection::empty(); + } + $query->andWhere(['or', ...$condition]); // If not, get the prepared element query } else { $query = $source->$fieldName; diff --git a/tests/unit/gql/PrepareQueryTest.php b/tests/unit/gql/PrepareQueryTest.php index 052d2a136d5..c16eed6ef15 100644 --- a/tests/unit/gql/PrepareQueryTest.php +++ b/tests/unit/gql/PrepareQueryTest.php @@ -9,6 +9,7 @@ use Craft; use craft\db\Table; +use craft\elements\ElementCollection; use craft\enums\PropagationMethod; use craft\gql\resolvers\elements\Asset as AssetResolver; use craft\gql\resolvers\elements\Category as CategoryResolver; @@ -189,7 +190,11 @@ public function relationalFieldQueryPreparationProvider(): array [ EntryResolver::class, [null, []], function($result) { $section = Craft::$app->getEntries()->getSectionByUid(self::SECTION_UID); - return $result->where === ['or', ['in', 'entries.sectionId', array_filter([$section?->id])]]; + // todo: figure out why $section is null during CI + if (!$section) { + return $result instanceof ElementCollection && $result->isEmpty(); + } + return $result->where === ['or', ['in', 'entries.sectionId', [$section->id]]]; }, ], From 9d1f60bf397fa32fbf5bf7643c350ea3fb8a5c81 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 13 Feb 2024 10:25:22 -0800 Subject: [PATCH 6/6] Fixed tests for Postgres --- tests/unit/gql/PrepareQueryTest.php | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/tests/unit/gql/PrepareQueryTest.php b/tests/unit/gql/PrepareQueryTest.php index c16eed6ef15..e875b4147d2 100644 --- a/tests/unit/gql/PrepareQueryTest.php +++ b/tests/unit/gql/PrepareQueryTest.php @@ -9,7 +9,6 @@ use Craft; use craft\db\Table; -use craft\elements\ElementCollection; use craft\enums\PropagationMethod; use craft\gql\resolvers\elements\Asset as AssetResolver; use craft\gql\resolvers\elements\Category as CategoryResolver; @@ -100,13 +99,13 @@ protected function _after(): void Craft::$app->set('entries', new Entries()); } - public const VOLUME_UID = 'volume-uid'; - public const CATEGORY_GROUP_UID = 'categoryGroup-uid'; - public const SECTION_UID = 'section-uid'; - public const ENTRY_TYPE_UID = 'entryType-uid'; - public const GLOBAL_SET_UID = 'globalSet-uid'; - public const TAG_GROUP_UID = 'tagGroup-uid'; - public const USER_GROUP_UID = 'userGroup-uid'; + public const VOLUME_UID = 'volume-uid--------------------------'; + public const CATEGORY_GROUP_UID = 'categoryGroup-uid-------------------'; + public const SECTION_UID = 'section-uid-------------------------'; + public const ENTRY_TYPE_UID = 'entryType-uid-----------------------'; + public const GLOBAL_SET_UID = 'globalSet-uid-----------------------'; + public const TAG_GROUP_UID = 'tagGroup-uid------------------------'; + public const USER_GROUP_UID = 'userGroup-uid-----------------------'; /** * Test relational field query preparation @@ -190,10 +189,6 @@ public function relationalFieldQueryPreparationProvider(): array [ EntryResolver::class, [null, []], function($result) { $section = Craft::$app->getEntries()->getSectionByUid(self::SECTION_UID); - // todo: figure out why $section is null during CI - if (!$section) { - return $result instanceof ElementCollection && $result->isEmpty(); - } return $result->where === ['or', ['in', 'entries.sectionId', [$section->id]]]; }, ],