rails-contact 0.1.1 → 0.1.4

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.rspec +2 -0
  4. data/CHANGELOG.md +20 -0
  5. data/Gemfile +3 -0
  6. data/README.md +114 -38
  7. data/app/assets/javascripts/rails/contact/application.js +1 -0
  8. data/app/assets/javascripts/rails/contact/nested_fields.js +49 -0
  9. data/app/assets/stylesheets/rails/contact/application.css +184 -12
  10. data/app/controllers/concerns/rails/contact/filterable.rb +11 -0
  11. data/app/controllers/rails/contact/application_controller.rb +6 -0
  12. data/app/controllers/rails/contact/contacts_controller.rb +55 -11
  13. data/app/helpers/rails/contact/application_helper.rb +7 -0
  14. data/app/models/rails/contact/contact.rb +32 -1
  15. data/app/models/rails/contact/contact_event.rb +11 -0
  16. data/app/models/rails/contact/contact_label.rb +12 -0
  17. data/app/models/rails/contact/contact_website.rb +11 -0
  18. data/app/models/rails/contact/label.rb +20 -0
  19. data/app/views/layouts/rails/contact/application.html.erb +1 -0
  20. data/app/views/rails/contact/_address_fields.html.erb +18 -0
  21. data/app/views/rails/contact/_bulk_selection_script.html.erb +13 -0
  22. data/app/views/rails/contact/_email_fields.html.erb +18 -0
  23. data/app/views/rails/contact/_event_fields.html.erb +18 -0
  24. data/app/views/rails/contact/_form.html.erb +187 -0
  25. data/app/views/rails/contact/_nested_fields_script.html.erb +51 -0
  26. data/app/views/rails/contact/_phone_fields.html.erb +18 -0
  27. data/app/views/rails/contact/_stylesheet.html.erb +1 -0
  28. data/app/views/rails/contact/_website_fields.html.erb +14 -0
  29. data/app/views/rails/contact/edit.html.erb +4 -0
  30. data/app/views/rails/contact/index.html.erb +82 -0
  31. data/app/views/rails/contact/new.html.erb +4 -0
  32. data/app/views/rails/contact/show.html.erb +56 -0
  33. data/config/routes.rb +2 -0
  34. data/docs/migration_guide.md +44 -0
  35. data/docs/parity_matrix.md +57 -0
  36. data/docs/product_decisions.md +36 -0
  37. data/docs/roadmap.md +23 -0
  38. data/lib/generators/rails/contact/contact_generator.rb +4 -0
  39. data/lib/generators/rails/contact/templates/contacts_controller.rb.tt +0 -2
  40. data/lib/generators/rails/contact/templates/create_rails_contact_contact_events.rb.tt +12 -0
  41. data/lib/generators/rails/contact/templates/create_rails_contact_contact_labels.rb.tt +12 -0
  42. data/lib/generators/rails/contact/templates/create_rails_contact_contact_websites.rb.tt +11 -0
  43. data/lib/generators/rails/contact/templates/create_rails_contact_contacts.rb.tt +2 -0
  44. data/lib/generators/rails/contact/templates/create_rails_contact_labels.rb.tt +11 -0
  45. data/lib/rails/contact/configuration.rb +6 -1
  46. data/lib/rails/contact/merge_contacts_service.rb +66 -0
  47. data/lib/rails/contact/routing.rb +8 -2
  48. data/lib/rails/contact/search/backends/database.rb +7 -3
  49. data/lib/rails/contact/search/backends/elasticsearch.rb +13 -1
  50. data/lib/rails/contact/version.rb +1 -1
  51. data/lib/rails/contact.rb +2 -0
  52. data/spec/concerns/filterable_spec.rb +16 -0
  53. data/spec/controllers/contacts_controller_spec.rb +26 -0
  54. data/spec/factories/contact_factories.rb +13 -0
  55. data/spec/helpers/application_helper_spec.rb +13 -0
  56. data/spec/models/contact_spec.rb +60 -0
  57. data/{test/test_helper.rb → spec/rails_helper.rb} +52 -8
  58. data/spec/services/merge_contacts_service_spec.rb +37 -0
  59. data/spec/spec_helper.rb +22 -0
  60. data/spec/views/contact_templates_spec.rb +17 -0
  61. metadata +40 -11
  62. data/app/views/rails/contact/contacts/_form.html.erb +0 -61
  63. data/app/views/rails/contact/contacts/edit.html.erb +0 -3
  64. data/app/views/rails/contact/contacts/index.html.erb +0 -38
  65. data/app/views/rails/contact/contacts/new.html.erb +0 -3
  66. data/app/views/rails/contact/contacts/show.html.erb +0 -13
  67. data/test/csv_import_service_test.rb +0 -25
  68. data/test/google_sync_service_test.rb +0 -47
  69. data/test/payload_mapper_test.rb +0 -19
  70. 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: b40ac2006fcc2931b47cbc8b339770f3c148bca499cae741ee87c2e756a1a290
