Skip to content

Commit 5954613

Browse files
authored
Merge pull request #7378 from alphagov/CM-618-develop-smart-answers-spike
(CM-618) Develop smart answers spike
2 parents 387087a + a7518fe commit 5954613

File tree

7 files changed

+293
-1
lines changed

7 files changed

+293
-1
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ gem "rails", "8.0.3"
66

77
gem "ast"
88
gem "bootsnap", require: false
9+
gem "content_block_tools"
910
gem "dartsass-rails"
1011
gem "erb_lint"
1112
gem "gds-api-adapters"

Gemfile.lock

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ GEM
111111
coderay (1.1.3)
112112
concurrent-ruby (1.3.5)
113113
connection_pool (2.5.4)
114+
content_block_tools (1.6.1)
115+
actionview (>= 6, < 8.1.2)
116+
gds-api-adapters (~> 101.0.0)
117+
govspeak (>= 10.6.3)
118+
rails (>= 6, < 8.1.2)
119+
view_component (~> 4)
114120
crack (1.0.1)
115121
bigdecimal
116122
rexml
@@ -658,6 +664,9 @@ GEM
658664
unicode-emoji (4.0.4)
659665
uri (1.1.1)
660666
useragent (0.16.11)
667+
view_component (4.1.0)
668+
activesupport (>= 7.1.0, < 8.2)
669+
concurrent-ruby (~> 1)
661670
webmock (3.26.1)
662671
addressable (>= 2.8.0)
663672
crack (>= 0.3.2)
@@ -684,6 +693,7 @@ DEPENDENCIES
684693
binding_of_caller
685694
bootsnap
686695
byebug
696+
content_block_tools
687697
dartsass-rails
688698
erb_lint
689699
gds-api-adapters

app/presenters/content_item_presenter.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ def payload
2525
routes: [
2626
{ type: "prefix", path: base_path },
2727
],
28-
}
28+
links:,
29+
}.compact
2930
end
3031

3132
private
@@ -37,4 +38,12 @@ def start_node_presenter
3738
def base_path
3839
"/#{flow.name}"
3940
end
41+
42+
def links
43+
embed_content_ids.any? ? { embed: embed_content_ids } : nil
44+
end
45+
46+
def embed_content_ids
47+
@embed_content_ids ||= ContentBlockDetector.new(flow).content_blocks.map(&:content_id).uniq
48+
end
4049
end
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Detects and extracts content blocks from Smart Answer flows and their associated files.
2+
#
3+
# This class analyzes a flow's Ruby source files, calculator files, and ERB templates
4+
# to find embedded content block references and returns the corresponding ContentBlock objects.
5+
class ContentBlockDetector
6+
# Regular expression pattern to match calculator class names in the format
7+
# SmartAnswer::Calculators::CalculatorName
8+
CALCULATOR_PATTERN = /SmartAnswer::Calculators::[a-zA-Z]+/
9+
10+
attr_reader :flow
11+
12+
# Initializes a new ContentBlockDetector
13+
#
14+
# @param flow [Object] The Smart Answer flow object to analyze for content blocks
15+
def initialize(flow)
16+
@flow = flow
17+
end
18+
19+
# Finds and returns all unique content blocks referenced in the flow
20+
#
21+
# Searches through all flow-related files (flow class, calculators, and templates)
22+
# to find content block references, extracts their embed codes, and converts them
23+
# to ContentBlock objects.
24+
#
25+
# @return [Array<ContentBlockTools::ContentBlock>] Array of unique content blocks found
26+
def content_blocks
27+
references = ContentBlockTools::ContentBlockReference.find_all_in_document(flow_content)
28+
embed_codes = references.map(&:embed_code).uniq
29+
embed_codes.map do |embed_code|
30+
ContentBlockTools::ContentBlock.from_embed_code(embed_code)
31+
end
32+
end
33+
34+
# Returns the file path of the flow's main Ruby class file
35+
#
36+
# Uses memoization to cache the result after first lookup
37+
#
38+
# @return [String] File path to the flow's Ruby class definition
39+
def flow_filename
40+
@flow_filename ||= Object.const_source_location(flow.class.to_s)[0]
41+
end
42+
43+
# Extracts and returns all calculator classes referenced in the flow file
44+
#
45+
# Scans the flow's source file for calculator class names matching the
46+
# CALCULATOR_PATTERN and constantizes them into actual class objects.
47+
# Uses memoization to cache the result.
48+
#
49+
# @return [Array<Class>] Array of calculator class objects
50+
def calculators
51+
@calculators ||= File.read(flow_filename)
52+
.scan(CALCULATOR_PATTERN)
53+
.map(&:constantize)
54+
end
55+
56+
# Returns file paths for all calculator classes used by the flow
57+
#
58+
# @return [Array<String>] Array of file paths to calculator class files
59+
def calculator_filenames
60+
calculators.map do |calculator|
61+
Object.const_source_location(calculator.to_s)[0]
62+
end
63+
end
64+
65+
# Returns all ERB template file paths associated with the flow
66+
#
67+
# Searches for all .erb files in the flow's template directory,
68+
# which follows the naming convention: app/flows/[flow_name]_flow/**/*.erb
69+
#
70+
# @return [Array<String>] Array of ERB template file paths
71+
def template_filenames
72+
Dir.glob(File.join("app", "flows", "#{flow.name.underscore}_flow", "**", "*.erb"))
73+
end
74+
75+
# Reads and concatenates the content of all flow-related files
76+
#
77+
# @return [String] Combined content of all flow files
78+
def flow_content
79+
files.map { |file|
80+
File.read(file)
81+
}.join
82+
end
83+
84+
private
85+
86+
# Returns an array of all file paths associated with the flow
87+
#
88+
# Includes the main flow file, calculator files, and all ERB templates
89+
#
90+
# @return [Array<String>] Array of file paths
91+
def files
92+
[
93+
flow_filename,
94+
*calculator_filenames,
95+
*template_filenames,
96+
]
97+
end
98+
end

