rails-contact 0.1.1 → 0.1.3

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.rspec +2 -0
  4. data/CHANGELOG.md +14 -0
  5. data/Gemfile +3 -0
  6. data/README.md +103 -39
  7. data/app/assets/javascripts/rails/contact/application.js +1 -0
  8. data/app/assets/javascripts/rails/contact/nested_fields.js +46 -0
  9. data/app/controllers/concerns/rails/contact/filterable.rb +11 -0
  10. data/app/controllers/rails/contact/contacts_controller.rb +55 -11
  11. data/app/helpers/rails/contact/application_helper.rb +7 -0
  12. data/app/models/rails/contact/contact.rb +32 -1
  13. data/app/models/rails/contact/contact_event.rb +11 -0
  14. data/app/models/rails/contact/contact_label.rb +12 -0
  15. data/app/models/rails/contact/contact_website.rb +11 -0
  16. data/app/models/rails/contact/label.rb +20 -0
  17. data/app/views/layouts/rails/contact/application.html.erb +1 -0
  18. data/app/views/rails/contact/_address_fields.html.erb +18 -0
  19. data/app/views/rails/contact/_email_fields.html.erb +18 -0
  20. data/app/views/rails/contact/_event_fields.html.erb +18 -0
  21. data/app/views/rails/contact/_form.html.erb +186 -0
  22. data/app/views/rails/contact/_phone_fields.html.erb +18 -0
  23. data/app/views/rails/contact/_website_fields.html.erb +14 -0
  24. data/app/views/rails/contact/{contacts/edit.html.erb → edit.html.erb} +1 -1
  25. data/app/views/rails/contact/index.html.erb +88 -0
  26. data/app/views/rails/contact/{contacts/new.html.erb → new.html.erb} +1 -1
  27. data/app/views/rails/contact/show.html.erb +55 -0
  28. data/config/routes.rb +2 -0
  29. data/docs/migration_guide.md +44 -0
  30. data/docs/parity_matrix.md +57 -0
  31. data/docs/product_decisions.md +36 -0
  32. data/docs/roadmap.md +23 -0
  33. data/lib/generators/rails/contact/contact_generator.rb +4 -0
  34. data/lib/generators/rails/contact/templates/create_rails_contact_contact_events.rb.tt +12 -0
  35. data/lib/generators/rails/contact/templates/create_rails_contact_contact_labels.rb.tt +12 -0
  36. data/lib/generators/rails/contact/templates/create_rails_contact_contact_websites.rb.tt +11 -0
  37. data/lib/generators/rails/contact/templates/create_rails_contact_contacts.rb.tt +2 -0
  38. data/lib/generators/rails/contact/templates/create_rails_contact_labels.rb.tt +11 -0
  39. data/lib/rails/contact/merge_contacts_service.rb +66 -0
  40. data/lib/rails/contact/routing.rb +8 -2
  41. data/lib/rails/contact/search/backends/database.rb +7 -3
  42. data/lib/rails/contact/search/backends/elasticsearch.rb +13 -1
  43. data/lib/rails/contact/version.rb +1 -1
  44. data/lib/rails/contact.rb +2 -0
  45. data/spec/concerns/filterable_spec.rb +16 -0
  46. data/spec/controllers/contacts_controller_spec.rb +26 -0
  47. data/spec/factories/contact_factories.rb +13 -0
  48. data/spec/helpers/application_helper_spec.rb +13 -0
  49. data/spec/models/contact_spec.rb +60 -0
  50. data/{test/test_helper.rb → spec/rails_helper.rb} +52 -8
  51. data/spec/services/merge_contacts_service_spec.rb +37 -0
  52. data/spec/spec_helper.rb +22 -0
  53. data/spec/views/contact_templates_spec.rb +17 -0
  54. metadata +37 -11
  55. data/app/views/rails/contact/contacts/_form.html.erb +0 -61
  56. data/app/views/rails/contact/contacts/index.html.erb +0 -38
  57. data/app/views/rails/contact/contacts/show.html.erb +0 -13
  58. data/test/csv_import_service_test.rb +0 -25
  59. data/test/google_sync_service_test.rb +0 -47
  60. data/test/payload_mapper_test.rb +0 -19
  61. data/test/search_fallback_test.rb +0 -25
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3f8cf546cda0f081e8da876f17377f8d71f384c41c868cfda1d19af009c6fd63
4
- data.tar.gz: 307ce9285a133f372d09aa503235620ab669a6ef94a60cbdd132486dc3086cc5
3
+ metadata.gz: 96bbf7fb23c7f016bf4ca07ef9713a0f2412f1236efeb11b20d978f3e8c809cc
4
+ data.tar.gz: c16b7c6f1258b827468f1f816b67bfe6bb77ad7e7fc2ceafb36a5197b5923bfe
5
5
  SHA512:
6
- metadata.gz: 721d2f5d8a7b111d6139069bd9af461278e385dca6080fbed28521fc582ba9ad8a4ecee3f1d4b6f5f9bae241ce94f22a18f61757b16aa290ec9c1bd6e495dec1
7
- data.tar.gz: 15b1587cd03cdc1b5d090b7f972c2c2d84e9313d7f7ae07e070d86bf14bbcd630ed4fb1e390d2a92cc164a5564663edbc59e4a296616195478053b53bb14047d
6
+ metadata.gz: c97455fedc7043f89e3a3ffcb5ebcc38a80d68ef597df76b2c4a25fb506c76d2c6e1dd577ffb071b4c4cd6df5de3f5c773b7ce5fad8bdcb51b9b72468293c4de
7
+ data.tar.gz: a16a9da2acbccebe3aaeac7371c5023f511278b6c8f88a2f5b629b8d2aada0336b5e68b2f327bd53c0f8844d99b5a22fb05f6cb754e46d9fe8fc1f188df17db0
@@ -61,5 +61,5 @@ jobs:
61
61
  bundler-cache: true
62
62
 
63
63
  - name: Run tests
64
- run: bundle exec ruby -Itest -e 'Dir["test/**/*_test.rb"].sort.each { |f| require File.expand_path(f) }'
64
+ run: bundle exec rspec --format progress
65
65
 
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --format documentation
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.3
4
+
5
+ - Flatten view partial paths under `app/views/rails/contact` and remove legacy `contacts/` partial nesting.
6
+ - Add `.gitignore` to keep generated artifacts (including `coverage/`) out of release commits.
7
+ - Update packaging/docs metadata for smoother RubyGems release workflow.
8
+
9
+ ## 0.1.2
10
+
11
+ - Breaking parity-focused rewrite foundation for richer Google-like contacts.
12
+ - Added label, website, event, merge, and bulk-delete capabilities.
13
+ - Added dynamic nested field add/remove behavior for multi-value rows.
14
+ - Introduced RSpec suite with SimpleCov gates (100% line/branch in tracked critical files).
15
+ - Added parity matrix, roadmap, and migration documentation.
16
+
3
17
  ## 0.1.1
4
18
 
5
19
  - Fix mounted route shape to avoid `/contacts/contacts` duplication.
data/Gemfile CHANGED
@@ -11,6 +11,9 @@ gem "minitest-reporters"
11
11
  gem "mocha"
12
12
  gem "webmock"
13
13
  gem "parallel", "~> 1.26.0"
14
+ gem "rspec-rails", "~> 8.0"
15
+ gem "factory_bot"
16
+ gem "simplecov", require: false
14
17
 
15
18
  # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
16
19
  gem "rubocop-rails-omakase", require: false
data/README.md CHANGED
@@ -1,70 +1,113 @@
1
1
  # rails-contact
2
2
 
3
- `rails-contact` is a mountable Rails engine to manage contacts in your app, with optional Elasticsearch search and Google Contacts sync.
3
+ `rails-contact` is a mountable Rails engine for Google-Contacts-style contact management.
4
4
 
5
- ## What you get
5
+ It provides:
6
6
 
7
- - Contact CRUD screens and controller
8
- - Local contact schema (emails, phones, addresses)
9
- - CSV importer
10
- - Elasticsearch search backend (with DB fallback)
11
- - Google sync service scaffolding
12
- - Install, migration, view, and controller generators
7
+ - rich contact profile fields
8
+ - multi-value contact methods (emails/phones/addresses/websites/events)
9
+ - labels/tags
10
+ - dynamic add/remove nested rows
11
+ - Elasticsearch-backed search with DB fallback
12
+ - CSV import and Google sync scaffolding
13
+ - merge and bulk-delete operations
14
+ - Devise-style override generators
13
15
 