4
+ data.tar.gz: 6be3bc28491e1f20cd6c61409f8d6454db921bf1907b1834e801760d5abb304a
5
5
  SHA512:
6
- metadata.gz: 721d2f5d8a7b111d6139069bd9af461278e385dca6080fbed28521fc582ba9ad8a4ecee3f1d4b6f5f9bae241ce94f22a18f61757b16aa290ec9c1bd6e495dec1
7
- data.tar.gz: 15b1587cd03cdc1b5d090b7f972c2c2d84e9313d7f7ae07e070d86bf14bbcd630ed4fb1e390d2a92cc164a5564663edbc59e4a296616195478053b53bb14047d
6
+ metadata.gz: 32f9fc983a9c6eea9e37ceb4d3e220ad4f0f14e4ba54d195e5ef1278f3674e598492b242cddde852efc7f51b797280758cf0f4b95ac8e905beb1dd69a2c5f65b
7
+ data.tar.gz: ca941d755aa4db4679f79a8a7122f3bfa36db04b0ba32f9623d522cdf2afc2df93091dd2f4bc3b7e415f7ef167e563808ffee61ecca190e8fc8f3291f89eeac1
@@ -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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4
4
+
5
+ - Default to the host `application` layout when the engine is mounted, with `inherit_host_layout` (default `true`) to opt back into the engine-only layout.
6
+ - Ensure nested field add/remove and bulk checkbox scripts run when the host uses importmap (inline scripts + idempotent global guards; same guard in `nested_fields.js` for the Sprockets bundle).
7
+ - Pull engine stylesheet into each main contact view so styling works without the engine layout’s asset tags.
8
+
9
+ ## 0.1.3
10
+
11
+ - Flatten view partial paths under `app/views/rails/contact` and remove legacy `contacts/` partial nesting.
12
+ - Add `.gitignore` to keep generated artifacts (including `coverage/`) out of release commits.
13
+ - Update packaging/docs metadata for smoother RubyGems release workflow.
14
+
15
+ ## 0.1.2
16
+
17
+ - Breaking parity-focused rewrite foundation for richer Google-like contacts.
18
+ - Added label, website, event, merge, and bulk-delete capabilities.
19
+ - Added dynamic nested field add/remove behavior for multi-value rows.
20
+ - Introduced RSpec suite with SimpleCov gates (100% line/branch in tracked critical files).
21
+ - Added parity matrix, roadmap, and migration documentation.
22
+
3
23
  ## 0.1.1
4
24
 
5
25
  - 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,125 @@
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.4"
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
29
36
  ```
30
37
 
31
- Optional override generators (Devise-style customization):
38
+ ### 3) Mount engine routes
32
39
 
33
- ```bash
34
- rails generate rails:contact:views
35
- rails generate rails:contact:controllers
40
+ ```ruby
41
+ rails_contact_for :contacts
36
42
  ```
37
43
 
38
- Then migrate:
44
+ or explicit mount:
39
45
 
40
- ```bash
41
- rails db:migrate
46
+ ```ruby
47
+ mount Rails::Contact::Engine => "/contacts", as: "rails_contact"
42
48
  ```
43
49
 
44
- ## 3) Mount routes (clean paths)
50
+ `rails_contact_for :contact` is auto-normalized to `/contacts`.
45
51
 
46
- Use either:
52
+ ### 4) Visit UI
53
+
54
+ - `/contacts`
55
+ - `/contacts/new`
56
+ - `/contacts/:id`
57
+
58
+ ### Host layout and importmap
59
+
60
+ By default the engine uses your host app layout named `application` so contacts pages match the rest of your UI (including Turbo and importmap). Styles for engine markup are included per page via `stylesheet_link_tag`, and nested “add row” / bulk-selection behavior uses small inline scripts so you do **not** need to pin gem JavaScript in the host importmap.
61
+
62
+ To use the engine’s standalone layout and bundled `javascript_include_tag "rails/contact/application"` instead:
47
63
 
48
64
  ```ruby
