Repository: NCCE/teachcomputing.org
Pull request #2294: Move primary computing glossary page to Strapi
Author: login: A-Wheeto
Created: 2025-02-12T15:23:54Z | Merged: 2025-03-12T11:55:59Z

## Status

* Current Status: Ready for (front-end / tech) review
* Closes https://github.com/NCCE/teachcomputing.org-issues/issues/2956

## Review progress:

- [ ] Browser tested
- [ ] Front-end review completed
- [ ] Tech review completed

## What's changed?
- Added primary computing glossary table component into Strapi via graphql
- New CMS view component for the table
- New stubs, mocks and tests



Diff:
diff --git a/.env.defaults b/.env.defaults
index fbdaddd563..5951836208 100644
--- a/.env.defaults
+++ b/.env.defaults
@@ -79,4 +79,4 @@ STRAPI_IMAGE_URL="http://strapi.teachcomputing.rpfdev.com"
 STRAPI_GRAPHQL_URL="http://strapi.teachcomputing.rpfdev.com/graphql"
 STRAPI_CONNECTION_TYPE="graphql"
 
-NODE_OPTIONS=--openssl-legacy-provider
\ No newline at end of file
+NODE_OPTIONS=--openssl-legacy-provider
diff --git a/app/components/cms/primary_glossary_table_component.rb b/app/components/cms/primary_glossary_table_component.rb
new file mode 100644
index 0000000000..fb911fa3e6
--- /dev/null
+++ b/app/components/cms/primary_glossary_table_component.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class Cms::PrimaryGlossaryTableComponent < ViewComponent::Base
+  def initialize(title:)
+    @title = title
+    @records = begin
+      Cms::Collections::PrimaryGlossaryTableItems.all_records
+    rescue ActiveRecord::RecordNotFound
+      []
+    end
+  end
+
+  def render?
+    @records.any?
+  end
+end
diff --git a/app/components/cms/primary_glossary_table_component/primary_glossary_table_component.html.erb b/app/components/cms/primary_glossary_table_component/primary_glossary_table_component.html.erb
new file mode 100644
index 0000000000..182d469c52
--- /dev/null
+++ b/app/components/cms/primary_glossary_table_component/primary_glossary_table_component.html.erb
@@ -0,0 +1,25 @@
+<%= render GovGridRowComponent.new(additional_classes: "primary-glossary-table", padding: {top: 0, bottom: 0}) do |row| %>
+  <%= row.with_column("full") do %>
+    <% if @title %>
+      <h2 class="govuk-heading-m" id="glossary"><%= @title %></h2>
+    <% end %>
+    <table class="govuk-table govuk-!-margin-bottom-0">
+      <thead class="govuk-table__head">
+        <tr>
+          <th class="govuk-table__header">Term</th>
+          <th class="govuk-table__header">Key Stage</th>
+          <th class="govuk-table__header">Definition</th>
+        </tr>
+      </thead>
+      <tbody class="govuk-table__body">
+        <% @records.each do |record| %>
+          <tr class="govuk-table__row">
+            <td class="govuk-table__cell"><%= record.term.value %></td>
+            <td class="govuk-table__cell"><%= record.key_stage.value %></td>
+            <td class="govuk-table__cell"><%= render Cms::RichTextBlockComponent.new(blocks: record.definition.blocks) %></td>
+          </tr>
+        <% end %>
+      </tbody>
+    </table>
+  <% end %>
+<% end %>
diff --git a/app/components/cms/primary_glossary_table_component/primary_glossary_table_component.scss b/app/components/cms/primary_glossary_table_component/primary_glossary_table_component.scss
new file mode 100644
index 0000000000..257fe5c930
--- /dev/null
+++ b/app/components/cms/primary_glossary_table_component/primary_glossary_table_component.scss
@@ -0,0 +1,21 @@
+.primary-glossary-table {
+  .govuk-table {
+    border-collapse: collapse;
+
+    th, td {
+      border: 1px solid black;
+      padding: 10px;
+    }
+
+    .govuk-table__header {
+      color: $white;
+      background-color: $purple;
+      text-wrap: nowrap;
+      text-align: center;
+    }
+
+    .govuk-table__cell {
+      padding-right: 10px;
+    }
+  }
+}
diff --git a/app/jobs/searchable_page_indexing_job.rb b/app/jobs/searchable_page_indexing_job.rb
index f433b1e8d6..be24b7a9dc 100644
--- a/app/jobs/searchable_page_indexing_job.rb
+++ b/app/jobs/searchable_page_indexing_job.rb
@@ -5,21 +5,17 @@ class SearchablePageIndexingJob < ApplicationJob
   def perform
     now = DateTime.now
     SearchablePages::CmsBlog.delete_all