14
- ## 1) Install the gem
16
+ ---
17
+
18
+ ## Quickstart
19
+
20
+ ### 1) Add gem
15
21
 
16
22
  ```ruby
17
- gem "rails-contact"
23
+ gem "rails-contact", "~> 0.1.3"
18
24
  ```
19
25
 
20
26
  ```bash
21
27
  bundle install
22
28
  ```
23
29
 
24
- ## 2) Run generators
30
+ ### 2) Install and generate schema
25
31
 
26
32
  ```bash
27
33
  rails generate rails:contact:install
28
34
  rails generate rails:contact:contact Contact
35
+ rails db:migrate
36
+ ```
37
+
38
+ ### 3) Mount engine routes
39
+
40
+ ```ruby
41
+ rails_contact_for :contacts
42
+ ```
43
+
44
+ or explicit mount:
45
+
46
+ ```ruby
47
+ mount Rails::Contact::Engine => "/contacts", as: "rails_contact"
29
48
  ```
30
49
 
31
- Optional override generators (Devise-style customization):
50
+ `rails_contact_for :contact` is auto-normalized to `/contacts`.
51
+
52
+ ### 4) Visit UI
53
+
54
+ - `/contacts`
55
+ - `/contacts/new`
56
+ - `/contacts/:id`
57
+
58
+ ---
59
+
60
+ ## Generators
32
61
 
33
62
  ```bash
63
+ rails generate rails:contact:install
64
+ rails generate rails:contact:contact Contact
34
65
  rails generate rails:contact:views
35
66
  rails generate rails:contact:controllers
36
67
  ```
37
68
 
38
- Then migrate:
69
+ - `views` copies templates so host apps can customize UI.
70
+ - `controllers` copies an override-ready contacts controller.
39
71
 
40
- ```bash
41
- rails db:migrate
42
- ```
72
+ ---
43
73
 
44
- ## 3) Mount routes (clean paths)
74
+ ## Feature map
45
75
 
46
- Use either:
76
+ ### Core profile fields
47
77
 
48
- ```ruby
49
- mount Rails::Contact::Engine => "/contacts", as: "rails_contact"
50
- ```
78
+ - Prefix, first, middle, last, suffix, nickname
79
+ - Company, job title, department
80
+ - Labels (comma-separated input)
81
+ - Notes and metadata
82
+ - Starred flag
83
+ - Photo URL
51
84
 
52
- or:
85
+ ### Multi-value sections (dynamic)
53
86
 
54
- ```ruby
55
- rails_contact_for :contacts
56
- ```
87
+ - Emails
88
+ - Phones
89
+ - Addresses
90
+ - Websites
91
+ - Events (birthday/custom)
57
92
 
58
- With this setup, paths are:
59
- - `/contacts` (index)
60
- - `/contacts/new`
61
- - `/contacts/:id`
93
+ Rows can be added/removed dynamically in form UI.
94
+
95
+ ### List/search
62
96
 
63
- No `/contacts/contacts` duplication.
97
+ - Search by name/email/phone/company/job title/labels
98
+ - Filter by city/region/sync/starred
99
+ - Sort by recent updates
64
100
 
65
- ## 4) Configure
101
+ ### Actions
66
102
 
67
- Generated initializer: `config/initializers/rails_contact.rb`
103
+ - Bulk delete selected contacts
104
+ - Merge source contact into target contact
105
+
106
+ ---
107
+
108
+ ## Configuration
109
+
110
+ Initializer: `config/initializers/rails_contact.rb`
68
111
 
69
112
  ```ruby
70
113
  Rails::Contact.configure do |config|
@@ -73,26 +116,38 @@ Rails::Contact.configure do |config|
73
116
  config.google_sync_enabled = false
74
117
  config.google_max_contacts = 25_000
75
118
  config.rolling_window_sort = :updated_at
119
+ config.default_per_page = 25
76
120
  end
77
121
  ```
78
122
 
79
- ## CSV import
123
+ ---
124
+
125
+ ## Rake tasks
80
126
 
81
127
  ```bash
128
+ rake rails_contact:reindex
129
+ rake rails_contact:sync_google
82
130
  rake rails_contact:import_csv CSV_PATH=/absolute/path/to/eq.csv
83
131
  ```
84
132
 
