Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions app/components/open_project/common/submenu_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,13 @@
</span>

<% if menu_item.count %>
<span class="op-bubble op-bubble_alt_highlighting" data-test-selector="op-submenu--item-count"><%= menu_item.count %></span>
Comment thread
myabc marked this conversation as resolved.
<%= render Primer::Beta::Counter.new(
count: menu_item.count,
scheme: :primary,
hide_if_zero: true,
round: true,
test_selector: "op-submenu--item-count"
) %>
<% end %>
</a>
</li>
Expand Down Expand Up @@ -90,7 +96,13 @@
</span>

<% if child_item.count %>
<span class="op-bubble op-bubble_alt_highlighting" data-test-selector="op-submenu--item-count"><%= child_item.count %></span>
<%= render Primer::Beta::Counter.new(
count: child_item.count,
scheme: :secondary,
hide_if_zero: true,
round: true,
test_selector: "op-submenu--item-count"
) %>
<% end %>
</a>
</li>
Expand Down
2 changes: 2 additions & 0 deletions config/locales/js-en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@
label_in_more_than: "in more than"
label_incoming_emails: "Incoming emails"
label_information_plural: "Information"
label_infinity: "Infinity"

Check failure on line 404 in config/locales/js-en.yml

View workflow job for this annotation

GitHub Actions / Yamllint

[yamllint] reported by reviewdog 🐶 [error] wrong ordering of key "label_infinity" in mapping (key-ordering) Raw Output: config/locales/js-en.yml:404:5: [error] wrong ordering of key "label_infinity" in mapping (key-ordering)
label_invalid: "Invalid"
label_import: "Import"
label_latest_activity: "Latest activity"
Expand All @@ -425,6 +426,7 @@
label_no_value: "No value"
label_none: "none"
label_not_contains: "doesn't contain"
label_not_available: "Not available"

Check failure on line 429 in config/locales/js-en.yml

View workflow job for this annotation

GitHub Actions / Yamllint

[yamllint] reported by reviewdog 🐶 [error] wrong ordering of key "label_not_available" in mapping (key-ordering) Raw Output: config/locales/js-en.yml:429:5: [error] wrong ordering of key "label_not_available" in mapping (key-ordering)
label_not_equals: "is not"
label_on: "on"
label_open_menu: "Open menu"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@
}
</div>
@if (!notification.readIAN) {
<span
class="op-ian-item--reason-count"
[textContent]="aggregatedNotifications.length"
></span>
<span class="op-ian-item--reason-count">
<primer-counter
[count]="aggregatedNotifications.length"
scheme="secondary"
></primer-counter>
</span>
}
<div class="op-ian-item--buttons">
@if (!notification.readIAN) {
Expand Down Expand Up @@ -118,7 +120,12 @@
<svg:rect x="0" y="14" width="80%" height="5" rx="1" />
</op-content-loader>
}
<span class="op-ian-item--reason-count"></span>
<span class="op-ian-item--reason-count">
<primer-counter
[count]="null"
scheme="secondary"
></primer-counter>
</span>
<div class="op-ian-item--buttons">
<i class="op-ian-item--button icon-mark-read">
</i>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ $subject-font-size: 14px

&--reason-count
grid-area: count
@include indicator-bubble
justify-self: flex-end
margin-right: 5px

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<span
class="Counter"
[class.Counter--primary]="scheme() === 'primary'"
[class.Counter--secondary]="scheme() === 'secondary'"
[title]="titleText()"
[hidden]="hidden()"
[textContent]="value()"
></span>
131 changes: 131 additions & 0 deletions frontend/src/app/shared/components/primer/counter.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//-- 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.
//++

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { PrimerCounterComponent } from './counter.component';

describe('PrimerCounterComponent', () => {
const I18nStub = {
locale: 'en',
t(key:string) {
return {
'js.label_infinity': 'Infinity',
'js.label_not_available': 'Not available',
}[key] ?? key;
},
};

let fixture:ComponentFixture<PrimerCounterComponent>;
let span:HTMLSpanElement;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PrimerCounterComponent],
providers: [
{ provide: I18nService, useValue: I18nStub },
],
}).compileComponents();

fixture = TestBed.createComponent(PrimerCounterComponent);
span = fixture.elementRef.nativeElement.querySelector('span.Counter') as HTMLSpanElement;
});

function render(inputs:Record<string, unknown>):void {
Object.entries(inputs).forEach(([key, value]) => fixture.componentRef.setInput(key, value));
fixture.detectChanges();
}

it('renders a plain count', () => {
render({ count: 12 });

expect(span.textContent).toEqual('12');
expect(span.getAttribute('title')).toEqual('12');
});

it('delimits large numbers', () => {
render({ count: 1234, limit: null });

expect(span.textContent).toEqual('1,234');
});

it('applies the limit with a trailing plus', () => {
render({ count: 6000, limit: 5000 });

expect(span.textContent).toEqual('5,000+');
expect(span.getAttribute('title')).toEqual('5,000+');
});

it('renders nothing and a fallback title for a null count', () => {
render({ count: null });

expect(span.textContent).toEqual('');
expect(span.getAttribute('title')).toEqual('Not available');
});