-    page = 1
-    per_page = 100
-    loop do
-      blog_search_records = Cms::Collections::Blog.all(page, per_page) # Strapi Graphql has a max limit of 100
-      if blog_search_records.resources.any?
-        SearchablePages::CmsBlog.insert_all(blog_search_records.resources.map { |blog| blog.to_search_record(now) })
-      end
-      break if (page * per_page) > blog_search_records.total_records
-      page += 1
+
+    all_blogs = Cms::Collections::Blog.all_records
+
+    if all_blogs.any?
+      SearchablePages::CmsBlog.insert_all(all_blogs.map { |blog| blog.to_search_record(now) })
     end
 
     SearchablePages::CmsWebPage.delete_all
-    page_search_records = Cms::Collections::WebPage.all(1, 100)
-    if page_search_records.resources.any?
-      SearchablePages::CmsWebPage.insert_all(page_search_records.resources.map { |page| page.to_search_record(now) })
+    page_search_records = Cms::Collections::WebPage.all_records
+    if page_search_records.any?
+      SearchablePages::CmsWebPage.insert_all(page_search_records.map { |page| page.to_search_record(now) })
     end
     enrichment_pages = Cms::Collections::EnrichmentPage.all(1, 10)
     if enrichment_pages.resources.any?
diff --git a/app/services/cms/collections/primary_glossary_table_items.rb b/app/services/cms/collections/primary_glossary_table_items.rb
new file mode 100644
index 0000000000..b95f134537
--- /dev/null
+++ b/app/services/cms/collections/primary_glossary_table_items.rb
@@ -0,0 +1,31 @@
+module Cms
+  module Collections
+    class PrimaryGlossaryTableItems < Resource
+      def self.is_collection = true
+
+      def self.resource_key
+        "primary-computing-glossary-table"
+      end
+
+      def self.graphql_key
+        "primaryComputingGlossaryTables"
+      end
+
+      def self.collection_attribute_mappings
+        [
+          {model: Cms::Models::TextField, key: :term},
+          {model: Cms::Models::TextField, key: :keyStage},
+          {model: Cms::Models::TextBlock, key: :definition}
+        ]
+      end
+
+      def self.resource_attribute_mappings
+        [
+          {model: Cms::Models::TextField, key: :term},
+          {model: Cms::Models::TextField, key: :keyStage},
+          {model: Cms::Models::TextBlock, key: :definition}
+        ]
+      end
+    end
+  end
+end
diff --git a/app/services/cms/dynamic_components/primary_glossary_table.rb b/app/services/cms/dynamic_components/primary_glossary_table.rb
new file mode 100644
index 0000000000..60535934b2
--- /dev/null
+++ b/app/services/cms/dynamic_components/primary_glossary_table.rb
@@ -0,0 +1,15 @@
+module Cms
+  module DynamicComponents
+    class PrimaryGlossaryTable
+      attr_accessor :title
+
+      def initialize(title:)
+        @title = title
+      end
+
+      def render
+        PrimaryGlossaryTableComponent.new(title:)
+      end
+    end
+  end
+end
diff --git a/app/services/cms/providers/strapi/factories/blocks_factory.rb b/app/services/cms/providers/strapi/factories/blocks_factory.rb
index d2dc671906..da779ee5f2 100644
--- a/app/services/cms/providers/strapi/factories/blocks_factory.rb
+++ b/app/services/cms/providers/strapi/factories/blocks_factory.rb
@@ -43,6 +43,8 @@ def self.generate_component(component_name, strapi_data)
               to_icon_row(strapi_data)
             when "two-column-video-section"
               to_two_column_video_section(strapi_data)
