Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<module>/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 <path>`. Committed, vetted scripts may use `php:eval` for static, non-dynamic operations.
Expand Down
44 changes: 44 additions & 0 deletions tests/behat/features/our_work_page.feature
Original file line number Diff line number Diff line change
@@ -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"
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
225 changes: 225 additions & 0 deletions web/modules/custom/do_base/do_base.deploy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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(
'<p class="ct-text-large">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.</p>'
. '<p><a class="ct-button ct-theme-light ct-theme-dark ct-button--primary ct-button--regular" href="/contact"><strong>Talk to us about your'
. ' platform</strong></a></p>'
),
'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(
'<p class="text-align-center eyebrow">What you are looking at</p>'
. '<h2 class="text-align-center"><strong>The work, not the pitch.</strong></h2>'
. '<p class="text-align-center ct-text-large">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.</p>'
. '<p class="text-align-center ct-text-large">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.</p>'
),
'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(
'<p class="text-align-center eyebrow">Every project</p>'
. '<h2 class="text-align-center"><strong>Newest work first.</strong></h2>'
),
'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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()];
}

Expand Down
Loading