From a4745c9cb8c14069d6020ad6ff1210b00149db89 Mon Sep 17 00:00:00 2001 From: cryptogopher Date: Thu, 6 Jul 2023 18:34:16 +0200 Subject: [PATCH] Add Units --- Gemfile | 2 + Gemfile.lock | 3 + .../images/pictograms/delete-outline.svg | 1 + app/assets/images/pictograms/plus-outline.svg | 1 + .../images/pictograms/weight-kilogram.svg | 1 + app/assets/stylesheets/application.css | 24 ++++++-- app/controllers/units_controller.rb | 57 +++++++++++++++++++ app/controllers/users_controller.rb | 1 - app/helpers/application_helper.rb | 22 ++++--- app/models/unit.rb | 26 +++++++++ app/models/user.rb | 2 + app/views/units/_form.html.erb | 26 +++++++++ app/views/units/edit.html.erb | 1 + app/views/units/index.html.erb | 35 ++++++++++++ app/views/units/new.html.erb | 22 +++++++ config/initializers/core_ext.rb | 1 + config/locales/en.yml | 20 +++++++ config/routes.rb | 2 + db/migrate/20230602185352_create_units.rb | 19 +++++++ db/schema.rb | 22 ++++++- lib/core_ext/big_decimal/formatting.rb | 12 ++++ 21 files changed, 285 insertions(+), 15 deletions(-) create mode 100644 app/assets/images/pictograms/delete-outline.svg create mode 100644 app/assets/images/pictograms/plus-outline.svg create mode 100644 app/assets/images/pictograms/weight-kilogram.svg create mode 100644 app/controllers/units_controller.rb create mode 100644 app/models/unit.rb create mode 100644 app/views/units/_form.html.erb create mode 120000 app/views/units/edit.html.erb create mode 100644 app/views/units/index.html.erb create mode 100644 app/views/units/new.html.erb create mode 100644 config/initializers/core_ext.rb create mode 100644 db/migrate/20230602185352_create_units.rb create mode 100644 lib/core_ext/big_decimal/formatting.rb diff --git a/Gemfile b/Gemfile index 214f1b0..e1872d6 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,8 @@ gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ] gem "devise" +gem 'awesome_nested_set' + group :development, :test do gem "byebug" end diff --git a/Gemfile.lock b/Gemfile.lock index eb73ba5..80c448b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -68,6 +68,8 @@ GEM tzinfo (~> 2.0) addressable (2.8.4) public_suffix (>= 2.0.2, < 6.0) + awesome_nested_set (3.5.0) + activerecord (>= 4.0.0, < 7.1) bcrypt (3.1.18) bindex (0.8.1) builder (3.2.4) @@ -210,6 +212,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + awesome_nested_set byebug capybara devise diff --git a/app/assets/images/pictograms/delete-outline.svg b/app/assets/images/pictograms/delete-outline.svg new file mode 100644 index 0000000..bcf8601 --- /dev/null +++ b/app/assets/images/pictograms/delete-outline.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/pictograms/plus-outline.svg b/app/assets/images/pictograms/plus-outline.svg new file mode 100644 index 0000000..dd4c86d --- /dev/null +++ b/app/assets/images/pictograms/plus-outline.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/pictograms/weight-kilogram.svg b/app/assets/images/pictograms/weight-kilogram.svg new file mode 100644 index 0000000..e7ebb64 --- /dev/null +++ b/app/assets/images/pictograms/weight-kilogram.svg @@ -0,0 +1 @@ + diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 7ff4439..db853e7 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -102,13 +102,17 @@ input:read-only:hover { .nav-menu .left > * { float: left; } +/* TODO: inactive tab color #d0d0d0 or #c7c7c7 */ .nav-menu .tab { border: none; + border-bottom: solid 0.2rem #a0a0a0; border-radius: 0; font-size: 0.9rem; - margin: 0 0.8rem; padding: 0.5rem 0.8rem; } +.nav-menu .tab:hover { + border-bottom: solid 0.2rem #009ade; +} .nav-menu .tab.active { border-bottom: solid 0.2rem; color: #009ade; @@ -276,20 +280,23 @@ table.items tbody tr:hover { background-color: #f3f3f3; } table.items th, -table.items td:not(:first-child):not(.actions) { +table.items td { padding: 0 0.8rem; text-align: center; } table.items td { border-top: 1px solid #dddddd; +} +table.items td:first-child { padding: 0; + text-align: left; } table.items a { color: black; cursor: pointer; display: block; font-weight: normal; - line-height: 2.5rem; + line-height: 2.2rem; padding: 0 0.8rem; text-decoration: none; } @@ -318,11 +325,15 @@ table.items svg { vertical-align: middle; width: 1.2rem; } +table.items td.number { + text-align: right; +} table.items td.actions { - padding-left: 0.8rem; + padding: 0 0 0 0.8rem; text-align: right; } table.items button { + font-weight: normal; margin-right: 0.25rem; padding: 0.25rem; } @@ -335,3 +346,8 @@ table.items select:focus-within, table.items select:focus-visible { color: black; } + +.contextual { + float: right; + margin-top: 1rem; +} diff --git a/app/controllers/units_controller.rb b/app/controllers/units_controller.rb new file mode 100644 index 0000000..bbd5c9c --- /dev/null +++ b/app/controllers/units_controller.rb @@ -0,0 +1,57 @@ +class UnitsController < ApplicationController + before_action :find_unit, only: [:edit, :update, :destroy] + + before_action except: :index do + raise AccessForbidden unless current_user.at_least(:active) + end + before_action only: [:edit, :update, :destroy] do + raise ArgumentError unless current_user == @unit.user + end + + def index + @units = current_user.units + end + + def new + @unit = current_user.units.new + end + + def create + @unit = current_user.units.new(unit_params) + if @unit.save + flash[:notice] = t(".success") + redirect_to units_url + else + render :new + end + end + + def edit + end + + def update + if @unit.update(unit_params) + flash[:notice] = t(".success") + redirect_to units_url + else + render :edit + end + end + + def destroy + if @unit.destroy + flash[:notice] = t(".success") + end + redirect_to units_url + end + + private + + def unit_params + params.require(:unit).permit(:symbol, :name, :base_id, :multiplier) + end + + def find_unit + @unit = Unit.find(params[:id]) + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 503ce07..a8fc04f 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -49,5 +49,4 @@ class UsersController < ApplicationController def find_user @user = User.find(params[:id]) end - end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 998c020..f0845a7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -69,21 +69,27 @@ module ApplicationHelper end def navigation_menu - #menu_items = {right: [[:users, :index],]} + menu_items = {right: [ + [".users", "account-multiple-outline", users_path, :admin], + [".units", "weight-kilogram", units_path, :restricted], + ]} - content_tag :div, class: "right" do - if current_user.at_least(:admin) - image_link_to t(".users"), "account-multiple-outline", users_path, class: "tab", - current: :active + menu_items.map do |alignment, items| + content_tag :div, class: alignment do + items.map do |label, image, path, status| + if current_user.at_least(status) + image_link_to t(label), image, path, class: "tab", current: :active + end + end.join.html_safe end - end + end.join.html_safe end private def image_element_to(type, name, image = nil, options = nil, html_options = {}) - current = html_options.delete(:current) - return "" if (current == :hide) && (url_for(options) == request.path) + current = (url_for(options) == request.path) && html_options.delete(:current) + return "" if current == :hide name = svg_tag("pictograms/#{image}") + name if image html_options[:class] = class_names(html_options[:class], "button", active: current == :active) diff --git a/app/models/unit.rb b/app/models/unit.rb new file mode 100644 index 0000000..4f50405 --- /dev/null +++ b/app/models/unit.rb @@ -0,0 +1,26 @@ +class Unit < ApplicationRecord + attribute :multiplier, default: 1 + + belongs_to :user, optional: true + belongs_to :base, optional: true, class_name: "Unit" + + validates :symbol, presence: true, uniqueness: {scope: :user_id} + validates :multiplier, numericality: {equal_to: 1}, unless: :base + validates :multiplier, numericality: {other_than: 1}, if: :base + validate if: -> { base.present? } do + errors.add(:base, :only_top_level_base_units) unless base.base.nil? + end + + acts_as_nested_set parent_column: :base_id, scope: :user, dependent: :destroy, order_column: :multiplier + + scope :defaults, -> { where(user: nil) } + + after_save if: :base do |record| + record.move_to_ordered_child_of(record.base, :multiplier) + end + + before_destroy do + # TODO: disallow destruction if any object depends on this unit + nil + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 9b0997b..e6275ef 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -11,6 +11,8 @@ class User < ApplicationRecord disabled: 0, # administratively disallowed to sign in }, default: :active + has_many :units, -> { order :lft }, dependent: :destroy + def at_least(status) User.statuses[self.status] >= User.statuses[status] end diff --git a/app/views/units/_form.html.erb b/app/views/units/_form.html.erb new file mode 100644 index 0000000..52a255f --- /dev/null +++ b/app/views/units/_form.html.erb @@ -0,0 +1,26 @@ +
> +

<%= t ".heading_new_unit" %>

+ + <%= labelled_form_for @unit, + url: project_units_path(@project), + html: {id: 'unit-add-form', name: 'unit-add-form'} do |f| %> + + <%= render partial: 'units/form', locals: {f: f} %> + <%= submit_tag l(:button_create) %> + <%= link_to l(:button_cancel), "#", onclick: '$("#add-unit").hide(); return false;' %> + <% end %> +
+
+ +<%= error_messages_for @unit %> + +
+
+
+

<%= f.text_field :shortname, required: true, size: 20 %>

+
+
+

<%= f.text_field :name, size: 60 %>

+
+
+
diff --git a/app/views/units/edit.html.erb b/app/views/units/edit.html.erb new file mode 120000 index 0000000..f9a278c --- /dev/null +++ b/app/views/units/edit.html.erb @@ -0,0 +1 @@ +/var/www/app/views/units/new.html.erb \ No newline at end of file diff --git a/app/views/units/index.html.erb b/app/views/units/index.html.erb new file mode 100644 index 0000000..1dbdfc1 --- /dev/null +++ b/app/views/units/index.html.erb @@ -0,0 +1,35 @@ +
+ <% if current_user.at_least(:active) %> + <%= image_link_to t(".add_unit"), "plus-outline", new_unit_path %> + <% end %> +
+ + + + + + + + <% if current_user.at_least(:active) %> + + <% end %> + + + + <% Unit.each_with_level(@units) do |unit, level| %> + + + + + <% if current_user.at_least(:active) %> + + <% end %> + + <% end %> + +
<%= User.human_attribute_name(:symbol).capitalize %><%= User.human_attribute_name(:name).capitalize %><%= User.human_attribute_name(:multiplier).capitalize %><%= t :actions %>
0 %>> + <%= link_to unit.symbol, edit_unit_path(unit) %> + <%= unit.name %><%= unit.multiplier unless unit.multiplier == 1 %> + <%= image_button_to t(".delete_unit"), "delete-outline", unit_path(unit), + method: :delete %> +
diff --git a/app/views/units/new.html.erb b/app/views/units/new.html.erb new file mode 100644 index 0000000..289fff6 --- /dev/null +++ b/app/views/units/new.html.erb @@ -0,0 +1,22 @@ +<% content_for :navigation, flush: true do %> +
+ <%= image_link_to t(:back), "arrow-left-bold-outline", + request.referer.present? ? :back : units_url %> +
+<% end %> + +<%= tabular_form_for @unit do |f| %> + <%= f.text_field :symbol, required: true, size: 10, autofocus: true, autocomplete: "off" %> + <%= f.text_field :name, size: 25, autocomplete: "off" %> + + <% if current_user.units.roots.count %> + <%= f.select :base_id, + current_user.units.roots.collect { |u| ["#{u.symbol}#{' - ' + u.name if u.name}", u.id] }, + {include_blank: t(".none")}, + onchange: 'this.form.unit_multiplier.disabled = (this.value == "");' %> + <%= f.number_field :multiplier, step: "any", disabled: @unit.base.nil?, size: 10, + autocomplete: "off" %> + <% end %> + + <%= f.submit @unit.persisted? ? t(:update) : t(:add) %> +<% end %> diff --git a/config/initializers/core_ext.rb b/config/initializers/core_ext.rb new file mode 100644 index 0000000..5feeadb --- /dev/null +++ b/config/initializers/core_ext.rb @@ -0,0 +1 @@ +require 'core_ext/big_decimal/formatting' diff --git a/config/locales/en.yml b/config/locales/en.yml index 5210532..1814cfb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,6 +1,11 @@ en: activerecord: attributes: + unit: + symbol: Symbol + name: Name + multiplier: Multiplier + base: Base unit user: email: e-mail status: status @@ -8,6 +13,18 @@ en: created_at: registered confirmed_at: confirmed unconfirmed_email: Awaiting confirmation for + units: + index: + add_unit: Add unit + delete_unit: Delete + new: + none: none + create: + success: Created new unit + update: + success: Updated unit + destroy: + success: Deleted unit users: index: disguise: View as... @@ -36,11 +53,14 @@ en: application: revert: Revert sign_out: Sign out + units: Units users: Users actions: Actions + add: Add back: Back or: or register: Register sign_in: Sign in recover_password: Recover password resend_confirmation: Resend confirmation + update: Update diff --git a/config/routes.rb b/config/routes.rb index db463e0..bc359e6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,8 @@ Rails.application.routes.draw do devise_for :users, path: '', path_names: {registration: 'profile'}, controllers: {registrations: :registrations} + resources :units, except: [:show] + resources :users, only: [:index, :show, :update] do member do post :disguise diff --git a/db/migrate/20230602185352_create_units.rb b/db/migrate/20230602185352_create_units.rb new file mode 100644 index 0000000..9d393b9 --- /dev/null +++ b/db/migrate/20230602185352_create_units.rb @@ -0,0 +1,19 @@ +class CreateUnits < ActiveRecord::Migration[7.0] + def change + create_table :units do |t| + t.references :user, foreign_key: true + t.string :symbol + t.string :name + t.decimal :multiplier, precision: 30, scale: 15 + + t.references :base + t.integer :lft, null: false + t.integer :rgt, null: false + + t.timestamps + end + add_index :units, [:user_id, :symbol], unique: true + add_index :units, :lft + add_index :units, :rgt + end +end diff --git a/db/schema.rb b/db/schema.rb index fb271eb..0af7bcf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,24 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_03_11_220654) do +ActiveRecord::Schema[7.0].define(version: 2023_06_02_185352) do + create_table "units", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "user_id" + t.string "symbol" + t.string "name" + t.decimal "multiplier", precision: 30, scale: 15 + t.bigint "base_id" + t.integer "lft", null: false + t.integer "rgt", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["base_id"], name: "index_units_on_base_id" + t.index ["lft"], name: "index_units_on_lft" + t.index ["rgt"], name: "index_units_on_rgt" + t.index ["user_id", "symbol"], name: "index_units_on_user_id_and_symbol", unique: true + t.index ["user_id"], name: "index_units_on_user_id" + end + create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "email", limit: 64, null: false t.integer "status", default: 0, null: false @@ -23,10 +40,11 @@ ActiveRecord::Schema[7.0].define(version: 2023_03_11_220654) do t.string "confirmation_token" t.datetime "confirmed_at" t.datetime "confirmation_sent_at" - t.string "unconfirmed_email" + t.string "unconfirmed_email", limit: 64 t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + add_foreign_key "units", "users" end diff --git a/lib/core_ext/big_decimal/formatting.rb b/lib/core_ext/big_decimal/formatting.rb new file mode 100644 index 0000000..e0c9d95 --- /dev/null +++ b/lib/core_ext/big_decimal/formatting.rb @@ -0,0 +1,12 @@ +#require "bigdecimal" +#require "bigdecimal/util" + +module FixinMe + module BigDecimalWithGrouping + def to_s(format = "3F") + super(format) + end + end +end + +BigDecimal.prepend(FixinMe::BigDecimalWithGrouping)