49
- mount Rails::Contact::Engine => "/contacts", as: "rails_contact"
65
+ Rails::Contact.configure do |config|
66
+ config.inherit_host_layout = false
67
+ end
50
68
  ```
51
69
 
52
- or:
70
+ ---
53
71
 
54
- ```ruby
55
- rails_contact_for :contacts
72
+ ## Generators
73
+
74
+ ```bash
75
+ rails generate rails:contact:install
76
+ rails generate rails:contact:contact Contact
77
+ rails generate rails:contact:views
78
+ rails generate rails:contact:controllers
56
79
  ```
57
80
 
58
- With this setup, paths are:
59
- - `/contacts` (index)
60
- - `/contacts/new`
61
- - `/contacts/:id`
81
+ - `views` copies templates so host apps can customize UI.
82
+ - `controllers` copies an override-ready contacts controller.
83
+
84
+ ---
85
+
86
+ ## Feature map
87
+
88
+ ### Core profile fields
89
+
90
+ - Prefix, first, middle, last, suffix, nickname
91
+ - Company, job title, department
92
+ - Labels (comma-separated input)
93
+ - Notes and metadata
94
+ - Starred flag
95
+ - Photo URL
96
+
97
+ ### Multi-value sections (dynamic)
98
+
99
+ - Emails
100
+ - Phones
101
+ - Addresses
102
+ - Websites
103
+ - Events (birthday/custom)
62
104
 
63
- No `/contacts/contacts` duplication.
105
+ Rows can be added/removed dynamically in form UI.
64
106
 
65
- ## 4) Configure
107
+ ### List/search
66
108
 
67
- Generated initializer: `config/initializers/rails_contact.rb`
109
+ - Search by name/email/phone/company/job title/labels
110
+ - Filter by city/region/sync/starred
111
+ - Sort by recent updates
112
+
113
+ ### Actions
114
+
115
+ - Bulk delete selected contacts
116
+ - Merge source contact into target contact
117
+
118
+ ---
119
+
120
+ ## Configuration
121
+
122
+ Initializer: `config/initializers/rails_contact.rb`
68
123
 
69
124
  ```ruby
70
125
  Rails::Contact.configure do |config|
@@ -73,26 +128,38 @@ Rails::Contact.configure do |config|
73
128
  config.google_sync_enabled = false
74
129
  config.google_max_contacts = 25_000
75
130
  config.rolling_window_sort = :updated_at
131
+ config.default_per_page = 25
76
132
  end
77
133
  ```
78
134
 
79
- ## CSV import
135
+ ---
136
+
137
+ ## Rake tasks
80
138
 
81
139
  ```bash
140
+ rake rails_contact:reindex
141
+ rake rails_contact:sync_google
82
142
  rake rails_contact:import_csv CSV_PATH=/absolute/path/to/eq.csv
83
143
  ```
84
144
 
85
- ## Utility tasks
145
+ ---
86
146
 
87
- - `rake rails_contact:reindex`
88
- - `rake rails_contact:sync_google`
147
+ ## Testing and coverage
89
148
 
90
- ## Test
149
+ RSpec is the primary framework.
91
150
 
92
151
  ```bash
93
- bundle exec ruby -Itest -e 'Dir["test/**/*_test.rb"].sort.each { |f| require File.expand_path(f) }'
152
+ bundle exec rspec
94
153
  ```
95
154
 
155
+ Coverage is enforced with SimpleCov:
156
+
157
+ - 100% line coverage target
158
+ - 100% branch coverage target
159
+ - CI fails below thresholds
160
+
161
+ ---
162
+
96
163
  ## Release
97
164
 
98
165
  ```bash
@@ -100,4 +167,13 @@ bundle exec rake build
100
167
  bundle exec rake release
101
168
  ```
102
169
 