+            when "primary-glossary-table"
+              DynamicComponents::PrimaryGlossaryTable.new(title: strapi_data[:title])
             end
           end
 
diff --git a/app/services/cms/providers/strapi/mocks/dynamic_components/primary_glossary_table.rb b/app/services/cms/providers/strapi/mocks/dynamic_components/primary_glossary_table.rb
new file mode 100644
index 0000000000..4c30b84e91
--- /dev/null
+++ b/app/services/cms/providers/strapi/mocks/dynamic_components/primary_glossary_table.rb
@@ -0,0 +1,15 @@
+module Cms
+  module Providers
+    module Strapi
+      module Mocks
+        module DynamicComponents
+          class PrimaryGlossaryTable < StrapiMock
+            strapi_component "blocks.primary-glossary-table"
+
+            attribute(:title) { Faker::Lorem.sentence }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/app/services/cms/providers/strapi/mocks/primary_glossary_table_items.rb b/app/services/cms/providers/strapi/mocks/primary_glossary_table_items.rb
new file mode 100644
index 0000000000..2535272bac
--- /dev/null
+++ b/app/services/cms/providers/strapi/mocks/primary_glossary_table_items.rb
@@ -0,0 +1,13 @@
+module Cms
+  module Providers
+    module Strapi
+      module Mocks
+        class PrimaryGlossaryTableItems < StrapiMock
+          attribute(:term) { Faker::Lorem.word }
+          attribute(:keyStage) { Faker::Lorem.word }
+          attribute(:definition) { RichBlocks.generate_data }
+        end
+      end
+    end
+  end
+end
diff --git a/app/services/cms/providers/strapi/queries/components/blocks/primary_glossary_table.rb b/app/services/cms/providers/strapi/queries/components/blocks/primary_glossary_table.rb
new file mode 100644
index 0000000000..60de31fc56
--- /dev/null
+++ b/app/services/cms/providers/strapi/queries/components/blocks/primary_glossary_table.rb
@@ -0,0 +1,21 @@
+module Cms
+  module Providers
+    module Strapi
+      module Queries
+        module Components
+          module Blocks
+            class PrimaryGlossaryTable < BaseComponentQuery
+              def self.name = "ComponentBlocksPrimaryGlossaryTable"
+
+              def self.base_fields
+                <<~GRAPHQL.freeze
+                  title
+                GRAPHQL
+              end
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/app/services/cms/providers/strapi/queries/dynamic_zone.rb b/app/services/cms/providers/strapi/queries/dynamic_zone.rb
index 4b69aecc13..9c72b62c06 100644
--- a/app/services/cms/providers/strapi/queries/dynamic_zone.rb
+++ b/app/services/cms/providers/strapi/queries/dynamic_zone.rb
@@ -15,6 +15,7 @@ class DynamicZone
             Components::Blocks::NumberedIconList,
             Components::Blocks::NumericCardsSection,
             Components::Blocks::PictureCardSection,
+            Components::Blocks::PrimaryGlossaryTable,
             Components::Blocks::QuestionAndAnswer,
             Components::Blocks::ResourceCardSection,
             Components::Blocks::SplitHorizontalCard,
diff --git a/app/services/cms/resource.rb b/app/services/cms/resource.rb
index a0d22be8b3..b9669408e8 100644
--- a/app/services/cms/resource.rb
+++ b/app/services/cms/resource.rb
@@ -93,6 +93,21 @@ def self.all(page, page_size, params: {})
       Collection.new(**response)
     end
 
+    def self.all_records(params: {})
+      page = 1
+      per_page = 100
+      all_records = []
+
+      loop do
+        records = all(page, per_page, params:)
+        all_records += records.resources
+
+        break if (page * per_page) > records.total_records
+        page += 1
+      end
+      all_records
+    end
+
     def self.clear_cache(key = nil)
       if key
         Rails.cache.delete("#{resource_key}-#{key}", namespace: "cms")
