wcc-contentful 0.3.0 → 1.0.0.pre.rc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +5 -5
  2. data/.rspec +1 -1
  3. data/Guardfile +43 -0
  4. data/README.md +161 -11
  5. data/Rakefile +3 -6
  6. data/app/controllers/wcc/contentful/webhook_controller.rb +25 -24
  7. data/app/jobs/wcc/contentful/webhook_enable_job.rb +36 -2
  8. data/bin/console +4 -3
  9. data/bin/rails +2 -0
  10. data/config/routes.rb +1 -1
  11. data/doc +1 -0
  12. data/lib/tasks/download_schema.rake +12 -0
  13. data/lib/wcc/contentful.rb +69 -45
  14. data/lib/wcc/contentful/active_record_shim.rb +72 -0
  15. data/lib/wcc/contentful/configuration.rb +177 -46
  16. data/lib/wcc/contentful/content_type_indexer.rb +14 -0
  17. data/lib/wcc/contentful/downloads_schema.rb +112 -0
  18. data/lib/wcc/contentful/engine.rb +33 -14
  19. data/lib/wcc/contentful/event.rb +171 -0
  20. data/lib/wcc/contentful/events.rb +41 -0
  21. data/lib/wcc/contentful/exceptions.rb +3 -33
  22. data/lib/wcc/contentful/indexed_representation.rb +2 -2
  23. data/lib/wcc/contentful/instrumentation.rb +31 -0
  24. data/lib/wcc/contentful/link.rb +28 -0
  25. data/lib/wcc/contentful/link_visitor.rb +122 -0
  26. data/lib/wcc/contentful/middleware.rb +7 -0
  27. data/lib/wcc/contentful/middleware/store.rb +158 -0
  28. data/lib/wcc/contentful/middleware/store/caching_middleware.rb +114 -0
  29. data/lib/wcc/contentful/model.rb +37 -4
  30. data/lib/wcc/contentful/model_builder.rb +1 -0
  31. data/lib/wcc/contentful/model_methods.rb +40 -15
  32. data/lib/wcc/contentful/model_singleton_methods.rb +47 -30
  33. data/lib/wcc/contentful/rake.rb +4 -0
  34. data/lib/wcc/contentful/rspec.rb +46 -0
  35. data/lib/wcc/contentful/services.rb +61 -27
  36. data/lib/wcc/contentful/simple_client.rb +81 -25
  37. data/lib/wcc/contentful/simple_client/management.rb +43 -10
  38. data/lib/wcc/contentful/simple_client/response.rb +61 -22
  39. data/lib/wcc/contentful/simple_client/typhoeus_adapter.rb +17 -17
  40. data/lib/wcc/contentful/store.rb +7 -66
  41. data/lib/wcc/contentful/store/README.md +85 -0
  42. data/lib/wcc/contentful/store/base.rb +34 -119
  43. data/lib/wcc/contentful/store/cdn_adapter.rb +71 -12
  44. data/lib/wcc/contentful/store/factory.rb +186 -0
  45. data/lib/wcc/contentful/store/instrumentation.rb +55 -0
  46. data/lib/wcc/contentful/store/interface.rb +82 -0
  47. data/lib/wcc/contentful/store/memory_store.rb +27 -24
  48. data/lib/wcc/contentful/store/postgres_store.rb +268 -101
  49. data/lib/wcc/contentful/store/postgres_store/schema_1.sql +73 -0
  50. data/lib/wcc/contentful/store/postgres_store/schema_2.sql +21 -0
  51. data/lib/wcc/contentful/store/query.rb +246 -0
  52. data/lib/wcc/contentful/store/query/interface.rb +63 -0
  53. data/lib/wcc/contentful/store/rspec_examples.rb +48 -0
  54. data/lib/wcc/contentful/store/rspec_examples/basic_store.rb +629 -0
  55. data/lib/wcc/contentful/store/rspec_examples/include_param.rb +283 -0
  56. data/lib/wcc/contentful/store/rspec_examples/nested_queries.rb +342 -0
  57. data/lib/wcc/contentful/sync_engine.rb +181 -0
  58. data/lib/wcc/contentful/test.rb +7 -0
  59. data/lib/wcc/contentful/test/attributes.rb +56 -0
  60. data/lib/wcc/contentful/test/double.rb +76 -0
  61. data/lib/wcc/contentful/test/factory.rb +101 -0
  62. data/lib/wcc/contentful/version.rb +1 -1
  63. data/wcc-contentful.gemspec +28 -14
  64. metadata +248 -152
  65. data/.circleci/config.yml +0 -51
  66. data/.gitignore +0 -26
  67. data/.rubocop.yml +0 -242
  68. data/.rubocop_todo.yml +0 -19
  69. data/.travis.yml +0 -5
  70. data/CHANGELOG.md +0 -180
  71. data/CODE_OF_CONDUCT.md +0 -74
  72. data/Gemfile +0 -8
  73. data/LICENSE.txt +0 -21
  74. data/app/jobs/wcc/contentful/delayed_sync_job.rb +0 -63
  75. data/lib/generators/wcc/USAGE +0 -24
  76. data/lib/generators/wcc/model_generator.rb +0 -90
  77. data/lib/generators/wcc/templates/.keep +0 -0
  78. data/lib/generators/wcc/templates/Procfile +0 -3
  79. data/lib/generators/wcc/templates/contentful_shell_wrapper +0 -385
  80. data/lib/generators/wcc/templates/menu/generated_add_menus.ts +0 -192
  81. data/lib/generators/wcc/templates/menu/models/menu.rb +0 -23
  82. data/lib/generators/wcc/templates/menu/models/menu_button.rb +0 -23
  83. data/lib/generators/wcc/templates/page/generated_add_pages.ts +0 -50
  84. data/lib/generators/wcc/templates/page/models/page.rb +0 -23
  85. data/lib/generators/wcc/templates/release +0 -9
  86. data/lib/generators/wcc/templates/wcc_contentful.rb +0 -17
  87. data/lib/wcc/contentful/client_ext.rb +0 -28
  88. data/lib/wcc/contentful/graphql.rb +0 -14
  89. data/lib/wcc/contentful/graphql/builder.rb +0 -177
  90. data/lib/wcc/contentful/graphql/types.rb +0 -54
  91. data/lib/wcc/contentful/model/dropdown_menu.rb +0 -7
  92. data/lib/wcc/contentful/model/menu.rb +0 -6
  93. data/lib/wcc/contentful/model/menu_button.rb +0 -16
  94. data/lib/wcc/contentful/model/page.rb +0 -8
  95. data/lib/wcc/contentful/model/redirect.rb +0 -19
  96. data/lib/wcc/contentful/model_validators.rb +0 -121
  97. data/lib/wcc/contentful/model_validators/dsl.rb +0 -166
  98. data/lib/wcc/contentful/simple_client/http_adapter.rb +0 -24
  99. data/lib/wcc/contentful/store/lazy_cache_store.rb +0 -161