85
- ## Utility tasks
133
+ ---
86
134
 
87
- - `rake rails_contact:reindex`
88
- - `rake rails_contact:sync_google`
135
+ ## Testing and coverage
89
136
 
90
- ## Test
137
+ RSpec is the primary framework.
91
138
 
92
139
  ```bash
93
- bundle exec ruby -Itest -e 'Dir["test/**/*_test.rb"].sort.each { |f| require File.expand_path(f) }'
140
+ bundle exec rspec
94
141
  ```
95
142
 
143
+ Coverage is enforced with SimpleCov:
144
+
145
+ - 100% line coverage target
146
+ - 100% branch coverage target
147
+ - CI fails below thresholds
148
+
149
+ ---
150
+
96
151
  ## Release
97
152
 
98
153
  ```bash
@@ -100,4 +155,13 @@ bundle exec rake build
100
155
  bundle exec rake release
101
156
  ```
102
157
 
103
- RubyGems MFA is required.
158
+ RubyGems MFA is required for push.
159
+
160
+ ---
161
+
162
+ ## Extended docs
163
+
164
+ - `docs/parity_matrix.md`
165
+ - `docs/product_decisions.md`
166
+ - `docs/roadmap.md`
167
+ - `docs/migration_guide.md`
@@ -0,0 +1 @@
1
+ //= require rails/contact/nested_fields
@@ -0,0 +1,46 @@
1
+ (() => {
2
+ function addRow(event) {
3
+ const button = event.target.closest("[data-add-nested]");
4
+ if (!button) return;
5
+
6
+ const wrapper = button.closest("[data-nested-wrapper]");
7
+ const targetSelector = button.dataset.target;
8
+ const templateSelector = button.dataset.template;
9
+ if (!wrapper || !targetSelector || !templateSelector) return;
10
+
11
+ const container = wrapper.querySelector(targetSelector);
12
+ const template = wrapper.querySelector(templateSelector);
13
+ if (!container || !template) return;
14
+
15
+ const uniqueId = String(Date.now() + Math.floor(Math.random() * 1000));
16
+ const html = template.innerHTML.replace(/NEW_RECORD/g, uniqueId);
17
+ container.insertAdjacentHTML("beforeend", html);
18
+ }
19
+
20
+ function removeRow(event) {
21
+ const button = event.target.closest("[data-action='nested-fields#remove']");
22
+ if (!button) return;
23
+ const row = button.closest("[data-nested-fields-target='row']");
24
+ if (!row) return;
25
+
26
+ const destroyField = row.querySelector("[data-nested-fields-target='destroyField']");
27
+ if (destroyField) {
28
+ destroyField.value = "1";
29
+ row.style.display = "none";
30
+ } else {
31
+ row.remove();
32
+ }
33
+ }
34
+
35
+ document.addEventListener("click", (event) => {
36
+ if (event.target.closest("[data-add-nested]")) {
37
+ event.preventDefault();
38
+ addRow(event);
39
+ return;
40
+ }
41
+ if (event.target.closest("[data-action='nested-fields#remove']")) {
42
+ event.preventDefault();
43
+ removeRow(event);
44
+ }
45
+ });
46
+ })();
@@ -0,0 +1,11 @@
1
+ module Rails
2
+ module Contact
3
+ module Filterable
4
+ private
5
+
6
+ def normalized_filters(params)
7
+ params.to_h.compact_blank
8
+ end
9
+ end
10
+ end
11
+ end
@@ -1,38 +1,51 @@
1
1
  module Rails
2
2
  module Contact
3
3
  class ContactsController < ApplicationController
4
+ include Rails::Contact::Filterable
5
+ METADATA_KEYS = %w[
6
+ prefix middle_name suffix nickname company job_title department website birthday notes
7
+ ].freeze
8
+
4
9
  before_action :set_contact, only: [ :show, :edit, :update, :destroy ]
5
10
 
6
11
  def index
7
12
  @query = params[:q].to_s.strip
8
- @contacts = Search::Query.new(@query, filters: filter_params).call
13
+ @contacts = Search::Query.new(@query, filters: normalized_filters(filter_params)).call
14
+ render "rails/contact/index"
9
15
  end
10
16
 
11
- def show; end
17
+ def show
18
+ render "rails/contact/show"
19
+ end
12
20
 
13
21
  def new