diff --git a/spec/components/cms/primary_glossary_table_component_spec.rb b/spec/components/cms/primary_glossary_table_component_spec.rb
new file mode 100644
index 0000000000..2b40737903
--- /dev/null
+++ b/spec/components/cms/primary_glossary_table_component_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Cms::PrimaryGlossaryTableComponent, type: :component do
+  let(:title) { Faker::Lorem.word }
+
+  context "with records" do
+    before do
+      stub_strapi_primary_computing_glossary_table_collection
+
+      render_inline(described_class.new(
+        title: title
+      ))
+    end
+
+    it "renders the title" do
+      expect(page).to have_css("h2", text: title)
+    end
+
+    it "renders a table with headers" do
+      expect(page).to have_css("table")
+      expect(page).to have_css(".govuk-table__header", text: "Term")
+      expect(page).to have_css(".govuk-table__header", text: "Key Stage")
+      expect(page).to have_css(".govuk-table__header", text: "Definition")
+    end
+
+    it "renders the glossary records" do
+      expect(page).to have_css(".govuk-table__row", count: 5)
+      expect(page).to have_css(".cms-rich-text-block-component", count: 5)
+    end
+  end
+
+  context "with no title" do
+    before do
+      stub_strapi_primary_computing_glossary_table_collection
+
+      render_inline(described_class.new(
+        title: nil
+      ))
+    end
+
+    it "does not render a title" do
+      expect(page).to_not have_css("h2")
+    end
+  end
+
+  context "when no records found" do
+    before do
+      stub_strapi_graphql_collection_error("primaryComputingGlossaryTables")
+
+      render_inline(described_class.new(
+        title: title
+      ))
+    end
+
+    it "does not render" do
+      expect(page).to_not have_css("table")
+    end
+  end
+end
diff --git a/spec/services/cms/dynamic_components/primary_glossary_table_spec.rb b/spec/services/cms/dynamic_components/primary_glossary_table_spec.rb
new file mode 100644
index 0000000000..5ac8131a0a
--- /dev/null
+++ b/spec/services/cms/dynamic_components/primary_glossary_table_spec.rb
@@ -0,0 +1,12 @@
+require "rails_helper"
+
+RSpec.describe Cms::DynamicComponents::PrimaryGlossaryTable do
+  before do
+    stub_strapi_primary_computing_glossary_table_collection
+    @text = Cms::Providers::Strapi::Factories::ComponentFactory.process_component(Cms::Mocks::DynamicComponents::PrimaryGlossaryTable.generate_raw_data)
+  end
+
+  it "should render as PrimaryGlossaryTableComponent" do
+    expect(@text.render).to be_a(Cms::PrimaryGlossaryTableComponent)
+  end
+end
diff --git a/spec/services/cms/resource_spec.rb b/spec/services/cms/resource_spec.rb
index d28d18062c..4232592f50 100644
--- a/spec/services/cms/resource_spec.rb
+++ b/spec/services/cms/resource_spec.rb
@@ -230,4 +230,44 @@ def self.resource_key
       end.to raise_error(Cms::Errors::NoCmsProviderDefined)
     end
   end
+
+  describe "#all_records" do
+    describe "with multiple records" do
+      before do
+        blogs = Array.new(210) { Cms::Mocks::Blog.generate_raw_data }
+
+        stub_strapi_blog_collection(blogs:, page: 1, page_size: 100)
+        stub_strapi_blog_collection(blogs:, page: 2, page_size: 100)
+        stub_strapi_blog_collection(blogs:, page: 3, page_size: 100)
+      end
+
+      it "should return all records" do
+        expect(Cms::Collections::Blog.all_records.count).to eq(210)
+      end
+    end
+
+    describe "with one record" do
+      before do
+        blogs = [Cms::Mocks::Blog.generate_raw_data]
+
+        stub_strapi_blog_collection(blogs:, page: 1, page_size: 100)
+      end
+
+      it "should return one record" do
+        expect(Cms::Collections::Blog.all_records.count).to eq(1)
+      end
+    end
+
+    describe "with no records" do
+      before do
+        blogs = []
+
+        stub_strapi_blog_collection(blogs:, page: 1, page_size: 100)
+      end
+
+      it "should return one record" do
+        expect(Cms::Collections::Blog.all_records.count).to eq(0)
+      end
+    end
+  end
 end
