[64635] Format enterprise tokens table according to their statuses

https://community.openproject.org/wp/64635

This follows the design on Figma:
- 4 columns: subscription, active users, domain, dates
- subscription shows plan name, subscriber, and some labels depending on
  the token validity and attributes
- the dates are highlighted if they have an influence on the token status
This commit is contained in:
Christophe Bliard
2025-06-17 12:14:40 +02:00
parent 6426f17d81
commit efbdb62656
12 changed files with 472 additions and 36 deletions
@@ -31,29 +31,63 @@
module Admin::EnterpriseTokens
class RowComponent < ::OpPrimer::BorderBoxRowComponent
alias :token :model
delegate :subscriber, :domain, to: :token
def email
token.mail
end
def plan
helpers.enterprise_token_plan_name(token)
end
def max_active_users
if token.unlimited_users?
I18n.t("js.admin.enterprise.upsell.unlimited")
def column_css_class(column)
if column == :dates
"#{super} -no-ellipsis"
else
token.max_active_users
super
end
end
# Subscription column
def subscription
render(Primer::Box.new(classes: "d-flex flex-column gap-1")) do
concat plan_name_with_statuses_html
concat subscriber_html
end
end
def plan_name_with_statuses_html
render(Primer::Box.new(classes: "d-flex flex-row gap-1 flex-items-baseline")) do
concat primer_text(plan_name, font_weight: :bold)
token.statuses.each do |status|
concat token_status_label_html(status)
end
end
end
def subscriber_html
primer_text(token.subscriber, tag: :div)
end
def token_status_label_html(status)
primer_label(I18n.t("admin.enterprise.status.#{status}"), scheme: status_scheme(status))
end
# Active users column
def max_active_users
count =
if token.unlimited_users?
I18n.t("js.admin.enterprise.upsell.unlimited")
else
token.max_active_users.to_s
end
primer_text(count, color: :subtle)
end
# Domain column
def domain
primer_text(token.domain, color: :subtle)
end
# Dates column
def dates
[
helpers.format_date(token.starts_at),
token.will_expire? ? helpers.format_date(token.expires_at) : I18n.t("js.admin.enterprise.upsell.unlimited")
].join(" ")
capture do
concat(primer_text(start_date, color: start_date_color))
concat(primer_text(" "))
concat(primer_text(expiration_date, color: expiration_date_color))
end
end
def button_links
@@ -86,5 +120,59 @@ module Admin::EnterpriseTokens
item.with_leading_visual_icon(icon: :trash)
end
end
private
def plan_name
helpers.enterprise_token_plan_name(token)
end
def status_scheme(status)
case status
when :trial
:accent
when :invalid_domain, :expired
:danger
when :not_active, :expiring_soon, :in_grace_period
:attention
end
end
def start_date
helpers.format_date(token.starts_at)
end
def expiration_date
token.will_expire? ? helpers.format_date(token.expires_at) : I18n.t("js.admin.enterprise.upsell.unlimited")
end
def start_date_color
if token.started?
:subtle
else
:attention
end
end
def expiration_date_color
if token.expiring_soon? || token.in_grace_period?
:attention
elsif token.expired?
:danger
else
:subtle
end
end
def primer_text(text, color: :subtle, **options)
if color != :subtle
options = options.reverse_merge(font_weight: :bold)
end
render(Primer::Beta::Text.new(color:, **options)) { text }
end
def primer_label(text, scheme:)
render(Primer::Beta::Label.new(scheme:)) { text }
end
end
end
@@ -30,13 +30,13 @@
module Admin::EnterpriseTokens
class TableComponent < ::OpPrimer::BorderBoxTableComponent
columns :plan, :subscriber, :max_active_users, :email, :domain, :dates
columns :subscription, :max_active_users, :domain, :dates
mobile_columns :plan, :subscriber, :max_active_users, :dates
mobile_columns :subscription, :max_active_users, :dates
mobile_labels :project_name
main_column :plan
main_column :subscription
def sortable?
false
@@ -56,10 +56,8 @@ module Admin::EnterpriseTokens
def headers
@headers ||= [
[:plan, { caption: EnterpriseToken.human_attribute_name(:plan) }],
[:subscriber, { caption: EnterpriseToken.human_attribute_name(:subscriber) }],
[:subscription, { caption: EnterpriseToken.human_attribute_name(:subscription) }],
[:max_active_users, { caption: EnterpriseToken.human_attribute_name(:active_user_count_restriction) }],
[:email, { caption: EnterpriseToken.human_attribute_name(:email) }],
[:domain, { caption: EnterpriseToken.human_attribute_name(:domain) }],
[:dates, { caption: I18n.t(:label_dates) }]
].compact
+1 -5
View File
@@ -59,11 +59,7 @@ module EnterpriseHelper
def enterprise_token_plan_name(enterprise_token)
plan = enterprise_token.plan.to_s
<<~LABEL.squish
#{I18n.t(plan, scope: [:enterprise_plans], default: plan.humanize)}
(#{I18n.t(:label_token_version)} #{enterprise_token.version})
LABEL
I18n.t(plan, scope: [:enterprise_plans], default: plan.humanize)
end
def enterprise_plan_additional_features(enterprise_token)
+33
View File
@@ -28,6 +28,8 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class EnterpriseToken < ApplicationRecord
EXPIRING_SOON_DAYS = 30
class << self
def all_tokens
all.sort_by(&:sort_key)
@@ -167,10 +169,41 @@ class EnterpriseToken < ApplicationRecord
delegate :clear_current_tokens_cache, to: :EnterpriseToken
def expiring_soon?
token_object.will_expire? \
&& token_object.active?(reprieve: false) \
&& token_object.expires_at <= EXPIRING_SOON_DAYS.days.from_now
end
def in_grace_period?
token_object.expired?(reprieve: false) \
&& !token_object.expired?(reprieve: true)
end
def expired?(reprieve: true)
token_object.expired?(reprieve:)
end
def statuses
statuses = []
if trial?
statuses << :trial
end
if invalid_domain?
statuses << :invalid_domain
end
if !started?
statuses << :not_active
elsif expiring_soon?
statuses << :expiring_soon
elsif in_grace_period?
statuses << :in_grace_period
elsif expired?
statuses << :expired
end
statuses
end
##
# The domain is only validated for tokens from version 2.0 onwards.
def invalid_domain?
+9 -1
View File
@@ -113,6 +113,13 @@ en:
contact: "Contact us for a demo"
enterprise_info_html: "is an Enterprise <span class='spot-icon spot-icon_inline spot-icon_enterprise-addons'></span> add-on."
upgrade_info: "Please upgrade to a paid plan to activate and start using it in your team."
status:
expired: "Expired"
expiring_soon: "Expiring soon"
in_grace_period: "In grace period"
invalid_domain: "Invalid domain"
not_active: "Not active"
trial: "Trial"
jemalloc_allocator: Jemalloc memory allocator
journal_aggregation:
explanation:
@@ -1003,9 +1010,10 @@ en:
enterprise_token:
starts_at: "Valid since"
subscriber: "Subscriber"
subscription: "Subscription"
plan: "Plan"
encoded_token: "Enterprise support token"
active_user_count_restriction: "Maximum active users"
active_user_count_restriction: "Active users"
enterprise_trial:
company: "Company"
grids/grid:
@@ -76,7 +76,7 @@ a
.-break-word
word-wrap: break-word
.ellipsis,
.ellipsis:not(.-no-ellipsis),
.form--field.ellipsis .form--label
@include text-shortener
@@ -0,0 +1,204 @@
# frozen_string_literal: true
#-- 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.
#++
require "rails_helper"
RSpec.describe Admin::EnterpriseTokens::TableComponent, type: :component do
include EnterpriseHelper
include EnterpriseTokenFactory
include Redmine::I18n
let(:token) { create_enterprise_token }
let(:tokens) { [token] }
subject(:component) { described_class.new(rows: tokens) }
it "renders multiple tokens in a table with plan and subscriber name in the subscription column" do
expired_token = create_enterprise_token(subscriber: "My company", expires_at: 1.month.ago)
active_token = create_enterprise_token(subscriber: "My company", plan: "Premium")
component = described_class.new(rows: [expired_token, active_token])
render_inline(component)
expect(page).to have_text(enterprise_token_plan_name(expired_token), count: 1)
expect(page).to have_text(enterprise_token_plan_name(active_token), count: 1)
expect(page).to have_text("My company", count: 2)
end
def first_subscription_cell
page.first(".subscription")
end
def subscription_cells
page.all(".subscription")
end
def first_dates_cell
page.first(".dates")
end
def dates_cells
page.all(".dates")
end
context "when a token is a trial" do
let(:tokens) do
[
create_enterprise_token(trial: true),
create_enterprise_token(trial: true, expires_at: Date.current.prev_day(60))
]
end
it "displays a 'Trial' accent label in the subscription column" do
render_inline(component)
expect(subscription_cells).to all(have_primer_label("Trial", scheme: "accent"))
end
end
context "when a token has invalid domain" do
let(:token) { create_enterprise_token(domain: "invalid.com") }
it "displays a 'Invalid domain' danger label in the subscription column" do
render_inline(component)
expect(first_subscription_cell).to have_primer_label(count: 1)
expect(first_subscription_cell).to have_primer_label("Invalid domain", scheme: "danger")
end
end
context "when a token is not active yet" do
let(:token) { create_enterprise_token(starts_at: Date.current.next_day(30)) }
it "displays a 'Not active' attention label in the subscription column" do
render_inline(component)
expect(first_subscription_cell).to have_primer_label(count: 1)
expect(first_subscription_cell).to have_primer_label("Not active", scheme: "attention")
end
it "has start date in :attention color in the dates column" do
render_inline(component)
expect(first_dates_cell).to have_primer_text(format_date(token.starts_at), color: "attention")
.and have_primer_text(format_date(token.expires_at), color: "subtle")
end
end
context "when a token is active and nearing expiration date" do
let(:tokens) do
[
create_enterprise_token(expires_at: Date.current),
create_enterprise_token(expires_at: Date.current.next_day(15))
]
end
it "displays a 'Expiring soon' attention label in the subscription column" do
render_inline(component)
expect(subscription_cells).to all(have_primer_label(count: 1))
expect(subscription_cells).to all(have_primer_label("Expiring soon", scheme: "attention"))
end
it "has expiry date in :attention color in the dates column" do
render_inline(component)
expect(dates_cells[0]).to have_primer_text(format_date(tokens.first.starts_at), color: "subtle")
.and have_primer_text(format_date(tokens.first.expires_at), color: "attention")
expect(dates_cells[1]).to have_primer_text(format_date(tokens.second.starts_at), color: "subtle")
.and have_primer_text(format_date(tokens.second.expires_at), color: "attention")
end
end
context "when a token reached expiration date but is within the reprieve days" do
let(:tokens) do
[
create_enterprise_token(reprieve_days: 5, expires_at: Date.current.prev_day(1)),
create_enterprise_token(reprieve_days: 5, expires_at: Date.current.prev_day(5))
]
end
it "displays a 'In grace period' attention label in the subscription column" do
render_inline(component)
expect(subscription_cells).to all(have_primer_label(count: 1))
expect(subscription_cells).to all(have_primer_label("In grace period", scheme: "attention"))
end
it "has expiry date in :attention color in the dates column" do
render_inline(component)
expect(dates_cells[0]).to have_primer_text(format_date(tokens.first.starts_at), color: "subtle")
.and have_primer_text(format_date(tokens.first.expires_at), color: "attention")
expect(dates_cells[1]).to have_primer_text(format_date(tokens.second.starts_at), color: "subtle")
.and have_primer_text(format_date(tokens.second.expires_at), color: "attention")
end
end
context "when a token is fully expired" do
let(:tokens) do
[
create_enterprise_token(expires_at: Date.current.prev_day(1)),
create_enterprise_token(expires_at: Date.current.prev_day(5), reprieve_days: 4),
create_enterprise_token(expires_at: Date.current.prev_day(42), reprieve_days: 30)
]
end
it "displays a 'Expired' danger label in the subscription column" do
render_inline(component)
expect(subscription_cells).to all(have_primer_label(count: 1))
expect(subscription_cells).to all(have_primer_label("Expired", scheme: "danger"))
end
it "has expiry date in :danger color in the dates column" do
render_inline(component)
expect(dates_cells[0]).to have_primer_text(format_date(tokens.first.starts_at), color: "subtle")
.and have_primer_text(format_date(tokens.first.expires_at), color: "danger")
expect(dates_cells[1]).to have_primer_text(format_date(tokens.second.starts_at), color: "subtle")
.and have_primer_text(format_date(tokens.second.expires_at), color: "danger")
expect(dates_cells[2]).to have_primer_text(format_date(tokens.third.starts_at), color: "subtle")
.and have_primer_text(format_date(tokens.third.expires_at), color: "danger")
end
end
context "when token does not expire" do
let(:token) do
create_enterprise_token(expires_at: nil)
end
it "displays a 'Unlimited' label in the subscription column" do
render_inline(component)
expect(first_dates_cell).to have_primer_text("Unlimited", color: "subtle")
end
end
end
+2 -2
View File
@@ -34,8 +34,8 @@ RSpec.describe EnterpriseHelper do
describe "#enterprise_token_plan_name" do
let(:token) { instance_double(EnterpriseToken, plan: :legacy_enterprise, version: "4.0.0") }
it "returns the correct string" do
expect(helper.enterprise_token_plan_name(token)).to eq("Enterprise Plan (Token Version 4.0.0)")
it "returns the plan name" do
expect(helper.enterprise_token_plan_name(token)).to eq("Enterprise Plan")
end
end
+64
View File
@@ -558,6 +558,70 @@ RSpec.describe EnterpriseToken do
end
end
describe "#expiring_soon?" do
context "when token expiration date is within 30 days" do
it "returns true" do
expect(build_enterprise_token(expires_at: Date.current)).to be_expiring_soon
expect(build_enterprise_token(expires_at: Date.current.next_day(10))).to be_expiring_soon
expect(build_enterprise_token(expires_at: Date.current.next_day(30))).to be_expiring_soon
end
end
context "when token has no expiration date" do
it "returns false" do
expect(build_enterprise_token(expires_at: nil)).not_to be_expiring_soon
end
end
context "when token expiration date is within 30 days but token is not active yet" do
it "returns false" do
expect(build_enterprise_token(starts_at: Date.tomorrow, expires_at: Date.current.next_day(20))).not_to be_expiring_soon
end
end
context "when token is expired but in grace period" do
it "returns false" do
expect(build_enterprise_token(expires_at: Date.yesterday, reprieve_days: 1)).not_to be_expiring_soon
end
end
context "when token is expired" do
it "returns false" do
expect(build_enterprise_token(expires_at: Date.current.prev_day(10), reprieve_days: 5)).not_to be_expiring_soon
end
end
end
describe "#in_grace_period?" do
context "when token has no expiration date" do
it "returns false" do
expect(build_enterprise_token(expires_at: nil)).not_to be_in_grace_period
end
end
context "when token expiration date is today or in the future" do
it "returns false" do
expect(build_enterprise_token(expires_at: Date.current, reprieve_days: 100)).not_to be_in_grace_period
expect(build_enterprise_token(expires_at: Date.tomorrow, reprieve_days: 100)).not_to be_in_grace_period
end
end
context "when token expiration date is in the past within reprieve_days days" do
it "returns true" do
expect(build_enterprise_token(expires_at: Date.yesterday, reprieve_days: 1)).to be_in_grace_period
expect(build_enterprise_token(expires_at: Date.current.prev_day(10), reprieve_days: 10)).to be_in_grace_period
expect(build_enterprise_token(expires_at: Date.current.prev_day(10), reprieve_days: 20)).to be_in_grace_period
end
end
context "when token expiration date is in the past outside of reprieve_days days" do
it "returns false" do
expect(build_enterprise_token(expires_at: Date.yesterday, reprieve_days: 0)).not_to be_in_grace_period
expect(build_enterprise_token(expires_at: Date.current.prev_day(10), reprieve_days: 9)).not_to be_in_grace_period
end
end
end
describe "#expired?" do
context "when token has no expiration date" do
let(:token) { build_enterprise_token(expires_at: nil) }
@@ -32,7 +32,7 @@ require "spec_helper"
RSpec.describe Authorization::EnterpriseService do
let(:instance) { described_class.new(token) }
let(:token) { instance_double(EnterpriseToken, token_object:, expired?: expired?) }
let(:token) { instance_double(EnterpriseToken, token_object:, expired?: expired?, invalid_domain?: false) }
let(:token_object) { OpenProject::Token.new }
let(:feature) { "some_feature" }
let(:expired?) { false }
@@ -46,8 +46,16 @@ Capybara.add_selector :primer_label, locator_type: [String, Symbol] do
text.public_send(exact ? :eql? : :include?, locator.to_s)
end
expression_filter :scheme do |expr, scheme|
builder(expr).add_attribute_conditions(class: "Label--#{scheme.downcase}")
# Use `node_filter` instead of `expression_filter` to have a better failure
# message when the selector fails: `expression_filter` modifies the initial
# query and elements without the expected scheme are not returned.
# `node_filter` applies the filter on the elements returned by the query so
# that error message can list them if none matches.
node_filter :scheme do |node, scheme|
actual = node[:class].scan(/(?<=Label--)[\w-]+/).first
(actual&.downcase == scheme.downcase).tap do |res|
add_error("Expected scheme to be #{scheme.inspect} but was #{actual.inspect}") unless res
end
end
describe_expression_filters do |scheme: nil, **|
@@ -55,6 +63,35 @@ Capybara.add_selector :primer_label, locator_type: [String, Symbol] do
end
end
Capybara.add_selector :primer_text, locator_type: [String] do
label "Primer Text"
xpath do |locator, **|
xpath = XPath.descendant(:span)
unless locator.nil?
locator = locator.to_s
xpath = xpath[XPath.string.n.is(locator)]
end
xpath
end
# Use `node_filter` instead of `expression_filter` to have a better failure
# message when the selector fails: `expression_filter` modifies the initial
# query and elements without the expected color are not returned.
# `node_filter` applies the filter on the elements returned by the query so
# that error message can list them if none matches.
node_filter :color do |node, color|
actual = node[:class].scan(/(?<=color-fg-)[\w-]+/).first
(actual&.downcase == color.downcase).tap do |res|
add_error("Expected color to be #{color.inspect} but was #{actual ? actual.inspect : 'not set'}") unless res
end
end
describe_expression_filters do |color: nil, **|
" with color #{color.inspect}" if color
end
end
Capybara.add_selector :octicon, locator_type: [String, Symbol] do
label "Octicon"
@@ -89,6 +126,14 @@ module Capybara
Matchers::NegatedMatcher.new(have_primer_label(...))
end
def have_primer_text(locator = nil, **, &)
Matchers::HaveSelector.new(:primer_text, locator, **, &)
end
def have_no_primer_text(...)
Matchers::NegatedMatcher.new(have_primer_text(...))
end
def have_octicon(locator = nil, **, &)
Matchers::HaveSelector.new(:octicon, locator, **, &)
end
+1 -1
View File
@@ -55,7 +55,7 @@ module EnterpriseTokenFactory
# @yield [EnterpriseToken] The `EnterpriseToken` instance
# @return [EnterpriseToken] The created `EnterpriseToken`
def create_enterprise_token(encoded_token_name = nil, **attributes)
encoded_token_name ||= "token"
encoded_token_name ||= "token_#{SecureRandom.uuid}"
enterprise_token = build_enterprise_token(encoded_token_name, **attributes) do |token|
token.save!(validate: false)
end