Primerize cost type list

This commit is contained in:
Oliver Günther
2026-05-14 21:04:55 +02:00
parent 6f3763652f
commit b1e9b367a8
10 changed files with 104 additions and 322 deletions
+6 -2
View File
@@ -89,8 +89,12 @@ class RowComponent < ApplicationComponent
:default
end
def checkmark(condition)
if condition
def checkmark(condition, primerized: false)
return unless condition
if primerized
render(Primer::Beta::Octicon.new(icon: :check))
else
helpers.op_icon "icon icon-checkmark"
end
end
@@ -31,6 +31,8 @@
module Admin
module CostTypes
class RowComponent < ::RowComponent
delegate :unit, :unit_plural, to: :cost_type
def cost_type
model
end
@@ -39,20 +41,27 @@ module Admin
helpers.link_to(cost_type.name, helpers.edit_admin_cost_type_path(cost_type))
end
def unit
cost_type.unit
end
def unit_plural
cost_type.unit_plural
end
def current_rate
helpers.to_currency_with_empty(cost_type.rate_at(table.fixed_date))
helpers.to_currency_with_empty(cost_type.rate_at(Date.current))
end
def default
checkmark(cost_type.is_default?)
checkmark(cost_type.is_default?, primerized: true)
end
def active_projects
if cost_type.is_for_all?
I18n.t("settings.project_attributes.label_for_all_projects")
else
count = Project.active.joins(:cost_types_projects)
.where(cost_types_projects: { cost_type_id: cost_type.id })
.count
count.zero? ? I18n.t(:label_none) : count
end
end
def deleted_at
helpers.format_date(cost_type.deleted_at) if cost_type.deleted_at
end
def column_css_class(column)
@@ -64,7 +73,7 @@ module Admin
end
def button_links
[lock_link]
table.locked? ? [restore_link] : [lock_link]
end
def lock_link
@@ -84,6 +93,21 @@ module Admin
)
)
end
def restore_link
render(
Primer::Beta::IconButton.new(
icon: :unlock,
scheme: :invisible,
tag: :a,
href: helpers.restore_admin_cost_type_path(cost_type),
"aria-label": t(:button_unlock),
tooltip_direction: :w,
test_selector: "op-admin-cost-type-#{cost_type.id}-restore",
data: { turbo_method: :patch }
)
)
end
end
end
end
@@ -31,30 +31,48 @@
module Admin
module CostTypes
class TableComponent < ::TableComponent
columns :name, :unit, :unit_plural, :current_rate, :default
sortable_columns :name, :unit, :unit_plural
options :fixed_date
options status: "active"
def columns
if status == "locked"
%i[name unit unit_plural current_rate deleted_at]
else
%i[name unit unit_plural current_rate active_projects default]
end
end
def sortable_columns
%i[name unit unit_plural]
end
def initial_sort
%i[name asc]
end
def headers
[
["name", { caption: CostType.model_name.human }],
["unit", { caption: CostType.human_attribute_name(:unit) }],
["unit_plural", { caption: CostType.human_attribute_name(:unit_plural) }],
["current_rate", { caption: CostType.human_attribute_name(:current_rate) }],
["default", { caption: I18n.t(:caption_default) }]
]
columns.map { |column| [column.to_s, { caption: header_caption(column) }] }
end
def sortable?
true
end
def fixed_date
options.fetch(:fixed_date) { Date.current }
def locked?
status == "locked"
end
private
def header_caption(column)
case column
when :name then CostType.model_name.human
when :unit then CostType.human_attribute_name(:unit)
when :unit_plural then CostType.human_attribute_name(:unit_plural)
when :current_rate then CostType.human_attribute_name(:current_rate)
when :active_projects then I18n.t("cost_types.admin.columns.active_projects")
when :default then I18n.t(:caption_default)
when :deleted_at then I18n.t(:caption_locked_on)
end
end
end
end
@@ -49,20 +49,9 @@ module Admin
"unit_plural" => "#{CostType.table_name}.unit_plural" }
sort_update sort_columns
@active_cost_types = CostType.active
@cost_types = CostType.order(sort_clause)
if params[:clear_filter]
@fixed_date = Time.zone.today
@include_deleted = nil
else
@fixed_date = begin
Date.parse(params[:fixed_date])
rescue StandardError
Time.zone.today
end
@include_deleted = params[:include_deleted]
end
@status = params[:status] == "locked" ? "locked" : "active"
@cost_types = (@status == "locked" ? CostType.where.not(deleted_at: nil) : CostType.active)
.order(sort_clause)
render action: "index", layout: !request.xhr?
end
@@ -119,7 +108,7 @@ module Admin
if @cost_type.save
flash[:notice] = t(:notice_successful_lock)
redirect_back_or_default({ action: "index" })
redirect_back_or_default({ action: "index" }, status: :see_other)
end
end
@@ -130,7 +119,7 @@ module Admin
if @cost_type.save
flash[:notice] = t(:notice_successful_restore)
redirect_back_or_default({ action: "index" })
redirect_back_or_default({ action: "index" }, status: :see_other)
end
end
@@ -64,8 +64,11 @@ class Projects::Settings::CostTypesController < Projects::SettingsController
respond_to do |format|
format.json { render json: {}, status: }
format.html do
flash[:notice] = I18n.t(:notice_successful_update) if status == :ok
flash[:error] = I18n.t("activerecord.errors.messages.is_for_all_cannot_modify") if status != :ok
if status == :ok
flash[:notice] = I18n.t(:notice_successful_update)
else
flash[:error] = I18n.t("activerecord.errors.messages.is_for_all_cannot_modify")
end
redirect_to project_settings_cost_types_path(@project)
end
end
@@ -1,131 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
<% cost_types = @cost_types.reject(&:deleted_at) -%>
<% if cost_types.empty? %>
<%= no_results_box %>
<% else %>
<div class="generic-table--container">
<div class="generic-table--results-container">
<table class="generic-table" data-controller="table-highlighting">
<colgroup>
<col>
<col>
<col>
<col>
<col>
<col>
<col data-highlight="false">
</colgroup>
<thead>
<tr>
<%= sort_header_tag "name", caption: CostType.model_name.human %>
<%= sort_header_tag "unit", caption: CostType.human_attribute_name(:unit) %>
<%= sort_header_tag "unit_plural", caption: CostType.human_attribute_name(:unit_plural) %>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= CostType.human_attribute_name(:current_rate) %>
</span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= t(:caption_set_rate) %>
</span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= t(:caption_default) %>
</span>
</div>
</div>
</th>
<th><div class="generic-table--empty-header"></div></th>
</tr>
</thead>
<tbody>
<% cost_types.each do |cost_type| %>
<tr>
<%= content_tag :td, link_to(cost_type.name, { controller: "/admin/cost_types", action: "edit", id: cost_type }) %>
<%= content_tag :td, cost_type.unit %>
<%= content_tag :td, cost_type.unit_plural %>
<%= content_tag :td, to_currency_with_empty(cost_type.rate_at(@fixed_date)), class: "currency", id: "cost_type_#{cost_type.id}_rate" %>
<td>
<%= form_for cost_type, url: { controller: "/admin/cost_types", action: "set_rate", id: cost_type }, method: :put, html: { class: "inline-label" } do |f| %>
<label class="sr-only" for="<%= "rate_field_#{cost_type.id}" %>"><%= t(:caption_set_rate) %></label>
<%= content_tag :input,
"",
value: "",
name: :rate,
size: 7,
inputmode: :decimal,
placeholder: t(:label_example_placeholder, decimal: unitless_currency_number(1000.50)),
id: "rate_field_#{cost_type.id}" %>
<span class="form-label">
<%= Setting.costs_currency %>
</span>
<button type="submit"
class="button submit_cost_type">
<%= icon_wrapper("icon icon-save", I18n.t(:caption_save_rate)) %>
</button>
<% end %>
</td>
<%= content_tag :td, cost_type.is_default? ? icon_wrapper("icon icon-checkmark", I18n.t(:general_text_Yes)) : "" %>
<td class="buttons">
<%= form_for cost_type, url: admin_cost_type_path(cost_type),
method: :delete,
html: { id: "delete_cost_type_#{cost_type.id}",
class: "delete_cost_type",
title: t(:button_lock) } do |f| %>
<button type="submit"
class="button--link submit_cost_type">
<%= icon_wrapper("icon icon-locked", I18n.t(:button_lock)) %>
</button>
<% end %>
</td>
</tr>
<tr style="display:none;">
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
@@ -1,98 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
<% cost_types = @cost_types.select(&:deleted_at) -%>
<% if cost_types.empty? %>
<%= no_results_box %>
<% else %>
<div class="generic-table--container">
<div class="generic-table--results-container">
<table class="generic-table locked_cost_types">
<colgroup>
<col>
<col>
<col>
<col>
<col>
<col data-highlight="false">
</colgroup>
<thead>
<tr>
<%= sort_header_tag("name", caption: CostType.model_name.human) %>
<%= sort_header_tag("unit", caption: CostType.human_attribute_name(:unit)) %>
<%= sort_header_tag("unit_plural", caption: CostType.human_attribute_name(:unit_plural)) %>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= CostType.human_attribute_name(:current_rate) %>
</span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= t(:caption_locked_on) %>
</span>
</div>
</div>
</th>
<th><div class="generic-table--empty-header"></div></th>
</tr>
</thead>
<tbody>
<% cost_types.each do |cost_type| %>
<tr>
<%= content_tag :td, cost_type.name %>
<%= content_tag :td, cost_type.unit %>
<%= content_tag :td, cost_type.unit_plural %>
<%= content_tag :td, to_currency_with_empty(cost_type.rate_at(@fixed_date)), class: "currency" %>
<%= content_tag :td, cost_type.deleted_at.to_date %>
<td class="buttons">
<%= form_for cost_type, url: restore_admin_cost_type_path(cost_type),
method: :patch,
html: { id: "restore_cost_type_#{cost_type.id}",
class: "restore_cost_type" } do |f| %>
<button type="submit"
class="button--link submit_cost_type">
<%= icon_wrapper("icon icon-unlocked", I18n.t(:button_unlock)) %>
</button>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
@@ -42,6 +42,19 @@ See COPYRIGHT and LICENSE files for more details.
%>
<%= render(Primer::OpenProject::SubHeader.new) do |subheader| %>
<% subheader.with_filter_component do %>
<%= render(Primer::Alpha::SegmentedControl.new("aria-label": t(:label_filter_plural), full_width: false)) do |control| %>
<% control.with_item(tag: :a,
href: admin_cost_types_path(status: "active"),
label: t(:label_active),
selected: @status == "active") %>
<% control.with_item(tag: :a,
href: admin_cost_types_path(status: "locked"),
label: t("members.menu.locked"),
selected: @status == "locked") %>
<% end %>
<% end %>
<% subheader.with_action_button(
scheme: :primary,
leading_icon: :plus,
@@ -51,44 +64,6 @@ See COPYRIGHT and LICENSE files for more details.
) do
CostType.model_name.human
end %>
<% subheader.with_bottom_pane_component do %>
<%= styled_form_tag(admin_cost_types_path, { method: :get, id: "query_form" }) do %>
<fieldset id="filters" class="simple-filters--container">
<legend><%= t(:label_filter_plural) %></legend>
<ul class="simple-filters--filters">
<li class="simple-filters--filter">
<%= styled_label_tag :fixed_date, t(:"attributes.fixed_date"), class: "simple-filters--filter-name" %>
<div class='simple-filters--filter-value'>
<%= angular_component_tag "opce-basic-single-date-picker",
inputs: {
value: @fixed_date,
id: :start_date,
name: :fixed_date
} %>
</div>
</li>
<li class="simple-filters--filter">
<%= styled_label_tag :include_deleted, t(:caption_show_locked), class: "simple-filters--filter-name -small" %>
<div class="simple-filters--filter-value">
<%= styled_check_box_tag :include_deleted, "1", @include_deleted, autocomplete: "off" %>
</div>
</li>
<li class="simple-filters--controls">
<%= submit_tag t(:button_apply), class: "button -primary -small" %>
<%= link_to t(:button_clear), cost_types_path, class: "button -small -with-icon icon-undo" %>
</li>
</ul>
</fieldset>
<% end %>
<% end %>
<% end %>
<%= render(Admin::CostTypes::TableComponent.new(rows: @active_cost_types, fixed_date: @fixed_date)) %>
<% if @include_deleted %>
<div class="cost-types--list-deleted">
<h3><%= t(:label_locked_cost_types) %></h3>
<%= render partial: "list_deleted" %>
</div>
<% end %>
<%= render(Admin::CostTypes::TableComponent.new(rows: @cost_types, status: @status)) %>
+2 -1
View File
@@ -144,7 +144,6 @@ en:
label_current_default_rate: "Current default rate"
label_date_on: "on"
label_deleted_cost_types: "Deleted cost types"
label_locked_cost_types: "Locked cost types"
label_display_cost_entries: "Display unit costs"
label_display_time_entries: "Display reported hours"
label_display_types: "Display types"
@@ -259,6 +258,8 @@ en:
errors:
no_cost_types_available: "No cost types are available in this project. Please contact an administrator."
admin:
columns:
active_projects: "Active projects"
cost_type_projects:
is_for_all_blank_slate:
heading: "This cost type is enabled in all projects"
@@ -47,20 +47,17 @@ RSpec.describe "deleting a cost type", :js do
scroll_to_and_click(find("[data-test-selector='op-admin-cost-type-#{cost_type.id}-lock']"))
end
# Expect no results if not locked
# Active list becomes empty
expect_angular_frontend_initialized
expect(page).to have_css ".generic-table--empty-row", wait: 10
# Show locked
find_by_id("include_deleted").set true
click_on "Apply"
# Switch to the locked tab via the segmented control
click_on I18n.t(:label_locked)
wait_for_network_idle
# Expect locked list to render with the cost type
expect(page).to have_text I18n.t(:label_locked_cost_types)
expect(page).to have_css(".restore_cost_type")
expect(page).to have_css(".cost-types--list-deleted td", text: "Translations")
# The locked cost type appears with a restore action
expect(page).to have_css("[data-test-selector='op-admin-cost-type-#{cost_type.id}-restore']")
expect(page).to have_css("td", text: "Translations")
end
end