diff --git a/spec/support/cms/providers/strapi/strapi_stubs.rb b/spec/support/cms/providers/strapi/strapi_stubs.rb
index 143d3329b1..cf121899ec 100644
--- a/spec/support/cms/providers/strapi/strapi_stubs.rb
+++ b/spec/support/cms/providers/strapi/strapi_stubs.rb
@@ -84,7 +84,12 @@ def stub_featured_posts_error
   end
 
   def stub_strapi_blog_collection(blogs: nil, page: 1, page_size: 10)
-    blog_list = blogs.presence || Array.new(5) { Cms::Mocks::Blog.generate_raw_data }
+    blog_list = if blogs.nil?
+      Array.new(5) { Cms::Mocks::Blog.generate_raw_data }
+    else
+      blogs
+    end
+
     if as_graphql
       stub_strapi_graphql_collection_query("blogs", blog_list, page:, page_size:)
     else
@@ -137,6 +142,15 @@ def stub_strapi_enrichment_collection(enrichment_pages: Array.new(2) { Cms::Mock
     end
   end
 
+  def stub_strapi_primary_computing_glossary_table_collection(table: nil, page: 1, page_size: 10)
+    table_list = table.presence || Array.new(5) { Cms::Mocks::PrimaryGlossaryTableItems.generate_raw_data }
+    if as_graphql
+      stub_strapi_graphql_collection_query("primaryComputingGlossaryTables", table_list, page:, page_size:)
+    else
+      stub_request(:get, /^https:\/\/strapi.teachcomputing.org\/api\/primary-computing-glossary-tables/).to_return_json(body: to_strapi_collection(table_list, page:, page_size:))
+    end
+  end
+
   def stub_strapi_web_page_collection(web_pages: nil)
     web_page_list = web_pages.presence || Array.new(5) { Cms::Mocks::WebPage.generate_raw_data }
     if as_graphql
@@ -216,6 +230,14 @@ def stub_strapi_graphql_query_missing(resource_name)
       .to_return_json(body: {data: response})
   end
 
+  def stub_strapi_graphql_collection_error(resource_name)
+    stub_strapi_schema
+    response = {errors: [{message: "Not found"}]}
+    stub_request(:post, /^https:\/\/strapi.teachcomputing.org\/graphql/)
+      .with(body: /#{resource_name}/)
+      .to_return_json(body: response)
+  end
+
   private
 
   def rest_resource_name_to_graph(resource_key)


Reviews:
user: login: msquance-stem
body: LGTM, just a naming change suggestion, and also a fix for lighthouse
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T13:05:54Z
user: login: A-Wheeto
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T13:40:38Z

Review comments:
diff_hunk: @@ -126,7 +141,11 @@ def self.clear_cache(key = nil)
         when "rest"
           Providers::Strapi::Client.new
         when "graphql"
-          Providers::Strapi::GraphqlClient.new
+          if Rails.env.test?
path: app/services/cms/resource.rb
user: login: msquance-stem
body: This is the old version, it should now just be
`Providers::Strapi::GraphqlClient.new`
We no longer need to pass the schema
created_at: 2025-03-03T15:20:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,31 @@
+module Cms
+  module Collections
+    class PrimaryGlossaryTable < Resource
path: app/services/cms/collections/primary_glossary_table.rb
user: login: msquance-stem
body: I wonder if we should rename this to PrimaryGlossaryTableItems, just to make it clear its separate to the component? I wouldn't bother changing the name on Strapi, just the collection name here
created_at: 2025-03-04T13:02:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,31 @@
+module Cms
+  module Collections
+    class PrimaryGlossaryTable < Resource
path: app/services/cms/collections/primary_glossary_table.rb
user: login: A-Wheeto
body: Agreed, I will update this.
created_at: 2025-03-04T13:40:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-03-04T14:39:43Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=NCCE_teachcomputing.org&pullRequest=2294) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=NCCE_teachcomputing.org&pullRequest=2294&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=NCCE_teachcomputing.org&pullRequest=2294&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=NCCE_teachcomputing.org&pullRequest=2294&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=NCCE_teachcomputing.org&pullRequest=2294&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=NCCE_teachcomputing.org&pullRequest=2294&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=NCCE_teachcomputing.org&pullRequest=2294)


author_association: NONE