diff --git a/docs/development.md b/docs/development.md index eb6603e7..d7a03783 100644 --- a/docs/development.md +++ b/docs/development.md @@ -68,6 +68,14 @@ The one exception is installing or uninstalling a module: that rebuilds the cont Deploy hooks (`hook_deploy_NAME()`) run after `cim` and need no such bootstrap. They also see configuration the same deployment just imported, so a deploy hook never needs to import configuration itself. +## Images a deploy hook needs + +A hook that builds a page cannot pull an image out of the media library when nothing has put one there yet. Ship the file with the module that creates the page, under `web/modules/custom//assets/`, and let the hook copy it into the public files directory and wrap it in a media entity. + +Give that media entity a fixed UUID in the hook. It is the UUID, not a filename check, that makes a second run a no-op, and it keeps one identity for the image across every environment. + +Treat a missing file as a reason to skip the image rather than to fail: a banner without its background is a far smaller loss than a deployment that stops. Export at the size the page actually uses, and prefer JPEG for photographic renders - a 2K PNG out of an image generator is several megabytes and stays in the repository for good. + ## Running ad-hoc code Never pass code inline through `drush php:eval`, stdin, or a heredoc. Write it to a file under `.artifacts/` and run `ahoy drush php:script `. Committed, vetted scripts may use `php:eval` for static, non-dynamic operations. diff --git a/tests/behat/features/our_work_page.feature b/tests/behat/features/our_work_page.feature new file mode 100644 index 00000000..0bbdca59 --- /dev/null +++ b/tests/behat/features/our_work_page.feature @@ -0,0 +1,44 @@ +@p1 @drevops @our_work +Feature: Our work page + + As a site visitor + I want to browse the projects that have been delivered + So that I can judge the depth of the work before making contact + + @api + Scenario: The page opens the primary navigation and introduces the work + Given I am an anonymous user + When I go to "/work" + Then the response status code should be 200 + And I should see the text "Work you can go and look at." + And I should see the text "The work, not the pitch." + And I should see the text "Newest work first." + # The link leads the menu, so the first item is the one that must point here. + And the element ".ct-navigation__menu .ct-menu__item--level-0:first-child .ct-menu__item__link" with the attribute "href" and the value "/work" should exist + + @api + Scenario: Published projects are listed as promo cards, twelve to a page + Given the following "project" content: + | title | moderation_state | field_do_n_year | field_do_n_status | field_c_n_banner_type | field_c_n_banner_theme | field_c_n_banner_blend_mode | field_c_n_vertical_spacing | + | [TEST] Our work project 01 | published | 2025 | completed | large | inherit | normal | both | + | [TEST] Our work project 02 | published | 2025 | completed | large | inherit | normal | both | + | [TEST] Our work project 03 | published | 2025 | completed | large | inherit | normal | both | + | [TEST] Our work project 04 | published | 2025 | completed | large | inherit | normal | both | + | [TEST] Our work project 05 | published | 2025 | completed | large | inherit | normal | both | + | [TEST] Our work project 06 | published | 2025 | completed | large | inherit | normal | both | + | [TEST] Our work project 07 | published | 2025 | completed | large | inherit | normal | both | + | [TEST] Our work project 08 | published | 2025 | completed | large | inherit | normal | both | + | [TEST] Our work project 09 | published | 2025 | completed | large | inherit | normal | both | + | [TEST] Our work project 10 | published | 2025 | completed | large | inherit | normal | both | + | [TEST] Our work project 11 | published | 2025 | completed | large | inherit | normal | both | + | [TEST] Our work project 12 | published | 2025 | completed | large | inherit | normal | both | + | [TEST] Our work project 13 | published | 2025 | completed | large | inherit | normal | both | + | [TEST] Our work draft | draft | 2025 | ongoing | large | inherit | normal | both | + And I am an anonymous user + When I go to "/work" + Then the response status code should be 200 + # Asserting the page fills rather than the total, so projects already on the + # site cannot change the outcome. + And the element ".ct-layout__main" should contain 12 elements matching ".ct-promo-card" + And should see a ".ct-pagination__items" element + And I should not see the text "[TEST] Our work draft" diff --git a/web/modules/custom/do_base/assets/our-work-banner.jpg b/web/modules/custom/do_base/assets/our-work-banner.jpg new file mode 100644 index 00000000..ee1bec0e Binary files /dev/null and b/web/modules/custom/do_base/assets/our-work-banner.jpg differ diff --git a/web/modules/custom/do_base/do_base.deploy.php b/web/modules/custom/do_base/do_base.deploy.php index dd01af4c..046cf144 100644 --- a/web/modules/custom/do_base/do_base.deploy.php +++ b/web/modules/custom/do_base/do_base.deploy.php @@ -13,11 +13,16 @@ use Drupal\Core\Cache\Cache; use Drupal\Core\Entity\Sql\DefaultTableMapping; use Drupal\Core\Entity\Sql\SqlContentEntityStorage; +use Drupal\Core\File\FileExists; +use Drupal\Core\File\FileSystemInterface; use Drupal\drupal_helpers\Helper; use Drupal\drupal_helpers\Report\Reporter; +use Drupal\file\FileInterface; use Drupal\media\MediaInterface; +use Drupal\menu_link_content\MenuLinkContentInterface; use Drupal\node\NodeInterface; use Drupal\paragraphs\ParagraphInterface; +use Drupal\pathauto\PathautoState; use Drupal\search_api\Entity\Index; use Drupal\taxonomy\TermInterface; @@ -346,6 +351,45 @@ function do_base_deploy_populate_how_we_work_page(): string { return sprintf('Created the How We Work page (node %s).', $node->id()); } +/** + * Creates the Our Work page and puts it first in the primary navigation. + */ +function do_base_deploy_populate_our_work_page(): string { + $node_uuid = '1287abe9-edc6-4cc9-a078-f7261a6c6e1d'; + $menu_name = 'civictheme-primary-navigation'; + $link_title = 'Our work'; + + $existing = \Drupal::entityTypeManager()->getStorage('node')->loadByProperties(['uuid' => $node_uuid]); + $node = reset($existing); + + if ($node instanceof NodeInterface) { + Helper::reporter()->skipped('The Our Work page already exists.'); + } + else { + $node = _do_base_our_work_build_page($node_uuid); + Helper::reporter()->created(sprintf('Created the Our Work page (node %s).', $node->id())); + } + + $link = Helper::menu()->findItem($menu_name, ['title' => $link_title]); + + if ($link instanceof MenuLinkContentInterface) { + Helper::reporter()->skipped(sprintf('The "%s" link already exists in the primary navigation.', $link_title)); + + return Helper::report(); + } + + // createTree() numbers links from their position in the tree it is handed, + // which describes this link alone and so says nothing about where it belongs + // among the links already in the menu. Reading the weight first keeps the + // link being created out of its own calculation. + $weight = _do_base_menu_leading_weight($menu_name); + + Helper::menu()->createTree($menu_name, [$link_title => 'entity:node/' . $node->id()]); + Helper::menu()->updateItem($menu_name, ['title' => $link_title], ['weight' => $weight]); + + return Helper::report(); +} + /** * Rebuilds the XML sitemap. */ @@ -505,6 +549,187 @@ function do_base_deploy_seed_project_vocabularies(): string { return Helper::report(); } +/** + * Assembles the Our Work page. + */ +function _do_base_our_work_build_page(string $node_uuid): NodeInterface { + $entity_type_manager = \Drupal::entityTypeManager(); + $paragraph_storage = $entity_type_manager->getStorage('paragraph'); + + $component = function (string $type, array $fields) use ($paragraph_storage): array { + $paragraph = $paragraph_storage->create(['type' => $type] + $fields); + + if (!$paragraph instanceof ParagraphInterface) { + throw new \RuntimeException(sprintf('Failed to create a "%s" paragraph.', $type)); + } + + $paragraph->save(); + + return ['target_id' => $paragraph->id(), 'target_revision_id' => $paragraph->getRevisionId()]; + }; + + $rich_text = (fn(string $html): array => ['value' => $html, 'format' => 'civictheme_rich_text']); + + // A failed save mid-way must not leave orphaned paragraphs behind, so the + // whole assembly commits or rolls back as one unit. + $transaction = \Drupal::database()->startTransaction(); + + try { + $banner_content = $component('civictheme_content', [ + 'field_c_p_content' => $rich_text( + '

