diff --git a/.cursorignore b/.cursorignore
new file mode 100644
index 000000000..d6be2f6d8
--- /dev/null
+++ b/.cursorignore
@@ -0,0 +1 @@
+.devbox/
diff --git a/.cursorindexingignore b/.cursorindexingignore
new file mode 100644
index 000000000..d6be2f6d8
--- /dev/null
+++ b/.cursorindexingignore
@@ -0,0 +1 @@
+.devbox/
diff --git a/Gemfile b/Gemfile
index eb92d048e..257d8f4c0 100644
--- a/Gemfile
+++ b/Gemfile
@@ -137,7 +137,7 @@ gem 'browser'
gem 'net-http'
gem 'ajax_modal_rails', '~> 1.0'
-gem 'sentry-rails', '~> 5.5'
+gem 'sentry-rails', '~> 6'
gem 'warning'
# Metrics
diff --git a/Gemfile.lock b/Gemfile.lock
index ecb6dd706..32f4d2022 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -246,7 +246,7 @@ GEM
caxlsx_rails (0.6.4)
actionpack (>= 3.1)
caxlsx (>= 3.0)
- cgi (0.5.1)
+ cgi (0.5.2)
childprocess (5.1.0)
logger (~> 1.5)
chronic (0.10.2)
@@ -260,7 +260,7 @@ GEM
coffee-script-source
execjs
coffee-script-source (1.12.2)
- concurrent-ruby (1.3.7)
+ concurrent-ruby (1.3.8)
connection_pool (2.5.5)
crass (1.0.7)
csv (3.3.5)
@@ -323,7 +323,7 @@ GEM
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
- erb (6.0.4)
+ erb (6.0.7)
erubi (1.13.1)
erubis (2.7.0)
excon (0.112.0)
@@ -365,12 +365,12 @@ GEM
nokogiri (>= 1.6.0)
ruby_parser (~> 3.5)
htmlentities (4.4.2)
- i18n (1.14.8)
+ i18n (1.15.2)
concurrent-ruby (~> 1.0)
ice_nine (0.11.2)
iniparse (1.5.0)
- io-console (0.8.2)
- irb (1.17.0)
+ io-console (0.9.2)
+ irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
@@ -475,7 +475,7 @@ GEM
racc
pg (1.6.3)
pg (1.6.3-arm64-darwin)
- pp (0.6.3)
+ pp (0.6.4)
prettyprint
pretender (0.6.0)
actionpack (>= 7.1)
@@ -492,15 +492,12 @@ GEM
pry (>= 0.13, < 0.17)
pry-rails (0.3.11)
pry (>= 0.13.0)
- psych (5.3.1)
- date
- stringio
public_suffix (7.0.5)
puma (8.0.2)
nio4r (~> 2.0)
pwned (2.4.1)
racc (1.8.1)
- rack (3.1.21)
+ rack (3.1.22)
rack-attack (6.8.0)
rack (>= 1.0, < 4)
rack-mini-profiler (4.0.1)
@@ -548,10 +545,15 @@ GEM
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
- rake (13.3.1)
- rdoc (7.2.0)
+ rake (13.4.2)
+ rbs (4.1.3)
+ logger
+ prism (>= 1.6.0)
+ tsort
+ rdoc (8.0.0)
erb
- psych (>= 4.0.0)
+ prism (>= 1.6.0)
+ rbs (>= 4.0.0)
tsort
recursive-open-struct (1.3.1)
ostruct
@@ -561,7 +563,7 @@ GEM
redis-client (0.28.0)
connection_pool
regexp_parser (2.11.3)
- reline (0.6.3)
+ reline (0.7.0)
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
@@ -633,12 +635,13 @@ GEM
sassc (2.4.0)
ffi (~> 1.9)
securerandom (0.4.1)
- sentry-rails (5.28.1)
- railties (>= 5.0)
- sentry-ruby (~> 5.28.1)
- sentry-ruby (5.28.1)
+ sentry-rails (6.7.0)
+ railties (>= 5.2.0)
+ sentry-ruby (~> 6.7.0)
+ sentry-ruby (6.7.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
+ logger
sexp_processor (4.17.5)
shoulda-matchers (7.0.1)
activesupport (>= 7.1)
@@ -662,7 +665,6 @@ GEM
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
- stringio (3.2.0)
temple (0.10.4)
terser (1.2.7)
execjs (>= 0.3.0, < 3)
@@ -729,7 +731,7 @@ GEM
yabeda (~> 0.8)
yajl-ruby (1.4.3)
yaml-safe_load_stream3 (0.1.2)
- zeitwerk (2.8.2)
+ zeitwerk (2.8.3)
zip_kit (6.3.4)
PLATFORMS
@@ -851,7 +853,7 @@ DEPENDENCIES
rubocop-rspec
ruby-prof
ruby_parser
- sentry-rails (~> 5.5)
+ sentry-rails (~> 6)
shoulda-matchers
simple_form
slack-notifier
@@ -943,7 +945,7 @@ CHECKSUMS
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
caxlsx (4.4.2) sha256=41bb1141eb083f2f72e0a34db85684a2ec48d38fed4c1ff20c1525c47cdc93eb
caxlsx_rails (0.6.4) sha256=27e1ebb4617473f49a7956214e352b485beb080d590769ef5a826c410c7d2276
- cgi (0.5.1) sha256=e93fcafc69b8a934fe1e6146121fa35430efa8b4a4047c4893764067036f18e9
+ cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec
chronic (0.10.2) sha256=766f2fcce6ac3cc152249ed0f2b827770d3e517e2e87c5fba7ed74f4889d2dc3
coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b
@@ -951,7 +953,7 @@ CHECKSUMS
coffee-rails (5.0.0) sha256=5daaa1ba51fd4907c98a333b6a9e7c1a99b1fff57fcef999b6c62d813cb91a9c
coffee-script (2.4.1) sha256=82fe281e11b93c8117b98c5ea8063e71741870f1c4fbb27177d7d6333dd38765
coffee-script-source (1.12.2) sha256=e12b16fd8927fbbf8b87cb2e9a85a6cf457c6881cc7ff8b1af15b31f70da07a4
- concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
+ concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
connection_pool (2.5.5) sha256=e54ff92855753df1fd7c59fa04a398833355f27dd14c074f8c83a05f72a716ad
crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
@@ -976,7 +978,7 @@ CHECKSUMS
dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2
dry-struct (1.8.1) sha256=033868594c45241540172bf1ebbc8bb76b72b4f0717072325deba38ac13e80f1
dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178
- erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9
+ erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
erubis (2.7.0) sha256=63653f5174a7997f6f1d6f465fbe1494dcc4bdab1fb8e635f6216989fb1148ba
excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0
@@ -996,11 +998,11 @@ CHECKSUMS
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
html2haml (2.3.0) sha256=b587eaf532609642fc3b5c2b35861e8c9e15e72e0355b93bbcfee1e3ae088127
htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3
- i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
+ i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
ice_nine (0.11.2) sha256=5d506a7d2723d5592dc121b9928e4931742730131f22a1a37649df1c1e2e63db
iniparse (1.5.0) sha256=36a165e98d8a250b7631c4a7f9afba32af78f089970cd6446a39771189c761f1
- io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
- irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae
+ io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
+ irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
jquery-rails (4.6.1) sha256=619f3496cdcdeaae1fd6dafa52dbac3fc45b745d4e09712da4184a16b3a8d9c0
jsbundling-rails (1.3.1) sha256=0fa03f6d051c694cbf55a022d8be53399879f2c4cf38b2968f86379c62b1c2ca
@@ -1053,7 +1055,7 @@ CHECKSUMS
parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99
pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f
- pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
+ pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
pretender (0.6.0) sha256=d4528df42dd0dc5424b5d077a23435341d31ddee09bc54c8301691f55afda63d
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
@@ -1061,12 +1063,11 @@ CHECKSUMS
pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e
pry-byebug (3.12.0) sha256=594e094ae8a8390a7ad4c7b36ae36e13304ed02664c67417d108dc5f7213d1b7
pry-rails (0.3.11) sha256=a69e28e24a34d75d1f60bcf241192a54253f8f7ef8a62cba1e75750a9653593d
- psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb
pwned (2.4.1) sha256=e375b45e1cd78aded2c923737f75e09e64bc66582142c144bd85f4ce1fa0955d
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
- rack (3.1.21) sha256=285c5f228eef30dbd1dc147859a880b71bc44412fea575cc58c28797f2db9777
+ rack (3.1.22) sha256=db116c1462fd32dec8b942a808ebedd4e1dbf1fcd0b24c481ae32ee99ca1ebe0
rack-attack (6.8.0) sha256=f2499fdebf85bcc05573a22dff57d24305ac14ec2e4156cd3c28d47cafeeecf2
rack-mini-profiler (4.0.1) sha256=485810c23211f908196c896ea10cad72ed68780ee2998bec1f1dfd7558263d78
rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
@@ -1078,14 +1079,15 @@ CHECKSUMS
rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2
railties (7.2.3.1) sha256=aea3393ee10243ceedcbeccb45458a0d58b524b6d21bf32eff8b93853baae15a
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
- rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
- rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
+ rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+ rbs (4.1.3) sha256=0c4474a9751cdc14364bfad0b3e53678323bbdc2c31683b0445932867dbab8c4
+ rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
recursive-open-struct (1.3.1) sha256=141b4a9c8817bb30f4275c5adb1b5bebaba41bf9b7dd6d6a75ad394390ad8720
redcarpet (3.6.1) sha256=d444910e6aa55480c6bcdc0cdb057626e8a32c054c29e793fa642ba2f155f445
redis (5.4.1) sha256=b5e675b57ad22b15c9bcc765d5ac26f60b675408af916d31527af9bd5a81faae
redis-client (0.28.0) sha256=888892f9cd8787a41c0ece00bdf5f556dfff7770326ce40bb2bc11f1bfec824b
regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
- reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
+ reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
request_store (1.7.0) sha256=e1b75d5346a315f452242a68c937ef8e48b215b9453a77a6c0acdca2934c88cb
responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
@@ -1108,8 +1110,8 @@ CHECKSUMS
rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c
sassc (2.4.0) sha256=4c60a2b0a3b36685c83b80d5789401c2f678c1652e3288315a1551d811d9f83e
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
- sentry-rails (5.28.1) sha256=43a5f884622c60fa459ba05dc8bd22fbd5f28daaced1d00dbcfc4669eaf93807
- sentry-ruby (5.28.1) sha256=1162d474271e8b82202c984b1116db234007a084c5b73293e921468898d07e99
+ sentry-rails (6.7.0) sha256=d295dc5aa239eb91d4fb123c55d9ff2d3c40e7c29921539c5b4ae684b1490293
+ sentry-ruby (6.7.0) sha256=b4b915d79a0395ad02106d4012aa1914641456e042ce36393f15bd1630d8db42
sexp_processor (4.17.5) sha256=ae2b48ba98353d5d465ce8759836b7a05f2e12c5879fcd14d7815b026de32f0e
shoulda-matchers (7.0.1) sha256=b4bfd8744c10e0a36c8ac1a687f921ee7e25ed529e50488d61b79a8688749c77
simple_form (5.4.1) sha256=58c3d229034c7e5545035c3271b6f030ef730c340b9d7d8eb730e0a385b20808
@@ -1118,7 +1120,6 @@ CHECKSUMS
sprockets (4.2.2) sha256=761e5a49f1c288704763f73139763564c845a8f856d52fba013458f8af1b59b1
sprockets-rails (3.5.2) sha256=a9e88e6ce9f8c912d349aa5401509165ec42326baf9e942a85de4b76dbc4119e
sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744
- stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
temple (0.10.4) sha256=b7a1e94b6f09038ab0b6e4fe0126996055da2c38bec53a8a336f075748fff72c
terser (1.2.7) sha256=1b12eb49769dadac44caac3485b38928ff4ab435f1bbbacfe8048cff84c6aa1b
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
@@ -1150,7 +1151,7 @@ CHECKSUMS
yabeda-rails (0.11.0) sha256=afa2581bd44c8f419cb3f2bbf9f6fb40f817c30476f7caf5d1c55c48d69a5b29
yajl-ruby (1.4.3) sha256=8c974d9c11ae07b0a3b6d26efea8407269b02e4138118fbe3ef0d2ec9724d1d2
yaml-safe_load_stream3 (0.1.2) sha256=fd4f63ffe57e65ec8fd5a314064151f843e83239ff87bfc6b2fdbdba81e7e481
- zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12
+ zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5
zip_kit (6.3.4) sha256=407c6d39feef818678fae2d129077bd2f920c9806072af3482e671eeae3c48ea
BUNDLED WITH
diff --git a/Procfile.dev b/Procfile.dev
deleted file mode 100644
index df923d487..000000000
--- a/Procfile.dev
+++ /dev/null
@@ -1,2 +0,0 @@
-web: bin/rails server
-js: yarn build --watch
diff --git a/app/assets/javascripts/ajax_modal_rails/index.coffee b/app/assets/javascripts/ajax_modal_rails/index.coffee
index 3974b389f..96b95e8cf 100644
--- a/app/assets/javascripts/ajax_modal_rails/index.coffee
+++ b/app/assets/javascripts/ajax_modal_rails/index.coffee
@@ -26,7 +26,7 @@ class window.AjaxModal
# the animation completes, select2 cannot measure the container width and renders
# at the wrong size. Both flags must be true before we initialize.
if contentReady[0] and modalShown[0]
- content.find('select.select2').select2({ dropdownParent: content })
+ content.find('select.select2').select2({ dropdownParent: content.closest('.modal-content') })
_registerLinks: ->
$('body').on 'click', @linkTriggersSelector, (e) =>
diff --git a/app/assets/javascripts/legacy.js b/app/assets/javascripts/legacy.js
index 17153da39..3c3bacb7e 100644
--- a/app/assets/javascripts/legacy.js
+++ b/app/assets/javascripts/legacy.js
@@ -33,7 +33,6 @@
//= require_tree ./templates
//= require ajax_modal_rails
//= require acknowledge_notification
-//= require_tree ./requirement_manager
//= require_tree ./service_manager
//= require_tree ./matches
//= require_tree ./users
diff --git a/app/assets/javascripts/remote_select_load.js.coffee b/app/assets/javascripts/remote_select_load.js.coffee
index 5604d8abf..b03630868 100644
--- a/app/assets/javascripts/remote_select_load.js.coffee
+++ b/app/assets/javascripts/remote_select_load.js.coffee
@@ -17,5 +17,7 @@ App.remoteSelectLoad.init = (root) =>
$select.append(data)
$select.attr('placeholder', original_placeholder)
if $select.hasClass('select2')
+ $modalContent = $select.closest('.modal-content')
+ dropdownParent = if $modalContent.length then $modalContent else $select.parent()
$select.select2('destroy')
- new App.Form.Select2Input this, { placeholder: original_placeholder, dropdownParent: $select.parent() }
+ new App.Form.Select2Input this, { placeholder: original_placeholder, dropdownParent: dropdownParent }
diff --git a/app/assets/javascripts/requirement_manager/add_button.js.coffee b/app/assets/javascripts/requirement_manager/add_button.js.coffee
deleted file mode 100644
index 8ddc173c2..000000000
--- a/app/assets/javascripts/requirement_manager/add_button.js.coffee
+++ /dev/null
@@ -1,15 +0,0 @@
-#= require ./namespace
-
-# This class represents the button that adds a new
-# requirement after it's been selected from the list
-
-class App.RequirementManager.AddButton
- constructor: (@element, opts) ->
- @controller = opts.controller
- @_add_on_click()
-
- _add_on_click: ->
- $(@element).on 'click', (evt) =>
- evt.preventDefault()
- @controller.add_requirement_from_rule_searcher()
- $(".jVariableRequirment").hide()
\ No newline at end of file
diff --git a/app/assets/javascripts/requirement_manager/controller.js.coffee b/app/assets/javascripts/requirement_manager/controller.js.coffee
deleted file mode 100644
index f7f4383f0..000000000
--- a/app/assets/javascripts/requirement_manager/controller.js.coffee
+++ /dev/null
@@ -1,131 +0,0 @@
-#= require ./namespace
-
-class App.RequirementManager.Controller
- constructor: (@element) ->
- # These two pieces of data are used to determine the form_input_prefix
- @selected_requirements = new App.RequirementManager.Store
- @loading_spinner = $(@element).find('[data-loading-spinner]')
- @next_index = 0
- @base_object_name = $(@element).attr 'data-base-object-name'
- @_init_existing_requirements()
- @_init_available_rules()
- @_init_new_requirement_positivity_toggles()
- @_init_searcher()
- @_init_add_buttons()
- @_listen_for_destroy_requirement_links()
- @hide_loading_spinner()
-
-
- _init_existing_requirements: ->
- # find the fieldsets generated by simple_fields_for and replace them with our
- # requirement_row template
- $existing_requirement_elements = $(@element).find '[data-existing-requirement]'
- $existing_requirement_elements.each (i, element) =>
- requirement = App.RequirementManager.Requirement.from_element(element)
- $(element).remove()
- @select_requirement requirement
-
- _init_available_rules: ->
- @available_rules = new App.RequirementManager.Store
- $(@element).find('[data-available-rule]')
- .each (_i, element) =>
- @available_rules.add App.RequirementManager.Rule.from_element element
- $(element).remove()
-
- _init_new_requirement_positivity_toggles: ->
- @new_requirement_positivity_toggles = $(@element).find('[data-new-requirement-positivity-toggle]').map (_i, toggle_button_element ) =>
- new App.RequirementManager.NewRequirementPositivityToggle toggle_button_element, controller: @
-
- _init_searcher: ->
- @searcher = new App.RequirementManager.Searcher $(@element).find('[data-rule-searcher]')[0], controller: @
-
- _init_add_buttons: ->
- add_button_element = $(@element).find('[data-add-button]')[0]
- new App.RequirementManager.AddButton add_button_element, controller: @
-
- add_requirement_from_rule_searcher: (attrs) ->
- chosen_rule_id = Number $(@searcher.element).val()
- chosen_rule = @available_rules.find chosen_rule_id
- if chosen_rule && (@new_requirement_positive_value == true || @new_requirement_positive_value == false)
- variable = ''
- if chosen_rule.variable
- $input = $(".jVariableRequirment[data-rule-id=#{chosen_rule.id}]").find('.variable')
- variable = $input.val()
- display_for_variable = ''
- if $input.data('options')?
- if Array.isArray(variable)
- chosen = variable.map (value) -> "#{$input.data('options')[value]}"
- display_for_variable = "(#{chosen.join(', ')})"
- else
- display_for_variable = "(#{$input.data('options')[variable]})"
-
- requirement = new App.RequirementManager.Requirement
- rule_id: chosen_rule.id
- rule_name: chosen_rule.name
- positive: @new_requirement_positive_value
- variable: variable
- display_for_variable: display_for_variable
- @select_requirement requirement
- @available_rules.remove chosen_rule.id
- @searcher.reset()
- @new_requirement_positive_value = null
- @new_requirement_positivity_toggles.each (_i, toggle) =>
- toggle.removeActive()
-
- _listen_for_destroy_requirement_links: ->
- $(@element).on 'click', '[data-destroy-requirement-link]', (evt) =>
- evt.preventDefault()
- target_rule_id = $(evt.target).attr('data-destroy-requirement-rule-id')
- # evt.target might be the inner content of the link
- unless !!target_rule_id
- link = $(evt.target).parents('[data-destroy-requirement-rule-id]')
- target_rule_id = $(link).attr('data-destroy-requirement-rule-id')
-
- @remove_requirement target_rule_id
-
- select_new_requirement_positivity: (value) ->
- @new_requirement_positive_value = value
- @new_requirement_positivity_toggles.each (_i, toggle) =>
- if toggle.requirement_positive == value
- toggle.setActive()
- else
- toggle.removeActive()
-
-
- select_requirement: (requirements) ->
- requirements_row = new App.RequirementManager.RequirementRow requirements,
- controller: @, index: @next_index
- @next_index++
- $(@element).find('[data-selected-requirements]').append requirements_row.to_html()
- if $(".row[data-persisted-id='']").length > 0 && $('.rule-not-persisted-alert').length == 0
- $('a[data-add-button]').closest('.row').after($('
', text: 'Highlighted rules have not been saved. Submit this page to activate new rules.', class: 'alert alert-warning rule-not-persisted-alert mt-3'))
- @selected_requirements.add requirements
-
-
- # We remove by rule_id because the requirement might not have an id if it was just added
- remove_requirement: (rule_id) ->
- requirement_row_element = $(@element).find("[data-requirement-row][data-requirement-rule-id=\"#{rule_id}\"]")[0]
- requirement_rule_id = Number $(requirement_row_element).attr('data-requirement-rule-id')
- requirement = @selected_requirements.find requirement_rule_id
-
-
- # hide the row and mark it for destroy
- $(requirement_row_element).hide()
- $(requirement_row_element).find('[data-destroy-requirement-input]').val('1')
-
- # update our lists
- @selected_requirements.remove requirement.rule_id
- @available_rules.add requirement.to_rule()
-
- # reset the searcher
- @searcher.reset()
-
-
- hide_loading_spinner: (callback) ->
- $(@loading_spinner).hide callback
-
- show_loading_spinner: (callback) ->
- $(@loading_spinner).show callback
-
-# Bootstrap 4's $.fn.modal.Constructor was removed in Bootstrap 5.
-# Select2-in-modal focus is handled by the select2:open handler in init.js.coffee.
diff --git a/app/assets/javascripts/requirement_manager/namespace.js.coffee b/app/assets/javascripts/requirement_manager/namespace.js.coffee
deleted file mode 100644
index ac27d8627..000000000
--- a/app/assets/javascripts/requirement_manager/namespace.js.coffee
+++ /dev/null
@@ -1 +0,0 @@
-App.RequirementManager ||= {}
\ No newline at end of file
diff --git a/app/assets/javascripts/requirement_manager/new_requirement_positivity_toggle.js.coffee b/app/assets/javascripts/requirement_manager/new_requirement_positivity_toggle.js.coffee
deleted file mode 100644
index f2807067a..000000000
--- a/app/assets/javascripts/requirement_manager/new_requirement_positivity_toggle.js.coffee
+++ /dev/null
@@ -1,21 +0,0 @@
-#= require ./namespace
-
-# This class represents the Must/Cant Buttons that decide the "postive"
-# attribute of newly added requirements
-
-class App.RequirementManager.NewRequirementPositivityToggle
- constructor: (@element, opts) ->
- @controller = opts.controller
- @requirement_positive = Boolean $(@element).data('requirement-positive')
- @_select_positivity_on_click()
-
- _select_positivity_on_click: ->
- $(@element).on 'click', (evt) =>
- evt.preventDefault()
- @controller.select_new_requirement_positivity @requirement_positive
-
- setActive: ->
- $(@element).addClass 'active'
-
- removeActive: ->
- $(@element).removeClass 'active'
diff --git a/app/assets/javascripts/requirement_manager/requirement.js.coffee b/app/assets/javascripts/requirement_manager/requirement.js.coffee
deleted file mode 100644
index de65b170a..000000000
--- a/app/assets/javascripts/requirement_manager/requirement.js.coffee
+++ /dev/null
@@ -1,33 +0,0 @@
-#= require ./namespace
-
-class App.RequirementManager.Requirement
- # basic model class that holds the data for a requirement
-
- constructor: (attrs) ->
- # oooo, object destructuring assignment :-d
- {@id, @rule_id, @rule_name, @positive, @variable, @display_for_variable} = attrs
- @must_active = 'active' if @positive
- @cant_active = 'active' unless @positive
- if @positive
- @positive_string = '1'
- else
- @positive_string = '0'
-
-
- @from_element: (element) =>
- rule_id = $(element).attr('data-requirement-rule-id')
-
- attrs =
- id: $(element).attr('data-requirement-id')
- rule_id: rule_id
- rule_name: $(element).attr('data-requirement-rule-name')
- positive: $(element).attr('data-requirement-positive') == 'true'
- variable: $(element).attr('data-requirement-variable')
- display_for_variable: $(element).attr('data-requirement-display-for-variable')
- new @(attrs)
-
- # use rule_id as our index in stores since we might not have an
- # id of our own
- store_key: -> @rule_id
-
- to_rule: -> new App.RequirementManager.Rule id: @rule_id, name: @rule_name, variable: @variable
\ No newline at end of file
diff --git a/app/assets/javascripts/requirement_manager/requirement_row.js.coffee b/app/assets/javascripts/requirement_manager/requirement_row.js.coffee
deleted file mode 100644
index 2fc109ade..000000000
--- a/app/assets/javascripts/requirement_manager/requirement_row.js.coffee
+++ /dev/null
@@ -1,11 +0,0 @@
-#= require ./namespace
-
-class App.RequirementManager.RequirementRow
- constructor: (@requirement, opts) ->
- @controller = opts.controller
- @input_name_prefix = "#{@controller.base_object_name}[requirements_attributes][#{opts.index}]"
-
- to_html: ->
- HandlebarsTemplates['requirement_manager/requirement_row']
- input_name_prefix: @input_name_prefix
- requirement: @requirement
diff --git a/app/assets/javascripts/requirement_manager/rule.js.coffee b/app/assets/javascripts/requirement_manager/rule.js.coffee
deleted file mode 100644
index 6569c78a9..000000000
--- a/app/assets/javascripts/requirement_manager/rule.js.coffee
+++ /dev/null
@@ -1,17 +0,0 @@
-#= require ./namespace
-
-class App.RequirementManager.Rule
- # basic model class that holds the data for a rule
-
- constructor: (attrs) ->
- # oooo, object destructuring assignment :-d
- {@id, @name, @variable} = attrs
-
- @from_element: (element) =>
- attrs =
- id: $(element).data('rule-id')
- name: $(element).data('rule-name')
- variable: $(element).data('variable')
- new @(attrs)
-
- store_key: -> @id
\ No newline at end of file
diff --git a/app/assets/javascripts/requirement_manager/searcher.js.coffee b/app/assets/javascripts/requirement_manager/searcher.js.coffee
deleted file mode 100644
index 00dda1e80..000000000
--- a/app/assets/javascripts/requirement_manager/searcher.js.coffee
+++ /dev/null
@@ -1,66 +0,0 @@
-#= require ./namespace
-
-# see notes on contact manager searcher performance
-# in app/assets/javascripts/contact_manager/searcher.js.coffee
-#
-# the same considerations are applicable here if we get a large number of rules
-#
-# ~ @rrosen 4/22/2016
-
-class App.RequirementManager.Searcher
- constructor: (@element, opts) ->
- @controller = opts.controller
- @available_rules = @controller.available_rules
- @placeholder = $(@element).attr 'placeholder'
- @_init_select2()
- @_init_select_listener()
-
- _dropdownParent: (element) ->
- # .modal-body has overflow-y:auto in Bootstrap 5 scrollable modals, which clips
- # the dropdown. Use .modal-content (above the clip boundary) when inside a modal.
- $modalContent = $(element).closest('.modal-content')
- if $modalContent.length then $modalContent else $(element).closest('form')
-
- _init_select2: ->
- $(@element).select2
- theme: 'bootstrap'
- data: @_select2_data()
- placeholder: @placeholder
- width: '100%'
- dropdownParent: @_dropdownParent(@element)
-
- _init_select_listener: ->
- $(".jVariableRequirment").hide()
- $(".jRuleSelectionNote").hide()
- $(@element).on 'select2:select', (e) =>
- rule_id = $(@element).val()
- $(".jVariableRequirment").hide()
- $(".jRuleSelectionNote").hide()
- $(".jVariableRequirment[data-rule-id=#{rule_id}]").show()
- $variableSelect = $(".jVariableRequirment[data-rule-id=#{rule_id}] select")
- $variableSelect.css('width', '100%')
- $variableSelect.select2
- dropdownParent: @_dropdownParent($variableSelect[0])
- $(".jRuleSelectionNote[data-rule-id=#{rule_id}]").show()
-
- reset: ->
- # resetting select2 this way currently throws
- # Uncaught TypeError: Cannot read property 'current' of null
- # on subsequent choices
- # doesn't seem to break anything though
- $(@element).val("")
- $(@element).select2 'destroy'
- $(@element).find('option').remove()
- @_init_select2()
-
- _select2_data: ->
- result = [{id: "", text: ""}]
- result = result.concat @available_rules.map (rule) => {text: rule.name, id: rule.id}
- result.sort(@_compare_rules)
-
- _compare_rules: (a, b) ->
- if a.text < b.text
- return -1
- if a.text > b.text
- return 1
- return 0
diff --git a/app/assets/javascripts/requirement_manager/store.js.coffee b/app/assets/javascripts/requirement_manager/store.js.coffee
deleted file mode 100644
index b976eb24d..000000000
--- a/app/assets/javascripts/requirement_manager/store.js.coffee
+++ /dev/null
@@ -1,18 +0,0 @@
-#= require ./namespace
-
-# A hash of requirements or rules by id
-class App.RequirementManager.Store
- constructor: ->
- @_store = {}
-
- find: (id) ->
- @_store[id]
-
- remove: (id) ->
- delete @_store[id]
-
- add: (obj) ->
- @_store[obj.store_key()] = obj
-
- map: (fn) ->
- fn.call(@, value) for own key, value of @_store
\ No newline at end of file
diff --git a/app/assets/javascripts/templates/requirement_manager/requirement_row.hbs b/app/assets/javascripts/templates/requirement_manager/requirement_row.hbs
deleted file mode 100644
index 02c0af165..000000000
--- a/app/assets/javascripts/templates/requirement_manager/requirement_row.hbs
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
- {{requirement.rule_name}}
- {{requirement.display_for_variable}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/assets/stylesheets/application.scss.erb b/app/assets/stylesheets/application.scss.erb
index 8bb1d5bf2..f5a242630 100644
--- a/app/assets/stylesheets/application.scss.erb
+++ b/app/assets/stylesheets/application.scss.erb
@@ -136,10 +136,10 @@
@import "modules/tables";
@import "modules/vouchers";
@import "modules/add_things_boxes";
-@import "modules/requirement_manager";
@import "modules/role_manager";
@import "modules/clients";
@import "modules/filter_sort";
+@import "modules/vacancy_submissions";
@import "modules/_match_list";
@import "vendor/icons";
@import "helpers/helper_classes";
diff --git a/app/assets/stylesheets/base/_forms.scss b/app/assets/stylesheets/base/_forms.scss
index 5ce3e2f31..83ca47c39 100644
--- a/app/assets/stylesheets/base/_forms.scss
+++ b/app/assets/stylesheets/base/_forms.scss
@@ -67,6 +67,7 @@
}
.has-error .help-block {
color: var(--brand-danger);
+ margin-top: 0.25rem;
}
.error .form-control {
diff --git a/app/assets/stylesheets/components/_card.scss b/app/assets/stylesheets/components/_card.scss
index 249030dfe..5fea93c1f 100644
--- a/app/assets/stylesheets/components/_card.scss
+++ b/app/assets/stylesheets/components/_card.scss
@@ -11,9 +11,18 @@
.c-card__header {
padding: space(3) space(3) 0 space(3);
+ background-color: $white;
+ width: 100%;
+ border-width: 1px;
+ border-style: solid;
+ border-color: $gray-020;
+ border-radius: $border-radius-default;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ border-bottom-width: 0;
}
-.c-card > .c-card__content {
+.c-card>.c-card__content {
@include card;
width: 100%;
}
@@ -23,15 +32,18 @@
}
.c-card--flush {
- padding: 0;
+ padding: 0 !important;
+
.table {
margin-bottom: 0;
}
+
.table thead tr th,
.table tbody tr td {
&:first-of-type {
padding-left: rem(15px);
}
+
&:last-of-type {
padding-right: rem(15px);
}
@@ -40,11 +52,13 @@
.c-card--linked {
cursor: pointer;
+
&:focus,
&:hover {
color: $text-color;
box-shadow: 0 2px 8px rgba($black, .1);
}
+
&:active {
background-color: $body-bg;
}
@@ -98,6 +112,7 @@
.c-card__content-block-text {
font-weight: 800;
+
&:empty {
margin-bottom: -30px;
}
diff --git a/app/assets/stylesheets/elements/_label.scss b/app/assets/stylesheets/elements/_label.scss
index 982350225..9944c6247 100644
--- a/app/assets/stylesheets/elements/_label.scss
+++ b/app/assets/stylesheets/elements/_label.scss
@@ -3,6 +3,12 @@ label {
@include open-sans;
}
+label abbr[title="required"],
+label abbr[data-bs-original-title="required"] {
+ color: var(--brand-danger);
+ text-decoration: none;
+}
+
.label-helper {
font-style: italic;
font-size: 95%;
diff --git a/app/assets/stylesheets/elements/_table.scss b/app/assets/stylesheets/elements/_table.scss
index 42243d773..63d988a3a 100644
--- a/app/assets/stylesheets/elements/_table.scss
+++ b/app/assets/stylesheets/elements/_table.scss
@@ -8,10 +8,6 @@ table.table-fixed {
background-color: $white;
}
-.table-hover tr {
- cursor: pointer;
-}
-
// Sort icon in table headers
th .fa {
padding-left: 4px;
diff --git a/app/assets/stylesheets/modules/_requirement_manager.scss b/app/assets/stylesheets/modules/_requirement_manager.scss
deleted file mode 100644
index 4aee3c43e..000000000
--- a/app/assets/stylesheets/modules/_requirement_manager.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-.jRuleSelectionNote.alert p {
- display: block;
-}
diff --git a/app/assets/stylesheets/modules/_tables.scss b/app/assets/stylesheets/modules/_tables.scss
index a9bc634ba..de245b360 100644
--- a/app/assets/stylesheets/modules/_tables.scss
+++ b/app/assets/stylesheets/modules/_tables.scss
@@ -1,10 +1,12 @@
.table-utilities {
margin-top: space(2);
margin-bottom: space(2);
+
@media (max-width: $screen-sm-max) {
@include cas-margin(12px null 24px);
}
}
+
// .table > thead > tr > th,
// .table > thead > tr > td,
// .table > tbody > tr > th,
@@ -16,12 +18,15 @@
.table__init-cap-header th {
text-transform: capitalize;
}
+
th[class^="sort-"] {
background-color: $cas-red-green;
}
+
.table__full-width {
width: 100%;
}
+
.table-responsive span[class^="icon-"],
.table-responsive span[class*="icon-"] {
font-size: rem(12);
@@ -31,11 +36,11 @@ th[class^="sort-"] {
.table-responsive:has(.dropdown-menu.show) {
overflow: visible;
}
+
.btn-sm span[class^="icon-"],
.btn-sm span[class*="icon-"],
.btn-sm span[class^="icon-"],
-.btn-sm span[class*="icon-"]
-{
+.btn-sm span[class*="icon-"] {
font-size: 10px;
line-height: 1.5;
padding-right: 2px;
@@ -43,18 +48,20 @@ th[class^="sort-"] {
.table tr.no-border,
table tr.no-border {
+
&,
- > th,
- > td {
+ >th,
+ >td {
border: 0;
}
}
-.table thead td {
+.table thead td,
+.table thead th {
background: $table-head-bg;
font-weight: 800;
border-bottom-color: $gray-020;
- border-bottom-size: 2px;
+ border-bottom-width: 2px;
}
.pagy-container {
diff --git a/app/assets/stylesheets/modules/_vacancy_submissions.scss b/app/assets/stylesheets/modules/_vacancy_submissions.scss
new file mode 100644
index 000000000..a57b1f415
--- /dev/null
+++ b/app/assets/stylesheets/modules/_vacancy_submissions.scss
@@ -0,0 +1,51 @@
+.vacancy-submission-detail,
+#vacancy_submission_form {
+ max-width: 800px;
+
+ .well {
+ background-color: #f5f7f8;
+ border: 1px solid #e3e8ec;
+ border-radius: 6px;
+ margin-bottom: 1.25rem;
+ padding: 1rem;
+ }
+
+ .well .card {
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
+ margin-bottom: 0;
+ }
+
+ .card {
+ border-color: #d5d5d5;
+ }
+
+ .card-header {
+ background: none;
+ border-bottom-color: #d5d5d5;
+ color: $cob-sg-blue-004;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ }
+
+ .vs-field-label {
+ color: #888;
+ display: block;
+ font-size: 12px;
+ margin-bottom: 2px;
+ }
+
+ .vs-field-value {
+ color: #333;
+ font-size: 14px;
+ }
+
+ .alert-changes-requested {
+ background: #fde8d8;
+ border: 1px solid #f5c6a5;
+ border-radius: 4px;
+ color: #991b1b;
+ padding: 12px 16px;
+ }
+}
diff --git a/app/assets/stylesheets/overrides/_select2.scss b/app/assets/stylesheets/overrides/_select2.scss
index 54f22ddd8..06501e805 100644
--- a/app/assets/stylesheets/overrides/_select2.scss
+++ b/app/assets/stylesheets/overrides/_select2.scss
@@ -92,6 +92,10 @@
margin-bottom: rem(2px);
}
+.is-invalid+.select2-container .select2-selection {
+ border-color: var(--bs-form-invalid-border-color, #dc3545);
+}
+
.select2-container--default .select2-selection--multiple .select2-selection__rendered {
&:only-child {
padding: 0;
diff --git a/app/assets/stylesheets/settings/_colors.scss b/app/assets/stylesheets/settings/_colors.scss
index 5b10375eb..69e3194ba 100644
--- a/app/assets/stylesheets/settings/_colors.scss
+++ b/app/assets/stylesheets/settings/_colors.scss
@@ -22,32 +22,33 @@ $cas-red-green: rgb(231, 223, 221) !default; // #E7DFDD
// additional cas colors
$cas-blue-007: #90A4BC !default;
-$brand-primary: #457687 !default;
-$brand-primary-l: #81adb9 !default;
-$brand-primary-ll: #d8e7ea !default;
-$brand-primary-d: darken($brand-primary, 5%) !default;
-$brand-secondary: #7D5226 !default;
-$brand-secondary-l: lighten($brand-secondary, 25%) !default;
-$brand-success: #009f44 !default;
-$brand-success-l: #81e7a7 !default;
-$brand-success-d: #68c27f !default;
+$brand-primary: #457687 !default;
+$brand-primary-l: #81adb9 !default;
+$brand-primary-ll: #d8e7ea !default;
+$brand-primary-d: darken($brand-primary, 5%) !default;
+$brand-secondary: #7D5226 !default;
+$brand-secondary-l: lighten($brand-secondary, 25%) !default;
+$brand-success: #009f44 !default;
+$brand-success-l: #81e7a7 !default;
+$brand-success-d: #68c27f !default;
$brand-positive: $brand-success;
$brand-positive-l: $brand-success-l;
-$brand-info: $brand-primary-l !default;
-$brand-warning: #bc6923 !default;
-$brand-warning-l: #ffcb9b !default;
-$brand-warning-d: #e5b17e !default;
+$brand-info: $brand-primary-l !default;
+$brand-warning: #bc6923 !default;
+$brand-warning-l: #ffcb9b !default;
+$brand-warning-d: #e5b17e !default;
// Sidebar / site menu (override in theme/styles/_variables.scss per deployment)
$menu-sidebar-bg: $cas-blue !default;
$menu-sidebar-border-d: darken($cas-blue, 6%) !default;
$menu-section-accent: $brand-warning-l !default;
-$brand-danger: #bb2716 !default;
-$brand-danger-d: #df9489 !default;
-$brand-danger-l: #ffa69b !default;
+$brand-danger: #bb2716 !default;
+$brand-danger-d: #df9489 !default;
+$brand-danger-l: #ffa69b !default;
$white: rgba(255, 255, 255, 1) !default;
+$gray-010: rgb(250, 251, 252) !default;
$gray-015: rgb(242, 242, 242) !default; // #f2f2f2 COB-sg lightest gray (bg)
$gray-020: rgb(233, 231, 237) !default; // #E9E7ED
$gray-025: rgb(228, 228, 228) !default; // #e4e4e4 COB-sg 2nd-to-lightest gray
@@ -61,12 +62,15 @@ $gray-070: rgb(64, 64, 64) !default; // #404040
$gray-080: rgb(51, 51, 51) !default; // #333
$black: rgba(0, 0, 0, 1) !default;
-$gray-base: #000 !default;
-$gray-darker: lighten($gray-base, 13.5%) !default; // #222
-$gray-dark: lighten($gray-base, 20%) !default; // #333
-$gray: lighten($gray-base, 33.5%) !default; // #555
-$gray-light: lighten($gray-base, 46.7%) !default; // #777
-$gray-lighter: lighten($gray-base, 93.5%) !default; // #eee
+$gray-base: #000 !default;
+$gray-darker: lighten($gray-base, 13.5%) !default; // #222
+$gray-dark: lighten($gray-base, 20%) !default; // #333
+$gray: lighten($gray-base, 33.5%) !default; // #555
+$gray-light: lighten($gray-base, 46.7%) !default; // #777
+$gray-lighter: lighten($gray-base, 93.5%) !default; // #eee
+
+// Forms
+$input-bg: white;
// Links
$link-color: #A46B32;
@@ -83,14 +87,14 @@ $tab-bg: #88A6C9; // Primary in HMIS
$text-color: $gray-070;
$theme-colors: (
- "primary": $brand-primary,
- "secondary": $brand-secondary,
- "success": $brand-success,
- "info": $brand-info,
- "warning": $brand-warning,
- "danger": $brand-danger,
- "light": $white,
- "dark": $gray-080
+ "primary": $brand-primary,
+ "secondary": $brand-secondary,
+ "success": $brand-success,
+ "info": $brand-info,
+ "warning": $brand-warning,
+ "danger": $brand-danger,
+ "light": $white,
+ "dark": $gray-080
);
$input-disabled: #eeeeee;
diff --git a/app/assets/stylesheets/settings/_custom_bootstrap_variables.scss b/app/assets/stylesheets/settings/_custom_bootstrap_variables.scss
index 94d3cb227..6e62d9b79 100644
--- a/app/assets/stylesheets/settings/_custom_bootstrap_variables.scss
+++ b/app/assets/stylesheets/settings/_custom_bootstrap_variables.scss
@@ -10,9 +10,13 @@ $nav-tabs-border-radius: 0;
$input-btn-padding-y: .6rem;
$input-padding-y: $input-btn-padding-y;
+// Badges
+$badge-padding-y: 0.5rem;
+$badge-padding-x: 0.75em;
+
// Buttons
-$btn-padding-x : 2rem;
-$btn-padding-y : $input-btn-padding-y;
+$btn-padding-x : 2rem;
+$btn-padding-y : $input-btn-padding-y;
$btn-padding-x-sm: 1.5rem;
$btn-padding-y-sm: .4rem;
@@ -20,12 +24,12 @@ $btn-padding-y-sm: .4rem;
$input-border-color: $brand-primary-l;
$input-disabled-bg: $input-disabled;
-$primary: $brand-primary;
-$secondary: $brand-secondary;
-$success: $brand-success;
-$info: $brand-info;
-$warning: $brand-warning;
-$danger: $brand-danger;
+$primary: $brand-primary;
+$secondary: $brand-secondary;
+$success: $brand-success;
+$info: $brand-info;
+$warning: $brand-warning;
+$danger: $brand-danger;
// Tooltips
$tooltip-font-size: 13px;
@@ -47,5 +51,8 @@ $pagination-active-bg: $brand-primary-d;
// Table
// App-specific variable — not a Bootstrap 5 variable (BS5 dropped $table-head-bg)
-$table-head-bg: $gray-015;
-$table-hover-bg: lighten($cob-sg-blue-005, 2%);
+$table-bg: white;
+$table-head-bg: $gray-010;
+$table-hover-bg: $gray-015;
+$table-cell-padding-x: 0.75rem;
+$table-cell-padding-y: 0.75rem;
diff --git a/app/assets/stylesheets/vendor/bootstrap/_v4_compatibility.scss b/app/assets/stylesheets/vendor/bootstrap/_v4_compatibility.scss
index ee7f72d90..cd4f233f5 100644
--- a/app/assets/stylesheets/vendor/bootstrap/_v4_compatibility.scss
+++ b/app/assets/stylesheets/vendor/bootstrap/_v4_compatibility.scss
@@ -41,15 +41,24 @@
// Caret suppression (matching $enable-caret: false behavior)
.dropdown-toggle::after { display: none; }
-// Badge color aliases — BS4 .badge-* → BS5 .text-bg-*
-.badge-primary { @extend .text-bg-primary; }
-.badge-secondary { @extend .text-bg-secondary; }
-.badge-success { @extend .text-bg-success; }
-.badge-danger { @extend .text-bg-danger; }
-.badge-warning { @extend .text-bg-warning; }
-.badge-info { @extend .text-bg-info; }
-.badge-light { @extend .text-bg-light; }
-.badge-dark { @extend .text-bg-dark; }
+// Badge color aliases — soft/subtle style using Bootstrap 5.3 CSS custom properties
+@each $color in (primary, secondary, success, danger, warning, info) {
+ .badge-#{$color} {
+ background-color: var(--bs-#{$color}-bg-subtle);
+ color: var(--bs-#{$color}-text-emphasis);
+ border: 1px solid var(--bs-#{$color}-border-subtle);
+ }
+}
+.badge-light {
+ background-color: var(--bs-light-bg-subtle);
+ color: var(--bs-light-text-emphasis);
+ border: 1px solid var(--bs-light-border-subtle);
+}
+.badge-dark {
+ background-color: var(--bs-dark-bg-subtle);
+ color: var(--bs-dark-text-emphasis);
+ border: 1px solid var(--bs-dark-border-subtle);
+}
// Input group addon (renamed to input-group-text in BS5)
.input-group-addon { @extend .input-group-text; }
diff --git a/app/controllers/admin/configs_controller.rb b/app/controllers/admin/configs_controller.rb
index 4d83afdd2..3e79bc60a 100644
--- a/app/controllers/admin/configs_controller.rb
+++ b/app/controllers/admin/configs_controller.rb
@@ -4,6 +4,8 @@
# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
###
+# frozen_string_literal: true
+
module Admin
class ConfigsController < ApplicationController
before_action :require_can_manage_config!
@@ -36,6 +38,7 @@ def update
:include_note_in_email_default,
:notify_all_on_progress_update,
:send_match_summary_email_on,
+ :vacancy_submission_mechanism,
non_hmis_fields: [],
)
end
diff --git a/app/controllers/admin/housing_attribute_names_controller.rb b/app/controllers/admin/housing_attribute_names_controller.rb
new file mode 100644
index 000000000..320305392
--- /dev/null
+++ b/app/controllers/admin/housing_attribute_names_controller.rb
@@ -0,0 +1,74 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+module Admin
+ class HousingAttributeNamesController < ApplicationController
+ before_action :require_can_manage_config!
+ before_action :set_row, only: [:edit, :update]
+
+ def index
+ @names = HousingAttribute.name_summary
+ end
+
+ def edit
+ @rows = HousingAttribute.where(name: @row.name, include_value: @row.include_value).
+ includes(:housingable).order(:housingable_type, :housingable_id)
+ @values = @row.include_value? ? HousingAttribute.value_summary(@row.name) : []
+ end
+
+ def update
+ old_name = @row.name
+ new_name = attribute_params[:name].presence || old_name
+
+ HousingAttribute.rename(old_name: old_name, new_name: new_name, include_value: @row.include_value)
+
+ if @row.include_value?
+ Array(attribute_params[:value_renames]).each do |pair|
+ HousingAttribute.rename_value(name: new_name, old_value: pair[:old_value], new_value: pair[:new_value])
+ end
+ end
+
+ flash[:notice] = new_name == old_name ? "Updated '#{new_name}'" : "Renamed '#{old_name}' to '#{new_name}'"
+ redirect_to edit_admin_housing_attribute_name_path(@row.id)
+ end
+
+ private
+
+ def set_row
+ @row = HousingAttribute.find(params[:id].to_i)
+ end
+
+ def attribute_params
+ params.require(:housing_attribute_name).permit(:name, value_renames: [:old_value, :new_value])
+ end
+
+ def housingable_type(housingable)
+ case housingable
+ when Building then 'Building'
+ when Unit then 'Unit'
+ end
+ end
+ helper_method :housingable_type
+
+ def housingable_name(housingable)
+ case housingable
+ when Building then housingable.name
+ when Unit then "#{housingable.name} (#{housingable.building&.name})"
+ end
+ end
+ helper_method :housingable_name
+
+ def housingable_edit_path(housingable)
+ case housingable
+ when Building then edit_building_path(housingable)
+ when Unit then edit_unit_path(housingable)
+ end
+ end
+ helper_method :housingable_edit_path
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 4925de37d..ceac2e414 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -9,6 +9,10 @@
require 'application_responder'
class ApplicationController < ActionController::Base
+ # Include the Markdown helper globally, it is used in a handful of places
+ # and we keep forgetting to include it, this means it is available when needed.
+ helper MarkdownHelper
+
include ControllerAuthorization
include ActivityLogger
include ArelHelper
diff --git a/app/controllers/buildings_controller.rb b/app/controllers/buildings_controller.rb
index 7434134d8..e3e2cb2ce 100644
--- a/app/controllers/buildings_controller.rb
+++ b/app/controllers/buildings_controller.rb
@@ -57,8 +57,8 @@ def new
end
def create
- @building = Building.create(building_params)
- if @building
+ @building = Building.new(building_params)
+ if @building.save
flash[:notice] = "#{@building.name} was successfully added."
redirect_to building_path(@building)
else
diff --git a/app/controllers/housing_attributes_controller.rb b/app/controllers/housing_attributes_controller.rb
index e84e7c47c..c75cc047f 100644
--- a/app/controllers/housing_attributes_controller.rb
+++ b/app/controllers/housing_attributes_controller.rb
@@ -4,6 +4,8 @@
# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
###
+# frozen_string_literal: true
+
class HousingAttributesController < ApplicationController
before_action :authenticate_user!
before_action :set_housingable # Must be defined on the child class
@@ -15,7 +17,8 @@ def index
end
def new
- @attribute = @housingable.housing_attributes.build
+ include_value = params[:amenity] != 'true'
+ @attribute = @housingable.housing_attributes.build(include_value: include_value)
end
def create
@@ -54,6 +57,7 @@ def attribute_params
params.require(:housing_attribute).permit(
:name,
:value,
+ :include_value,
"#{@housingable.class.name.downcase}_id",
)
end
diff --git a/app/controllers/opportunities_controller.rb b/app/controllers/opportunities_controller.rb
index 9c2f56525..8927f5136 100644
--- a/app/controllers/opportunities_controller.rb
+++ b/app/controllers/opportunities_controller.rb
@@ -242,7 +242,7 @@ def route_from_tab(tab_label)
# TODO: limit to programs you are associated with
private def sub_program_scope
- SubProgram.all
+ SubProgram.open
end
# TODO: limit to buildings you are associated with
diff --git a/app/controllers/style_guides_controller.rb b/app/controllers/style_guides_controller.rb
index 1d004d971..a28dde716 100644
--- a/app/controllers/style_guides_controller.rb
+++ b/app/controllers/style_guides_controller.rb
@@ -4,6 +4,8 @@
# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
###
+# frozen_string_literal: true
+
class StyleGuidesController < ApplicationController
include AjaxModalRails::Controller
@@ -15,4 +17,17 @@ def form
@form = OpenStruct.new
end
+ private def guide_routes
+ @guide_routes ||= {
+ summary: 'Summary',
+ dnd_match_review: 'DnD Match Review',
+ form: 'Form Elements',
+ icon_font: 'Icon Font',
+ pagination: 'Pagination',
+ stepped_progress: 'Stepped Progress',
+ tags: 'Tags',
+ typography: 'Typography',
+ }
+ end
+ helper_method :guide_routes
end
diff --git a/app/controllers/unit_for_building_controller.rb b/app/controllers/unit_for_building_controller.rb
index 5dc7e2ccf..1efb75ff1 100644
--- a/app/controllers/unit_for_building_controller.rb
+++ b/app/controllers/unit_for_building_controller.rb
@@ -20,6 +20,7 @@ def create
@unit = unit_source.new(unit_params)
@unit.elevator_accessible = @unit.building.elevator_accessible_default
if @unit.save
+ @unit.apply_default_housing_attributes
flash[:notice] = "Unit
#{@unit[:name]} in
#{@unit.building.name} was successfully created."
redirect_to program_sub_program_vouchers_path(program_id: params[:program_id].to_i, sub_program_id: params[:sub_program_id].to_i)
else
diff --git a/app/controllers/units_controller.rb b/app/controllers/units_controller.rb
index e44e582d5..84b968b79 100644
--- a/app/controllers/units_controller.rb
+++ b/app/controllers/units_controller.rb
@@ -136,6 +136,7 @@ def unit_params
:available,
:building_id,
:elevator_accessible,
+ :notes,
requirements_attributes: [:id, :rule_id, :positive, :variable, :_destroy],
)
end
diff --git a/app/controllers/vacancy_submissions_controller.rb b/app/controllers/vacancy_submissions_controller.rb
new file mode 100644
index 000000000..cc0b603bd
--- /dev/null
+++ b/app/controllers/vacancy_submissions_controller.rb
@@ -0,0 +1,289 @@
+###
+# Copyright 2016 - 2025 Green River Data Analysis, LLC
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+class VacancySubmissionsController < ApplicationController
+ SECTIONS = {
+ 'route' => ->(sp, _vs) {
+ { partial: 'vacancy_submissions/route',
+ route: VacancySubmission.derive_route(sp) }
+ },
+ 'requirements' => ->(sp, _vs) {
+ { partial: 'requirement_manager/inherited_rules',
+ inheritee: sp, show_label: false }
+ },
+ 'required_documents' => ->(sp, vs) {
+ { partial: 'vacancy_submissions/required_documents',
+ sub_program: sp,
+ vacancy_submission: vs }
+ },
+ 'vacancy' => ->(sp, vs) {
+ { partial: 'vacancy_submissions/vacancy',
+ sub_program: sp,
+ vacancy_submission: vs,
+ is_voucher: VacancySubmission.derive_is_voucher(sp) }
+ },
+ }.freeze
+ before_action :authenticate_user!
+ before_action :require_can_view_opportunities!, only: [:index, :new, :create, :sub_program_section]
+ before_action :require_can_submit_or_review_vacancies!, only: [:show]
+ before_action :require_can_review_vacancies!, only: [:approve, :return_submission]
+ before_action :require_can_add_vacancies!, only: [:resubmit, :edit, :update]
+ before_action :load_submission, only: [:approve, :return_submission, :resubmit]
+ before_action :load_resubmittable_submission, only: [:edit, :update]
+
+ def index
+ @vacancy_submissions = VacancySubmission
+ .filtered(program_id: params[:program_id], status_filter: params[:status])
+ .order(updated_at: :desc)
+ .page(params[:page]).per(25)
+
+ program_ids = @vacancy_submissions.map(&:program_id).uniq.compact
+ @programs_by_id = Program.where(id: program_ids).index_by(&:id)
+ @programs = Program.where(id: VacancySubmission.program_ids_in_use).order(:name).pluck(:name, :id)
+ end
+
+ def show
+ @submission = VacancySubmission.includes(user: [:contact, :agency]).find(params[:id])
+ @notes = @submission.vacancy_submission_notes.includes(:user).order(created_at: :asc)
+ @program = Program.find_by(id: @submission.program_id)
+ @sub_program = @program.sub_programs.find_by(id: @submission.sub_program_id)
+ end
+
+ def new
+ @vacancy_submission = VacancySubmission.new
+ load_form_data
+ end
+
+ def create
+ program = Program.find_by(id: submission_params[:program_id])
+ sub_program = program&.sub_programs&.find_by(id: submission_params[:sub_program_id])
+
+ unless program && sub_program
+ @vacancy_submission = VacancySubmission.new(
+ program_id: submission_params[:program_id],
+ sub_program_id: submission_params[:sub_program_id],
+ )
+ @vacancy_submission.errors.add(:program_id, :blank) unless program
+ @vacancy_submission.errors.add(:sub_program_id, :blank) unless sub_program
+ load_form_data
+ return render :new, status: :unprocessable_entity
+ end
+
+ @vacancy_submission = VacancySubmission.new(
+ user: current_user,
+ status: 'awaiting_approval',
+ draft_data: build_draft_data(program: program, sub_program: sub_program),
+ )
+
+ if @vacancy_submission.save
+ @vacancy_submission.vacancy_submission_notes.create!(
+ user: current_user,
+ note_type: 'status_change',
+ body: 'Initial submission.',
+ )
+ VacancySubmissions::Notifier.new(@vacancy_submission).notify_submitted!
+ redirect_to vacancy_submissions_path, notice: 'Vacancy submission created.'
+ else
+ load_form_data
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ @program = Program.find_by(id: @submission.program_id)
+ @sub_program = @program.sub_programs.find_by(id: @submission.sub_program_id)
+ load_form_data
+ end
+
+ def update
+ program = Program.find_by(id: submission_params[:program_id])
+ sub_program = program&.sub_programs&.find_by(id: submission_params[:sub_program_id])
+
+ unless program && sub_program
+ @program = Program.find_by(id: @submission.program_id)
+ @sub_program = @program&.sub_programs&.find_by(id: @submission.sub_program_id)
+ load_form_data
+ @submission.errors.add(:program_id, :blank) unless program
+ @submission.errors.add(:sub_program_id, :blank) unless sub_program
+ return render :edit, status: :unprocessable_entity
+ end
+
+ draft_data = @submission.draft_data.merge(build_draft_data(program: program, sub_program: sub_program))
+
+ changed_sections = detect_changed_sections(@submission.draft_data, draft_data)
+
+ if @submission.update(draft_data: draft_data)
+ if changed_sections.any?
+ @submission.vacancy_submission_notes.create!(
+ user: current_user,
+ note_type: 'status_change',
+ body: "Updated: #{changed_sections.join(', ')}.",
+ )
+ end
+ redirect_to vacancy_submission_path(@submission), notice: 'Submission updated.'
+ else
+ @program = program
+ @sub_program = sub_program
+ load_form_data
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def sub_program_section
+ config = SECTIONS[params[:section]]
+ return head :not_found unless config
+
+ sub_program = SubProgram.find(params[:sub_program_id])
+ vacancy_submission = if params[:vacancy_submission_id].present?
+ VacancySubmission.find(params[:vacancy_submission_id])
+ else
+ VacancySubmission.new
+ end
+ vacancy_submission.required_document_names = Array(params[:required_document_names]) if params[:required_document_names].present?
+ vacancy_submission.units = normalize_units(params[:units]) if params[:units].present?
+
+ locals = config.call(sub_program, vacancy_submission)
+ render partial: locals.delete(:partial), locals: locals
+ end
+
+ def approve
+ return redirect_to vacancy_submission_path(@submission), alert: 'This submission cannot be approved in its current state.' unless @submission.approvable?
+
+ @submission.approve!(user: current_user)
+ redirect_to vacancy_submission_path(@submission), notice: 'Submission approved.'
+ rescue ActiveRecord::RecordInvalid => e
+ redirect_to vacancy_submission_path(@submission), alert: "Could not approve: #{e.message}"
+ rescue StandardError => e
+ Rails.logger.error("VacancySubmission ##{@submission.id} approval failed: #{e.class}: #{e.message}")
+ redirect_to vacancy_submission_path(@submission),
+ alert: 'Could not approve: an unexpected error occurred and no changes were saved.'
+ end
+
+ def return_submission
+ return redirect_to vacancy_submission_path(@submission), alert: 'Note is required when returning a submission.' if params[:body].blank?
+
+ return redirect_to vacancy_submission_path(@submission), alert: 'This submission cannot be returned in its current state.' unless @submission.returnable?
+
+ @submission.return_for_changes!(body: params[:body], user: current_user)
+ VacancySubmissions::Notifier.new(@submission).notify_changes_requested!
+ redirect_to vacancy_submission_path(@submission), notice: 'Submission returned for changes.'
+ rescue ActiveRecord::RecordInvalid => e
+ redirect_to vacancy_submission_path(@submission), alert: "Could not return submission: #{e.message}"
+ end
+
+ def resubmit
+ return redirect_to vacancy_submission_path(@submission), alert: 'This submission cannot be resubmitted in its current state.' unless @submission.resubmittable?
+
+ @submission.resubmit!(user: current_user)
+ VacancySubmissions::Notifier.new(@submission).notify_resubmitted!
+ redirect_to vacancy_submission_path(@submission), notice: 'Submission resubmitted for review.'
+ rescue ActiveRecord::RecordInvalid => e
+ redirect_to vacancy_submission_path(@submission), alert: "Could not resubmit: #{e.message}"
+ end
+
+ private
+
+ def load_submission
+ @submission = VacancySubmission.find(params[:id])
+ end
+
+ def load_resubmittable_submission
+ @submission = VacancySubmission.find(params[:id])
+ redirect_to vacancy_submission_path(@submission), alert: 'This submission cannot be edited in its current state.' unless @submission.resubmittable?
+ end
+
+ def build_draft_data(program:, sub_program:)
+ {
+ 'program_id' => program.id,
+ 'sub_program_id' => sub_program.id,
+ 'route' => VacancySubmission.derive_route(sub_program),
+ 'is_voucher' => VacancySubmission.derive_is_voucher(sub_program),
+ 'units' => normalize_units(submission_params[:units]),
+ 'required_document_names' => Array(submission_params[:required_document_names]),
+ 'notes' => submission_params[:notes].to_s.strip.presence,
+ }
+ end
+
+ def detect_changed_sections(old_data, new_data)
+ sections = []
+ sections << 'Program' if old_data['program_id'] != new_data['program_id']
+ sections << 'Sub-Program' if old_data['sub_program_id'] != new_data['sub_program_id']
+ sections << 'Unit Type' if old_data['is_voucher'] != new_data['is_voucher']
+ sections << 'Vacancy' if old_data['units'].to_json != new_data['units'].to_json
+ sections << 'Required Documents' if old_data['required_document_names'] != new_data['required_document_names']
+ sections << 'Notes' if old_data['notes'] != new_data['notes']
+ sections
+ end
+
+ def require_can_submit_or_review_vacancies!
+ not_authorized! unless can_add_vacancies? || can_review_vacancies?
+ end
+
+ def submission_params
+ params.require(:vacancy_submission).permit(
+ :program_id, :sub_program_id, :notes,
+ units: [
+ :name, :building_id, :unit_number,
+ :date_ready, :age_limit, :bedrooms, :notes,
+ shared_spaces: [],
+ amenities: [],
+ accessibility: [],
+ attributes: [:name, :value],
+ media_links: [:url, :label],
+ requirements_attributes: [:id, :rule_id, :positive, :variable, :_destroy]
+ ],
+ required_document_names: []
+ )
+ end
+
+ def normalize_units(units_params)
+ return [] if units_params.blank?
+
+ units_params.values.map do |unit|
+ h = unit.respond_to?(:to_unsafe_h) ? unit.to_unsafe_h : unit.to_h
+ h['attributes'] = Array(h['attributes']&.values)
+ h['media_links'] = Array(h['media_links']&.values)
+ # requirements_attributes comes from the form submit; requirements (as a
+ # numeric-keyed hash) comes from forward-params on re-render after failure.
+ h['requirements'] = if h.key?('requirements_attributes')
+ normalize_requirements(h.delete('requirements_attributes'))
+ else
+ reqs = h['requirements']
+ reqs.is_a?(Hash) ? reqs.values : Array(reqs)
+ end
+ h
+ end
+ end
+
+ def normalize_requirements(reqs_params)
+ return [] if reqs_params.blank?
+
+ reqs_params.values.reject { |r| r['_destroy'].to_s == '1' }.map do |r|
+ { 'rule_id' => r['rule_id'].to_s, 'positive' => r['positive'].to_s, 'variable' => r['variable'].to_s }
+ end
+ end
+
+ def load_form_data
+ @programs = Program.order(:name).includes(:sub_programs, :match_route)
+ @programs_data = @programs.map do |p|
+ {
+ id: p.id,
+ name: p.name,
+ resource_type: nil,
+ sub_programs: p.sub_programs.open.order(:name).map do |sp|
+ {
+ id: sp.id,
+ name: sp.name.presence || '(unnamed)',
+ is_voucher: VacancySubmission.derive_is_voucher(sp),
+ program_type: sp.program_type,
+ }
+ end,
+ }
+ end
+ end
+end
diff --git a/app/helpers/markdown_helper.rb b/app/helpers/markdown_helper.rb
new file mode 100644
index 000000000..840d470d8
--- /dev/null
+++ b/app/helpers/markdown_helper.rb
@@ -0,0 +1,27 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+module MarkdownHelper
+ # Render a trusted Markdown string to HTML.
+ #
+ # IMPORTANT: the result is marked html_safe, so only ever pass content
+ # authored by trusted staff/admins (e.g. Translation text) here — never
+ # end-user / client-entered content. The renderer is hardened as defense in
+ # depth (escape_html neutralizes raw HTML tags; safe_links_only blocks
+ # javascript:/data: links; no_images drops images so no remote resource
+ # auto-loads as a tracking pixel), but it does NOT sanitize like an allow-list
+ # would: phishing-style https links still pass through. If you ever need to
+ # render untrusted input, run it through Rails' sanitize instead.
+ def render_markdown(text)
+ return ''.html_safe if text.blank?
+
+ renderer = Redcarpet::Render::HTML.new(escape_html: true, safe_links_only: true, no_images: true)
+ markdown = Redcarpet::Markdown.new(renderer, autolink: true)
+ markdown.render(text).html_safe
+ end
+end
diff --git a/app/helpers/vacancy_submissions_helper.rb b/app/helpers/vacancy_submissions_helper.rb
new file mode 100644
index 000000000..7f87c128e
--- /dev/null
+++ b/app/helpers/vacancy_submissions_helper.rb
@@ -0,0 +1,44 @@
+###
+# Copyright 2016 - 2025 Green River Data Analysis, LLC
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+module VacancySubmissionsHelper
+ STATUS_BADGE_CLASSES = {
+ 'awaiting_approval' => 'badge-warning',
+ 'return_changes_requested' => 'badge-danger',
+ 'active' => 'badge-success',
+ }.freeze
+
+ STATUS_DISPLAY = {
+ 'awaiting_approval' => 'Awaiting Approval',
+ 'return_changes_requested' => 'Return / Changes Requested',
+ 'active' => 'Active',
+ }.freeze
+
+ def vacancy_submission_status_badge(submission)
+ label = STATUS_DISPLAY[submission.status] || submission.status.humanize
+ css = STATUS_BADGE_CLASSES[submission.status] || 'badge-secondary'
+ content_tag(:span, label, class: "badge #{css}")
+ end
+
+ # Options for the unit's building select2 dropdown. The visible label is
+ # "Name (street address)" so type-ahead matches on either the name or the
+ # street; each option carries the name and full address as data attributes so
+ # the picked building's details can be shown below the dropdown. Memoized so
+ # rendering many unit rows only queries buildings once per request.
+ def vacancy_building_select_options(selected_id)
+ @_vacancy_building_options ||= Building.order(:name).map do |building|
+ label = building.address.present? ? "#{building.name} (#{building.address})" : building.name
+ [
+ label,
+ building.id,
+ { data: { name: building.name, address: building.building_address.join(', ') } },
+ ]
+ end
+ options_for_select(@_vacancy_building_options, selected_id&.to_i)
+ end
+end
diff --git a/app/javascript/application.js b/app/javascript/application.js
index 6b241d5ef..408c0b989 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -6,9 +6,17 @@
import * as bootstrap from 'bootstrap'
import { Application } from '@hotwired/stimulus'
-import HistoryController from './controllers/history_controller'
import DatepickerController from './controllers/datepicker_controller'
+import HistoryController from './controllers/history_controller'
+import ReturnModalController from './controllers/return_modal_controller'
import RoleManagerController from './controllers/role_manager_controller'
+import VacancySubmissionFormController from './controllers/vacancy_submission_form_controller'
+import RequirementsController from './controllers/requirements_controller'
+import VacancyUnitAttributesController from './controllers/vacancy_unit_attributes_controller'
+import VacancyUnitBuildingController from './controllers/vacancy_unit_building_controller'
+import VacancyUnitMediaLinksController from './controllers/vacancy_unit_media_links_controller'
+import VacancyUnitRequirementsController from './controllers/vacancy_unit_requirements_controller'
+import VacancyUnitsController from './controllers/vacancy_units_controller'
// Expose bootstrap globally for any legacy inline scripts that reference window.bootstrap
window.bootstrap = bootstrap
@@ -43,6 +51,14 @@ document.addEventListener('DOMContentLoaded', () => {
// Start Stimulus
const application = Application.start()
-application.register('history', HistoryController)
application.register('datepicker', DatepickerController)
+application.register('history', HistoryController)
+application.register('return-modal', ReturnModalController)
application.register('role-manager', RoleManagerController)
+application.register('vacancy-submission-form', VacancySubmissionFormController)
+application.register('requirements', RequirementsController)
+application.register('vacancy-unit-attributes', VacancyUnitAttributesController)
+application.register('vacancy-unit-building', VacancyUnitBuildingController)
+application.register('vacancy-unit-media-links', VacancyUnitMediaLinksController)
+application.register('vacancy-unit-requirements', VacancyUnitRequirementsController)
+application.register('vacancy-units', VacancyUnitsController)
diff --git a/app/javascript/controllers/requirement_variable_mixin.js b/app/javascript/controllers/requirement_variable_mixin.js
new file mode 100644
index 000000000..120f00a5b
--- /dev/null
+++ b/app/javascript/controllers/requirement_variable_mixin.js
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2016 - 2025 Green River Data Analysis, LLC
+ *
+ * License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+ */
+
+// Shared variable-input logic for both requirements controllers.
+// Mixed into controller prototypes after class definition.
+export const RequirementVariableMixin = {
+ select2Options(el) {
+ if (!window.$) return {}
+ const modalContent = el.closest('.modal-content')
+ return modalContent ? { dropdownParent: $(modalContent) } : {}
+ },
+
+ wireMultiSelectSync(entry) {
+ const multiDisplay = entry.querySelector('[data-req-multi-display]')
+ if (!multiDisplay) return
+ const hidden = entry.querySelector('[data-req-variable]')
+ if (!hidden) return
+ const sync = () => {
+ hidden.value = Array.from(multiDisplay.selectedOptions)
+ .map((o) => o.value)
+ .join(',')
+ }
+ multiDisplay.addEventListener('change', sync)
+ if (window.$) $(multiDisplay).on('select2:select select2:unselect', sync)
+ },
+
+ updateVariableInput(entry, ruleSelect) {
+ const container = entry.querySelector('[data-req-variable-container]')
+ if (!container) return
+ const selected = ruleSelect.options[ruleSelect.selectedIndex]
+ const isVariable = selected?.dataset.variable === 'true'
+ if (!isVariable) {
+ container.hidden = true
+ container.innerHTML = ''
+ return
+ }
+ container.hidden = false
+ const type = selected?.dataset.variableType || 'text'
+ const options = JSON.parse(selected?.dataset.variableOptions || '[]')
+ const label = selected?.dataset.variableLabel || ''
+ const varName = container.dataset.variableName
+ const currentValue = container.querySelector('[data-req-variable]')?.value || ''
+ container.innerHTML = ''
+ if (label) {
+ const labelEl = document.createElement('label')
+ labelEl.className = 'form-label mb-1'
+ labelEl.textContent = label
+ container.appendChild(labelEl)
+ }
+ if (type === 'select') {
+ this.buildSelectInput(container, varName, options, currentValue, false)
+ } else if (type === 'multi-select') {
+ this.buildSelectInput(container, varName, options, currentValue.split(',').filter(Boolean), true)
+ } else if (type === 'number') {
+ const input = document.createElement('input')
+ input.type = 'number'
+ input.className = 'form-control'
+ input.placeholder = 'Value'
+ input.name = `${varName}[variable]`
+ input.required = true
+ input.dataset.reqVariable = ''
+ input.value = currentValue
+ container.appendChild(input)
+ } else {
+ const input = document.createElement('input')
+ input.type = 'text'
+ input.className = 'form-control'
+ input.placeholder = 'Value'
+ input.name = `${varName}[variable]`
+ input.required = true
+ input.dataset.reqVariable = ''
+ input.value = currentValue
+ container.appendChild(input)
+ }
+ },
+
+ buildSelectInput(container, varName, options, currentValue, multiple) {
+ if (multiple) {
+ const hidden = document.createElement('input')
+ hidden.type = 'hidden'
+ hidden.name = `${varName}[variable]`
+ hidden.dataset.reqVariable = ''
+ hidden.value = Array.isArray(currentValue) ? currentValue.join(',') : currentValue
+
+ const select = document.createElement('select')
+ select.className = 'form-select select2'
+ select.multiple = true
+ select.required = true
+ select.dataset.reqMultiDisplay = ''
+ options.forEach(([val, label]) => {
+ const opt = new Option(label, val)
+ opt.selected = currentValue.includes(val.toString())
+ select.appendChild(opt)
+ })
+ const sync = () => {
+ hidden.value = Array.from(select.selectedOptions)
+ .map((o) => o.value)
+ .join(',')
+ }
+ select.addEventListener('change', sync)
+ if (window.$) $(select).on('select2:select select2:unselect', sync)
+ container.appendChild(select)
+ container.appendChild(hidden)
+ if (window.App?.Form?.Select2Input) new window.App.Form.Select2Input(select, this.select2Options(container))
+ } else {
+ const select = document.createElement('select')
+ select.className = 'form-select select2'
+ select.name = `${varName}[variable]`
+ select.required = true
+ select.dataset.reqVariable = ''
+ select.appendChild(new Option('Select...', ''))
+ options.forEach(([val, label]) => {
+ const opt = new Option(label, val)
+ opt.selected = val.toString() === currentValue.toString()
+ select.appendChild(opt)
+ })
+ container.appendChild(select)
+ if (window.App?.Form?.Select2Input) new window.App.Form.Select2Input(select, this.select2Options(container))
+ }
+ },
+}
diff --git a/app/javascript/controllers/requirements_controller.js b/app/javascript/controllers/requirements_controller.js
new file mode 100644
index 000000000..f418dbb39
--- /dev/null
+++ b/app/javascript/controllers/requirements_controller.js
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2016 - 2025 Green River Data Analysis, LLC
+ *
+ * License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+ */
+
+import { Controller } from '@hotwired/stimulus'
+import { RequirementVariableMixin } from './requirement_variable_mixin'
+
+class RequirementsController extends Controller {
+ static targets = ['list']
+
+ connect() {
+ this.nextIndex = this.listTarget.querySelectorAll('[data-req-entry]').length
+ this.listTarget.querySelectorAll('[data-req-entry]').forEach((entry) => {
+ this.initEntry(entry, false)
+ })
+ }
+
+ addReq() {
+ const template = this.element.querySelector('template[data-req-template]')
+ const html = template.innerHTML.replaceAll('__REQ__', this.nextIndex)
+ this.listTarget.insertAdjacentHTML('beforeend', html)
+ const entries = this.listTarget.querySelectorAll('[data-req-entry]')
+ const newEntry = entries[entries.length - 1]
+ this.initEntry(newEntry, true)
+ this.nextIndex++
+ }
+
+ removeReq(event) {
+ const entry = event.currentTarget.closest('[data-req-entry]')
+ const destroyInput = entry.querySelector('[data-req-destroy]')
+ if (destroyInput) {
+ destroyInput.value = '1'
+ entry.hidden = true
+ } else {
+ entry.remove()
+ }
+ }
+
+ initEntry(entry, isNew) {
+ if (window.App?.Form?.Select2Input) {
+ entry.querySelectorAll('select.select2').forEach((el) => {
+ new window.App.Form.Select2Input(el, this.select2Options(el))
+ })
+ }
+ this.wireMultiSelectSync(entry)
+ const ruleSelect = entry.querySelector('[data-req-rule-select]')
+ if (!ruleSelect) return
+ const update = () => this.updateVariableInput(entry, ruleSelect)
+ if (window.$) {
+ $(ruleSelect).off('change.requirements').on('change.requirements', update)
+ } else {
+ ruleSelect.addEventListener('change', update)
+ }
+ if (isNew) update()
+ }
+}
+
+Object.assign(RequirementsController.prototype, RequirementVariableMixin)
+export default RequirementsController
diff --git a/app/javascript/controllers/return_modal_controller.js b/app/javascript/controllers/return_modal_controller.js
new file mode 100644
index 000000000..bb96f7c6e
--- /dev/null
+++ b/app/javascript/controllers/return_modal_controller.js
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2016 - 2025 Green River Data Analysis, LLC
+ *
+ * License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+ */
+
+import { Controller } from '@hotwired/stimulus'
+
+export default class extends Controller {
+ static targets = ['textarea', 'submitButton']
+
+ connect() {
+ this.toggle()
+ }
+
+ onInput() {
+ this.toggle()
+ }
+
+ toggle() {
+ this.submitButtonTarget.disabled = this.textareaTarget.value.trim() === ''
+ }
+}
diff --git a/app/javascript/controllers/vacancy_submission_form_controller.js b/app/javascript/controllers/vacancy_submission_form_controller.js
new file mode 100644
index 000000000..537280e26
--- /dev/null
+++ b/app/javascript/controllers/vacancy_submission_form_controller.js
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2016 - 2025 Green River Data Analysis, LLC
+ *
+ * License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+ */
+
+import { Controller } from '@hotwired/stimulus'
+
+export default class extends Controller {
+ static targets = [
+ 'programSelect',
+ 'subProgramSelect',
+ 'routeContainer',
+ 'requirementsContainer',
+ 'documentsContainer',
+ 'vacancyContainer',
+ ]
+
+ static values = {
+ programs: Array,
+ initialProgram: Number,
+ initialSubProgram: Number,
+ vacancySubmissionId: Number,
+ }
+
+ connect() {
+ this.isInitializing = true
+ this.programMap = {}
+ this.programsValue.forEach((p) => {
+ this.programMap[p.id] = p
+ })
+
+ if (typeof $ !== 'undefined') {
+ $(this.programSelectTarget).on('change.vacancy-form', () => {
+ this.onProgramChange()
+ })
+ $(this.subProgramSelectTarget).on('change.vacancy-form', () => {
+ this.onSubProgramChange()
+ })
+ } else {
+ this.programSelectTarget.addEventListener('change', () => this.onProgramChange())
+ this.subProgramSelectTarget.addEventListener('change', () => this.onSubProgramChange())
+ }
+
+ if (this.initialProgramValue) {
+ this.populateSubPrograms(this.initialProgramValue, this.initialSubProgramValue)
+ }
+ this.isInitializing = false
+ if (this.initialSubProgramValue) {
+ this.refreshAll()
+ }
+ }
+
+ disconnect() {
+ if (typeof $ !== 'undefined') {
+ $(this.programSelectTarget).off('change.vacancy-form')
+ $(this.subProgramSelectTarget).off('change.vacancy-form')
+ }
+ }
+
+ onProgramChange() {
+ const value = parseInt(this.programSelectTarget.value, 10)
+ this.populateSubPrograms(value, null)
+ }
+
+ onSubProgramChange() {
+ this.refreshAll()
+ }
+
+ populateSubPrograms(programId, selectedSubProgramId) {
+ const sub = this.subProgramSelectTarget
+ sub.innerHTML = ''
+
+ const program = this.programMap[programId]
+ if (!programId || !program) {
+ sub.disabled = true
+ this.refreshAll()
+ return
+ }
+
+ program.sub_programs.forEach((sp) => {
+ const selected = selectedSubProgramId && String(sp.id) === String(selectedSubProgramId)
+ const option = new Option(sp.name, sp.id, false, selected)
+ option.dataset.isVoucher = sp.is_voucher
+ option.dataset.programType = sp.program_type || ''
+ sub.append(option)
+ })
+
+ sub.disabled = false
+ if (typeof $ !== 'undefined') {
+ $(sub).trigger('change')
+ }
+ this.refreshAll()
+ }
+
+ static sectionDefs = [
+ { target: 'routeContainer', section: 'route', emptyHtml: '' },
+ { target: 'requirementsContainer', section: 'requirements', emptyHtml: '
Select a sub-program to see inherited requirements.
' },
+ { target: 'documentsContainer', section: 'required_documents', emptyHtml: '' },
+ { target: 'vacancyContainer', section: 'vacancy', emptyHtml: '
Select a sub-program to see unit type information.
' },
+ ]
+
+ refreshAll() {
+ this.constructor.sectionDefs.forEach(def => this.refreshSection(def))
+ }
+
+ async refreshSection({ target, section, emptyHtml }) {
+ if (this.isInitializing) return
+
+ const hasProp = `has${target[0].toUpperCase()}${target.slice(1)}Target`
+ if (!this[hasProp]) return
+
+ const containerProp = `${target}Target`
+ const subProgramId = this.subProgramSelectTarget.value
+ if (!subProgramId) {
+ this[containerProp].innerHTML = emptyHtml
+ return
+ }
+
+ let url = `/vacancy_submissions/sub_program_section?sub_program_id=${subProgramId}§ion=${section}`
+ if (this.vacancySubmissionIdValue) url += `&vacancy_submission_id=${this.vacancySubmissionIdValue}`
+
+ const rawForward = this[containerProp].dataset.forwardParams
+ if (rawForward) {
+ const encoded = this.encodeParams(JSON.parse(rawForward))
+ if (encoded) url += `&${encoded}`
+ delete this[containerProp].dataset.forwardParams
+ }
+
+ const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
+ this[containerProp].innerHTML = await response.text()
+ this.applyErrors(this[containerProp])
+ }
+
+ encodeParams(obj, prefix = '') {
+ return Object.entries(obj).flatMap(([key, value]) => {
+ const fullKey = prefix ? `${prefix}[${key}]` : key
+ if (Array.isArray(value)) {
+ if (value.length === 0) return []
+ if (typeof value[0] === 'object' && value[0] !== null) {
+ return value.flatMap((item, i) => this.encodeParams(item, `${fullKey}[${i}]`))
+ }
+ return value.map(v => `${encodeURIComponent(fullKey)}[]=${encodeURIComponent(v)}`)
+ }
+ if (typeof value === 'object' && value !== null) {
+ return this.encodeParams(value, fullKey)
+ }
+ if (value === null || value === undefined || value === '') return []
+ return [`${encodeURIComponent(fullKey)}=${encodeURIComponent(value)}`]
+ }).join('&')
+ }
+
+ applyErrors(container) {
+ const raw = container.dataset.fieldErrors
+ if (!raw) return
+ let errors
+ try { errors = JSON.parse(raw) } catch { return }
+ delete container.dataset.fieldErrors
+ if (!errors.length) return
+
+ container.querySelectorAll('input[required], select[required]').forEach((el) => {
+ if (el.value.trim() !== '') return
+ el.classList.add('is-invalid')
+ const select2Container = el.nextElementSibling?.classList.contains('select2-container')
+ ? el.nextElementSibling
+ : null
+ if (select2Container) select2Container.classList.add('is-invalid')
+ const feedback = document.createElement('div')
+ feedback.className = 'invalid-feedback'
+ feedback.textContent = 'This field is required.'
+ ;(select2Container || el).after(feedback)
+ })
+ }
+}
diff --git a/app/javascript/controllers/vacancy_unit_attributes_controller.js b/app/javascript/controllers/vacancy_unit_attributes_controller.js
new file mode 100644
index 000000000..0392b5695
--- /dev/null
+++ b/app/javascript/controllers/vacancy_unit_attributes_controller.js
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2016 - 2025 Green River Data Analysis, LLC
+ *
+ * License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+ */
+
+import { Controller } from '@hotwired/stimulus'
+
+export default class extends Controller {
+ static targets = ['list']
+
+ connect() {
+ this.nextIndex = this.listTarget.querySelectorAll('[data-attr-entry]').length
+ this.attachNameHandlers(this.listTarget)
+ }
+
+ addAttribute() {
+ const template = this.element.querySelector('template[data-attr-template]')
+ const unitIndex = this.element.dataset.unitIndex
+ const attrIndex = this.nextIndex
+ const html = template.innerHTML
+ .replaceAll('__UNIT_INDEX__', unitIndex)
+ .replaceAll('__ATTR_INDEX__', attrIndex)
+ this.listTarget.insertAdjacentHTML('beforeend', html)
+ const entries = this.listTarget.querySelectorAll('[data-attr-entry]')
+ this.initSelect2(entries[entries.length - 1])
+ this.nextIndex++
+ }
+
+ removeAttribute(event) {
+ event.currentTarget.closest('[data-attr-entry]').remove()
+ }
+
+ cascadeValues(nameSelect) {
+ const entry = nameSelect.closest('[data-attr-entry]')
+ const valueSelect = entry.querySelector('[data-attr-value-select]')
+ if (!valueSelect) return
+
+ const catalog = JSON.parse(this.element.dataset.attributeCatalog || '{}')
+ const values = catalog[nameSelect.value] || []
+
+ if (window.$) $(valueSelect).select2('destroy')
+
+ const fragment = document.createDocumentFragment()
+ const blank = document.createElement('option')
+ blank.value = ''
+ blank.textContent = 'Select value...'
+ fragment.appendChild(blank)
+ values.forEach((v) => {
+ const opt = document.createElement('option')
+ opt.value = v
+ opt.textContent = v
+ fragment.appendChild(opt)
+ })
+ valueSelect.replaceChildren(fragment)
+
+ if (window.App?.Form?.Select2Input) new window.App.Form.Select2Input(valueSelect)
+ }
+
+ attachNameHandlers(container) {
+ if (!window.$) return
+ $(container).find('[data-attr-name-select]')
+ .off('change.vacancyUnitAttr')
+ .on('change.vacancyUnitAttr', (e) => this.cascadeValues(e.currentTarget))
+ }
+
+ initSelect2(container) {
+ if (!window.App?.Form?.Select2Input) return
+ container.querySelectorAll('select.select2').forEach((el) => {
+ new window.App.Form.Select2Input(el)
+ })
+ this.attachNameHandlers(container)
+ }
+}
diff --git a/app/javascript/controllers/vacancy_unit_building_controller.js b/app/javascript/controllers/vacancy_unit_building_controller.js
new file mode 100644
index 000000000..a1a40f084
--- /dev/null
+++ b/app/javascript/controllers/vacancy_unit_building_controller.js
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2016 - 2025 Green River Data Analysis, LLC
+ *
+ * License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+ */
+
+import { Controller } from '@hotwired/stimulus'
+
+// Shows the selected building's name and full address below the building
+// dropdown. select2 fires a native `change` on the underlying select, so the
+// change action wires straight through.
+export default class extends Controller {
+ static targets = ['select', 'display']
+
+ connect() {
+ this.render()
+ }
+
+ render() {
+ const option = this.selectTarget.selectedOptions[0]
+ if (!option || !option.value) {
+ this.displayTarget.hidden = true
+ this.displayTarget.replaceChildren()
+ return
+ }
+
+ const name = option.dataset.name || option.textContent
+ const address = option.dataset.address || ''
+
+ this.displayTarget.replaceChildren()
+ const nameEl = document.createElement('div')
+ nameEl.className = 'fw-semibold'
+ nameEl.textContent = name
+ this.displayTarget.appendChild(nameEl)
+
+ if (address) {
+ const addressEl = document.createElement('div')
+ addressEl.className = 'text-muted'
+ addressEl.textContent = address
+ this.displayTarget.appendChild(addressEl)
+ }
+
+ this.displayTarget.hidden = false
+ }
+}
diff --git a/app/javascript/controllers/vacancy_unit_media_links_controller.js b/app/javascript/controllers/vacancy_unit_media_links_controller.js
new file mode 100644
index 000000000..769eeb760
--- /dev/null
+++ b/app/javascript/controllers/vacancy_unit_media_links_controller.js
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2016 - 2025 Green River Data Analysis, LLC
+ *
+ * License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+ */
+
+import { Controller } from '@hotwired/stimulus'
+
+export default class extends Controller {
+ static targets = ['list']
+
+ connect() {
+ this.nextIndex = this.listTarget.querySelectorAll('[data-media-link-entry]').length
+ }
+
+ addLink() {
+ const template = this.element.querySelector('template[data-media-link-template]')
+ const unitIndex = this.element.dataset.unitIndex
+ const linkIndex = this.nextIndex
+ const html = template.innerHTML
+ .replaceAll('__UNIT_INDEX__', unitIndex)
+ .replaceAll('__LINK_INDEX__', linkIndex)
+ this.listTarget.insertAdjacentHTML('beforeend', html)
+ this.nextIndex++
+ }
+
+ removeLink(event) {
+ event.currentTarget.closest('[data-media-link-entry]').remove()
+ }
+}
diff --git a/app/javascript/controllers/vacancy_unit_requirements_controller.js b/app/javascript/controllers/vacancy_unit_requirements_controller.js
new file mode 100644
index 000000000..02b823d3c
--- /dev/null
+++ b/app/javascript/controllers/vacancy_unit_requirements_controller.js
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2016 - 2025 Green River Data Analysis, LLC
+ *
+ * License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+ */
+
+import { Controller } from '@hotwired/stimulus'
+import { RequirementVariableMixin } from './requirement_variable_mixin'
+
+class VacancyUnitRequirementsController extends Controller {
+ static targets = ['list']
+
+ connect() {
+ this.nextIndex = this.listTarget.querySelectorAll('[data-req-entry]').length
+ // Select2 on existing rows is initialized by the parent vacancy-units controller.
+ // We only wire change handlers and multi-select sync here.
+ this.listTarget.querySelectorAll('[data-req-entry]').forEach((entry) => {
+ this.initEntry(entry, false)
+ })
+ }
+
+ addReq() {
+ const template = this.element.querySelector('template[data-req-template]')
+ const html = template.innerHTML.replaceAll('__REQ__', this.nextIndex)
+ this.listTarget.insertAdjacentHTML('beforeend', html)
+ const entries = this.listTarget.querySelectorAll('[data-req-entry]')
+ const newEntry = entries[entries.length - 1]
+ this.initEntry(newEntry, true)
+ this.nextIndex++
+ }
+
+ removeReq(event) {
+ event.currentTarget.closest('[data-req-entry]').remove()
+ }
+
+ initEntry(entry, isNew) {
+ // Only initialize Select2 for dynamically added rows; parent handles existing ones.
+ if (isNew && window.App?.Form?.Select2Input) {
+ entry.querySelectorAll('select.select2').forEach((el) => {
+ new window.App.Form.Select2Input(el, this.select2Options(el))
+ })
+ }
+ this.wireMultiSelectSync(entry)
+ const ruleSelect = entry.querySelector('[data-req-rule-select]')
+ if (!ruleSelect) return
+ const update = () => this.updateVariableInput(entry, ruleSelect)
+ if (window.$) {
+ $(ruleSelect).off('change.vacancyReqs').on('change.vacancyReqs', update)
+ } else {
+ ruleSelect.addEventListener('change', update)
+ }
+ if (isNew) update()
+ }
+}
+
+Object.assign(VacancyUnitRequirementsController.prototype, RequirementVariableMixin)
+export default VacancyUnitRequirementsController
diff --git a/app/javascript/controllers/vacancy_units_controller.js b/app/javascript/controllers/vacancy_units_controller.js
new file mode 100644
index 000000000..3549a8002
--- /dev/null
+++ b/app/javascript/controllers/vacancy_units_controller.js
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2016 - 2025 Green River Data Analysis, LLC
+ *
+ * License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+ */
+
+import { Controller } from '@hotwired/stimulus'
+
+export default class extends Controller {
+ static targets = ['list']
+ static values = { isVoucher: Boolean }
+
+ connect() {
+ this.nextIndex = this.listTarget.querySelectorAll('[data-unit-entry]').length
+ this.updateRemoveButtons()
+ this.initSelect2(this.element)
+ }
+
+ addUnit() {
+ const template = this.element.querySelector('template[data-unit-template]')
+ const number = this.nextIndex + 1
+ const html = template.innerHTML
+ .replaceAll('__INDEX__', this.nextIndex)
+ .replaceAll('__NUMBER__', number)
+ this.listTarget.insertAdjacentHTML('beforeend', html)
+ const entries = this.listTarget.querySelectorAll('[data-unit-entry]')
+ const newEntry = entries[entries.length - 1]
+ this.initSelect2(newEntry)
+ this.nextIndex++
+ this.renumber()
+ this.updateRemoveButtons()
+ }
+
+ initSelect2(container) {
+ if (!window.App?.Form?.Select2Input) return
+ container.querySelectorAll('select.select2').forEach((el) => {
+ new window.App.Form.Select2Input(el)
+ })
+ }
+
+ removeUnit(event) {
+ event.currentTarget.closest('[data-unit-entry]').remove()
+ this.renumber()
+ this.updateRemoveButtons()
+ }
+
+ renumber() {
+ const entries = this.listTarget.querySelectorAll('[data-unit-entry]')
+ if (this.isVoucherValue) {
+ entries.forEach((entry, index) => {
+ const label = entry.querySelector('[data-voucher-label]')
+ const input = entry.querySelector('[data-voucher-name]')
+ const number = index + 1
+ if (label) label.textContent = `Voucher ${number}`
+ if (input) input.value = `Voucher ${number}`
+ })
+ } else {
+ entries.forEach((entry, index) => {
+ const title = entry.querySelector('[data-unit-card-title]')
+ if (title) title.textContent = `Unit ${index + 1}`
+ })
+ }
+ }
+
+ updateRemoveButtons() {
+ const entries = this.listTarget.querySelectorAll('[data-unit-entry]')
+ entries.forEach((entry, index) => {
+ const btn = entry.querySelector('[data-action="vacancy-units#removeUnit"]')
+ if (btn) btn.hidden = index === 0
+ })
+ }
+}
diff --git a/app/mailers/vacancy_submissions_mailer.rb b/app/mailers/vacancy_submissions_mailer.rb
new file mode 100644
index 000000000..575e87df6
--- /dev/null
+++ b/app/mailers/vacancy_submissions_mailer.rb
@@ -0,0 +1,78 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+# Notifies staff about vacancy-submission workflow transitions. Delivered
+# through the CAS Message/Contact pipeline (see DatabaseMailer). Email bodies
+# are authored in Markdown from translatable fragments (Translation.translate)
+# and rendered to HTML with Redcarpet via MarkdownHelper for the HTML part.
+class VacancySubmissionsMailer < DatabaseMailer
+ helper MarkdownHelper
+
+ def submitted_for_review(vacancy_submission, reviewer)
+ setup(vacancy_submission, reviewer)
+ @body = reviewer_body('A new vacancy submission is awaiting your review.')
+ mail to: reviewer.email, subject: Translation.translate('Vacancy submission awaiting review')
+ end
+
+ def resubmitted_for_review(vacancy_submission, reviewer)
+ setup(vacancy_submission, reviewer)
+ @body = reviewer_body('A vacancy submission has been resubmitted for your review.')
+ mail to: reviewer.email, subject: Translation.translate('Vacancy submission resubmitted for review')
+ end
+
+ def changes_requested(vacancy_submission, submitter)
+ setup(vacancy_submission, submitter)
+ @body = changes_requested_body
+ mail to: submitter.email, subject: Translation.translate('Changes requested on your vacancy submission')
+ end
+
+ private
+
+ def setup(vacancy_submission, recipient)
+ @submission = vacancy_submission
+ @recipient = recipient
+ @url = vacancy_submission_url(@submission, host: ENV['FQDN'])
+ end
+
+ def reviewer_body(intro_key)
+ [
+ Translation.translate(intro_key),
+ '',
+ "- **#{Translation.translate('Program')}:** #{program_name}",
+ "- **#{Translation.translate('Submitted by')}:** #{submitter_name}",
+ '',
+ "[#{Translation.translate('View submission')}](#{@url})",
+ ].join("\n")
+ end
+
+ # Intentionally omits the reviewer's free-text note: it may contain
+ # human-entered PII, so we link back to the app rather than emailing it.
+ def changes_requested_body
+ [
+ Translation.translate('Changes have been requested on your vacancy submission before it can be approved.'),
+ '',
+ "- **#{Translation.translate('Program')}:** #{program_name}",
+ "- **#{Translation.translate('Reviewer')}:** #{reviewer_name}",
+ '',
+ "[#{Translation.translate('View submission')}](#{@url})",
+ ].join("\n")
+ end
+
+ def program_name
+ Program.find_by(id: @submission.program_id)&.name || '—'
+ end
+
+ def submitter_name
+ @submission.user&.name.presence || '—'
+ end
+
+ def reviewer_name
+ @submission.vacancy_submission_notes.
+ select { |n| n.note_type == 'reviewer_note' }.last&.user&.name.presence || 'the CAS Admin'
+ end
+end
diff --git a/app/models/concerns/has_requirements.rb b/app/models/concerns/has_requirements.rb
index 19809011e..e97698161 100644
--- a/app/models/concerns/has_requirements.rb
+++ b/app/models/concerns/has_requirements.rb
@@ -4,6 +4,8 @@
# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
###
+# frozen_string_literal: true
+
# The unwitting consequence of decentralizing the network of requirements was
# that I had to put code in place to avoid infinitely looping (in two
# different ways). There are a few different ways this code can be improved.
@@ -14,12 +16,12 @@ module HasRequirements
included do
has_many :requirements, as: :requirer
- accepts_nested_attributes_for :requirements, allow_destroy: true
+ accepts_nested_attributes_for :requirements, allow_destroy: true, reject_if: ->(attrs) { attrs['rule_id'].blank? }
end
def available_rules
Rule.where.not(id: requirements.select(:rule_id)).order(:name)
end
- class ClassMethodLoopException < StandardError; end;
+ class ClassMethodLoopException < StandardError; end
end
diff --git a/app/models/concerns/inherits_requirements_from_services.rb b/app/models/concerns/inherits_requirements_from_services.rb
index 67e3e1a79..5f2a2ce04 100644
--- a/app/models/concerns/inherits_requirements_from_services.rb
+++ b/app/models/concerns/inherits_requirements_from_services.rb
@@ -4,7 +4,9 @@
# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
###
-# Most things with inherited rules only inherit
+# frozen_string_literal: true
+
+# Most things with inherited requirements only inherit
# from services, so the common code is here
module InheritsRequirementsFromServices
extend ActiveSupport::Concern
@@ -22,8 +24,7 @@ def inherited_service_requirements_by_source
module ClassMethods
def preload_inherited_service_requirements
- preload services: {requirements: :rules}
+ preload services: { requirements: :rules }
end
end
-
end
diff --git a/app/models/concerns/inherits_requirements_from_services_only.rb b/app/models/concerns/inherits_requirements_from_services_only.rb
index 3c8108077..2753344f2 100644
--- a/app/models/concerns/inherits_requirements_from_services_only.rb
+++ b/app/models/concerns/inherits_requirements_from_services_only.rb
@@ -4,7 +4,9 @@
# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
###
-# Most things with inherited rules only inherit
+# frozen_string_literal: true
+
+# Most things with inherited requirements only inherit
# from services, so the common code is here
module InheritsRequirementsFromServicesOnly
extend ActiveSupport::Concern
@@ -19,5 +21,4 @@ def preload_inherited_requirements
preload_inherited_service_requirements
end
end
-
end
diff --git a/app/models/config.rb b/app/models/config.rb
index ebb5dc3b1..823ed0e6d 100644
--- a/app/models/config.rb
+++ b/app/models/config.rb
@@ -64,4 +64,15 @@ def include_note_in_email_options
'Allow match notes in email notifications (default to yes)' => true,
}.freeze
end
+
+ def vacancy_submission_mechanism_options
+ {
+ 'Traditional' => 'traditional',
+ 'Vacancy Submissions' => 'vacancy_submission',
+ }.freeze
+ end
+
+ def self.active_vacancy_submission_mechanism_traditional?
+ get(:vacancy_submission_mechanism)&.downcase == 'traditional'
+ end
end
diff --git a/app/models/housing_attribute.rb b/app/models/housing_attribute.rb
index 7d2ae82be..5f6cbd3c5 100644
--- a/app/models/housing_attribute.rb
+++ b/app/models/housing_attribute.rb
@@ -4,23 +4,74 @@
# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
###
+# frozen_string_literal: true
+
class HousingAttribute < ApplicationRecord
acts_as_paranoid
has_paper_trail
belongs_to :housingable, polymorphic: true
+ scope :with_value, -> { where(include_value: true) }
+ scope :without_value, -> { where(include_value: false) }
+
validates_presence_of :name
- validates_presence_of :value
+ validates :value, presence: true, if: :include_value?
+
+ def self.existing_attributes
+ with_value.pluck(:name).uniq.sort
+ end
+
+ def self.existing_amenities
+ without_value.pluck(:name).uniq.sort
+ end
+
+ def self.attribute_catalog
+ with_value.pluck(:name, :value).each_with_object(Hash.new { |h, k| h[k] = [] }) do |(n, v), h|
+ h[n] << v
+ end.tap { |h| h.each_value { |vals| vals.sort!.uniq! } }
+ end
+
+ def self.name_summary
+ counts = group(:name, :include_value).count
+ representative_ids = group(:name, :include_value).minimum(:id)
+
+ counts.map do |(name, include_value), count|
+ OpenStruct.new(
+ name: name,
+ type: include_value ? 'Attribute' : 'Amenity',
+ count: count,
+ id: representative_ids[[name, include_value]],
+ )
+ end.sort_by { |n| [n.name.to_s.downcase, n.type] }
+ end
+
+ def self.value_summary(name)
+ where(name: name).with_value.group(:value).count.
+ sort_by { |value, _| value.to_s }.
+ map { |value, count| OpenStruct.new(value: value, count: count) }
+ end
+
+ def self.rename(old_name:, new_name:, include_value:)
+ return if new_name.blank? || new_name == old_name
+
+ transaction do
+ where(name: old_name, include_value: include_value).find_each { |ha| ha.update!(name: new_name) }
+ end
+ end
+
+ def self.rename_value(name:, old_value:, new_value:)
+ return if old_value.blank? || new_value.blank? || old_value == new_value
- def existing_attributes
- HousingAttribute.pluck(:name).uniq.sort
+ transaction do
+ where(name: name, value: old_value).with_value.find_each { |ha| ha.update!(value: new_value) }
+ end
end
def existing_values(for_attribute: nil)
for_attribute ||= name
return [] if for_attribute.blank?
- HousingAttribute.where(name: for_attribute).pluck(:value).uniq.sort
+ self.class.with_value.where(name: for_attribute).pluck(:value).uniq.sort
end
end
diff --git a/app/models/menu/menu.rb b/app/models/menu/menu.rb
index fdd930cc9..a98f2c4c5 100644
--- a/app/models/menu/menu.rb
+++ b/app/models/menu/menu.rb
@@ -168,6 +168,14 @@ def site_menu
visible: ->(_user) { h.can_view_opportunities? },
),
)
+ menu.add_child(
+ Menu::Item.new(
+ user: user,
+ path: vacancy_submissions_path,
+ title: Translation.translate('Vacancy Submissions'),
+ visible: ->(_user) { ! Config.active_vacancy_submission_mechanism_traditional? && h.can_view_opportunities? },
+ ),
+ )
menu.add_child(
Menu::Item.new(
user: user,
diff --git a/app/models/requirement.rb b/app/models/requirement.rb
index 1062e7983..4ed5fb9b1 100644
--- a/app/models/requirement.rb
+++ b/app/models/requirement.rb
@@ -4,6 +4,8 @@
# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
###
+# frozen_string_literal: true
+
class Requirement < ApplicationRecord
include MatchArchive
belongs_to :rule
@@ -12,7 +14,7 @@ class Requirement < ApplicationRecord
acts_as_paranoid
has_paper_trail
- delegate :variable_requirement?, to: :rule
+ delegate :variable_requirement?, to: :rule, allow_nil: true
validates_presence_of :variable, if: :variable_requirement?
def clients_that_fit(scope, opportunity = nil)
@@ -33,13 +35,33 @@ def compatible_with(requirements)
delegate :alternate_name, to: :rule, allow_nil: true, prefix: true
def name(on_unit: false)
+ "#{positive? ? 'Must' : "Can't"} #{rule_text(on_unit: on_unit)}"
+ end
+
+ def rule_text(on_unit: false)
if on_unit && rule_alternate_name.present?
- "#{positive? ? 'Must' : "Can't"} #{Translation.translate(rule_alternate_name)}"
+ Translation.translate(rule_alternate_name)
else
- "#{positive? ? 'Must' : "Can't"} #{rule&.verb} #{Translation.translate(rule_name)}"
+ "#{rule&.verb} #{Translation.translate(rule_name)}"
end
end
+ def self.modal_verb_for(positive)
+ positive ? 'Must' : "Can't"
+ end
+
+ def self.modal_verb_class_for(positive)
+ positive ? 'badge-primary' : 'badge-warning'
+ end
+
+ def modal_verb
+ self.class.modal_verb_for(positive?)
+ end
+
+ def modal_verb_class
+ self.class.modal_verb_class_for(positive?)
+ end
+
def display_for_variable
"(#{rule&.display_for_variable(variable)})" if variable.present?
end
diff --git a/app/models/role.rb b/app/models/role.rb
index 0327140d2..ea24145e6 100644
--- a/app/models/role.rb
+++ b/app/models/role.rb
@@ -129,6 +129,7 @@ def self.permissions_with_descriptions
# Housing Inventory — Opportunities
can_view_opportunities: { description: 'View housing opportunities', category: 'Housing Inventory', sub_category: 'Opportunities', administrative: false },
can_edit_opportunities: { description: 'Create and edit housing opportunities', category: 'Housing Inventory', sub_category: 'Opportunities', administrative: false },
+ can_review_vacancies: { description: 'Review and approve vacancy submissions', category: 'Housing Inventory', sub_category: 'Opportunities', administrative: false },
# Housing Inventory — Funding & Subgrantees
can_view_funding_sources: { description: 'View funding source records', category: 'Housing Inventory', sub_category: 'Funding & Subgrantees', administrative: false },
@@ -217,6 +218,7 @@ def self.permissions
:can_view_units,
:can_edit_units,
:can_add_vacancies,
+ :can_review_vacancies,
:can_view_contacts,
:can_edit_contacts,
:can_view_rule_list,
diff --git a/app/models/rule.rb b/app/models/rule.rb
index f7ea1bf18..a2ae646f5 100644
--- a/app/models/rule.rb
+++ b/app/models/rule.rb
@@ -47,10 +47,26 @@ def selection_note(context: nil) # rubocop:disable Lint/UnusedMethodArgument
nil
end
+ def self.variable_rule_classes
+ descendants.select { |c| c.new.variable_requirement? }
+ end
+
def variable_requirement?
false
end
+ def variable_input_type
+ nil
+ end
+
+ def variable_label
+ nil
+ end
+
+ def variable_options
+ []
+ end
+
def display_for_variable(_value)
nil
end
diff --git a/app/models/rules/active_in_cohort.rb b/app/models/rules/active_in_cohort.rb
index d881a137a..4e37463f3 100644
--- a/app/models/rules/active_in_cohort.rb
+++ b/app/models/rules/active_in_cohort.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'multi-select'
+ end
+
+ def variable_label
+ 'Cohort'
+ end
+
+ def variable_options
+ available_cohorts
+ end
+
def available_cohorts
return [] unless Warehouse::Base.enabled?
diff --git a/app/models/rules/age_greater_than_x.rb b/app/models/rules/age_greater_than_x.rb
index bdcb4a11d..9b58888b5 100644
--- a/app/models/rules/age_greater_than_x.rb
+++ b/app/models/rules/age_greater_than_x.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'number'
+ end
+
+ def variable_label
+ 'Age'
+ end
+
+ def variable_options
+ []
+ end
+
def display_for_variable(value)
value.to_i
end
diff --git a/app/models/rules/age_greater_than_y.rb b/app/models/rules/age_greater_than_y.rb
index 19aad1ce7..04c7fd6af 100644
--- a/app/models/rules/age_greater_than_y.rb
+++ b/app/models/rules/age_greater_than_y.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'number'
+ end
+
+ def variable_label
+ 'Age'
+ end
+
+ def variable_options
+ []
+ end
+
def display_for_variable(value)
value.to_i
end
diff --git a/app/models/rules/assessment_completed_within.rb b/app/models/rules/assessment_completed_within.rb
index 959969b3a..6d9b652e1 100644
--- a/app/models/rules/assessment_completed_within.rb
+++ b/app/models/rules/assessment_completed_within.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'select'
+ end
+
+ def variable_label
+ 'Assessment'
+ end
+
+ def variable_options
+ available_options
+ end
+
def available_options
@available_options ||= [
[30, 'Last Month'],
diff --git a/app/models/rules/assessment_score_greater_than_specified.rb b/app/models/rules/assessment_score_greater_than_specified.rb
index efed8a989..88ed97503 100644
--- a/app/models/rules/assessment_score_greater_than_specified.rb
+++ b/app/models/rules/assessment_score_greater_than_specified.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'select'
+ end
+
+ def variable_label
+ 'Assessment Score'
+ end
+
+ def variable_options
+ available_scores
+ end
+
def available_scores
@available_scores ||= Array.new(20) { |i| (i + 1) * 5 }.map { |i| [i, i] }
end
diff --git a/app/models/rules/bedroom.rb b/app/models/rules/bedroom.rb
index 43ca47a64..640ca3905 100644
--- a/app/models/rules/bedroom.rb
+++ b/app/models/rules/bedroom.rb
@@ -29,6 +29,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'select'
+ end
+
+ def variable_label
+ 'Bedrooms'
+ end
+
+ def variable_options
+ available_number_of_bedrooms
+ end
+
def available_number_of_bedrooms
[
[1, 'One'],
diff --git a/app/models/rules/bedroom_exact.rb b/app/models/rules/bedroom_exact.rb
index c83170242..c28f3a58e 100644
--- a/app/models/rules/bedroom_exact.rb
+++ b/app/models/rules/bedroom_exact.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'select'
+ end
+
+ def variable_label
+ 'Bedrooms'
+ end
+
+ def variable_options
+ available_number_of_bedrooms
+ end
+
def available_number_of_bedrooms
[
[1, 'One'],
diff --git a/app/models/rules/challenge.rb b/app/models/rules/challenge.rb
index a9c2e8e6c..deaacb48a 100644
--- a/app/models/rules/challenge.rb
+++ b/app/models/rules/challenge.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'multi-select'
+ end
+
+ def variable_label
+ 'Challenge'
+ end
+
+ def variable_options
+ available_challenges
+ end
+
def available_challenges
[
'Domestic Violence',
diff --git a/app/models/rules/enrolled_in_hmis_project.rb b/app/models/rules/enrolled_in_hmis_project.rb
index a342a8e32..82acda7bd 100644
--- a/app/models/rules/enrolled_in_hmis_project.rb
+++ b/app/models/rules/enrolled_in_hmis_project.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'multi-select'
+ end
+
+ def variable_label
+ 'Projects'
+ end
+
+ def variable_options
+ available_projects
+ end
+
def available_projects
@available_projects ||= if Warehouse::Base.enabled?
Warehouse::Project.joins(:organization).preload(:organization).map do |project|
diff --git a/app/models/rules/enrolled_in_hmis_project_type.rb b/app/models/rules/enrolled_in_hmis_project_type.rb
index 2e12eb0ee..47aafcd7e 100644
--- a/app/models/rules/enrolled_in_hmis_project_type.rb
+++ b/app/models/rules/enrolled_in_hmis_project_type.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'multi-select'
+ end
+
+ def variable_label
+ 'Project Types'
+ end
+
+ def variable_options
+ available_project_types.to_a
+ end
+
def available_project_types
{
es: 'ES - Emergency Shelter',
diff --git a/app/models/rules/enrolled_in_hmis_project_type_any_ph_no_move_in.rb b/app/models/rules/enrolled_in_hmis_project_type_any_ph_no_move_in.rb
index 3431c4fc7..119f5d1a3 100644
--- a/app/models/rules/enrolled_in_hmis_project_type_any_ph_no_move_in.rb
+++ b/app/models/rules/enrolled_in_hmis_project_type_any_ph_no_move_in.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'multi-select'
+ end
+
+ def variable_label
+ 'Project Types'
+ end
+
+ def variable_options
+ available_project_types.to_a
+ end
+
def available_project_types
{
ph: 'PH - Permanent Housing - without move-in',
diff --git a/app/models/rules/has_file_tags.rb b/app/models/rules/has_file_tags.rb
index 847593512..8f46d0391 100644
--- a/app/models/rules/has_file_tags.rb
+++ b/app/models/rules/has_file_tags.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'multi-select'
+ end
+
+ def variable_label
+ 'File Tags'
+ end
+
+ def variable_options
+ available_tags
+ end
+
def available_tags
return [] unless Warehouse::Base.enabled?
diff --git a/app/models/rules/income_maximum.rb b/app/models/rules/income_maximum.rb
index 6bee7127d..70a52c853 100644
--- a/app/models/rules/income_maximum.rb
+++ b/app/models/rules/income_maximum.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'number'
+ end
+
+ def variable_label
+ 'Income'
+ end
+
+ def variable_options
+ []
+ end
+
def display_for_variable value
"$#{value} / month"
end
diff --git a/app/models/rules/income_minimum.rb b/app/models/rules/income_minimum.rb
index 14c631158..2f5657ded 100644
--- a/app/models/rules/income_minimum.rb
+++ b/app/models/rules/income_minimum.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'number'
+ end
+
+ def variable_label
+ 'Income'
+ end
+
+ def variable_options
+ []
+ end
+
def display_for_variable value
"$#{value} / month"
end
diff --git a/app/models/rules/interested_in_neighborhood.rb b/app/models/rules/interested_in_neighborhood.rb
index e59064ee2..04a25b3f0 100644
--- a/app/models/rules/interested_in_neighborhood.rb
+++ b/app/models/rules/interested_in_neighborhood.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'select'
+ end
+
+ def variable_label
+ 'Neighborhood'
+ end
+
+ def variable_options
+ available_neighborhoods
+ end
+
def available_neighborhoods
Neighborhood.order(:name).pluck(:id, :name)
end
diff --git a/app/models/rules/non_hmis_assessment_type.rb b/app/models/rules/non_hmis_assessment_type.rb
index e9b45fc71..4a9723f19 100644
--- a/app/models/rules/non_hmis_assessment_type.rb
+++ b/app/models/rules/non_hmis_assessment_type.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'multi-select'
+ end
+
+ def variable_label
+ 'Assessment Types'
+ end
+
+ def variable_options
+ available_assessments
+ end
+
def available_assessments
@available_assessments ||= NonHmisAssessment.known_assessments_for_matching
end
diff --git a/app/models/rules/occupancy.rb b/app/models/rules/occupancy.rb
index 6e07184ea..fe4e30b04 100644
--- a/app/models/rules/occupancy.rb
+++ b/app/models/rules/occupancy.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'select'
+ end
+
+ def variable_label
+ 'Occupancy'
+ end
+
+ def variable_options
+ available_occupancy
+ end
+
def available_occupancy
[
[1, 'One'],
diff --git a/app/models/rules/rank_below.rb b/app/models/rules/rank_below.rb
index 4fa11ad2a..2c2ec0ffe 100644
--- a/app/models/rules/rank_below.rb
+++ b/app/models/rules/rank_below.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'select'
+ end
+
+ def variable_label
+ 'Rank'
+ end
+
+ def variable_options
+ available_ranks
+ end
+
def available_ranks
(1..500)
end
diff --git a/app/models/rules/strength.rb b/app/models/rules/strength.rb
index a1fc88b71..9284ba398 100644
--- a/app/models/rules/strength.rb
+++ b/app/models/rules/strength.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'multi-select'
+ end
+
+ def variable_label
+ 'Strength'
+ end
+
+ def variable_options
+ available_strengths
+ end
+
def available_strengths
[
'Employable Skills',
diff --git a/app/models/rules/tagged_with.rb b/app/models/rules/tagged_with.rb
index ee58d8d69..5663ab04d 100644
--- a/app/models/rules/tagged_with.rb
+++ b/app/models/rules/tagged_with.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'select'
+ end
+
+ def variable_label
+ 'Tag'
+ end
+
+ def variable_options
+ available_tags
+ end
+
def available_tags
Tag.all.map { |tag| [tag.id, tag.name] }
end
diff --git a/app/models/rules/unsheltered_days.rb b/app/models/rules/unsheltered_days.rb
index 55e4791ca..632ff1707 100644
--- a/app/models/rules/unsheltered_days.rb
+++ b/app/models/rules/unsheltered_days.rb
@@ -15,6 +15,18 @@ def variable_requirement?
true
end
+ def variable_input_type
+ 'select'
+ end
+
+ def variable_label
+ 'Days Unsheltered'
+ end
+
+ def variable_options
+ available_unsheltered_days
+ end
+
def available_unsheltered_days
[
[1, '1 Day'],
diff --git a/app/models/sub_program.rb b/app/models/sub_program.rb
index 72b3ef1d9..1a9aaffc1 100644
--- a/app/models/sub_program.rb
+++ b/app/models/sub_program.rb
@@ -106,6 +106,10 @@ def default_match_contacts
@default_match_contacts ||= SubProgramContacts.new sub_program: self
end
+ def full_name
+ "#{program.name} - #{name.presence || '(unnamed)'}"
+ end
+
def name_with_status
@name_with_status = name
@name_with_status += ' (Closed) ' if closed?
diff --git a/app/models/translation.rb b/app/models/translation.rb
index cb330baa3..0a6709b71 100644
--- a/app/models/translation.rb
+++ b/app/models/translation.rb
@@ -59,6 +59,8 @@ def self.known_translations
'Able to work full-time',
'a criminal background hearing has been schedule for',
'A criminal background hearing is not necessary',
+ 'A new vacancy submission is awaiting your review.',
+ 'A vacancy submission has been resubmitted for your review.',
'Actively fleeing domestic violence in your home or staying with someone else',
'Added to assessed clients',
'Add Photo or Video Link',
@@ -85,6 +87,8 @@ def self.known_translations
'CAS Reports',
'CE Assessment',
'Challenges for housing placement',
+ 'Changes have been requested on your vacancy submission before it can be approved.',
+ 'Changes requested on your vacancy submission',
'Check off all the Boston neighborhoods you are willing to live in. Another way to decide is to figure out which places you will not live in, and check off the rest. You are not penalized if you change your mind about where you want to live.',
'Children under age 18 in household',
'Chronically homeless family',
@@ -223,6 +227,7 @@ def self.known_translations
'Outside/place not meant for human habitation within the City of Boston',
'Part of a family',
'Photos and Videos',
+ 'Program',
'Project-Based unit: The unit is affordable (about 30-40% of your income), but the affordability is attached to the unit. It is not mobile- if you leave, you will lose the affordability. You do not have to do a full housing search in the private market with landlords because the actual unit would be open and available.',
'Provider Only Route',
'Public Housing unit',
@@ -233,6 +238,7 @@ def self.known_translations
'Requires ground floor unit or elevator access',
'Requires wheelchair accessible unit',
'researching criminal background and deciding whether to schedule a hearing',
+ 'Reviewer',
'RH Staff at Bridge Over Troubled Water (youth agency)',
'Route Five Development Officer',
'Route Five DND Contact',
@@ -271,6 +277,7 @@ def self.known_translations
'Staff Decline',
'Strengths',
'Submit Client Application',
+ 'Submitted by',
'Tag',
'Tags',
'TC HAT',
@@ -289,7 +296,10 @@ def self.known_translations
'Unsheltered (outside, in a place not meant for human habitation, etc.)',
'Uploaded file does not have the correct header. Incorrect file?',
'U.S Citizen or Permanent Resident',
+ 'Vacancy submission awaiting review',
+ 'Vacancy submission resubmitted for review',
'View details here: Six',
+ 'View submission',
'Veteran Agency Contact Details',
'Voucher: An affordable housing \ticket\ used to find a home with private landlords. It is mobile, so you can move units and still keep the affordability (about 30-40% of your income for rent)',
'Voucher: An affordable housing “ticket” used to find a home with private landlords. It is mobile, so you can move units and still keep the affordability (about 30-40% of your income for rent)',
diff --git a/app/models/user.rb b/app/models/user.rb
index 4e784c8c7..e78aee5d2 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -45,6 +45,7 @@ class User < ApplicationRecord
scope :developer, -> { joins(:roles).where(roles: { name: 'developer' }) }
scope :dnd_initial_contact, -> { active.dnd_staff.where receive_initial_notification: true }
scope :housing_subsidy_admin, -> { joins(:roles).where(roles: { can_add_vacancies: true }) }
+ scope :vacancy_reviewers, -> { active.joins(:roles).where(roles: { can_review_vacancies: true }).distinct }
scope :match_admins, -> do
joins(:roles).where(roles: { can_reject_matches: true }).
or(joins(:roles).where(roles: { can_approve_matches: true }))
diff --git a/app/models/vacancy_submission.rb b/app/models/vacancy_submission.rb
new file mode 100644
index 000000000..bf34bc6dc
--- /dev/null
+++ b/app/models/vacancy_submission.rb
@@ -0,0 +1,237 @@
+###
+# Copyright 2016 - 2025 Green River Data Analysis, LLC
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+class VacancySubmission < ApplicationRecord
+ has_paper_trail
+
+ belongs_to :user, optional: true
+ has_many :vacancy_submission_notes, dependent: :destroy
+
+ store_accessor :draft_data,
+ :program_id, :sub_program_id, :route, :is_voucher, :units,
+ :required_document_names, :notes
+
+ # Accessibility options captured by the Unit#elevator_accessible boolean rather
+ # than a Requirement rule — both simply set elevator_accessible to true.
+ ELEVATOR_ACCESSIBILITY_OPTIONS = ['Elevator to unit', 'Ground floor unit'].freeze
+
+ # Accessibility checkboxes shown on the vacancy form. Wheelchair options map to
+ # the Requirement rule they enforce; the elevator / ground-floor options map to
+ # nil because they are captured by the Unit#elevator_accessible boolean
+ # (see ELEVATOR_ACCESSIBILITY_OPTIONS) rather than a Requirement.
+ #
+ # It's unclear if the wheelchair requirements should always be enforced or if we
+ # need a mechanism to mark them optional. For instance, someone who isn't a
+ # wheelchair user can live comfortably in a wheelchair accessible building.
+ ACCESSIBILITY_OPTIONS = {
+ 'Wheelchair accessible unit' => Rules::Wheelchair,
+ 'Wheelchair accessible building' => Rules::Wheelchair,
+ }.merge(ELEVATOR_ACCESSIBILITY_OPTIONS.index_with { nil }).freeze
+
+ # Studio and 3+ bedrooms don't have a dedicated Rule yet, so we approximate
+ # with Rules::BedroomExact until product decides how they should be modeled.
+ BEDROOM_OPTIONS = {
+ 'SRO' => { rule_class: Rules::SroOk, variable: nil },
+ 'Studio' => { rule_class: Rules::BedroomExact, variable: 1 }, # approximation; TBD with product team
+ 'One bedroom' => { rule_class: Rules::BedroomExact, variable: 1 },
+ 'Two bedrooms' => { rule_class: Rules::BedroomExact, variable: 2 },
+ 'Three or more bedrooms' => { rule_class: Rules::BedroomExact, variable: 3 }, # collapses 3+ to exactly 3 for now
+ }.freeze
+
+ AGE_LIMIT_OPTIONS = {
+ 'N/A' => nil,
+ '50+' => Rules::AgeGreaterThanFifty,
+ '55+' => Rules::AgeGreaterThanFiftyFive,
+ '60+' => Rules::AgeGreaterThanSixty,
+ }.freeze
+
+ SHARED_SPACE_OPTIONS = ['Kitchen', 'Living Room', 'Bathroom'].freeze
+
+ STATUSES = ['awaiting_approval', 'return_changes_requested', 'active'].freeze
+ REVIEW_QUEUE_STATUSES = ['awaiting_approval', 'return_changes_requested'].freeze
+
+ validates :status, inclusion: { in: STATUSES }
+ validate :required_draft_fields
+
+ scope :queue, -> { where(status: REVIEW_QUEUE_STATUSES) }
+ scope :by_status, ->(s) { where(status: s) }
+
+ def self.filtered(program_id:, status_filter:)
+ scope = all
+ scope = scope.where("draft_data ->> 'program_id' = ?", program_id.to_s) if program_id.present?
+ case status_filter.to_s
+ when 'queue', ''
+ scope.queue
+ when 'all'
+ scope
+ else
+ scope.by_status(status_filter)
+ end
+ end
+
+ def self.program_ids_in_use
+ distinct.pluck(Arel.sql("(draft_data ->> 'program_id')::integer")).compact
+ end
+
+ def self.derive_route(sub_program)
+ sub_program&.match_route&.title
+ end
+
+ def self.derive_is_voucher(sub_program)
+ sub_program&.program_type == 'Tenant-Based'
+ end
+
+ def program_id
+ super&.to_i
+ end
+
+ def sub_program_id
+ super&.to_i
+ end
+
+ def voucher?
+ is_voucher
+ end
+
+ def site_display
+ return ['N/A'] if voucher?
+
+ Array(units).map do |u|
+ building = Building.find_by(id: u['building_id'])
+ next '—' if building.nil?
+
+ [
+ building.name,
+ ("Unit #{u['unit_number']}" if u['unit_number'].present?),
+ *building.building_address,
+ ].compact.reject(&:blank?).join(', ').presence || '—'
+ end
+ end
+
+ def voucher_type_display
+ voucher? ? 'Voucher' : 'Physical Unit'
+ end
+
+ def status_display
+ {
+ 'awaiting_approval' => 'Awaiting Approval',
+ 'return_changes_requested' => 'Return / Changes Requested',
+ 'active' => 'Active',
+ }[status] || status.to_s.humanize
+ end
+
+ def bedrooms_display
+ return ['—'] if voucher?
+
+ Array(units).map { |u| u['bedrooms'] }.compact.reject(&:blank?)
+ end
+
+ def changes_requested_display
+ return '—' if status != 'return_changes_requested'
+
+ vacancy_submission_notes.select { |n| n.note_type == 'reviewer_note' }.last&.body || '—'
+ end
+
+ # Reviewers can approve from either queue state — including return_changes_requested —
+ # as an override without requiring the submitter to go through the resubmit flow.
+ def approvable?
+ status.in?(REVIEW_QUEUE_STATUSES)
+ end
+
+ def returnable?
+ status.in?(REVIEW_QUEUE_STATUSES)
+ end
+
+ def resubmittable?
+ status == 'return_changes_requested'
+ end
+
+ def approve!(user:)
+ transaction do
+ update!(status: 'active')
+ VacancySubmissions::Approval.new(self, user: user).call!
+ vacancy_submission_notes.create!(
+ user: user,
+ note_type: 'status_change',
+ body: 'Approved — status changed to Active.',
+ )
+ end
+ end
+
+ def return_for_changes!(body:, user:)
+ transaction do
+ # Returning is how a reviewer sends a submission back to be fixed, so it
+ # must work even when the draft is invalid (e.g. units captured under an
+ # older address mechanism). Only the reviewer note is required here; that
+ # is enforced in the controller.
+ self.status = 'return_changes_requested'
+ save!(validate: false)
+ vacancy_submission_notes.create!(
+ user: user,
+ note_type: 'reviewer_note',
+ body: body,
+ )
+ vacancy_submission_notes.create!(
+ user: user,
+ note_type: 'status_change',
+ body: 'Returned for changes.',
+ )
+ end
+ end
+
+ def resubmit!(user:)
+ transaction do
+ update!(status: 'awaiting_approval')
+ vacancy_submission_notes.create!(
+ user: user,
+ note_type: 'status_change',
+ body: 'Resubmitted for review.',
+ )
+ end
+ end
+
+ private
+
+ def required_draft_fields
+ errors.add(:program_id, :blank) if program_id.blank?
+ errors.add(:sub_program_id, :blank) if sub_program_id.blank?
+ validate_units
+ end
+
+ def validate_units
+ unit_list = Array(units)
+ if unit_list.empty?
+ errors.add(:units, :blank)
+ return
+ end
+ if voucher?
+ unit_list.each { |u| errors.add(:units, 'each voucher must have a name') if u['name'].blank? }
+ else
+ unit_list.each do |u|
+ errors.add(:units, 'each unit must have a building') if u['building_id'].blank?
+ errors.add(:units, 'each unit must have a unit number') if u['unit_number'].blank?
+ end
+ end
+ unit_list.each { |u| validate_requirement_variables(u) }
+ end
+
+ # Requirements attached to a unit/voucher may reference a rule that needs a
+ # variable (e.g. bedroom count, HMIS projects). The draft form can submit
+ # such a rule with a blank variable, so guard it here rather than deferring
+ # the failure to approval time.
+ def validate_requirement_variables(unit)
+ Array(unit['requirements']).each do |req|
+ next if req['rule_id'].blank? || req['variable'].present?
+
+ rule = Rule.find_by(id: req['rule_id'])
+ next unless rule&.variable_requirement?
+
+ errors.add(:units, "#{rule.name} requires a value")
+ end
+ end
+end
diff --git a/app/models/vacancy_submission_note.rb b/app/models/vacancy_submission_note.rb
new file mode 100644
index 000000000..c5e0314ae
--- /dev/null
+++ b/app/models/vacancy_submission_note.rb
@@ -0,0 +1,30 @@
+###
+# Copyright 2016 - 2025 Green River Data Analysis, LLC
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+class VacancySubmissionNote < ApplicationRecord
+ NOTE_TYPES = ['reviewer_note', 'status_change'].freeze
+
+ belongs_to :vacancy_submission
+ belongs_to :user, optional: true
+
+ ACTION_LABELS = {
+ 'Initial submission' => 'Submitted',
+ 'Approved' => 'Approved',
+ 'Resubmitted' => 'Resubmitted',
+ 'Updated:' => 'Submission Edited',
+ }.freeze
+
+ validates :note_type, inclusion: { in: NOTE_TYPES }
+ validates :body, presence: true
+
+ def action_label
+ return 'Return / Changes Requested' if note_type == 'reviewer_note'
+
+ ACTION_LABELS.detect { |prefix, _| body.start_with?(prefix) }&.last || 'Status Change'
+ end
+end
diff --git a/app/models/vacancy_submissions/approval.rb b/app/models/vacancy_submissions/approval.rb
new file mode 100644
index 000000000..f6cf44de2
--- /dev/null
+++ b/app/models/vacancy_submissions/approval.rb
@@ -0,0 +1,178 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+module VacancySubmissions
+ # Materializes an approved VacancySubmission's draft_data into real
+ # Voucher/Opportunity/Requirement records.
+ class Approval
+ def initialize(vacancy_submission, user:)
+ @vacancy_submission = vacancy_submission
+ @user = user
+ end
+
+ def call!
+ sub_program = resolve_sub_program!
+
+ updated_units = Array(vacancy_submission.units).map do |unit_hash|
+ unit = build_unit_for(unit_hash) if sub_program.has_buildings?
+ voucher = create_voucher!(sub_program, unit_hash, unit: unit)
+
+ unit_hash = unit_hash.merge('voucher_id' => voucher.id)
+ unit ? unit_hash.merge('unit_id' => unit.id) : unit_hash
+ end
+
+ vacancy_submission.units = updated_units
+ vacancy_submission.save!
+ end
+
+ private
+
+ attr_reader :vacancy_submission, :user
+
+ def resolve_sub_program!
+ program = Program.find_by(id: vacancy_submission.program_id)
+ sub_program = program&.sub_programs&.find_by(id: vacancy_submission.sub_program_id)
+ return sub_program if sub_program.present?
+
+ vacancy_submission.errors.add(:sub_program_id, 'could not be resolved to a Sub-Program')
+ raise ActiveRecord::RecordInvalid.new(vacancy_submission)
+ end
+
+ def create_voucher!(sub_program, unit_hash, unit: nil)
+ options = {
+ sub_program: sub_program,
+ creator: user,
+ unit: unit,
+ available: false,
+ date_available: resolved_date(unit_hash),
+ }
+ # Apply match-route weighting rules just as opportunities_controller and
+ # vouchers_controller do: pull the next rule for the route, increment its
+ # round-robin counter, and attach copies of its requirements to the voucher.
+ weighting_requirements = WeightingRule.requirements_and_increment!(sub_program)
+ options[:requirements] = weighting_requirements if weighting_requirements.present?
+ voucher = Voucher.create!(options)
+ voucher.create_opportunity!(available: false, available_candidate: false)
+ attach_requirements!(unit || voucher, unit_hash)
+ voucher
+ end
+
+ def resolve_building_for(unit_hash)
+ building = Building.find_by(id: unit_hash['building_id'])
+ return building if building.present?
+
+ vacancy_submission.errors.add(:base, 'Selected building could not be found')
+ raise ActiveRecord::RecordInvalid.new(vacancy_submission)
+ end
+
+ def build_unit_for(unit_hash)
+ building = resolve_building_for(unit_hash)
+ unit = Unit.create!(
+ building: building,
+ name: unit_hash['unit_number'].presence || SecureRandom.hex,
+ available: true,
+ elevator_accessible: elevator_accessible?(unit_hash),
+ notes: unit_hash['notes'],
+ )
+ attach_accessibility_requirements!(unit, unit_hash)
+ attach_bedroom_requirement!(unit, unit_hash)
+ attach_age_limit_requirement!(unit, unit_hash)
+ attach_value_less_housing_attributes!(unit, unit_hash['shared_spaces'])
+ attach_value_less_housing_attributes!(unit, unit_hash['amenities'])
+ attach_attributes!(unit, unit_hash)
+ attach_media_links!(unit, unit_hash)
+ unit
+ end
+
+ def elevator_accessible?(unit_hash)
+ Array(unit_hash['accessibility']).any? { |opt| VacancySubmission::ELEVATOR_ACCESSIBILITY_OPTIONS.include?(opt) }
+ end
+
+ def attach_accessibility_requirements!(unit, unit_hash)
+ # Elevator / ground-floor options map to nil in ACCESSIBILITY_OPTIONS (they set
+ # Unit#elevator_accessible in build_unit_for instead), so compact leaves only the
+ # accessibility options that become Requirements (e.g. Wheelchair).
+ rule_classes = Array(unit_hash['accessibility']).map { |opt| VacancySubmission::ACCESSIBILITY_OPTIONS[opt] }.compact.uniq
+ rule_classes.each do |rule_class|
+ Requirement.create!(requirer: unit, rule: find_rule!(rule_class), positive: true)
+ end
+ end
+
+ def attach_bedroom_requirement!(unit, unit_hash)
+ mapping = VacancySubmission::BEDROOM_OPTIONS[unit_hash['bedrooms']]
+ return if mapping.nil?
+
+ Requirement.create!(
+ requirer: unit,
+ rule: find_rule!(mapping[:rule_class]),
+ positive: true,
+ variable: mapping[:variable],
+ )
+ end
+
+ def attach_age_limit_requirement!(unit, unit_hash)
+ rule_class = VacancySubmission::AGE_LIMIT_OPTIONS[unit_hash['age_limit']]
+ return if rule_class.nil?
+
+ Requirement.create!(requirer: unit, rule: find_rule!(rule_class), positive: true)
+ end
+
+ def attach_value_less_housing_attributes!(unit, names)
+ Array(names).each do |name|
+ HousingAttribute.create!(housingable: unit, name: name, include_value: false)
+ end
+ end
+
+ def attach_attributes!(unit, unit_hash)
+ Array(unit_hash['attributes']).each do |attribute|
+ next if attribute['name'].blank? || attribute['value'].blank?
+
+ HousingAttribute.create!(housingable: unit, name: attribute['name'], value: attribute['value'], include_value: true)
+ end
+ end
+
+ def attach_media_links!(unit, unit_hash)
+ Array(unit_hash['media_links']).each do |media_link|
+ next if media_link['url'].blank?
+
+ HousingMediaLink.create!(housingable: unit, url: media_link['url'], label: media_link['label'].presence || 'Photo')
+ end
+ end
+
+ def attach_requirements!(requirer, unit_hash)
+ Array(unit_hash['requirements']).each do |requirement_attrs|
+ next if requirement_attrs['rule_id'].blank?
+
+ Requirement.create!(
+ requirer: requirer,
+ rule_id: requirement_attrs['rule_id'],
+ positive: requirement_attrs['positive'].to_s != 'false',
+ variable: requirement_attrs['variable'],
+ )
+ end
+ end
+
+ def resolved_date(unit_hash)
+ raw = unit_hash['date_ready']
+ return Date.current if raw.blank?
+
+ Date.parse(raw)
+ rescue ArgumentError # includes Date::Error
+ vacancy_submission.errors.add(:base, "'#{raw}' is not a valid ready date")
+ raise ActiveRecord::RecordInvalid.new(vacancy_submission)
+ end
+
+ def find_rule!(rule_class)
+ rule = Rule.find_by(type: rule_class.name)
+ return rule if rule.present?
+
+ vacancy_submission.errors.add(:base, "Required rule '#{rule_class.name}' is not configured")
+ raise ActiveRecord::RecordInvalid.new(vacancy_submission)
+ end
+ end
+end
diff --git a/app/models/vacancy_submissions/notifier.rb b/app/models/vacancy_submissions/notifier.rb
new file mode 100644
index 000000000..6bad2c04a
--- /dev/null
+++ b/app/models/vacancy_submissions/notifier.rb
@@ -0,0 +1,43 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+module VacancySubmissions
+ # Resolves recipients and enqueues the vacancy-submission workflow emails.
+ # Called from the controller after a transition commits so mail is never
+ # enqueued inside the transaction that performs the state change.
+ class Notifier
+ def initialize(vacancy_submission)
+ @vacancy_submission = vacancy_submission
+ end
+
+ def notify_submitted!
+ reviewers.each do |reviewer|
+ VacancySubmissionsMailer.submitted_for_review(@vacancy_submission, reviewer).deliver_later
+ end
+ end
+
+ def notify_resubmitted!
+ reviewers.each do |reviewer|
+ VacancySubmissionsMailer.resubmitted_for_review(@vacancy_submission, reviewer).deliver_later
+ end
+ end
+
+ def notify_changes_requested!
+ submitter = @vacancy_submission.user
+ return if submitter.blank?
+
+ VacancySubmissionsMailer.changes_requested(@vacancy_submission, submitter).deliver_later
+ end
+
+ private
+
+ def reviewers
+ User.vacancy_reviewers
+ end
+ end
+end
diff --git a/app/views/admin/configs/index.haml b/app/views/admin/configs/index.haml
index 57e09b938..c13e20952 100644
--- a/app/views/admin/configs/index.haml
+++ b/app/views/admin/configs/index.haml
@@ -24,6 +24,7 @@
= f.input :unavailable_for_length, collection: UnavailableAsCandidateFor.available_expiration_lengths, label: 'How long should a client be unavailable on a specific route after a successful match?', as: :select2
= f.input :lock_days, label: 'Days to lock non-HMIS assessments after editing', hint: 'Only relevant for some assessment types'
= f.input :lock_grace_days, label: 'Days the assessment is editable before being locked'
+ = f.input :vacancy_submission_mechanism, collection: @config.vacancy_submission_mechanism_options, include_blank: false, as: :select2, label: 'Vacancy Submission Mechanism'
.row
.col-sm-6
= f.submit value: 'Save Site Configuration', class: 'btn btn-success'
diff --git a/app/views/admin/housing_attribute_names/edit.haml b/app/views/admin/housing_attribute_names/edit.haml
new file mode 100644
index 000000000..1cce07c13
--- /dev/null
+++ b/app/views/admin/housing_attribute_names/edit.haml
@@ -0,0 +1,49 @@
+- type_label = @row.include_value? ? 'Attribute' : 'Amenity'
+- title = "Edit #{type_label} '#{@row.name}'"
+- content_for :title, title
+= render 'menus/admin_tabs'
+%h1= content_for :title
+
+.row.mb-4
+ .col-sm-6
+ = simple_form_for :housing_attribute_name, url: admin_housing_attribute_name_path(@row.id), method: :patch do |f|
+ .form-inputs
+ = f.input :name, label: 'Attribute name', input_html: { value: @row.name }
+ - @values.each do |v|
+ .form-group
+ = label_tag nil, "Value: #{v.value}", class: 'control-label'
+ = hidden_field_tag 'housing_attribute_name[value_renames][][old_value]', v.value
+ = text_field_tag 'housing_attribute_name[value_renames][][new_value]', v.value, class: 'form-control'
+ %p.text-muted
+ Renaming into a name or value that already exists as this same type (#{type_label})
+ merges the two. A same name used as the other type is a separate entity and is unaffected.
+ .form-actions.text-right
+ = f.button :submit, class: 'btn btn-primary'
+ .col-sm-6
+- if @values.any?
+ .values-table.mb-4
+ %h2 Available Options
+ %table.table.table-striped
+ %thead
+ %tr
+ %th Value
+ %th In Use
+ %tbody
+ - @values.each do |v|
+ %tr
+ %td= v.value
+ %td= v.count
+
+%h2 Currently Used By
+%table.table.table-striped
+ %thead
+ %tr
+ %th Type
+ %th Record
+ %th Value
+ %tbody
+ - @rows.each do |row|
+ %tr
+ %td= housingable_type(row.housingable)
+ %td= link_to housingable_name(row.housingable), housingable_edit_path(row.housingable)
+ %td= row.value
diff --git a/app/views/admin/housing_attribute_names/index.haml b/app/views/admin/housing_attribute_names/index.haml
new file mode 100644
index 000000000..afe5305e8
--- /dev/null
+++ b/app/views/admin/housing_attribute_names/index.haml
@@ -0,0 +1,16 @@
+- title = "Housing Attributes"
+- content_for :title, title
+= render 'menus/admin_tabs'
+%h1= content_for :title
+%table.table.table-striped
+ %thead
+ %tr
+ %th Name
+ %th Type
+ %th In Use
+ %tbody
+ - @names.each do |n|
+ %tr
+ %td= link_to n.name, edit_admin_housing_attribute_name_path(n.id)
+ %td= n.type
+ %td= n.count
diff --git a/app/views/admin/rules/active_in_cohorts/_active_in_cohort.haml b/app/views/admin/rules/active_in_cohorts/_active_in_cohort.haml
deleted file mode 100644
index ef6f16e99..000000000
--- a/app/views/admin/rules/active_in_cohorts/_active_in_cohort.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/active_in_cohorts/active_in_cohort', active_in_cohort: active_in_cohort
\ No newline at end of file
diff --git a/app/views/admin/rules/age_greater_than_ies/_age_greater_than_y.haml b/app/views/admin/rules/age_greater_than_ies/_age_greater_than_y.haml
deleted file mode 100644
index 9a112aeeb..000000000
--- a/app/views/admin/rules/age_greater_than_ies/_age_greater_than_y.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/age_greater_than_ies/age_greater_than_y', age_greater_than_y: age_greater_than_y
diff --git a/app/views/admin/rules/age_greater_than_xes/_age_greater_than_x.haml b/app/views/admin/rules/age_greater_than_xes/_age_greater_than_x.haml
deleted file mode 100644
index f534c603f..000000000
--- a/app/views/admin/rules/age_greater_than_xes/_age_greater_than_x.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/age_greater_than_xes/age_greater_than_x', age_greater_than_x: age_greater_than_x
diff --git a/app/views/admin/rules/assessment_completed_withins/_assessment_completed_within.haml b/app/views/admin/rules/assessment_completed_withins/_assessment_completed_within.haml
deleted file mode 100644
index 2a3d6c1e1..000000000
--- a/app/views/admin/rules/assessment_completed_withins/_assessment_completed_within.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-- rule = assessment_completed_within
-= render 'rules/assessment_completed_withins/assessment_completed_within', assessment_completed_within: assessment_completed_within
diff --git a/app/views/admin/rules/assessment_score_greater_than_specifieds/_assessment_score_greater_than_specified.haml b/app/views/admin/rules/assessment_score_greater_than_specifieds/_assessment_score_greater_than_specified.haml
deleted file mode 100644
index c3dabbf6d..000000000
--- a/app/views/admin/rules/assessment_score_greater_than_specifieds/_assessment_score_greater_than_specified.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/assessment_score_greater_than_specifieds/assessment_score_greater_than_specified', assessment_score_greater_than_specified: assessment_score_greater_than_specified
diff --git a/app/views/admin/rules/bedroom_exacts/_bedroom_exact.haml b/app/views/admin/rules/bedroom_exacts/_bedroom_exact.haml
deleted file mode 100644
index 0d003b83d..000000000
--- a/app/views/admin/rules/bedroom_exacts/_bedroom_exact.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/bedroom_exacts/bedroom_exact', bedroom_exact: bedroom_exact
diff --git a/app/views/admin/rules/bedrooms/_bedroom.haml b/app/views/admin/rules/bedrooms/_bedroom.haml
deleted file mode 100644
index 787a2930b..000000000
--- a/app/views/admin/rules/bedrooms/_bedroom.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/bedrooms/bedroom', bedroom: bedroom
diff --git a/app/views/admin/rules/challenges/_challenge.haml b/app/views/admin/rules/challenges/_challenge.haml
deleted file mode 100644
index 0240dc320..000000000
--- a/app/views/admin/rules/challenges/_challenge.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/challenges/challenge', challenge: challenge
diff --git a/app/views/admin/rules/enrolled_in_hmis_project_type_any_ph_no_move_ins/_enrolled_in_hmis_project_type_any_ph_no_move_in.haml b/app/views/admin/rules/enrolled_in_hmis_project_type_any_ph_no_move_ins/_enrolled_in_hmis_project_type_any_ph_no_move_in.haml
deleted file mode 100644
index c5e5c628a..000000000
--- a/app/views/admin/rules/enrolled_in_hmis_project_type_any_ph_no_move_ins/_enrolled_in_hmis_project_type_any_ph_no_move_in.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/enrolled_in_hmis_project_type_any_ph_no_move_ins/enrolled_in_hmis_project_type_any_ph_no_move_in', enrolled_in_hmis_project_type_any_ph_no_move_in: enrolled_in_hmis_project_type_any_ph_no_move_in
diff --git a/app/views/admin/rules/enrolled_in_hmis_project_types/_enrolled_in_hmis_project_type.haml b/app/views/admin/rules/enrolled_in_hmis_project_types/_enrolled_in_hmis_project_type.haml
deleted file mode 100644
index 61c41f1f2..000000000
--- a/app/views/admin/rules/enrolled_in_hmis_project_types/_enrolled_in_hmis_project_type.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/enrolled_in_hmis_project_types/enrolled_in_hmis_project_type', enrolled_in_hmis_project_type: enrolled_in_hmis_project_type
diff --git a/app/views/admin/rules/enrolled_in_hmis_projects/_enrolled_in_hmis_project.haml b/app/views/admin/rules/enrolled_in_hmis_projects/_enrolled_in_hmis_project.haml
deleted file mode 100644
index 4cc54e9c6..000000000
--- a/app/views/admin/rules/enrolled_in_hmis_projects/_enrolled_in_hmis_project.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/enrolled_in_hmis_projects/enrolled_in_hmis_project', enrolled_in_hmis_project: enrolled_in_hmis_project
\ No newline at end of file
diff --git a/app/views/admin/rules/has_file_tags/_has_file_tags.haml b/app/views/admin/rules/has_file_tags/_has_file_tags.haml
deleted file mode 100644
index 44f113c78..000000000
--- a/app/views/admin/rules/has_file_tags/_has_file_tags.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/has_file_tags/has_file_tags', has_file_tags: has_file_tags
\ No newline at end of file
diff --git a/app/views/admin/rules/income_maximums/_income_maximum.haml b/app/views/admin/rules/income_maximums/_income_maximum.haml
deleted file mode 100644
index 4dfea8910..000000000
--- a/app/views/admin/rules/income_maximums/_income_maximum.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/income_maximums/income_maximum', income_maximum: income_maximum
diff --git a/app/views/admin/rules/income_minimums/_income_minimum.haml b/app/views/admin/rules/income_minimums/_income_minimum.haml
deleted file mode 100644
index 86566d0ae..000000000
--- a/app/views/admin/rules/income_minimums/_income_minimum.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/income_minimums/income_minimum', income_minimum: income_minimum
diff --git a/app/views/admin/rules/interested_in_neighborhoods/_interested_in_neighborhood.haml b/app/views/admin/rules/interested_in_neighborhoods/_interested_in_neighborhood.haml
deleted file mode 100644
index bbce3259c..000000000
--- a/app/views/admin/rules/interested_in_neighborhoods/_interested_in_neighborhood.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/interested_in_neighborhoods/interested_in_neighborhood', interested_in_neighborhood: interested_in_neighborhood
\ No newline at end of file
diff --git a/app/views/admin/rules/non_hmis_assessment_types/_non_hmis_assessment_type.haml b/app/views/admin/rules/non_hmis_assessment_types/_non_hmis_assessment_type.haml
deleted file mode 100644
index 317d03a57..000000000
--- a/app/views/admin/rules/non_hmis_assessment_types/_non_hmis_assessment_type.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/non_hmis_assessment_types/non_hmis_assessment_type', non_hmis_assessment_type: non_hmis_assessment_type
diff --git a/app/views/admin/rules/occupancies/_occupancy.haml b/app/views/admin/rules/occupancies/_occupancy.haml
deleted file mode 100644
index f2b998278..000000000
--- a/app/views/admin/rules/occupancies/_occupancy.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/occupancies/occupancy', occupancy: occupancy
\ No newline at end of file
diff --git a/app/views/admin/rules/rank_belows/_rank_below.haml b/app/views/admin/rules/rank_belows/_rank_below.haml
deleted file mode 100644
index dcc68b569..000000000
--- a/app/views/admin/rules/rank_belows/_rank_below.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/rank_belows/rank_below', rank_below: rank_below
\ No newline at end of file
diff --git a/app/views/admin/rules/strengths/_strength.haml b/app/views/admin/rules/strengths/_strength.haml
deleted file mode 100644
index 7e2ce5fcf..000000000
--- a/app/views/admin/rules/strengths/_strength.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/strengths/strength', strength: strength
diff --git a/app/views/admin/rules/tagged_withs/_tagged_with.haml b/app/views/admin/rules/tagged_withs/_tagged_with.haml
deleted file mode 100644
index a08d57bf8..000000000
--- a/app/views/admin/rules/tagged_withs/_tagged_with.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/tagged_withs/tagged_with', tagged_with: tagged_with
\ No newline at end of file
diff --git a/app/views/admin/rules/unsheltered_days/_unsheltered_days.haml b/app/views/admin/rules/unsheltered_days/_unsheltered_days.haml
deleted file mode 100644
index 3243f9e61..000000000
--- a/app/views/admin/rules/unsheltered_days/_unsheltered_days.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'rules/unsheltered_days/unsheltered_days', unsheltered_days: unsheltered_days
diff --git a/app/views/housing_attributes/_amenities.haml b/app/views/housing_attributes/_amenities.haml
new file mode 100644
index 000000000..9150fd72d
--- /dev/null
+++ b/app/views/housing_attributes/_amenities.haml
@@ -0,0 +1,31 @@
+-# Partial: presents amenities (value-less attributes) associated with a building or unit
+-# Arguments:
+-# housing_object: The particular building or unit object
+-# mode: current options are 'edit' or 'read'. 'read' is the default
+
+:ruby
+ mode ||= 'read'
+
+.c-card.c-card--flush.mb-4
+ %h3 Amenities
+ .c-card__content.p-0.pb-4
+ %table.table.table-striped
+ %thead
+ %tr
+ %th Amenity
+ - if mode == 'edit'
+ %th
+ %tbody
+ - housing_object.housing_attributes.without_value.each do |amenity|
+ %tr
+ %td= amenity.name
+ - if mode == 'edit'
+ %td.text-right
+ = link_to polymorphic_path([housing_object, amenity]), method: :delete, data: { confirm: "Are you sure you want to delete the #{amenity.name} amenity? It will be removed as an option from all units, not just this unit." }, class: 'btn btn-sm btn-danger' do
+ %i.icon-cross
+ Remove
+ - if mode == 'edit'
+ .text-right
+ = link_to new_polymorphic_path([housing_object, HousingAttribute], amenity: true), class: 'btn btn-secondary mr-4 mt-4', data: { loads_in_pjax_modal: true } do
+ %i.icon-plus
+ Add Amenity
diff --git a/app/views/housing_attributes/_attributes.haml b/app/views/housing_attributes/_attributes.haml
index 3a0242846..c99b17154 100644
--- a/app/views/housing_attributes/_attributes.haml
+++ b/app/views/housing_attributes/_attributes.haml
@@ -24,7 +24,7 @@
%th
%th
%tbody
- - housing_object.housing_attributes.each do |attribute|
+ - housing_object.housing_attributes.with_value.each do |attribute|
%tr
%td= attribute.name
%td= attribute.value
diff --git a/app/views/housing_attributes/new.haml b/app/views/housing_attributes/new.haml
index 4a60ce739..303c44289 100644
--- a/app/views/housing_attributes/new.haml
+++ b/app/views/housing_attributes/new.haml
@@ -1,26 +1,35 @@
--# Partial: used polymorphically for building and unit attributes
+-# Partial: used polymorphically for building and unit attributes/amenities
-# Arguments:
-# housingable: currently a building or unit object
-# attribute: a HousingAttribute object
-= content_for :modal_title do
- Add Attribute
+- if @attribute.include_value?
+ = content_for :modal_title do
+ Add Attribute
-= simple_form_for @attribute, url: polymorphic_path([@housingable, :housing_attributes]) do |f|
- = f.input :name, collection: @attribute.existing_attributes, as: :select_2, input_html: { class: :jType, data: { tags: true }}, include_blank: true, hint: 'If you don\'t see the type required, type it in and select it', placeholder: 'Please chooose'
- = f.input :value, collection: [], as: :select_2, input_html: { class: :jValue, data: { tags: true, 'collection-path' => polymorphic_path([:values, @housingable, :housing_attributes]) } }, include_blank: true, hint: 'If you don\'t see the value required, type it in and select it', placeholder: 'Please chooose'
+ = simple_form_for @attribute, url: polymorphic_path([@housingable, :housing_attributes]) do |f|
+ = f.hidden_field :include_value, value: true
+ = f.input :name, collection: HousingAttribute.existing_attributes, as: :select_2, input_html: { class: :jType, data: { tags: true }}, include_blank: true, hint: 'If you don\'t see the type required, type it in and select it', placeholder: 'Please choose'
+ = f.input :value, collection: [], as: :select_2, input_html: { class: :jValue, data: { tags: true, 'collection-path' => polymorphic_path([:values, @housingable, :housing_attributes]) } }, include_blank: true, hint: 'If you don\'t see the value required, type it in and select it', placeholder: 'Please choose'
+ = f.button :submit
- = f.button :submit
-
-= render 'init_select2'
-= content_for :page_js do
- :javascript
- $(document).ready(function() {
- $('.jType').on('change', function(e) {
- var attribute_name = $(this).val();
- var input = $('.jValue');
- [path, query] = input.data('collection-path').split('?');
- input.data('collection-path', path + "?attribute_name=" + attribute_name);
- App.remoteSelectLoad.init();
+ = content_for :page_js do
+ :javascript
+ $(document).ready(function() {
+ $('.jType').on('change', function(e) {
+ var attribute_name = $(this).val();
+ var input = $('.jValue');
+ [path, query] = input.data('collection-path').split('?');
+ input.data('collection-path', path + "?attribute_name=" + attribute_name);
+ App.remoteSelectLoad.init();
+ });
});
- });
+
+- else
+ = content_for :modal_title do
+ Add Amenity
+
+ = simple_form_for @attribute, url: polymorphic_path([@housingable, :housing_attributes]) do |f|
+ = f.hidden_field :include_value, value: false
+ = f.input :name, collection: HousingAttribute.existing_amenities, as: :select_2, input_html: { data: { tags: true } }, include_blank: true, hint: 'If you don\'t see the amenity required, type it in and select it', placeholder: 'Please choose'
+ = f.button :submit
diff --git a/app/views/menus/_admin_tabs.haml b/app/views/menus/_admin_tabs.haml
index a8d2b826a..b6ba8f18c 100644
--- a/app/views/menus/_admin_tabs.haml
+++ b/app/views/menus/_admin_tabs.haml
@@ -18,6 +18,9 @@
-if can_manage_config?
%li.nav-item{role: "presentation", class: ('active' if current_page?(admin_match_routes_path))}
= link_to 'Match Routes', admin_match_routes_path, class: 'nav-link'
+ -if can_manage_config?
+ %li.nav-item{role: "presentation", class: ('active' if current_page?(admin_housing_attribute_names_path))}
+ = link_to 'Housing Attributes', admin_housing_attribute_names_path, class: 'nav-link'
- if can_manage_sessions?
%li.nav-item{role: 'presentation', class: ('active' if controller_name == "sessions")}
= link_to 'Sessions', admin_sessions_path, class: 'nav-link'
diff --git a/app/views/non_hmis_assessments/pathways_version_three/_pathways_version_three_preamble.haml b/app/views/non_hmis_assessments/pathways_version_three/_pathways_version_three_preamble.haml
index 1abdf4ea8..aa612598d 100644
--- a/app/views/non_hmis_assessments/pathways_version_three/_pathways_version_three_preamble.haml
+++ b/app/views/non_hmis_assessments/pathways_version_three/_pathways_version_three_preamble.haml
@@ -17,8 +17,7 @@
All youth specific RRH opportunities are being matched through CAS via the Pathways assessment.
%h4 Veteran RRH:
%p Veteran specific RRH is available via direct referral through Supportive Services for Veteran Families (SSVF) with no Pathways Assessment required at the following agencies:
-- markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML, autolink: true)
-= markdown.render(Translation.translate('Veteran Agency Contact Details')).html_safe
+= render_markdown(Translation.translate('Veteran Agency Contact Details'))
%h2 Key Introduction Points to Share with the Participant
%p
diff --git a/app/views/non_hmis_clients/assessments/_common_description_display.haml b/app/views/non_hmis_clients/assessments/_common_description_display.haml
index 06275b67a..d5ebb0dd7 100644
--- a/app/views/non_hmis_clients/assessments/_common_description_display.haml
+++ b/app/views/non_hmis_clients/assessments/_common_description_display.haml
@@ -1,4 +1,3 @@
- if options[:description].present?
.description.mt-2
- - markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML, autolink: true)
- = markdown.render(options[:description]).html_safe
+ = render_markdown(options[:description])
diff --git a/app/views/opportunities/index.haml b/app/views/opportunities/index.haml
index 9a601ada3..28b2ef44c 100644
--- a/app/views/opportunities/index.haml
+++ b/app/views/opportunities/index.haml
@@ -1,6 +1,6 @@
.o-page__title
%h1= t '.title'
- - if can_add_vacancies?
+ - if can_add_vacancies? && Config.active_vacancy_submission_mechanism_traditional?
.o-page__action.o-page__action--stacked
= link_to new_opportunity_path, class: 'btn btn-primary margin-bottom-02' do
%span.icon-plus
@@ -8,6 +8,12 @@
%a.how-to-popover{'data-bs-toggle': "popover", 'data-bs-placement': "bottom", 'data-bs-content': "
- Find the #{Building.model_name.human} & edit units available
- Find program and make a voucher available
", role: 'button', 'tabindex': 0,'data-bs-html': "true", 'data-bs-trigger': "focus"}
%span.icon-info
How to Add a Vacancy
+ - elsif can_add_vacancies?
+ .o-page__action.o-page__action--stacked
+ = link_to new_vacancy_submission_path, class: 'btn btn-primary margin-bottom-02' do
+ %span.icon-plus
+ Add a Vacancy
+
- @prompt = t '.search_prompt'
.row.o-search
diff --git a/app/views/program_details/edit.haml b/app/views/program_details/edit.haml
index 3d849a074..fadc44021 100644
--- a/app/views/program_details/edit.haml
+++ b/app/views/program_details/edit.haml
@@ -38,4 +38,4 @@
= content_for :page_js do
:javascript
- $('select').select2();
+ $('select.select2').select2();
diff --git a/app/views/requirement_manager/_form_fields.haml b/app/views/requirement_manager/_form_fields.haml
index c2bec56b1..7d3df91e2 100644
--- a/app/views/requirement_manager/_form_fields.haml
+++ b/app/views/requirement_manager/_form_fields.haml
@@ -1,13 +1,15 @@
-- help_text ||= ''
-- hide_inherited ||= false
-- on_unit ||= false
-- section_header ||= 'Rules'
-- note_context ||= nil
-- markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML, autolink: true)
+- help_text = local_assigns.fetch(:help_text, '')
+- hide_inherited = local_assigns.fetch(:hide_inherited, false)
+- on_unit = local_assigns.fetch(:on_unit, false)
+- section_header = local_assigns.fetch(:section_header, 'Rules')
+- all_rules = Rule.order(:name).to_a
+- all_rules_by_id = all_rules.index_by(&:id)
+- requirements = form.object.requirements.reject(&:marked_for_destruction?).sort_by { |r| r.created_at || Time.current }
+- base_name = form.object_name
.row
.col-lg-12
- .c-card.c-card--flush{ data: {requirement_manager: true, base_object_name: form.object_name} }
+ .c-card.c-card--flush
%h3= section_header
.c-card__content
- if help_text.present?
@@ -15,46 +17,59 @@
- unless hide_inherited
.mb-6= render 'requirement_manager/inherited_rules', inheritee: form.object
- if can_assign_requirements?
- .mb-6
- %label= selected_requirements_heading
- %div{ data: {selected_requirements: true} }
- - requirements = form.object.requirements&.preload(:rule)&.order(:created_at)
- - requirements&.each do |requirement|
- - rule_name = requirement.rule_alternate_name if on_unit
- - rule_name ||= requirement.rule_name_with_verb
- %div{data: {existing_requirement: true, requirement_id: requirement.id, requirement_rule_id: requirement.rule_id, requirement_rule_name: rule_name, requirement_positive: requirement.positive.to_s, requirement_variable: requirement.variable.to_s, requirement_display_for_variable: requirement.display_for_variable }}
-
- %label Add Rule Condition
- .c-binary-choice
- .input-group.mb-2.mr-2
- .d-flex.flex-wrap
- %a{href: '#', class: 'btn btn-outline-primary', style: 'border-right: 0; border-radius: .25em 0 0 .25em', data: {new_requirement_positivity_toggle: true, requirement_positive: 'true'}} Must
- %a{href: '#', class: 'btn btn-outline-primary', style: 'border-radius: 0', data: {new_requirement_positivity_toggle: true, requirement_positive: 'false'}} Can't
- = select_tag :available_rules, nil, class: 'form-control select2--with-prepends', data: {rule_searcher: true}
- - if form.object.persisted?
- - form.object.available_rules.each do |rule|
- - rule_name = rule.alternate_name if on_unit
- - rule_name ||= rule.name_with_verb
- %div{data: {available_rule: true, rule_id: rule.id, rule_name: rule_name, variable: rule.variable_requirement?.to_s}}
- - if rule.variable_requirement?
- .jVariableRequirment.mb-4{data: {rule_id: rule.id}}
- = render rule
- - if rule.selection_note(context: note_context).present?
- .jRuleSelectionNote.alert.alert-info.mb-4{data: {rule_id: rule.id}, style: 'display: none;'}
- .d-block
- = markdown.render(rule.selection_note(context: note_context)).html_safe
-
- -# Include jVariableRequirment divs for the existing requirements, otherwise we don't have values if the rule needs to be changed
- - requirements&.each do |requirement|
- - rule = requirement.rule
- - if rule.variable_requirement?
- .jVariableRequirment.mb-4{data: {rule_id: rule.id}}
- = render rule
-
- %a{href: '#', class: 'btn btn-primary mb-2', data: {add_button: true}} Add Rule
-
-= content_for :page_js do
- :javascript
- $('[data-requirement-manager]').each(function(_i, element) {
- new App.RequirementManager.Controller(element);
- });
+ %fieldset{data: { controller: 'requirements' }}
+ %div{data: { 'requirements-target': 'list' }}
+ - requirements.each_with_index do |req, req_i|
+ - rule = all_rules_by_id[req.rule_id]
+ - next unless rule
+ - rule_name = on_unit ? (rule.alternate_name.presence || rule.name_with_verb) : rule.name_with_verb
+ .mb-2{data: { 'req-entry': '' }}
+ %input{type: 'hidden', name: "#{base_name}[requirements_attributes][#{req_i}][id]", value: req.id}
+ %input{type: 'hidden', name: "#{base_name}[requirements_attributes][#{req_i}][_destroy]", value: '0', data: { 'req-destroy': '' }}
+ .d-flex.gap-2.align-items-center
+ %select.form-select.w-auto.flex-shrink-0{ name: "#{base_name}[requirements_attributes][#{req_i}][positive]" }
+ %option{value: 'true', selected: req.positive} Must
+ %option{value: 'false', selected: !req.positive} Can't
+ %select.form-select.select2.flex-grow-1{ name: "#{base_name}[requirements_attributes][#{req_i}][rule_id]", data: { 'req-rule-select': '' } }
+ %option{value: ''} Select rule...
+ - all_rules.each do |rule_opt|
+ - rule_opt_name = on_unit ? (rule_opt.alternate_name.presence || rule_opt.name_with_verb) : rule_opt.name_with_verb
+ %option{value: rule_opt.id, selected: rule_opt.id == req.rule_id, data: { variable: rule_opt.variable_requirement?.to_s, 'variable-type': rule_opt.variable_input_type.to_s, 'variable-options': rule_opt.variable_options.to_json, 'variable-label': rule_opt.variable_label.to_s }}= rule_opt_name
+ %button.btn.btn-sm.btn-outline-danger{type: 'button', data: { action: 'requirements#removeReq' }} Remove
+ .mt-1{ data: { 'req-variable-container': '', 'variable-name': "#{base_name}[requirements_attributes][#{req_i}]" }, hidden: !rule.variable_requirement? }
+ - if rule.variable_requirement?
+ - variable_errors = req.errors[:variable]
+ - if rule.variable_label.present?
+ %label.form-label.mb-1= rule.variable_label
+ - case rule.variable_input_type
+ - when 'select'
+ %select.form-select.select2{ name: "#{base_name}[requirements_attributes][#{req_i}][variable]", class: ('is-invalid' if variable_errors.any?), data: { 'req-variable': '' } }
+ %option{value: ''} Select...
+ - rule.variable_options.each do |val, label|
+ %option{value: val, selected: val.to_s == req.variable.to_s}= label
+ - when 'multi-select'
+ - current_values = req.variable.to_s.split(',')
+ %select.form-select.select2{ multiple: true, class: ('is-invalid' if variable_errors.any?), data: { 'req-multi-display': '' } }
+ - rule.variable_options.each do |val, label|
+ %option{value: val, selected: current_values.include?(val.to_s)}= label
+ %input{type: 'hidden', name: "#{base_name}[requirements_attributes][#{req_i}][variable]", value: req.variable, data: { 'req-variable': '' }}
+ - when 'number'
+ %input.form-control{ type: 'number', name: "#{base_name}[requirements_attributes][#{req_i}][variable]", value: req.variable, placeholder: 'Value', class: ('is-invalid' if variable_errors.any?), data: { 'req-variable': '' }}
+ - else
+ %input.form-control{ type: 'text', name: "#{base_name}[requirements_attributes][#{req_i}][variable]", value: req.variable, placeholder: 'Value', class: ('is-invalid' if variable_errors.any?), data: { 'req-variable': '' }}
+ - if variable_errors.any?
+ .invalid-feedback.d-block= variable_errors.first
+ %template{data: { 'req-template': '' }}
+ .mb-2{data: { 'req-entry': '' }}
+ .d-flex.gap-2.align-items-center
+ %select.form-select.w-auto.flex-shrink-0{ name: "#{base_name}[requirements_attributes][__REQ__][positive]" }
+ %option{value: 'true'} Must
+ %option{value: 'false'} Can't
+ %select.form-select.select2.flex-grow-1{ name: "#{base_name}[requirements_attributes][__REQ__][rule_id]", data: { 'req-rule-select': '' } }
+ %option{value: ''} Select rule...
+ - all_rules.each do |rule_opt|
+ - rule_opt_name = on_unit ? (rule_opt.alternate_name.presence || rule_opt.name_with_verb) : rule_opt.name_with_verb
+ %option{value: rule_opt.id, data: { variable: rule_opt.variable_requirement?.to_s, 'variable-type': rule_opt.variable_input_type.to_s, 'variable-options': rule_opt.variable_options.to_json, 'variable-label': rule_opt.variable_label.to_s }}= rule_opt_name
+ %button.btn.btn-sm.btn-outline-danger{type: 'button', data: { action: 'requirements#removeReq' }} Remove
+ .mt-1{ data: { 'req-variable-container': '', 'variable-name': "#{base_name}[requirements_attributes][__REQ__]" }, hidden: true }
+ %button.btn.btn-sm.btn-outline-secondary.mt-2{type: 'button', data: { action: 'requirements#addReq' }} + Add Rule
diff --git a/app/views/requirement_manager/_inherited_rules.html.haml b/app/views/requirement_manager/_inherited_rules.html.haml
index 0d44e3b70..1bd124e25 100644
--- a/app/views/requirement_manager/_inherited_rules.html.haml
+++ b/app/views/requirement_manager/_inherited_rules.html.haml
@@ -1,17 +1,23 @@
-%label Inherited Rules
+- unless local_assigns[:show_label] == false
+ %label Inherited Requirements
- if inheritee.inherited_requirements_by_source.values.none?(&:present?)
%p (none)
- else
.table-responsive
%table.table
- %tr
- %th Source
- %th Rule
- - inheritee.inherited_requirements_by_source.each do |requirement_source, requirements|
- - requirements.each do |requirement|
- %tr
- %td= requirement_source.name
- %td
- = requirement.name
- - if requirement.variable.present?
- = requirement.display_for_variable
+ %thead
+ %tr
+ %th Type
+ %th Rule
+ %th Source
+ %tbody
+ - inheritee.inherited_requirements_by_source.each do |requirement_source, requirements|
+ - requirements.each do |requirement|
+ %tr
+ %td
+ %div{class: ['badge', requirement.modal_verb_class]}= requirement.modal_verb
+ %td
+ = requirement.rule_text
+ - if requirement.variable.present?
+ = requirement.display_for_variable
+ %td= requirement_source.name
diff --git a/app/views/rules/active_in_cohorts/_active_in_cohort.haml b/app/views/rules/active_in_cohorts/_active_in_cohort.haml
deleted file mode 100644
index a665933ef..000000000
--- a/app/views/rules/active_in_cohorts/_active_in_cohort.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = active_in_cohort
-%h4 Cohort
-= select_tag(:variable, options_from_collection_for_select(rule.available_cohorts, :first, :last), class: 'variable', name: 'variable', multiple: true, id: "variable_#{rule.id}", data: { options: rule.available_cohorts.to_h.to_json })
diff --git a/app/views/rules/age_greater_than_ies/_age_greater_than_y.haml b/app/views/rules/age_greater_than_ies/_age_greater_than_y.haml
deleted file mode 100644
index fbc07b2d3..000000000
--- a/app/views/rules/age_greater_than_ies/_age_greater_than_y.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = age_greater_than_y
-%h4 Age (years)
-= text_field_tag(:variable, 18, class: 'variable form-control', id: "variable_#{rule.id}", type: 'number')
diff --git a/app/views/rules/age_greater_than_xes/_age_greater_than_x.haml b/app/views/rules/age_greater_than_xes/_age_greater_than_x.haml
deleted file mode 100644
index 237672d1d..000000000
--- a/app/views/rules/age_greater_than_xes/_age_greater_than_x.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = age_greater_than_x
-%h4 Age (years)
-= text_field_tag(:variable, 18, class: 'variable form-control', id: "variable_#{rule.id}", type: 'number')
diff --git a/app/views/rules/assessment_completed_withins/_assessment_completed_within.haml b/app/views/rules/assessment_completed_withins/_assessment_completed_within.haml
deleted file mode 100644
index a7e1cc97c..000000000
--- a/app/views/rules/assessment_completed_withins/_assessment_completed_within.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = assessment_completed_within
-%h4 Range
-= select_tag(:variable, options_from_collection_for_select(rule.available_options, :first, :last), class: 'variable', id: "variable_#{rule.id}", data: {options: rule.available_options.to_h.to_json})
diff --git a/app/views/rules/assessment_score_greater_than_specifieds/_assessment_score_greater_than_specified.haml b/app/views/rules/assessment_score_greater_than_specifieds/_assessment_score_greater_than_specified.haml
deleted file mode 100644
index 357bc3f33..000000000
--- a/app/views/rules/assessment_score_greater_than_specifieds/_assessment_score_greater_than_specified.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = assessment_score_greater_than_specified
-%h4 Score
-= select_tag(:variable, options_from_collection_for_select(rule.available_scores, :first, :last), class: 'variable', id: "variable_#{rule.id}", data: {options: rule.available_scores.to_h.to_json})
diff --git a/app/views/rules/bedroom_exacts/_bedroom_exact.haml b/app/views/rules/bedroom_exacts/_bedroom_exact.haml
deleted file mode 100644
index 4089dfc1d..000000000
--- a/app/views/rules/bedroom_exacts/_bedroom_exact.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = bedroom_exact
-%h4 Number of Bedrooms
-= select_tag(:variable, options_from_collection_for_select(rule.available_number_of_bedrooms, :first, :last), class: 'variable', id: "variable_#{rule.id}", data: {options: rule.available_number_of_bedrooms.to_h.to_json})
diff --git a/app/views/rules/bedrooms/_bedroom.haml b/app/views/rules/bedrooms/_bedroom.haml
deleted file mode 100644
index a4dc6393f..000000000
--- a/app/views/rules/bedrooms/_bedroom.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = bedroom
-%h4 Number of Bedrooms
-= select_tag(:variable, options_from_collection_for_select(rule.available_number_of_bedrooms, :first, :last), class: 'variable', id: "variable_#{rule.id}", data: {options: rule.available_number_of_bedrooms.to_h.to_json})
diff --git a/app/views/rules/challenges/_challenge.haml b/app/views/rules/challenges/_challenge.haml
deleted file mode 100644
index 0210f1168..000000000
--- a/app/views/rules/challenges/_challenge.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = challenge
-%h4= Translation.translate('Challenges for housing placement')
-= select_tag(:variable, options_from_collection_for_select(rule.available_challenges, :first, :last), class: 'variable', id: "variable_#{rule.id}", name: 'variable', multiple: true, data: {options: rule.available_challenges.to_h.to_json })
diff --git a/app/views/rules/enrolled_in_hmis_project_type_any_ph_no_move_ins/_enrolled_in_hmis_project_type_any_ph_no_move_in.haml b/app/views/rules/enrolled_in_hmis_project_type_any_ph_no_move_ins/_enrolled_in_hmis_project_type_any_ph_no_move_in.haml
deleted file mode 100644
index c3c0a880d..000000000
--- a/app/views/rules/enrolled_in_hmis_project_type_any_ph_no_move_ins/_enrolled_in_hmis_project_type_any_ph_no_move_in.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = enrolled_in_hmis_project_type_any_ph_no_move_in
-%h4 Project Type
-= select_tag(:variable, options_from_collection_for_select(rule.available_project_types, :first, :last), class: 'variable', id: "variable_#{rule.id}", name: 'variable', multiple: true, data: {options: rule.available_project_types.to_h.to_json })
diff --git a/app/views/rules/enrolled_in_hmis_project_types/_enrolled_in_hmis_project_type.haml b/app/views/rules/enrolled_in_hmis_project_types/_enrolled_in_hmis_project_type.haml
deleted file mode 100644
index 67667da94..000000000
--- a/app/views/rules/enrolled_in_hmis_project_types/_enrolled_in_hmis_project_type.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = enrolled_in_hmis_project_type
-%h4 Project Type
-= select_tag(:variable, options_from_collection_for_select(rule.available_project_types, :first, :last), class: 'variable', id: "variable_#{rule.id}", name: 'variable', multiple: true, data: {options: rule.available_project_types.to_h.to_json })
diff --git a/app/views/rules/enrolled_in_hmis_projects/_enrolled_in_hmis_project.haml b/app/views/rules/enrolled_in_hmis_projects/_enrolled_in_hmis_project.haml
deleted file mode 100644
index beedab7b8..000000000
--- a/app/views/rules/enrolled_in_hmis_projects/_enrolled_in_hmis_project.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = enrolled_in_hmis_project
-%h4 Project
-= select_tag(:variable, options_from_collection_for_select(rule.available_projects, :first, :last), class: 'variable', id: "variable_#{rule.id}", name: 'variable', multiple: true, data: {options: rule.available_projects.to_h.to_json })
diff --git a/app/views/rules/has_file_tags/_has_file_tags.haml b/app/views/rules/has_file_tags/_has_file_tags.haml
deleted file mode 100644
index 520320830..000000000
--- a/app/views/rules/has_file_tags/_has_file_tags.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = has_file_tags
-%h4 Required Documentation
-= select_tag(:variable, options_from_collection_for_select(rule.available_tags, :first, :last), class: 'variable', name: 'variable', multiple: true, id: "variable_#{rule.id}", data: { options: rule.available_tags.to_json })
diff --git a/app/views/rules/income_maximums/_income_maximum.haml b/app/views/rules/income_maximums/_income_maximum.haml
deleted file mode 100644
index d47c1e072..000000000
--- a/app/views/rules/income_maximums/_income_maximum.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = income_maximum
-%h4 Maximum Monthly Income
-= text_field_tag(:variable, 100, class: 'variable form-control', id: "variable_#{rule.id}", type: 'number')
diff --git a/app/views/rules/income_minimums/_income_minimum.haml b/app/views/rules/income_minimums/_income_minimum.haml
deleted file mode 100644
index e1ad8b544..000000000
--- a/app/views/rules/income_minimums/_income_minimum.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = income_minimum
-%h4 Minimum Monthly Income
-= text_field_tag(:variable, 100, class: 'variable form-control', id: "variable_#{rule.id}", type: 'number')
diff --git a/app/views/rules/interested_in_neighborhoods/_interested_in_neighborhood.haml b/app/views/rules/interested_in_neighborhoods/_interested_in_neighborhood.haml
deleted file mode 100644
index d97f552be..000000000
--- a/app/views/rules/interested_in_neighborhoods/_interested_in_neighborhood.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = interested_in_neighborhood
-%h4 Neighborhood
-= select_tag(:variable, options_from_collection_for_select(rule.available_neighborhoods, :first, :last), class: 'variable', id: "variable_#{rule.id}", data: {options: rule.available_neighborhoods.to_h.to_json})
diff --git a/app/views/rules/non_hmis_assessment_types/_non_hmis_assessment_type.haml b/app/views/rules/non_hmis_assessment_types/_non_hmis_assessment_type.haml
deleted file mode 100644
index 88ba0b193..000000000
--- a/app/views/rules/non_hmis_assessment_types/_non_hmis_assessment_type.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = non_hmis_assessment_type
-%h4 Assessment Name
-= select_tag(:variable, options_from_collection_for_select(rule.available_assessments, :first, :last), class: 'variable', id: "variable_#{rule.id}", name: 'variable', multiple: true, data: {options: rule.available_assessments.to_h.to_json })
diff --git a/app/views/rules/occupancies/_occupancy.haml b/app/views/rules/occupancies/_occupancy.haml
deleted file mode 100644
index 17d968078..000000000
--- a/app/views/rules/occupancies/_occupancy.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = occupancy
-%h4 Minimum Occupancy
-= select_tag(:variable, options_from_collection_for_select(rule.available_occupancy, :first, :last), class: 'variable', id: "variable_#{rule.id}", data: {options: rule.available_occupancy.to_h.to_json})
diff --git a/app/views/rules/pathways_eligibles/_pathways_eligible.haml b/app/views/rules/pathways_eligibles/_pathways_eligible.haml
deleted file mode 100644
index 8d9e85e34..000000000
--- a/app/views/rules/pathways_eligibles/_pathways_eligible.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = pathways_eligible
-%h4 Route
-= select_tag(:variable, options_from_collection_for_select(rule.available_routes, :first, :last), class: 'variable', id: "variable_#{rule.id}", data: {options: rule.available_routes.to_h.to_json})
diff --git a/app/views/rules/rank_belows/_rank_below.haml b/app/views/rules/rank_belows/_rank_below.haml
deleted file mode 100644
index c262ceed6..000000000
--- a/app/views/rules/rank_belows/_rank_below.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- rule = rank_below
-%h4 Rank
-.w-25
- = text_field_tag(:variable, 100, class: 'variable form-control', id: "variable_#{rule.id}", type: 'number')
diff --git a/app/views/rules/strengths/_strength.haml b/app/views/rules/strengths/_strength.haml
deleted file mode 100644
index dfba63f66..000000000
--- a/app/views/rules/strengths/_strength.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = strength
-%h4 Strengths
-= select_tag(:variable, options_from_collection_for_select(rule.available_strengths, :first, :last), class: 'variable', id: "variable_#{rule.id}", name: 'variable', multiple: true, data: {options: rule.available_strengths.to_h.to_json })
diff --git a/app/views/rules/tagged_withs/_tagged_with.haml b/app/views/rules/tagged_withs/_tagged_with.haml
deleted file mode 100644
index c8354c9dc..000000000
--- a/app/views/rules/tagged_withs/_tagged_with.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = tagged_with
-%h4= Translation.translate('Tag')
-= select_tag(:variable, options_from_collection_for_select(rule.available_tags, :first, :last), class: 'variable', id: "variable_#{rule.id}", data: {options: rule.available_tags.to_h.to_json})
diff --git a/app/views/rules/unsheltered_days/_unsheltered_days.haml b/app/views/rules/unsheltered_days/_unsheltered_days.haml
deleted file mode 100644
index 0cea20965..000000000
--- a/app/views/rules/unsheltered_days/_unsheltered_days.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- rule = unsheltered_days
-%h4 Unsheltered Days
-= select_tag(:variable, options_from_collection_for_select(rule.available_unsheltered_days, :first, :last), class: 'variable', id: "variable_#{rule.id}", data: {options: rule.available_unsheltered_days.to_h.to_json})
diff --git a/app/views/style_guides/_badges.haml b/app/views/style_guides/_badges.haml
new file mode 100644
index 000000000..15a2ab1d1
--- /dev/null
+++ b/app/views/style_guides/_badges.haml
@@ -0,0 +1,22 @@
+%h2.mt-4 Badges
+
+%p.text-muted
+ Bootstrap badges using soft/subtle colors.
+ Class names follow the BS4 convention (.badge-*) aliased from BS5 .text-bg-* with lighter backgrounds.
+
+.d-flex.flex-wrap.gap-3.align-items-start.mb-3
+ - %w[primary secondary success danger warning info light dark].each do |color|
+ .d-flex.flex-column.align-items-center.gap-1
+ %span.badge{ class: "badge-#{color}" }= color.capitalize
+ %code.small .badge-#{color}
+
+%h3.mt-4 In context
+
+%p
+ Badges are used inline with text, e.g. status indicators:
+ %span.badge.badge-success Active
+ %span.badge.badge-warning Awaiting Approval
+ %span.badge.badge-danger Return / Changes Requested
+ %span.badge.badge-info Info
+ %span.badge.badge-primary Primary
+ %span.badge.badge-secondary Secondary
diff --git a/app/views/style_guides/_pages.haml b/app/views/style_guides/_pages.haml
new file mode 100644
index 000000000..4fb81ec5b
--- /dev/null
+++ b/app/views/style_guides/_pages.haml
@@ -0,0 +1,5 @@
+.c-card
+ .c-card__content
+ %ul.nav.flex-column
+ - guide_routes.each do |key, value|
+ %li.nav-item= link_to(value, send("#{key}_style_guides_path"), class: 'nav-link')
diff --git a/app/views/style_guides/dnd_match_review.haml b/app/views/style_guides/dnd_match_review.haml
index e2bd0ffb5..fd608465d 100644
--- a/app/views/style_guides/dnd_match_review.haml
+++ b/app/views/style_guides/dnd_match_review.haml
@@ -2,243 +2,247 @@
-# If we've got a row, set a col div too
-# generally, using h1 for page titles. Site title is no longer h1
-.row
- .col-sm-8
- %h1 Housing Opportunity: Match Progress
- .col-sm-4
- .summary-readout.pull-right
- .summary-readout--tile.summary-readout--tile__green
- .summary-readout--tile-label.summary-readout--tile-label__outside
- Status:
- .summary-readout--tile-color-block
- Pending
- .summary-readout--tile.summary-readout--tile__blue
- .summary-readout--tile-label.summary-readout--tile-label__outside
- Current Step Assigned To:
- .summary-readout--tile-color-block
- Shelter Agency
-.detail-box
- -# These label/value pairs can all be displayed conditionally if present
- .row
- .col-sm-9
+.row.mb-3
+ .col-sm-10
+ .row
+ .col-sm-8
+ %h1 Housing Opportunity: Match Progress
+ .col-sm-4
+ .summary-readout.pull-right
+ .summary-readout--tile.summary-readout--tile__green
+ .summary-readout--tile-label.summary-readout--tile-label__outside
+ Status:
+ .summary-readout--tile-color-block
+ Pending
+ .summary-readout--tile.summary-readout--tile__blue
+ .summary-readout--tile-label.summary-readout--tile-label__outside
+ Current Step Assigned To:
+ .summary-readout--tile-color-block
+ Shelter Agency
+ .detail-box
+ -# These label/value pairs can all be displayed conditionally if present
.row
+ .col-sm-9
+ .row
+ .col-sm-3
+ %h3.detail-box--label Program
+ .detail-box--value
+ %h3.detail-box--label Sub-Grantee
+ .detail-box--value
+ %h3.detail-box--label Sub-Contractor
+ .detail-box--value
+ .col-sm-3
+ %h3.detail-box--label= "#{Building.model_name.human}"
+ .detail-box--value
+ Walnut Park
+ %br
+ 237 Walnut Ave,
+ %br
+ Boston, MA 02119
+ .col-sm-2
+ %h3.detail-box--label Unit
+ .detail-box--value 243-B
+ .col-sm-4
+ %h3.detail-box--label Client
+ .detail-box--value
+ =link_to 'Ronnie Romero', '#'
+ .row
+ .col-sm-5
+ %h3.detail-box--label Services
+ .detail-box--value
+ Addiction Support Services
+ %br
+ Mental Health Services
+ .col-sm-7
+ %h3.detail-box--label Rules
+ .detail-box--value
+ Must be mental health services eligible
.col-sm-3
- %h3.detail-box--label Program
+ %h3.detail-box--label Shelter Agency Contact
.detail-box--value
- %h3.detail-box--label Sub-Grantee
- .detail-box--value
- %h3.detail-box--label Sub-Contractor
- .detail-box--value
- .col-sm-3
- %h3.detail-box--label= "#{Building.model_name.human}"
- .detail-box--value
- Walnut Park
+ Leroy Bass
%br
- 237 Walnut Ave,
+ Pine Street Inn
%br
- Boston, MA 02119
- .col-sm-2
- %h3.detail-box--label Unit
- .detail-box--value 243-B
- .col-sm-4
- %h3.detail-box--label Client
- .detail-box--value
- =link_to 'Ronnie Romero', '#'
- .row
- .col-sm-5
- %h3.detail-box--label Services
- .detail-box--value
- Addiction Support Services
+ =link_to 'leroybass@pinestreetinn.org', 'mailto:leroybass@pinestreetinn.org'
%br
- Mental Health Services
- .col-sm-7
- %h3.detail-box--label Rules
+ 617.555.1212
+ %h3.detail-box--label
+ = "#{Translation.translate('Housing Subsidy Administrator')}"
.detail-box--value
- Must be mental health services eligible
- .col-sm-3
- %h3.detail-box--label Shelter Agency Contact
- .detail-box--value
- Leroy Bass
- %br
- Pine Street Inn
- %br
- =link_to 'leroybass@pinestreetinn.org', 'mailto:leroybass@pinestreetinn.org'
- %br
- 617.555.1212
- %h3.detail-box--label
- = "#{Translation.translate('Housing Subsidy Administrator')}"
- .detail-box--value
- Bertha Carter
- %br
- =link_to 'carter@domain.org', 'mailto:carter@domain.org'
- %br
- 617.333.1212
- .detail-box--link
- =link_to match_contacts_modal_style_guides_path, data: {loads_in_pjax_modal: true} do
- %span.icon-pencil
- Edit Contacts
-.row
- .col-sm-12
- .action-row.pull-right
- = link_to "#", class: 'btn btn-primary', data: {loads_in_pjax_modal: true} do
- %span.icon-plus
- Add Note to Match
-.row
- .col-sm-12
- .current-step
- %h3.current-step--heading
- Current Step:
- %span.current-step--name Shelter Agency Initial Review
- %h5.current-step--assignemnt--label
- Assigned To:
- %span.current-step--assignment--value Shelter Agency
- %p.current-step--introduction
- Placeholder for friendly text about this page and next steps.
+ Bertha Carter
+ %br
+ =link_to 'carter@domain.org', 'mailto:carter@domain.org'
+ %br
+ 617.333.1212
+ .detail-box--link
+ =link_to match_contacts_modal_style_guides_path, data: {loads_in_pjax_modal: true} do
+ %span.icon-pencil
+ Edit Contacts
+ .row
+ .col-sm-12
+ .action-row.pull-right
+ = link_to "#", class: 'btn btn-primary', data: {loads_in_pjax_modal: true} do
+ %span.icon-plus
+ Add Note to Match
+ .row
+ .col-sm-12
+ .current-step
+ %h3.current-step--heading
+ Current Step:
+ %span.current-step--name Shelter Agency Initial Review
+ %h5.current-step--assignemnt--label
+ Assigned To:
+ %span.current-step--assignment--value Shelter Agency
+ %p.current-step--introduction
+ Placeholder for friendly text about this page and next steps.
--# if @decision.editable?
-%form.simple_form.current-step-form
- .row
- .col-sm-12
- .button-row
- = link_to "#", class: 'btn btn-primary has-requirement', data: {loads_in_pjax_modal: true} do
- %span.icon-plus
- Review Contacts for Match
- .requirement-warning
- %span.icon-warning
- Required for match acceptance
- .col-sm-8
- .row.form-inputs
- .col-sm-6
- .form-group.select.optional.notification_response_action
- %label.select.optional.control-label{for: 'notification_response_action'}
- Please select one
- %select.optional.form-control
- %option{value: ''}
- %option{value: 'accepted'}Accept
- %option{value: 'declined'}Decline
+ -# if @decision.editable?
+ %form.simple_form.current-step-form
+ .row
.col-sm-12
- .form-group
- %label.control-label{for: 'exampleInputEmail1'}
- Email
- %p.help-block Use your own email here
- %input{type: 'email', class: 'form-control', id: 'exampleInputEmail1', placeholder: 'Enter email'}
- .form-group.has-error
- %label.control-label{for: 'note'}
- Note
- %p.error-block Please add a note explaining the decision
- %textarea{class: 'form-control', rows: 4, name: 'note'}
- .form-group
- %label.control-label Project, Tenant, or Sponsor-Based?
- .radio
- %label
- %input{type: 'radio', name: 'optionsRadios', id: 'optionsRadio1', value: 'option1', checked: 'checked'}
- Project-Based
- .row
- .col-sm-6.col-sm-offset-1
+ .button-row
+ = link_to "#", class: 'btn btn-primary has-requirement', data: {loads_in_pjax_modal: true} do
+ %span.icon-plus
+ Review Contacts for Match
+ .requirement-warning
+ %span.icon-warning
+ Required for match acceptance
+ .col-sm-8
+ .row.form-inputs
+ .col-sm-6
+ .form-group.select.optional.notification_response_action
+ %label.select.optional.control-label{for: 'notification_response_action'}
+ Please select one
+ %select.optional.form-control
+ %option{value: ''}
+ %option{value: 'accepted'}Accept
+ %option{value: 'declined'}Decline
+ .col-sm-12
+ .form-group
+ %label.control-label{for: 'exampleInputEmail1'}
+ Email
+ %p.help-block Use your own email here
+ %input{type: 'email', class: 'form-control', id: 'exampleInputEmail1', placeholder: 'Enter email'}
+ .form-group.has-error
+ %label.control-label{for: 'note'}
+ Note
+ %p.error-block Please add a note explaining the decision
+ %textarea{class: 'form-control', rows: 4, name: 'note'}
+ .form-group
+ %label.control-label Project, Tenant, or Sponsor-Based?
+ .radio
+ %label
+ %input{type: 'radio', name: 'optionsRadios', id: 'optionsRadio1', value: 'option1', checked: 'checked'}
+ Project-Based
+ .row
+ .col-sm-6.col-sm-offset-1
+ .form-group
+ %label.control-label{for: 'building-name'}= "#{Building.model_name.human}"
+ %input{type: 'text', class: 'form-control', name: 'building-name', id: 'building-name'}
+ .radio
+ %label
+ %input{type: 'radio', name: 'optionsRadios', id: 'optionsRadio2', value: 'option2'}
+ Tenant-Based
+ .radio
+ %label
+ %input{type: 'radio', name: 'optionsRadios', id: 'optionsRadio3', value: 'option3'}
+ Sponsor-Based
+ .form-group
+ %label.control-label Reasons
+ .checkbox
+ %label
+ %input{type: 'checkbox', name: 'optionsRadios', id: 'optionsCheckbox1', value: 'option1', checked: 'checked'}
+ This reason
+ .checkbox
+ %label
+ %input{type: 'checkbox', name: 'optionsRadios', id: 'optionsCheckbox2', value: 'option2'}
+ That reason
+ .form-group
+ %label.control-label{for: 'note-2'}
+ Another Note
+ %textarea{class: 'form-control', name: 'note-2', rows: 4}
+ .form-group
+ .checkbox
+ %label
+ %input{type: 'checkbox', name: 'optionsRadios', id: 'optionsCheckbox4', value: 'option1', checked: 'checked'}
+ We are no longer able to contact this client
.form-group
- %label.control-label{for: 'building-name'}= "#{Building.model_name.human}"
- %input{type: 'text', class: 'form-control', name: 'building-name', id: 'building-name'}
- .radio
- %label
- %input{type: 'radio', name: 'optionsRadios', id: 'optionsRadio2', value: 'option2'}
- Tenant-Based
- .radio
- %label
- %input{type: 'radio', name: 'optionsRadios', id: 'optionsRadio3', value: 'option3'}
- Sponsor-Based
- .form-group
- %label.control-label Reasons
- .checkbox
- %label
- %input{type: 'checkbox', name: 'optionsRadios', id: 'optionsCheckbox1', value: 'option1', checked: 'checked'}
- This reason
- .checkbox
- %label
- %input{type: 'checkbox', name: 'optionsRadios', id: 'optionsCheckbox2', value: 'option2'}
- That reason
- .form-group
- %label.control-label{for: 'note-2'}
- Another Note
- %textarea{class: 'form-control', name: 'note-2', rows: 4}
- .form-group
- .checkbox
- %label
- %input{type: 'checkbox', name: 'optionsRadios', id: 'optionsCheckbox4', value: 'option1', checked: 'checked'}
- We are no longer able to contact this client
- .form-group
- .row
- .col-sm-5.col-sm-offset-1
- %label.control-label{for: 'date-last-saw-client'}When did you last see this client?
- .input-group
- %input{type: 'text', name: 'date-last-saw-client', id: 'date-last-saw-client', class: 'form-control'}
- %span.input-group-text.icon-calendar
+ .row
+ .col-sm-5.col-sm-offset-1
+ %label.control-label{for: 'date-last-saw-client'}When did you last see this client?
+ .input-group
+ %input{type: 'text', name: 'date-last-saw-client', id: 'date-last-saw-client', class: 'form-control'}
+ %span.input-group-text.icon-calendar
- .form-actions
- %p Forms that have two possible actions have a btn-primary (blue) and a btn-secondary (gray).
- =link_to 'Decline Match', '#', {class: 'btn btn-danger'}
- =link_to 'Accept Match', '#', {class: 'btn btn-success margin-left-01'}
- .form-actions
- %p Cancel buttons and toggle switches (e.g. must/can't toggle on rules) use btn-light (white).
- %input{type: 'submit', name: 'commit', value: 'Respond', class: 'btn btn-light'}
+ .form-actions
+ %p Forms that have two possible actions have a btn-primary (blue) and a btn-secondary (gray).
+ =link_to 'Decline Match', '#', {class: 'btn btn-danger'}
+ =link_to 'Accept Match', '#', {class: 'btn btn-success margin-left-01'}
+ .form-actions
+ %p Cancel buttons and toggle switches (e.g. must/can't toggle on rules) use btn-light (white).
+ %input{type: 'submit', name: 'commit', value: 'Respond', class: 'btn btn-light'}
-.row
- -# If we've got a .row, we want a .col-x-y, too, so everything aligns vertically
- .col-sm-12
- %h3.history History
- .table-responsive
- %table.table.table-condensed.table-striped
- %tbody
- %tr
- %td.history--date 3/28/2016
- %td.history--event-name Housing Subsidy Administrator noted criminal background hearing date: 4/8/2016
- %td.history--contact-name Bertha Carter
- %tr
- %td.history--date 3/25/2016
- %td.history--event-name
- Shelter Agency accepted match recommendation
- .history--event-note
- %strong Note:
- Mr. Romero indicates he is very interested in this opportunity.
- %td.history--contact-name Leroy Bass
- %tr
- %td.history--date 3/24/2016
- %td.history--event-name Match recommendation acknowledged by Shelter Agency
- %td.history--contact-name Leroy Bass
- %tr
- %td.history--date 3/24/2016
- %td.history--event-name
- Match Notification Sent to HSA
- %br
- Match Notification Sent to Shelter Agency
- %br
- Match Notification Sent to Client
- %td.history--contact-name
- Bertha Carter
- %br
- Leroy Bass
- %br
- Ronnie Romero
- %tr
- %td.history--date 3/24/2016
- %td.history--event-name
- DND Accepted Match Recommendation
- .history--event-note
- %strong Note:
- There will be no housing search worker involved in this match.
- .history--event-note--actions
- -# These are available only if it's your own note
- =link_to '#' do
- %span.icon-pencil
- Edit Note
- =link_to '#' do
- %span.icon-cross
- Delete Note
- %td.history--contact-name Laila Bernstein
- %tr
- %td.history--date 3/23/2016
- %td.history--event-name System Match Recommendation
- %td.history--contact-name
- =link_to 'View other matches for this opportunity','#'
+ .row
+ -# If we've got a .row, we want a .col-x-y, too, so everything aligns vertically
+ .col-sm-12
+ %h3.history History
+ .table-responsive
+ %table.table.table-condensed.table-striped
+ %tbody
+ %tr
+ %td.history--date 3/28/2016
+ %td.history--event-name Housing Subsidy Administrator noted criminal background hearing date: 4/8/2016
+ %td.history--contact-name Bertha Carter
+ %tr
+ %td.history--date 3/25/2016
+ %td.history--event-name
+ Shelter Agency accepted match recommendation
+ .history--event-note
+ %strong Note:
+ Mr. Romero indicates he is very interested in this opportunity.
+ %td.history--contact-name Leroy Bass
+ %tr
+ %td.history--date 3/24/2016
+ %td.history--event-name Match recommendation acknowledged by Shelter Agency
+ %td.history--contact-name Leroy Bass
+ %tr
+ %td.history--date 3/24/2016
+ %td.history--event-name
+ Match Notification Sent to HSA
+ %br
+ Match Notification Sent to Shelter Agency
+ %br
+ Match Notification Sent to Client
+ %td.history--contact-name
+ Bertha Carter
+ %br
+ Leroy Bass
+ %br
+ Ronnie Romero
+ %tr
+ %td.history--date 3/24/2016
+ %td.history--event-name
+ DND Accepted Match Recommendation
+ .history--event-note
+ %strong Note:
+ There will be no housing search worker involved in this match.
+ .history--event-note--actions
+ -# These are available only if it's your own note
+ =link_to '#' do
+ %span.icon-pencil
+ Edit Note
+ =link_to '#' do
+ %span.icon-cross
+ Delete Note
+ %td.history--contact-name Laila Bernstein
+ %tr
+ %td.history--date 3/23/2016
+ %td.history--event-name System Match Recommendation
+ %td.history--contact-name
+ =link_to 'View other matches for this opportunity','#'
+ .col-sm-2
+ = render 'pages'
diff --git a/app/views/style_guides/form.haml b/app/views/style_guides/form.haml
index 6e23a3e2a..e71a9f590 100644
--- a/app/views/style_guides/form.haml
+++ b/app/views/style_guides/form.haml
@@ -28,49 +28,53 @@
- form_defaults = { required: false }
.container.py-4
- %h1 Form
- %section
- = simple_form_for @form, as: :mock_2, url: '#', defaults: form_defaults do |f|
- .row
- .col-6
- %h4 Default input
- = f.input :first_name
+ .row
+ .col-sm-10
+ %h1 Form
+ %section
+ = simple_form_for @form, as: :mock_2, url: '#', defaults: form_defaults do |f|
+ .row
+ .col-6
+ %h4 Default input
+ = f.input :first_name
- %h4 Required with validation
- = f.input :last_name, required: true, **validate_html
+ %h4 Required with validation
+ = f.input :last_name, required: true, **validate_html
- %h4 Disabled
- = f.input :disabled, disabled: true
+ %h4 Disabled
+ = f.input :disabled, disabled: true
- %h4 Number
- = f.input :random_number, as: :numeric, required: true, **validate_html
+ %h4 Number
+ = f.input :random_number, as: :numeric, required: true, **validate_html
- %h4 Normal Select
- = f.input :sel, collection: ['Choice 1', 'Choice 2']
+ %h4 Normal Select
+ = f.input :sel, collection: ['Choice 1', 'Choice 2']
- %h4 Select 2
- = f.input :select2, as: :select2, collection: ['Choice 1', 'Choice 2']
+ %h4 Select 2
+ = f.input :select2, as: :select2, collection: ['Choice 1', 'Choice 2']
- %h4 Select 2 with select all/none
- = f.input :select2_all_none, as: :select2, collection: ['Choice 3', 'Choice 4', 'Choice 5'], input_html: { multiple: true }, hint: 'Hint goes here. Really long hint. It\'s so long, it keeps going, and going, and going, and going.'
- = f.input :select2_all_none2, as: :select2, collection: ['Choice 3', 'Choice 4', 'Choice 5'], input_html: { multiple: true }, label: 'No Hint'
+ %h4 Select 2 with select all/none
+ = f.input :select2_all_none, as: :select2, collection: ['Choice 3', 'Choice 4', 'Choice 5'], input_html: { multiple: true }, hint: 'Hint goes here. Really long hint. It\'s so long, it keeps going, and going, and going, and going.'
+ = f.input :select2_all_none2, as: :select2, collection: ['Choice 3', 'Choice 4', 'Choice 5'], input_html: { multiple: true }, label: 'No Hint'
- %h4 Password
- = f.input :password, as: :password, required: true, **validate_html
+ %h4 Password
+ = f.input :password, as: :password, required: true, **validate_html
- %h4 Textarea Input
- = f.input :textarea, as: :text, required: true, **validate_html
+ %h4 Textarea Input
+ = f.input :textarea, as: :text, required: true, **validate_html
- %h4 Date picker Input
- = f.input :date, as: :date_picker, value: '2010-04-25'
+ %h4 Date picker Input
+ = f.input :date, as: :date_picker, value: '2010-04-25'
- .row
- .col-12
- %section
- %h4 Submit
- = f.submit value: 'Create', class: 'btn btn-primary'
- = f.submit value: 'Cancel', class: 'btn btn-secondary'
- = f.submit value: 'Breaking Change', class: 'btn btn-warning'
- = f.submit value: 'Delete (Descructive)', class: 'btn btn-danger'
+ .row
+ .col-12
+ %section
+ %h4 Submit
+ = f.submit value: 'Create', class: 'btn btn-primary'
+ = f.submit value: 'Cancel', class: 'btn btn-secondary'
+ = f.submit value: 'Breaking Change', class: 'btn btn-warning'
+ = f.submit value: 'Delete (Descructive)', class: 'btn btn-danger'
- %section
+ %section
+ .col-sm-2
+ = render 'pages'
diff --git a/app/views/style_guides/icon_font.haml b/app/views/style_guides/icon_font.haml
index 587c17ec3..3676b8c40 100644
--- a/app/views/style_guides/icon_font.haml
+++ b/app/views/style_guides/icon_font.haml
@@ -132,3 +132,5 @@
.col-sm-3.mb-6
%i{ class: "icon-#{icon}" }
%pre= ".icon-#{icon}"
+ .col-sm-2
+ = render 'pages'
diff --git a/app/views/style_guides/index.haml b/app/views/style_guides/index.haml
index 2f1b46e6e..d33970fc7 100644
--- a/app/views/style_guides/index.haml
+++ b/app/views/style_guides/index.haml
@@ -1,8 +1,3 @@
.container.py-4
%h1 Style Guide
- %ul
- %li= link_to 'Conact Modal', match_contacts_modal_style_guides_path
- %li= link_to 'DND Match Review', dnd_match_review_style_guides_path
- %li= link_to 'Forms', form_style_guides_path
- %li= link_to 'Icon Font', icon_font_style_guides_path
- %li= link_to 'Summary', summary_style_guides_path
+ = render 'pages'
diff --git a/app/views/style_guides/pagination.haml b/app/views/style_guides/pagination.haml
index 3c7f3ba6b..1d38c6a93 100644
--- a/app/views/style_guides/pagination.haml
+++ b/app/views/style_guides/pagination.haml
@@ -1 +1,5 @@
-= render 'pagination'
+.row
+ .col-sm-10
+ = render 'pagination'
+ .col-sm-2
+ = render 'pages'
diff --git a/app/views/style_guides/stepped_progress.haml b/app/views/style_guides/stepped_progress.haml
index 955d1ec65..1e893d4be 100644
--- a/app/views/style_guides/stepped_progress.haml
+++ b/app/views/style_guides/stepped_progress.haml
@@ -1 +1,5 @@
-= render 'stepped_progress'
+.row
+ .col-sm-10
+ = render 'stepped_progress'
+ .col-sm-2
+ = render 'pages'
diff --git a/app/views/style_guides/summary.haml b/app/views/style_guides/summary.haml
index 8135eac29..526077827 100644
--- a/app/views/style_guides/summary.haml
+++ b/app/views/style_guides/summary.haml
@@ -1,14 +1,21 @@
-.mt-4
- = render 'tags'
+.row
+ .col-sm-10
+ .mt-4
+ = render 'badges'
-.mt-4
- = render 'typography'
+ .mt-4
+ = render 'tags'
-.mt-4.mb-8
- = render 'stepped_progress'
+ .mt-4
+ = render 'typography'
-.mt-4.mb-8
- = render 'buttons'
+ .mt-4.mb-8
+ = render 'stepped_progress'
-.mt-4
- = render 'alerts'
+ .mt-4.mb-8
+ = render 'buttons'
+
+ .mt-4
+ = render 'alerts'
+ .col-sm-2
+ = render 'pages'
diff --git a/app/views/style_guides/tags.haml b/app/views/style_guides/tags.haml
index 945ecbd7d..5536447d0 100644
--- a/app/views/style_guides/tags.haml
+++ b/app/views/style_guides/tags.haml
@@ -1 +1,5 @@
-= render 'tags'
+.row
+ .col-sm-10
+ = render 'tags'
+ .col-sm-2
+ = render 'pages'
diff --git a/app/views/style_guides/typography.haml b/app/views/style_guides/typography.haml
index 8ef14ef50..5ec1a6363 100644
--- a/app/views/style_guides/typography.haml
+++ b/app/views/style_guides/typography.haml
@@ -1 +1,5 @@
-= render 'typography'
+.row
+ .col-sm-10
+ = render 'typography'
+ .col-sm-2
+ = render 'pages'
diff --git a/app/views/units/_form.html.haml b/app/views/units/_form.html.haml
index 44839c93a..449d648b3 100644
--- a/app/views/units/_form.html.haml
+++ b/app/views/units/_form.html.haml
@@ -22,8 +22,10 @@
= render 'housing_attributes/attributes', housing_object: @unit.building, mode: 'read', heading: 'Default Building Attributes', subheading: 'The following attributes will be applied on unit creation'
- if @unit.persisted?
= render 'housing_attributes/attributes', housing_object: @unit, mode: 'edit'
+ = render 'housing_attributes/amenities', housing_object: @unit, mode: 'edit'
= render 'housing_media_links/links', housing_object: @unit, mode: 'edit'
= render 'requirement_manager/form_fields', form: f, selected_requirements_heading: "Rules for this #{Unit.model_name.human}", on_unit: true
+ = f.input :notes, as: :text, label: 'Notes', hint: 'Notes about this unit'
.form-actions
= f.button :submit
diff --git a/app/views/vacancy_submissions/_form.html.haml b/app/views/vacancy_submissions/_form.html.haml
new file mode 100644
index 000000000..9c4bbba71
--- /dev/null
+++ b/app/views/vacancy_submissions/_form.html.haml
@@ -0,0 +1,58 @@
+- editing = vacancy_submission.persisted?
+- initial_program = vacancy_submission.program_id || 0
+- initial_sub = vacancy_submission.sub_program_id || 0
+- stimulus_data = { controller: 'vacancy-submission-form', 'vacancy-submission-form-programs-value' => @programs_data.to_json, 'vacancy-submission-form-initial-program-value' => initial_program, 'vacancy-submission-form-initial-sub-program-value' => initial_sub, 'vacancy-submission-form-vacancy-submission-id-value' => vacancy_submission.id.to_i }
+
+= simple_form_for vacancy_submission, html: { id: 'vacancy_submission_form', data: stimulus_data } do |f|
+ = f.error_notification
+
+ %p.text-muted.small
+ Fields marked with
+ %span.text-danger *
+ are required.
+
+ / ── Section 1: Route & Program ──
+ .c-card.mb-4
+ .c-card__header
+ %h2 Route & Program
+ .c-card__content
+ = f.input :program_id, label: 'Program', as: :select2,
+ collection: @programs.map { |p| [p.name, p.id] },
+ include_blank: 'Select a program',
+ required: true,
+ input_html: { data: { 'vacancy-submission-form-target': 'programSelect' } }
+
+ = f.input :sub_program_id, label: 'Sub-Program', as: :select2,
+ collection: [],
+ include_blank: false,
+ input_html: { disabled: true, data: { 'vacancy-submission-form-target': 'subProgramSelect' } }
+
+ %div{data: { 'vacancy-submission-form-target': 'routeContainer' }}
+
+ / ── Inherited Requirements ──
+ .c-card.mb-4
+ .c-card__header
+ %h2 Inherited Requirements
+ .c-card__content
+ %div{data: { 'vacancy-submission-form-target': 'requirementsContainer' }}
+ %p.text-muted Select a sub-program to see inherited requirements.
+
+ / ── Required Documents ──
+ %div{data: { 'vacancy-submission-form-target': 'documentsContainer', 'forward-params': { required_document_names: Array(vacancy_submission.required_document_names) }.to_json }}
+
+ / ── Section 2: Vacancy ──
+ - units_errors = vacancy_submission.errors[:units]
+ %div{data: { 'vacancy-submission-form-target': 'vacancyContainer', 'field-errors': units_errors.to_json, 'forward-params': { units: Array(vacancy_submission.units) }.to_json }}
+ %p.text-muted Select a sub-program to see unit type information.
+
+ / ── General Notes ──
+ .c-card.mb-4
+ .c-card__header
+ %h2 General Notes
+ .c-card__content
+ = f.input :notes, label: 'Additional Notes — Screening Criteria, Special Eligibility & Relevant Info', as: :text, input_html: { rows: 4, placeholder: 'Enter any additional notes about this vacancy submission...' }, hint: 'Please describe any screening criteria, special eligibility requirements, or other information relevant to this submission.'
+
+ / Actions
+ .d-flex.gap-2
+ = f.submit (editing ? 'Save Changes' : 'Submit Vacancy'), class: 'btn btn-primary'
+ = link_to 'Cancel', (editing ? vacancy_submission_path(vacancy_submission) : vacancy_submissions_path), class: 'btn btn-outline-secondary'
diff --git a/app/views/vacancy_submissions/_required_documents.html.haml b/app/views/vacancy_submissions/_required_documents.html.haml
new file mode 100644
index 000000000..569b44abf
--- /dev/null
+++ b/app/views/vacancy_submissions/_required_documents.html.haml
@@ -0,0 +1,11 @@
+- if sub_program.file_tags.any?
+ .c-card.mb-4
+ .c-card__header
+ %h2 Required Documents
+ .c-card__content
+ %p.text-muted.small Select any documents a client must have before they can move in.
+ - checked_names = Array(vacancy_submission.required_document_names)
+ - sub_program.file_tags.order(:name).each do |tag|
+ .form-check.mb-2
+ %input.form-check-input{ type: 'checkbox', name: 'vacancy_submission[required_document_names][]', value: tag.name, id: "required_document_#{tag.id}", checked: checked_names.include?(tag.name) }
+ %label.form-check-label{ for: "required_document_#{tag.id}" }= tag.name
diff --git a/app/views/vacancy_submissions/_return_modal.html.haml b/app/views/vacancy_submissions/_return_modal.html.haml
new file mode 100644
index 000000000..fdd3ba9aa
--- /dev/null
+++ b/app/views/vacancy_submissions/_return_modal.html.haml
@@ -0,0 +1,17 @@
+.modal.fade#return-modal{tabindex: '-1', aria: {labelledby: 'return-modal-label', hidden: 'true'}, data: { controller: 'return-modal' }}
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %h5.modal-title#return-modal-label Return for Changes
+ %button.btn-close{type: 'button', "data-bs-dismiss": "modal", aria: {label: 'Close'}}
+ = form_with url: return_submission_vacancy_submission_path(@submission), method: :post, local: true do |f|
+ .modal-body
+ .mb-3
+ = f.label :body, 'Note for submitter', class: 'form-label'
+ %span.text-danger *
+ = f.text_area :body, class: 'form-control', rows: 4,
+ data: { 'return-modal-target': 'textarea', action: 'input->return-modal#onInput' }
+ .modal-footer
+ %button.btn.btn-outline-secondary{type: 'button', "data-bs-dismiss": "modal"} Cancel
+ = f.submit 'Return for Changes', class: 'btn btn-warning', disabled: true,
+ data: { 'return-modal-target': 'submitButton' }
diff --git a/app/views/vacancy_submissions/_route.html.haml b/app/views/vacancy_submissions/_route.html.haml
new file mode 100644
index 000000000..74a07ebad
--- /dev/null
+++ b/app/views/vacancy_submissions/_route.html.haml
@@ -0,0 +1,3 @@
+.form-group
+ %label.control-label Route
+ .form-control-plaintext.text-muted= route
diff --git a/app/views/vacancy_submissions/_search_form.html.haml b/app/views/vacancy_submissions/_search_form.html.haml
new file mode 100644
index 000000000..4dbeb4ac9
--- /dev/null
+++ b/app/views/vacancy_submissions/_search_form.html.haml
@@ -0,0 +1,7 @@
+= form_tag(vacancy_submissions_path, method: :get) do
+ .d-flex.flex-wrap.gap-2.align-items-center.mb-3
+ - status_options = [['Queue (Awaiting + Changes Requested)', 'queue'], ['All Statuses', 'all']] + VacancySubmissionsHelper::STATUS_DISPLAY.map { |k, v| [v, k] }
+ = select_tag :status, options_for_select(status_options, params[:status].presence || 'queue'), class: 'form-select', style: 'width: auto;'
+ = select_tag :program_id, options_for_select([['All Programs', '']] + @programs, params[:program_id]), class: 'select2', style: 'width: 260px;'
+ %button.btn.btn-primary{type: :submit} Update Filter
+= render 'init_select2'
diff --git a/app/views/vacancy_submissions/_unit_accessibility.html.haml b/app/views/vacancy_submissions/_unit_accessibility.html.haml
new file mode 100644
index 000000000..9fe1fb48e
--- /dev/null
+++ b/app/views/vacancy_submissions/_unit_accessibility.html.haml
@@ -0,0 +1,16 @@
+- readonly = local_assigns.fetch(:readonly, false)
+- if readonly
+ - checked = Array(unit['accessibility']).select(&:present?)
+ - if checked.any?
+ %dl.row.g-2.mb-3
+ .col-sm-12
+ %dt.vs-field-label Accessibility
+ %dd.vs-field-value.mb-0= checked.join(', ')
+- else
+ %fieldset.mb-4
+ %legend Accessibility
+ .d-flex.flex-wrap.gap-3
+ - VacancySubmission::ACCESSIBILITY_OPTIONS.each_key do |option|
+ .form-check
+ %input.form-check-input{ type: 'checkbox', name: "vacancy_submission[units][#{index}][accessibility][]", value: option, id: "unit_#{index}_accessibility_#{option.parameterize}", checked: Array(unit['accessibility']).include?(option) }
+ %label.form-check-label{ for: "unit_#{index}_accessibility_#{option.parameterize}" }= option
diff --git a/app/views/vacancy_submissions/_unit_address.html.haml b/app/views/vacancy_submissions/_unit_address.html.haml
new file mode 100644
index 000000000..2fd941242
--- /dev/null
+++ b/app/views/vacancy_submissions/_unit_address.html.haml
@@ -0,0 +1,39 @@
+- readonly = local_assigns.fetch(:readonly, false)
+- default_building_id = local_assigns.fetch(:default_building_id, nil)
+- confidential = local_assigns.fetch(:confidential, false)
+- if readonly
+ - building = Building.find_by(id: unit['building_id'])
+ %dl.row.g-2.mb-3
+ .col-sm-6
+ %dt.vs-field-label Building
+ %dd.vs-field-value.mb-0
+ - if building
+ = building.name
+ %br/
+ = building.building_address.join(', ').presence || '—'
+ - else
+ = '—'
+ .col-sm-6
+ %dt.vs-field-label Unit Number
+ %dd.vs-field-value.mb-0= unit['unit_number'].presence || '—'
+- else
+ - selected_building_id = unit['building_id'].presence || default_building_id
+ %fieldset.mb-4{data: { controller: 'vacancy-unit-building' }}
+ %legend
+ Unit Address
+ .mb-3
+ %label.form-label.fw-semibold
+ Building
+ %abbr{title: 'required'} *
+ %select.form-select.select2{ name: "vacancy_submission[units][#{index}][building_id]", required: true, style: 'width: 100%;', data: { 'vacancy-unit-building-target': 'select', action: 'change->vacancy-unit-building#render' } }
+ %option{value: ''} Select a building…
+ = vacancy_building_select_options(selected_building_id)
+ .mt-2.small{data: { 'vacancy-unit-building-target': 'display' }, hidden: true}
+ .mb-3
+ - if confidential
+ .alert.alert-warning.py-2.small
+ This program is confidential, do not enter a real address as the unit number
+ %label.form-label
+ Unit Number
+ %abbr{title: 'required'} *
+ %input.form-control{ name: "vacancy_submission[units][#{index}][unit_number]", value: unit['unit_number'], placeholder: 'Number', required: true }
diff --git a/app/views/vacancy_submissions/_unit_amenities.html.haml b/app/views/vacancy_submissions/_unit_amenities.html.haml
new file mode 100644
index 000000000..bf65c4fd4
--- /dev/null
+++ b/app/views/vacancy_submissions/_unit_amenities.html.haml
@@ -0,0 +1,16 @@
+- readonly = local_assigns.fetch(:readonly, false)
+- if readonly
+ - checked = Array(unit['amenities']).select(&:present?)
+ - if checked.any?
+ %dl.g-2.mb-3
+ %dt.vs-field-label Amenities
+ %dd.vs-field-value.mb-2= checked.join(', ')
+- else
+ - if amenity_names.any?
+ %fieldset.mb-4
+ %legend Amenities
+ .d-flex.flex-wrap.gap-3
+ - amenity_names.each do |amenity|
+ .form-check
+ %input.form-check-input{ type: 'checkbox', name: "vacancy_submission[units][#{index}][amenities][]", value: amenity, id: "unit_#{index}_amenity_#{amenity.parameterize}", checked: Array(unit['amenities']).include?(amenity) }
+ %label.form-check-label{ for: "unit_#{index}_amenity_#{amenity.parameterize}" }= amenity
diff --git a/app/views/vacancy_submissions/_unit_attributes.html.haml b/app/views/vacancy_submissions/_unit_attributes.html.haml
new file mode 100644
index 000000000..6080dda47
--- /dev/null
+++ b/app/views/vacancy_submissions/_unit_attributes.html.haml
@@ -0,0 +1,36 @@
+- readonly = local_assigns.fetch(:readonly, false)
+- if readonly
+ - attrs = Array(unit['attributes']).select { |a| a['name'].present? }
+ - if attrs.any?
+ %dl.g-2.mb-3
+ %dt.vs-field-label Attributes
+ %dd.vs-field-value.mb-2
+ %ul.mb-0.ps-4
+ - attrs.each do |attr|
+ %li= "#{attr['name']}: #{attr['value']}"
+- else
+ %fieldset.mb-4{data: { controller: 'vacancy-unit-attributes', 'unit-index': index, 'attribute-catalog': attr_catalog.to_json }}
+ %legend Attributes
+ %div{data: { 'vacancy-unit-attributes-target': 'list' }}
+ - Array(unit['attributes']).each_with_index do |attr, attr_i|
+ - saved_values = attr_catalog[attr['name']] || []
+ .d-flex.gap-2.align-items-start.mb-2{data: { 'attr-entry': '' }}
+ %select.form-select.select2{ name: "vacancy_submission[units][#{index}][attributes][#{attr_i}][name]", data: { tags: true, 'attr-name-select': true } }
+ %option{value: ''} Select name...
+ - attr_names.each do |n|
+ %option{value: n, selected: attr['name'] == n}= n
+ %select.form-select.select2{ name: "vacancy_submission[units][#{index}][attributes][#{attr_i}][value]", data: { tags: true, 'attr-value-select': true } }
+ %option{value: ''} Select value...
+ - saved_values.each do |v|
+ %option{value: v, selected: attr['value'] == v}= v
+ %button.btn.btn-sm.btn-outline-danger.mt-1{type: 'button', data: { action: 'vacancy-unit-attributes#removeAttribute' }} Remove
+ %template{data: { 'attr-template': '' }}
+ .d-flex.gap-2.align-items-start.mb-2{data: { 'attr-entry': '' }}
+ %select.form-select.select2{ name: 'vacancy_submission[units][__UNIT_INDEX__][attributes][__ATTR_INDEX__][name]', data: { tags: true, 'attr-name-select': true } }
+ %option{value: ''} Select name...
+ - attr_names.each do |n|
+ %option{value: n}= n
+ %select.form-select.select2{ name: 'vacancy_submission[units][__UNIT_INDEX__][attributes][__ATTR_INDEX__][value]', data: { tags: true, 'attr-value-select': true } }
+ %option{value: ''} Select value...
+ %button.btn.btn-sm.btn-outline-danger.mt-1{type: 'button', data: { action: 'vacancy-unit-attributes#removeAttribute' }} Remove
+ %button.btn.btn-sm.btn-outline-secondary.mt-2{type: 'button', data: { action: 'vacancy-unit-attributes#addAttribute' }} + Add Attribute
diff --git a/app/views/vacancy_submissions/_unit_details.html.haml b/app/views/vacancy_submissions/_unit_details.html.haml
new file mode 100644
index 000000000..b3e54f658
--- /dev/null
+++ b/app/views/vacancy_submissions/_unit_details.html.haml
@@ -0,0 +1,48 @@
+- readonly = local_assigns.fetch(:readonly, false)
+- if readonly
+ %dl.g-2.mb-3
+ %dt.vs-field-label Date Ready
+ %dd.vs-field-value.mb-2= unit['date_ready'].presence || '—'
+ %dt.vs-field-label Age Limit
+ %dd.vs-field-value.mb-2= unit['age_limit'].presence || '—'
+ %dt.vs-field-label Bedrooms
+ %dd.vs-field-value.mb-2= unit['bedrooms'].presence || '—'
+ - shared = Array(unit['shared_spaces']).select(&:present?)
+ - if shared.any?
+ %dt.vs-field-label Shared Spaces
+ %dd.vs-field-value.mb-2= shared.join(', ')
+- else
+ %fieldset.mb-4
+ %legend
+ Unit Details
+ .row.mb-3
+ .col-md-6
+ %label.form-label
+ Date unit will be ready
+ %abbr{title: 'required'} *
+ %input.form-control{ type: 'date', name: "vacancy_submission[units][#{index}][date_ready]", value: unit['date_ready'], min: Date.today.iso8601, required: true }
+ .row.mb-3
+ .col-md-6
+ %label.form-label
+ Age limit
+ %abbr{title: 'required'} *
+ %select.form-select.select2{ name: "vacancy_submission[units][#{index}][age_limit]", required: true }
+ %option{value: ''} Select...
+ - VacancySubmission::AGE_LIMIT_OPTIONS.each_key do |opt|
+ %option{value: opt, selected: unit['age_limit'] == opt}= opt
+ .row.mb-3
+ .col-md-6
+ %label.form-label
+ Number of bedrooms
+ %abbr{title: 'required'} *
+ %select.form-select.select2{ name: "vacancy_submission[units][#{index}][bedrooms]", required: true }
+ %option{value: ''} Select...
+ - VacancySubmission::BEDROOM_OPTIONS.each_key do |opt|
+ %option{value: opt, selected: unit['bedrooms'] == opt}= opt
+ .mb-3
+ %label.form-label Shared spaces
+ .d-flex.gap-4
+ - VacancySubmission::SHARED_SPACE_OPTIONS.each do |space|
+ .form-check
+ %input.form-check-input{ type: 'checkbox', name: "vacancy_submission[units][#{index}][shared_spaces][]", value: space, id: "unit_#{index}_shared_#{space.parameterize}", checked: Array(unit['shared_spaces']).include?(space) }
+ %label.form-check-label{ for: "unit_#{index}_shared_#{space.parameterize}" }= space
diff --git a/app/views/vacancy_submissions/_unit_media_links.html.haml b/app/views/vacancy_submissions/_unit_media_links.html.haml
new file mode 100644
index 000000000..c3c0daf23
--- /dev/null
+++ b/app/views/vacancy_submissions/_unit_media_links.html.haml
@@ -0,0 +1,32 @@
+- readonly = local_assigns.fetch(:readonly, false)
+- if readonly
+ - links = Array(unit['media_links']).select { |l| l['url'].present? }
+ - if links.any?
+ %dl.row.g-2.mb-3
+ .col-12
+ %dt.vs-field-label Photos & Media
+ %dd.vs-field-value.mb-0
+ %ul.mb-0.ps-3
+ - links.each do |link|
+ %li= link_to(link['label'].presence || link['url'], link['url'], target: '_blank', rel: 'noopener')
+- else
+ %fieldset.mb-4{data: { controller: 'vacancy-unit-media-links', 'unit-index': index }}
+ %legend Photos & Media
+ %div{data: { 'vacancy-unit-media-links-target': 'list' }}
+ - Array(unit['media_links']).each_with_index do |link, link_i|
+ .row.g-2.mb-2.align-items-center{data: { 'media-link-entry': '' }}
+ .col-md-5
+ %input.form-control{ type: 'url', name: "vacancy_submission[units][#{index}][media_links][#{link_i}][url]", value: link['url'], placeholder: 'https://' }
+ .col-md-5
+ %input.form-control{ name: "vacancy_submission[units][#{index}][media_links][#{link_i}][label]", value: link['label'], placeholder: 'e.g. Living Room' }
+ .col-md-2
+ %button.btn.btn-sm.btn-outline-danger{type: 'button', data: { action: 'vacancy-unit-media-links#removeLink' }} Remove
+ %template{data: { 'media-link-template': '' }}
+ .row.g-2.mb-2.align-items-center{data: { 'media-link-entry': '' }}
+ .col-md-5
+ %input.form-control{ type: 'url', name: 'vacancy_submission[units][__UNIT_INDEX__][media_links][__LINK_INDEX__][url]', placeholder: 'https://' }
+ .col-md-5
+ %input.form-control{ name: 'vacancy_submission[units][__UNIT_INDEX__][media_links][__LINK_INDEX__][label]', placeholder: 'e.g. Living Room' }
+ .col-md-2
+ %button.btn.btn-sm.btn-outline-danger{type: 'button', data: { action: 'vacancy-unit-media-links#removeLink' }} Remove
+ %button.btn.btn-sm.btn-outline-secondary.mt-2{type: 'button', data: { action: 'vacancy-unit-media-links#addLink' }} + Add Photo or Media Link
diff --git a/app/views/vacancy_submissions/_unit_requirements.html.haml b/app/views/vacancy_submissions/_unit_requirements.html.haml
new file mode 100644
index 000000000..8923d325d
--- /dev/null
+++ b/app/views/vacancy_submissions/_unit_requirements.html.haml
@@ -0,0 +1,80 @@
+- readonly = local_assigns.fetch(:readonly, false)
+- on_unit = local_assigns.fetch(:on_unit, false)
+- json_requirements = Array(unit['requirements'])
+- all_rules = local_assigns.fetch(:all_rules, [])
+- if readonly
+ - reqs = json_requirements.select { |r| r['rule_id'].present? }
+ - if reqs.any?
+ %p
+ %strong Requirements
+ .table-responsive
+ %table.table
+ %thead
+ %tr
+ %th Type
+ %th Rule
+ %tbody
+ - rules = Rule.where(id:reqs.map { |r| r['rule_id'] }.compact).index_by(&:id)
+ - reqs.each do |req|
+ - rule = rules[req['rule_id'].to_i]
+ - next unless rule
+ - req_positive = req['positive'].to_s == 'true' || req['positive'] == true
+ - requirement = Requirement.new(rule: rule, positive: req_positive, variable: req['variable'])
+ %tr
+ %td
+ %div{class: ['badge', Requirement.modal_verb_class_for(req_positive)]}= Requirement.modal_verb_for(req_positive)
+ %td
+ = requirement.name
+ = requirement.display_for_variable
+- else
+ %fieldset.mb-4{data: { controller: 'vacancy-unit-requirements' }}
+ %legend Requirements
+ %div{data: { 'vacancy-unit-requirements-target': 'list' }}
+ - json_requirements.each_with_index do |req, req_i|
+ - next if req['rule_id'].blank?
+ - selected_rule = all_rules.find { |r| r.id.to_s == req['rule_id'].to_s }
+ .mb-2{data: { 'req-entry': '' }}
+ .d-flex.gap-2.align-items-center
+ %select.form-select.w-auto.flex-shrink-0{ name: "vacancy_submission[units][#{index}][requirements_attributes][#{req_i}][positive]" }
+ %option{value: 'true', selected: req['positive'].to_s == 'true'} Must
+ %option{value: 'false', selected: req['positive'].to_s != 'true'} Can't
+ %select.form-select.select2.flex-grow-1{ name: "vacancy_submission[units][#{index}][requirements_attributes][#{req_i}][rule_id]", data: { 'req-rule-select': '' } }
+ %option{value: ''} Select rule...
+ - all_rules.each do |rule_opt|
+ - rule_name = on_unit ? (rule_opt.alternate_name.presence || rule_opt.name_with_verb) : rule_opt.name_with_verb
+ %option{value: rule_opt.id, selected: rule_opt.id.to_s == req['rule_id'].to_s, data: { variable: rule_opt.variable_requirement?.to_s, 'variable-type': rule_opt.variable_input_type.to_s, 'variable-options': rule_opt.variable_options.to_json, 'variable-label': rule_opt.variable_label.to_s }}= rule_name
+ %button.btn.btn-sm.btn-outline-danger.mt-1{type: 'button', data: { action: 'vacancy-unit-requirements#removeReq' }} Remove
+ .mt-1{ data: { 'req-variable-container': '', 'variable-name': "vacancy_submission[units][#{index}][requirements_attributes][#{req_i}]" }, hidden: !selected_rule&.variable_requirement? }
+ - if selected_rule&.variable_requirement?
+ - if selected_rule.variable_label.present?
+ %label.form-label.mb-1= selected_rule.variable_label
+ - case selected_rule.variable_input_type
+ - when 'select'
+ %select.form-select.select2{ name: "vacancy_submission[units][#{index}][requirements_attributes][#{req_i}][variable]", required: true, data: { 'req-variable': '' } }
+ %option{value: ''} Select...
+ - selected_rule.variable_options.each do |val, label|
+ %option{value: val, selected: val.to_s == req['variable'].to_s}= label
+ - when 'multi-select'
+ - current_values = req['variable'].to_s.split(',')
+ %select.form-select.select2{ multiple: true, required: true, data: { 'req-multi-display': '' } }
+ - selected_rule.variable_options.each do |val, label|
+ %option{value: val, selected: current_values.include?(val.to_s)}= label
+ %input{type: 'hidden', name: "vacancy_submission[units][#{index}][requirements_attributes][#{req_i}][variable]", value: req['variable'], data: { 'req-variable': '' }}
+ - when 'number'
+ %input.form-control{type: 'number', name: "vacancy_submission[units][#{index}][requirements_attributes][#{req_i}][variable]", value: req['variable'], placeholder: 'Value', required: true, data: { 'req-variable': '' }}
+ - else
+ %input.form-control{type: 'text', name: "vacancy_submission[units][#{index}][requirements_attributes][#{req_i}][variable]", value: req['variable'], placeholder: 'Value', required: true, data: { 'req-variable': '' }}
+ %template{data: { 'req-template': '' }}
+ .mb-2{data: { 'req-entry': '' }}
+ .d-flex.gap-2.align-items-center
+ %select.form-select.w-auto.flex-shrink-0{ name: "vacancy_submission[units][#{index}][requirements_attributes][__REQ__][positive]" }
+ %option{value: 'true'} Must
+ %option{value: 'false'} Can't
+ %select.form-select.select2.flex-grow-1{ name: "vacancy_submission[units][#{index}][requirements_attributes][__REQ__][rule_id]", data: { 'req-rule-select': '' } }
+ %option{value: ''} Select rule...
+ - all_rules.each do |rule_opt|
+ - rule_name = on_unit ? (rule_opt.alternate_name.presence || rule_opt.name_with_verb) : rule_opt.name_with_verb
+ %option{value: rule_opt.id, data: { variable: rule_opt.variable_requirement?.to_s, 'variable-type': rule_opt.variable_input_type.to_s, 'variable-options': rule_opt.variable_options.to_json, 'variable-label': rule_opt.variable_label.to_s }}= rule_name
+ %button.btn.btn-sm.btn-outline-danger.mt-1{type: 'button', data: { action: 'vacancy-unit-requirements#removeReq' }} Remove
+ .mt-1{ data: { 'req-variable-container': '', 'variable-name': "vacancy_submission[units][#{index}][requirements_attributes][__REQ__]" }, hidden: true }
+ %button.btn.btn-sm.btn-outline-secondary.mt-2{type: 'button', data: { action: 'vacancy-unit-requirements#addReq' }} + Add Rule
diff --git a/app/views/vacancy_submissions/_unit_row.html.haml b/app/views/vacancy_submissions/_unit_row.html.haml
new file mode 100644
index 000000000..320d5505b
--- /dev/null
+++ b/app/views/vacancy_submissions/_unit_row.html.haml
@@ -0,0 +1,36 @@
+- readonly = local_assigns.fetch(:readonly, false)
+- amenity_names = local_assigns.fetch(:amenity_names, [])
+- attr_catalog = local_assigns.fetch(:attr_catalog, {})
+- attr_names = local_assigns.fetch(:attr_names, [])
+- all_rules = local_assigns.fetch(:all_rules, [])
+- default_building_id = local_assigns.fetch(:default_building_id, nil)
+- confidential = local_assigns.fetch(:confidential, false)
+.c-card.mb-2{data: (readonly ? {} : { 'unit-entry': '' })}
+ .c-card__header
+ - if readonly && unit['unit_id'].present? && can_edit_units?
+ .d-flex.justify-content-between.align-items-center
+ %h2= "Unit #{number}"
+ = link_to 'Edit Unit', edit_unit_path(unit['unit_id']), class: 'btn btn-outline-secondary btn-sm mb-2'
+ - else
+ %h2{data: (readonly ? {} : { 'unit-card-title': '' })}= "Unit #{number}"
+ .c-card__content
+ = render 'vacancy_submissions/unit_address', unit: unit, index: index, readonly: readonly, default_building_id: default_building_id, confidential: confidential
+ = render 'vacancy_submissions/unit_details', unit: unit, index: index, readonly: readonly
+ = render 'vacancy_submissions/unit_amenities', unit: unit, index: index, amenity_names: amenity_names, readonly: readonly
+ = render 'vacancy_submissions/unit_accessibility', unit: unit, index: index, readonly: readonly
+ = render 'vacancy_submissions/unit_attributes', unit: unit, index: index, attr_catalog: attr_catalog, attr_names: attr_names, readonly: readonly
+ = render 'vacancy_submissions/unit_media_links', unit: unit, index: index, readonly: readonly
+ = render 'vacancy_submissions/unit_requirements', unit: unit, index: index, on_unit: true, all_rules: all_rules, readonly: readonly
+ - if readonly
+ - if unit['notes'].present?
+ %dl.row.g-2.mb-0
+ .col-sm-12
+ %dt.vs-field-label Unit Notes
+ %dd.vs-field-value.mb-0= simple_format(h(unit['notes']))
+ - else
+ .mb-3
+ %label.form-label Unit Notes
+ %textarea.form-control{name: "vacancy_submission[units][#{index}][notes]", rows: 3, placeholder: 'Any notes about this unit...'}= unit['notes']
+ - unless readonly
+ .d-flex.justify-content-end.mt-2
+ %button.btn.btn-sm.btn-outline-danger{type: 'button', data: { action: 'vacancy-units#removeUnit' }} Remove
diff --git a/app/views/vacancy_submissions/_vacancy.html.haml b/app/views/vacancy_submissions/_vacancy.html.haml
new file mode 100644
index 000000000..691d6349a
--- /dev/null
+++ b/app/views/vacancy_submissions/_vacancy.html.haml
@@ -0,0 +1,6 @@
+- unit_list = Array(vacancy_submission.units).presence || [{}]
+- if is_voucher
+ = render 'vacancy_submissions/vacancy_vouchers', unit_list: unit_list
+- else
+ - confidential = sub_program&.confidential? || sub_program&.program&.confidential?
+ = render 'vacancy_submissions/vacancy_units', unit_list: unit_list, default_building_id: sub_program&.building_id, confidential: confidential
diff --git a/app/views/vacancy_submissions/_vacancy_units.html.haml b/app/views/vacancy_submissions/_vacancy_units.html.haml
new file mode 100644
index 000000000..385830fd3
--- /dev/null
+++ b/app/views/vacancy_submissions/_vacancy_units.html.haml
@@ -0,0 +1,15 @@
+- attr_catalog = HousingAttribute.attribute_catalog
+- attr_names = HousingAttribute.existing_attributes
+- amenity_names = HousingAttribute.existing_amenities
+- all_rules = Rule.order(:name).to_a
+- default_building_id = local_assigns.fetch(:default_building_id, nil)
+- confidential = local_assigns.fetch(:confidential, false)
+.mb-4{data: { controller: 'vacancy-units', 'vacancy-units-is-voucher-value': false }}
+ %div{data: { 'vacancy-units-target': 'list' }}
+ - unit_list.each_with_index do |unit, i|
+ = render 'vacancy_submissions/unit_row', unit: unit, index: i, number: i + 1, attr_catalog: attr_catalog, attr_names: attr_names, amenity_names: amenity_names, all_rules: all_rules, default_building_id: default_building_id, confidential: confidential
+ %template{data: { 'unit-template': '' }}
+ = render 'vacancy_submissions/unit_row', unit: {}, index: '__INDEX__', number: '__NUMBER__', attr_catalog: attr_catalog, attr_names: attr_names, amenity_names: amenity_names, all_rules: all_rules, default_building_id: default_building_id, confidential: confidential
+ .d-flex.justify-content-end.mt-2.pe-4
+ %button.btn.btn-outline-secondary{type: 'button', data: { action: 'vacancy-units#addUnit' }}
+ + Add another unit
diff --git a/app/views/vacancy_submissions/_vacancy_vouchers.html.haml b/app/views/vacancy_submissions/_vacancy_vouchers.html.haml
new file mode 100644
index 000000000..8077ea4b1
--- /dev/null
+++ b/app/views/vacancy_submissions/_vacancy_vouchers.html.haml
@@ -0,0 +1,13 @@
+- all_rules = Rule.order(:name).to_a
+.c-card.mb-4
+ .c-card__header
+ %h2 Vouchers
+ .c-card__content
+ %div{data: { controller: 'vacancy-units', 'vacancy-units-is-voucher-value': true }}
+ %div{data: { 'vacancy-units-target': 'list' }}
+ - unit_list.each_with_index do |unit, i|
+ = render 'vacancy_submissions/voucher_row', unit: unit, index: i, number: i + 1, all_rules: all_rules
+ %template{data: { 'unit-template': '' }}
+ = render 'vacancy_submissions/voucher_row', unit: {}, index: '__INDEX__', number: '__NUMBER__', all_rules: all_rules
+ %button.btn.btn-outline-secondary.mt-2{type: 'button', data: { action: 'vacancy-units#addUnit' }}
+ + Add another voucher
diff --git a/app/views/vacancy_submissions/_voucher_row.html.haml b/app/views/vacancy_submissions/_voucher_row.html.haml
new file mode 100644
index 000000000..f7eb9b4c0
--- /dev/null
+++ b/app/views/vacancy_submissions/_voucher_row.html.haml
@@ -0,0 +1,12 @@
+- readonly = local_assigns.fetch(:readonly, false)
+- all_rules = local_assigns.fetch(:all_rules, [])
+.border.rounded.p-3.mb-3{data: (readonly ? {} : { 'unit-entry': '' })}
+ - if readonly
+ .d-flex.align-items-center.mb-2
+ %p.fw-semibold.mb-0= unit['name'].presence || '—'
+ - else
+ .d-flex.align-items-center.justify-content-between
+ %p.fw-semibold.mb-0{data: { 'voucher-label': '' }}= "Voucher #{number}"
+ %button.btn.btn-sm.btn-outline-danger{type: 'button', data: { action: 'vacancy-units#removeUnit' }} Remove
+ %input{type: 'hidden', name: "vacancy_submission[units][#{index}][name]", value: "Voucher #{number}", data: { 'voucher-name': '' }}
+ = render 'vacancy_submissions/unit_requirements', unit: unit, index: index, all_rules: all_rules, readonly: readonly
diff --git a/app/views/vacancy_submissions/edit.html.haml b/app/views/vacancy_submissions/edit.html.haml
new file mode 100644
index 000000000..0356fa4f7
--- /dev/null
+++ b/app/views/vacancy_submissions/edit.html.haml
@@ -0,0 +1,12 @@
+.text-muted.small.mb-3
+ = link_to 'All Vacancy Submissions', vacancy_submissions_path
+ | /
+ = link_to (@program&.name || 'Submission'), vacancy_submission_path(@submission)
+ | /
+ Edit
+
+%h1.h3.mb-4= "Edit — #{@program&.name || 'Submission'}"
+
+.row
+ .col-md-8
+ = render 'form', vacancy_submission: @submission
diff --git a/app/views/vacancy_submissions/index.html.haml b/app/views/vacancy_submissions/index.html.haml
new file mode 100644
index 000000000..df95b69ff
--- /dev/null
+++ b/app/views/vacancy_submissions/index.html.haml
@@ -0,0 +1,65 @@
+.o-page__title
+ %h1 All Vacancy Submissions
+ - if can_view_opportunities?
+ .o-page__action
+ = link_to new_vacancy_submission_path, class: 'btn btn-primary' do
+ %span.icon-plus
+ New Vacancy
+
+.col-md-9
+ = render 'search_form'
+
+.row.table-utilities
+ .col-12
+ %p= page_entries_info @vacancy_submissions
+
+.table-responsive
+ .c-card.c-card__flush.mb-4
+ .c-card__content.p-0
+ %table.table.table-hover.mb-0
+ %thead
+ %tr
+ %th Status
+ %th Program
+ %th Voucher Type
+ %th Location(s)
+ %th Bedrooms
+ %th Changes Requested
+ %tbody
+ - if @vacancy_submissions.any?
+ - @vacancy_submissions.each do |submission|
+ %tr
+ %td
+ .mb-1= vacancy_submission_status_badge(submission)
+ .mb-1
+ %em
+ Submitted:
+ %strong= submission.created_at.to_date
+ .mb-1
+ %em
+ Last updated:
+ %strong= submission.updated_at.to_date
+ %td= link_to(@programs_by_id[submission.program_id]&.name || '—', vacancy_submission_path(submission))
+ %td= submission.voucher_type_display
+ %td
+ - multi_site = submission.site_display.length > 1
+ - submission.site_display.each.with_index do |site, index|
+ %p
+ - if multi_site
+ %strong #{index + 1}:
+ = site
+ %td
+ - multi_bedrooms = submission.bedrooms_display.length > 1
+ - submission.bedrooms_display.each.with_index do |bedrooms, index|
+ %p
+ - if multi_bedrooms
+ %strong #{index + 1}:
+ = bedrooms
+ - changes_requested = submission.changes_requested_display
+ - change_class = changes_requested.length > 1 ? 'text-danger' : ''
+ %td{ class: change_class }= truncate(changes_requested, length:50, separator: ' ')
+ - else
+ %tr
+ %td{colspan: 8} No vacancy submissions found.
+
+%p= paginate @vacancy_submissions
diff --git a/app/views/vacancy_submissions/new.html.haml b/app/views/vacancy_submissions/new.html.haml
new file mode 100644
index 000000000..ad6f2361e
--- /dev/null
+++ b/app/views/vacancy_submissions/new.html.haml
@@ -0,0 +1,4 @@
+.row
+ .col-md-8
+ %h1 New Vacancy Submission
+ = render 'form', vacancy_submission: @vacancy_submission
diff --git a/app/views/vacancy_submissions/show.html.haml b/app/views/vacancy_submissions/show.html.haml
new file mode 100644
index 000000000..583db48a8
--- /dev/null
+++ b/app/views/vacancy_submissions/show.html.haml
@@ -0,0 +1,146 @@
+- last_reviewer_note = @notes.select { |n| n.note_type == 'reviewer_note' }.last
+= content_for :crumbs do
+ = link_to vacancy_submissions_path do
+ « All Vacancy Submissions
+
+.d-flex.align-items-center.gap-2.mb-4
+ %h1.mb-0= @program&.name || '—'
+ = vacancy_submission_status_badge(@submission)
+
+- if @submission.status == 'return_changes_requested' && last_reviewer_note
+ .alert-changes-requested.mb-4.d-flex.justify-content-between.align-items-center
+ .change-note
+ %p.fw-semibold.mb-1 Changes Requested by #{last_reviewer_note.user&.name || 'the CAS Admin'}
+ = simple_format(h(last_reviewer_note.body))
+ .change-actions
+ - if @submission.resubmittable? && can_add_vacancies?
+ = link_to 'Edit', edit_vacancy_submission_path(@submission), class: 'btn btn-outline-secondary btn-sm'
+
+
+.c-card
+ .c-card__header
+ %h2 Submission Details
+ .c-card__content
+ %dl.row.g-3.mb-0
+ .col-sm-6
+ %dt.vs-field-label Submitted
+ %dd.vs-field-value.mb-0= @submission.created_at.strftime('%b %-d, %Y %I:%M %p')
+ .col-sm-6
+ %dt.vs-field-label By
+ %dd.vs-field-value.mb-0= @submission.user&.name.presence || '—'
+ .col-sm-6
+ %dt.vs-field-label Email
+ %dd.vs-field-value.mb-0= @submission.user&.email.presence || '—'
+ .col-sm-6
+ %dt.vs-field-label Phone
+ %dd.vs-field-value.mb-0= @submission.user&.contact&.phone.presence || '—'
+ .col-sm-6
+ %dt.vs-field-label Organization
+ %dd.vs-field-value.mb-0= @submission.user&.agency&.name.presence || '—'
+
+
+.c-card
+ .c-card__header
+ .d-flex.justify-content-between.align-items-center
+ %h2 Program
+ .c-card__content
+ %dl.row.g-3.mb-0
+ .col-sm-6
+ %dt.vs-field-label Program
+ %dd.vs-field-value.mb-0= @program&.name || '—'
+ .col-sm-6
+ %dt.vs-field-label Sub-Program
+ - include_link = @sub_program && (can_edit_programs? || @sub_program&.editable_by?(current_user))
+ %dd.vs-field-value.mb-0= link_to_if include_link, @sub_program&.name || '—', edit_program_sub_program_path(@program, @sub_program)
+ .col-sm-6
+ %dt.vs-field-label Route
+ %dd.vs-field-value.mb-0= @submission.route || '—'
+ .col-sm-6
+ %dt.vs-field-label Vacancy
+ %dd.vs-field-value.mb-0= @submission.voucher_type_display
+
+- units = Array(@submission.units)
+- if @submission.voucher?
+ - units.each_with_index do |unit, i|
+ .c-card
+ .c-card__header
+ %h2= "Voucher #{i + 1}"
+ .c-card__content
+ = render 'vacancy_submissions/voucher_row', unit: unit, index: i, number: i + 1, readonly: true
+- else
+ - attr_catalog = HousingAttribute.attribute_catalog
+ - attr_names = HousingAttribute.existing_attributes
+ - amenity_names = HousingAttribute.existing_amenities
+ - units.each_with_index do |unit, i|
+ = render 'vacancy_submissions/unit_row', unit: unit, index: i, number: i + 1, readonly: true, amenity_names: amenity_names, attr_catalog: attr_catalog, attr_names: attr_names
+
+- required_names = Array(@submission.required_document_names).select(&:present?)
+- if required_names.any?
+ .c-card
+ .c-card__header
+ %h2 Required Documents
+ .c-card__content
+ %ul.mb-0.ps-3
+ - required_names.each do |name|
+ %li= name
+
+- if @submission.notes.present?
+ .c-card
+ .c-card__header
+ %h2 General Notes
+ .c-card__content
+ = simple_format(h(@submission.notes))
+
+- if @sub_program
+ .c-card
+ .c-card__header
+ %h2 Inherited Requirements
+ .c-card__content
+ = render 'requirement_manager/inherited_rules', inheritee: @sub_program, show_label: false
+
+- if @submission.resubmittable? && can_add_vacancies?
+ .c-card
+ .c-card__header
+ %h2 Actions
+ .c-card__content
+ %p Notifies #{last_reviewer_note.user&.name || 'the CAS Admin'} that your corrections are ready for re-review.
+ = button_to 'Resubmit for Approval', resubmit_vacancy_submission_path(@submission), method: :post, class: 'btn btn-primary'
+- elsif @submission.status == 'awaiting_approval' && can_review_vacancies?
+ .c-card
+ .c-card__header
+ %h2 Actions
+ .c-card__content
+ .d-flex.gap-2
+ = button_to 'Approve', approve_vacancy_submission_path(@submission), method: :post, class: 'btn btn-success'
+ %button.btn.btn-warning{"data-bs-toggle": "modal", "data-bs-target": "#return-modal"}
+ Return for Changes
+
+
+.c-card
+ .c-card__header
+ .d-flex.justify-content-between.align-items-center
+ %h2
+ Activity Log
+ %span.text-muted.fw-normal{style: 'text-transform: none; letter-spacing: normal; font-size: 12px;'}= "(#{@notes.size})"
+ .c-card__content.c-card--flush
+ - if @notes.any?
+ %table.table.table.mb-0
+ %thead
+ %tr
+ %th Time
+ %th Action
+ %th By
+ %th Details
+ %tbody
+ - @notes.reverse_each do |note|
+ - next if note.body == 'Returned for changes.'
+ %tr
+ %td.text-muted.text-nowrap= note.created_at.strftime('%b %-d, %Y %I:%M %p')
+ %td= note.action_label
+ %td= note.user&.name || 'System'
+ %td= note.body
+ - else
+ .p-3
+ %p.text-muted.mb-0 No activity yet.
+
+= render 'return_modal'
diff --git a/app/views/vacancy_submissions_mailer/changes_requested.html.haml b/app/views/vacancy_submissions_mailer/changes_requested.html.haml
new file mode 100644
index 000000000..5f055e295
--- /dev/null
+++ b/app/views/vacancy_submissions_mailer/changes_requested.html.haml
@@ -0,0 +1 @@
+= render_markdown(@body)
diff --git a/app/views/vacancy_submissions_mailer/changes_requested.text.haml b/app/views/vacancy_submissions_mailer/changes_requested.text.haml
new file mode 100644
index 000000000..8d404e1c5
--- /dev/null
+++ b/app/views/vacancy_submissions_mailer/changes_requested.text.haml
@@ -0,0 +1 @@
+!= @body
diff --git a/app/views/vacancy_submissions_mailer/resubmitted_for_review.html.haml b/app/views/vacancy_submissions_mailer/resubmitted_for_review.html.haml
new file mode 100644
index 000000000..5f055e295
--- /dev/null
+++ b/app/views/vacancy_submissions_mailer/resubmitted_for_review.html.haml
@@ -0,0 +1 @@
+= render_markdown(@body)
diff --git a/app/views/vacancy_submissions_mailer/resubmitted_for_review.text.haml b/app/views/vacancy_submissions_mailer/resubmitted_for_review.text.haml
new file mode 100644
index 000000000..8d404e1c5
--- /dev/null
+++ b/app/views/vacancy_submissions_mailer/resubmitted_for_review.text.haml
@@ -0,0 +1 @@
+!= @body
diff --git a/app/views/vacancy_submissions_mailer/submitted_for_review.html.haml b/app/views/vacancy_submissions_mailer/submitted_for_review.html.haml
new file mode 100644
index 000000000..5f055e295
--- /dev/null
+++ b/app/views/vacancy_submissions_mailer/submitted_for_review.html.haml
@@ -0,0 +1 @@
+= render_markdown(@body)
diff --git a/app/views/vacancy_submissions_mailer/submitted_for_review.text.haml b/app/views/vacancy_submissions_mailer/submitted_for_review.text.haml
new file mode 100644
index 000000000..8d404e1c5
--- /dev/null
+++ b/app/views/vacancy_submissions_mailer/submitted_for_review.text.haml
@@ -0,0 +1 @@
+!= @body
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index 51514989a..e3c22074f 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -7,7 +7,7 @@
"check_name": "Execute",
"message": "Possible command injection",
"file": "config/deploy/docker/lib/command_args.rb",
- "line": 34,
+ "line": 40,
"link": "https://brakemanscanner.org/docs/warning_types/command_injection/",
"code": "`diff #{path} #{tmppath}`",
"render_path": null,
@@ -86,7 +86,7 @@
"check_name": "Execute",
"message": "Possible command injection",
"file": "config/deploy/docker/lib/deployer.rb",
- "line": 119,
+ "line": 125,
"link": "https://brakemanscanner.org/docs/warning_types/command_injection/",
"code": "`git ls-remote origin | grep refs/heads/#{`git rev-parse --abbrev-ref HEAD`.chomp}$`",
"render_path": null,
@@ -109,7 +109,7 @@
"check_name": "EOLRails",
"message": "Support for Rails 7.2.3.1 ends on 2026-08-09",
"file": "Gemfile.lock",
- "line": 1083,
+ "line": 1075,
"link": "https://brakemanscanner.org/docs/warning_types/unmaintained_dependency/",
"code": null,
"render_path": null,
@@ -151,7 +151,7 @@
"check_name": "Execute",
"message": "Possible command injection",
"file": "config/deploy/docker/lib/ecs_tools.rb",
- "line": 25,
+ "line": 31,
"link": "https://brakemanscanner.org/docs/warning_types/command_injection/",
"code": "system(\"ssh #{\"ecs0.openpath.host\"} 'docker commit #{`ssh #{\"ecs0.openpath.host\"} \"docker ps -a -n 10\"`.split(/\\n/).grep(/#{IMAGE}/).first.split(/\\s/).first} shell'\")",
"render_path": null,
@@ -201,6 +201,39 @@
],
"note": ""
},
+ {
+ "warning_type": "Cross-Site Scripting",
+ "warning_code": 2,
+ "fingerprint": "33b06d2b14093fefd2e2dc479aead03e88041ace6634b994f7dc5aee37236f1f",
+ "check_name": "CrossSiteScripting",
+ "message": "Unescaped model attribute",
+ "file": "app/views/vacancy_submissions/_unit_requirements.html.haml",
+ "line": 25,
+ "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting",
+ "code": "::Haml::AttributeBuilder.build_class(true, [\"badge\", Requirement.modal_verb_class_for(((req[\"positive\"].to_s == \"true\") or (req[\"positive\"] == true)))])",
+ "render_path": [
+ {
+ "type": "template",
+ "name": "vacancy_submissions/_unit_row",
+ "line": 16,
+ "file": "app/views/vacancy_submissions/_unit_row.html.haml",
+ "rendered": {
+ "name": "vacancy_submissions/_unit_requirements",
+ "file": "app/views/vacancy_submissions/_unit_requirements.html.haml"
+ }
+ }
+ ],
+ "location": {
+ "type": "template",
+ "template": "vacancy_submissions/_unit_requirements"
+ },
+ "user_input": "Requirement.modal_verb_class_for(((req[\"positive\"].to_s == \"true\") or (req[\"positive\"] == true)))",
+ "confidence": "Weak",
+ "cwe_id": [
+ 79
+ ],
+ "note": ""
+ },
{
"warning_type": "Command Injection",
"warning_code": 14,
@@ -208,7 +241,7 @@
"check_name": "Execute",
"message": "Possible command injection",
"file": "config/deploy/docker/lib/deployer.rb",
- "line": 120,
+ "line": 126,
"link": "https://brakemanscanner.org/docs/warning_types/command_injection/",
"code": "`git rev-parse #{`git rev-parse --abbrev-ref HEAD`.chomp}`",
"render_path": null,
@@ -265,7 +298,7 @@
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/rules/tagged_with.rb",
- "line": 34,
+ "line": 46,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "scope.where((\"tags ->>'#{requirement.variable}' is not null\" or \"not(tags ->>'#{requirement.variable}' is not null) OR tags is null\"))",
"render_path": null,
@@ -321,7 +354,7 @@
"check_name": "Execute",
"message": "Possible command injection",
"file": "config/deploy/docker/lib/deployer.rb",
- "line": 162,
+ "line": 168,
"link": "https://brakemanscanner.org/docs/warning_types/command_injection/",
"code": "`echo #{revision} > #{_assets_path}/REVISION`",
"render_path": null,
@@ -344,7 +377,7 @@
"check_name": "Execute",
"message": "Possible command injection",
"file": "config/deploy/docker/lib/ecs_tools.rb",
- "line": 38,
+ "line": 44,
"link": "https://brakemanscanner.org/docs/warning_types/command_injection/",
"code": "exec(\"awslogs get #{group} ALL --watch\")",
"render_path": null,
@@ -547,7 +580,7 @@
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/rules/non_hmis_assessment_type.rb",
- "line": 32,
+ "line": 44,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "scope.where((\"'#{value_as_array(requirement.variable)}'::jsonb ? assessment_name\" or \"not(#{\"'#{value_as_array(requirement.variable)}'::jsonb ? assessment_name\"}) OR assessment_name is null\"))",
"render_path": null,
@@ -614,7 +647,7 @@
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/rules/rank_below.rb",
- "line": 37,
+ "line": 49,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "Arel.sql(\"(tags->>'#{opportunity.match_route.tag_id.to_i}')::int >= #{requirement.variable}\")",
"render_path": null,
@@ -728,7 +761,7 @@
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/rules/rank_below.rb",
- "line": 35,
+ "line": 47,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "Arel.sql(\"(tags->>'#{opportunity.match_route.tag_id.to_i}')::int < #{requirement.variable}\")",
"render_path": null,
@@ -867,5 +900,5 @@
"note": ""
}
],
- "brakeman_version": "8.0.4"
+ "brakeman_version": "8.0.5"
}
diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb
index 9f0655c6a..c368f3248 100644
--- a/config/initializers/sentry.rb
+++ b/config/initializers/sentry.rb
@@ -7,6 +7,7 @@
# frozen_string_literal: true
require_relative '../../lib/util/git'
+require_relative '../../lib/util/sentry_event_filter'
# ENV['SENTRY_DSN'] is reserved by Sentry and its use seems to prevent this initializer from being recognized.
# Hence, we use CAS_SENTRY_DSN. Any other alternate key should also be fine.
@@ -29,10 +30,8 @@
# Replacement for Raven's: `config.sanitize_fields`
# See: https://stackoverflow.com/questions/68867756/missing-piece-in-sentry-raven-to-sentry-ruby-guide
# And: https://github.com/getsentry/sentry-ruby/issues/1140
- filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
- config.before_send = ->(event, _hint) do
- filter.filter(event.to_hash)
- end
+ event_filter = SentryEventFilter.new(Rails.application.config.filter_parameters)
+ config.before_send = event_filter.method(:call)
end
cluster_type =
diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb
index 754db9bc1..39a129a2c 100644
--- a/config/initializers/simple_form.rb
+++ b/config/initializers/simple_form.rb
@@ -104,12 +104,12 @@
# config.item_wrapper_class = nil
# How the label text should be generated altogether with the required text.
- # config.label_text = lambda { |label, required, explicit_label| "#{required} #{label}" }
+ config.label_text = lambda { |label, required, explicit_label| "#{label} #{required}" }
# You can define the class to use on all labels. Default is nil.
# config.label_class = nil
- # You can define the default class to be used on forms. Can be overriden
+ # You can define the default class to be used on forms. Can be overridden
# with `html: { :class }`. Defaulting to none.
# config.default_form_class = nil
diff --git a/config/routes.rb b/config/routes.rb
index fd8a33951..de8c57420 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -57,6 +57,14 @@
get :closed, on: :collection
end
end
+ resources :vacancy_submissions, only: [:index, :new, :create, :show, :edit, :update] do
+ get :sub_program_section, on: :collection
+ member do
+ post :approve
+ post :return_submission
+ post :resubmit
+ end
+ end
resources :buildings do
resources :contacts, except: :show, controller: :building_contacts, concerns: [:restorable]
get :available_units, on: :member
@@ -214,6 +222,7 @@ def manage_matches
resources :match_routes, only: [:index, :edit, :update] do
resources :weighting_rules, on: :member, except: [:create]
end
+ resources :housing_attribute_names, only: [:index, :edit, :update]
resources :sessions, only: [:index, :destroy]
end
diff --git a/db/migrate/20260528000001_create_vacancy_submissions.rb b/db/migrate/20260528000001_create_vacancy_submissions.rb
new file mode 100644
index 000000000..0a63fa3b7
--- /dev/null
+++ b/db/migrate/20260528000001_create_vacancy_submissions.rb
@@ -0,0 +1,21 @@
+###
+# Copyright 2016 - 2025 Green River Data Analysis, LLC
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+class CreateVacancySubmissions < ActiveRecord::Migration[7.0]
+ def change
+ create_table :vacancy_submissions do |t|
+ t.references :user, null: true, foreign_key: true
+ t.string :status, null: false, default: 'awaiting_approval'
+ t.jsonb :draft_data, null: false, default: {}
+ t.timestamps
+ end
+
+ add_index :vacancy_submissions, :status
+ add_index :vacancy_submissions, :draft_data, using: :gin
+ end
+end
diff --git a/db/migrate/20260601000001_create_vacancy_submission_notes.rb b/db/migrate/20260601000001_create_vacancy_submission_notes.rb
new file mode 100644
index 000000000..99b6c66d3
--- /dev/null
+++ b/db/migrate/20260601000001_create_vacancy_submission_notes.rb
@@ -0,0 +1,19 @@
+###
+# Copyright 2016 - 2025 Green River Data Analysis, LLC
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+class CreateVacancySubmissionNotes < ActiveRecord::Migration[7.0]
+ def change
+ create_table :vacancy_submission_notes do |t|
+ t.references :vacancy_submission, null: false, foreign_key: true
+ t.references :user, null: true, foreign_key: true
+ t.string :note_type, null: false
+ t.text :body, null: false
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260601000002_add_vacancy_permissions_to_roles.rb b/db/migrate/20260601000002_add_vacancy_permissions_to_roles.rb
new file mode 100644
index 000000000..e959d5161
--- /dev/null
+++ b/db/migrate/20260601000002_add_vacancy_permissions_to_roles.rb
@@ -0,0 +1,13 @@
+###
+# Copyright 2016 - 2025 Green River Data Analysis, LLC
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+class AddVacancyPermissionsToRoles < ActiveRecord::Migration[7.0]
+ def change
+ add_column :roles, :can_review_vacancies, :boolean, default: false, null: false
+ end
+end
diff --git a/db/migrate/20260624131950_add_include_value_to_housing_attributes.rb b/db/migrate/20260624131950_add_include_value_to_housing_attributes.rb
new file mode 100644
index 000000000..932e377d8
--- /dev/null
+++ b/db/migrate/20260624131950_add_include_value_to_housing_attributes.rb
@@ -0,0 +1,13 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+class AddIncludeValueToHousingAttributes < ActiveRecord::Migration[7.2]
+ def change
+ add_column :housing_attributes, :include_value, :boolean, default: true, null: false
+ end
+end
diff --git a/db/migrate/20260701000001_add_notes_to_units.rb b/db/migrate/20260701000001_add_notes_to_units.rb
new file mode 100644
index 000000000..fde7f3138
--- /dev/null
+++ b/db/migrate/20260701000001_add_notes_to_units.rb
@@ -0,0 +1,13 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+class AddNotesToUnits < ActiveRecord::Migration[7.2]
+ def change
+ add_column :units, :notes, :text
+ end
+end
diff --git a/db/migrate/20260701170632_add_vacancy_submission_config.rb b/db/migrate/20260701170632_add_vacancy_submission_config.rb
new file mode 100644
index 000000000..74bbce664
--- /dev/null
+++ b/db/migrate/20260701170632_add_vacancy_submission_config.rb
@@ -0,0 +1,13 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+class AddVacancySubmissionConfig < ActiveRecord::Migration[7.2]
+ def change
+ add_column :configs, :vacancy_submission_mechanism, :string, default: 'Traditional'
+ end
+end
diff --git a/db/migrate/20260714153122_fix_default_casing.rb b/db/migrate/20260714153122_fix_default_casing.rb
new file mode 100644
index 000000000..de507dd37
--- /dev/null
+++ b/db/migrate/20260714153122_fix_default_casing.rb
@@ -0,0 +1,13 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+class FixDefaultCasing < ActiveRecord::Migration[7.2]
+ def change
+ change_column_default :configs, :vacancy_submission_mechanism, from: 'Traditional', to: 'traditional'
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index bb38330f0..b4e8c9c90 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -736,7 +736,8 @@ CREATE TABLE public.configs (
limit_client_names_on_matches boolean DEFAULT true,
include_note_in_email_default boolean,
notify_all_on_progress_update boolean DEFAULT false,
- send_match_summary_email_on integer
+ send_match_summary_email_on integer,
+ vacancy_submission_mechanism character varying DEFAULT 'traditional'::character varying
);
@@ -1399,7 +1400,8 @@ CREATE TABLE public.housing_attributes (
value character varying,
deleted_at timestamp without time zone,
created_at timestamp without time zone NOT NULL,
- updated_at timestamp without time zone NOT NULL
+ updated_at timestamp without time zone NOT NULL,
+ include_value boolean DEFAULT true NOT NULL
);
@@ -3370,7 +3372,8 @@ CREATE TABLE public.roles (
can_edit_voucher_rules boolean DEFAULT false,
can_manage_all_deidentified_clients boolean DEFAULT false,
can_manage_all_identified_clients boolean DEFAULT false,
- can_activate_matches boolean DEFAULT false
+ can_activate_matches boolean DEFAULT false,
+ can_review_vacancies boolean DEFAULT false NOT NULL
);
@@ -4065,7 +4068,8 @@ CREATE TABLE public.units (
data_source_id integer,
data_source_id_column_name character varying,
elevator_accessible boolean DEFAULT false NOT NULL,
- active boolean DEFAULT true NOT NULL
+ active boolean DEFAULT true NOT NULL,
+ notes text
);
@@ -4193,6 +4197,73 @@ CREATE SEQUENCE public.users_id_seq
ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id;
+--
+-- Name: vacancy_submission_notes; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.vacancy_submission_notes (
+ id bigint NOT NULL,
+ vacancy_submission_id bigint NOT NULL,
+ user_id bigint,
+ note_type character varying NOT NULL,
+ body text NOT NULL,
+ created_at timestamp(6) without time zone NOT NULL,
+ updated_at timestamp(6) without time zone NOT NULL
+);
+
+
+--
+-- Name: vacancy_submission_notes_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.vacancy_submission_notes_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: vacancy_submission_notes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.vacancy_submission_notes_id_seq OWNED BY public.vacancy_submission_notes.id;
+
+
+--
+-- Name: vacancy_submissions; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.vacancy_submissions (
+ id bigint NOT NULL,
+ user_id bigint,
+ status character varying DEFAULT 'awaiting_approval'::character varying NOT NULL,
+ draft_data jsonb DEFAULT '{}'::jsonb NOT NULL,
+ created_at timestamp(6) without time zone NOT NULL,
+ updated_at timestamp(6) without time zone NOT NULL
+);
+
+
+--
+-- Name: vacancy_submissions_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.vacancy_submissions_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: vacancy_submissions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.vacancy_submissions_id_seq OWNED BY public.vacancy_submissions.id;
+
+
--
-- Name: versions; Type: TABLE; Schema: public; Owner: -
--
@@ -4984,6 +5055,20 @@ ALTER TABLE ONLY public.user_roles ALTER COLUMN id SET DEFAULT nextval('public.u
ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass);
+--
+-- Name: vacancy_submission_notes id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.vacancy_submission_notes ALTER COLUMN id SET DEFAULT nextval('public.vacancy_submission_notes_id_seq'::regclass);
+
+
+--
+-- Name: vacancy_submissions id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.vacancy_submissions ALTER COLUMN id SET DEFAULT nextval('public.vacancy_submissions_id_seq'::regclass);
+
+
--
-- Name: versions id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -5772,6 +5857,22 @@ ALTER TABLE ONLY public.users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
+--
+-- Name: vacancy_submission_notes vacancy_submission_notes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.vacancy_submission_notes
+ ADD CONSTRAINT vacancy_submission_notes_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: vacancy_submissions vacancy_submissions_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.vacancy_submissions
+ ADD CONSTRAINT vacancy_submissions_pkey PRIMARY KEY (id);
+
+
--
-- Name: versions versions_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -6945,6 +7046,41 @@ CREATE UNIQUE INDEX index_users_on_reset_password_token ON public.users USING bt
CREATE UNIQUE INDEX index_users_on_unlock_token ON public.users USING btree (unlock_token);
+--
+-- Name: index_vacancy_submission_notes_on_user_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_vacancy_submission_notes_on_user_id ON public.vacancy_submission_notes USING btree (user_id);
+
+
+--
+-- Name: index_vacancy_submission_notes_on_vacancy_submission_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_vacancy_submission_notes_on_vacancy_submission_id ON public.vacancy_submission_notes USING btree (vacancy_submission_id);
+
+
+--
+-- Name: index_vacancy_submissions_on_draft_data; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_vacancy_submissions_on_draft_data ON public.vacancy_submissions USING gin (draft_data);
+
+
+--
+-- Name: index_vacancy_submissions_on_status; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_vacancy_submissions_on_status ON public.vacancy_submissions USING btree (status);
+
+
+--
+-- Name: index_vacancy_submissions_on_user_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_vacancy_submissions_on_user_id ON public.vacancy_submissions USING btree (user_id);
+
+
--
-- Name: index_versions_on_item_type_and_item_id; Type: INDEX; Schema: public; Owner: -
--
@@ -6994,6 +7130,30 @@ CREATE INDEX index_weighting_rules_on_route_id ON public.weighting_rules USING b
CREATE UNIQUE INDEX uidx_client_search_queries ON public.client_search_queries USING btree (fingerprint);
+--
+-- Name: vacancy_submissions fk_rails_14ca527d24; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.vacancy_submissions
+ ADD CONSTRAINT fk_rails_14ca527d24 FOREIGN KEY (user_id) REFERENCES public.users(id);
+
+
+--
+-- Name: vacancy_submission_notes fk_rails_8379de24b6; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.vacancy_submission_notes
+ ADD CONSTRAINT fk_rails_8379de24b6 FOREIGN KEY (vacancy_submission_id) REFERENCES public.vacancy_submissions(id);
+
+
+--
+-- Name: vacancy_submission_notes fk_rails_851fc7240f; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.vacancy_submission_notes
+ ADD CONSTRAINT fk_rails_851fc7240f FOREIGN KEY (user_id) REFERENCES public.users(id);
+
+
--
-- Name: active_storage_variant_records fk_rails_993965df05; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -7153,6 +7313,13 @@ ALTER TABLE ONLY public.vouchers
SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
+('20260714153122'),
+('20260701170632'),
+('20260701000001'),
+('20260624131950'),
+('20260601000002'),
+('20260601000001'),
+('20260528000001'),
('20260728120000'),
('20260505173350'),
('20260505170932'),
diff --git a/docker/app/entrypoint.dev.sh b/docker/app/entrypoint.dev.sh
index f672db558..2fca0e0f7 100755
--- a/docker/app/entrypoint.dev.sh
+++ b/docker/app/entrypoint.dev.sh
@@ -26,5 +26,8 @@ bundle config --global set build.sassc --disable-march-tune-native
bundle install --quiet || echo "bundle install failed"
yarn install --silent --frozen-lockfile || echo "yarn install failed"
+yarn build || echo "yarn build failed"
+yarn watch &
+
# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
diff --git a/docs/features/requirements_manager.md b/docs/features/requirements_manager.md
index 4120f6bac..5e20f5670 100644
--- a/docs/features/requirements_manager.md
+++ b/docs/features/requirements_manager.md
@@ -136,7 +136,7 @@ The requirement manager is rendered via partial:
- `form`: SimpleForm form builder
- `selected_requirements_heading`: Label for the requirements section
- `help_text`: Optional help text
-- `hide_inherited`: Hide inherited rules section
+- `hide_inherited`: Hide inherited requirements section
- `on_unit`: Use alternate names for unit context
- `section_header`: Override default "Rules" header
- `note_context`: Context symbol for `selection_note` (e.g., `:voucher`)
diff --git a/lib/tasks/sentry.rake b/lib/tasks/sentry.rake
new file mode 100644
index 000000000..e75cc99d8
--- /dev/null
+++ b/lib/tasks/sentry.rake
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+namespace :sentry do
+ desc 'Send real test events through every Sentry.capture_* path this app uses, and report whether each was delivered'
+ task verify: :environment do
+ require_relative '../util/sentry_verifier'
+
+ success = SentryVerifier.new.run!
+ exit(1) unless success
+ end
+end
diff --git a/lib/util/sentry_event_filter.rb b/lib/util/sentry_event_filter.rb
new file mode 100644
index 000000000..03fa8f63b
--- /dev/null
+++ b/lib/util/sentry_event_filter.rb
@@ -0,0 +1,38 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+# Scrubs sensitive parameters from a Sentry event before it's sent, mirroring
+# Raven's old `config.sanitize_fields`. Used as `Sentry::Configuration#before_send`.
+#
+# Must mutate and return the event itself, not a Hash -- sentry-ruby silently discards the
+# event if before_send returns anything other than a Sentry::ErrorEvent or CheckInEvent.
+class SentryEventFilter
+ def initialize(filter_parameters)
+ @filter = ActiveSupport::ParameterFilter.new(filter_parameters)
+ end
+
+ def call(event, _hint)
+ event.tags = @filter.filter(event.tags)
+ event.extra = @filter.filter(event.extra)
+ event.contexts = @filter.filter(event.contexts)
+ event.user = @filter.filter(event.user)
+
+ event.breadcrumbs&.each do |crumb|
+ crumb.data = @filter.filter(crumb.data) if crumb.data.is_a?(Hash)
+ end
+
+ if event.request
+ event.request.data = @filter.filter(event.request.data) if event.request.data.is_a?(Hash)
+ event.request.headers = @filter.filter(event.request.headers)
+ event.request.env = @filter.filter(event.request.env)
+ event.request.cookies = @filter.filter(event.request.cookies) if event.request.cookies.is_a?(Hash)
+ end
+
+ event
+ end
+end
diff --git a/lib/util/sentry_verifier.rb b/lib/util/sentry_verifier.rb
new file mode 100644
index 000000000..c35730290
--- /dev/null
+++ b/lib/util/sentry_verifier.rb
@@ -0,0 +1,95 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+# Sends real test events through every Sentry.capture_* path this app actually uses
+# (grep app/ lib/ for the current list) and reports whether each one round-tripped to
+# Sentry successfully. Meant to be run by hand -- via `rake sentry:verify` -- against a
+# real CAS_SENTRY_DSN when you need to confirm delivery, not as part of the test suite.
+#
+# By default Sentry sends events from a background thread and swallows delivery errors
+# (network failures, non-2xx responses) into a debug-only log line, so a successful-looking
+# `Sentry.capture_exception` call proves nothing about actual delivery. This verifier forces
+# synchronous sending (background_worker_threads = 0) so a failed send returns nil instead of
+# silently vanishing, and turns on config.debug so the underlying HTTP failure reason (if any)
+# reaches the log.
+class SentryVerifier
+ Outcome = Struct.new(:check, :sent, keyword_init: true)
+
+ def initialize(output: $stdout)
+ @output = output
+ end
+
+ def run!
+ unless Sentry.initialized?
+ @output.puts 'Sentry.initialized? is false -- CAS_SENTRY_DSN is missing, or Rails.env is not in config.enabled_environments. Nothing to verify.'
+ return false
+ end
+
+ report_configuration
+
+ with_synchronous_debug_sending do
+ outcomes = [
+ capture_exception_check,
+ capture_message_check,
+ capture_exception_with_info_check,
+ ]
+ outcomes.each { |o| @output.puts "#{o.sent ? 'OK ' : 'FAIL'} #{o.check}" }
+ outcomes.all?(&:sent)
+ end
+ end
+
+ private
+
+ def config
+ Sentry.get_current_client.configuration
+ end
+
+ def report_configuration
+ @output.puts "environment: #{config.environment.inspect} (enabled_environments: #{config.enabled_environments.inspect})"
+ @output.puts "dsn host: #{config.dsn&.host.inspect}"
+ end
+
+ def with_synchronous_debug_sending
+ original_threads = config.background_worker_threads
+ original_debug = config.debug
+ config.background_worker_threads = 0
+ config.debug = true
+
+ yield
+ ensure
+ config.background_worker_threads = original_threads
+ config.debug = original_debug
+ end
+
+ def marker
+ @marker ||= "SentryVerifier-#{SecureRandom.hex(4)}"
+ end
+
+ def capture_exception_check
+ begin
+ raise "#{marker}: simulated unhandled exception (capture_exception)"
+ rescue StandardError => e
+ result = Sentry.capture_exception(e)
+ end
+ Outcome.new(check: 'Sentry.capture_exception (simulates an unhandled 500)', sent: result.present?)
+ end
+
+ def capture_message_check
+ result = Sentry.capture_message("#{marker}: test message (capture_message)")
+ Outcome.new(check: 'Sentry.capture_message', sent: result.present?)
+ end
+
+ def capture_exception_with_info_check
+ begin
+ raise "#{marker}: simulated exception (capture_exception_with_info)"
+ rescue StandardError => e
+ result = Sentry.capture_exception_with_info(e, 'SentryVerifier check', marker: marker)
+ end
+ Outcome.new(check: 'Sentry.capture_exception_with_info', sent: result.present?)
+ end
+end
diff --git a/package.json b/package.json
index 1dcfb1735..1f4646f88 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"scripts": {
"build": "esbuild app/javascript/application.js --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets",
"build:production": "esbuild app/javascript/application.js --bundle --sourcemap --minify --outdir=app/assets/builds --public-path=/assets",
+ "watch": "esbuild app/javascript/application.js --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets --watch=forever",
"dedup": "yarn-deduplicate -s fewer yarn.lock"
}
}
diff --git a/spec/controllers/buildings_controller_spec.rb b/spec/controllers/buildings_controller_spec.rb
new file mode 100644
index 000000000..affdb7b3a
--- /dev/null
+++ b/spec/controllers/buildings_controller_spec.rb
@@ -0,0 +1,49 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BuildingsController, type: :controller do
+ let(:user) { create(:user) }
+ let(:admin_role) { create(:admin_role) }
+
+ before do
+ authenticate(user)
+ user.roles << admin_role
+ end
+
+ describe 'POST #create' do
+ it 'creates the building and redirects to it' do
+ expect do
+ post :create, params: { building: { name: 'New Building' } }
+ end.to change(Building, :count).by(1)
+ expect(response).to redirect_to(building_path(Building.last))
+ end
+
+ context 'when a variable-requiring rule is submitted with a blank variable' do
+ let!(:variable_rule) { create(:bedroom_exact) }
+
+ it 're-renders the form with errors instead of persisting or raising' do
+ expect do
+ post :create, params: {
+ building: {
+ name: 'New Building',
+ requirements_attributes: [
+ { 'rule_id' => variable_rule.id.to_s, 'positive' => 'true', 'variable' => '' },
+ ],
+ },
+ }
+ end.not_to change(Building, :count)
+
+ expect(Requirement.count).to eq(0)
+ expect(response).to render_template(:new)
+ expect(assigns(:building).errors).to be_present
+ end
+ end
+ end
+end
diff --git a/spec/controllers/unit_for_building_controller_spec.rb b/spec/controllers/unit_for_building_controller_spec.rb
index 1b2fb9c08..fa7119c87 100644
--- a/spec/controllers/unit_for_building_controller_spec.rb
+++ b/spec/controllers/unit_for_building_controller_spec.rb
@@ -114,6 +114,21 @@
end
end
+ context 'when the building has default housing attributes' do
+ let(:building) { create(:building, elevator_accessible_default: false) }
+
+ before do
+ create(:housing_attribute, :with_value, housingable: building)
+ end
+
+ it 'copies the building default housing attributes onto the new unit' do
+ post :create, params: valid_params
+
+ created_unit = Unit.last
+ expect(created_unit.housing_attributes.pluck(:name, :value)).to include(['Furniture', 'Furnished'])
+ end
+ end
+
context 'when unit creation fails validation' do
let(:building) { create(:building, elevator_accessible_default: true) }
let(:invalid_params) do
diff --git a/spec/controllers/vacancy_submissions_controller_spec.rb b/spec/controllers/vacancy_submissions_controller_spec.rb
new file mode 100644
index 000000000..550cb6c90
--- /dev/null
+++ b/spec/controllers/vacancy_submissions_controller_spec.rb
@@ -0,0 +1,547 @@
+###
+# Copyright 2016 - 2025 Green River Data Analysis, LLC
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe VacancySubmissionsController, type: :controller do
+ let(:user) { create(:user) }
+ let(:admin_role) { create(:admin_role) }
+
+ before do
+ authenticate(user)
+ user.roles << admin_role
+ end
+
+ describe 'GET #index' do
+ let!(:submission) { create(:vacancy_submission) }
+
+ it 'renders the index template' do
+ get :index
+ expect(response).to render_template(:index)
+ end
+
+ it 'assigns @vacancy_submissions' do
+ get :index
+ expect(assigns(:vacancy_submissions)).to include(submission)
+ end
+
+ it 'filters by status=active' do
+ active = create(:vacancy_submission, :active)
+ get :index, params: { status: 'active' }
+ expect(assigns(:vacancy_submissions)).to contain_exactly(active)
+ end
+
+ it 'returns 200' do
+ get :index
+ expect(response).to have_http_status(:ok)
+ end
+
+ context 'rendering the program filter' do
+ render_views
+
+ it 'renders a select2 dropdown of programs and an Update Filter button, not a text search box' do
+ create(:vacancy_submission, the_program: create(:program, name: 'Sunset Housing'))
+ get :index
+
+ expect(response.body).to include('select2')
+ expect(response.body).to include('Sunset Housing')
+ expect(response.body).to include('Update Filter')
+ expect(response.body).not_to include('name="q"')
+ end
+
+ it 'excludes programs that have no vacancy submissions' do
+ create(:vacancy_submission, the_program: create(:program, name: 'Sunset Housing'))
+ create(:program, name: 'Never Submitted')
+
+ get :index
+
+ expect(response.body).to include('Sunset Housing')
+ expect(response.body).not_to include('Never Submitted')
+ end
+ end
+ end
+
+ describe 'GET #new' do
+ it 'renders the new template' do
+ get :new
+ expect(response).to render_template(:new)
+ end
+
+ it 'assigns a new VacancySubmission' do
+ get :new
+ expect(assigns(:vacancy_submission)).to be_a_new(VacancySubmission)
+ end
+
+ it 'assigns @programs' do
+ create(:program)
+ get :new
+ expect(assigns(:programs)).to be_present
+ end
+ end
+
+ describe 'POST #create' do
+ let(:program) { create(:program) }
+ let(:building) { create(:building) }
+ let(:sub_program) { create(:sub_program, program: program, program_type: 'Project-Based', building: building) }
+
+ let(:valid_params) do
+ {
+ vacancy_submission: {
+ program_id: program.id,
+ sub_program_id: sub_program.id,
+ units: {
+ '0' => {
+ building_id: building.id,
+ unit_number: '1A',
+ },
+ },
+ },
+ }
+ end
+
+ it 'creates a new VacancySubmission' do
+ expect do
+ post :create, params: valid_params
+ end.to change(VacancySubmission, :count).by(1)
+ end
+
+ it 'sets status to awaiting_approval' do
+ post :create, params: valid_params
+ expect(VacancySubmission.last.status).to eq('awaiting_approval')
+ end
+
+ it 'derives is_voucher as false for Project-Based sub-program' do
+ post :create, params: valid_params
+ expect(VacancySubmission.last.draft_data['is_voucher']).to be false
+ end
+
+ it 'sets user_id to current user' do
+ post :create, params: valid_params
+ expect(VacancySubmission.last.user).to eq(user)
+ end
+
+ it 'redirects to index on success' do
+ post :create, params: valid_params
+ expect(response).to redirect_to(vacancy_submissions_path)
+ end
+
+ it 'notifies reviewers that a submission is awaiting review' do
+ notifier = instance_double(VacancySubmissions::Notifier)
+ allow(VacancySubmissions::Notifier).to receive(:new).and_return(notifier)
+ expect(notifier).to receive(:notify_submitted!)
+
+ post :create, params: valid_params
+ end
+
+ it 'does not notify when the submission is invalid' do
+ expect(VacancySubmissions::Notifier).not_to receive(:new)
+
+ post :create, params: { vacancy_submission: { program_id: program.id } }
+ end
+
+ context 'with a Tenant-Based sub-program (voucher)' do
+ let(:voucher_sp) { create(:sub_program, program: program, program_type: 'Tenant-Based') }
+
+ it 'sets is_voucher to true and does not require address' do
+ post :create, params: {
+ vacancy_submission: {
+ program_id: program.id,
+ sub_program_id: voucher_sp.id,
+ units: { '0' => { name: 'Voucher #1' } },
+ },
+ }
+ expect(VacancySubmission.last.draft_data['is_voucher']).to be true
+ end
+ end
+
+ context 'with missing required fields' do
+ it 'does not create a submission and re-renders new' do
+ expect do
+ post :create, params: { vacancy_submission: { program_id: program.id } }
+ end.not_to change(VacancySubmission, :count)
+ expect(response).to render_template(:new)
+ end
+ end
+
+ context 'with a variable-requiring rule but a blank variable' do
+ let!(:variable_rule) { create(:bedroom_exact) }
+
+ it 'does not create a submission and re-renders new' do
+ expect do
+ post :create, params: {
+ vacancy_submission: {
+ program_id: program.id,
+ sub_program_id: sub_program.id,
+ units: {
+ '0' => {
+ building_id: building.id,
+ unit_number: '1A',
+ requirements_attributes: {
+ '0' => { rule_id: variable_rule.id.to_s, positive: 'true', variable: '' },
+ },
+ },
+ },
+ },
+ }
+ end.not_to change(VacancySubmission, :count)
+ expect(response).to render_template(:new)
+ end
+ end
+ end
+
+ describe 'GET #sub_program_section (vacancy section)' do
+ render_views
+
+ let(:program) { create(:program) }
+ let(:building) { create(:building, name: 'Maple Court', address: '10 Maple St', city: 'Boston', state: 'MA', zip_code: '02118') }
+
+ def get_vacancy_section(sub_program)
+ get :sub_program_section, params: { section: 'vacancy', sub_program_id: sub_program.id }
+ end
+
+ it 'renders a building select2 dropdown listing existing buildings by name and street' do
+ sub_program = create(:sub_program, program: program, program_type: 'Project-Based', building: building)
+ get_vacancy_section(sub_program)
+ expect(response.body).to include('vacancy_submission[units][0][building_id]')
+ expect(response.body).to include('select2')
+ expect(response.body).to include('Maple Court (10 Maple St)')
+ end
+
+ it 'pre-selects the sub-program building by default' do
+ sub_program = create(:sub_program, program: program, program_type: 'Project-Based', building: building)
+ get_vacancy_section(sub_program)
+ expect(response.body).to match(/value="#{building.id}"[^>]*selected|selected[^>]*value="#{building.id}"/)
+ end
+
+ it 'shows the confidential warning when the sub-program is confidential' do
+ sub_program = create(:sub_program, program: program, program_type: 'Project-Based', building: building, confidential: true)
+ get_vacancy_section(sub_program)
+ expect(response.body).to include('This program is confidential, do not enter a real address as the unit number')
+ end
+
+ it 'omits the confidential warning when neither the program nor the sub-program is confidential' do
+ sub_program = create(:sub_program, program: program, program_type: 'Project-Based', building: building, confidential: false)
+ get_vacancy_section(sub_program)
+ expect(response.body).not_to include('This program is confidential')
+ end
+
+ it 'shows the confidential warning when the program is confidential, even if the sub-program is not' do
+ confidential_program = create(:program, confidential: true)
+ sub_program = create(:sub_program, program: confidential_program, program_type: 'Project-Based', building: building, confidential: false)
+ get_vacancy_section(sub_program)
+ expect(response.body).to include('This program is confidential, do not enter a real address as the unit number')
+ end
+ end
+
+ describe 'GET #show' do
+ let!(:submission) { create(:vacancy_submission) }
+
+ it 'renders the show template for a reviewer' do
+ get :show, params: { id: submission.id }
+ expect(response).to render_template(:show)
+ end
+
+ it 'assigns @submission' do
+ get :show, params: { id: submission.id }
+ expect(assigns(:submission)).to eq(submission)
+ end
+
+ it 'assigns @notes in chronological order' do
+ note1 = create(:vacancy_submission_note, vacancy_submission: submission, created_at: 1.hour.ago)
+ note2 = create(:vacancy_submission_note, vacancy_submission: submission, created_at: 1.minute.ago)
+ get :show, params: { id: submission.id }
+ expect(assigns(:notes).to_a).to eq([note1, note2])
+ end
+
+ context 'when the submission is active and has a linked unit' do
+ render_views
+
+ let(:unit) { create(:unit, building: create(:building)) }
+ let(:active_submission) { create(:vacancy_submission, :active) }
+
+ before do
+ active_submission.units = [{ 'building_id' => unit.building_id, 'unit_number' => '1A', 'unit_id' => unit.id, 'voucher_id' => 999 }]
+ active_submission.save!
+ end
+
+ it 'shows an Edit Unit link when the user can edit units' do
+ get :show, params: { id: active_submission.id }
+ expect(response.body).to include(edit_unit_path(unit))
+ end
+
+ it 'does not show an Edit Unit link when the user cannot edit units' do
+ user.roles = [create(:role, name: 'reviewer_only', can_review_vacancies: true)]
+ get :show, params: { id: active_submission.id }
+ expect(response.body).not_to include(edit_unit_path(unit))
+ end
+ end
+
+ context 'when user lacks both vacancy permissions' do
+ let(:no_vacancy_role) { create(:role, name: 'no_vacancy') }
+
+ before do
+ user.roles = [no_vacancy_role]
+ end
+
+ it 'redirects with not authorized' do
+ get :show, params: { id: submission.id }
+ expect(response).to redirect_to(root_path)
+ end
+ end
+ end
+
+ describe 'GET #edit' do
+ let!(:submission) { create(:vacancy_submission, :changes_requested) }
+
+ it 'renders the edit template' do
+ get :edit, params: { id: submission.id }
+ expect(response).to render_template(:edit)
+ end
+
+ it 'assigns @submission' do
+ get :edit, params: { id: submission.id }
+ expect(assigns(:submission)).to eq(submission)
+ end
+
+ it 'redirects with alert when submission is not resubmittable' do
+ submission.update!(status: 'awaiting_approval')
+ get :edit, params: { id: submission.id }
+ expect(response).to redirect_to(vacancy_submission_path(submission))
+ expect(flash[:alert]).to be_present
+ end
+ end
+
+ describe 'PATCH #update' do
+ let(:program) { create(:program) }
+ let(:building) { create(:building) }
+ let(:new_building) { create(:building) }
+ let(:sub_program) { create(:sub_program, program: program, program_type: 'Project-Based', building: building) }
+ let!(:submission) { create(:vacancy_submission, :changes_requested, the_program: program, the_sub_program: sub_program) }
+
+ let(:update_params) do
+ {
+ id: submission.id,
+ vacancy_submission: {
+ program_id: program.id,
+ sub_program_id: sub_program.id,
+ units: {
+ '0' => {
+ building_id: new_building.id,
+ unit_number: '2B',
+ },
+ },
+ },
+ }
+ end
+
+ it 'updates the submission draft_data' do
+ patch :update, params: update_params
+ expect(submission.reload.draft_data['units'][0]['building_id'].to_i).to eq(new_building.id)
+ end
+
+ it 'creates a note recording the address change' do
+ expect do
+ patch :update, params: update_params
+ end.to change(VacancySubmissionNote, :count).by(1)
+ expect(VacancySubmissionNote.last.body).to include('Vacancy')
+ end
+
+ it 'redirects to the show page on success' do
+ patch :update, params: update_params
+ expect(response).to redirect_to(vacancy_submission_path(submission))
+ end
+
+ it 'redirects with alert when submission is not resubmittable' do
+ submission.update!(status: 'awaiting_approval')
+ patch :update, params: update_params
+ expect(response).to redirect_to(vacancy_submission_path(submission))
+ expect(flash[:alert]).to be_present
+ end
+
+ it 're-renders edit when sub_program is missing' do
+ patch :update, params: {
+ id: submission.id,
+ vacancy_submission: { program_id: program.id },
+ }
+ expect(response).to render_template(:edit)
+ end
+ end
+
+ describe 'POST #approve' do
+ let!(:submission) { create(:vacancy_submission, status: 'awaiting_approval') }
+
+ it 'transitions status to active' do
+ expect do
+ post :approve, params: { id: submission.id }
+ end.to change { submission.reload.status }.from('awaiting_approval').to('active')
+ end
+
+ it 'creates a status_change note' do
+ expect do
+ post :approve, params: { id: submission.id }
+ end.to change(VacancySubmissionNote, :count).by(1)
+ expect(VacancySubmissionNote.last.note_type).to eq('status_change')
+ end
+
+ it 'redirects to the show page' do
+ post :approve, params: { id: submission.id }
+ expect(response).to redirect_to(vacancy_submission_path(submission))
+ end
+
+ it 'redirects with alert when submission is already active' do
+ submission.update!(status: 'active')
+ post :approve, params: { id: submission.id }
+ expect(response).to redirect_to(vacancy_submission_path(submission))
+ expect(flash[:alert]).to be_present
+ end
+
+ context 'when user can only submit (not review)' do
+ let(:submit_only_role) { create(:role, name: 'submit_only', can_add_vacancies: true) }
+
+ before { user.roles = [submit_only_role] }
+
+ it 'redirects with not authorized' do
+ post :approve, params: { id: submission.id }
+ expect(response).to redirect_to(root_path)
+ end
+ end
+
+ context 'when approval fails' do
+ it 'surfaces a RecordInvalid as a flash alert and rolls back the status' do
+ # Drive a real approval failure (no stubbing): a unit whose building can't
+ # be found makes Approval raise RecordInvalid, so this exercises the real
+ # rescue path and the reload genuinely proves the transaction rolled back.
+ submission.units = [{ 'building_id' => 0, 'unit_number' => '1A' }]
+ submission.save!
+
+ post :approve, params: { id: submission.id }
+
+ expect(response).to redirect_to(vacancy_submission_path(submission))
+ expect(flash[:alert]).to be_present
+ expect(submission.reload.status).to eq('awaiting_approval')
+ end
+
+ it 'rescues an unexpected error and surfaces a generic alert instead of raising' do
+ # The real approval path converts its domain failures into RecordInvalid, so
+ # an arbitrary non-domain error (e.g. an infrastructure fault) can only be
+ # produced with a stub. This is the one branch that needs a seam; the deeper
+ # fix would be making #approve! injectable so we needn't stub the class.
+ allow(VacancySubmission).to receive(:find).and_return(submission)
+ allow(submission).to receive(:approve!).and_raise(StandardError.new('boom'))
+
+ expect { post :approve, params: { id: submission.id } }.not_to raise_error
+ expect(response).to redirect_to(vacancy_submission_path(submission))
+ expect(flash[:alert]).to be_present
+ end
+ end
+ end
+
+ describe 'POST #return_submission' do
+ let!(:submission) { create(:vacancy_submission, status: 'awaiting_approval') }
+
+ it 'transitions status to return_changes_requested' do
+ expect do
+ post :return_submission, params: { id: submission.id, body: 'Please fix the address.' }
+ end.to change { submission.reload.status }.to('return_changes_requested')
+ end
+
+ it 'creates a reviewer_note and a status_change note' do
+ expect do
+ post :return_submission, params: { id: submission.id, body: 'Fix it.' }
+ end.to change(VacancySubmissionNote, :count).by(2)
+ types = VacancySubmissionNote.last(2).map(&:note_type)
+ expect(types).to include('reviewer_note', 'status_change')
+ end
+
+ it 'notifies the submitter that changes were requested' do
+ notifier = instance_double(VacancySubmissions::Notifier)
+ allow(VacancySubmissions::Notifier).to receive(:new).and_return(notifier)
+ expect(notifier).to receive(:notify_changes_requested!)
+
+ post :return_submission, params: { id: submission.id, body: 'Fix it.' }
+ end
+
+ it 'does not notify when the body is blank' do
+ expect(VacancySubmissions::Notifier).not_to receive(:new)
+
+ post :return_submission, params: { id: submission.id, body: '' }
+ end
+
+ it 'redirects with alert when body is blank' do
+ post :return_submission, params: { id: submission.id, body: '' }
+ expect(response).to redirect_to(vacancy_submission_path(submission))
+ expect(flash[:alert]).to be_present
+ expect(submission.reload.status).to eq('awaiting_approval')
+ end
+
+ it 'redirects with alert when submission is active' do
+ submission.update!(status: 'active')
+ post :return_submission, params: { id: submission.id, body: 'Fix it.' }
+ expect(response).to redirect_to(vacancy_submission_path(submission))
+ expect(flash[:alert]).to be_present
+ end
+
+ context 'when user can only submit (not review)' do
+ let(:submit_only_role) { create(:role, name: 'submit_only', can_add_vacancies: true) }
+
+ before { user.roles = [submit_only_role] }
+
+ it 'redirects with not authorized' do
+ post :return_submission, params: { id: submission.id, body: 'Fix it.' }
+ expect(response).to redirect_to(root_path)
+ end
+ end
+ end
+
+ describe 'POST #resubmit' do
+ let!(:submission) { create(:vacancy_submission, :changes_requested) }
+
+ it 'transitions status to awaiting_approval' do
+ expect do
+ post :resubmit, params: { id: submission.id }
+ end.to change { submission.reload.status }.to('awaiting_approval')
+ end
+
+ it 'creates a status_change note' do
+ expect do
+ post :resubmit, params: { id: submission.id }
+ end.to change(VacancySubmissionNote, :count).by(1)
+ end
+
+ it 'redirects to the show page' do
+ post :resubmit, params: { id: submission.id }
+ expect(response).to redirect_to(vacancy_submission_path(submission))
+ end
+
+ it 'notifies reviewers that the submission was resubmitted' do
+ notifier = instance_double(VacancySubmissions::Notifier)
+ allow(VacancySubmissions::Notifier).to receive(:new).and_return(notifier)
+ expect(notifier).to receive(:notify_resubmitted!)
+
+ post :resubmit, params: { id: submission.id }
+ end
+
+ it 'redirects with alert when not resubmittable' do
+ submission.update!(status: 'awaiting_approval')
+ post :resubmit, params: { id: submission.id }
+ expect(response).to redirect_to(vacancy_submission_path(submission))
+ expect(flash[:alert]).to be_present
+ end
+
+ context 'when user lacks can_add_vacancies' do
+ let(:review_only_role) { create(:role, name: 'review_only', can_review_vacancies: true) }
+
+ before { user.roles = [review_only_role] }
+
+ it 'redirects with not authorized' do
+ post :resubmit, params: { id: submission.id }
+ expect(response).to redirect_to(root_path)
+ end
+ end
+ end
+end
diff --git a/spec/factories/housing_attributes.rb b/spec/factories/housing_attributes.rb
index 6238a701c..53b671b36 100644
--- a/spec/factories/housing_attributes.rb
+++ b/spec/factories/housing_attributes.rb
@@ -4,8 +4,20 @@
# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
###
+# frozen_string_literal: true
+
FactoryBot.define do
factory :housing_attribute do
-
+ trait :with_value do
+ name { 'Furniture' }
+ value { 'Furnished' }
+ include_value { true }
+ end
+
+ trait :without_value do
+ name { 'Dishwasher' }
+ value { nil }
+ include_value { false }
+ end
end
end
diff --git a/spec/factories/roles.rb b/spec/factories/roles.rb
index 09195508a..eee760cb4 100644
--- a/spec/factories/roles.rb
+++ b/spec/factories/roles.rb
@@ -49,6 +49,7 @@
can_view_units { true }
can_edit_units { true }
can_add_vacancies { true }
+ can_review_vacancies { true }
can_view_contacts { true }
can_edit_contacts { true }
can_view_rule_list { true }
diff --git a/spec/factories/rules.rb b/spec/factories/rules.rb
index d40703cda..ca0014849 100644
--- a/spec/factories/rules.rb
+++ b/spec/factories/rules.rb
@@ -414,6 +414,10 @@
name { 'Ok wih SRO' }
verb { 'be' }
end
+ factory :bedroom_exact, class: 'Rules::BedroomExact' do
+ name { 'Bedroom exact' }
+ verb { 'have' }
+ end
factory :evicted, class: 'Rules::NeverEvicted' do
name { 'Never been evicted' }
verb { 'have' }
diff --git a/spec/factories/vacancy_submission_notes.rb b/spec/factories/vacancy_submission_notes.rb
new file mode 100644
index 000000000..0d2d59da5
--- /dev/null
+++ b/spec/factories/vacancy_submission_notes.rb
@@ -0,0 +1,21 @@
+###
+# Copyright 2016 - 2025 Green River Data Analysis, LLC
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :vacancy_submission_note do
+ association :vacancy_submission
+ association :user
+ note_type { 'status_change' }
+ body { 'Status changed to Active.' }
+
+ trait :reviewer_note do
+ note_type { 'reviewer_note' }
+ body { 'Please update the street address.' }
+ end
+ end
+end
diff --git a/spec/factories/vacancy_submissions.rb b/spec/factories/vacancy_submissions.rb
new file mode 100644
index 000000000..ca8d6affe
--- /dev/null
+++ b/spec/factories/vacancy_submissions.rb
@@ -0,0 +1,51 @@
+###
+# Copyright 2016 - 2025 Green River Data Analysis, LLC
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/production/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :vacancy_submission do
+ association :user
+ status { 'awaiting_approval' }
+
+ transient do
+ the_program { create(:program) }
+ the_sub_program { create(:sub_program, program: the_program, program_type: 'Project-Based', building: create(:building)) }
+ is_voucher { false }
+ end
+
+ draft_data do
+ data = {
+ 'program_id' => the_program.id,
+ 'sub_program_id' => the_sub_program.id,
+ 'route' => 'PSH Resource',
+ 'is_voucher' => is_voucher,
+ }
+ if is_voucher
+ data['units'] = [{ 'name' => 'Voucher #1' }]
+ else
+ data['units'] = [{ 'building_id' => the_sub_program.building_id, 'unit_number' => '1A' }]
+ end
+ data
+ end
+
+ trait :voucher do
+ is_voucher { true }
+
+ transient do
+ the_sub_program { create(:sub_program, program: the_program, program_type: 'Tenant-Based') }
+ end
+ end
+
+ trait :changes_requested do
+ status { 'return_changes_requested' }
+ end
+
+ trait :active do
+ status { 'active' }
+ end
+ end
+end
diff --git a/spec/factories/weighting_rules.rb b/spec/factories/weighting_rules.rb
new file mode 100644
index 000000000..dd8da46af
--- /dev/null
+++ b/spec/factories/weighting_rules.rb
@@ -0,0 +1,14 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :weighting_rule, class: 'WeightingRule' do
+ # MatchRoutes::Default is seeded in spec_helper via MatchRoutes::Base.ensure_all
+ route { MatchRoutes::Default.first }
+ end
+end
diff --git a/spec/helpers/markdown_helper_spec.rb b/spec/helpers/markdown_helper_spec.rb
new file mode 100644
index 000000000..3f853326f
--- /dev/null
+++ b/spec/helpers/markdown_helper_spec.rb
@@ -0,0 +1,68 @@
+###
+# Copyright Green River Data Group, Inc.
+#
+# License detail: https://github.com/greenriver/boston-cas/blob/stable/LICENSE.md
+###
+
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe MarkdownHelper, type: :helper do
+ describe '#render_markdown' do
+ it 'renders bold markdown as HTML' do
+ expect(helper.render_markdown('**bold**')).to include('
bold')
+ end
+
+ it 'renders links as anchor tags' do
+ html = helper.render_markdown('[View submission](https://example.com/x)')
+ expect(html).to include('
one')
+ expect(html).to include('two')
+ end
+
+ it 'autolinks bare URLs' do
+ expect(helper.render_markdown('see https://example.com')).to include('alert(1)')
+ expect(html).not_to include('