14
22
  @contact = Contact.new
15
23
  build_default_associations
24
+ render "rails/contact/new"
16
25
  end
17
26
 
18
- def edit; end
27
+ def edit
28
+ render "rails/contact/edit"
29
+ end
19
30
 
20
31
  def create
21
32
  @contact = Contact.new(contact_params)
33
+ assign_labels(@contact)
22
34
  if @contact.save
23
35
  enqueue_index(@contact.id)
24
36
  redirect_to contact_path(@contact), notice: "Contact created."
25
37
  else
26
- render :new, status: :unprocessable_entity
38
+ render "rails/contact/new", status: :unprocessable_entity
27
39
  end
28
40
  end
29
41
 
30
42
  def update
43
+ assign_labels(@contact)
31
44
  if @contact.update(contact_params)
32
45
  enqueue_index(@contact.id)
33
46
  redirect_to contact_path(@contact), notice: "Contact updated."
34
47
  else
35
- render :edit, status: :unprocessable_entity
48
+ render "rails/contact/edit", status: :unprocessable_entity
36
49
  end
37
50
  end
38
51
 
@@ -43,6 +56,21 @@ module Rails
43
56
  redirect_to contacts_path, notice: "Contact deleted."
44
57
  end
45
58
 
59
+ def bulk_destroy
60
+ ids = params[:ids].to_s.split(",").map(&:to_i).reject(&:zero?).uniq
61
+ Contact.where(id: ids).find_each(&:destroy!)
62
+ redirect_to contacts_path, notice: "#{ids.size} contact(s) deleted."
63
+ end
64
+
65
+ def merge
66
+ source_id = params[:source_id]
67
+ target_id = params[:target_id]
68
+ MergeContactsService.new(source_id: source_id, target_id: target_id).call
69
+ redirect_to contact_path(target_id), notice: "Contacts merged."
70
+ rescue StandardError => e
71
+ redirect_to contacts_path, alert: "Merge failed: #{e.message}"
72
+ end
73
+
46
74
  private
47
75
 
48
76
  def set_contact
@@ -51,16 +79,24 @@ module Rails
51
79
  end
52
80
 
53
81
  def contact_params