@@ -1,192 +0,0 @@
1
-
2
- import Migration from 'contentful-migration-cli'
3
-
4
- export = function (migration: Migration) {
5
- const menu = migration.createContentType('menu', {
6
- displayField: 'name',
7
- name: 'Menu',
8
- description: 'A Menu contains a number of Menu Buttons or other Menus, ' +
9
- 'which will be rendered as drop-downs.'
10
- })
11
-
12
- menu.createField('name', {
13
- name: 'Menu Name',
14
- type: 'Symbol',
15
- localized: false,
16
- required: true,
17
- validations: [],
18
- disabled: false,
19
- omitted: false
20
- })
21
-
22
- menu.createField('items', {
23
- name: 'Items',
24
- type: 'Array',
25
- localized: false,
26
- required: false,
27
- validations: [],
28
- disabled: false,
29
- omitted: false,
30
- items:
31
- {
32
- type: 'Link',
33
- validations:
34
- [{
35
- linkContentType:
36
- ['dropdownMenu',
37
- 'menuButton'],
38
- message: 'The items must be either buttons or drop-down menus.'
39
- }],
40
- linkType: 'Entry'
41
- }
42
- })
43
-
44
- menu.changeEditorInterface('name', 'singleLine')
45
- menu.changeEditorInterface('items', 'entryLinksEditor')
46
-
47
- const menubutton = migration.createContentType('menuButton', {
48
- displayField: 'text',
49
- name: 'Menu Button',
50
- description: 'A Menu Button is a clickable button that goes on a Menu. It has a link to a Page or a URL.'
51
- })
52
-
53
- menubutton.createField('text', {
54
- name: 'Text',
55
- type: 'Symbol',
56
- localized: false,
57
- required: true,
58
- validations:
59
- [{
60
- size:
61
- {
62
- min: 1,
63
- max: 60
64
- },
65
- message: 'A Menu Button should have a very short text field - ideally a single word. Please limit the text to 60 characters.'
66
- }],
67
- disabled: false,
68
- omitted: false
69
- })
70
-
71
- menubutton.createField('icon', {
72
- name: 'Icon',
73
- type: 'Link',
74
- localized: false,
75
- required: false,
76
- validations: [{ linkMimetypeGroup: ['image'] }],
77
- disabled: false,
78
- omitted: false,
79
- linkType: 'Asset'
80
- })
81
-
82
- menubutton.createField('externalLink', {
83
- name: 'External Link',
84
- type: 'Symbol',
85
- localized: false,
86
- required: false,
87
- validations:
88
- [{
89
- regexp: { pattern: '^(\\w+):(\\/\\/)?(\\w+:{0,1}\\w*@)?((\\w+\\.)+[^\\s\\/#]+)(:[0-9]+)?(\\/|(\\/|\\#)([\\w#!:.?+=&%@!\\-\\/]+))?$|^(\\/|(\\/|\\#)([\\w#!:.?+=&%@!\\-\\/]+))$' },
90
- message: 'The external link must be a URL like \'https://www.watermark.org/\', a mailto url like \'mailto:info@watermark.org\', or a relative URL like \'#location-on-page\''
91
- }],
92
- disabled: false,
93
- omitted: false
94
- })
95
-
96
- menubutton.createField('link', {
97
- name: 'Page Link',
98
- type: 'Link',
99
- localized: false,
100
- required: false,
101
- validations:
102
- [{
103
- linkContentType: ['page'],
104
- message: 'The Page Link must be a link to a Page which has a slug.'
105
- }],
106
- disabled: false,
107
- omitted: false,
108
- linkType: 'Entry'
109
- })
110
-
111
- menubutton.createField('ionIcon', {
112
- name: 'Ion Icon',
113
- type: 'Symbol',
114
- localized: false,
115
- required: false,
116
- validations:
117
- [{
118
- regexp: { pattern: '^ion-[a-z\\-]+$' },
119
- message: 'The icon should start with \'ion-\', like \'ion-arrow-down-c\'. See http://ionicons.com/'
120
- }],
121
- disabled: false,
122
- omitted: false
123
- })
124
-
125
- menubutton.createField('style', {
126
- name: 'Style',
127
- type: 'Symbol',
128
- localized: false,
129
- required: false,
130
- validations: [{ in: ['oval-border'] }],
131
- disabled: false,
132
- omitted: false
133
- })
134
-
135
- menubutton.changeEditorInterface('text', 'singleLine')
136
- menubutton.changeEditorInterface('icon', 'assetLinkEditor')
137
- menubutton.changeEditorInterface('externalLink', 'singleLine')
138
- menubutton.changeEditorInterface('link', 'entryLinkEditor')
139
- menubutton.changeEditorInterface('ionIcon', 'singleLine')
140
- menubutton.changeEditorInterface('style', 'dropdown')
141
-
142
- const dropdownmenu = migration.createContentType('dropdownMenu', {
143
- displayField: 'name',
144
- name: 'Dropdown Menu',
145
- description: 'A Dropdown Menu can be attached to a main menu to show additional menu items on click.'
146
- })
147
-
148
- dropdownmenu.createField('name', {
149
- name: 'Menu Name',
150
- type: 'Symbol',
151
- localized: false,
152
- required: false,
153
- validations: [],
154
- disabled: false,
155
- omitted: false
156
- })
157
-
158
- dropdownmenu.createField('label', {
159
- name: 'Menu Label',
160
- type: 'Link',
161
- localized: false,
162
- required: false,
163
- validations: [{ linkContentType: ['menuButton'] }],
164
- disabled: false,
165
- omitted: false,
166
- linkType: 'Entry'
167
- })
168
-
169
- dropdownmenu.createField('items', {
170
- name: 'Items',
171
- type: 'Array',
172
- localized: false,
173
- required: false,
174
- validations: [],
175
- disabled: false,
176
- omitted: false,
177
- items:
178
- {
179
- type: 'Link',
180
- validations:
181
- [{
182
- linkContentType:
183
- ['menuButton']
184
- }],
185
- linkType: 'Entry'
186
- }
187
- })
188
-
189
- dropdownmenu.changeEditorInterface('name', 'singleLine')
190
- dropdownmenu.changeEditorInterface('label', 'entryLinkEditor')
191
- dropdownmenu.changeEditorInterface('items', 'entryLinksEditor')
192
- }
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # This model represents the 'menu' content type in Contentful. Any linked
4
- # entries of the 'menu' content type will be resolved as instances of this class.
5
- # It exposes #find, #find_by, and #find_all methods to query Contentful.
6
- class Menu < WCC::Contentful::Model::Menu
7
- # Add custom validations to ensure that app-specific properties exist:
8
- # validate_field :foo, :String, :required
9
- # validate_field :bar_links, :Array, link_to: %w[bar baz]
10
-
11
- # Override functionality or add utilities
12
- #
13
- # # Example: override equality
14
- # def ===(other)
15
- # ...
16
- # end
17
- #
18
- # # Example: override "name" attribute to always be camelized.
19
- # # `@name` is populated by the gem in the initializer.
20
- # def name
21
- # @name_camelized ||= @name.camelize(true)
22
- # end
23
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # This model represents the 'menuButton' content type in Contentful. Any linked
4
- # entries of the 'menuButton' content type will be resolved as instances of this class.
5
- # It exposes #find, #find_by, and #find_all methods to query Contentful.
6
- class MenuButton < WCC::Contentful::Model::MenuButton
7
- # Add custom validations to ensure that app-specific properties exist:
8
- # validate_field :foo, :String, :required
9
- # validate_field :bar_links, :Array, link_to: %w[bar baz]
10
-
11
- # Override functionality or add utilities
12
- #
13
- # # Example: override equality
14
- # def ===(other)
15
- # ...
16
- # end
17
- #
18
- # # Example: override "text" attribute to always be camelized.
19
- # # `@text` is populated by the gem in the initializer.
20
- # def text
21
- # @text_camelized ||= @text.camelize(true)
22
- # end
23
- end
@@ -1,50 +0,0 @@
1
- import Migration from 'contentful-migration-cli'
2
-
3
- export = function (migration: Migration) {
4
- const page = migration.createContentType('page')
5
- .name('Page')
6
- .description('A page describes a collection of sections that correspond' +
7
- 'to a URL slug')
8
- .displayField('title')
9
-
10
- page.createField('title')
11
- .name('Title')
12
- .type('Symbol')
13
- .required(true)
14
-
15
- page.createField('slug')
16
- .name('Slug')
17
- .type('Symbol')
18
- .required(true)
19
- .validations([
20
- {
21
- unique: true
22
- },
23
- {
24
- regexp: { pattern: "(\\/|\\/([\w#!:.?+=&%@!\\-\\/]))?$" },
25
- message: "The slug must look like the path part of a URL and begin with a forward slash, example: '/my-page-slug'"
26
- }
27
- ])
28
-
29
- page.createField('sections')
30
- .name('Sections')
31
- .type('Array')
32
- .items({
33
- type: 'Link',
34
- linkType: 'Entry'
35
- })
36
-
37
- page.createField('subpages')
38
- .name('Subpages')
39
- .type('Array')
40
- .items({
41
- type: 'Link',
42
- linkType: 'Entry',
43
- validations: [
44
- {
45
- linkContentType: [ 'page' ]
46
- }
47
- ]
48
- })
49
-
50
- }
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # This model represents the 'page' content type in Contentful. Any linked
4
- # entries of the 'page' content type will be resolved as instances of this class.
5
- # It exposes #find, #find_by, and #find_all methods to query Contentful.
6
- class Page < WCC::Contentful::Model::Page
7
- # Add custom validations to ensure that app-specific properties exist:
8
- # validate_field :foo, :String, :required
9
- # validate_field :bar_links, :Array, link_to: %w[bar baz]
10
-
11
- # Override functionality or add utilities
12
- #
13
- # # Example: override equality
14
- # def ===(other)
15
- # ...
16
- # end
17
- #
18
- # # Example: override "title" attribute to always be titlecase.
19
- # # `@title` is populated by the gem in the initializer.
20
- # def title
21
- # @title_titlecased ||= @title.titlecase
22
- # end
23
- end
@@ -1,9 +0,0 @@
1
- #!/bin/sh
2
-
3
- set -e
4
-
5
- echo "Migrating database..."
6
- bundle exec rake db:migrate
7
-
8
- DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
9
- $DIR/contentful migrate -y
@@ -1,17 +0,0 @@
1
-
2
- WCC::Contentful.configure do |config|
3
- # Required
4
- config.access_token = # Contentful CDN access token
5
- config.space = # Contentful Space ID
6
-
7
- # Optional
8
- config.management_token = # Contentful API management token
9
- config.default_locale = # Set default locale, if left blank this is 'en-US'
10
- config.content_delivery = # :direct, :eager_sync, or :lazy_sync
11
- end
12
-
13
- # Download content types, build models, and sync content
14
- WCC::Contentful.init!
15
-
16
- # Validate that models conform to a defined specification
17
- WCC::Contentful.validate_models! unless defined?(Rails) && Rails.env.development?
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Contentful::Client
4
- class << self
5
- alias_method :old_get_http, :get_http
6
- end
7
-
8
- def self.adapter
9
- @adapter ||=
10
- WCC::Contentful::SimpleClient.load_adapter(WCC::Contentful.configuration.http_adapter) ||
11
- ->(url, query, headers, proxy) { old_get_http(url, query, headers, proxy) }
12
- end
13
-
14
- def self.get_http(url, query, headers = {}, proxy = {})
15
- if environment = WCC::Contentful.configuration.environment
16
- url = rewrite_to_environment(url, environment)
17
- end
18
-
19
- adapter.call(url, query, headers, proxy)
20
- end
21
-
22
- REWRITE_REGEXP = /^(https?\:\/\/(?:\w+)\.contentful\.com\/spaces\/[^\/]+\/)(?!environments)(.+)$/
23
- def self.rewrite_to_environment(url, environment)
24
- return url unless m = REWRITE_REGEXP.match(url)
25
-
26
- File.join(m[1], 'environments', environment, m[2])
27
- end
28
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- gem 'graphql', '~> 1.7'
4
- require 'graphql'
5
-
6
- module WCC::Contentful
7
- # This module builds a GraphQL schema out of our IndexedRepresentation.
8
- # It is currently unused and not hooked up in the WCC::Contentful.init! method.
9
- # TODO: https://zube.io/watermarkchurch/development/c/2234 hook it up
10
- module Graphql
11
- end
12
- end
13
-
14
- require_relative 'graphql/builder'
@@ -1,177 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'graphql'
4
-
5
- require_relative 'types'
6
-
7
- module WCC::Contentful::Graphql
8
- class Builder
9
- attr_reader :schema_types
10
-
11
- def initialize(types, store)
12
- @types = types
13
- @store = store
14
- end
15
-
16
- def build_schema
17
- @schema_types = build_schema_types
18
-
19
- root_query_type = build_root_query(@schema_types)
20
-
21
- builder = self
22
- GraphQL::Schema.define do
23
- query root_query_type
24
-
25
- resolve_type ->(_type, obj, _ctx) {
26
- content_type = WCC::Contentful::Helpers.content_type_from_raw(obj)
27
- builder.schema_types[content_type]
28
- }
29
- end
30
- end
31
-
32
- private
33
-
34
- def build_root_query(schema_types)
35
- store = @store
36
-
37
- GraphQL::ObjectType.define do
38
- name 'Query'
39
- description 'The query root of this schema'
40
-
41
- schema_types.each do |content_type, schema_type|
42
- field schema_type.name.to_sym do
43
- type schema_type
44
- argument :id, types.ID
45
- description "Find a #{schema_type.name} by ID"
46
-
47
- resolve ->(_obj, args, _ctx) {
48
- if args['id'].nil?
49
- store.find_by(content_type: content_type)
50
- else
51
- store.find(args['id'])
52
- end
53
- }
54
- end
55
-
56
- field "all#{schema_type.name}".to_sym do
57
- type schema_type.to_list_type
58
- argument :filter, Types::FilterType
59
-
60
- resolve ->(_obj, args, ctx) {
61
- relation = store.find_all(content_type: content_type)
62
- # TODO: improve this POC
63
- if args[:filter]
64
- filter = {}
65
- filter[args[:filter]['field']] = { eq: args[:filter][:eq] }
66
- relation = relation.apply(filter, ctx)
67
- end
68
- relation.result
69
- }
70
- end
71
- end
72
- end
73
- end
74
-
75
- def build_schema_types
76
- @types.each_with_object({}) do |(k, v), h|
77
- h[k] = build_schema_type(v)
78
- end
79
- end
80
-
81
- def build_schema_type(typedef)
82
- store = @store
83
- builder = self
84
- content_type = typedef.content_type
85
-
86
- GraphQL::ObjectType.define do
87
- name(typedef.name)
88
-
89
- description("Generated from content type #{content_type}")
90
-
91
- field :id, !types.ID do
92
- resolve ->(obj, _args, _ctx) {
93
- obj.dig('sys', 'id')
94
- }
95
- end
96
-
97
- field :_content_type, !types.String do
98
- resolve ->(_, _, _) {
99
- content_type
100
- }
101
- end
102
-
103
- # Make a field for each column:
104
- typedef.fields.each_value do |f|
105
- case f.type
106
- when :Asset
107
- field(f.name.to_sym, -> {
108
- type = builder.schema_types['Asset']
109
- type = type.to_list_type if f.array
110
- type
111
- }) do
112
- resolve ->(obj, _args, ctx) {
113
- links = obj.dig('fields', f.name, ctx[:locale] || 'en-US')
114
- return if links.nil?
115
-
116
- if links.is_a? Array
117
- links.reject(&:nil?).map { |l| store.find(l.dig('sys', 'id')) }
118
- else
119
- store.find(links.dig('sys', 'id'))
120
- end
121
- }
122
- end
123
- when :Link
124
- field(f.name.to_sym, -> {
125
- type =
126
- if f.link_types.nil? || f.link_types.empty?
127
- builder.schema_types['AnyContentful'] ||=
128
- Types::BuildUnionType.call(builder.schema_types, 'AnyContentful')
129
- elsif f.link_types.length == 1
130
- builder.schema_types[f.link_types.first]
131
- else
132
- from_types = builder.schema_types.select { |key| f.link_types.include?(key) }
133
- name = "#{typedef.name}_#{f.name}"
134
- builder.schema_types[name] ||= Types::BuildUnionType.call(from_types, name)
135
- end
136
- type = type.to_list_type if f.array
137
- type
138
- }) do
139
- resolve ->(obj, _args, ctx) {
140
- links = obj.dig('fields', f.name, ctx[:locale] || 'en-US')
141
- return if links.nil?
142
-
143
- if links.is_a? Array
144
- links.reject(&:nil?).map { |l| store.find(l.dig('sys', 'id')) }
145
- else
146
- store.find(links.dig('sys', 'id'))
147
- end
148
- }
149
- end
150
- else
151
- type =
152
- case f.type
153
- when :DateTime
154
- Types::DateTimeType
155
- when :Coordinates
156
- Types::CoordinatesType
157
- when :Json
158
- Types::HashType
159
- else
160
- types.public_send(f.type)
161
- end
162
- type = type.to_list_type if f.array
163
- field(f.name.to_sym, type) do
164
- resolve ->(obj, _args, ctx) {
165
- if obj.is_a? Array
166
- obj.map { |o| o.dig('fields', f.name, ctx[:locale] || 'en-US') }
167
- else
168
- obj.dig('fields', f.name, ctx[:locale] || 'en-US')
169
- end
170
- }
171
- end
172
- end
173
- end
174
- end
175
- end
176
- end
177
- end