103
- RubyGems MFA is required.
170
+ RubyGems MFA is required for push.
171
+
172
+ ---
173
+
174
+ ## Extended docs
175
+
176
+ - `docs/parity_matrix.md`
177
+ - `docs/product_decisions.md`
178
+ - `docs/roadmap.md`
179
+ - `docs/migration_guide.md`
@@ -0,0 +1 @@
1
+ //= require rails/contact/nested_fields
@@ -0,0 +1,49 @@
1
+ (() => {
2
+ if (window.__railsContactNestedFieldsBound) return;
3
+ window.__railsContactNestedFieldsBound = true;
4
+
5
+ function addRow(event) {
6
+ const button = event.target.closest("[data-add-nested]");
7
+ if (!button) return;
8
+
9
+ const wrapper = button.closest("[data-nested-wrapper]");
10
+ const targetSelector = button.dataset.target;
11
+ const templateSelector = button.dataset.template;
12
+ if (!wrapper || !targetSelector || !templateSelector) return;
13
+
14
+ const container = wrapper.querySelector(targetSelector);
15
+ const template = wrapper.querySelector(templateSelector);
16
+ if (!container || !template) return;
17
+
18
+ const uniqueId = String(Date.now() + Math.floor(Math.random() * 1000));
19
+ const html = template.innerHTML.replace(/NEW_RECORD/g, uniqueId);
20
+ container.insertAdjacentHTML("beforeend", html);
21
+ }
22
+
23
+ function removeRow(event) {
24
+ const button = event.target.closest("[data-action='nested-fields#remove']");
25
+ if (!button) return;
26
+ const row = button.closest("[data-nested-fields-target='row']");
27
+ if (!row) return;
28
+
29
+ const destroyField = row.querySelector("[data-nested-fields-target='destroyField']");
30
+ if (destroyField) {
31
+ destroyField.value = "1";
32
+ row.style.display = "none";
33
+ } else {
34
+ row.remove();
35
+ }
36
+ }
37
+
38
+ document.addEventListener("click", (event) => {
39
+ if (event.target.closest("[data-add-nested]")) {
40
+ event.preventDefault();
41
+ addRow(event);
42
+ return;
43
+ }
44
+ if (event.target.closest("[data-action='nested-fields#remove']")) {
45
+ event.preventDefault();
46
+ removeRow(event);
47
+ }
48
+ });
49
+ })();
@@ -1,15 +1,187 @@
1
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
2
  *= require_self
15
3
  */
