diff --git a/app/controllers/quantities_controller.rb b/app/controllers/quantities_controller.rb
index 15bbf93..50c573c 100644
--- a/app/controllers/quantities_controller.rb
+++ b/app/controllers/quantities_controller.rb
@@ -1,2 +1,9 @@
class QuantitiesController < ApplicationController
+ before_action except: :index do
+ raise AccessForbidden unless current_user.at_least(:active)
+ end
+
+ def index
+ @quantities = current_user.quantities.includes(:parent).includes(:subquantities).ordered
+ end
end
diff --git a/app/models/quantity.rb b/app/models/quantity.rb
new file mode 100644
index 0000000..ce6ebcf
--- /dev/null
+++ b/app/models/quantity.rb
@@ -0,0 +1,35 @@
+class Quantity < ApplicationRecord
+ belongs_to :user, optional: true
+ belongs_to :parent, optional: true, class_name: "Quantity"
+ has_many :subquantities, class_name: "Quantity", inverse_of: :parent,
+ dependent: :restrict_with_error
+
+ validate if: ->{ parent.present? } do
+ errors.add(:parent, :user_mismatch) unless user == parent.user
+ end
+ validates :name, presence: true, length: {maximum: type_for_attribute(:name).limit}
+ validates :description, length: {maximum: type_for_attribute(:description).limit}
+
+ scope :defaults, ->{ where(user: nil) }
+ scope :ordered, ->{
+ left_outer_joins(:parent).order(ordering)
+ }
+
+ def self.ordering
+ [arel_table.coalesce(Arel::Table.new(:parents_quantities)[:name], arel_table[:name]),
+ arel_table[:parent_id].not_eq(nil),
+ :name]
+ end
+
+ def to_s
+ name
+ end
+
+ def movable?
+ subunits.empty?
+ end
+
+ def default?
+ parent_id.nil?
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 35f8c76..0ef2ba3 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -11,6 +11,7 @@ class User < ApplicationRecord
disabled: 0, # administratively disallowed to sign in
}, default: :active
+ has_many :quantities, dependent: :destroy
has_many :units, dependent: :destroy
validates :email, presence: true, uniqueness: true,
diff --git a/app/views/quantities/index.html.erb b/app/views/quantities/index.html.erb
new file mode 100644
index 0000000..1ae631d
--- /dev/null
+++ b/app/views/quantities/index.html.erb
@@ -0,0 +1,110 @@
+
+ <% if current_user.at_least(:active) %>
+ <%= image_link_to t('.new_quantity'), 'plus-outline', new_quantity_path,
+ id: dom_id(Quantity, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
+ <% end %>
+ <%#= image_link_to t('.import_quantities'), 'download-outline', default_quantities_path,
+ class: 'tools' %>
+
+
+<%= tag.div id: :quantity_form %>
+
+
+
+
+ <%= Quantity.human_attribute_name(:name).capitalize %> |
+ <%= Quantity.human_attribute_name(:description).capitalize %> |
+ <% if current_user.at_least(:active) %>
+ <%= t :actions %> |
+ |
+ <% end %>
+
+ <%= tag.tr id: 'quantity_', hidden: true,
+ ondragover: 'dragOver(event)', ondrop: 'drop(event)',
+ ondragenter: 'dragEnter(event)', ondragleave: 'dragLeave(event)',
+ data: {drop_id: 'quantity_'} do %>
+ <%= t '.top_level_drop' %> |
+ <% end %>
+
+
+ <%= render(@quantities) || render_no_items %>
+
+
+
+<%= javascript_tag do %>
+ function processKey(event) {
+ if (event.key == "Escape") {
+ event.currentTarget.querySelector("a[name=cancel]").click();
+ }
+ }
+
+ var lastEnterTime;
+ function dragStart(event) {
+ lastEnterTime = event.timeStamp;
+ var row = event.currentTarget;
+ row.closest("table").querySelectorAll("thead tr").forEach((tr) => {
+ tr.toggleAttribute("hidden");
+ });
+ event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path"));
+ var rowRectangle = row.getBoundingClientRect();
+ event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top);
+ event.dataTransfer.dropEffect = "move";
+ }
+
+ /*
+ * Drag tracking assumptions (based on FF 122.0 experience):
+ * * Enter/Leave events at the same timeStamp may not be logically ordered
+ * (e.g. E -> E -> L, not E -> L -> E),
+ * * not every Enter event has corresponding Leave event, especially during
+ * rapid pointer moves
+ * NOTE: sometimes Leave is not emitted when pointer goes fast over table
+ * and outside. This should probably be fixed in browser, than patched here.
+ */
+ function dragEnter(event) {
+ //console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id);
+ dragLeave(event);
+ lastEnterTime = event.timeStamp;
+ const id = event.currentTarget.getAttribute("data-drop-id");
+ document.getElementById(id).classList.add("dropzone");
+ }
+
+ function dragOver(event) {
+ event.preventDefault();
+ }
+
+ function dragLeave(event) {
+ //console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id);
+ // Leave has been accounted for by Enter at the same timestamp, processed earlier
+ if (event.timeStamp <= lastEnterTime) return;
+ event.currentTarget.closest("table").querySelectorAll(".dropzone").forEach((tr) => {
+ tr.classList.remove("dropzone");
+ })
+ }
+
+ function dragEnd(event) {
+ dragLeave(event);
+ event.currentTarget.closest("table").querySelectorAll("thead tr").forEach((tr) => {
+ tr.toggleAttribute("hidden");
+ });
+ }
+
+ function drop(event) {
+ event.preventDefault();
+
+ var params = new URLSearchParams();
+ var parent_id = event.currentTarget.getAttribute("data-drop-id").split("_").pop();
+ params.append("quantity[parent_id]", parent_id);
+
+ fetch(event.dataTransfer.getData("text/plain"), {
+ body: params,
+ headers: {
+ "Accept": "text/vnd.turbo-stream.html",
+ "X-CSRF-Token": document.head.querySelector("meta[name=csrf-token]").content,
+ "X-Requested-With": "XMLHttpRequest"
+ },
+ method: "POST"
+ })
+ .then(response => response.text())
+ .then(html => Turbo.renderStreamMessage(html))
+ }
+<% end %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index c2af10e..171c407 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -55,6 +55,9 @@ en:
source_code: Get code
quantities:
navigation: Quantities
+ no_items: There are no configured quantities. You can Add some or Import from defaults.
+ index:
+ new_quantity: New quantity
units:
navigation: Units
no_items: There are no configured units. You can Add some or Import from defaults.