SQLite is the simplest relational database to use from JRuby desktop applications, it is built-in on all platforms (Mac, Windows, and Linux), and it can be utilized conveniently via ActiveRecord, the Rails object-relational mapping library following the Active Record Enterprise Application Architecture Pattern. In fact, I have used it a few years ago to build Are We There Yet?, a small project management app, using Glimmer DSL for SWT and bidirectional data-binding. In this blog post tutorial, I cover how to configure a SQLite database and connect to it using ActiveRecord from within a Ruby desktop application, data-binding the ActiveRecord Model to the GUI View bidirectionally.
Let us build a simple Contact Manager desktop app to demonstrate how to use a relational database to store contacts in a SQLite database via ActiveRecord.
Requirements:
- Store contacts having the following attributes:
- First Name
- Last Name
- Phone
- Street
- City
- State/Province
- Zip/Postal Code
- Country
- List stored contacts
- Search stored contacts by any of the contact attributes.
- Sort contacts by First Name, Last Name, Email, Phone, or Full Address
- Edit a stored contact
- Delete a stored contact
- Delete all stored contacts
Implementation:
We will use Glimmer DSL for SWT (JRuby Desktop Development GUI Framework) to build the solution. Nonetheless, the same solution could be implemented with Glimmer DSL for LibUI using MRI CRuby, or any of the other Glimmer desktop GUI libraries for that matter.
1- Gem Install
We will start by scaffolding a Ruby desktop application using Glimmer DSL for SWT v4.23.1.4. To do so, the library prerequisites (JDK 18 and JRuby 9.3.4.0) must be setup. Afterwards, you must install the glimmer-dsl-swt gem by running the following command (you can try the latest version if you prefer by leaving the version option out, but it might vary from this tutorial):
gem install glimmer-dsl-swt -v4.23.1.4
% gem install glimmer-dsl-swt -v4.23.1.4 Fetching text-table-1.2.4.gem Fetching rouge-3.28.0.gem Fetching super_module-1.4.1.gem Fetching wisper-2.0.1.gem Fetching method_source-1.0.0.gem Fetching tty-screen-0.8.1.gem Fetching tty-cursor-0.7.1.gem Fetching tty-reader-0.9.0.gem Fetching tty-color-0.6.0.gem Fetching pastel-0.8.0.gem Fetching tty-prompt-0.23.1.gem Fetching rake-tui-0.2.3.gem Fetching awesome_print-1.9.2.gem Fetching os-1.1.4.gem Fetching nested_inherited_jruby_include_package-0.3.0.gem Fetching jruby-win32ole-0.8.5.gem Fetching facets-3.1.0.gem Fetching array_include_methods-1.4.0.gem Fetching glimmer-2.7.3.gem Fetching puts_debuggerer-0.13.5.gem Fetching glimmer-dsl-swt-4.23.1.4.gem Fetching concurrent-ruby-1.1.10.gem Successfully installed text-table-1.2.4 Successfully installed method_source-1.0.0 Successfully installed super_module-1.4.1 Successfully installed rouge-3.28.0 Successfully installed wisper-2.0.1 Successfully installed tty-screen-0.8.1 Successfully installed tty-cursor-0.7.1 Successfully installed tty-reader-0.9.0 Successfully installed tty-color-0.6.0 Successfully installed pastel-0.8.0 Successfully installed tty-prompt-0.23.1 Successfully installed rake-tui-0.2.3 Successfully installed awesome_print-1.9.2 Successfully installed puts_debuggerer-0.13.5 Successfully installed os-1.1.4 Successfully installed nested_inherited_jruby_include_package-0.3.0 Successfully installed jruby-win32ole-0.8.5 Successfully installed facets-3.1.0 Successfully installed array_include_methods-1.4.0 Successfully installed glimmer-2.7.3 Successfully installed concurrent-ruby-1.1.10 You are ready to use `glimmer` and `girb` commands on Windows and Linux. On the Mac, run `glimmer-setup` command to complete setup of Glimmer DSL for SWT (it will configure a Mac required jruby option globally `-J-XstartOnFirstThread` so that you do not have to add manually), making `glimmer` and `girb` commands ready for use: glimmer-setup Successfully installed glimmer-dsl-swt-4.23.1.4 22 gems installed
If you are running on a Mac machine, run the command mentioned in the post gem installation instructions (unless you have run it before):
glimmer-setup
2- Scaffolding
To scaffold an application called contact_manager
with Glimmer DSL for SWT, you can run the following command:
glimmer "scaffold[contact_manager]"
This will generate the files of a Hello, World! application and launch it afterwards. Below is the output, including all the generated files.
% glimmer "scaffold[contact_manager]" Fetching highline-2.0.3.gem Fetching rack-2.2.3.gem Fetching semver2-3.4.2.gem Fetching kamelcase-0.0.2.gem Fetching nokogiri-1.13.4-java.gem Fetching multi_xml-0.6.0.gem Fetching multi_json-1.15.0.gem Fetching jwt-2.3.0.gem Fetching ruby2_keywords-0.0.5.gem Fetching faraday-retry-1.0.3.gem Fetching faraday-rack-1.0.0.gem Fetching faraday-patron-1.0.0.gem Fetching faraday-net_http-1.0.1.gem Fetching faraday-net_http_persistent-1.2.0.gem Fetching multipart-post-2.1.1.gem Fetching faraday-multipart-1.0.3.gem Fetching faraday-httpclient-1.0.1.gem Fetching faraday-excon-1.1.0.gem Fetching faraday-em_synchrony-1.0.0.gem Fetching faraday-em_http-1.0.0.gem Fetching hashie-3.6.0.gem Fetching thread_safe-0.3.6-java.gem Fetching descendants_tracker-0.0.4.gem Fetching public_suffix-4.0.6.gem Fetching addressable-2.8.0.gem Fetching github_api-0.19.0.gem Fetching faraday-1.10.0.gem Fetching oauth2-1.4.9.gem Fetching rchardet-1.8.0.gem Fetching git-1.10.2.gem Fetching builder-3.2.4.gem Fetching juwelier-2.4.9.gem Successfully installed semver2-3.4.2 Successfully installed nokogiri-1.13.4-java Successfully installed kamelcase-0.0.2 Successfully installed highline-2.0.3 Successfully installed rack-2.2.3 Successfully installed multi_xml-0.6.0 Successfully installed multi_json-1.15.0 Successfully installed jwt-2.3.0 Successfully installed ruby2_keywords-0.0.5 Successfully installed faraday-retry-1.0.3 Successfully installed faraday-rack-1.0.0 Successfully installed faraday-patron-1.0.0 Successfully installed faraday-net_http_persistent-1.2.0 Successfully installed faraday-net_http-1.0.1 Successfully installed multipart-post-2.1.1 Successfully installed faraday-multipart-1.0.3 Successfully installed faraday-httpclient-1.0.1 Successfully installed faraday-excon-1.1.0 Successfully installed faraday-em_synchrony-1.0.0 Successfully installed faraday-em_http-1.0.0 Successfully installed faraday-1.10.0 Successfully installed oauth2-1.4.9 Successfully installed hashie-3.6.0 Successfully installed thread_safe-0.3.6-java Successfully installed descendants_tracker-0.0.4 Successfully installed public_suffix-4.0.6 Successfully installed addressable-2.8.0 Successfully installed github_api-0.19.0 Successfully installed rchardet-1.8.0 Successfully installed git-1.10.2 Successfully installed builder-3.2.4 Successfully installed juwelier-2.4.9 32 gems installed irb_chwscreate.gitignore irb_chwscreateRakefile irb_chwscreateGemfile irb_chwscreateLICENSE.txt irb_chwscreateREADME.markdown irb_chwscreate.document irb_chwscreatelib irb_chwscreatelib/contact_manager.rb irb_chwscreate.rspec Juwelier has prepared your gem in ./contact_manager Created contact_manager/.gitignore Created contact_manager/.ruby-version Created contact_manager/.ruby-gemset Created contact_manager/VERSION Created contact_manager/LICENSE.txt Created contact_manager/Gemfile Created contact_manager/Rakefile Created contact_manager/app/contact_manager.rb Created contact_manager/app/contact_manager/view/app_view.rb Created contact_manager/icons/windows/Contact Manager.ico Created contact_manager/icons/macosx/Contact Manager.icns Created contact_manager/icons/linux/Contact Manager.png Created contact_manager/app/contact_manager/launch.rb Created contact_manager/bin/contact_manager jruby-9.3.4.0 - #gemset created /Users/andymaleh/.rvm/gems/jruby-9.3.4.0@contact_manager jruby-9.3.4.0 - #generating contact_manager wrappers.............. Fetching gem metadata from https://rubygems.org/......... Resolving dependencies................... Using rake 13.0.6 Fetching public_suffix 4.0.6 Fetching array_include_methods 1.4.0 Fetching awesome_print 1.9.2 Fetching builder 3.2.4 Installing builder 3.2.4 Installing array_include_methods 1.4.0 Installing awesome_print 1.9.2 Installing public_suffix 4.0.6 Using bundler 2.3.9 Fetching concurrent-ruby 1.1.10 Fetching thread_safe 0.3.6 (java) Installing concurrent-ruby 1.1.10 Fetching diff-lcs 1.5.0 Installing thread_safe 0.3.6 (java) Installing diff-lcs 1.5.0 Fetching docile 1.4.0 Installing docile 1.4.0 Fetching facets 3.1.0 Fetching faraday-em_http 1.0.0 Fetching faraday-em_synchrony 1.0.0 Installing faraday-em_http 1.0.0 Installing facets 3.1.0 Installing faraday-em_synchrony 1.0.0 Fetching faraday-excon 1.1.0 Fetching faraday-httpclient 1.0.1 Installing faraday-excon 1.1.0 Installing faraday-httpclient 1.0.1 Fetching multipart-post 2.1.1 Fetching faraday-net_http 1.0.1 Installing multipart-post 2.1.1 Installing faraday-net_http 1.0.1 Fetching faraday-net_http_persistent 1.2.0 Fetching faraday-patron 1.0.0 Fetching faraday-rack 1.0.0 Installing faraday-net_http_persistent 1.2.0 Installing faraday-rack 1.0.0 Installing faraday-patron 1.0.0 Fetching faraday-retry 1.0.3 Installing faraday-retry 1.0.3 Fetching ruby2_keywords 0.0.5 Fetching rchardet 1.8.0 Installing ruby2_keywords 0.0.5 Installing rchardet 1.8.0 Fetching hashie 3.6.0 Fetching jwt 2.3.0 Installing hashie 3.6.0 Installing jwt 2.3.0 Fetching multi_json 1.15.0 Installing multi_json 1.15.0 Fetching multi_xml 0.6.0 Fetching rack 2.2.3 Installing multi_xml 0.6.0 Installing rack 2.2.3 Fetching jruby-win32ole 0.8.5 Fetching nested_inherited_jruby_include_package 0.3.0 Installing nested_inherited_jruby_include_package 0.3.0 Installing jruby-win32ole 0.8.5 Fetching os 1.1.4 Installing os 1.1.4 Fetching tty-color 0.6.0 Fetching tty-cursor 0.7.1 Fetching tty-screen 0.8.1 Installing tty-color 0.6.0 Installing tty-cursor 0.7.1 Installing tty-screen 0.8.1 Fetching wisper 2.0.1 Fetching rouge 3.28.0 Fetching method_source 1.0.0 Installing wisper 2.0.1 Installing method_source 1.0.0 Installing rouge 3.28.0 Fetching text-table 1.2.4 Installing text-table 1.2.4 Fetching highline 2.0.3 Installing highline 2.0.3 Using jar-dependencies 0.4.1 Fetching jruby-jars 9.3.4.0 Fetching jruby-rack 1.1.21 Installing jruby-rack 1.1.21 Fetching semver2 3.4.2 Installing semver2 3.4.2 Fetching racc 1.6.0 (java) Installing racc 1.6.0 (java) Fetching rspec-support 3.5.0 Installing rspec-support 3.5.0 Fetching rubyzip 1.3.0 Fetching simplecov-html 0.12.3 Installing rubyzip 1.3.0 Installing simplecov-html 0.12.3 Fetching simplecov_json_formatter 0.1.4 Installing simplecov_json_formatter 0.1.4 Fetching addressable 2.8.0 Fetching puts_debuggerer 0.13.5 Installing puts_debuggerer 0.13.5 Installing addressable 2.8.0 Fetching descendants_tracker 0.0.4 Installing descendants_tracker 0.0.4 Fetching faraday-multipart 1.0.3 Installing faraday-multipart 1.0.3 Fetching git 1.10.2 Fetching pastel 0.8.0 Fetching super_module 1.4.1 Installing git 1.10.2 Installing pastel 0.8.0 Installing super_module 1.4.1 Fetching tty-reader 0.9.0 Fetching psych 4.0.3 (java) Installing tty-reader 0.9.0 Fetching kamelcase 0.0.2 Installing kamelcase 0.0.2 Installing jruby-jars 9.3.4.0 Installing psych 4.0.3 (java) Fetching nokogiri 1.13.4 (java) Fetching rspec-core 3.5.4 Installing rspec-core 3.5.4 jar dependencies for psych-4.0.3-java.gemspec . . . Installing gem 'ruby-maven' . . . Fetching rspec-expectations 3.5.0 Installing rspec-expectations 3.5.0 Installing nokogiri 1.13.4 (java) Fetching rspec-mocks 3.5.0 Installing rspec-mocks 3.5.0 Fetching simplecov 0.21.2 Installing simplecov 0.21.2 Fetching faraday 1.10.0 Installing faraday 1.10.0 Fetching glimmer 2.7.3 Installing glimmer 2.7.3 Fetching tty-prompt 0.23.1 Installing tty-prompt 0.23.1 Fetching rspec 3.5.0 Fetching warbler 2.0.5 Installing rspec 3.5.0 Installing warbler 2.0.5 Fetching oauth2 1.4.9 Fetching rake-tui 0.2.3 Installing rake-tui 0.2.3 Installing oauth2 1.4.9 Fetching glimmer-dsl-swt 4.23.1.4 Fetching github_api 0.19.0 using maven for the first time results in maven downloading all its default plugin and can take time. as those plugins get cached on disk and further execution of maven is much faster then the first time. Installing github_api 0.19.0 Installing glimmer-dsl-swt 4.23.1.4 2022-04-11T20:47:17.877-04:00 [main] WARN FilenoUtil : Native subprocess control requires open access to the JDK IO subsystem Pass '--add-opens java.base/sun.nio.ch=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED' to enable. org.yaml:snakeyaml:1.28:compile Fetching rdoc 6.4.0 Installing rdoc 6.4.0 Fetching juwelier 2.4.9 Installing juwelier 2.4.9 Bundle complete! 5 Gemfile dependencies, 74 gems now installed. Use `bundle info [gemname]` to see where a bundled gem is installed. Post-install message from glimmer-dsl-swt: [" You are ready to use `glimmer` and `girb` commands on Windows and Linux. On the Mac, run `glimmer-setup` command to complete setup of Glimmer DSL for SWT (it will configure a Mac required jruby option globally `-J-XstartOnFirstThread` so that you do not have to add manually), making `glimmer` and `girb` commands ready for use: glimmer-setup "] exist .rspec create spec/spec_helper.rb Created contact_manager/spec/spec_helper.rb Generated: contact_manager.gemspec Locking gem jar-dependencies by downloading and storing in vendor/jars... lock_jars --vendor-dir vendor/jars Installing gem 'ruby-maven' . . . Installing gem 'ruby-maven-libs' . . . using maven for the first time results in maven downloading all its default plugin and can take time. as those plugins get cached on disk and further execution of maven is much faster then the first time. -- jar root dependencies -- 2022-04-11T20:48:09.018-04:00 [main] WARN FilenoUtil : Native subprocess control requires open access to the JDK IO subsystem Pass '--add-opens java.base/sun.nio.ch=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED' to enable. org.yaml:snakeyaml:1.28:compile vendor jars: irb_chws- create vendor/jars/org/yaml/snakeyaml/1.28/snakeyaml-1.28.jar Jars.lock created Generating JAR configuration (config/warble.rb) to use with Warbler... /Users/andymaleh/.rvm/gems/jruby-9.3.4.0@contact_manager/bin/warble cp /Users/andymaleh/.rvm/gems/jruby-9.3.4.0@contact_manager/gems/warbler-2.0.5/warble.rb config/warble.rb Generating JAR with Warbler... /Users/andymaleh/.rvm/gems/jruby-9.3.4.0@contact_manager/bin/warble application directory `db' does not exist or is not a directory; skipping application directory `docs' does not exist or is not a directory; skipping application directory `fonts' does not exist or is not a directory; skipping application directory `images' does not exist or is not a directory; skipping application directory `lib' does not exist or is not a directory; skipping application directory `package' does not exist or is not a directory; skipping application directory `script' does not exist or is not a directory; skipping application directory `sounds' does not exist or is not a directory; skipping application directory `videos' does not exist or is not a directory; skipping rm -f dist/contact_manager.jar Creating dist/contact_manager.jar Generating native executable with jpackage... Java Version 18 Detected! jpackage --type app-image --dest 'packages/bundles' --input 'dist' --main-class JarMain --main-jar 'contact_manager.jar' --name 'Contact Manager' --vendor 'Contact Manager' --icon 'icons/macosx/Contact Manager.icns' --java-options '-XstartOnFirstThread' --mac-package-name 'Contact Manager' --mac-package-identifier 'org.contact_manager.application.contact_manager' --app-version "1.0.0" --copyright "Copyright (c) 2022 contact_manager" --name 'Contact Manager' --description 'Contact Manager' Launching Glimmer Application: ./bin/contact_manager Launching Glimmer Application: /Users/andymaleh/code/contact_manager/app/contact_manager/launch.rb
Contact Manager Hello, World! Scaffolded Application:
3- Add ActiveRecord gems to Gemfile
Add the following ActiveRecord gems to Gemfile (we are using the latest versions compatible with JRuby 9.3.4.0, having Ruby 2.6.8 compatibility; ActiveRecord 7 requires a Ruby 2.7.0 compatible implementation, which JRuby does not support yet as of today, but should be supporting in the near future):
gem 'activerecord', '~> 6.1.5' gem 'activerecord-jdbcsqlite3-adapter', '~> 61.1', :platform => :jruby
Run:
bundle install
Output:
Fetching gem metadata from https://rubygems.org/....... Resolving dependencies.......................... Using rake 13.0.6 Using concurrent-ruby 1.1.10 Fetching zeitwerk 2.5.4 Fetching minitest 5.15.0 Using public_suffix 4.0.6 Fetching jdbc-sqlite3 3.28.0 Using array_include_methods 1.4.0 Using awesome_print 1.9.2 Using builder 3.2.4 Using bundler 2.3.9 Using thread_safe 0.3.6 (java) Using diff-lcs 1.5.0 Using docile 1.4.0 Using facets 3.1.0 Using faraday-em_http 1.0.0 Using faraday-em_synchrony 1.0.0 Using faraday-excon 1.1.0 Using faraday-httpclient 1.0.1 Using multipart-post 2.1.1 Using faraday-net_http 1.0.1 Using faraday-net_http_persistent 1.2.0 Using faraday-patron 1.0.0 Using faraday-rack 1.0.0 Using faraday-retry 1.0.3 Using ruby2_keywords 0.0.5 Using rchardet 1.8.0 Using hashie 3.6.0 Using jwt 2.3.0 Using multi_json 1.15.0 Using multi_xml 0.6.0 Using rack 2.2.3 Using jruby-win32ole 0.8.5 Using nested_inherited_jruby_include_package 0.3.0 Using os 1.1.4 Using tty-color 0.6.0 Using tty-cursor 0.7.1 Using tty-screen 0.8.1 Using wisper 2.0.1 Using rouge 3.28.0 Using method_source 1.0.0 Using text-table 1.2.4 Using highline 2.0.3 Using jar-dependencies 0.4.1 Using jruby-jars 9.3.4.0 Using jruby-rack 1.1.21 Using semver2 3.4.2 Using racc 1.6.0 (java) Using rspec-support 3.5.0 Using rubyzip 1.3.0 Using simplecov-html 0.12.3 Using simplecov_json_formatter 0.1.4 Fetching i18n 1.10.0 Installing zeitwerk 2.5.4 Installing minitest 5.15.0 Installing i18n 1.10.0 Fetching tzinfo 2.0.4 Using addressable 2.8.0 Using puts_debuggerer 0.13.5 Using descendants_tracker 0.0.4 Using glimmer 2.7.3 Using faraday-multipart 1.0.3 Using git 1.10.2 Using pastel 0.8.0 Using tty-reader 0.9.0 Using super_module 1.4.1 Using psych 4.0.3 (java) Using kamelcase 0.0.2 Using nokogiri 1.13.4 (java) Using rspec-core 3.5.4 Using rspec-expectations 3.5.0 Using rspec-mocks 3.5.0 Using warbler 2.0.5 Using simplecov 0.21.2 Using faraday 1.10.0 Using tty-prompt 0.23.1 Using rdoc 6.4.0 Using rspec 3.5.0 Using oauth2 1.4.9 Using rake-tui 0.2.3 Using github_api 0.19.0 Using glimmer-dsl-swt 4.23.1.4 Using juwelier 2.4.9 Installing tzinfo 2.0.4 Fetching activesupport 6.1.5 Installing activesupport 6.1.5 Fetching activemodel 6.1.5 Installing activemodel 6.1.5 Fetching activerecord 6.1.5 Installing activerecord 6.1.5 Fetching activerecord-jdbc-adapter 61.1 (java) Installing jdbc-sqlite3 3.28.0 Installing activerecord-jdbc-adapter 61.1 (java) Fetching activerecord-jdbcsqlite3-adapter 61.1 (java) Installing activerecord-jdbcsqlite3-adapter 61.1 (java) Bundle complete! 7 Gemfile dependencies, 84 gems now installed. Use `bundle info [gemname]` to see where a bundled gem is installed.
4- Add an ActiveRecord migration for the contacts database table
Add a db/migrate.rb file under the project root directory with the following content (you can change the ActiveRecord::Migration version depending on the gem version you are using), which will run migrations on every app start, and skip executed migrations on subsequent runs of the app:
migrate_dir = File.expand_path('../migrate', __FILE__) Dir.glob(File.join(migrate_dir, '**', '*.rb')).each {|migration| require migration} ActiveRecord::Migration[6.1].descendants.each do |migration| begin migration.migrate(:up) rescue => e raise e unless e.full_message.match(/table "[^"]+" already exists/) end end
And, add a db/migrate/20220411211513_create_contacts.rb (date/time prefix must be the time you created the file) with the following content:
require 'active_record' class CreateContacts < ActiveRecord::Migration[6.1] def change create_table :contacts do |t| t.string :first_name t.string :last_name t.string :email t.string :phone t.string :street t.string :city t.string :state_or_province t.string :zip_or_postal_code t.string :country end end end
5- Add a SQLite database with ActiveRecord
Create a db/db.rb file with the following content under the project root directory to set up the SQLite database and run migrations afterwards using db/migrate.rb:
require 'active_record' require 'jdbc/sqlite3' Jdbc::SQLite3.load_driver require 'activerecord-jdbcsqlite3-adapter' if defined? JRUBY_VERSION @connection = ActiveRecord::Base.establish_connection( adapter: 'sqlite3', database: File.join(Dir.home, 'db/contact_manager.sqlite3') ) require 'db/migrate'
6- Add the Contact ActiveRecord model
Create app/contact_manager/model/contact.rb and add the Contact model code to it:
require 'db/db' class Contact < ActiveRecord::Base STATES = [ 'AK', 'AL', 'AR', 'AZ', 'CA', 'CO', 'CT', 'DC', 'DE', 'FL', 'GA', 'HI', 'IA', 'ID', 'IL', 'IN', 'KS', 'KY', 'LA', 'MA', 'MD', 'ME', 'MI', 'MN', 'MO', 'MS', 'MT', 'NC', 'ND', 'NE', 'NH', 'NJ', 'NM', 'NV', 'NY', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC', 'SD', 'TN', 'TX', 'UT', 'VA', 'VT', 'WA', 'WI', 'WV', 'WY'] PROVINCES = ['NL', 'PE', 'NS', 'NB', 'QC', 'ON', 'MB', 'SK', 'AB', 'BC', 'YT', 'NT', 'NU'] validates :first_name, presence: true validates :last_name, presence: true validates :email, format: {with: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i, message: 'must be a valid email address'}, allow_blank: true validates :phone, format: {with: /\A[(]?[0-9]{3}[)]?[ -\.\/0-9]{7,9}\z/, message: 'must be a valid phone number'}, allow_blank: true validates :zip_or_postal_code, format: {with: /\A[a-z0-9][0-9][a-z0-9][ 0-9][a-z0-9][a-z0-9]?[0-9]?\z/i, message: 'must be a valid phone number'}, allow_blank: true validate :email_or_phone_is_present def address address_fields = [street, city, state_or_province, zip_or_postal_code, country] address_fields.map { |field| field.blank? ? nil : field }.compact.join(', ') end def country_options ['', 'Canada', 'USA'] end def state_or_province_options (PROVINCES + [''] + STATES) end private def email_or_phone_is_present if email.blank? && phone.blank? errors.add(:email, 'must be present unless phone is present') errors.add(:phone, 'must be present unless email is present') end end end
7- Add the ContactRepository model
Create app/contact_manager/model/contact_repository.rb and add the ContactRepository model code to it:
require 'singleton' require 'contact_manager/model/contact' class ContactRepository include Singleton def all Contact.all end def search(query_value) if query_value.present? attribute_names = Contact.new.attributes.keys conditions = attribute_names.reduce('') do |conditions, attribute| if conditions.blank? conditions += "lower(#{attribute}) like ?" else conditions += " OR lower(#{attribute}) like ?" end end Contact.where(conditions, *(["%#{query_value.downcase}%"]*attribute_names.count)) else all end end def destroy_all_contacts Contact.destroy_all end end
8- Add the ContactPresenter model
Create app/contact_manager/model/contact_presenter.rb and add the ContactPresenter model code to it:
require 'contact_manager/model/contact' require 'contact_manager/model/contact_repository' # ContactPresenter is an enhanced Controller that also # enables bidirectional data-binding of contact attributes class ContactPresenter attr_accessor :contacts, :query, :current_contact def initialize renew_current_contact self.contacts = ContactRepository.instance.all # Monitor Contact collection changes # the after_commit hook executes the block under a different object # binding, so we must use `this` to access self this = self Contact.after_commit(on: [:create, :update, :destroy]) do this.contacts = ContactRepository.instance.all end end def query=(query_value) @query = query_value self.contacts = ContactRepository.instance.search(query_value) end def renew_current_contact self.current_contact = Contact.new end def save_current_contact current_contact.save.tap do |saved| renew_current_contact if saved end end def destroy_current_contact if current_contact&.persisted? current_contact.destroy renew_current_contact end end def destroy_all_contacts ContactRepository.instance.destroy_all_contacts end end
9- Add the ContactForm custom widget view
Create app/contact_manager/view/contact_form.rb and add the contact_form
custom widget view code to it:
class ContactManager module View class ContactForm include Glimmer::UI::CustomWidget options :contact_presenter body { composite { grid_layout { num_columns 2 make_columns_equal_width true margin_width 0 margin_height 0 vertical_spacing 0 } form_column { form_field(:first_name) form_field(:last_name) form_field(:email) form_field(:phone) } form_column { form_field(:street) form_field(:city) form_field(:state_or_province, editor: :combo, editor_args: :read_only, property: :selection) form_field(:zip_or_postal_code) form_field(:country, editor: :combo, editor_args: :read_only, property: :selection) } composite { # having a composite ensures padding around button layout_data { horizontal_span 2 horizontal_alignment :fill grab_excess_horizontal_space true } grid_layout { margin_height 0 } button { layout_data { horizontal_alignment :fill grab_excess_horizontal_space true } text 'Save Contact' on_widget_selected do save_contact end } } } } def form_column(&content) composite { |the_composite| layout_data { horizontal_alignment :fill vertical_alignment :fill grab_excess_horizontal_space true grab_excess_vertical_space true } grid_layout { num_columns 2 make_columns_equal_width false } content.call(the_composite) } end def form_field(field, editor: :text, editor_args: [], property: :text) @form_field_labels ||= {} @form_field_labels[field] = label { layout_data { width_hint 120 } text field.to_s.gsub('_', ' ').titlecase } @form_field_texts ||= {} @form_field_texts[field] = send(editor, *editor_args) { layout_data { width_hint 150 horizontal_alignment :fill grab_excess_horizontal_space true } # use nested data-binding to monitor change of contact # in addition to contact field send(property) <=> [contact_presenter, "current_contact.#{field}"] on_key_pressed do |event| save_contact if event.keyCode == swt(:cr) end } end def save_contact if contact_presenter.save_current_contact reset_validations else show_validations end end def reset_validations contact_presenter.current_contact.attributes.keys.each do |attribute_name| @form_field_labels[attribute_name.to_sym]&.foreground = :black @form_field_labels[attribute_name.to_sym]&.tool_tip_text = nil end focus_first_field end def show_validations contact_presenter.current_contact.errors.errors.each do |error| @form_field_labels[error.attribute].foreground = :red @form_field_labels[error.attribute].tool_tip_text = error.full_message end end def focus_first_field @form_field_texts[:first_name].set_focus end end end end
10- Add the ContactTable custom widget view
Create app/contact_manager/view/contact_table.rb and add the contact_table
custom widget view code to it:
class ContactManager module View class ContactTable include Glimmer::UI::CustomWidget options :contact_presenter, :reset_validations_action body { composite { grid_layout { margin_height 0 } text(:search) { layout_data { horizontal_alignment :fill grab_excess_horizontal_space true } text <=> [contact_presenter, :query] } table { layout_data { height_hint 250 horizontal_alignment :fill grab_excess_horizontal_space true vertical_alignment :fill grab_excess_vertical_space true } table_column { text 'First Name' width 120 } table_column { text 'Last Name' width 120 } table_column { text 'Email' width 180 } table_column { text 'Phone' width 120 } table_column { text 'Address' width 320 } # Ensure converting to Array on read because contacts is an ActiveRecord collection, # but an Array object is required by Glimmer DSL for SWT table data-binding logic items <=> [contact_presenter, :contacts, on_read: :to_a, column_properties: [:first_name, :last_name, :email, :phone, :address]] selection <=> [contact_presenter, :current_contact, after_write: reset_validations_action] menu { menu_item { text '&Delete...' on_widget_selected do result = message_box(:yes, :no) { text 'Delete' message 'Are you sure you want to delete the selected contact?' }.open contact_presenter.destroy_current_contact if result == swt(:yes) end } } } } } end end end
11- Add the ContactManagerMenuBar custom widget view
Create app/contact_manager/view/contact_manager_menu_bar.rb and add the contact_manager_menu_bar
custom widget view code to it:
class ContactManager module View class ContactManagerMenuBar include Glimmer::UI::CustomWidget ACCELERATOR_KEY = OS.mac? ? :command : :ctrl options :contact_presenter, :about_action, :save_contact_action, :reset_validations_action body { menu_bar { menu { text '&Contact' menu_item { text '&New' accelerator ACCELERATOR_KEY, :n on_widget_selected do contact_presenter.renew_current_contact reset_validations_action.call end } menu_item { text '&Save' accelerator ACCELERATOR_KEY, :s on_widget_selected do save_contact_action.call end } menu_item { text '&Delete...' accelerator ACCELERATOR_KEY, :d on_widget_selected do result = message_box(:yes, :no) { text 'Delete' message 'Are you sure you want to delete the selected contact?' }.open contact_presenter.destroy_current_contact if result == swt(:yes) end } menu_item { text 'Dele&te All...' on_widget_selected do result = message_box(:yes, :no) { text 'Delete All' message 'Are you sure you want to delete all your contacts?' }.open contact_presenter.destroy_all_contacts if result == swt(:yes) end } } menu { text '&Help' menu_item { text '&About...' on_widget_selected do about_action.call end } } } } end end end
12- Update the AppView
Update app/contact_manager/view/app_view.rb by replacing all code with the following:
require 'contact_manager/model/contact_presenter' require 'contact_manager/view/contact_manager_menu_bar' require 'contact_manager/view/contact_form' require 'contact_manager/view/contact_table' class ContactManager module View class AppView include Glimmer::UI::Application before_body do @contact_presenter = ContactPresenter.new @display = display { on_about do display_about_dialog end on_preferences do display_about_dialog end } end body { shell { grid_layout image File.join(APP_ROOT, 'icons', 'windows', "Contact Manager.ico") if OS.windows? image File.join(APP_ROOT, 'icons', 'linux', "Contact Manager.png") unless OS.windows? text "Contact Manager" @contact_form = contact_form(contact_presenter: @contact_presenter) { layout_data { horizontal_alignment :fill grab_excess_horizontal_space true } } contact_table( contact_presenter: @contact_presenter, reset_validations_action: @contact_form.method(:reset_validations), ) { layout_data { horizontal_alignment :fill grab_excess_horizontal_space true vertical_alignment :fill grab_excess_vertical_space true } } contact_manager_menu_bar( contact_presenter: @contact_presenter, about_action: method(:display_about_dialog), save_contact_action: @contact_form.method(:save_contact), reset_validations_action: @contact_form.method(:reset_validations), ) } } def display_about_dialog message_box(body_root) { text 'About' message "Contact Manager #{VERSION}\n\n#{LICENSE}" }.open end end end end
13- Update the ContactManager entry point
Update app/contact_manager.rb by replacing all code with the following:
$LOAD_PATH.unshift(File.expand_path('..', __FILE__)) $LOAD_PATH.unshift(File.expand_path('../..', __FILE__)) begin require 'bundler/setup' Bundler.require(:default) rescue # this runs when packaged as a gem (no bundler) require 'glimmer-dsl-swt' # add more gems if needed require 'active_record' require 'activerecord-jdbcsqlite3-adapter' end class ContactManager include Glimmer APP_ROOT = File.expand_path('../..', __FILE__) VERSION = File.read(File.join(APP_ROOT, 'VERSION')) LICENSE = File.read(File.join(APP_ROOT, 'LICENSE.txt')) Display.app_name = 'Contact Manager' Display.app_version = ContactManager::VERSION end require 'contact_manager/view/app_view'
14- Replace app icons
Replace scaffolded app icons with the files below:
Mac (icons/macosx/Contact%20Manager.icns)
https://github.com/AndyObtiva/contact_manager/raw/master/icons/macosx/Contact%20Manager.icns
Windows (icons/windows/Contact%20Manager.ico)
https://github.com/AndyObtiva/contact_manager/raw/master/icons/windows/Contact%20Manager.ico
Linux (icons/linux/Contact%20Manager.png)
https://github.com/AndyObtiva/contact_manager/raw/master/icons/linux/Contact%20Manager.png
15- Run
Run with the following command from inside the project directory:
glimmer run
Or use the scaffolded binary script:
./bin/contact_manager
Result:
16- Package Native Executable
Glimmer DSL for SWT supports packaging native executables using the glimmer package
command. To get started with it, follow the packaging and distribution documentation.
You can run the following command on the Mac to package a native executable:
glimmer "package[dmg]"
On windows, you can run the following command assuming you have setup the WiX Toolset prerequisite:
glimmer "package[msi]"
On debian based Linux distros, you can run:
glimmer "package[deb]"
On redhat based Linux distros, you can run:
glimmer "package[rpm]"
Software Architecture & Design
Contact Manager follows the Model-View-Presenter flavor of MVC, so the View communicates to the Model via a Presenter, which is an enhanced Controller that enables bidirectional attribute data-binding between the Model and the View.
The View uses contact_form, contact_table, and contact_manager_menu_bar custom widgets (components).
The Contact Manager graphical user interface leverages the Master-Detail Interface Pattern by displaying a master list via contact_table and allowing navigation by selecting a Contact and displaying its details for editing in contact_form.
contact_table leverages the table widget, which has automatic in-memory sort support. contact_table also comes with a 'Delete...' right-click menu item (aka contextual pop up menu item) for deleting a selected Contact row.
contact_manager_menu_bar provides top menu bar actions like 'New', 'Save', 'Delete...', and 'Delete All...'
The Model layer includes a Contact and ContactRepository (DDD Repository Pattern) in addition to ContactPresenter (which is both a Controller and a Model at a higher level).
Contact follows the Active Record Pattern for Object Relational Mapping to store objects in a SQLite relational database table called contacts via a migration. It also implements ActiveRecord Validations for first_name, last_name, email, phone, and zip_or_postal_code fields.
ContactRepository provides the ability to search through all Contacts using the ActiveRecord Query Interface, triggered indirectly by ContactPresenter when typing into a text field that is on top of the contact_table .
The database is stored at File.join(Dir.home, 'db/contact_manager.sqlite3')
Reference Project at GitHub
A canonical version of the Contact Manager app exists on GitHub:
https://github.com/AndyObtiva/contact_manager
Happy Glimmering!
P.S. One caveat to be aware of is there is currently a reported issue in using the activerecord-jdbcsqlite3-adapter gem on a Mac ARM64 machine. So, please avoid that platform until the issue is fixed.
Top comments (0)