4
+
5
+ /* Basic reset-ish defaults scoped to engine layout usage */
6
+ html,
7
+ body {
8
+ margin: 0;
9
+ padding: 0;
10
+ background: #f8fafc;
11
+ color: #111827;
12
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
13
+ }
14
+
15
+ body {
16
+ line-height: 1.4;
17
+ }
18
+
19
+ a {
20
+ color: #2563eb;
21
+ text-decoration: none;
22
+ }
23
+
24
+ a:hover {
25
+ text-decoration: underline;
26
+ }
27
+
28
+ /* Utility classes used in engine views */
29
+ .space-y-6 > * + * {
30
+ margin-top: 1.5rem;
31
+ }
32
+
33
+ .space-y-4 > * + * {
34
+ margin-top: 1rem;
35
+ }
36
+
37
+ .space-y-3 > * + * {
38
+ margin-top: 0.75rem;
39
+ }
40
+
41
+ .mt-1 { margin-top: 0.25rem; }
42
+ .mt-2 { margin-top: 0.5rem; }
43
+ .mt-3 { margin-top: 0.75rem; }
44
+ .mt-4 { margin-top: 1rem; }
45
+ .mb-1 { margin-bottom: 0.25rem; }
46
+ .mb-3 { margin-bottom: 0.75rem; }
47
+ .ml-4 { margin-left: 1rem; }
48
+ .pt-2 { padding-top: 0.5rem; }
49
+
50
+ .flex { display: flex; }
51
+ .inline-flex { display: inline-flex; }
52
+ .items-center { align-items: center; }
53
+ .items-end { align-items: flex-end; }
54
+ .items-start { align-items: flex-start; }
55
+ .justify-between { justify-content: space-between; }
56
+ .justify-center { justify-content: center; }
57
+ .gap-2 { gap: 0.5rem; }
58
+ .gap-6 { gap: 1.5rem; }
59
+
60
+ .grid { display: grid; }
61
+ .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
62
+ .md\:grid-cols-2 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
63
+ .md\:grid-cols-3 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
64
+ .md\:grid-cols-4 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
65
+ .md\:grid-cols-5 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
66
+
67
+ .w-full { width: 100%; }
68
+ .w-24 { width: 6rem; }
69
+ .w-28 { width: 7rem; }
70
+ .h-4 { height: 1rem; }
71
+ .w-4 { width: 1rem; }
72
+ .h-24 { height: 6rem; }
73
+ .min-w-full { min-width: 100%; }
74
+
75
+ .rounded-md { border-radius: 0.375rem; }
76
+ .rounded-lg { border-radius: 0.5rem; }
77
+ .rounded-xl { border-radius: 0.75rem; }
78
+ .rounded-full { border-radius: 9999px; }
79
+
80
+ .border { border: 1px solid #d1d5db; }
81
+ .border-gray-200 { border-color: #e5e7eb; }
82
+ .border-gray-300 { border-color: #d1d5db; }
83
+ .border-red-200 { border-color: #fecaca; }
84
+
85
+ .bg-white { background: #fff; }
86
+ .bg-gray-50 { background: #f9fafb; }
87
+ .bg-gray-100 { background: #f3f4f6; }
88
+ .bg-gray-900 { background: #111827; }
89
+ .bg-blue-600 { background: #2563eb; }
90
+ .bg-red-50 { background: #fef2f2; }
91
+ .bg-red-600 { background: #dc2626; }
92
+
93
+ .text-white { color: #fff; }
94
+ .text-gray-900 { color: #111827; }
95
+ .text-gray-700 { color: #374151; }
96
+ .text-gray-600 { color: #4b5563; }
97
+ .text-gray-500 { color: #6b7280; }
98
+ .text-red-700 { color: #b91c1c; }
99
+ .text-blue-600 { color: #2563eb; }
100
+
101
+ .text-xs { font-size: 0.75rem; }
102
+ .text-sm { font-size: 0.875rem; }
103
+ .text-2xl { font-size: 1.5rem; }
104
+ .font-medium { font-weight: 500; }
105
+ .font-semibold { font-weight: 600; }
106
+ .font-bold { font-weight: 700; }
107
+ .uppercase { text-transform: uppercase; }
108
+ .tracking-wide { letter-spacing: 0.03em; }
109
+ .text-left { text-align: left; }
110
+ .text-right { text-align: right; }
111
+ .text-center { text-align: center; }
112
+
113
+ .p-4 { padding: 1rem; }
114
+ .px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
115
+ .px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
116
+ .px-4 { padding-left: 1rem; padding-right: 1rem; }
117
+ .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
118
+ .py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
119
+ .py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
120
+ .py-8 { padding-top: 2rem; padding-bottom: 2rem; }
121
+ .pl-5 { padding-left: 1.25rem; }
122
+
123
+ .shadow-sm { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); }
124
+ .overflow-hidden { overflow: hidden; }
125
+ .object-cover { object-fit: cover; }
126
+ .whitespace-pre-line { white-space: pre-line; }
127
+ .list-disc { list-style: disc; }
128
+
129
+ .divide-y > * + * {
130
+ border-top: 1px solid #e5e7eb;
131
+ }
132
+
133
+ .divide-gray-100 > * + * {
134
+ border-top-color: #f3f4f6;
135
+ }
136
+
137
+ .divide-gray-200 > * + * {
138
+ border-top-color: #e5e7eb;
139
+ }
140
+
141
+ .hover\:bg-gray-50:hover { background: #f9fafb; }
142
+ .hover\:bg-blue-700:hover { background: #1d4ed8; }
143
+ .hover\:bg-red-700:hover { background: #b91c1c; }
144
+ .hover\:bg-gray-700:hover { background: #374151; }
145
+ .hover\:text-blue-700:hover { color: #1d4ed8; }
146
+
147
+ input,
148
+ textarea,
149
+ select,
150
+ button {
151
+ font: inherit;
152
+ }
153
+
154
+ input[type="text"],
155
+ input[type="email"],
156
+ input[type="url"],
157
+ input[type="number"],
158
+ input[type="date"],
159
+ textarea {
160
+ box-sizing: border-box;
161
+ }
162
+
163
+ button,
164
+ input[type="submit"] {
165
+ cursor: pointer;
166
+ }
167
+
168
+ table {
169
+ border-collapse: collapse;
170
+ }
171
+
172
+ /* Layout breathing room */
173
+ body > * {
174
+ max-width: 1100px;
175
+ margin: 1.5rem auto;
176
+ padding: 0 1rem;
177
+ }
178
+
179
+ @media (min-width: 768px) {
180
+ .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
181
+ .md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
182
+ .md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
183
+ .md\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
184
+ .md\:items-end { align-items: end; }
185
+ .md\:col-span-2 { grid-column: span 2 / span 2; }
186
+ .md\:col-span-3 { grid-column: span 3 / span 3; }
187
+ }
@@ -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
@@ -3,8 +3,14 @@ module Rails
3
3
  class ApplicationController < ActionController::Base
4
4
  protect_from_forgery with: :exception
5
5
 
6
+ layout :rails_contact_layout
7
+
6
8
  private
7
9
 
10
+ def rails_contact_layout
11
+ Rails::Contact.configuration.inherit_host_layout ? "application" : "rails/contact/application"
12
+ end
13
+
8
14
  def t_flash(key, default:)
9
15
  I18n.t("rails_contact.flash.#{key}", default: default)
10
16
  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