diff --git a/app/assets/images/pictograms/view-columns.svg b/app/assets/images/pictograms/view-columns.svg new file mode 100644 index 0000000..889e3c1 --- /dev/null +++ b/app/assets/images/pictograms/view-columns.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/pictograms/view-rows.svg b/app/assets/images/pictograms/view-rows.svg new file mode 100644 index 0000000..51e7655 --- /dev/null +++ b/app/assets/images/pictograms/view-rows.svg @@ -0,0 +1 @@ + diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d01d199..01cbe75 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -36,6 +36,7 @@ --color-blue: #009ade; --color-dark-red: #b21237; --color-red: #ff1f5b; + --color-purple: #8b2be2; --depth: 0; @@ -231,6 +232,10 @@ textarea:invalid { text-decoration: underline 1px var(--color-border-gray); text-underline-offset: 0.25em; } +button.link { + border: none; + padding: 0; +} [name=cancel], .auxiliary { border-color: var(--color-border-gray); @@ -252,6 +257,13 @@ textarea:invalid { background-color: var(--color-red); border-color: var(--color-red); } +tr:has(select[data-changed]) button[name="button"], +.set-default-unit:not([disabled]) { + background-color: var(--color-purple); + border-color: var(--color-purple); + color: white; + fill: white; +} .link:focus-visible { text-decoration-color: var(--color-gray); } @@ -361,7 +373,7 @@ header { pointer-events: auto; } .flash:before { - filter: invert(); + filter: invert(100%); height: 1.4em; margin: 0 0.5em; width: 1.4em; @@ -513,7 +525,7 @@ header { cursor: grab; } .items-table .form td { - vertical-align: top; + vertical-align: middle; } .items-table td:not(:first-child), .grayed { @@ -640,3 +652,42 @@ li::marker { min-width: 66%; width: max-content; } +.measurements-section { + overflow-x: auto; +} +body[data-measurements-view=wide] .measurements-compact, +body[data-measurements-view=compact] .measurements-wide { + display: none; +} +body[data-measurements-view=compact] .view-toggle[data-view=compact], +body[data-measurements-view=wide] .view-toggle[data-view=wide] { + background-color: var(--color-blue); + border-color: var(--color-blue); + color: white; + fill: white; +} +#measurements tr.grouped td { + border-top: none; +} +#measurements tr.grouped .taken-at, +#measurements tr.grouped .created-at { + visibility: hidden; +} +.measurements-wide td { + vertical-align: middle; + white-space: nowrap; +} +.wide-cell { + align-items: center; + display: inline-flex; + gap: 0.25em; +} +.wide-cell .button { + border: none; + font-size: inherit; + height: auto; + padding: 0; +} +.wide-cell button.link::after { + content: none; +} diff --git a/app/controllers/measurements_controller.rb b/app/controllers/measurements_controller.rb index ebe6622..b824cf1 100644 --- a/app/controllers/measurements_controller.rb +++ b/app/controllers/measurements_controller.rb @@ -1,7 +1,12 @@ class MeasurementsController < ApplicationController + before_action :find_readout, only: [:destroy, :edit, :update] + + before_action except: :index do + raise AccessForbidden unless current_user.at_least(:active) + end + def index - @measurements = [] - #@measurements = current_user.units.ordered.includes(:base, :subunits) + @measurements = current_user.readouts.includes(:quantity, :unit).order(taken_at: :desc, id: :desc) end def new @@ -9,8 +14,40 @@ class MeasurementsController < ApplicationController end def create + taken_at = params.permit(:taken_at)[:taken_at] + readout_params = params.permit(readouts: Readout::ATTRIBUTES).fetch(:readouts, []) + @readouts = readout_params.map { |rp| current_user.readouts.build(rp.merge(taken_at: taken_at)) } + + if @readouts.present? && @readouts.all?(&:valid?) + ActiveRecord::Base.transaction { @readouts.each(&:save!) } + flash.now[:notice] = t('.success', count: @readouts.size) + else + errors = @readouts.flat_map { |r| r.errors.full_messages } + flash.now[:alert] = errors.present? ? errors.first : t('.no_readouts') + end + end + + def edit + @user_units = current_user.units.ordered + end + + def update + if @readout.update(params.require(:readout).permit(:value, :unit_id, :taken_at)) + flash.now[:notice] = t('.success') + else + @user_units = current_user.units.ordered + render :edit + end end def destroy + @readout.destroy! + flash.now[:notice] = t('.success') + end + + private + + def find_readout + @readout = current_user.readouts.find(params[:id]) end end diff --git a/app/javascript/application.js b/app/javascript/application.js index 09ced32..4eb29d3 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -9,6 +9,143 @@ function showPage(event) { } document.addEventListener('turbo:load', showPage) +function groupMeasurements() { + var tbody = document.getElementById('measurements'); + if (!tbody) return; + var prevTakenAt = null; + Array.from(tbody.querySelectorAll('tr[data-taken-at]')) + .filter(function(row) { return row.style.display !== 'none' }) + .forEach(function(row) { + var takenAt = row.dataset.takenAt; + row.classList.toggle('grouped', takenAt !== null && takenAt === prevTakenAt); + prevTakenAt = takenAt; + }); +} + +function buildWideTable() { + var tbody = document.getElementById('measurements'); + var wideContainer = document.getElementById('measurements-wide'); + if (!tbody || !wideContainer) return; + + var rows = Array.from(tbody.querySelectorAll('tr[data-taken-at]')); + + if (rows.length === 0) { wideContainer.innerHTML = ''; return; } + + // Unique quantities in alphabetical order + var qOrder = [], qSeen = new Set(); + rows.forEach(function(r) { + var id = r.dataset.quantityId; + if (id && !qSeen.has(id)) { qSeen.add(id); qOrder.push({id: id, name: r.dataset.quantityName}); } + }); + qOrder.sort(function(a, b) { return a.name.localeCompare(b.name); }); + + // Group rows by taken_at, preserving order + var groups = [], groupMap = new Map(); + rows.forEach(function(r) { + var key = r.dataset.takenAt || ''; + if (!groupMap.has(key)) { var g = {rows: []}; groups.push(g); groupMap.set(key, g); } + groupMap.get(key).rows.push(r); + }); + + // Read column headers from compact thead + var ths = document.querySelectorAll('.measurements-compact thead th'); + var takenAtHeader = ths[3] ? ths[3].textContent : ''; + var createdAtHeader = ths[4] ? ths[4].textContent : ''; + + var table = document.createElement('table'); + table.className = 'items-table'; + + // Header + var thead = table.createTHead(); + var hrow = thead.insertRow(); + [takenAtHeader].concat(qOrder.map(function(q) { return q.name; })).concat([createdAtHeader]).forEach(function(text) { + var th = document.createElement('th'); + th.textContent = text; + hrow.appendChild(th); + }); + + // Body + var tbodyEl = table.createTBody(); + groups.forEach(function(group) { + var tr = tbodyEl.insertRow(); + + // Taken at + var tdTime = tr.insertCell(); + var takenAtEl = group.rows[0].querySelector('.taken-at'); + tdTime.textContent = takenAtEl ? takenAtEl.textContent : ''; + + // One cell per quantity + qOrder.forEach(function(q) { + var td = tr.insertCell(); + var readoutRow = group.rows.find(function(r) { return r.dataset.quantityId === q.id; }); + if (readoutRow) { + td.className = 'ralign'; + var wrap = document.createElement('span'); + wrap.className = 'wide-cell'; + var editLink = readoutRow.querySelector('a.link'); + if (editLink) { + var editUrl = editLink.href + (editLink.href.includes('?') ? '&' : '?') + 'view=wide'; + var btn = document.createElement('button'); + btn.className = 'link'; + btn.type = 'button'; + btn.dataset.editUrl = editUrl; + btn.addEventListener('click', function() { editMeasurementWide(this.dataset.editUrl); this.blur(); }); + btn.textContent = readoutRow.dataset.value; + wrap.appendChild(btn); + wrap.appendChild(document.createTextNode('\u00a0' + readoutRow.dataset.unit)); + } else { + wrap.appendChild(document.createTextNode(readoutRow.dataset.value + '\u00a0' + readoutRow.dataset.unit)); + } + var srcActions = readoutRow.querySelector('td.flex'); + if (srcActions) srcActions.querySelectorAll('form').forEach(function(f) { + var cloned = f.cloneNode(true); + var span = cloned.querySelector('button span'); + if (span) span.remove(); + wrap.appendChild(cloned); + }); + td.appendChild(wrap); + } + }); + + // Created at (from first row of group) + var tdCreated = tr.insertCell(); + var createdAtEl = group.rows[0].querySelector('.created-at'); + tdCreated.textContent = createdAtEl ? createdAtEl.textContent : ''; + }); + + wideContainer.innerHTML = ''; + wideContainer.appendChild(table); +} + +function getMeasurementsView() { + return localStorage.getItem('measurements-view') || 'compact'; +} + +function applyMeasurementsView(view) { + document.body.dataset.measurementsView = view; + if (view === 'wide') buildWideTable(); +} + +function setMeasurementsView(view) { + localStorage.setItem('measurements-view', view); + applyMeasurementsView(view); +} +window.setMeasurementsView = setMeasurementsView + +document.addEventListener('turbo:load', function() { + var tbody = document.getElementById('measurements'); + if (!tbody) return; + groupMeasurements(); + applyMeasurementsView(getMeasurementsView()); + new MutationObserver(function() { + groupMeasurements(); + if (getMeasurementsView() === 'wide') buildWideTable(); + }).observe(tbody, { + childList: true, subtree: true, + attributes: true, attributeFilter: ['style'] + }); +}) + function detailsChange(event) { var target = event.currentTarget var count = target.querySelectorAll('input:checked:not([disabled])').length @@ -37,6 +174,65 @@ window.detailsObserver = new MutationObserver((mutations) => { mutations[0].target.dispatchEvent(new Event('change', {bubbles: true})) }); +function editMeasurementWide(url) { + fetch(url, { + headers: { + 'Accept': 'text/vnd.turbo-stream.html', + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => response.text()) + .then(html => { + Turbo.renderStreamMessage(html); + requestAnimationFrame(() => { + var panel = document.getElementById('measurement_edit_form'); + if (panel && panel.firstElementChild) { + panel.scrollIntoView({behavior: 'smooth', block: 'nearest'}); + } + }); + }); +} +window.editMeasurementWide = editMeasurementWide + +function readoutUnitChanged(select) { + var button = select.closest('tr').querySelector('.set-default-unit'); + if (select.value && select.value !== select.dataset.defaultUnitId) { + button.removeAttribute('disabled'); + button.removeAttribute('aria-disabled'); + button.removeAttribute('tabindex'); + } else { + button.setAttribute('disabled', 'disabled'); + button.setAttribute('aria-disabled', 'true'); + button.setAttribute('tabindex', '-1'); + } +} +window.readoutUnitChanged = readoutUnitChanged + +function setDefaultUnit(button) { + var select = button.closest('tr').querySelector('select[data-default-unit-id]'); + var params = new URLSearchParams(); + params.append('quantity[default_unit_id]', select.value); + + fetch(button.dataset.path, { + 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: 'PATCH' + }) + .then(response => { + if (response.ok) { + select.dataset.defaultUnitId = select.value; + readoutUnitChanged(select); + } + return response.text(); + }) + .then(html => Turbo.renderStreamMessage(html)); +} +window.setDefaultUnit = setDefaultUnit + function formValidate(event) { var id = event.submitter.getAttribute("data-validate") if (!id) return; diff --git a/app/views/measurements/_edit_form.html.erb b/app/views/measurements/_edit_form.html.erb new file mode 100644 index 0000000..845ef41 --- /dev/null +++ b/app/views/measurements/_edit_form.html.erb @@ -0,0 +1,24 @@ +<%= tabular_fields_for @readout, form: form_tag do |form| %> + <%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)", + data: {form: form_tag, hidden_row: hidden_row, link: link} do %> + <%= @readout.quantity %> + + <%= form.number_field :value, required: true, autofocus: true %> + + + <%= form.collection_select :unit_id, @user_units, :id, + ->(u){ sanitize(' ' * (u.base_id? ? 1 : 0) + u.symbol) }, + {}, required: true %> + + + <%= form.datetime_field :taken_at %> + + + + <%= form.button %> + <%= image_link_to t(:cancel), "close-outline", measurements_path, + class: 'dangerous', name: :cancel, + onclick: render_turbo_stream('edit_form_close', {row: row}) %> + + <% end %> +<% end %> diff --git a/app/views/measurements/_edit_form_close.html.erb b/app/views/measurements/_edit_form_close.html.erb new file mode 100644 index 0000000..e79ea3c --- /dev/null +++ b/app/views/measurements/_edit_form_close.html.erb @@ -0,0 +1,2 @@ +<%= turbo_stream.close_form row %> +<%= turbo_stream.update :flashes %> diff --git a/app/views/measurements/_edit_panel.html.erb b/app/views/measurements/_edit_panel.html.erb new file mode 100644 index 0000000..20f59b2 --- /dev/null +++ b/app/views/measurements/_edit_panel.html.erb @@ -0,0 +1,33 @@ +<% form_tag = dom_id(@readout, :edit, :form) %> +<% row = dom_id(@readout, :edit) %> +<% hidden_row = dom_id(@readout) %> + +<%= tabular_form_with model: @readout, url: measurement_path(@readout), + id: form_tag do |form| %> + + + <%= tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)", + data: {form: form_tag, hidden_row: hidden_row} do %> + + + + + + + <% end %> + +
<%= @readout.quantity %> + <%= form.number_field :value, required: true, autofocus: true %> + + <%= form.collection_select :unit_id, @user_units, :id, + ->(u){ sanitize(' ' * (u.base_id? ? 1 : 0) + u.symbol) }, + {}, required: true %> + + <%= form.datetime_field :taken_at %> + + <%= form.button %> + <%= image_link_to t(:cancel), "close-outline", measurements_path, + class: 'dangerous', name: :cancel, + onclick: render_turbo_stream('edit_form_close', {row: row}) %> +
+<% end %> diff --git a/app/views/measurements/_readout.html.erb b/app/views/measurements/_readout.html.erb new file mode 100644 index 0000000..ab1c864 --- /dev/null +++ b/app/views/measurements/_readout.html.erb @@ -0,0 +1,22 @@ +<%= tag.tr id: dom_id(readout), data: {taken_at: readout.taken_at&.iso8601, + quantity_id: readout.quantity_id, quantity_name: readout.quantity.name, + value: format("%.10g", readout.value), unit: readout.unit.symbol} do %> + + <% if current_user.at_least(:active) %> + <%= link_to readout.quantity, edit_measurement_path(readout), + class: 'link', onclick: 'this.blur();', data: {turbo_stream: true} %> + <% else %> + <%= readout.quantity %> + <% end %> + + <%= format("%.10g", readout.value) %> + <%= readout.unit %> + <%= l(readout.taken_at) if readout.taken_at %> + <%= l(readout.created_at) %> + <% if current_user.at_least(:active) %> + + <%= image_button_to t('.destroy'), 'delete-outline', measurement_path(readout), + method: :delete, data: {turbo_stream: true} %> + + <% end %> +<% end %> diff --git a/app/views/measurements/edit.turbo_stream.erb b/app/views/measurements/edit.turbo_stream.erb new file mode 100644 index 0000000..18424a1 --- /dev/null +++ b/app/views/measurements/edit.turbo_stream.erb @@ -0,0 +1,18 @@ +<% ids = {row: dom_id(@readout, :edit), + hidden_row: dom_id(@readout), + link: nil, + form_tag: dom_id(@readout, :edit, :form)} %> + +<% if params[:view] == 'wide' %> + <%= turbo_stream.update :measurement_edit_form, partial: 'edit_panel' %> + <%= turbo_stream.hide ids[:hidden_row] %> +<% else %> + <%= turbo_stream.append :measurement_edit_form do %> + <%- tabular_form_with model: @readout, url: measurement_path(@readout), + html: {id: ids[:form_tag]} do %> + <% end %> + <% end %> + <%= turbo_stream.hide ids[:hidden_row] %> + <%= turbo_stream.remove ids[:row] %> + <%= turbo_stream.after @readout, partial: 'edit_form', locals: ids -%> +<% end %> diff --git a/app/views/measurements/index.html.erb b/app/views/measurements/index.html.erb index bac10e1..2aac86b 100644 --- a/app/views/measurements/index.html.erb +++ b/app/views/measurements/index.html.erb @@ -5,10 +5,34 @@ id: :new_measurement_link, onclick: 'this.blur();', data: {turbo_stream: true} %> <% end %> + <%= image_button_tag '', 'view-rows', name: nil, type: 'button', + class: 'view-toggle', title: t('.view_compact'), + data: {view: 'compact'}, onclick: "setMeasurementsView('compact')" %> + <%= image_button_tag '', 'view-columns', name: nil, type: 'button', + class: 'view-toggle', title: t('.view_wide'), + data: {view: 'wide'}, onclick: "setMeasurementsView('wide')" %> + + +
+ <%= tag.div id: :measurement_edit_form %> + + + + + + + + + <% if current_user.at_least(:active) %> + + <% end %> + + + + <%= render(partial: 'readout', collection: @measurements, as: :readout) || render_no_items %> + +
<%= Quantity.model_name.human %><%= Readout.human_attribute_name(:value) %><%= Unit.model_name.human %><%= Readout.human_attribute_name(:taken_at) %><%= Readout.human_attribute_name(:created_at) %>
+ +
- - - <%= render(@measurements) || render_no_items %> - -
diff --git a/app/views/measurements/update.turbo_stream.erb b/app/views/measurements/update.turbo_stream.erb new file mode 100644 index 0000000..36f4d44 --- /dev/null +++ b/app/views/measurements/update.turbo_stream.erb @@ -0,0 +1,2 @@ +<%= turbo_stream.close_form dom_id(@readout, :edit) %> +<%= turbo_stream.replace @readout, partial: 'measurements/readout', locals: {readout: @readout} %> diff --git a/config/locales/en.yml b/config/locales/en.yml index b8fd2db..5f70cc5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -97,13 +97,18 @@ en: taken_at_html: Measurement taken at  index: new_measurement: Add measurement + view_compact: Compact view + view_wide: Wide view readout: + edit: Edit destroy: Delete create: success: one: Recorded 1 measurement. other: Recorded %{count} measurements. no_readouts: No readouts selected. + update: + success: Measurement updated. destroy: success: Measurement deleted. quantities: diff --git a/test/controllers/measurements_controller_test.rb b/test/controllers/measurements_controller_test.rb index 025e77e..0adaa76 100644 --- a/test/controllers/measurements_controller_test.rb +++ b/test/controllers/measurements_controller_test.rb @@ -1,8 +1,7 @@ require "test_helper" class MeasurementsControllerTest < ActionDispatch::IntegrationTest - #test "should get index" do - # get measurements_index_url - # assert_response :success - #end + # test "the truth" do + # assert true + # end end diff --git a/test/system/measurements_test.rb b/test/system/measurements_test.rb new file mode 100644 index 0000000..a82f57f --- /dev/null +++ b/test/system/measurements_test.rb @@ -0,0 +1,64 @@ +require "application_system_test_case" + +class MeasurementsTest < ApplicationSystemTestCase + setup do + @user = sign_in(user: users(:alice)) + + @quantity = @user.quantities.create!(name: 'Weight') + @unit = @user.units.create!(symbol: 'kg') + @readout = @user.readouts.create!(quantity: @quantity, unit: @unit, value: 82.5) + + visit measurements_path + end + + test "index shows quantity name as edit link for active user" do + within 'tbody' do + assert_selector :link, exact_text: @quantity.name + end + end + + test "edit opens inline form on quantity link click" do + within 'tbody' do + click_on @quantity.name + assert_selector ':focus' + assert_selector 'input[name="readout[value]"]' + end + end + + test "edit and update measurement value" do + within 'tbody' do + click_on @quantity.name + fill_in 'readout[value]', with: '83.1' + assert_difference ->{ @readout.reload.value }, 83.1 - @readout.value do + click_on t('helpers.submit.update') + end + assert_no_selector :fillable_field + assert_selector :link, exact_text: @quantity.name + end + assert_selector '.flash.notice', text: t('measurements.update.success') + end + + test "cancel edit restores original row" do + within 'tbody' do + click_on @quantity.name + assert_selector 'input[name="readout[value]"]' + click_on t(:cancel) + assert_no_selector :fillable_field + assert_selector :link, exact_text: @quantity.name + end + end + + test "wide view edit opens panel form" do + @readout.update!(taken_at: Time.now) + visit measurements_path + execute_script("localStorage.removeItem('measurements-view')") + visit measurements_path + + find('button[data-view="wide"]').click + within '#measurements-wide' do + assert_text format("%.10g", 82.5), wait: 3 + find('button.link').click + end + assert_selector '#measurement_edit_form input[name="readout[value]"]', wait: 5 + end +end