54
- params.require(:contact).permit(
55
- :given_name, :family_name, :current_city, :departure_city, :region_name, :biography, :sync_eligible,
82
+ permitted = params.require(:contact).permit(
83
+ :given_name, :family_name, :current_city, :departure_city, :region_name, :biography, :sync_eligible, :starred, :photo_url,
84
+ :labels_csv,
85
+ metadata: {},
56
86
  emails_attributes: [ :id, :value, :label, :primary, :_destroy ],
57
87
  phones_attributes: [ :id, :value, :label, :primary, :_destroy ],
58
- addresses_attributes: [ :id, :city, :departure_city, :formatted_value, :label, :_destroy ]
88
+ addresses_attributes: [ :id, :city, :departure_city, :formatted_value, :label, :_destroy ],
89
+ websites_attributes: [ :id, :url, :label, :_destroy ],
90
+ events_attributes: [ :id, :event_type, :event_date, :label, :_destroy ]
59
91
  )
92
+
93
+ metadata = permitted[:metadata].is_a?(ActionController::Parameters) ? permitted[:metadata].to_h : {}
94
+ permitted[:metadata] = metadata.slice(*METADATA_KEYS)
95
+ permitted
60
96
  end
61
97
 
62
98
  def filter_params
63
- params.permit(:city, :region, :sync_eligible)
99
+ params.permit(:city, :region, :sync_eligible, :starred)
64
100
  end
65
101
 
66
102
  def enqueue_index(contact_id)
@@ -68,9 +104,17 @@ module Rails
68
104
  end
69
105
 
70
106
  def build_default_associations
71
- @contact.emails.build if @contact.emails.empty?
72
- @contact.phones.build if @contact.phones.empty?
107
+ @contact.emails.build(label: "work") while @contact.emails.size < 2
108
+ @contact.phones.build(label: "mobile") while @contact.phones.size < 2
73
109
  @contact.addresses.build if @contact.addresses.empty?
110
+ @contact.websites.build(label: "profile") if @contact.websites.empty?
111
+ @contact.events.build(event_type: "birthday") if @contact.events.empty?
112
+ end
113
+
114
+ def assign_labels(contact)
115
+ return unless params.dig(:contact, :labels_csv).present?
116
+
117
+ contact.set_labels_from_csv!(params.dig(:contact, :labels_csv))
74
118
  end
75
119
  end
76
120
  end
@@ -1,6 +1,13 @@
1
1
  module Rails
2
2
  module Contact
3
3
  module ApplicationHelper
4
+ def contact_initials(contact)
5
+ [ contact.given_name, contact.family_name ].map { |part| part.to_s.first.to_s.upcase }.join
6
+ end
7
+
8
+ def contact_chip(value)
9
+ value.presence || "-"
10
+ end
4
11
  end
5
12
  end
6
13
  end
@@ -1,13 +1,20 @@
1
+ require "json"
2
+
1
3
  module Rails
2
4
  module Contact
3
5
  class Contact < ApplicationRecord
4
6
  self.table_name = "rails_contact_contacts"
7
+ attr_accessor :labels_csv
5
8
 
6
9
  has_many :emails, class_name: "Rails::Contact::ContactEmail", dependent: :destroy, inverse_of: :contact
7
10
  has_many :phones, class_name: "Rails::Contact::ContactPhone", dependent: :destroy, inverse_of: :contact
8
11
  has_many :addresses, class_name: "Rails::Contact::ContactAddress", dependent: :destroy, inverse_of: :contact
12
+ has_many :websites, class_name: "Rails::Contact::ContactWebsite", dependent: :destroy, inverse_of: :contact
13
+ has_many :events, class_name: "Rails::Contact::ContactEvent", dependent: :destroy, inverse_of: :contact
14
+ has_many :contact_labels, class_name: "Rails::Contact::ContactLabel", dependent: :destroy, inverse_of: :contact
15
+ has_many :labels, through: :contact_labels, source: :label
9
16
 
10
- accepts_nested_attributes_for :emails, :phones, :addresses, allow_destroy: true
17
+ accepts_nested_attributes_for :emails, :phones, :addresses, :websites, :events, allow_destroy: true
11
18
 
12
19
  validates :given_name, presence: true
13
20
 
@@ -27,6 +34,30 @@ module Rails
27
34
  def primary_phone
28
35
  phones.find(&:primary?) || phones.first
29
36
  end
37
+
38
+ def meta(key)
39
+ metadata_hash[key.to_s]
40
+ end
41
+
42
+ def set_labels_from_csv!(csv_string)
43
+ names = csv_string.to_s.split(",").map(&:strip).reject(&:empty?).uniq
44
+ self.labels = names.map { |name| Rails::Contact::Label.find_or_create_by!(name: name) }
45
+ end
46
+
47
+ def labels_csv
48
+ labels.pluck(:name).sort.join(", ")
49
+ end
50
+
51
+ def metadata_hash
52
+ value = metadata
53
+ return value if value.is_a?(Hash)
54
+ return {} if value.blank?
55
+
56
+ parsed = JSON.parse(value)
57
+ parsed.is_a?(Hash) ? parsed : {}
58
+ rescue JSON::ParserError
59
+ {}
60
+ end
30
61
  end
31
62
  end
32
63
  end
@@ -0,0 +1,11 @@
1
+ module Rails
2
+ module Contact
3
+ class ContactEvent < ApplicationRecord
4
+ self.table_name = "rails_contact_contact_events"
5
+
6
+ belongs_to :contact, class_name: "Rails::Contact::Contact", inverse_of: :events
7
+
8
+ validates :event_type, presence: true
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ module Rails
2
+ module Contact
3
+ class ContactLabel < ApplicationRecord
4
+ self.table_name = "rails_contact_contact_labels"
5
+
6
+ belongs_to :contact, class_name: "Rails::Contact::Contact", inverse_of: :contact_labels
7
+ belongs_to :label, class_name: "Rails::Contact::Label", inverse_of: :contact_labels
8
+
9
+ validates :label_id, uniqueness: { scope: :contact_id }
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ module Rails
2
+ module Contact
3
+ class ContactWebsite < ApplicationRecord
4
+ self.table_name = "rails_contact_contact_websites"
5
+
6
+ belongs_to :contact, class_name: "Rails::Contact::Contact", inverse_of: :websites
7
+
8
+ validates :url, presence: true
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ module Rails
2
+ module Contact
3
+ class Label < ApplicationRecord
4
+ self.table_name = "rails_contact_labels"
5
+
6
+ has_many :contact_labels, class_name: "Rails::Contact::ContactLabel", dependent: :destroy, inverse_of: :label
7
+ has_many :contacts, through: :contact_labels, source: :contact
8
+
9
+ validates :name, presence: true, uniqueness: { case_sensitive: false }
10
+
11
+ before_validation :normalize_name
12
+
13
+ private
14
+
15
+ def normalize_name
16
+ self.name = name.to_s.strip
17
+ end
18
+ end
19
+ end
20
+ end
@@ -8,6 +8,7 @@
8
8
  <%= yield :head %>
9
9
 
10
10
  <%= stylesheet_link_tag "rails/contact/application", media: "all" %>
11
+ <%= javascript_include_tag "rails/contact/application", defer: true %>
11
12
  </head>
12
13
  <body>
13
14
 
@@ -0,0 +1,18 @@
1
+ <div class="grid grid-cols-1 gap-3 md:grid-cols-4" data-nested-fields-target="row">
2
+ <%= address_fields.hidden_field :_destroy, value: 0, data: { nested_fields_target: "destroyField" } %>
3
+ <div>
4
+ <%= address_fields.label :city, "Current City", class: "mb-1 block text-sm font-medium text-gray-700" %>
5
+ <%= address_fields.text_field :city, class: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
6
+ </div>
7
+ <div>
8
+ <%= address_fields.label :departure_city, "Departure City", class: "mb-1 block text-sm font-medium text-gray-700" %>
9
+ <%= address_fields.text_field :departure_city, class: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
10
+ </div>
11
+ <div>
12
+ <%= address_fields.label :label, "Label", class: "mb-1 block text-sm font-medium text-gray-700" %>
13
+ <%= address_fields.text_field :label, class: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
14
+ </div>
15
+ <div class="flex items-end">
16
+ <button type="button" class="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700" data-action="nested-fields#remove">Remove</button>
17
+ </div>
18
+ </div>
@@ -0,0 +1,18 @@
1
+ <div class="grid grid-cols-1 gap-3 md:grid-cols-5" data-nested-fields-target="row">
2
+ <%= email_fields.hidden_field :_destroy, value: 0, data: { nested_fields_target: "destroyField" } %>
3
+ <div class="md:col-span-3">
4
+ <%= email_fields.label :value, "Email", class: "mb-1 block text-sm font-medium text-gray-700" %>
5
+ <%= email_fields.email_field :value, class: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
6
+ </div>
7
+ <div>
8
+ <%= email_fields.label :label, "Label", class: "mb-1 block text-sm font-medium text-gray-700" %>
9
+ <%= email_fields.text_field :label, class: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
10
+ </div>
11
+ <div class="flex items-end gap-2">
12
+ <label class="inline-flex items-center gap-2 text-sm text-gray-700">
13
+ <%= email_fields.check_box :primary, class: "h-4 w-4 rounded border-gray-300 text-blue-600" %>
14
+ Primary
15
+ </label>
16
+ <button type="button" class="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700" data-action="nested-fields#remove">Remove</button>
17
+ </div>
18
+ </div>
@@ -0,0 +1,18 @@
1
+ <div class="grid grid-cols-1 gap-3 md:grid-cols-4" data-nested-fields-target="row">
2
+ <%= event_fields.hidden_field :_destroy, value: 0, data: { nested_fields_target: "destroyField" } %>
3
+ <div>
4
+ <%= event_fields.label :event_type, "Event Type", class: "mb-1 block text-sm font-medium text-gray-700" %>
5
+ <%= event_fields.text_field :event_type, class: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
6
+ </div>
7
+ <div>
8
+ <%= event_fields.label :event_date, "Date", class: "mb-1 block text-sm font-medium text-gray-700" %>
9
+ <%= event_fields.date_field :event_date, class: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
10
+ </div>
11
+ <div>
12
+ <%= event_fields.label :label, "Label", class: "mb-1 block text-sm font-medium text-gray-700" %>
13
+ <%= event_fields.text_field :label, class: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
14
+ </div>
15
+ <div class="flex items-end">
16
+ <button type="button" class="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700" data-action="nested-fields#remove">Remove</button>
17
+ </div>
18
+ </div>