From c70865acd5210b00f4ef5fee2c06d78f22b12703 Mon Sep 17 00:00:00 2001 From: astanley Date: Fri, 24 Apr 2026 11:57:27 -0300 Subject: [PATCH] made columns sortable --- src/Controller/MetadataProfileController.php | 463 +++++++++++++++++-- 1 file changed, 428 insertions(+), 35 deletions(-) diff --git a/src/Controller/MetadataProfileController.php b/src/Controller/MetadataProfileController.php index e38b740..db0abb1 100644 --- a/src/Controller/MetadataProfileController.php +++ b/src/Controller/MetadataProfileController.php @@ -23,7 +23,14 @@ use Drupal\node\NodeTypeInterface; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\ResponseHeaderBag; - +/** + * Controller for displaying and exporting bundle metadata profiles. + * + * The controller inspects the current bundle's field definitions, builds a + * summary of field configuration, Search API usage, facet usage, form-display + * weight, and field usage counts, then renders the results as a Drupal page or + * downloadable CSV. + */ class MetadataProfileController extends ControllerBase { use MessengerTrait; @@ -112,6 +119,26 @@ class MetadataProfileController extends ControllerBase { */ protected array $usageCounts = []; + /** + * Constructs a MetadataProfileController object. + * + * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager + * The entity field manager service. + * @param \Drupal\Core\Field\FieldTypePluginManagerInterface $field_type_plugin_manager + * The field type plugin manager service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory service. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The current route match service. + * @param \Drupal\Core\File\FileSystemInterface $file_system + * The file system service. + * @param \Drupal\system\FileDownloadController $file_download_controller + * The file download controller. + * @param \Drupal\Core\Database\Connection $database + * The active database connection. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager service. + */ public function __construct( EntityFieldManagerInterface $entity_field_manager, FieldTypePluginManagerInterface $field_type_plugin_manager, @@ -138,6 +165,9 @@ class MetadataProfileController extends ControllerBase { $this->entityTypeManagerService = $entity_type_manager; } + /** + * {@inheritdoc} + */ public static function create(ContainerInterface $container) { return new static( $container->get('entity_field.manager'), @@ -152,9 +182,11 @@ class MetadataProfileController extends ControllerBase { } /** - * Returns a page (render array) displaying the metadata profile. + * Displays the metadata profile page for the current bundle. * * @return array + * A render array containing bundle details, a download link, a sortable + * summary table, and detailed per-field sections. */ public function profile() { // Get core content type information. @@ -194,7 +226,13 @@ class MetadataProfileController extends ControllerBase { } /** - * Format basic information about a bundle. + * Builds the render array for basic bundle information. + * + * @param \Drupal\Core\Config\Config $bundle + * The bundle configuration object. + * + * @return array + * A render array containing the bundle label, machine name, and description. */ protected function formatBundle(Config $bundle) { if (str_starts_with($bundle->getName(), 'node.type')) { @@ -427,12 +465,17 @@ class MetadataProfileController extends ControllerBase { * @return array * Render array for a Drupal table. */ - protected function buildSummaryTable($metadata_profile) { + protected function buildSummaryTable($metadata_profile): array { + $header = $this->getHeaders(); $rows = $this->getRows($metadata_profile, TRUE); + + $this->sortRows($rows, $header); + return [ '#type' => 'table', - '#header' => $this->getHeaders(), + '#header' => $header, '#rows' => $rows, + '#empty' => $this->t('No fields found.'), ]; } @@ -463,14 +506,19 @@ class MetadataProfileController extends ControllerBase { * @return array * A numerically indexed array of row arrays. */ - protected function getRows($metadata_profile, $display = NULL) { + protected function getRows($metadata_profile, $display = NULL): array { $rows = []; foreach ($metadata_profile as $field_name => $field_profile) { - if (str_starts_with($field_name, 'field_') or in_array($field_name, [ + if (str_starts_with($field_name, 'field_') || in_array($field_name, [ 'title', 'name', - ])) { + ], TRUE)) { $bundles = $field_profile['target_bundles'] ?? []; + $search_api = $field_profile['search_api'] ?: [ + 'in_search_api' => FALSE, + 'has_facet' => FALSE, + ]; + $rows[] = [ $display ? $field_profile['details_link'] : $field_profile['label'], $field_profile['machine_name'], @@ -479,9 +527,8 @@ class MetadataProfileController extends ControllerBase { $field_profile['required'], $field_profile['repeatable'], $field_profile['auto_create'], - - $this->yesNoIcon($field_profile['search_api']['in_search_api'],$this->t('In search api')), - $this->YesNoIcon($field_profile['search_api']['has_facet'], $this->t('Faceted')), + $this->yesNoIcon($search_api['in_search_api'], $this->t('In search api')), + $this->yesNoIcon($search_api['has_facet'], $this->t('Faceted')), $display ? $this->formatListForTable($bundles) : $bundles, @@ -490,31 +537,184 @@ class MetadataProfileController extends ControllerBase { ]; } } - usort($rows, function($a, $b) { - return $b[11] <=> $a[11]; - }); + return $rows; } - protected function getHeaders() { + /** + * Gets the summary table header definitions. + * + * @return array + * Header definitions for the sortable summary table. + */ + protected function getHeaders(): array { return [ - $this->t('Field'), - $this->t('Machine name'), - $this->t('Description'), - $this->t('Type'), - $this->t('Required'), - $this->t('Repeatable'), - $this->t('Create new'), - $this->t('In Search API'), - $this->t('Has Facet'), - $this->t('Target bundles'), - $this->t('Weight'), - $this->t("Usage count"), + [ + 'data' => $this->t('Field'), + 'field' => 'field', + ], + [ + 'data' => $this->t('Machine name'), + 'field' => 'machine_name', + ], + [ + 'data' => $this->t('Description'), + 'field' => 'description', + ], + [ + 'data' => $this->t('Type'), + 'field' => 'type', + ], + [ + 'data' => $this->t('Required'), + 'field' => 'required', + ], + [ + 'data' => $this->t('Repeatable'), + 'field' => 'repeatable', + ], + [ + 'data' => $this->t('Create new'), + 'field' => 'auto_create', + ], + [ + 'data' => $this->t('In Search API'), + 'field' => 'in_search_api', + ], + [ + 'data' => $this->t('Has Facet'), + 'field' => 'has_facet', + ], + [ + 'data' => $this->t('Target bundles'), + 'field' => 'target_bundles', + ], + [ + 'data' => $this->t('Weight'), + 'field' => 'weight', + ], + [ + 'data' => $this->t('Usage count'), + 'field' => 'usage_count', + 'sort' => 'desc', + ], // TODO: add more columns ]; } + /** + * Sorts summary table rows using Drupal table sort query parameters. + * + * If no table column has been selected, rows are sorted by usage count in + * descending order. + * + * @param array $rows + * The rows to sort, passed by reference. + * @param array $header + * The table header definition used to map labels to row indexes. + * + * @return void + */ + protected function sortRows(array &$rows, array $header): void { + $request = \Drupal::request(); + + $order = $request->query->get('order'); + $sort = strtolower((string) $request->query->get('sort', 'desc')); + + // Default sort if no column has been clicked yet. + if (!$order) { + usort($rows, function ($a, $b) { + return ($b[11] ?? 0) <=> ($a[11] ?? 0); + }); + return; + } + + $column_index = NULL; + + foreach ($header as $index => $column) { + if ((string) $column['data'] === $order) { + $column_index = $index; + break; + } + } + + if ($column_index === NULL) { + return; + } + + usort($rows, function ($a, $b) use ($column_index, $sort) { + $a_value = $this->extractSortableValue($a[$column_index] ?? ''); + $b_value = $this->extractSortableValue($b[$column_index] ?? ''); + + if (is_numeric($a_value) && is_numeric($b_value)) { + $result = $a_value <=> $b_value; + } + else { + $result = strnatcasecmp((string) $a_value, (string) $b_value); + } + + return $sort === 'desc' ? -$result : $result; + }); + } + + /** + * Extracts value from a renderable table cell for sorting. + * + * @param mixed $cell + * A scalar value, Link object, or render array from a table cell. + * + * @return string|int|float + * The sortable scalar value. + */ + protected function extractSortableValue($cell): string|int|float { + if (is_scalar($cell) || $cell === NULL) { + return $cell ?? ''; + } + + if ($cell instanceof Link) { + return (string) $cell->getText(); + } + + if (is_array($cell)) { + if (isset($cell['data']) && is_scalar($cell['data'])) { + return $cell['data']; + } + + if (isset($cell['#plain_text'])) { + return $cell['#plain_text']; + } + + if (isset($cell['#markup'])) { + return trim(strip_tags((string) $cell['#markup'])); + } + + if (isset($cell['data']['#markup'])) { + return trim(strip_tags((string) $cell['data']['#markup'])); + } + + if (isset($cell['data']['#plain_text'])) { + return $cell['data']['#plain_text']; + } + + if (isset($cell['data']['#items']) && is_array($cell['data']['#items'])) { + return implode(', ', array_map('strval', $cell['data']['#items'])); + } + } + + return ''; + } + + /** + * Builds the detailed render array for a single field profile. + * + * @param array $field_profile + * A field profile array produced by ::getMetadataProfile(). + * + * @return array + * A render array for the field details section, or an empty array if the + * field should not be displayed. + */ protected function buildField(array $field_profile) { $render_array = []; $field_name = $field_profile['machine_name']; @@ -568,6 +768,15 @@ class MetadataProfileController extends ControllerBase { return $render_array; } + /** + * Gets the edit URL for a configurable field or base field override. + * + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The field definition. + * + * @return \Drupal\Core\Url|null + * The edit URL, or NULL when no editable route is available. + */ protected function getFieldEditUrl(FieldDefinitionInterface $field_definition) { $redirect_url = Url::fromRoute('', ['fragment' => $field_definition->getName()]); if ($field_definition->getFieldStorageDefinition() instanceof BaseFieldDefinition) { @@ -597,11 +806,31 @@ class MetadataProfileController extends ControllerBase { return $edit_url; } + /** + * Builds a link to the field's details section on the current page. + * + * @param string $field_name + * The field machine name, used as the fragment identifier. + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The field definition. + * + * @return \Drupal\Core\Link + * A link to the field details anchor. + */ private function getFieldDetailsFragmentLink(string $field_name, FieldDefinitionInterface $field_definition) { $url = Url::fromRoute('', [], ['fragment' => $field_name]); return Link::fromTextAndUrl($field_definition->getLabel(), $url); } + /** + * Formats a field edit URL as a Drupal link render array. + * + * @param \Drupal\Core\Url $edit_url + * The field edit URL. + * + * @return array + * A render array for the edit link. + */ protected function formatFieldEditLink(Url $edit_url) { return [ '#type' => 'link', @@ -611,6 +840,16 @@ class MetadataProfileController extends ControllerBase { ]; } + /** + * Builds the configuration rows for a field details table. + * + * @param array $field_profile + * A field profile array produced by ::getMetadataProfile(). + * + * @return array + * A table rows array containing type, required, repeatable, auto-create, + * and target-bundle information where applicable. + */ protected function getFieldTableRows(array $field_profile) { $rows = [ [$this->t('Type:'), $field_profile['type']], @@ -630,6 +869,18 @@ class MetadataProfileController extends ControllerBase { return $rows; } + /** + * Formats the field type label for display. + * + * Entity reference fields include their target entity type in the display + * label. + * + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The field definition. + * + * @return string + * The human-readable field type label. + */ protected function formatType(FieldDefinitionInterface $field_definition) { $type = $field_definition->getType(); $type_label = $this->fieldTypePluginManager->getDefinition($type)['label']; @@ -639,14 +890,33 @@ class MetadataProfileController extends ControllerBase { return $type_label; } + /** + * Formats the field required setting as a yes/no icon. + * + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The field definition. + * + * @return array + * A render array containing the required indicator. + */ protected function formatRequired(FieldDefinitionInterface $field_definition) { $cardinality = $field_definition->isRequired(); return $this->yesNoIcon($cardinality, $this->t('Required')); } /** - * This does not work - most fields dont have a form weight specified in - * getDisplayOptions. + * Gets a field's effective form-display weight. + * + * Nested fields inherit their parent group weight, which is combined with the + * local field weight to preserve ordering within groups. + * + * @param string $field_name + * The field machine name. + * @param array $form + * The rendered entity form array. + * + * @return int|float + * The effective field weight, or 9999 when the field is not present. */ protected function getWeight(string $field_name, array $form) { if (isset($form[$field_name])) { @@ -664,6 +934,15 @@ class MetadataProfileController extends ControllerBase { } } + /** + * Formats a field's cardinality as a repeatable indicator. + * + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The field definition. + * + * @return array|\Drupal\Core\StringTranslation\TranslatableMarkup + * A yes/no icon render array, or translated text for limited cardinality. + */ protected function formatCardinality(FieldDefinitionInterface $field_definition) { $cardinality = $field_definition ->getFieldStorageDefinition() @@ -682,6 +961,16 @@ class MetadataProfileController extends ControllerBase { ]); } + /** + * Formats whether referenced entities can be auto-created on submission. + * + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The field definition. + * + * @return array|null + * A yes/no icon render array for supported reference fields, or NULL for + * non-reference fields. + */ protected function formatCreateNew(FieldDefinitionInterface $field_definition) { if (!in_array($field_definition->getType(), [ 'entity_reference', @@ -696,6 +985,15 @@ class MetadataProfileController extends ControllerBase { return $this->yesNoIcon($create_new, $this->t("Creatable")); } + /** + * Formats target bundle restrictions for an entity reference field. + * + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The field definition. + * + * @return array + * A list of allowed target bundles, labelled where supported. + */ protected function formatTargetBundles(FieldDefinitionInterface $field_definition) { $handler = $field_definition->getSetting('handler'); $setting = $field_definition->getSetting('handler_settings'); @@ -729,6 +1027,15 @@ class MetadataProfileController extends ControllerBase { ]; } + /** + * Formats a list as an item-list render array inside a table cell. + * + * @param array $list + * The list items. + * + * @return array + * A table cell render array containing an unordered item list. + */ protected function formatListForTable(array $list) { return [ 'data' => [ @@ -739,6 +1046,16 @@ class MetadataProfileController extends ControllerBase { ]; } + /** + * Builds the Search API table for a field profile. + * + * @param array $field_profile + * A field profile array produced by ::getMetadataProfile(). + * + * @return array + * A render array for the Search API table, or an empty array when the field + * is not indexed. + */ protected function formatSearchApi($field_profile) { if (!$field_profile['search_api']['in_search_api']) { return []; @@ -769,6 +1086,20 @@ class MetadataProfileController extends ControllerBase { } } + /** + * Builds Search API metadata for one indexed field setting. + * + * @param string $field_setting_name + * The Search API field machine name. + * @param array $field_setting + * The Search API field setting array from index configuration. + * @param \Drupal\Core\Config\ImmutableConfig $index_config + * The Search API index configuration object. + * + * @return array + * Structured Search API field metadata, including included fields and facet + * information where available. + */ protected function getSearchApiField($field_setting_name, $field_setting, $index_config) { $search_api_field_array = [ 'search_api_field_name' => $field_setting_name, @@ -819,10 +1150,28 @@ class MetadataProfileController extends ControllerBase { return $search_api_field_array; } + /** + * Determines whether a facet block is placed. + * + * @param mixed $facet + * The facet entity. + * + * @return string + * Placement status text. Currently returns an empty string. + */ protected function getBlockVisible($facet) { return ''; } + /** + * Formats one Search API field profile as a table row. + * + * @param array $search_api_field_profile + * Structured Search API field metadata. + * + * @return array + * A table row render array. + */ protected function formatSearchApiField($search_api_field_profile) { return [ 'data' => [ @@ -875,15 +1224,14 @@ class MetadataProfileController extends ControllerBase { /** * Generates and returns a CSV download of the metadata profile. * - * Writes a temporary CSV file and serves it using Drupal's - * file download controller. + * Writes a temporary CSV file and serves it as an attachment. + * + * @param \Drupal\node\NodeTypeInterface $node_type + * The node type whose metadata profile should be exported. * * @return \Symfony\Component\HttpFoundation\BinaryFileResponse * The file download response. */ - - - public function download(NodeTypeInterface $node_type) { // Rebuild your state from the entity $this->entityBundle = $node_type->id(); @@ -892,7 +1240,9 @@ class MetadataProfileController extends ControllerBase { $contentType = $this->entityBundle; $entityKey = $this->entityTypeBundleOf; - $headers = $this->getHeaders(); + $headers = array_map(function ($header) { + return is_array($header) && isset($header['data']) ? (string) $header['data'] : (string) $header; + }, $this->getHeaders()); $metadata_profile = $this->getMetadataProfile(); $rows = $this->getRows($metadata_profile); $rows = $this->sanitizeRowsForCSV($rows); @@ -920,6 +1270,16 @@ class MetadataProfileController extends ControllerBase { return $response; } + /** + * Builds the facets table for a field profile. + * + * @param array $field_profile + * A field profile array produced by ::getMetadataProfile(). + * + * @return array + * A render array for the facets table, or an empty array when no facets are + * present. + */ private function formatFacets(array $field_profile) { if (!$field_profile['search_api']['has_facet']) { return []; @@ -952,6 +1312,15 @@ class MetadataProfileController extends ControllerBase { } } + /** + * Formats one facet definition as a table row. + * + * @param array $facet + * A structured facet metadata array. + * + * @return array + * A table row render array. + */ private function formatFacet($facet) { return [ 'data' => [ @@ -965,6 +1334,17 @@ class MetadataProfileController extends ControllerBase { ]; } + /** + * Adds form-display weights to field profiles and sorts them by weight. + * + * Weight extraction is currently implemented for node bundles. + * + * @param array $metadata_profile + * Metadata profile array keyed by field machine name. + * + * @return array + * The metadata profile array with weight values added where available. + */ private function addWeights(array $metadata_profile) { if ($this->entityTypeBundleOf == 'node') { $node = \Drupal\node\Entity\Node::create(['type' => $this->entityBundle]); @@ -980,6 +1360,17 @@ class MetadataProfileController extends ControllerBase { return $metadata_profile; } + /** + * Combines parent and local form weights into a sortable decimal weight. + * + * @param int|float $parent_weight + * The parent element weight. + * @param int|float $local_weight + * The local element weight. + * + * @return float + * The combined sortable weight. + */ private function combineWeights($parent_weight, $local_weight) { $parent_int = (string) floor($parent_weight); $parent_fraction = (string) ($parent_weight - $parent_int); @@ -1066,6 +1457,8 @@ class MetadataProfileController extends ControllerBase { * * @param bool $value * TRUE for yes, FALSE for no. + * @param string $tooltip + * Text used in the icon title attribute. * * @return array * Render array containing markup for the icon.