Every project here is a platform we designed, built, upgraded or rescued. Each one names the client where we are free to, the' + . ' year, the sector and the technologies, along with the part we actually played. No case study gloss, no invented metrics.

' + . '

Talk to us about your' + . ' platform

' + ), + 'field_c_p_theme' => 'dark', + 'field_c_p_background' => FALSE, + 'field_c_p_vertical_spacing' => 'bottom', + ]); + + $intro = $component('civictheme_content', [ + 'field_c_p_content' => $rich_text( + '

What you are looking at

' + . '

The work, not the pitch.

' + . '

Most portfolios are written to impress. This one is written so you can check it. Every entry names the client,' + . ' the year, the sector and the technologies involved, along with the part we actually played, which is sometimes the whole platform and sometimes one' + . ' difficult piece of it.

' + . '

Where a project produced something open source, the contributions are linked from its page, so you can read the' + . ' code instead of taking our word for it. Where a site is public, the link goes straight to it.

' + ), + 'field_c_p_theme' => 'light', + 'field_c_p_background' => TRUE, + 'field_c_p_vertical_spacing' => 'both', + ]); + + $projects = $component('civictheme_automated_list', [ + 'field_c_p_content' => $rich_text( + '

Every project

' + . '

Newest work first.

' + ), + 'field_c_p_list_type' => 'civictheme_automated_list__block1', + 'field_c_p_list_content_type' => 'project', + 'field_c_p_list_limit_type' => 'unlimited', + 'field_c_p_list_limit' => 12, + 'field_c_p_list_item_view_as' => 'civictheme_promo_card', + 'field_c_p_list_item_theme' => 'light', + 'field_c_p_list_column_count' => 3, + 'field_c_p_list_fill_width' => FALSE, + 'field_c_p_theme' => 'light', + 'field_c_p_background' => FALSE, + 'field_c_p_vertical_spacing' => 'both', + ]); + + $values = [ + 'type' => 'civictheme_page', + 'uuid' => $node_uuid, + 'title' => 'Our work', + 'status' => 1, + 'moderation_state' => 'published', + 'field_c_n_summary' => 'Platforms we have designed, built, upgraded and kept running. Each project names the client, the year, the sector and the technologies,' + . ' along with the part we actually played.', + 'field_c_n_banner_theme' => 'dark', + 'field_c_n_banner_type' => 'large', + 'field_c_n_banner_title' => 'Work you can go and look at.', + 'field_c_n_banner_blend_mode' => 'soft-light', + 'field_c_n_banner_hide_breadcrumb' => FALSE, + 'field_c_n_banner_components' => [$banner_content], + 'field_c_n_hide_sidebar' => TRUE, + 'field_c_n_show_last_updated' => FALSE, + 'field_c_n_vertical_spacing' => 'none', + 'field_c_n_components' => [$intro, $projects], + // The alias is shorter than the title, so pathauto is switched off for + // this node rather than left to derive one; without the skip it would + // take the alias back the first time an author saves the page. + 'path' => ['alias' => '/work', 'pathauto' => PathautoState::SKIP], + ]; + + // A missing image leaves the banner a flat dark band, which is a far + // smaller loss than a deployment that stops. + $banner_media = _do_base_our_work_banner_media(); + + if ($banner_media instanceof MediaInterface) { + $values['field_c_n_banner_background'] = ['target_id' => $banner_media->id()]; + } + + $node = $entity_type_manager->getStorage('node')->create($values); + $node->save(); + } + catch (\Throwable $throwable) { + $transaction->rollBack(); + + throw $throwable; + } + + return $node; +} + +/** + * Loads or creates the media entity holding the Our Work banner image. + */ +function _do_base_our_work_banner_media(): ?MediaInterface { + $media_uuid = 'a93f1457-90ca-4ce0-8685-38f32322317f'; + $media_storage = \Drupal::entityTypeManager()->getStorage('media'); + + $existing = $media_storage->loadByProperties(['uuid' => $media_uuid]); + $media = reset($existing); + + if ($media instanceof MediaInterface) { + return $media; + } + + $source = DRUPAL_ROOT . '/' . \Drupal::service('extension.list.module')->getPath('do_base') . '/assets/our-work-banner.jpg'; + + if (!is_file($source)) { + return NULL; + } + + $directory = 'public://images'; + + if (!\Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY)) { + return NULL; + } + + $file = \Drupal::service('file.repository')->writeData((string) file_get_contents($source), $directory . '/our-work-banner.jpg', FileExists::Replace); + + if (!$file instanceof FileInterface) { + return NULL; + } + + // CivicTheme image media carry the alt text as their library name, so an + // author browsing the library sees what the image shows. + $description = 'Layered slabs of teal glass and brushed metal bars against a soft grey-blue background.'; + + $media = $media_storage->create([ + 'bundle' => 'civictheme_image', + 'uuid' => $media_uuid, + 'name' => $description, + 'status' => 1, + 'field_c_m_image' => ['target_id' => $file->id(), 'alt' => $description], + ]); + $media->save(); + + return $media; +} + +/** + * Returns a weight that sorts ahead of every top-level link in a menu. + */ +function _do_base_menu_leading_weight(string $menu_name): int { + $links = \Drupal::entityTypeManager()->getStorage('menu_link_content')->loadByProperties(['menu_name' => $menu_name]); + + // Seeded so that a menu with no top-level links still yields a weight. + $weights = [0]; + + foreach ($links as $link) { + if ($link instanceof MenuLinkContentInterface && $link->getParentId() === '') { + $weights[] = $link->getWeight(); + } + } + + return min($weights) - 1; +} + /** * Loads the taxonomy term that defines which articles are blog articles. */ diff --git a/web/modules/custom/do_generated_content/src/Generator/NodeGeneratorBase.php b/web/modules/custom/do_generated_content/src/Generator/NodeGeneratorBase.php index afe96fc9..4b5c5bce 100644 --- a/web/modules/custom/do_generated_content/src/Generator/NodeGeneratorBase.php +++ b/web/modules/custom/do_generated_content/src/Generator/NodeGeneratorBase.php @@ -138,7 +138,12 @@ protected function commonValues(int $index): array { $thumbnail = $this->helper::randomMediaItem('civictheme_image'); - if (CaseMatrix::bit($index, 4) && $thumbnail instanceof MediaInterface) { + // Shares bit 0 rather than taking a high bit of its own: a high bit only + // turns on over the last few indices of the run, which are the ones + // moderationState() withholds from publication, so a card image would + // hardly ever reach an anonymous visitor. Not bit 2 - a card carrying an + // image but no summary is a combination worth covering. + if (CaseMatrix::bit($index, 0) && $thumbnail instanceof MediaInterface) { $values['field_c_n_thumbnail'] = ['target_id' => $thumbnail->id()]; }