├── .gitignore ├── .rspec ├── .ruby-version ├── Capfile ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── app ├── assets │ ├── config │ │ └── manifest.js │ ├── images │ │ └── favicon.png │ ├── javascripts │ │ └── application.js │ └── stylesheets │ │ ├── _helpers.sass │ │ └── application.scss ├── channels │ └── application_cable │ │ ├── channel.rb │ │ └── connection.rb ├── controllers │ ├── application_controller.rb │ ├── companies_controller.rb │ ├── concerns │ │ └── .keep │ ├── pages_controller.rb │ ├── placeholders_controller.rb │ ├── projects_controller.rb │ ├── tasks_controller.rb │ └── tenants_controller.rb ├── helpers │ ├── application_helper.rb │ ├── flashes_helper.rb │ ├── placeholder_helper.rb │ └── tour_helper.rb ├── jobs │ └── application_job.rb ├── mailers │ └── application_mailer.rb ├── models │ ├── application_record.rb │ ├── company.rb │ ├── concerns │ │ └── .keep │ ├── fragment_explainer.rb │ ├── project.rb │ ├── task.rb │ └── tenant.rb └── views │ ├── companies │ ├── _form.erb │ ├── edit.erb │ ├── index.erb │ ├── new.erb │ └── show.erb │ ├── layouts │ ├── _fragment_explainer.erb │ ├── _nav.erb │ └── application.erb │ ├── pages │ └── home.erb │ ├── placeholders │ ├── _form.erb │ ├── _index.erb │ ├── _show.erb │ ├── _table.erb │ ├── _table_inner.erb │ ├── _templates.erb │ ├── form.erb │ ├── index.erb │ ├── show.erb │ └── table.erb │ ├── projects │ ├── _form.erb │ ├── edit.erb │ ├── index.erb │ ├── new.erb │ ├── show.erb │ └── suggest_name.erb │ └── tasks │ ├── _form.erb │ ├── _head.erb │ ├── _task.erb │ ├── edit.erb │ ├── index.erb │ ├── new.erb │ └── show.erb ├── bin ├── bundle ├── rails ├── rake ├── setup └── spring ├── config.ru ├── config ├── application.rb ├── boot.rb ├── cable.yml ├── database.yml ├── deploy.rb ├── deploy │ └── production.rb ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── initializers │ ├── application_controller_renderer.rb │ ├── assets.rb │ ├── backtrace_silencers.rb │ ├── content_security_policy.rb │ ├── cookies_serializer.rb │ ├── ext.rb │ ├── filter_parameter_logging.rb │ ├── inflections.rb │ ├── mime_types.rb │ └── wrap_parameters.rb ├── locales │ └── en.yml ├── puma.rb ├── routes.rb ├── schedule.rb ├── secrets.yml ├── spring.rb └── storage.yml ├── db ├── migrate │ ├── 20200716071558_add_nested_form_test_records.rb │ ├── 20201209112615_create_tenants.rb │ ├── 20210107141505_create_tasks.rb │ ├── 20210623061414_drop_budgets.rb │ ├── 20211201123306_index_foreign_keys.rb │ └── 20241005194107_add_position_to_tasks.rb └── seeds.rb ├── lib ├── assets │ └── .keep ├── ext │ ├── action_view │ │ └── form_builder │ │ │ └── error_message.rb │ └── ruby │ │ ├── enumerable │ │ └── enumerable_natural_sort.rb │ │ └── string │ │ └── string_to_sort_atoms.rb └── tasks │ ├── .keep │ └── db.rake ├── log └── .keep ├── public ├── 404.html ├── 422.html ├── 500.html ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── favicon.ico └── robots.txt ├── spec ├── spec_helper.rb ├── support │ └── capybara.rb └── system │ └── home_spec.rb ├── storage └── .keep ├── test ├── application_system_test_case.rb ├── channels │ └── application_cable │ │ └── connection_test.rb ├── controllers │ └── .keep ├── fixtures │ ├── .keep │ └── files │ │ └── .keep ├── helpers │ └── .keep ├── integration │ └── .keep ├── mailers │ └── .keep ├── models │ └── .keep ├── system │ └── .keep └── test_helper.rb └── vendor └── asset-libs ├── .keep ├── roboto-mono ├── roboto-mono-v22-latin-regular.eot ├── roboto-mono-v22-latin-regular.svg ├── roboto-mono-v22-latin-regular.ttf ├── roboto-mono-v22-latin-regular.woff ├── roboto-mono-v22-latin-regular.woff2 └── roboto-mono.scss └── roboto ├── roboto-v30-latin-500.eot ├── roboto-v30-latin-500.svg ├── roboto-v30-latin-500.ttf ├── roboto-v30-latin-500.woff ├── roboto-v30-latin-500.woff2 ├── roboto-v30-latin-regular.eot ├── roboto-v30-latin-regular.svg ├── roboto-v30-latin-regular.ttf ├── roboto-v30-latin-regular.woff ├── roboto-v30-latin-regular.woff2 └── roboto.scss /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all logfiles and tempfiles. 11 | /log/* 12 | /tmp/* 13 | !/log/.keep 14 | !/tmp/.keep 15 | 16 | # Ignore uploaded files in development. 17 | /storage/* 18 | !/storage/.keep 19 | 20 | /public/assets 21 | .byebug_history 22 | 23 | # Ignore master key for decrypting credentials and more. 24 | /config/master.key 25 | 26 | /public/packs 27 | /public/packs-test 28 | /public/system 29 | /node_modules 30 | /yarn-error.log 31 | yarn-debug.log* 32 | .yarn-integrity 33 | 34 | /.idea 35 | /db/schema.rb 36 | /db/*.sqlite3 37 | 38 | /coverage 39 | 40 | # LibreOffice lock file 41 | .~lock.*xlsx# 42 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.6.6 2 | -------------------------------------------------------------------------------- /Capfile: -------------------------------------------------------------------------------- 1 | # Load DSL and set up stages 2 | require "capistrano/setup" 3 | 4 | # Include default deployment tasks 5 | require "capistrano/deploy" 6 | 7 | # Load the SCM plugin appropriate to your project: 8 | # 9 | # require "capistrano/scm/hg" 10 | # install_plugin Capistrano::SCM::Hg 11 | # or 12 | # require "capistrano/scm/svn" 13 | # install_plugin Capistrano::SCM::Svn 14 | # or 15 | require "capistrano/scm/git" 16 | install_plugin Capistrano::SCM::Git 17 | 18 | # Include tasks from other gems included in your Gemfile 19 | # 20 | # For documentation on these, see for example: 21 | # 22 | # https://github.com/capistrano/rvm 23 | # https://github.com/capistrano/rbenv 24 | # https://github.com/capistrano/chruby 25 | # https://github.com/capistrano/bundler 26 | # https://github.com/capistrano/rails 27 | # https://github.com/capistrano/passenger 28 | # 29 | # require "capistrano/rvm" 30 | # require "capistrano/rbenv" 31 | # require "capistrano/chruby" 32 | require "capistrano/bundler" 33 | require "capistrano/rails/assets" 34 | require "capistrano/rails/migrations" 35 | require 'whenever/capistrano' 36 | require "capistrano/opscomplete" 37 | require "capistrano/passenger" 38 | 39 | # Load custom tasks from `lib/capistrano/tasks` if you have any defined 40 | Dir.glob("lib/capistrano/tasks/*.rake").each { |r| import r } 41 | 42 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' 5 | gem 'rails', '~> 6.0' 6 | gem 'pg' 7 | gem 'sqlite3' 8 | # Use Puma as the app server 9 | gem 'puma' 10 | # Use SCSS for stylesheets 11 | gem 'sass-rails' 12 | 13 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder 14 | gem 'jbuilder' 15 | 16 | gem 'coffee-script' 17 | 18 | gem 'faker' 19 | 20 | # Reduces boot times through caching; required in config/boot.rb 21 | gem 'bootsnap', '>= 1.4.2', require: false 22 | 23 | # gem 'unpoly-rails', path: '../unpoly-rails' 24 | gem 'unpoly-rails', '~> 3.10' 25 | 26 | gem 'bootstrap', '>=5.0.0' 27 | 28 | gem 'minidusen' 29 | 30 | gem 'has_defaults' 31 | 32 | gem 'database_cleaner' 33 | 34 | gem 'whenever', require: false 35 | 36 | gem 'terser' 37 | 38 | group :development, :test do 39 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console 40 | gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] 41 | end 42 | 43 | group :development do 44 | # Access an interactive console on exception pages or by calling 'console' anywhere in the code. 45 | gem 'web-console', '>= 3.3.0' 46 | gem 'listen', '~> 3.2' 47 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring 48 | gem 'spring' 49 | gem 'spring-watcher-listen', '~> 2.0.0' 50 | 51 | gem 'capistrano', '~> 3.10', require: false 52 | gem 'capistrano-rails', '~> 1.3', require: false 53 | gem 'capistrano-passenger', require: false 54 | gem 'capistrano-opscomplete', require: false 55 | end 56 | 57 | group :test do 58 | gem 'rspec-rails' 59 | gem 'capybara' 60 | gem 'selenium-webdriver' 61 | end 62 | 63 | 64 | # group :test do 65 | # # Adds support for Capybara system testing and selenium driver 66 | # gem 'capybara', '>= 2.15' 67 | # gem 'selenium-webdriver' 68 | # # Easy installation and use of web drivers to run system tests with browsers 69 | # gem 'webdrivers' 70 | # end 71 | 72 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 73 | gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] 74 | 75 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actioncable (6.1.7.2) 5 | actionpack (= 6.1.7.2) 6 | activesupport (= 6.1.7.2) 7 | nio4r (~> 2.0) 8 | websocket-driver (>= 0.6.1) 9 | actionmailbox (6.1.7.2) 10 | actionpack (= 6.1.7.2) 11 | activejob (= 6.1.7.2) 12 | activerecord (= 6.1.7.2) 13 | activestorage (= 6.1.7.2) 14 | activesupport (= 6.1.7.2) 15 | mail (>= 2.7.1) 16 | actionmailer (6.1.7.2) 17 | actionpack (= 6.1.7.2) 18 | actionview (= 6.1.7.2) 19 | activejob (= 6.1.7.2) 20 | activesupport (= 6.1.7.2) 21 | mail (~> 2.5, >= 2.5.4) 22 | rails-dom-testing (~> 2.0) 23 | actionpack (6.1.7.2) 24 | actionview (= 6.1.7.2) 25 | activesupport (= 6.1.7.2) 26 | rack (~> 2.0, >= 2.0.9) 27 | rack-test (>= 0.6.3) 28 | rails-dom-testing (~> 2.0) 29 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 30 | actiontext (6.1.7.2) 31 | actionpack (= 6.1.7.2) 32 | activerecord (= 6.1.7.2) 33 | activestorage (= 6.1.7.2) 34 | activesupport (= 6.1.7.2) 35 | nokogiri (>= 1.8.5) 36 | actionview (6.1.7.2) 37 | activesupport (= 6.1.7.2) 38 | builder (~> 3.1) 39 | erubi (~> 1.4) 40 | rails-dom-testing (~> 2.0) 41 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 42 | activejob (6.1.7.2) 43 | activesupport (= 6.1.7.2) 44 | globalid (>= 0.3.6) 45 | activemodel (6.1.7.2) 46 | activesupport (= 6.1.7.2) 47 | activerecord (6.1.7.2) 48 | activemodel (= 6.1.7.2) 49 | activesupport (= 6.1.7.2) 50 | activestorage (6.1.7.2) 51 | actionpack (= 6.1.7.2) 52 | activejob (= 6.1.7.2) 53 | activerecord (= 6.1.7.2) 54 | activesupport (= 6.1.7.2) 55 | marcel (~> 1.0) 56 | mini_mime (>= 1.1.0) 57 | activesupport (6.1.7.2) 58 | concurrent-ruby (~> 1.0, >= 1.0.2) 59 | i18n (>= 1.6, < 2) 60 | minitest (>= 5.1) 61 | tzinfo (~> 2.0) 62 | zeitwerk (~> 2.3) 63 | addressable (2.7.0) 64 | public_suffix (>= 2.0.2, < 5.0) 65 | airbrussh (1.4.0) 66 | sshkit (>= 1.6.1, != 1.7.0) 67 | autoprefixer-rails (10.4.16.0) 68 | execjs (~> 2) 69 | base64 (0.2.0) 70 | bindex (0.8.1) 71 | bootsnap (1.4.6) 72 | msgpack (~> 1.0) 73 | bootstrap (5.3.3) 74 | autoprefixer-rails (>= 9.1.0) 75 | popper_js (>= 2.11.8, < 3) 76 | builder (3.3.0) 77 | byebug (11.1.3) 78 | capistrano (3.16.0) 79 | airbrussh (>= 1.0.0) 80 | i18n 81 | rake (>= 10.0.0) 82 | sshkit (>= 1.9.0) 83 | capistrano-bundler (2.0.1) 84 | capistrano (~> 3.1) 85 | capistrano-opscomplete (1.0.0) 86 | capistrano (>= 3.0, < 4.0.0) 87 | rake 88 | capistrano-passenger (0.2.1) 89 | capistrano (~> 3.0) 90 | capistrano-rails (1.6.1) 91 | capistrano (~> 3.1) 92 | capistrano-bundler (>= 1.1, < 3) 93 | capybara (3.35.3) 94 | addressable 95 | mini_mime (>= 0.1.3) 96 | nokogiri (~> 1.8) 97 | rack (>= 1.6.0) 98 | rack-test (>= 0.6.3) 99 | regexp_parser (>= 1.5, < 3.0) 100 | xpath (~> 3.2) 101 | childprocess (3.0.0) 102 | chronic (0.10.2) 103 | coffee-script (2.4.1) 104 | coffee-script-source 105 | execjs 106 | coffee-script-source (1.12.2) 107 | concurrent-ruby (1.3.4) 108 | crass (1.0.6) 109 | database_cleaner (1.7.0) 110 | date (3.3.3) 111 | diff-lcs (1.4.4) 112 | edge_rider (2.3.0) 113 | activerecord (>= 3.2) 114 | erubi (1.13.0) 115 | execjs (2.7.0) 116 | faker (2.22.0) 117 | i18n (>= 1.8.11, < 2) 118 | ffi (1.13.1) 119 | globalid (1.1.0) 120 | activesupport (>= 5.0) 121 | has_defaults (0.4.4) 122 | activerecord 123 | i18n (1.14.6) 124 | concurrent-ruby (~> 1.0) 125 | jbuilder (2.10.0) 126 | activesupport (>= 5.0.0) 127 | listen (3.2.1) 128 | rb-fsevent (~> 0.10, >= 0.10.3) 129 | rb-inotify (~> 0.9, >= 0.9.10) 130 | logger (1.7.0) 131 | loofah (2.22.0) 132 | crass (~> 1.0.2) 133 | nokogiri (>= 1.12.0) 134 | mail (2.8.1) 135 | mini_mime (>= 0.1.1) 136 | net-imap 137 | net-pop 138 | net-smtp 139 | marcel (1.0.2) 140 | memoized (1.1.2) 141 | method_source (1.1.0) 142 | mini_mime (1.1.2) 143 | mini_portile2 (2.8.7) 144 | minidusen (0.11.0) 145 | activerecord (>= 3.2) 146 | activesupport (>= 3.2) 147 | edge_rider (>= 0.2.5) 148 | minitest (5.25.1) 149 | msgpack (1.3.3) 150 | net-imap (0.3.4) 151 | date 152 | net-protocol 153 | net-pop (0.1.2) 154 | net-protocol 155 | net-protocol (0.2.1) 156 | timeout 157 | net-scp (4.1.0) 158 | net-ssh (>= 2.6.5, < 8.0.0) 159 | net-sftp (4.0.0) 160 | net-ssh (>= 5.0.0, < 8.0.0) 161 | net-smtp (0.3.3) 162 | net-protocol 163 | net-ssh (7.3.0) 164 | nio4r (2.5.8) 165 | nokogiri (1.13.10) 166 | mini_portile2 (~> 2.8.0) 167 | racc (~> 1.4) 168 | ostruct (0.6.1) 169 | pg (1.2.2) 170 | popper_js (2.11.8) 171 | public_suffix (4.0.6) 172 | puma (4.3.8) 173 | nio4r (~> 2.0) 174 | racc (1.8.1) 175 | rack (2.2.9) 176 | rack-test (2.1.0) 177 | rack (>= 1.3) 178 | rails (6.1.7.2) 179 | actioncable (= 6.1.7.2) 180 | actionmailbox (= 6.1.7.2) 181 | actionmailer (= 6.1.7.2) 182 | actionpack (= 6.1.7.2) 183 | actiontext (= 6.1.7.2) 184 | actionview (= 6.1.7.2) 185 | activejob (= 6.1.7.2) 186 | activemodel (= 6.1.7.2) 187 | activerecord (= 6.1.7.2) 188 | activestorage (= 6.1.7.2) 189 | activesupport (= 6.1.7.2) 190 | bundler (>= 1.15.0) 191 | railties (= 6.1.7.2) 192 | sprockets-rails (>= 2.0.0) 193 | rails-dom-testing (2.2.0) 194 | activesupport (>= 5.0.0) 195 | minitest 196 | nokogiri (>= 1.6) 197 | rails-html-sanitizer (1.5.0) 198 | loofah (~> 2.19, >= 2.19.1) 199 | railties (6.1.7.2) 200 | actionpack (= 6.1.7.2) 201 | activesupport (= 6.1.7.2) 202 | method_source 203 | rake (>= 12.2) 204 | thor (~> 1.0) 205 | rake (13.2.1) 206 | rb-fsevent (0.10.4) 207 | rb-inotify (0.10.1) 208 | ffi (~> 1.0) 209 | regexp_parser (2.1.1) 210 | rspec-core (3.10.1) 211 | rspec-support (~> 3.10.0) 212 | rspec-expectations (3.10.1) 213 | diff-lcs (>= 1.2.0, < 2.0) 214 | rspec-support (~> 3.10.0) 215 | rspec-mocks (3.10.2) 216 | diff-lcs (>= 1.2.0, < 2.0) 217 | rspec-support (~> 3.10.0) 218 | rspec-rails (5.0.1) 219 | actionpack (>= 5.2) 220 | activesupport (>= 5.2) 221 | railties (>= 5.2) 222 | rspec-core (~> 3.10) 223 | rspec-expectations (~> 3.10) 224 | rspec-mocks (~> 3.10) 225 | rspec-support (~> 3.10) 226 | rspec-support (3.10.2) 227 | rubyzip (2.3.0) 228 | sass-rails (6.0.0) 229 | sassc-rails (~> 2.1, >= 2.1.1) 230 | sassc (2.4.0) 231 | ffi (~> 1.9) 232 | sassc-rails (2.1.2) 233 | railties (>= 4.0.0) 234 | sassc (>= 2.0) 235 | sprockets (> 3.0) 236 | sprockets-rails 237 | tilt 238 | selenium-webdriver (3.142.7) 239 | childprocess (>= 0.5, < 4.0) 240 | rubyzip (>= 1.2.2) 241 | spring (2.1.0) 242 | spring-watcher-listen (2.0.1) 243 | listen (>= 2.7, < 4.0) 244 | spring (>= 1.2, < 3.0) 245 | sprockets (4.2.0) 246 | concurrent-ruby (~> 1.0) 247 | rack (>= 2.2.4, < 4) 248 | sprockets-rails (3.4.2) 249 | actionpack (>= 5.2) 250 | activesupport (>= 5.2) 251 | sprockets (>= 3.0.0) 252 | sqlite3 (1.4.2) 253 | sshkit (1.24.0) 254 | base64 255 | logger 256 | net-scp (>= 1.1.2) 257 | net-sftp (>= 2.1.2) 258 | net-ssh (>= 2.8.0) 259 | ostruct 260 | terser (1.1.3) 261 | execjs (>= 0.3.0, < 3) 262 | thor (1.3.2) 263 | tilt (2.0.10) 264 | timeout (0.3.2) 265 | tzinfo (2.0.6) 266 | concurrent-ruby (~> 1.0) 267 | unpoly-rails (3.10.2) 268 | actionpack (>= 3.2) 269 | activesupport (>= 3.2) 270 | memoized 271 | railties (>= 3.2) 272 | web-console (4.2.1) 273 | actionview (>= 6.0.0) 274 | activemodel (>= 6.0.0) 275 | bindex (>= 0.4.0) 276 | railties (>= 6.0.0) 277 | websocket-driver (0.7.5) 278 | websocket-extensions (>= 0.1.0) 279 | websocket-extensions (0.1.5) 280 | whenever (1.0.0) 281 | chronic (>= 0.6.3) 282 | xpath (3.2.0) 283 | nokogiri (~> 1.8) 284 | zeitwerk (2.6.18) 285 | 286 | PLATFORMS 287 | ruby 288 | 289 | DEPENDENCIES 290 | bootsnap (>= 1.4.2) 291 | bootstrap (>= 5.0.0) 292 | byebug 293 | capistrano (~> 3.10) 294 | capistrano-opscomplete 295 | capistrano-passenger 296 | capistrano-rails (~> 1.3) 297 | capybara 298 | coffee-script 299 | database_cleaner 300 | faker 301 | has_defaults 302 | jbuilder 303 | listen (~> 3.2) 304 | minidusen 305 | pg 306 | puma 307 | rails (~> 6.0) 308 | rspec-rails 309 | sass-rails 310 | selenium-webdriver 311 | spring 312 | spring-watcher-listen (~> 2.0.0) 313 | sqlite3 314 | terser 315 | tzinfo-data 316 | unpoly-rails (~> 3.10) 317 | web-console (>= 3.3.0) 318 | whenever 319 | 320 | BUNDLED WITH 321 | 2.4.22 322 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Henning Koch 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | This README would normally document whatever steps are necessary to get the 4 | application up and running. 5 | 6 | Things you may want to cover: 7 | 8 | * Ruby version 9 | 10 | * System dependencies 11 | 12 | * Configuration 13 | 14 | * Database creation 15 | 16 | * Database initialization 17 | 18 | * How to run the test suite 19 | 20 | * Services (job queues, cache servers, search engines, etc.) 21 | 22 | * Deployment instructions 23 | 24 | * ... 25 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link application.js 4 | //= link application.css 5 | 6 | -------------------------------------------------------------------------------- /app/assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unpoly/unpoly-demo/HEAD/app/assets/images/favicon.png -------------------------------------------------------------------------------- /app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // Unpoly ////////////////////////////////////////////////////////////////////////////////////////////////////////////// 2 | 3 | //= require unpoly 4 | //= require unpoly-bootstrap5 5 | 6 | // Accelerate all links so the demo feels snappy. 7 | // We still assign out [up-follow] manually so there's less magic for demo viewers. 8 | up.link.config.instantSelectors.unshift('a[href]') 9 | up.link.config.preloadSelectors.unshift('a[href]') 10 | 11 | // We use a .form-group to contain each (label, input, error, help) tuple. 12 | // Unpoly can use this to only update the affected a group when validating. 13 | up.form.config.groupSelectors.unshift('.form-group') 14 | 15 | // Since we're rendering instant loading state for all interactions in the demo, 16 | // wait longer until we show the progress bar. 17 | up.network.config.lateTime = 1250 18 | 19 | // Enable more logging for curious users. 20 | up.log.enable() 21 | 22 | 23 | // Tour ///////////////////////////////////////////////////////////////////////////////////////////////////////////////// 24 | 25 | // Gray out tour dots once clicked. 26 | up.on('up:link:follow', '.tour-dot', (event, element) => { element.classList.add('viewed') }) 27 | 28 | 29 | // Fragment explainer ////////////////////////////////////////////////////////////////////////////////////////////////// 30 | 31 | up.compiler('.fragment-explainer', function(container) { 32 | let lastFragment = document.documentElement 33 | 34 | let targetExplainer = container.querySelector('.fragment-explainer--target') 35 | let revealTarget = container.querySelector('.fragment-explainer--reveal') 36 | let requestExplainer = container.querySelector('.fragment-explainer--request') 37 | let rttExplainer = container.querySelector('.fragment-explainer--rtt') 38 | 39 | return [ 40 | up.on('up:fragment:inserted', (_event, fragment) => { 41 | if (fragment.matches('[up-hungry]')) return 42 | if (fragment.querySelector('.placeholder')) return 43 | if (fragment.matches('up-modal, up-modal main, up-drawer, up-drawer main, up-popup, up-popup main')) fragment = up.layer.current.getBoxElement() 44 | lastFragment = fragment 45 | targetExplainer.innerText = up.fragment.toTarget(fragment, { verify: false }) 46 | if (config.showFragments.checked) showInsertedFlash(lastFragment) 47 | }), 48 | 49 | up.on(revealTarget, 'click', (event) => { 50 | up.event.halt(event) 51 | showInsertedFlash(lastFragment) 52 | }), 53 | 54 | up.on('up:link:follow up:form:submit', ({ renderOptions }) => { 55 | let method = up.util.normalizeMethod(renderOptions.method) 56 | requestExplainer.innerText = `${method} ${renderOptions.url}` 57 | }), 58 | 59 | up.on('up:fragment:loaded', ({ request, response, revalidating }) => { 60 | if (revalidating) return 61 | 62 | let rtt = Math.max(response.loadedAt - request.builtAt, 0) 63 | let info = `${rtt} ms` 64 | if (request.fromCache) { 65 | info += ' (cache)' 66 | } 67 | rttExplainer.innerHTML = info 68 | }) 69 | ] 70 | }) 71 | 72 | up.compiler('form#config', function(form) { 73 | return [ 74 | up.on('up:link:follow up:form:submit', function(event) { 75 | if (form.disableCache.checked) { 76 | event.renderOptions.cache = false 77 | } 78 | if (form.noPreviews.checked) { 79 | Object.assign(event.renderOptions, up.RenderOptions.NO_PREVIEWS) 80 | } 81 | if (form.noMotion.checked) { 82 | Object.assign(event.renderOptions, up.RenderOptions.NO_MOTION) 83 | } 84 | }), 85 | 86 | up.on('up:link:preload', (event) => { 87 | if (form.disableCache.checked) { 88 | event.preventDefault() 89 | } 90 | }), 91 | 92 | up.on('up:request:load', ({ request }) => { 93 | if (form.extraLatency.checked) { 94 | request.headers['X-Extra-Latency'] = 'true' 95 | } 96 | }), 97 | 98 | up.on('up:click', { capture: true }, (event) => { 99 | if (form.showClicks.checked) { 100 | let style = { 101 | top: event.clientY + "px", 102 | left: event.clientX + "px" 103 | } 104 | let bubble = up.element.affix(document.body, '.click-bubble', { style }) 105 | bubble.addEventListener('animationend', () => bubble.remove()); 106 | } 107 | }) 108 | ] 109 | }) 110 | 111 | function showInsertedFlash(fragment) { 112 | fragment = up.fragment.get(fragment) 113 | fragment.classList.add('new-fragment', 'inserted') 114 | up.util.timer(0, () => fragment.classList.remove('inserted')) 115 | up.util.timer(750, () => fragment.classList.remove('new-fragment')) 116 | } 117 | 118 | 119 | // Notifications /////////////////////////////////////////////////////////////////////////////////////////////////////// 120 | 121 | // Prompt user to reload when frontend assets changeds on the server. 122 | up.on('up:assets:changed', function() { 123 | up.element.show(document.querySelector('#new-version')) 124 | }) 125 | 126 | // Remove notification flashes after a few seconds. 127 | up.compiler('.alert', function(alert) { 128 | up.util.timer(4000, () => up.destroy(alert, { animation: 'move-to-top' })) 129 | }) 130 | 131 | 132 | // Loading state /////////////////////////////////////////////////////////////////////////////////////////////////////// 133 | 134 | function findButton(origin) { 135 | if (origin.matches('.btn')) { 136 | return origin 137 | } else { 138 | let form = origin.closest('form') 139 | return up.form.submitButtons(form)[0] 140 | } 141 | } 142 | 143 | // Show a spinning wheel inside the button. 144 | up.preview('btn-spinner', function(preview) { 145 | let button = findButton(preview.origin) 146 | // Keep the button dimensions while we're hiding its content. 147 | preview.setStyle(button, { height: button.offsetHeight + 'px', width: button.offsetWidth + 'px' }) 148 | preview.swapContent(button, '') 149 | }) 150 | 151 | // Show a spinning wheel inside a loading popup overlay 152 | up.preview('popup-spinner', function(preview) { 153 | preview.showPlaceholder('') 154 | }) 155 | 156 | // Show a spinning wheel inside a loading main element 157 | up.preview('main-spinner', function(preview) { 158 | let main = up.fragment.closest(preview.fragment, ':main') 159 | preview.insert(main, 'afterbegin', '
') 160 | }) 161 | 162 | // Shorten placeholder table to the maximum number of rows given 163 | up.compiler('.placeholder-wave', function(placeholder, { rows = 15 }) { 164 | let trs = placeholder.querySelectorAll('tr') 165 | for (let i = 0; i < trs.length - rows; i++) { 166 | trs[i].remove() 167 | } 168 | }) 169 | 170 | 171 | // Templates with simple {{variable}} substitution ///////////////////////////////////////////////////////////////////// 172 | 173 | up.on('up:template:clone', '[type="text/minimustache"]', function(event) { 174 | let template = event.target.innerHTML 175 | let filled = template.replace(/{{(\w+)}}/g, (_match, variable) => up.util.escapeHTML(event.data[variable])) 176 | event.nodes = up.element.createNodesFromHTML(filled) 177 | }) 178 | 179 | 180 | // Tasks /////////////////////////////////////////////////////////////////////////////////////////////////////////////// 181 | 182 | // Setting a `-done` class will line-through the task text. 183 | up.preview('finish-task', function(preview) { 184 | preview.addClass('-done') 185 | }) 186 | 187 | // Removing a `-done` class will remove the line-through decoration from the task text. 188 | up.preview('unfinish-task', function(preview) { 189 | preview.removeClass('-done') 190 | }) 191 | 192 | up.preview('add-task', function(preview) { 193 | let form = preview.origin.closest('form') 194 | let text = preview.params.get('task[text]') 195 | let newItem = up.template.clone('#task-preview', { text }) 196 | preview.insert(form, 'afterend', newItem) 197 | form.reset() 198 | }) 199 | 200 | up.preview('clear-tasks', function(preview) { 201 | let doneTasks = up.fragment.all('.task.-done') 202 | for (let doneTask of doneTasks) { 203 | preview.hide(doneTask) 204 | } 205 | }) 206 | 207 | up.compiler('#tasks', function(container) { 208 | let draggingItem 209 | 210 | up.on(container, 'dragstart', '.task-item', function(event, item) { 211 | draggingItem = item 212 | item.classList.add('-dragging') 213 | }) 214 | 215 | up.on(container, 'dragover', '.task', function(event, item) { 216 | event.preventDefault() // prime drop event 217 | item.classList.add('-dropping') 218 | }) 219 | 220 | up.on(container, 'dragleave', '.task', function(event, item) { 221 | item.classList.remove('-dropping') 222 | }) 223 | 224 | up.on(container, 'drop', '.task', function(event, dropItem) { 225 | if (!draggingItem) return 226 | event.preventDefault() 227 | 228 | draggingItem.classList.remove('-dragging') 229 | dropItem.classList.remove('-dropping') 230 | 231 | if (draggingItem !== dropItem) { 232 | up.render({ 233 | target: '#tasks', 234 | method: 'patch', 235 | url: `/tasks/${draggingItem.dataset.id}/move`, 236 | params: { reference: dropItem.dataset.id }, 237 | fallback: true, 238 | preview: (preview) => preview.insert(dropItem, 'afterend', draggingItem) 239 | }) 240 | } 241 | }) 242 | 243 | }) 244 | -------------------------------------------------------------------------------- /app/assets/stylesheets/_helpers.sass: -------------------------------------------------------------------------------- 1 | @function spacer($length) 2 | @return map-get($spacers, $length) 3 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.scss: -------------------------------------------------------------------------------- 1 | //= require unpoly 2 | //= require unpoly-bootstrap5 3 | //= require roboto 4 | //= require roboto-mono 5 | 6 | 7 | // Theme /////////////////////////////////////////////////////////////////////////////////////////////////////////////// 8 | 9 | @import 'bootstrap'; 10 | @import 'helpers'; 11 | 12 | :root { 13 | --bs-font-sans-serif: 'Roboto', sans-serif; 14 | --bs-font-monospace: 'Roboto Mono', monospace; 15 | } 16 | 17 | a { 18 | --bs-link-decoration: none; 19 | } 20 | 21 | .navbar { 22 | --bs-navbar-padding-x: 1rem; 23 | } 24 | 25 | body { 26 | margin: 5.5rem spacer(4) 7rem spacer(4); 27 | overflow-y: scroll; 28 | } 29 | 30 | .container { 31 | max-width: 1100px; 32 | } 33 | 34 | p { 35 | margin-bottom: 0.8rem; 36 | } 37 | 38 | b, strong, th { 39 | font-weight: 500; 40 | } 41 | 42 | .field_with_errors { 43 | display: contents; 44 | 45 | // For some reason display: contents does not work here. 46 | .input-group & { 47 | display: inline-block; 48 | flex: 1 1 auto; 49 | } 50 | 51 | &:not(form.up-active &) { 52 | 53 | label { 54 | color: $danger; 55 | } 56 | 57 | input, 58 | textarea, 59 | select { 60 | border-color: $danger; 61 | } 62 | 63 | } 64 | 65 | } 66 | 67 | .form-error { 68 | &:not(form.up-active &) { 69 | color: $danger; 70 | } 71 | } 72 | 73 | 74 | .form-group { 75 | margin-bottom: spacer(3); 76 | } 77 | 78 | .btn { 79 | text-align: center; 80 | 81 | &.btn-sm { 82 | min-width: 50px; 83 | } 84 | 85 | &:not(.btn-sm) { 86 | min-width: 75px; 87 | } 88 | } 89 | 90 | $popup-spacing: 12px; 91 | 92 | up-popup { 93 | z-index: 1070; 94 | } 95 | 96 | up-popup.tour-hint { 97 | margin: $popup-spacing; 98 | 99 | code { 100 | white-space: nowrap 101 | } 102 | } 103 | 104 | up-popup-content { 105 | line-height: 1.3; 106 | 107 | p { 108 | margin-bottom: 0.7rem; 109 | } 110 | 111 | :not(.popup-spinner):last-child { 112 | margin-bottom: 0; 113 | } 114 | } 115 | 116 | up-modal-box { 117 | padding: 1.8rem 2rem; 118 | } 119 | 120 | up-modal-content { 121 | :last-child { 122 | margin-bottom: 0; 123 | } 124 | 125 | >*:last-child { 126 | margin-bottom: 0; 127 | } 128 | } 129 | 130 | 131 | // Tasks ///////////////////////////////////////////////////////////////////////////////////////////// 132 | 133 | #tasks { 134 | max-width: 650px; 135 | margin: 0 auto; 136 | } 137 | 138 | .task { 139 | position: relative; 140 | display: flex; 141 | align-items: center; 142 | gap: spacer(3); 143 | border: $border-width $border-style $border-color; 144 | padding: ($spacer * 0.5) $spacer; 145 | 146 | &:first-child { 147 | border-top-left-radius: $border-radius; 148 | border-top-right-radius: $border-radius; 149 | } 150 | 151 | &:last-child { 152 | border-bottom-left-radius: $border-radius; 153 | border-bottom-right-radius: $border-radius; 154 | } 155 | 156 | &+& { 157 | border-top: none; 158 | } 159 | 160 | } 161 | 162 | .task-item { 163 | &.-done { 164 | .task-item--text { 165 | text-decoration: line-through; 166 | } 167 | 168 | .task-item--toggle:after { 169 | content: '✔'; 170 | position: absolute; 171 | width: 100%; 172 | height: 100%; 173 | font-size: 1.2em; 174 | line-height: 1.3em; 175 | text-align: center; 176 | } 177 | } 178 | 179 | &.-previewing { 180 | color: $gray-600 181 | } 182 | 183 | &[draggable=true] { 184 | cursor: move; 185 | } 186 | 187 | } 188 | 189 | .task-item--toggle { 190 | flex: 0 0 auto; 191 | width: 1.5em; 192 | height: 1.5em; 193 | display: inline-block; 194 | border-radius: 4em; 195 | border: 1px solid $gray-500; 196 | position: relative; 197 | } 198 | 199 | .task-item--text { 200 | flex: 1 1 0; 201 | padding: $btn-padding-y 0; 202 | } 203 | 204 | .task-item--edit { 205 | flex: 0 0 auto; 206 | } 207 | 208 | .task-item.-dragging { 209 | cursor: move; 210 | cursor: -webkit-grabbing; 211 | cursor: -moz-grabbing; 212 | } 213 | 214 | .task.-dropping { 215 | border-bottom-color: $primary !important; 216 | border-bottom-width: 3px; 217 | margin-bottom: -2px; 218 | } 219 | 220 | .task-form { 221 | gap: 0; 222 | display: grid; 223 | grid-template-columns: 1fr auto; 224 | grid-template-rows: auto; 225 | grid-template-areas: 226 | "input save" 227 | "error error"; 228 | align-items: center; 229 | 230 | 231 | column-gap: spacer(3); 232 | 233 | &:focus { 234 | outline: none !important; 235 | } 236 | } 237 | 238 | .task-form--input { 239 | grid-area: input; 240 | &:focus { 241 | box-shadow: none !important; 242 | } 243 | } 244 | 245 | .task-form--save { 246 | grid-area: save; 247 | } 248 | 249 | .task-form--error { 250 | margin-top: spacer(1); 251 | grid-area: error; 252 | } 253 | 254 | 255 | // Fragment explainer ////////////////////////////////////////////////////////////////////////////////////////////////// 256 | 257 | .fragment-explainer { 258 | background-color: white; 259 | padding: 0.95rem 1.2rem 1.0rem; 260 | position: fixed; 261 | z-index: 2500; 262 | bottom: 0; 263 | left: 0; 264 | width: 100vw; 265 | border-top: 1px solid $gray-500; 266 | box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); 267 | display: flex; 268 | gap: 0.6rem 1.7rem; 269 | flex-wrap: wrap; 270 | line-height: 1.35; 271 | justify-content: flex-start; 272 | align-items: center; 273 | } 274 | 275 | .fragment-explainer--cell { 276 | flex: 0 1 auto; 277 | min-width: calc(min(100px, 13vw)); 278 | 279 | a { 280 | text-decoration: underline; 281 | } 282 | } 283 | 284 | .fragment-explainer--label { 285 | margin-bottom: 4px; 286 | font-size: 0.8em; 287 | font-weight: 500; 288 | text-transform: uppercase; 289 | color: #444; 290 | } 291 | 292 | #config { 293 | display: grid; 294 | grid-template-columns: auto auto auto; 295 | column-gap: 1em; 296 | } 297 | 298 | .new-fragment { 299 | $new-fragment-color: #fc2; 300 | $new-fragment-opacity: 0.6; 301 | 302 | position: relative; 303 | 304 | &:after { 305 | transition: all 750ms ease-out; 306 | content: ''; 307 | position: absolute; 308 | left: 0; 309 | top: 0; 310 | right: 0; 311 | bottom: 0; 312 | background-color: rgba($new-fragment-color, 0); 313 | outline: 10px solid rgba($new-fragment-color, 0); 314 | z-index: 1; 315 | } 316 | 317 | &.inserted:after { 318 | background-color: rgba($new-fragment-color, $new-fragment-opacity); 319 | outline: 10px solid rgba($new-fragment-color, $new-fragment-opacity); 320 | } 321 | } 322 | 323 | .click-bubble { 324 | box-sizing: border-box; 325 | position: fixed; 326 | border-style: solid; 327 | border-color: #ff2299; 328 | border-radius: 50%; 329 | animation: click-bubble 0.3s ease-out; 330 | z-index: 999999; 331 | } 332 | 333 | @keyframes click-bubble { 334 | 0% { 335 | opacity: 0.8; 336 | width:0.5em; height: 0.5em; 337 | margin: -0.25em; 338 | border-width: 0.5em; 339 | } 340 | 100% { 341 | opacity: 0.1; 342 | width: 6em; 343 | height: 6em; 344 | margin: -3em; 345 | border-width: 0.2em; 346 | } 347 | } 348 | 349 | 350 | // Tour //////////////////////////////////////////////////////////////////////////////////////////////////////////////// 351 | 352 | $tour-dot-color: $green; 353 | 354 | .tour-dot { 355 | margin: 0 0.4rem; 356 | display: inline-block; 357 | vertical-align: middle; 358 | width: 10px; 359 | height: 10px; 360 | border-radius: 50%; 361 | background: $tour-dot-color; 362 | cursor: pointer; 363 | box-shadow: 0 0 0 4px rgba($tour-dot-color, 0.3); 364 | font-size: 0; 365 | 366 | &:hover { 367 | animation: none; 368 | } 369 | &[overlay-only] { 370 | display: none; 371 | 372 | up-modal &, 373 | up-drawer & { 374 | display: inline-block; 375 | } 376 | } 377 | &.viewed { 378 | animation: none; 379 | filter: grayscale(100%); 380 | opacity: 0.6; 381 | } 382 | } 383 | 384 | 385 | // Notifications //////////////////////////////////////////////////////////////////////////////////////////////////////// 386 | 387 | #new-version { 388 | position: fixed; 389 | top: 0.75rem; 390 | right: 1rem; 391 | z-index: 1050; 392 | } 393 | 394 | [up-flashes], .flashes-explainer { 395 | position: fixed; 396 | top: 0.4rem; 397 | right: 1rem; 398 | z-index: 1050; 399 | 400 | .alert { 401 | padding: 0.5rem 0.7rem; 402 | margin: 0; 403 | } 404 | } 405 | 406 | .flashes-explainer { 407 | padding: 0.5rem 0.7rem; 408 | border: 1px solid rgba(0, 0, 0, 0.1); 409 | color: #666; 410 | border-radius: 0.25rem; 411 | 412 | #new-version:not([hidden]) + & { 413 | display: none; 414 | } 415 | } 416 | 417 | 418 | // Loading state /////////////////////////////////////////////////////////////////////////////////////////////////////// 419 | 420 | .placeholder { 421 | opacity: 0.1; 422 | border-radius: 15px; 423 | } 424 | 425 | @mixin spinner($size: 1em, $color: $info, $thickness: 2px) { 426 | display: inline-block; 427 | vertical-align: middle; 428 | width: $size; 429 | aspect-ratio: 1; 430 | position: relative; 431 | 432 | // Have the rotating square in an :after element so the 433 | // bounding box of the actual element remains a steady rectangle. 434 | &:after { 435 | content: ''; 436 | position: absolute; 437 | left: 0; 438 | top: 0; 439 | right: 0; 440 | bottom: 0; 441 | border: $thickness solid rgba($color, 1.0); 442 | border-bottom-color: rgba($color, 0.2); 443 | border-radius: 50%; 444 | box-sizing: border-box; 445 | animation: rotation 1s linear infinite; 446 | opacity: inherit; 447 | @content; 448 | } 449 | 450 | } 451 | 452 | .btn-spinner { 453 | @include spinner(1em, white); 454 | margin-top: -0.15em; 455 | 456 | .btn:has(&) { 457 | text-align: center; 458 | } 459 | } 460 | 461 | .popup-spinner { 462 | @include spinner($size: 2.5rem, $color: $info); 463 | margin: 0.5rem 3rem 6rem 0.5rem; 464 | } 465 | 466 | .main-spinner { 467 | @include spinner($size: 6rem, $color: $info, $thickness: 3px) { 468 | transition: opacity 0.3s ease-in; 469 | opacity: 1.0; 470 | @starting-style { 471 | opacity: 0.1; 472 | } 473 | } 474 | 475 | position: absolute; 476 | top: 50%; 477 | left: 50%; 478 | transform: translate(-50%, -50%); 479 | 480 | main:has(>&) { 481 | position: relative; 482 | opacity: 1; 483 | filter: grayscale(0%); 484 | 485 | & > *:not(.main-spinner) { 486 | transition: opacity 0.3s ease-out, filter 0.3s ease-out; 487 | opacity: 0.4; 488 | filter: grayscale(100%); 489 | } 490 | } 491 | } 492 | 493 | .placeholder-wave { 494 | transition: opacity 0.2s ease-in; 495 | opacity: 1.0; 496 | 497 | @starting-style { 498 | opacity: 0.5; 499 | } 500 | } 501 | 502 | @keyframes rotation { 503 | 0% { 504 | transform: rotate(0deg); 505 | } 506 | 100% { 507 | transform: rotate(360deg); 508 | } 509 | } 510 | 511 | $search-active-stripe-color: rgba(200, 230, 255, 0.3); 512 | 513 | input[type=search].up-active { 514 | background-image: 515 | repeating-linear-gradient( 516 | -45deg, 517 | transparent, 518 | transparent 1rem, 519 | $search-active-stripe-color 1rem, 520 | $search-active-stripe-color 2rem 521 | ); 522 | background-size: 2000% 2000%; 523 | animation: barberpole 200s linear infinite; 524 | } 525 | 526 | @keyframes barberpole { 527 | 100% { 528 | background-position: -1000% 1000%; 529 | } 530 | } 531 | 532 | -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | include Memoized 3 | 4 | before_action :reset_faker 5 | before_action :create_missing_tenant 6 | after_action :emulate_latency 7 | 8 | private 9 | 10 | def create_missing_tenant 11 | unless current_tenant 12 | tenant = Tenant.create! 13 | session[:tenant_id] = tenant.id 14 | session[:return_to] = request.original_url 15 | 16 | # This action checks if the user has cookies enables, 17 | # then redirects back to the current poath. 18 | redirect_to verify_tenant_path 19 | end 20 | end 21 | 22 | helper_method memoize def current_tenant 23 | if (tenant_id = session[:tenant_id].presence) 24 | Tenant.for_current_schema.where(id: tenant_id).first 25 | end 26 | end 27 | 28 | helper_method memoize def fragment_explainer 29 | FragmentExplainer.new(self) 30 | end 31 | 32 | def reset_faker 33 | # The model is asking Faker to produce unique names in Tenent.create, Project.suggest_name, etc. 34 | # We must periodically release the list of used names or Faker will eventually throw errors. 35 | # See https://github.com/faker-ruby/faker#ensuring-unique-values 36 | Faker::UniqueGenerator.clear 37 | end 38 | 39 | def emulate_latency 40 | if up? && request.headers['X-Extra-Latency'].present? && !response.redirect? 41 | sleep 1.0 42 | end 43 | end 44 | 45 | end 46 | -------------------------------------------------------------------------------- /app/controllers/companies_controller.rb: -------------------------------------------------------------------------------- 1 | class CompaniesController < ApplicationController 2 | 3 | def new 4 | build_company 5 | end 6 | 7 | def create 8 | build_company 9 | save_company(form: 'new') 10 | end 11 | 12 | def edit 13 | load_company 14 | build_company 15 | end 16 | 17 | def update 18 | load_company 19 | build_company 20 | save_company(form: 'edit') 21 | end 22 | 23 | def show 24 | load_company 25 | end 26 | 27 | def index 28 | load_companies 29 | end 30 | 31 | def destroy 32 | load_company 33 | if @company.destroy 34 | up.layer.emit('company:destroyed') 35 | redirect_to companies_path 36 | else 37 | redirect_to @company, alert: 'Could not delete company' 38 | end 39 | end 40 | 41 | private 42 | 43 | def build_company 44 | @company ||= company_scope.build 45 | @company.attributes = company_attributes 46 | end 47 | 48 | def load_company 49 | @company ||= company_scope.find(params[:id]) 50 | end 51 | 52 | def save_company(form:) 53 | if up.validate? 54 | @company.valid? # run validations 55 | render form 56 | elsif @company.save 57 | redirect_to @company, notice: 'Company saved successfully' 58 | else 59 | render form, status: :bad_request 60 | end 61 | end 62 | 63 | def load_companies 64 | @companies = company_scope 65 | .search(params[:query]) 66 | .order(:name) 67 | .to_a 68 | end 69 | 70 | def company_scope 71 | current_tenant.companies 72 | end 73 | 74 | def company_attributes 75 | if (attrs = params[:company]) 76 | attrs.permit(:name, :address) 77 | else 78 | {} 79 | end 80 | end 81 | 82 | end 83 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/pages_controller.rb: -------------------------------------------------------------------------------- 1 | class PagesController < ApplicationController 2 | def home 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/controllers/placeholders_controller.rb: -------------------------------------------------------------------------------- 1 | class PlaceholdersController < ApplicationController 2 | 3 | def index 4 | end 5 | 6 | def show 7 | end 8 | 9 | def form 10 | end 11 | 12 | end 13 | -------------------------------------------------------------------------------- /app/controllers/projects_controller.rb: -------------------------------------------------------------------------------- 1 | class ProjectsController < ApplicationController 2 | 3 | def new 4 | build_project 5 | end 6 | 7 | def create 8 | build_project 9 | save_project(form: 'new') 10 | end 11 | 12 | def edit 13 | load_project 14 | build_project 15 | end 16 | 17 | def update 18 | load_project 19 | build_project 20 | save_project(form: 'edit') 21 | end 22 | 23 | def show 24 | load_project 25 | end 26 | 27 | def index 28 | load_projects 29 | end 30 | 31 | def destroy 32 | load_project 33 | if @project.destroy 34 | up.layer.emit('project:destroyed') 35 | redirect_to projects_path 36 | else 37 | redirect_to @project, alert: 'Could not delete project' 38 | end 39 | end 40 | 41 | def suggest_name 42 | @names = (1..10).map { Project.suggest_name } 43 | end 44 | 45 | private 46 | 47 | def build_project 48 | @project ||= project_scope.build 49 | @project.attributes = project_attributes 50 | end 51 | 52 | def load_project 53 | @project ||= project_scope.find(params[:id]) 54 | end 55 | 56 | def save_project(form:) 57 | if up.validate? 58 | @project.valid? # run validations 59 | render form 60 | elsif @project.save 61 | up.layer.emit('project:saved') 62 | redirect_to @project, notice: 'Project saved successfully' 63 | else 64 | render form, status: :bad_request 65 | end 66 | end 67 | 68 | def load_projects 69 | @projects = project_scope.includes(:company).order(:name).to_a 70 | end 71 | 72 | def project_scope 73 | current_tenant.projects 74 | end 75 | 76 | def project_attributes 77 | if (attrs = params[:project]) 78 | attrs.permit(:name, :company_id, :budget) 79 | else 80 | {} 81 | end 82 | end 83 | 84 | end 85 | -------------------------------------------------------------------------------- /app/controllers/tasks_controller.rb: -------------------------------------------------------------------------------- 1 | class TasksController < ApplicationController 2 | 3 | def new 4 | build_task 5 | end 6 | 7 | def create 8 | build_task 9 | 10 | if @task.save 11 | redirect_to tasks_path, notice: 'Task added' 12 | else 13 | render 'new', status: :bad_request 14 | end 15 | end 16 | 17 | def edit 18 | load_task 19 | build_task 20 | end 21 | 22 | def update 23 | load_task 24 | build_task 25 | 26 | if @task.save 27 | redirect_to @task, notice: 'Task updated' 28 | else 29 | render 'edit', status: :bad_request 30 | end 31 | end 32 | 33 | def show 34 | load_task 35 | end 36 | 37 | def index 38 | render_tasks 39 | end 40 | 41 | def clear_done 42 | task_scope.clear_done! 43 | redirect_to tasks_path 44 | end 45 | 46 | def finish 47 | load_task 48 | @task.finish! 49 | redirect_to @task 50 | end 51 | 52 | def unfinish 53 | load_task 54 | @task.unfinish! 55 | redirect_to @task 56 | end 57 | 58 | def move 59 | load_task 60 | reference = params[:reference] 61 | if reference == 'start' 62 | task_scope.move_to_start!(@task) 63 | else 64 | reference_task = find_task(reference) 65 | task_scope.move_after!(reference_task, @task) 66 | end 67 | 68 | render_tasks 69 | end 70 | 71 | private 72 | 73 | def render_tasks 74 | load_tasks 75 | render :index 76 | end 77 | 78 | def load_tasks 79 | @tasks ||= task_scope.to_a 80 | end 81 | 82 | def task_scope 83 | current_tenant.tasks.by_position 84 | end 85 | 86 | def load_task 87 | @task ||= find_task(params[:id]) 88 | end 89 | 90 | def find_task(id) 91 | task_scope.find(id) 92 | end 93 | 94 | def build_task 95 | @task ||= task_scope.build 96 | @task.attributes = task_attributes 97 | end 98 | 99 | def task_attributes 100 | if (attrs = params[:task]) 101 | attrs.permit(:text, :done) 102 | else 103 | {} 104 | end 105 | end 106 | 107 | helper_method def open_task_count 108 | load_tasks 109 | @tasks.select(&:open?).size 110 | end 111 | 112 | end 113 | -------------------------------------------------------------------------------- /app/controllers/tenants_controller.rb: -------------------------------------------------------------------------------- 1 | class TenantsController < ApplicationController 2 | skip_before_action :create_missing_tenant 3 | 4 | def verify 5 | if current_tenant 6 | return_to = session[:return_to].presence || root_url 7 | redirect_to return_to 8 | else 9 | render text: 'This demo requires cookies' 10 | end 11 | 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | 3 | # def button_to(name = nil, options = nil, html_options = nil, &block) 4 | # up_html_options, html_options = html_options.partition { |key, _value| key.to_s.start_with?('up-') }.map(&:to_h) 5 | # html_options[:form] ||= {} 6 | # html_options[:form].merge!(up_html_options) 7 | # super(name, options, html_options, &block) 8 | # end 9 | 10 | end 11 | -------------------------------------------------------------------------------- /app/helpers/flashes_helper.rb: -------------------------------------------------------------------------------- 1 | module FlashesHelper 2 | 3 | def flashes 4 | html = ''.html_safe 5 | flash.map do |type, message| 6 | html << content_tag(:div, message, class: bootstrap_flash_classes(type), role: 'alert') 7 | end 8 | html 9 | end 10 | 11 | def bootstrap_flash_type(type) 12 | case type.to_s 13 | when 'notice' 14 | 'success' 15 | when 'error', 'alert' 16 | 'danger' 17 | else 18 | 'info' 19 | end 20 | end 21 | 22 | def bootstrap_flash_classes(type) 23 | "alert alert-#{bootstrap_flash_type(type)}" 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /app/helpers/placeholder_helper.rb: -------------------------------------------------------------------------------- 1 | module PlaceholderHelper 2 | 3 | def placeholder(*content, &block) 4 | content_tag(:span, *content, class: 'placeholder', &block) 5 | end 6 | 7 | end 8 | -------------------------------------------------------------------------------- /app/helpers/tour_helper.rb: -------------------------------------------------------------------------------- 1 | module TourHelper 2 | 3 | def tour_dot(options = {}, &block) 4 | html = capture(&block).strip.html_safe 5 | 6 | # The margin looks nicer when the text is wrapped in

tags. 7 | # Wrap the given HTML in a

if the callerr hasn't already done so. 8 | unless html.starts_with?('<') 9 | html = content_tag(:p, html) 10 | end 11 | 12 | # Add a button to close the hint popup. 13 | html << <<~HTML.html_safe 14 |

15 | OK 16 |

17 | HTML 18 | 19 | if strip_tags(html).size > 400 20 | size = 'large' 21 | else 22 | size = 'medium' 23 | end 24 | 25 | # The hint is just an Unpoly popup. 26 | attrs = { 27 | class: 'tour-dot', 28 | href: '#', 29 | 'up-layer': 'new popup', 30 | 'up-content': '' + html, # force-escape the HTML string by making it unsafe 31 | 'up-position': options.fetch(:position, 'right'), 32 | 'up-align': options.fetch(:align, 'top'), 33 | 'up-class': 'tour-hint', 34 | 'up-size': size 35 | } 36 | 37 | # This is a hint to hide this dot on the root layer (see application.sass). 38 | if options[:overlay_only] 39 | attrs['overlay-only'] = '' 40 | end 41 | 42 | if (size = options[:size]) 43 | attrs['up-size'] = size 44 | end 45 | 46 | content_tag(:a, '', attrs) 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: 'from@example.com' 3 | layout 'mailer' 4 | end 5 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /app/models/company.rb: -------------------------------------------------------------------------------- 1 | class Company < ActiveRecord::Base 2 | belongs_to :tenant 3 | has_many :projects, dependent: :destroy 4 | 5 | validates :name, presence: true, uniqueness: { case_sensitive: false, scope: :tenant_id } 6 | validates :address, presence: true 7 | 8 | def self.search(query) 9 | if query.present? 10 | where_like([:name, :address] => query) 11 | else 12 | all 13 | end 14 | end 15 | 16 | end 17 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/fragment_explainer.rb: -------------------------------------------------------------------------------- 1 | class FragmentExplainer 2 | 3 | def initialize(controller) 4 | @controller = controller 5 | end 6 | 7 | def controller_short_path 8 | "#{controller_name}_controller.rb" 9 | end 10 | 11 | def controller_path 12 | "app/controllers/#{controller_short_path}" 13 | end 14 | 15 | def controller_github_url 16 | github_url(controller_path) 17 | end 18 | 19 | def view_short_path 20 | "#{controller_name}/#{view_name}.erb" 21 | end 22 | 23 | def view_path 24 | "app/views/#{view_short_path}" 25 | end 26 | 27 | def view_github_url 28 | github_url(view_path) 29 | end 30 | 31 | def layout_short_path 32 | "application.html.erb" 33 | end 34 | 35 | def layout_path 36 | "app/views/layouts/#{layout_short_path}" 37 | end 38 | 39 | def layout_github_url 40 | github_url(layout_path) 41 | end 42 | 43 | # def updated_selector 44 | # selector = if response.status.to_s.starts_with?('2') 45 | # up.target 46 | # else 47 | # up.fail_target 48 | # end 49 | # 50 | # selector = selector.gsub(', [up-flashes=""]', '') 51 | # selector = selector.gsub(', .fragment-explainer', '') 52 | # selector = selector.gsub('[up-main~=modal]', 'main') 53 | # selector = selector.gsub('[up-main~=popup]', 'main') 54 | # 55 | # selector 56 | # end 57 | 58 | delegate :controller_name, :action_name, :response, :up, :up?, to: :controller 59 | 60 | private 61 | 62 | def github_url(path) 63 | "https://github.com/unpoly/unpoly-demo/blob/master/#{path}" 64 | end 65 | 66 | def view_name 67 | name = action_name 68 | name = name.sub(/^create$/, 'new') 69 | name = name.sub(/^update$/, 'edit') 70 | name 71 | end 72 | 73 | attr_reader :controller 74 | 75 | # def relative_to_root(path) 76 | # path.sub(Rails.root.to_s + '/', '') 77 | # end 78 | 79 | end 80 | 81 | # def track_view_path 82 | # subscriber = ActiveSupport::Notifications.subscribe "render_template.action_view" do |name, started, finished, unique_id, data| 83 | # @view_path = relative_to_root(data[:identifier]) 84 | # @layout_path = relative_to_root(data[:layout]) 85 | # end 86 | # 87 | # 88 | # @controller_path = "app/controllers/" + controller_name + "_controller.rb" 89 | # @view_path = "app/views/" + controller_name + "/" + action_name.sub('create', 'new').sub('update', 'edit') + ".html.erb" 90 | # 91 | # yield 92 | # ensure 93 | # ActiveSupport::Notifications.unsubscribe(subscriber) 94 | # end 95 | # 96 | -------------------------------------------------------------------------------- /app/models/project.rb: -------------------------------------------------------------------------------- 1 | class Project < ActiveRecord::Base 2 | belongs_to :tenant 3 | belongs_to :company 4 | 5 | validates :name, presence: true, uniqueness: { case_sensitive: false, scope: :tenant_id } 6 | validates :company_id, presence: true 7 | validates :budget, numericality: { greater_than_or_equal_to: 100 } 8 | 9 | def self.suggest_name 10 | Faker::App.unique.name 11 | end 12 | 13 | end 14 | -------------------------------------------------------------------------------- /app/models/task.rb: -------------------------------------------------------------------------------- 1 | class Task < ApplicationRecord 2 | belongs_to :tenant 3 | validates :text, presence: true, uniqueness: { case_sensitive: false, scope: :tenant_id } 4 | 5 | has_defaults done: false 6 | 7 | scope :by_position, ->{ order(Arel.sql('COALESCE(position, -id)')) } 8 | 9 | def open? 10 | !done? 11 | end 12 | 13 | def finish! 14 | update!(done: true) 15 | end 16 | 17 | def unfinish! 18 | update!(done: false) 19 | end 20 | 21 | class << self 22 | 23 | def clear_done! 24 | where(done: true).destroy_all 25 | end 26 | 27 | def move_to_start!(task) 28 | old_list = by_position.to_a 29 | new_list = [task, *old_list.without(task)] 30 | rewrite_positions!(new_list) 31 | end 32 | 33 | def move_after!(reference, task) 34 | old_list = by_position.to_a 35 | new_list = old_list.without(task) 36 | reference_position = new_list.index(reference) 37 | new_list.insert(reference_position + 1, task) 38 | rewrite_positions!(new_list) 39 | end 40 | 41 | private 42 | 43 | def rewrite_positions!(list) 44 | Rails.logger.info "rewrite_positions: #{list.inspect}" 45 | list.each_with_index do |item, index| 46 | item.update_attribute(:position, index) 47 | end 48 | end 49 | 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /app/models/tenant.rb: -------------------------------------------------------------------------------- 1 | class Tenant < ApplicationRecord 2 | 3 | has_many :companies, dependent: :delete_all 4 | has_many :projects, dependent: :delete_all 5 | has_many :tasks, dependent: :delete_all 6 | 7 | after_create :create_sample_records 8 | 9 | # If we change the schema we recreate new tenants 10 | has_defaults schema_version: -> { self.class.schema_version } 11 | 12 | scope :for_current_schema, -> { where(schema_version: schema_version)} 13 | 14 | def self.schema_version 15 | connection.migration_context.current_version 16 | end 17 | 18 | def self.clean! 19 | old_tenants = where('created_at < ?', 7.days.ago) 20 | incompatible_tenants = where('schema_version != ?', schema_version) 21 | cleanable_tenants = old_tenants.or(incompatible_tenants) 22 | cleanable_tenants.find_each(&:destroy) 23 | end 24 | 25 | private 26 | 27 | def create_sample_records 28 | 5.times do 29 | company = companies.create!( 30 | name: Faker::Company.unique.name, 31 | address: sample_address 32 | ) 33 | 34 | 3.times do 35 | projects.create!( 36 | company: company, 37 | name: Project.suggest_name, 38 | budget: ((1..150).to_a.sample * 1000), 39 | ) 40 | end 41 | end 42 | 43 | 5.times do 44 | tasks.create!( 45 | text: Faker::Lorem.unique.sentence(word_count: 2, random_words_to_add: 3).chop, 46 | created_at: rand(100).hours.ago 47 | ) 48 | end 49 | end 50 | 51 | def sample_address 52 | <<~ADDRESS 53 | #{Faker::Address.street_name} #{rand(100) + 1} 54 | #{Faker::Address.postcode} #{Faker::Address.city} 55 | #{Faker::Address.country} 56 | ADDRESS 57 | end 58 | 59 | end 60 | 61 | -------------------------------------------------------------------------------- /app/views/companies/_form.erb: -------------------------------------------------------------------------------- 1 | <%= form_for @company, html: { 2 | 'up-submit': true, 3 | 'up-validate': true, 4 | 'up-disable': true, 5 | 'up-preview': 'btn-spinner', 6 | } do |form| %> 7 | 8 |
9 | <%= form.label :name %> 10 | <%= tour_dot do %> 11 |

The form uses the [up-validate] attribute to validate user input on the server as you're completing fields.

12 |

Try entering a duplicate company name and then focus the address field.

13 | <% end %> 14 | <%= form.text_field :name, class: 'form-control' %> 15 | <%= form.error_message :name %> 16 |
17 | 18 |
19 | <%= form.label :address %> 20 | <%= form.text_area :address, class: 'form-control', rows: 6 %> 21 | <%= form.error_message :address %> 22 |
23 | 24 |
25 |
26 | <%= form.button "Save", class: 'btn btn-primary' %> 27 | 28 | <% if @company.new_record? %> 29 | <%= tour_dot(overlay_only: true) do %> 30 |

Saving an invalid form will re-render validation errors in the same overlay.

31 |

Links and forms always render in the same layer unless another layer is targeted explicitly.

32 | <% end %> 33 | <% end %> 34 |
35 | 36 |
37 | <% if local_assigns[:cancel_path] %> 38 | <%= link_to 'Cancel', cancel_path, class: 'btn btn-outline-secondary', 39 | 'up-follow': true, 40 | 'up-preview': 'main-spinner' 41 | %> 42 | <% end %> 43 |
44 |
45 | 46 | <% end %> 47 | -------------------------------------------------------------------------------- /app/views/companies/edit.erb: -------------------------------------------------------------------------------- 1 |

Edit company <%= @company.id %>

2 | 3 | <%= render 'form', cancel_path: @company %> 4 | -------------------------------------------------------------------------------- /app/views/companies/index.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

All companies

4 |
5 | 6 |
7 | <%= tour_dot(position: 'left') do %> 8 |

9 | The search form has an [up-target="#companies"] attribute. 10 | This causes the submission results to update the <div id="companies"> element below. 11 |

12 | 13 |

14 | The form also has an [up-autosubmit] attribute. 15 | This automatically submits the user while the user is typing. 16 |

17 | 18 |

19 | Forms and links assigned an .up-active class while loading. 20 | We use this class for the stripe animation on the search field. 21 |

22 | <% end %> 23 | 24 | <%= form_tag companies_path, method: :get, class: 'd-inline-block', 25 | 'up-target': '#companies', 26 | 'up-autosubmit': true do 27 | %> 28 | 29 | 30 | <% end %> 31 |
32 | 33 |
34 | <%= tour_dot(position: 'left') do %> 35 |

36 | The New company button has an [up-layer=new] attribute. 37 | This causes the link destination to open in an overlay. 38 |

39 |

40 | The button also defines a close condition using an [up-accept-location] attribute. 41 | This causes the overlay to automatically close when it reaches the URL of a newly created company. 42 |

43 |

44 | The [up-on-accepted] attribute causes the company list below to reload when the overlay closes. 45 |

46 | <% end %> 47 | 48 | <%= link_to 'New company', new_company_path, class: 'btn btn-primary', 49 | 'up-layer': 'new', 50 | 'up-placeholder': '#form-placeholder', 51 | 'up-accept-location': company_path('$id'), 52 | 'up-on-accepted': "up.reload('#companies', { placeholder: '#table-placeholder { rows: 5 }' })" 53 | %> 54 |
55 |
56 | 57 |
58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | <% @companies.each_with_index do |company, i| %> 67 | 68 | 81 | 82 | <% end %> 83 | 84 |
Name
69 | <%= link_to company.name, company, 70 | 'up-layer': 'new', 71 | 'up-placeholder': '#show-placeholder', 72 | 'up-dismiss-event': 'company:destroyed', 73 | 'up-on-dismissed': "up.reload('#companies', { placeholder: '#table-placeholder { rows: 5 }' })" 74 | %> 75 | 76 | <%= tour_dot do %> 77 | The link to the company details has an [up-layer=new] attribute. 78 | This causes the link destination to open in an overlay. 79 | <% end if i == 0 %> 80 |
85 |
86 | -------------------------------------------------------------------------------- /app/views/companies/new.erb: -------------------------------------------------------------------------------- 1 |

New company

2 | 3 | <%= render 'form' %> 4 | -------------------------------------------------------------------------------- /app/views/companies/show.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Company #<%= @company.id %>

4 |
5 | 6 |
7 | <%= tour_dot(overlay_only: true) do %> 8 |

Note how the Edit link renders within this overlay.

9 |

Links and forms always update their own layer unless another layer is targeted explicitly.

10 | <% end %> 11 | 12 | <%= link_to 'Edit', [:edit, @company], 13 | class: 'btn btn-primary', 14 | 'up-follow': true, 15 | 'up-preview': 'main-spinner' 16 | %> 17 | 18 | <%= link_to 'Delete', @company, method: :delete, 19 | class: 'btn btn-danger', 20 | 'up-follow': true, 21 | 'up-confirm': 'Really delete?', 22 | 'up-preview': 'btn-spinner' 23 | %> 24 |
25 |
26 | 27 |
28 |
Name
29 |
<%= @company.name %>
30 |
Address
31 |
32 | <% if @company.address.present? %> 33 | <%= safe_join(@company.address.lines, '
'.html_safe) %> 34 | <% else %> 35 | — 36 | <% end %> 37 |
38 |
39 | 40 |
41 |
42 |

Projects

43 |
44 | 45 |
46 |
47 | <%= tour_dot(position: 'left') do %> 48 |

49 | This button re-uses the project form that we already built for the Projects tab. 50 |

51 | 52 |

53 | When a new project was created, the overlay closes and the project list is reloaded. 54 |

55 | <% end %> 56 | <%= link_to 'Add project', new_project_path(project: { company_id: @company.id }), class: 'btn btn-primary btn-sm', 57 | 'up-layer': 'new', 58 | 'up-placeholder': '#form-placeholder', 59 | 'up-accept-location': project_path('$id'), 60 | 'up-on-accepted': "up.reload('#projects', { placeholder: '#table-placeholder { rows: 5 }' })", 61 | 'up-context': { from_company: true }.to_json 62 | %> 63 |
64 |
65 |
66 | 67 |
68 | <% if @company.projects.present? %> 69 | 70 | 71 | 72 | 73 | 74 | 75 | <% @company.projects.natural_sort_by(&:name).each_with_index do |project, i| %> 76 | 77 | 90 | 93 | 94 | <% end %> 95 | 96 |
NameBudget
78 | <%= link_to project.name, project, 79 | 'up-layer': 'new', 80 | 'up-placeholder': '#show-placeholder', 81 | 'up-dismiss-event': 'project:destroyed', 82 | 'up-on-dismissed': "up.reload('#projects', { placeholder: '#table-placeholder { rows: 5 }' })", 83 | 'up-context': { from_company: true }.to_json 84 | %> 85 | <%= tour_dot(overlay_only: true) do %> 86 |

Opening the project will open another modal over this one.

87 |

Overlays can be stacked infinitely.

88 | <% end if i == 0%> 89 |
91 | <%= number_to_currency(project.budget, unit: '€') %> 92 |
97 | <% else %> 98 |

99 | This company has no projects yet. 100 |

101 | <% end %> 102 |
103 | -------------------------------------------------------------------------------- /app/views/layouts/_fragment_explainer.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
Last fragment:
5 | html 6 | (reveal) 7 |
8 | 9 |
10 |
Server-side code:
11 |
12 | Controller 13 | / 14 | View 15 |
16 |
17 | 18 |
19 |
Last request:
20 | GET <%= request.path %> 21 |
22 | 23 |
24 |
Round-trip:
25 |
26 | (initial load) 27 |
28 |
29 | 30 |
31 |
32 |
33 | 34 | 35 |
36 |
37 | 38 | 39 |
40 |
41 | 42 | 43 |
44 |
45 | 46 | 47 |
48 |
49 | 50 | 51 |
52 |
53 | 54 | 55 |
56 |
57 |
58 | 59 |
60 | -------------------------------------------------------------------------------- /app/views/layouts/_nav.erb: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /app/views/layouts/application.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Unpoly Demo 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | <%= javascript_include_tag 'application', defer: true %> 9 | <%= stylesheet_link_tag 'application', media: 'all' %> 10 | <%= favicon_link_tag("favicon.png") %> 11 | 12 | 13 | 14 | <%= render 'layouts/nav' %> 15 | 16 | 20 | 21 |
22 | <%= tour_dot(position: 'left') do %> 23 |

24 | The element after this one uses the [up-flashes] attribute 25 | to extract notification messages from any response, even 26 | if this container wasn't targeted. 27 |

28 | 29 |

30 | A compiler removes any <div class="alert"> 31 | after some seconds. 32 |

33 | <% end %> 34 | Notifications go here 35 |
36 | 37 |
38 | <%= flashes %> 39 |
40 | 41 |
42 |
43 | <%= yield %> 44 |
45 |
46 | 47 | <%= render 'layouts/fragment_explainer' %> 48 | 49 | <%= render 'placeholders/templates' unless up? %> 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /app/views/pages/home.erb: -------------------------------------------------------------------------------- 1 |

Unpoly Demo

2 | 3 |

4 | This is a small CRUD 5 | application to show off Unpoly. 6 |

7 | 8 | 9 |

10 | We recommend viewing this demo on a PC or tablet. 11 |

12 | 13 |

14 | Like all Unpoly applications, this demo is a classic web app that renders HTML on the server. 15 | The app is enhanced to feel like a single-page app 16 | by adding [up] attributes to links and forms. Aside from these attributes the server does not need to know about Unpoly in the frontend. 17 |

18 | 19 |

Fragments

20 | 21 |

22 | Much of Unpoly's magic lies in updating page fragments instead of making full page loads. 23 | The server still renders full HTML pages, but we only use 24 | the targeted fragments and discard the rest. 25 |

26 | 27 |

28 | Under this model all DOM state outside the updated fragment is preserved. 29 | Following links and forms feels smooth, without seeing flashes of incomplete content or losing scroll positions or incomplete form data. 30 | Pages also load much faster since the DOM, CSS and JavaScript environments do not need to be 31 | destroyed and recreated for every request. 32 |

33 | 34 |

35 | To get a better understanding which parts of the page were updated, the bar at the bottom shows which selectors were 36 | targeted. Click on reveal to highlight the fragment in the page. 37 |

38 | 39 | 40 |

The demo app

41 | 42 |

43 | The top navigation divides the app is into three sections: 44 |

45 | 46 | 51 | 52 |

Exploring the app

53 | 54 |

55 | Click on everything! All data is scoped to your session, so feel free to change and delete records. 56 |

57 | 58 |

59 | Throughout the app you will see green dots <%= tour_dot { 'This is a hint!' } %>. 60 | Clicking on a dot will reveal a hint about what you can discover here. 61 |

62 | 63 |

Source code

64 | 65 |

66 | We encourage you to use your browser's DevTools to inspect the HTML. Take a closer look at links, forms and their [up] attributes. 67 |

68 | 69 |

70 | The bar at the bottom of the screen shows links to the server-side code that rendered the last HTML response. 71 | The entire application code can be cloned from the unpoly-demo repository. 72 |

73 | 74 |

75 | The backend is written in Ruby, but don't let that scare you if you're using another language. 76 | The backend only makes a few SQL queries and renders HTML using a template language similar to PHP, Moustache or Pug. 77 | The community has also provided a Python re-implementation of this demo using Django. 78 | 79 |

80 | 81 |

82 | The frontend contains no significant JavaScript 83 | aside from Unpoly itself. The CSS is just Bootstrap 4 84 | with minimal tweaks. 85 |

86 | -------------------------------------------------------------------------------- /app/views/placeholders/_form.erb: -------------------------------------------------------------------------------- 1 |
2 |

<%= placeholder 'Placeholder #123' %>

3 | 4 |
5 |
6 | 7 | 8 |
9 | 10 |
11 | 12 | 13 |
14 | 15 |
16 | 17 | 18 |
19 |
20 | 21 |
22 |
23 | Save 24 |
25 | 26 |
27 | Cancel 28 |
29 |
30 | 31 |
32 | -------------------------------------------------------------------------------- /app/views/placeholders/_index.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

<%= placeholder 'All records' %>

5 |
6 |
7 | New record 8 |
9 |
10 | 11 | <%= render 'placeholders/table_inner' %> 12 |
13 | -------------------------------------------------------------------------------- /app/views/placeholders/_show.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

<%= placeholder 'Placeholder #123' %>

5 |
6 | 7 |
8 | Edit 9 | Delete 10 |
11 |
12 | 13 |
14 |
<%= placeholder 'Name' %>
15 |
<%= placeholder 'Acme Corporation' %>
16 | 17 |
<%= placeholder 'Address' %>
18 |
19 | <%= placeholder do %> 20 | Footaster Street 123 21 |
22 | 12345 Fooville 23 | <% end %> 24 |
25 | 26 |
<%= placeholder 'Budget' %>
27 |
<%= placeholder '€ 1234' %>
28 |
29 | 30 |
31 | -------------------------------------------------------------------------------- /app/views/placeholders/_table.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= render 'placeholders/table_inner' %> 3 |
4 | -------------------------------------------------------------------------------- /app/views/placeholders/_table_inner.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | <% 15.times do%> 11 | 12 | 13 | 14 | 15 | <% end %> 16 | 17 |
<%= placeholder 'Name' %><%= placeholder 'Company' %>
<%= placeholder Faker::App.name %><%= placeholder Faker::Company.name %>
18 | -------------------------------------------------------------------------------- /app/views/placeholders/_templates.erb: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 12 | 13 | 16 | -------------------------------------------------------------------------------- /app/views/placeholders/form.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: 'form' %> 2 | -------------------------------------------------------------------------------- /app/views/placeholders/index.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: 'index' %> 2 | -------------------------------------------------------------------------------- /app/views/placeholders/show.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: 'show' %> 2 | 3 | -------------------------------------------------------------------------------- /app/views/placeholders/table.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: 'table' %> 2 | -------------------------------------------------------------------------------- /app/views/projects/_form.erb: -------------------------------------------------------------------------------- 1 | <%= form_for @project, html: { 2 | 'up-submit': true, 3 | 'up-validate': true, 4 | 'up-disable': true, 5 | 'up-preview': 'btn-spinner', 6 | } do |form| %> 7 | 8 |
9 | <%= form.label :name %> 10 |
11 | <%= form.text_field :name, class: 'form-control', autocomplete: 'off' %> 12 | 13 | <%= tour_dot(position: 'left') do %> 14 |

15 | The button opens a popup with suggestions for a project name. 16 |

17 |

18 | When a suggestion is clicked, the choice is communicated 19 | back to the project form via a custom name:select event.

20 |

21 | The Suggest name link has an [up-on-accepted] 22 | attribute that copies the suggestion into the project form. 23 |

24 | <% end %> 25 |
26 | 27 | <%= link_to suggest_name_projects_path, 28 | class: 'btn btn-outline-secondary', 29 | 'up-preview': 'popup-spinner', 30 | 'up-layer': 'new popup', 31 | 'up-align': 'right', 32 | 'up-accept-event': 'name:select', 33 | 'up-on-accepted': "up.fragment.get('#project_name').value = value.name" do %> 34 | Suggest name 35 | <% end %> 36 |
37 | <%= form.error_message :name %> 38 |
39 | 40 |
41 | <%= form.label :company_id %> 42 | 43 |
44 | <%= form.collection_select :company_id, current_tenant.companies.order(:name), :id, :name, { include_blank: '