formblocks 0.1.0

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 (86) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +57 -0
  3. data/CHANGELOG.md +24 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +472 -0
  6. data/Rakefile +24 -0
  7. data/app/controllers/concerns/formblocks/request_context.rb +58 -0
  8. data/app/controllers/formblocks/application_controller.rb +16 -0
  9. data/app/controllers/formblocks/assets_controller.rb +27 -0
  10. data/app/controllers/formblocks/blocks_controller.rb +81 -0
  11. data/app/controllers/formblocks/dashboard_controller.rb +41 -0
  12. data/app/controllers/formblocks/forms_controller.rb +71 -0
  13. data/app/controllers/formblocks/pages_controller.rb +44 -0
  14. data/app/controllers/formblocks/public/forms_controller.rb +74 -0
  15. data/app/controllers/formblocks/responses_controller.rb +39 -0
  16. data/app/controllers/formblocks/settings_controller.rb +30 -0
  17. data/app/helpers/formblocks/application_helper.rb +93 -0
  18. data/app/models/concerns/formblocks/attachable.rb +43 -0
  19. data/app/models/formblocks/application_record.rb +7 -0
  20. data/app/models/formblocks/block.rb +177 -0
  21. data/app/models/formblocks/blocks/checkbox.rb +22 -0
  22. data/app/models/formblocks/blocks/email.rb +23 -0
  23. data/app/models/formblocks/blocks/heading.rb +13 -0
  24. data/app/models/formblocks/blocks/hidden.rb +25 -0
  25. data/app/models/formblocks/blocks/image.rb +14 -0
  26. data/app/models/formblocks/blocks/input.rb +59 -0
  27. data/app/models/formblocks/blocks/name.rb +9 -0
  28. data/app/models/formblocks/blocks/paragraph.rb +13 -0
  29. data/app/models/formblocks/blocks/phone.rb +13 -0
  30. data/app/models/formblocks/blocks/radio_group.rb +28 -0
  31. data/app/models/formblocks/blocks/text.rb +12 -0
  32. data/app/models/formblocks/blocks/textarea.rb +8 -0
  33. data/app/models/formblocks/blocks/url.rb +26 -0
  34. data/app/models/formblocks/csv_export.rb +47 -0
  35. data/app/models/formblocks/form.rb +197 -0
  36. data/app/models/formblocks/page.rb +79 -0
  37. data/app/models/formblocks/response.rb +48 -0
  38. data/app/models/formblocks/setting.rb +19 -0
  39. data/app/views/formblocks/blocks/_block.html.erb +102 -0
  40. data/app/views/formblocks/blocks/_palette.html.erb +16 -0
  41. data/app/views/formblocks/forms/_header.html.erb +19 -0
  42. data/app/views/formblocks/forms/edit.html.erb +38 -0
  43. data/app/views/formblocks/forms/index.html.erb +48 -0
  44. data/app/views/formblocks/forms/new.html.erb +26 -0
  45. data/app/views/formblocks/forms/published.html.erb +25 -0
  46. data/app/views/formblocks/forms/settings.html.erb +43 -0
  47. data/app/views/formblocks/pages/_page.html.erb +51 -0
  48. data/app/views/formblocks/public/blocks/_checkbox.html.erb +15 -0
  49. data/app/views/formblocks/public/blocks/_field.html.erb +13 -0
  50. data/app/views/formblocks/public/blocks/_heading.html.erb +1 -0
  51. data/app/views/formblocks/public/blocks/_hidden.html.erb +5 -0
  52. data/app/views/formblocks/public/blocks/_image.html.erb +3 -0
  53. data/app/views/formblocks/public/blocks/_paragraph.html.erb +1 -0
  54. data/app/views/formblocks/public/blocks/_radio_group.html.erb +17 -0
  55. data/app/views/formblocks/public/blocks/_text.html.erb +6 -0
  56. data/app/views/formblocks/public/blocks/_textarea.html.erb +5 -0
  57. data/app/views/formblocks/public/forms/not_found.html.erb +3 -0
  58. data/app/views/formblocks/public/forms/show.html.erb +37 -0
  59. data/app/views/formblocks/public/forms/thanks.html.erb +5 -0
  60. data/app/views/formblocks/responses/index.html.erb +45 -0
  61. data/app/views/formblocks/responses/show.html.erb +43 -0
  62. data/app/views/formblocks/settings/show.html.erb +19 -0
  63. data/app/views/formblocks/shared/_admin.html.erb +21 -0
  64. data/app/views/formblocks/shared/_brand_fields.html.erb +29 -0
  65. data/app/views/formblocks/shared/_errors.html.erb +7 -0
  66. data/app/views/layouts/formblocks/application.html.erb +18 -0
  67. data/app/views/layouts/formblocks/public.html.erb +20 -0
  68. data/config/locales/formblocks.en.yml +188 -0
  69. data/config/routes.rb +35 -0
  70. data/lib/formblocks/assets/admin.css +212 -0
  71. data/lib/formblocks/assets/admin.js +215 -0
  72. data/lib/formblocks/assets/public.css +66 -0
  73. data/lib/formblocks/assets/public.js +86 -0
  74. data/lib/formblocks/assets.rb +81 -0
  75. data/lib/formblocks/configuration.rb +93 -0
  76. data/lib/formblocks/engine.rb +33 -0
  77. data/lib/formblocks/seeds.rb +50 -0
  78. data/lib/formblocks/templates.rb +97 -0
  79. data/lib/formblocks/version.rb +5 -0
  80. data/lib/formblocks.rb +74 -0
  81. data/lib/generators/formblocks/install/install_generator.rb +43 -0
  82. data/lib/generators/formblocks/install/templates/create_formblocks_tables.rb.tt +65 -0
  83. data/lib/generators/formblocks/install/templates/initializer.rb.tt +69 -0
  84. data/lib/generators/formblocks/migration_helpers.rb +40 -0
  85. data/lib/tasks/formblocks_tasks.rake +10 -0
  86. metadata +199 -0
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ # Host-tunable settings. Everything has a safe default, so a fresh install
5
+ # works with zero configuration; the hooks below let an app decide who may
6
+ # build forms, which tenant they belong to, and what happens on submit.
7
+ class Configuration
8
+ # The gem's own admin layout. Compared against, so DashboardController can
9
+ # tell "the host left this alone" from "the host chose this".
10
+ DEFAULT_ADMIN_LAYOUT = 'formblocks/application'
11
+
12
+ # Your product's name, shown in page titles ("Contact form · Nusii") and
13
+ # as the alt text of a logo. nil resolves to the Rails application name.
14
+ attr_accessor :app_name
15
+
16
+ # Per-request gate for the builder, responses and settings. Defaults to
17
+ # development only — override it before deploying, e.g. with an admin check.
18
+ attr_accessor :authorize_admin
19
+
20
+ # Layout used by the admin pages. Override this to render the builder
21
+ # inside your app's admin shell, e.g. "admin/application".
22
+ attr_accessor :admin_layout
23
+
24
+ # The controller the ADMIN inherits from, as a String so it resolves
25
+ # lazily. Default: a plain 'ActionController::Base', where
26
+ # `authorize_admin` is the only gate. Name the controller your own admin
27
+ # already inherits from and the builder adopts that whole stack — layout,
28
+ # helpers, authentication, request context.
29
+ #
30
+ # Only the admin uses it. The public form pages stay on the engine's own
31
+ # controller, so an admin base controller here can never demand a staff
32
+ # session from a visitor filling in a form.
33
+ attr_accessor :base_controller_class
34
+
35
+ # Resolve the current tenant (optional, for multi-tenant apps). Return an
36
+ # opaque key — a GlobalID, an id, a subdomain, a slug — or nil. Receives
37
+ # the request. nil (the default) is a single, global collection. Forms,
38
+ # responses and the settings all scope to whatever this returns.
39
+ attr_accessor :tenant
40
+
41
+ # Called with each saved Formblocks::Response — notify Slack, send an
42
+ # email, create a lead. Runs inline after save; keep it fast or hand off
43
+ # to a job.
44
+ attr_accessor :on_submit
45
+
46
+ # Logo and image-block uploads. Requires Active Storage in the host; set
47
+ # false to hide every upload even when Active Storage is loaded.
48
+ attr_accessor :attachments
49
+ attr_accessor :max_upload_size
50
+
51
+ # The Active Storage service that stores uploads — form logos, the global
52
+ # logo, image blocks — as a service name from the host's
53
+ # config/storage.yml (e.g. a dedicated bucket or folder, or an entry with
54
+ # provider options like Cloudinary's folder/tags). nil, the default, uses
55
+ # the environment's default service. Read when the models load, so set it
56
+ # in an initializer.
57
+ attr_accessor :storage_service
58
+
59
+ # Per-IP throttle for the public submit endpoint, as keyword arguments for
60
+ # Rails' rate limiter. Read once when the controller loads — set it in an
61
+ # initializer. nil disables throttling.
62
+ attr_accessor :rate_limit
63
+
64
+ # Brand defaults when neither the form nor the settings page set a color.
65
+ attr_accessor :default_primary_color, :default_button_text_color
66
+
67
+ # Where the admin engine is mounted, and where public forms live. Both are
68
+ # written by `mount_formblocks`; set them here only if you mount manually.
69
+ attr_accessor :mount_path, :public_path
70
+
71
+ # Form templates offered on the "New form" page, keyed by an identifier.
72
+ # See Formblocks::Templates for the built-in ones and the definition shape.
73
+ attr_reader :templates
74
+
75
+ def initialize
76
+ @app_name = nil
77
+ @authorize_admin = ->(_request) { Rails.env.development? }
78
+ @admin_layout = DEFAULT_ADMIN_LAYOUT
79
+ @base_controller_class = 'ActionController::Base'
80
+ @tenant = ->(_request) {}
81
+ @on_submit = ->(_response) {}
82
+ @attachments = true
83
+ @max_upload_size = 5 * 1024 * 1024
84
+ @storage_service = nil
85
+ @rate_limit = { to: 10, within: 60 }
86
+ @default_primary_color = '#111827'
87
+ @default_button_text_color = '#ffffff'
88
+ @mount_path = '/forms'
89
+ @public_path = '/f'
90
+ @templates = Templates.builtin
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Formblocks
6
+
7
+ rake_tasks do
8
+ load File.expand_path('../tasks/formblocks_tasks.rake', __dir__)
9
+ end
10
+
11
+ # The public form pages are drawn on the HOST's route set, not the
12
+ # engine's, so they can live at a path of the host's choosing that is
13
+ # separate from the admin mount. `mount_formblocks` does both in one line.
14
+ #
15
+ # The mount is named `formblocks` on purpose: the engine's views reach
16
+ # their own assets through that proxy even when a request came in through
17
+ # a host route (the public pages) or a host layout.
18
+ initializer 'formblocks.routing' do
19
+ ActionDispatch::Routing::Mapper.include(Module.new do
20
+ def mount_formblocks(at: Formblocks.config.mount_path, public_at: Formblocks.config.public_path, **options)
21
+ Formblocks.config.mount_path = at
22
+ Formblocks.config.public_path = public_at
23
+ mount Formblocks::Engine, at:, as: :formblocks, **options
24
+
25
+ public_at = public_at.to_s.chomp('/')
26
+ get "#{public_at}/:slug", to: 'formblocks/public/forms#show', as: :formblocks_form
27
+ post "#{public_at}/:slug", to: 'formblocks/public/forms#create'
28
+ get "#{public_at}/:slug/thanks", to: 'formblocks/public/forms#thanks', as: :formblocks_form_thanks
29
+ end
30
+ end)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ # Demo data for trying the gem out: three forms built from the built-in
5
+ # templates — two published, with a few responses each, and one draft.
6
+ # Idempotent: running it again refreshes the same forms (found by slug)
7
+ # instead of duplicating them.
8
+ module Seeds
9
+ SLUGS = { 'contact' => 'demo-contact', 'lead' => 'demo-lead-capture', 'feedback' => 'demo-feedback' }.freeze
10
+
11
+ RESPONSES = {
12
+ 'demo-contact' => [
13
+ { 'your_name' => 'Ada Lovelace', 'email_address' => 'ada@example.com',
14
+ 'message' => 'Could you send over the enterprise pricing?' },
15
+ { 'your_name' => 'Grace Hopper', 'email_address' => 'grace@example.com',
16
+ 'message' => "Loved the demo.\nWhen does the API ship?" }
17
+ ],
18
+ 'demo-lead-capture' => [
19
+ { 'full_name' => 'Linus Torvalds', 'work_email' => 'linus@example.com', 'team_size' => 'Just me',
20
+ 'send_me_product_updates_by_email' => '1' },
21
+ { 'full_name' => 'Margaret Hamilton', 'work_email' => 'margaret@example.com',
22
+ 'phone_number' => '+1 617 555 0100', 'company_website' => 'https://apollo.example', 'team_size' => '51+',
23
+ 'what_are_you_hoping_to_solve' => 'Onboarding for a distributed team.' },
24
+ { 'full_name' => 'Ken Thompson', 'work_email' => 'ken@example.com', 'team_size' => '2–10' }
25
+ ]
26
+ }.freeze
27
+
28
+ def self.load!(tenant: nil)
29
+ forms = SLUGS.to_h do |template, slug|
30
+ definition = Templates.builtin.fetch(template)
31
+ form = Form.find_by(slug:) || Form.from_template(definition, tenant:).tap { |f| f.slug = slug }
32
+ form.tenant = tenant
33
+ form.save!
34
+ template == 'feedback' ? form.unpublish! : form.publish!
35
+ [slug, form]
36
+ end
37
+
38
+ responses = RESPONSES.flat_map do |slug, answers_list|
39
+ form = forms.fetch(slug)
40
+ form.responses.destroy_all
41
+ answers_list.map do |answers|
42
+ form.responses.new(page_url: 'https://example.com/pricing', locale: 'en',
43
+ user_agent: 'Mozilla/5.0 (demo)').fill(answers).tap(&:save!)
44
+ end
45
+ end
46
+
47
+ { forms: forms.values, responses: }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ # Form templates offered on the "New form" page. A template is a plain hash:
5
+ #
6
+ # {
7
+ # title: 'Contact form',
8
+ # description: 'Name, email and a message.',
9
+ # pages: [
10
+ # { button_text: 'Send',
11
+ # blocks: [
12
+ # { type: 'heading', content: 'Get in touch' },
13
+ # { type: 'email', label: 'Email', required: true }
14
+ # ] }
15
+ # ],
16
+ # thank_you: { blocks: [{ type: 'heading', content: 'Thanks!' }] }
17
+ # }
18
+ #
19
+ # `type` is a block kind (see Formblocks::Block.kinds); the other keys are
20
+ # block attributes. Hosts add their own with
21
+ # `Formblocks.config.templates['webinar'] = { ... }` and remove a built-in
22
+ # with `Formblocks.config.templates.delete('contact')`.
23
+ module Templates
24
+ def self.builtin
25
+ {
26
+ 'contact' => {
27
+ title: 'Contact form',
28
+ description: 'Name, email and a message. The classic.',
29
+ pages: [
30
+ { button_text: 'Send message',
31
+ blocks: [
32
+ { type: 'heading', content: 'Get in touch' },
33
+ { type: 'paragraph', content: 'Leave us a message and we will get back to you within a day.' },
34
+ { type: 'name', label: 'Your name', required: true },
35
+ { type: 'email', label: 'Email address', required: true },
36
+ { type: 'textarea', label: 'Message', required: true, placeholder: 'How can we help?' }
37
+ ] }
38
+ ],
39
+ thank_you: {
40
+ blocks: [
41
+ { type: 'heading', content: 'Thanks for reaching out!' },
42
+ { type: 'paragraph', content: 'We have received your message and will reply soon.' }
43
+ ]
44
+ }
45
+ },
46
+ 'lead' => {
47
+ title: 'Lead capture',
48
+ description: 'Two steps: contact details first, then a few qualifying questions.',
49
+ pages: [
50
+ { button_text: 'Continue',
51
+ blocks: [
52
+ { type: 'heading', content: 'Request a demo' },
53
+ { type: 'name', label: 'Full name', required: true },
54
+ { type: 'email', label: 'Work email', required: true },
55
+ { type: 'phone', label: 'Phone number' },
56
+ { type: 'url', label: 'Company website' }
57
+ ] },
58
+ { button_text: 'Request demo',
59
+ blocks: [
60
+ { type: 'heading', content: 'A bit about your team' },
61
+ { type: 'radio_group', label: 'Team size', required: true,
62
+ options: ['Just me', '2–10', '11–50', '51+'] },
63
+ { type: 'textarea', label: 'What are you hoping to solve?' },
64
+ { type: 'checkbox', label: 'Send me product updates by email' }
65
+ ] }
66
+ ],
67
+ thank_you: {
68
+ blocks: [
69
+ { type: 'heading', content: 'Thank you!' },
70
+ { type: 'paragraph', content: 'We will be in touch shortly to schedule your demo.' }
71
+ ]
72
+ }
73
+ },
74
+ 'feedback' => {
75
+ title: 'Feedback survey',
76
+ description: 'A quick rating and an open comment.',
77
+ pages: [
78
+ { button_text: 'Send feedback',
79
+ blocks: [
80
+ { type: 'heading', content: 'How are we doing?' },
81
+ { type: 'radio_group', label: 'How satisfied are you?', required: true,
82
+ options: ['Very satisfied', 'Satisfied', 'Neutral', 'Unsatisfied', 'Very unsatisfied'] },
83
+ { type: 'textarea', label: 'What could we do better?' },
84
+ { type: 'email', label: 'Email, if you would like a reply' }
85
+ ] }
86
+ ],
87
+ thank_you: {
88
+ blocks: [
89
+ { type: 'heading', content: 'Thanks for your feedback!' },
90
+ { type: 'paragraph', content: 'Every answer helps us improve.' }
91
+ ]
92
+ }
93
+ }
94
+ }
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ VERSION = '0.1.0'
5
+ end
data/lib/formblocks.rb ADDED
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'turbo-rails'
4
+ require 'stimulus-rails'
5
+
6
+ require 'formblocks/version'
7
+ require 'formblocks/configuration'
8
+ require 'formblocks/assets'
9
+ require 'formblocks/templates'
10
+ require 'formblocks/seeds'
11
+ require 'formblocks/engine'
12
+
13
+ # Formblocks: a block-based form builder for Rails. Forms are built from
14
+ # blocks (content and inputs) spread over pages, published at a public URL,
15
+ # and their responses land in your own database with a dashboard and CSV export.
16
+ module Formblocks
17
+ class << self
18
+ def config
19
+ @config ||= Configuration.new
20
+ end
21
+
22
+ def configure
23
+ yield config
24
+ end
25
+
26
+ # The class the admin controllers inherit from. Resolved on every call
27
+ # rather than memoized, so a host that reassigns base_controller_class in
28
+ # a reloadable initializer is not pinned to a stale, unloaded constant.
29
+ def base_controller
30
+ config.base_controller_class.to_s.constantize
31
+ end
32
+
33
+ # Can this request use the builder and read responses? Checked by every
34
+ # admin action, and by the public page to allow previews of drafts.
35
+ def admin?(request)
36
+ !!config.authorize_admin.call(request)
37
+ end
38
+
39
+ # The tenant key for this request, or nil for the single global collection.
40
+ # Normalized to a string so `where(tenant:)` is consistent whether the
41
+ # resolver returns a GlobalID, an integer id, or a slug.
42
+ def tenant(request)
43
+ config.tenant.call(request).presence&.to_s
44
+ end
45
+
46
+ # Forms belonging to a host record, keyed by its GlobalID.
47
+ def for(record)
48
+ Form.for_tenant(tenant_key_for(record))
49
+ end
50
+
51
+ # The product name for titles and alt text: config.app_name, else the
52
+ # Rails application's module name, verbatim ("Nusii", "SupeRails").
53
+ def app_name
54
+ config.app_name.presence || rails_app_name
55
+ end
56
+
57
+ # Logo and image uploads need Active Storage loaded in the host.
58
+ def attachments?
59
+ config.attachments && defined?(::ActiveStorage) ? true : false
60
+ end
61
+
62
+ private
63
+
64
+ def rails_app_name
65
+ Rails.application.class.module_parent_name
66
+ rescue StandardError
67
+ 'this app'
68
+ end
69
+
70
+ def tenant_key_for(record)
71
+ record.respond_to?(:to_gid) ? record.to_gid.to_s : record.to_s
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+ require_relative '../migration_helpers'
6
+
7
+ module Formblocks
8
+ module Generators
9
+ class InstallGenerator < Rails::Generators::Base
10
+ include ActiveRecord::Generators::Migration
11
+ include MigrationHelpers
12
+
13
+ source_root File.expand_path('templates', __dir__)
14
+
15
+ desc 'Installs formblocks: config initializer, migration, and routes.'
16
+
17
+ class_option :mount_path, type: :string, default: '/forms',
18
+ desc: 'Where the admin (builder, responses, settings) is mounted'
19
+ class_option :public_path, type: :string, default: '/f',
20
+ desc: 'Where published forms are served'
21
+
22
+ def create_initializer
23
+ template 'initializer.rb.tt', 'config/initializers/formblocks.rb'
24
+ end
25
+
26
+ def create_migration_file
27
+ migration_template 'create_formblocks_tables.rb.tt',
28
+ 'db/migrate/create_formblocks_tables.rb'
29
+ end
30
+
31
+ def mount_engine
32
+ route %(mount_formblocks at: "#{options[:mount_path]}", public_at: "#{options[:public_path]}")
33
+ end
34
+
35
+ def post_install
36
+ say "\nformblocks installed. Run `bin/rails db:migrate`.", :green
37
+ say "Build forms at #{options[:mount_path]} (development only until you set config.authorize_admin)."
38
+ say "Published forms are served at #{options[:public_path]}/<slug>."
39
+ say "Logo and image uploads need Active Storage: `bin/rails active_storage:install` if you have not.\n"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateFormblocksTables < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :formblocks_settings<%= primary_key_type_option %> do |t|
6
+ t.string :tenant # opaque per-tenant key; nil = single global collection
7
+ t.string :primary_color
8
+ t.string :button_text_color
9
+
10
+ t.timestamps
11
+ end
12
+ add_index :formblocks_settings, :tenant, unique: true
13
+
14
+ create_table :formblocks_forms<%= primary_key_type_option %> do |t|
15
+ t.string :title, null: false
16
+ t.string :slug, null: false
17
+ t.datetime :published_at
18
+ t.string :primary_color
19
+ t.string :button_text_color
20
+ t.string :tenant
21
+ t.integer :responses_count, null: false, default: 0
22
+
23
+ t.timestamps
24
+ end
25
+ add_index :formblocks_forms, :slug, unique: true
26
+ add_index :formblocks_forms, :tenant
27
+
28
+ create_table :formblocks_pages<%= primary_key_type_option %> do |t|
29
+ t.references :form, null: false<%= foreign_key_type_option %>, foreign_key: { to_table: :formblocks_forms }
30
+ t.string :kind, null: false, default: 'step'
31
+ t.integer :position, null: false, default: 0
32
+ t.string :button_text
33
+
34
+ t.timestamps
35
+ end
36
+
37
+ create_table :formblocks_blocks<%= primary_key_type_option %> do |t|
38
+ t.references :page, null: false<%= foreign_key_type_option %>, foreign_key: { to_table: :formblocks_pages }
39
+ t.string :type, null: false
40
+ t.integer :position, null: false, default: 0
41
+ t.string :key
42
+ t.string :label
43
+ t.string :placeholder
44
+ t.text :help_text
45
+ t.text :content
46
+ t.boolean :required, null: false, default: false
47
+ t.json :options
48
+ t.json :settings
49
+
50
+ t.timestamps
51
+ end
52
+
53
+ create_table :formblocks_responses<%= primary_key_type_option %> do |t|
54
+ t.references :form, null: false<%= foreign_key_type_option %>, foreign_key: { to_table: :formblocks_forms }
55
+ t.string :tenant
56
+ t.json :answers
57
+ t.string :page_url
58
+ t.string :user_agent
59
+ t.string :locale
60
+
61
+ t.timestamps
62
+ end
63
+ add_index :formblocks_responses, %i[tenant created_at]
64
+ end
65
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ Formblocks.configure do |config|
4
+ # Your product's name, shown in page titles ("Contact form · Nusii") and
5
+ # as the alt text of your logo. Defaults to your Rails application name.
6
+ # config.app_name = "My App"
7
+
8
+ # Who can build forms, read responses and change settings at the mount
9
+ # path. Receives the raw request. Defaults to development only — the admin
10
+ # answers 403 everywhere else until you override this.
11
+ #
12
+ # Devise / Warden:
13
+ # config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
14
+ #
15
+ # Rails 8 built-in auth (bin/rails generate authentication):
16
+ # config.authorize_admin = lambda do |request|
17
+ # token = request.cookies["session_id"]
18
+ # Session.find_signed(token)&.user&.admin? if token
19
+ # end
20
+ #
21
+ # Two ways to put the builder inside an admin you already have.
22
+ #
23
+ # The layout only — the gem's controllers, your shell:
24
+ # config.admin_layout = "admin/application"
25
+ #
26
+ # Or the whole stack. Name the controller your own admin inherits from and
27
+ # the builder picks up its layout, helpers, authentication and any request
28
+ # context its before_actions set up. Only the admin inherits it; the public
29
+ # form pages stay on the engine's own controller.
30
+ # config.base_controller_class = "Admin::BaseController"
31
+
32
+ # Multi-tenancy (optional). Scope forms, responses and settings to a
33
+ # tenant — each Account/Organization its own set of forms. Return an
34
+ # opaque key (nil = one global collection, the default). A GlobalID is the
35
+ # recommended key; an id, subdomain or slug work too.
36
+ # config.tenant = ->(request) { Current.account&.to_gid&.to_s }
37
+
38
+ # Called with each saved Formblocks::Response — notify Slack, send an
39
+ # email, create a lead. `response.answers` is a hash keyed by block key
40
+ # ("email", "name", ...); `response.form` is the form it came from.
41
+ # config.on_submit = ->(response) {}
42
+
43
+ # Logo and image uploads (requires Active Storage).
44
+ # config.attachments = true
45
+ # config.max_upload_size = 5.megabytes
46
+
47
+ # Store uploads (logos, image blocks) on a specific Active Storage service
48
+ # from config/storage.yml — e.g. a dedicated bucket or folder, or a service
49
+ # entry carrying provider options like Cloudinary's `folder:` / `tags:` —
50
+ # to keep form media apart from the rest of your library. Default: your
51
+ # environment's default service.
52
+ # config.storage_service = :formblocks
53
+
54
+ # Brand defaults, used when neither the form nor the settings page set one.
55
+ # config.default_primary_color = "#111827"
56
+ # config.default_button_text_color = "#ffffff"
57
+
58
+ # Form templates on the "New form" page. Add your own or drop a built-in.
59
+ # config.templates["webinar"] = { title: "Webinar signup", pages: [...] }
60
+ # config.templates.delete("feedback")
61
+
62
+ # Per-IP throttle for the public submit endpoint. nil disables it.
63
+ # config.rate_limit = { to: 10, within: 1.minute }
64
+
65
+ # Paths, written by `mount_formblocks` in routes.rb. Set these only if you
66
+ # mount the engine manually.
67
+ # config.mount_path = "<%= options[:mount_path] %>"
68
+ # config.public_path = "<%= options[:public_path] %>"
69
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ module Generators
5
+ # Shared bits every migration-writing generator needs.
6
+ module MigrationHelpers
7
+ private
8
+
9
+ def migration_version
10
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
11
+ end
12
+
13
+ # Follow the host's own key type instead of forcing bigint. An app that
14
+ # keys its models with uuids sets this, and its
15
+ # `active_storage_attachments.record_id` is then a uuid column — bigint
16
+ # tables here could never take a logo or image attachment.
17
+ #
18
+ # Same lookup Rails' own Active Storage, Action Text and Action Mailbox
19
+ # migrations do, so a host that set it once gets consistent tables from
20
+ # all of them. Rendered as a `create_table` option rather than a bare
21
+ # value, because a template is expanded at generate time.
22
+ def primary_key_type_option
23
+ type = primary_key_type
24
+ type ? ", id: :#{type}" : ''
25
+ end
26
+
27
+ # The matching option for `t.references`, so foreign keys point at the
28
+ # right column type.
29
+ def foreign_key_type_option
30
+ type = primary_key_type
31
+ type ? ", type: :#{type}" : ''
32
+ end
33
+
34
+ def primary_key_type
35
+ config = Rails.configuration.generators
36
+ config.options[config.orm][:primary_key_type]
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :formblocks do
4
+ desc 'Create or refresh formblocks demo data: three template forms and a few responses'
5
+ task seed_demo: :environment do
6
+ result = Formblocks::Seeds.load!
7
+ puts "Seeded #{result[:forms].size} forms and #{result[:responses].size} responses."
8
+ result[:forms].each { |form| puts " #{form.status.ljust(9)} #{form.title} (#{form.slug})" }
9
+ end
10
+ end