rails-contact 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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/.github/dependabot.yml +12 -0
  3. data/.github/workflows/ci.yml +65 -0
  4. data/CHANGELOG.md +10 -0
  5. data/Gemfile +19 -0
  6. data/LICENSE.txt +21 -0
  7. data/MIT-LICENSE +21 -0
  8. data/README.md +71 -0
  9. data/Rakefile +3 -0
  10. data/app/assets/stylesheets/rails/contact/application.css +15 -0
  11. data/app/controllers/rails/contact/application_controller.rb +13 -0
  12. data/app/controllers/rails/contact/contacts_controller.rb +77 -0
  13. data/app/helpers/rails/contact/application_helper.rb +6 -0
  14. data/app/jobs/rails/contact/application_job.rb +6 -0
  15. data/app/jobs/rails/contact/google_sync_job.rb +13 -0
  16. data/app/jobs/rails/contact/index_contact_job.rb +18 -0
  17. data/app/mailers/rails/contact/application_mailer.rb +8 -0
  18. data/app/mailers/rails/contact/contact_mailer.rb +32 -0
  19. data/app/models/rails/contact/application_record.rb +7 -0
  20. data/app/models/rails/contact/contact.rb +32 -0
  21. data/app/models/rails/contact/contact_address.rb +9 -0
  22. data/app/models/rails/contact/contact_email.rb +24 -0
  23. data/app/models/rails/contact/contact_phone.rb +28 -0
  24. data/app/views/layouts/rails/contact/application.html.erb +17 -0
  25. data/app/views/rails/contact/contacts/_form.html.erb +61 -0
  26. data/app/views/rails/contact/contacts/edit.html.erb +3 -0
  27. data/app/views/rails/contact/contacts/index.html.erb +38 -0
  28. data/app/views/rails/contact/contacts/new.html.erb +3 -0
  29. data/app/views/rails/contact/contacts/show.html.erb +13 -0
  30. data/bin/rails +25 -0
  31. data/bin/rubocop +8 -0
  32. data/config/routes.rb +4 -0
  33. data/lib/generators/rails/contact/contact_generator.rb +29 -0
  34. data/lib/generators/rails/contact/install/install_generator.rb +19 -0
  35. data/lib/generators/rails/contact/install/templates/rails_contact.rb.tt +16 -0
  36. data/lib/generators/rails/contact/templates/create_rails_contact_contact_addresses.rb.tt +13 -0
  37. data/lib/generators/rails/contact/templates/create_rails_contact_contact_emails.rb.tt +15 -0
  38. data/lib/generators/rails/contact/templates/create_rails_contact_contact_phones.rb.tt +16 -0
  39. data/lib/generators/rails/contact/templates/create_rails_contact_contacts.rb.tt +24 -0
  40. data/lib/generators/rails/contact/views_generator.rb +15 -0
  41. data/lib/generators/rails_contact/rails_contact_generator.rb +7 -0
  42. data/lib/rails/contact/configuration.rb +25 -0
  43. data/lib/rails/contact/csv/import_service.rb +87 -0
  44. data/lib/rails/contact/engine.rb +21 -0
  45. data/lib/rails/contact/google/client.rb +55 -0
  46. data/lib/rails/contact/google/conflict_resolver.rb +20 -0
  47. data/lib/rails/contact/google/payload_mapper.rb +26 -0
  48. data/lib/rails/contact/google/sync_service.rb +34 -0
  49. data/lib/rails/contact/google/token_store.rb +31 -0
  50. data/lib/rails/contact/orm.rb +19 -0
  51. data/lib/rails/contact/routing.rb +11 -0
  52. data/lib/rails/contact/search/backends/database.rb +41 -0
  53. data/lib/rails/contact/search/backends/elasticsearch.rb +119 -0
  54. data/lib/rails/contact/search/query.rb +27 -0
  55. data/lib/rails/contact/version.rb +5 -0
  56. data/lib/rails/contact.rb +32 -0
  57. data/lib/tasks/rails/contact_tasks.rake +31 -0
  58. data/test/csv_import_service_test.rb +25 -0
  59. data/test/google_sync_service_test.rb +47 -0
  60. data/test/payload_mapper_test.rb +19 -0
  61. data/test/search_fallback_test.rb +25 -0
  62. data/test/test_helper.rb +79 -0
  63. metadata +192 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: da9e560dd16bfddaae191d2951cdb891d1c81a5c03e854fa608ce698bad0f4ed