docs/tasks/using-content-blocks.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Using content blocks in Smart Answers
2+
3+
You can use content blocks from the [Content Block Manager](https://content-block-manager.publishing.service.gov.uk) in Smart Answers.
4+
This lets you reuse content across multiple Smart Answers, which will automatically update when the content block is updated.
5+
6+
## Content block embed codes
7+
8+
A content block embed code is a short snippet used to embed a content block. It looks like this:
9+
10+
```
11+
{{embed:content_block_contact:information-commissioners-office}}
12+
````
13+
14+
## Using an embed code in a Smart Answer
15+
16+
You can use embed codes in a Smart Answer’s Flow, Calculator or view.
17+
18+
To fetch a content block using an embed code, call the [`ContentBlockTools::ContentBlock.from_embed_code` method](https://github.com/alphagov/govuk_content_block_tools/blob/69f06ce51513e47f2cc2925b933a0de09249a516/lib/content_block_tools/content_block.rb#L69):
19+
20+
```ruby
21+
block = ContentBlockTools::ContentBlock.from_embed_code("{{embed:content_block_contact:information-commissioners-office}}")
22+
````
23+
24+
You can then use the [`render` method](https://github.com/alphagov/govuk_content_block_tools/blob/69f06ce51513e47f2cc2925b933a0de09249a516/lib/content_block_tools/content_block.rb#L93) to return the content block as an HTML string:
25+
26+
```ruby
27+
block.render #=> "<div class=\"content-block content-block--contact\" ..."
28+
```
29+
30+
## Publishing a Smart Answer that uses content blocks
31+
32+
After adding a content block to a Smart Answer, you must [republish the Smart Answer](https://github.com/alphagov/smart-answers/blob/main/docs/tasks/publishing.md).
33+
This ensures the Smart Answer is shown as dependent content in Content Block Manager. It also allows changes to the content block to be previewed in the context of the Smart Answer.
34+
35+
This process uses the [`ContentBlockDetector` class](https://github.com/alphagov/smart-answers/blob/main/app/services/content_block_detector.rb) to detect embedded content blocks and send them to the Publishing API as links.
36+
37+
## Limitations
38+
39+
Because the Content Block Manager’s preview service finds and replaces content blocks within pages, it’s not yet possible to use content blocks in Calculations.
40+
We plan to address this in the future.

test/unit/content_item_presenter_test.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,35 @@ class ContentItemPresenterPresenterTest < ActiveSupport::TestCase
2828
assert_equal "The Gorge of Eternal Peril!!!", payload[:description]
2929
assert_match %r{He who would cross the Bridge of Death}, payload.dig(:details, :hidden_search_terms, 0)
3030
end
31+
32+
context "when the ContentBlockDetector finds associated content blocks" do
33+
setup do
34+
@content_id = SecureRandom.uuid
35+
content_block_1 = stub("content_block", content_id: @content_id)
36+
content_block_2 = stub("content_block", content_id: @content_id)
37+
38+
detector = stub("detector", content_blocks: [content_block_1, content_block_2])
39+
ContentBlockDetector.expects(:new).with(@flow).returns(detector)
40+
end
41+
42+
should "include an array of unique content IDs in the payload" do
43+
payload = ContentItemPresenter.new(@flow).payload
44+
45+
assert_equal [@content_id], payload[:links][:embed]
46+
end
47+
end
48+
49+
context "when the ContentBlockDetector does not find associated content blocks" do
50+
setup do
51+
detector = stub("detector", content_blocks: [])
52+
ContentBlockDetector.expects(:new).with(@flow).returns(detector)
53+
end
54+
55+
should "not include any links" do
56+
payload = ContentItemPresenter.new(@flow).payload
57+
58+
assert_nil payload[:links]
59+
end
60+
end
3161
end
3262
end
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
require "test_helper"
2+
3+
class ContentBlockDetectorTest < ActiveSupport::TestCase
4+
setup do
5+
@flow = mock("flow")
6+
@flow.stubs(:name).returns("example")
7+
@flow_class = mock("flow_class")
8+
@flow_class.stubs(:to_s).returns("ExampleFlow")
9+
@flow.stubs(:class).returns(@flow_class)
10+
11+
@flow_file = "/path/to/flow.rb"
12+
@template_file = "/path/to/template.erb"
13+
@calculator_file = "/path/to/calculator.rb"
14+
15+
@detector = ContentBlockDetector.new(@flow)
16+
end
17+
18+
context "#flow_filename" do
19+
should "return the filename for the flow" do
20+
Object.expects(:const_source_location).with(@flow_class.to_s).returns([@flow_file])
21+
22+
assert_equal @flow_file, @detector.flow_filename
23+
end
24+
end
25+
26+
context "#calculators" do
27+
should "return the filename for the template" do
28+
flow_content = "FLOW CONTENT"
29+
calculator = stub("calculator", constantize: "calculator_constantized")
30+
31+
@detector.stubs(:flow_filename).returns(@flow_file)
32+
File.expects(:read).with(@flow_file).returns(flow_content)
33+
flow_content.expects(:scan)
34+
.with(ContentBlockDetector::CALCULATOR_PATTERN)
35+
.returns([calculator])
36+
37+
assert_equal [calculator.constantize], @detector.calculators
38+
end
39+
end
40+
41+
context "#calculator_filenames" do
42+
should "return the filename for the calculators" do
43+
calculator = stub("calculator")
44+
45+
@detector.stubs(:calculators).returns([calculator])
46+
Object.stubs(:const_source_location)
47+
.with(calculator.to_s)
48+
.returns([@calculator_file])
49+
50+
assert_equal [@calculator_file], @detector.calculator_filenames
51+
end
52+
end
53+
54+
context "#template_filenames" do
55+
should "return the filename for the templates" do
56+
glob = File.join("app", "flows", "#{@flow.name.underscore}_flow", "**", "*.erb")
57+
Dir.expects(:glob).with(glob)
58+
.returns([@template_file])
59+
60+
assert_equal [@template_file], @detector.template_filenames
61+
end
62+
end
63+
64+
context "#flow_content" do
65+
should "return the content for the flow's files" do
66+
@detector.stubs(:flow_filename).returns(@flow_file)
67+
@detector.stubs(:calculator_filenames).returns(@calculator_file)
68+
@detector.stubs(:template_filenames).returns(@template_file)
69+
70+
flow_content = "FLOW CONTENT"
71+
calculator_content = "CALCULATOR CONTENT"
72+
template_content = "TEMPLATE CONTENT"
73+
74+
File.expects(:read).with(@flow_file).returns(flow_content)
75+
File.expects(:read).with(@calculator_file).returns(calculator_content)
76+
File.expects(:read).with(@template_file).returns(template_content)
77+
78+
expected_content = [flow_content, calculator_content, template_content].join
79+
80+
assert_equal expected_content, @detector.flow_content
81+
end
82+
end
83+
84+
context "#content_blocks" do
85+
should "return unique content blocks from flow content" do
86+
flow_content = "FLOW CONTENT"
87+
88+
@detector.stubs(:flow_content).returns(flow_content)
89+
90+
reference = stub("reference", embed_code: "EMBED CODE")
91+
content_block = stub("content_block")
92+
93+
ContentBlockTools::ContentBlockReference.stubs(:find_all_in_document)
94+
.with(flow_content)
95+
.returns([reference])
96+
97+
ContentBlockTools::ContentBlock.stubs(:from_embed_code)
98+
.with(reference.embed_code)
99+
.returns(content_block)
100+
101+
assert_equal [content_block], @detector.content_blocks
102+
end
103+
end
104+
end

0 commit comments

Comments
 (0)