it('renders nothing and a fallback title for an undefined count', () => {
render({ count: undefined });

expect(span.textContent).toEqual('');
expect(span.getAttribute('title')).toEqual('Not available');
});

it('renders nothing and a fallback title for a NaN count', () => {
render({ count: NaN });

expect(span.textContent).toEqual('');
expect(span.getAttribute('title')).toEqual('Not available');
});

it('renders the infinity symbol', () => {
render({ count: Infinity });

expect(span.textContent).toEqual('∞');
expect(span.getAttribute('title')).toEqual('Infinity');
});

it('applies the scheme modifier class', () => {
render({ count: 1, scheme: 'secondary' });

expect(span.classList).toContain('Counter--secondary');
expect(span.classList).not.toContain('Counter--primary');
});

it('hides a zero count when hideIfZero is set', () => {
render({ count: 0, 'hide-if-zero': true });

expect(span.hidden).toBe(true);
});

it('does not hide a zero count by default', () => {
render({ count: 0 });

expect(span.hidden).toBe(false);
expect(span.textContent).toEqual('0');
});
});
106 changes: 106 additions & 0 deletions frontend/src/app/shared/components/primer/counter.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//-- 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.
//++

/* eslint-disable @angular-eslint/component-selector, @angular-eslint/no-input-rename */

import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';

type Scheme = 'default' | 'primary' | 'secondary';

// Angular port of Primer::Beta::Counter, used to add a count to navigational
// elements. The `text` and `round` options of the Ruby component are not
// supported.
@Component({
selector: 'primer-counter',
templateUrl: './counter.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
})
export class PrimerCounterComponent {
private readonly I18n = inject(I18nService);

// The number to be displayed (e.g. # of issues, pull requests).
readonly count = input<number | null>(0);

// Color scheme. One of default | primary | secondary.
readonly scheme = input<Scheme>('default');

// Maximum value to display. Pass `null` for no limit. When `count` exceeds
// `limit`, the value is rendered as e.g. "5,000+".
readonly limit = input<number | null>(5_000);

// When true, a `hidden` attribute is added to the counter if `count` is zero.
readonly hideIfZero = input(false, { alias: 'hide-if-zero' });

// Displayed text: "" when no value (CSS hides it), "∞" for infinity,
// otherwise the delimited count.
readonly value = computed(() => {
const count = this.count();
if (count === null || count === undefined) {
return ''; // CSS will hide it
}
if (count === Infinity || count === -Infinity) {
return '∞';
}
Comment on lines +68 to +70

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am no fan of this.. When will we ever want to display the infinity sign? Is this supported by the Primer component?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Primer::Beta::Counter does support this — it renders for Float::INFINITY (with Infinity title attribute). I don't think we're currently rendering infinity anywhere in OP (in JS or Ruby), but kept this for API parity.

if (Number.isNaN(count)) {
return '';
}
return this.displayNumber(count);
});

// Title attribute, mirroring the displayed count (including limit capping) as a tooltip.
readonly titleText = computed(() => {
const count = this.count();
if (count === null || count === undefined) {
return this.I18n.t('js.label_not_available');
}
if (count === Infinity || count === -Infinity) {
return this.I18n.t('js.label_infinity');
}
if (Number.isNaN(count)) {
return this.I18n.t('js.label_not_available');
}
return this.displayNumber(count);
});
Comment thread
myabc marked this conversation as resolved.

readonly hidden = computed(() => this.count() === 0 && this.hideIfZero());

private displayNumber(count:number):string {
const value = Math.trunc(count);
const limit = this.limit();
const capped = limit === null ? value : Math.min(value, limit);
const formatter = new Intl.NumberFormat(this.I18n.locale, {
maximumFractionDigits: 0,
useGrouping: true,
});
const str = formatter.format(capped);

return limit !== null && value > limit ? `${str}+` : str;
}
}
3 changes: 3 additions & 0 deletions frontend/src/app/shared/shared.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import { OpenprojectModalModule } from 'core-app/shared/components/modal/modal.m
import { FullCalendarModule } from '@fullcalendar/angular';
import { OpDatePickerModule } from 'core-app/shared/components/datepicker/datepicker.module';
import { OpBreadcrumbsComponent } from './components/breadcrumbs/op-breadcrumbs.component';
import { PrimerCounterComponent } from './components/primer/counter.component';
import { PrimerIconButtonComponent } from './components/primer/icon-button.component';

export function bootstrapModule(injector:Injector):void {
Expand Down Expand Up @@ -127,6 +128,7 @@ export function bootstrapModule(injector:Injector):void {
FullCalendarModule,
OpDatePickerModule,

PrimerCounterComponent,
PrimerIconButtonComponent
],
exports: [
Expand Down Expand Up @@ -181,6 +183,7 @@ export function bootstrapModule(injector:Injector):void {

OpNonWorkingDaysListComponent,

PrimerCounterComponent,
PrimerIconButtonComponent
],
providers: [
Expand Down
3 changes: 0 additions & 3 deletions frontend/src/global_styles/common/bubble/bubble.sass
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
.op-bubble
@include indicator-bubble

&_alt_highlighting
background: #878787

&_squared
padding: 0

Expand Down
Loading