4
+ data.tar.gz: be80125cfbca594d514a43701f0468a083868d74cb77a5a7e9fdb9bfa1b92cb3
5
+ SHA512:
6
+ metadata.gz: b2e6b507b3e9b1eb0bb4a06a9784ce5965937d3625a74b81749ea60785918f3afeb752f2ae8ca530ff47c4211907e49d9d91e0ea1cf281c8daefdabee92c0a55
7
+ data.tar.gz: 7ef76b8f5e22865f80b36a05665c3dd25105ccba061fb5810b6d79c3a530bb2d68d6d9fe1d4464383f76d33989666e1622db2754063e868c6ec9fa715b4f9152
@@ -0,0 +1,12 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: bundler
4
+ directory: "/"
5
+ schedule:
6
+ interval: weekly
7
+ open-pull-requests-limit: 10
8
+ - package-ecosystem: github-actions
9
+ directory: "/"
10
+ schedule:
11
+ interval: weekly
12
+ open-pull-requests-limit: 10
@@ -0,0 +1,65 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [ main ]
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ env:
12
+ RUBY_VERSION: "3.3"
13
+ RUBOCOP_CACHE_ROOT: tmp/rubocop
14
+ steps:
15
+ - name: Checkout code
16
+ uses: actions/checkout@v6
17
+
18
+ - name: Set up Ruby
19
+ uses: ruby/setup-ruby@v1
20
+ with:
21
+ ruby-version: ${{ env.RUBY_VERSION }}
22
+ bundler-cache: true
23
+
24
+ - name: Prepare RuboCop cache
25
+ uses: actions/cache@v4
26
+ env:
27
+ DEPENDENCIES_HASH: ${{ hashFiles('**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }}
28
+ with:
29
+ path: ${{ env.RUBOCOP_CACHE_ROOT }}
30
+ key: rubocop-${{ runner.os }}-${{ env.RUBY_VERSION }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }}
31
+ restore-keys: |
32
+ rubocop-${{ runner.os }}-${{ env.RUBY_VERSION }}-${{ env.DEPENDENCIES_HASH }}-
33
+
34
+ - name: Lint code for consistent style
35
+ run: bin/rubocop -f github
36
+
37
+ test:
38
+ runs-on: ubuntu-latest
39
+ strategy:
40
+ fail-fast: false
41
+ matrix:
42
+ ruby: ["3.2", "3.3"]
43
+ services:
44
+ elasticsearch:
45
+ image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4
46
+ env:
47
+ discovery.type: single-node
48
+ xpack.security.enabled: "false"
49
+ ports:
50
+ - 9200:9200
51
+ env:
52
+ ELASTICSEARCH_URL: http://127.0.0.1:9200
53
+ steps:
54
+ - name: Checkout code
55
+ uses: actions/checkout@v6
56
+
57
+ - name: Set up Ruby
58
+ uses: ruby/setup-ruby@v1
59
+ with:
60
+ ruby-version: ${{ matrix.ruby }}
61
+ bundler-cache: true
62
+
63
+ - name: Run tests
64
+ run: bundle exec ruby -Itest -e 'Dir["test/**/*_test.rb"].sort.each { |f| require File.expand_path(f) }'
65
+
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release of `rails-contact`.
6
+ - Mountable engine with contact CRUD.
7
+ - CSV import service for Google-shaped contact fields.
8
+ - Elasticsearch-backed search with database fallback.
9
+ - Google Contacts sync service scaffold with rolling-window support.
10
+ - Generators for install and migrations.
data/Gemfile ADDED
@@ -0,0 +1,19 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in rails-contact.gemspec.
4
+ gemspec
5
+
6
+ gem "puma"
7
+
8
+ gem "sqlite3"
9
+ gem "rack-test"
10
+ gem "minitest-reporters"
11
+ gem "mocha"
12
+ gem "webmock"
13
+ gem "parallel", "~> 1.26.0"
14
+
15
+ # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
16
+ gem "rubocop-rails-omakase", require: false
17
+
18
+ # Start debugger with binding.b [https://github.com/ruby/debug]
19
+ # gem "debug", ">= 1.0.0"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kshitiz Sinha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kshitiz Sinha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # rails-contact
2
+
3
+ `rails-contact` is a mountable Rails engine for Google-shaped contact management with:
4
+
5
+ - local source-of-truth data model
6
+ - Elasticsearch-powered search/filter (with DB fallback)
7
+ - CSV import pipeline
8
+ - capped rolling-window Google Contacts sync hooks
9
+
10
+ ## Install
11
+
12
+ ```ruby
13
+ gem "rails-contact"
14
+ ```
15
+
16
+ ```bash
17
+ bundle install
18
+ rails generate rails:contact:install
19
+ rails generate rails:contact:contact Contact
20
+ rails db:migrate
21
+ ```
22
+
23
+ ## Configure
24
+
25
+ Initializer: `config/initializers/rails_contact.rb`
26
+
27
+ ```ruby
28
+ Rails::Contact.configure do |config|
29
+ config.search_backend = :elasticsearch
30
+ config.elasticsearch_url = ENV.fetch("ELASTICSEARCH_URL", "http://127.0.0.1:9200")
31
+ config.google_sync_enabled = true
32
+ config.google_max_contacts = 25_000
33
+ config.rolling_window_sort = :updated_at
34
+ end
35
+ ```
36
+
37
+ ## Mount
38
+
39
+ ```ruby
40
+ mount Rails::Contact::Engine => "/contacts", as: "rails_contact"
41
+ ```
42
+
43
+ ## Features
44
+
45
+ - Contact CRUD with nested emails/phones/addresses
46
+ - Filter and full-text search by name, email, and phone
47
+ - CSV import mapping for fields like Enquirer First Name / Enquirer Email / country-code phones
48
+ - Google payload mapping and sync service scaffolding
49
+ - Reindex and sync tasks:
50
+ - `rake rails_contact:reindex`
51
+ - `rake rails_contact:sync_google`
52
+ - `rake rails_contact:import_csv CSV_PATH=/path/to/file.csv`
53
+
54
+ ## Test
55
+
56
+ ```bash
57
+ bundle exec ruby -Itest test/**/*_test.rb
58
+ ```
59
+
60
+ ## Release
61
+
62
+ ```bash
63
+ bundle exec rake build
64
+ bundle exec rake release
65
+ ```
66
+
67
+ Ensure RubyGems MFA is enabled for maintainers.
68
+
69
+ ## License
70
+
71
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,13 @@
1
+ module Rails
2
+ module Contact
3
+ class ApplicationController < ActionController::Base
4
+ protect_from_forgery with: :exception
5
+
6
+ private
7
+
8
+ def t_flash(key, default:)
9
+ I18n.t("rails_contact.flash.#{key}", default: default)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,77 @@
1
+ module Rails
2
+ module Contact
3
+ class ContactsController < ApplicationController
4
+ before_action :set_contact, only: [ :show, :edit, :update, :destroy ]
5
+
6
+ def index
7
+ @query = params[:q].to_s.strip
8
+ @contacts = Search::Query.new(@query, filters: filter_params).call
9
+ end
10
+
11
+ def show; end
12
+
13
+ def new
14
+ @contact = Contact.new
15
+ build_default_associations
16
+ end
17
+
18
+ def edit; end
19
+
20
+ def create
21
+ @contact = Contact.new(contact_params)
22
+ if @contact.save
23
+ enqueue_index(@contact.id)
24
+ redirect_to contact_path(@contact), notice: "Contact created."
25
+ else
26
+ render :new, status: :unprocessable_entity
27
+ end
28
+ end
29
+
30
+ def update
31
+ if @contact.update(contact_params)
32
+ enqueue_index(@contact.id)
33
+ redirect_to contact_path(@contact), notice: "Contact updated."
34
+ else
35
+ render :edit, status: :unprocessable_entity
36
+ end
37
+ end
38
+
39
+ def destroy
40
+ id = @contact.id
41
+ @contact.destroy!
42
+ IndexContactJob.perform_later(id, remove: true)
43
+ redirect_to contacts_path, notice: "Contact deleted."
44
+ end
45
+
46
+ private
47
+
48
+ def set_contact
49
+ @contact = Contact.find(params[:id])
50
+ build_default_associations
51
+ end
52
+
53
+ def contact_params
54
+ params.require(:contact).permit(
55
+ :given_name, :family_name, :current_city, :departure_city, :region_name, :biography, :sync_eligible,
56
+ emails_attributes: [ :id, :value, :label, :primary, :_destroy ],
57
+ phones_attributes: [ :id, :value, :label, :primary, :_destroy ],
58
+ addresses_attributes: [ :id, :city, :departure_city, :formatted_value, :label, :_destroy ]
59
+ )
60
+ end
61
+
62
+ def filter_params
63
+ params.permit(:city, :region, :sync_eligible)
64
+ end
65
+
66
+ def enqueue_index(contact_id)
67
+ IndexContactJob.perform_later(contact_id)
68
+ end
69
+
70
+ def build_default_associations
71
+ @contact.emails.build if @contact.emails.empty?
72
+ @contact.phones.build if @contact.phones.empty?
73
+ @contact.addresses.build if @contact.addresses.empty?
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,6 @@
1
+ module Rails
2
+ module Contact
3
+ module ApplicationHelper
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Rails
2
+ module Contact
3
+ class ApplicationJob < ActiveJob::Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,13 @@
1
+ module Rails
2
+ module Contact
3
+ class GoogleSyncJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform
7
+ return unless Rails::Contact.configuration.google_sync_enabled
8
+
9
+ Google::SyncService.new.sync!
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,18 @@
1
+ module Rails
2
+ module Contact
3
+ class IndexContactJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform(contact_id, remove: false)
7
+ backend = Search::Backends::Elasticsearch.new
8
+ return backend.remove(contact_id) if remove
9
+
10
+ contact = Contact.includes(:emails, :phones).find_by(id: contact_id)
11
+ return if contact.nil?
12
+
13
+ backend.create_index!
14
+ backend.upsert(contact)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,8 @@
1
+ module Rails
2
+ module Contact
3
+ class ApplicationMailer < ActionMailer::Base
4
+ default from: "from@example.com"
5
+ layout "mailer"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,32 @@
1
+ module Rails
2
+ module Contact
3
+ class ContactMailer < ApplicationMailer
4
+ def confirmation_instructions(email, token)
5
+ mail_for(email, "Confirmation instructions", token)
6
+ end
7
+
8
+ def reset_password_instructions(email, token)
9
+ mail_for(email, "Reset password instructions", token)
10
+ end
11
+
12
+ def unlock_instructions(email, token)
13
+ mail_for(email, "Unlock instructions", token)
14
+ end
15
+
16
+ def email_changed(email)
17
+ mail(to: email, subject: "Email changed notification")
18
+ end
19
+
20
+ def password_changed(email)
21
+ mail(to: email, subject: "Password changed notification")
22
+ end
23
+
24
+ private
25
+
26
+ def mail_for(email, subject, token)
27
+ Rails.logger.info("[rails-contact] #{subject} for #{email}: #{token}")
28
+ mail(to: email, subject: subject)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,7 @@
1
+ module Rails
2
+ module Contact
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,32 @@
1
+ module Rails
2
+ module Contact
3
+ class Contact < ApplicationRecord
4
+ self.table_name = "rails_contact_contacts"
5
+
6
+ has_many :emails, class_name: "Rails::Contact::ContactEmail", dependent: :destroy, inverse_of: :contact
7
+ has_many :phones, class_name: "Rails::Contact::ContactPhone", dependent: :destroy, inverse_of: :contact
8
+ has_many :addresses, class_name: "Rails::Contact::ContactAddress", dependent: :destroy, inverse_of: :contact
9
+
10
+ accepts_nested_attributes_for :emails, :phones, :addresses, allow_destroy: true
11
+
12
+ validates :given_name, presence: true
13
+
14
+ scope :recent_first, -> { order(updated_at: :desc, id: :desc) }
15
+ scope :sync_window, lambda {
16
+ recent_first.limit(Rails::Contact.configuration.google_max_contacts)
17
+ }
18
+
19
+ def full_name
20
+ [ given_name, family_name ].compact.join(" ").strip
21
+ end
22
+
23
+ def primary_email
24
+ emails.find(&:primary?) || emails.first
25
+ end
26
+
27
+ def primary_phone
28
+ phones.find(&:primary?) || phones.first
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,9 @@
1
+ module Rails
2
+ module Contact
3
+ class ContactAddress < ApplicationRecord
4
+ self.table_name = "rails_contact_contact_addresses"
5
+
6
+ belongs_to :contact, class_name: "Rails::Contact::Contact", inverse_of: :addresses
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ module Rails
2
+ module Contact
3
+ class ContactEmail < ApplicationRecord
4
+ self.table_name = "rails_contact_contact_emails"
5
+
6
+ belongs_to :contact, class_name: "Rails::Contact::Contact", inverse_of: :emails
7
+
8
+ validates :value, presence: true
9
+ validates :value, format: { with: URI::MailTo::EMAIL_REGEXP }
10
+
11
+ before_validation :normalize_value
12
+
13
+ def primary?
14
+ primary
15
+ end
16
+
17
+ private
18
+
19
+ def normalize_value
20
+ self.value = value.to_s.strip.downcase.presence
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,28 @@
1
+ require "phonelib"
2
+
3
+ module Rails
4
+ module Contact
5
+ class ContactPhone < ApplicationRecord
6
+ self.table_name = "rails_contact_contact_phones"
7
+
8
+ belongs_to :contact, class_name: "Rails::Contact::Contact", inverse_of: :phones
9
+
10
+ validates :value, presence: true
11
+
12
+ before_validation :normalize_value
13
+
14
+ def primary?
15
+ primary
16
+ end
17
+
18
+ private
19
+
20
+ def normalize_value
21
+ return if value.blank?
22
+
23
+ parsed = Phonelib.parse(value)
24
+ self.e164 = parsed.e164 || value
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Rails contact</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "rails/contact/application", media: "all" %>
11
+ </head>
12
+ <body>
13
+
14
+ <%= yield %>
15
+
16
+ </body>
17
+ </html>
@@ -0,0 +1,61 @@
1
+ <%= form_with model: contact, local: true do |f| %>
2
+ <% if contact.errors.any? %>
3
+ <div data-turbo-temporary="true">
4
+ <h2><%= pluralize(contact.errors.count, "error") %> prevented save</h2>
5
+ <ul>
6
+ <% contact.errors.full_messages.each do |message| %>
7
+ <li><%= message %></li>
8
+ <% end %>
9
+ </ul>
10
+ </div>
11
+ <% end %>
12
+
13
+ <p>
14
+ <%= f.label :given_name %>
15
+ <%= f.text_field :given_name %>
16
+ </p>
17
+ <p>
18
+ <%= f.label :family_name %>
19
+ <%= f.text_field :family_name %>
20
+ </p>
21
+ <p>
22
+ <%= f.label :current_city %>
23
+ <%= f.text_field :current_city %>
24
+ </p>
25
+ <p>
26
+ <%= f.label :departure_city %>
27
+ <%= f.text_field :departure_city %>
28
+ </p>
29
+ <p>
30
+ <%= f.label :region_name %>
31
+ <%= f.text_field :region_name %>
32
+ </p>
33
+ <p>
34
+ <%= f.label :biography %>
35
+ <%= f.text_area :biography, rows: 3 %>
36
+ </p>
37
+ <p>
38
+ <%= f.label :sync_eligible %>
39
+ <%= f.check_box :sync_eligible %>
40
+ </p>
41
+
42
+ <%= f.fields_for :emails do |email_fields| %>
43
+ <p>
44
+ <%= email_fields.label :value, "Email" %>
45
+ <%= email_fields.email_field :value %>
46
+ <%= email_fields.label :primary %>
47
+ <%= email_fields.check_box :primary %>
48
+ </p>
49
+ <% end %>
50
+
51
+ <%= f.fields_for :phones do |phone_fields| %>
52
+ <p>
53
+ <%= phone_fields.label :value, "Phone" %>
54
+ <%= phone_fields.text_field :value %>
55
+ <%= phone_fields.label :label %>
56
+ <%= phone_fields.text_field :label %>
57
+ </p>
58
+ <% end %>
59
+
60
+ <%= f.submit %>
61
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <h1>Edit Contact</h1>
2
+ <%= render "form", contact: @contact %>
3
+ <%= link_to "Back", contacts_path %>
@@ -0,0 +1,38 @@
1
+ <h1>Contacts</h1>
2
+
3
+ <%= form_with url: contacts_path, method: :get, local: true do |f| %>
4
+ <%= f.label :q, "Search" %>
5
+ <%= f.text_field :q, value: @query %>
6
+ <%= f.label :city, "Current City" %>
7
+ <%= f.text_field :city, value: params[:city] %>
8
+ <%= f.label :region, "Region" %>
9
+ <%= f.text_field :region, value: params[:region] %>
10
+ <%= f.submit "Filter" %>
11
+ <% end %>
12
+
13
+ <p><%= link_to "New Contact", new_contact_path %></p>
14
+
15
+ <table>
16
+ <thead>
17
+ <tr>
18
+ <th>Name</th>
19
+ <th>Email</th>
20
+ <th>Phone</th>
21
+ <th>Current City</th>
22
+ <th>Region</th>
23
+ <th></th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ <% @contacts.each do |contact| %>
28
+ <tr>
29
+ <td><%= contact.full_name %></td>
30
+ <td><%= contact.primary_email&.value %></td>
31
+ <td><%= contact.primary_phone&.e164 %></td>
32
+ <td><%= contact.current_city %></td>
33
+ <td><%= contact.region_name %></td>
34
+ <td><%= link_to "Show", contact_path(contact) %></td>
35
+ </tr>
36
+ <% end %>
37
+ </tbody>
38
+ </table>
@@ -0,0 +1,3 @@
1
+ <h1>New Contact</h1>
2
+ <%= render "form", contact: @contact %>
3
+ <%= link_to "Back", contacts_path %>