diff --git a/app/javascript/controllers/submit_on_change_controller.js b/app/javascript/controllers/submit_on_change_controller.js
new file mode 100644
index 00000000..b08e471c
--- /dev/null
+++ b/app/javascript/controllers/submit_on_change_controller.js
@@ -0,0 +1,7 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ submit() {
+ this.element.requestSubmit()
+ }
+}
diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb
index fd5a00a8..6edd18c3 100644
--- a/app/views/accounts/index.html.erb
+++ b/app/views/accounts/index.html.erb
@@ -20,16 +20,7 @@
<%= table[:heading] %>
- <% if table[:page].number > 1 || table[:page].before_last? %>
-
- <% end %>
+ <%= render "shared/pagination", page: table[:page], param: table[:page_param] %>
- <% if table[:page].number > 1 || table[:page].before_last? %>
-
- <% end %>
+ <%= render "shared/pagination", page: table[:page], param: table[:page_param] %>
<% end %>
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
index 95ab9c9d..2d9db756 100644
--- a/app/views/layouts/admin.html.erb
+++ b/app/views/layouts/admin.html.erb
@@ -1,5 +1,6 @@
"
data-light-theme="<%= theme_attributes[:"data-light-theme"] %>"
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index e8064e75..cf8c3991 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -1,5 +1,6 @@
"
data-light-theme="<%= theme_attributes[:"data-light-theme"] %>"
diff --git a/app/views/layouts/login.html.erb b/app/views/layouts/login.html.erb
new file mode 100644
index 00000000..6af44b79
--- /dev/null
+++ b/app/views/layouts/login.html.erb
@@ -0,0 +1,21 @@
+
+<%= tag.html(lang: I18n.locale, **theme_attributes, "data-controller": "theme") do %>
+
+ <%= Appkit.config.app_display_name %>
+
+
+
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+ <%= render "appkit/shared/pwa_meta" %>
+
+ <%= stylesheet_link_tag "application", media: "all", "data-turbo-track": "reload" %>
+ <%= javascript_importmap_tags %>
+
+
+
+ <%= render "appkit/shared/flashes" %>
+ <%= yield %>
+
+
+<% end %>
diff --git a/app/views/shared/_pagination.html.erb b/app/views/shared/_pagination.html.erb
index 4d193bd1..97906ddc 100644
--- a/app/views/shared/_pagination.html.erb
+++ b/app/views/shared/_pagination.html.erb
@@ -1,14 +1,14 @@
-<%# locals: (page:) %>
+<%# locals: (page:, param: :page) %>
<% show_previous = page.number > 1 %>
<% show_next = page.before_last? %>
<% if show_previous || show_next %>
<% end %>
diff --git a/app/views/users/_user.html.erb b/app/views/users/_user.html.erb
index 88d07021..0af13d67 100644
--- a/app/views/users/_user.html.erb
+++ b/app/views/users/_user.html.erb
@@ -2,10 +2,10 @@
<%= link_to user.name, user.current? ? edit_user_profile_path(user) : user_profile_path(user) %>
- <%= form_with model: user, url: user_path(user), data: { controller: "form" }, method: :patch do |form| %>
+ <%= form_with model: user, url: user_path(user), data: { controller: "submit-on-change" }, method: :patch do |form| %>
diff --git a/app/views/users/profiles/edit.html.erb b/app/views/users/profiles/edit.html.erb
index 90c520e1..5ba4d482 100644
--- a/app/views/users/profiles/edit.html.erb
+++ b/app/views/users/profiles/edit.html.erb
@@ -1,7 +1,5 @@
<% content_for(:title) { t(".title") } %>
-<%= render layout: "shared/title_bar", locals: { title: t('.title') } do %>
- <%= link_to t("common.back"), users_path %>
-<% end %>
+<%= render "shared/title_bar", title: t(".title") %>
<%= form_with model: @user, url: user_profile_path(@user) do |form| %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 1235c87e..8a9e9cae 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -110,8 +110,10 @@ en:
amount: Amount
category: Category
category:
+ direction: Direction
kind: Kind
name: Name
+ parent_category: Parent category
chattel:
brand: Brand
kind: Kind
@@ -121,6 +123,7 @@ en:
notes: Notes
purchase_price: Purchase price
purchase_transaction: Transaction
+ purchase_transaction_id: Purchase transaction
purchased_at: Purchase Date
serial_number: Serial Number
warranty_document: Warranty document
@@ -140,10 +143,21 @@ en:
account: Account
amount: Amount
appropriation: Appropriation
+ booked_at: Booked at
category: Category
+ category_id: Category
+ creditor: Creditor
date: Date
+ debitor: Debitor
description: Description
+ id: Id
+ interest_at: Interest at
+ note: Note
+ original_balance_after_mutation: Original balance after mutation
+ original_note: Original note
+ original_tag: Original tag
proof_of_purchase: Proof of purchase
+ type: Type
transaction_split:
appropriation: Appropriation
errors:
diff --git a/config/locales/it.yml b/config/locales/it.yml
index cda24834..a0469fa6 100644
--- a/config/locales/it.yml
+++ b/config/locales/it.yml
@@ -110,8 +110,10 @@ it:
amount: Importo
category: Categoria
category:
+ direction: Direzione
kind: Tipo
name: Nome
+ parent_category: Categoria principale
chattel:
brand: Marca
kind: Tipo
@@ -121,6 +123,7 @@ it:
notes: Note
purchase_price: Prezzo d'acquisto
purchase_transaction: Transazione
+ purchase_transaction_id: Transazione d'acquisto
purchased_at: Data d'acquisto
serial_number: Numero seriale
warranty_document: Documento di garanzia
@@ -140,10 +143,21 @@ it:
account: Conto
amount: Importo
appropriation: Stanziamento
+ booked_at: Data registrazione
category: Categoria
+ category_id: Categoria
+ creditor: Creditore
date: Data
+ debitor: Debitore
description: Descrizione
+ id: ID
+ interest_at: Data valuta
+ note: Nota
+ original_balance_after_mutation: Saldo originale dopo il movimento
+ original_note: Descrizione originale
+ original_tag: Etichetta originale
proof_of_purchase: Prova d'acquisto
+ type: Tipo
transaction_split:
appropriation: Stanziamento
errors:
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index 1f619068..56efe3e9 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -110,8 +110,10 @@ nl:
amount: Bedrag
category: Categorie
category:
+ direction: Richting
kind: Type
name: Naam
+ parent_category: Hoofdcategorie
chattel:
brand: Merk
kind: Type
@@ -121,6 +123,7 @@ nl:
notes: Notities
purchase_price: Aankoopprijs
purchase_transaction: Transactie
+ purchase_transaction_id: Aankooptransactie
purchased_at: Aankoopdatum
serial_number: Serienummer
warranty_document: Garantiedocument
@@ -140,10 +143,21 @@ nl:
account: Rekening
amount: Bedrag
appropriation: Begrotingspost
+ booked_at: Boekingsdatum
category: Categorie
+ category_id: Categorie
+ creditor: Crediteur
date: Datum
+ debitor: Debiteur
description: Omschrijving
+ id: ID
+ interest_at: Rentedatum
+ note: Opmerking
+ original_balance_after_mutation: Oorspronkelijk saldo na mutatie
+ original_note: Oorspronkelijke omschrijving
+ original_tag: Oorspronkelijk label
proof_of_purchase: Aankoopbewijs
+ type: Type
transaction_split:
appropriation: Begrotingspost
errors:
diff --git a/test/integration/form_labels_test.rb b/test/integration/form_labels_test.rb
new file mode 100644
index 00000000..66fda278
--- /dev/null
+++ b/test/integration/form_labels_test.rb
@@ -0,0 +1,58 @@
+require "test_helper"
+
+class FormLabelsTest < ActionDispatch::IntegrationTest
+ LABELS = {
+ nl: {
+ transaction: [ "ID", "Debiteur", "Crediteur", "Categorie", "Opmerking", "Boekingsdatum", "Rentedatum", "Type",
+ "Oorspronkelijke omschrijving", "Oorspronkelijk saldo na mutatie", "Oorspronkelijk label" ],
+ category: [ "Richting", "Hoofdcategorie" ],
+ chattel: [ "Aankooptransactie" ]
+ },
+ it: {
+ transaction: [ "ID", "Debitore", "Creditore", "Categoria", "Nota", "Data registrazione", "Data valuta", "Tipo",
+ "Descrizione originale", "Saldo originale dopo il movimento", "Etichetta originale" ],
+ category: [ "Direzione", "Categoria principale" ],
+ chattel: [ "Transazione d'acquisto" ]
+ }
+ }.freeze
+
+ PAGES = {
+ transaction: -> { edit_transaction_path(transactions(:debit_grocery)) },
+ category: -> { edit_category_path(categories(:groceries)) },
+ chattel: -> { edit_chattel_path(chattels(:one)) }
+ }.freeze
+
+ setup do
+ @user = users(:admin)
+ end
+
+ LABELS.each_key do |locale|
+ names = { nl: "Dutch", it: "Italian" }.fetch(locale)
+
+ test "the transaction form labels its fields in #{names}" do
+ assert_labels locale, :transaction
+ end
+
+ test "the category form labels direction and parent category in #{names}" do
+ assert_labels locale, :category
+ end
+
+ test "the chattel form labels the purchase transaction in #{names}" do
+ assert_labels locale, :chattel
+ end
+ end
+
+ private
+
+ def assert_labels(locale, form)
+ @user.update!(locale: locale)
+ sign_in_as(@user)
+
+ get instance_exec(&PAGES.fetch(form))
+
+ assert_response :success
+ LABELS.dig(locale, form).each do |text|
+ assert_select "form label", text: text
+ end
+ end
+end
diff --git a/test/integration/login_layout_test.rb b/test/integration/login_layout_test.rb
new file mode 100644
index 00000000..56525dbe
--- /dev/null
+++ b/test/integration/login_layout_test.rb
@@ -0,0 +1,11 @@
+require "test_helper"
+
+class LoginLayoutTest < ActionDispatch::IntegrationTest
+ test "a signed-out visitor's page leaves the theme to the browser" do
+ get new_session_path
+
+ assert_select "html[data-controller=?]", "theme"
+ assert_select "html[data-theme]", count: 0
+ assert_select "html[data-color-scheme]", count: 0
+ end
+end
diff --git a/test/integration/page_language_test.rb b/test/integration/page_language_test.rb
new file mode 100644
index 00000000..7429f9fa
--- /dev/null
+++ b/test/integration/page_language_test.rb
@@ -0,0 +1,33 @@
+require "test_helper"
+
+class PageLanguageTest < ActionDispatch::IntegrationTest
+ test "a Dutch user gets lang nl" do
+ sign_in_as(users(:admin))
+
+ get dashboard_index_path
+
+ assert_select "html[lang=?]", "nl"
+ end
+
+ test "an English user gets lang en" do
+ sign_in_as(users(:member))
+
+ get dashboard_index_path
+
+ assert_select "html[lang=?]", "en"
+ end
+
+ test "the admin layout declares the user's language" do
+ sign_in_as(users(:admin))
+
+ get admin_root_path
+
+ assert_select "html[lang=?]", "nl"
+ end
+
+ test "a signed-out visitor gets the default locale" do
+ get new_session_path
+
+ assert_select "html[lang=?]", I18n.default_locale.to_s
+ end
+end
diff --git a/test/integration/pagination_navigation_test.rb b/test/integration/pagination_navigation_test.rb
index c4532932..4f7e58cc 100644
--- a/test/integration/pagination_navigation_test.rb
+++ b/test/integration/pagination_navigation_test.rb
@@ -21,6 +21,29 @@ class PaginationNavigationTest < ActionDispatch::IntegrationTest
assert_select "nav.pagination a[rel='next']", 0
end
+ test "external accounts pagination writes external_page" do
+ create_external_accounts(51)
+
+ get accounts_path
+
+ assert_response :success
+ assert_select "nav.pagination a[rel='next'][href*='external_page=2']", 2
+ assert_select "nav.pagination a[rel='next'][href*='own_page']", 0
+
+ get accounts_path, params: { external_page: 2 }
+
+ assert_select "nav.pagination a[rel='prev'][href*='external_page=1']", 2
+ assert_select "nav.pagination a[rel='next']", 0
+ end
+
+ test "accounts pagination keeps the other query parameters" do
+ create_accounts(51)
+
+ get accounts_path, params: { external_page: 1, q: "kept" }
+
+ assert_select "nav.pagination a[rel='next'][href*='own_page=2'][href*='external_page=1'][href*='q=kept']", 2
+ end
+
test "transactions pagination keeps filter params and does not show next on overflow pages" do
create_uncategorized_transactions(21)
@@ -64,6 +87,15 @@ def create_accounts(count)
end
end
+ def create_external_accounts(count)
+ count.times do |index|
+ Account.create!(
+ account_numbers: [ AccountNumber.new(number: format("NL98SHOP%010d", index)) ],
+ name: "External Pagination Account #{index}"
+ )
+ end
+ end
+
def create_uncategorized_transactions(count)
debitor = accounts(:checking)
creditor = accounts(:unknown)
diff --git a/test/integration/users/profiles_test.rb b/test/integration/users/profiles_test.rb
index a21597b4..f116e845 100644
--- a/test/integration/users/profiles_test.rb
+++ b/test/integration/users/profiles_test.rb
@@ -65,4 +65,20 @@ class Users::ProfilesTest < ActionDispatch::IntegrationTest
assert_select "form > footer a[role=button][href=?]", user_profile_path(@member)
assert_select "form > footer button.button-danger", count: 0
end
+
+ test "your own profile edit page has no back link" do
+ sign_in_as(@member)
+
+ get edit_user_profile_path(@member)
+
+ assert_select "a[href=?]", users_path, count: 0
+ end
+
+ test "another user's profile page links back to the users list" do
+ sign_in_as(@member)
+
+ get user_profile_path(@other)
+
+ assert_select "a[href=?]", users_path, text: I18n.t("common.back", locale: @member.locale)
+ end
end
diff --git a/test/integration/users_index_test.rb b/test/integration/users_index_test.rb
index 71e523b2..80496728 100644
--- a/test/integration/users_index_test.rb
+++ b/test/integration/users_index_test.rb
@@ -19,4 +19,20 @@ class UsersIndexTest < ActionDispatch::IntegrationTest
assert_select "button.button-danger", count: 0
assert_select "main > header > h1", count: 1
end
+
+ test "your own role box is disabled" do
+ sign_in_as(users(:admin))
+
+ get users_path
+
+ assert_select "input##{dom_id(users(:admin), :role)}[disabled]"
+ assert_select "input##{dom_id(users(:member), :role)}:not([disabled])"
+ end
+
+ test "a member sees every role box disabled" do
+ get users_path
+
+ assert_select "input[type=checkbox][id^=role_user_]", minimum: 2
+ assert_select "input[type=checkbox][id^=role_user_]:not([disabled])", count: 0
+ end
end
diff --git a/test/system/user_roles_test.rb b/test/system/user_roles_test.rb
new file mode 100644
index 00000000..5d45ff74
--- /dev/null
+++ b/test/system/user_roles_test.rb
@@ -0,0 +1,36 @@
+require "application_system_test_case"
+
+class UserRolesTest < ApplicationSystemTestCase
+ setup do
+ @admin = users(:admin)
+ @member = users(:member)
+ sign_in_as(@admin)
+ end
+
+ test "an administrator makes a member an administrator by ticking the role box" do
+ visit users_url
+
+ find("label[for='#{dom_id(@member, :role)}']").click
+
+ assert_selector "label[for='#{dom_id(@member, :role)}']",
+ text: I18n.t("users.user.role_administrator", locale: locale)
+ assert_predicate @member.reload, :administrator?
+ end
+
+ test "an administrator makes another administrator a member by unticking the role box" do
+ @member.update!(role: :administrator)
+ visit users_url
+
+ find("label[for='#{dom_id(@member, :role)}']").click
+
+ assert_selector "label[for='#{dom_id(@member, :role)}']",
+ text: I18n.t("users.user.role_member", locale: locale)
+ assert_predicate @member.reload, :member?
+ end
+
+ private
+
+ def dom_id(record, prefix)
+ ActionView::RecordIdentifier.dom_id(record, prefix)
+ end
+end