diff --git a/app/components/open_project/common/submenu_component.html.erb b/app/components/open_project/common/submenu_component.html.erb index f671b80a712..789c0f18eb2 100644 --- a/app/components/open_project/common/submenu_component.html.erb +++ b/app/components/open_project/common/submenu_component.html.erb @@ -48,7 +48,13 @@ <% if menu_item.count %> - <%= menu_item.count %> + <%= render Primer::Beta::Counter.new( + count: menu_item.count, + scheme: :primary, + hide_if_zero: true, + round: true, + test_selector: "op-submenu--item-count" + ) %> <% end %> @@ -90,7 +96,13 @@ <% if child_item.count %> - <%= child_item.count %> + <%= render Primer::Beta::Counter.new( + count: child_item.count, + scheme: :secondary, + hide_if_zero: true, + round: true, + test_selector: "op-submenu--item-count" + ) %> <% end %> diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 2f1eaf5d883..c6aaae68cdc 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -400,6 +400,7 @@ en: label_in_more_than: "in more than" label_incoming_emails: "Incoming emails" label_information_plural: "Information" + label_infinity: "Infinity" label_invalid: "Invalid" label_import: "Import" label_latest_activity: "Latest activity" @@ -424,6 +425,7 @@ en: label_no_value: "No value" label_none: "none" label_not_contains: "doesn't contain" + label_not_available: "Not available" label_not_equals: "is not" label_on: "on" label_open_menu: "Open menu" diff --git a/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.html b/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.html index f2e7243297e..8c5f3f140c9 100644 --- a/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.html +++ b/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.html @@ -45,10 +45,12 @@ } @if (!notification.readIAN) { - + + + }
@if (!notification.readIAN) { @@ -118,7 +120,12 @@ } - + + +
diff --git a/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.sass b/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.sass index f4c21feca89..4cfd07e10f1 100644 --- a/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.sass +++ b/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.sass @@ -103,7 +103,6 @@ $subject-font-size: 14px &--reason-count grid-area: count - @include indicator-bubble justify-self: flex-end margin-right: 5px diff --git a/frontend/src/app/shared/components/primer/counter.component.html b/frontend/src/app/shared/components/primer/counter.component.html new file mode 100644 index 00000000000..81291f808b2 --- /dev/null +++ b/frontend/src/app/shared/components/primer/counter.component.html @@ -0,0 +1,8 @@ + diff --git a/frontend/src/app/shared/components/primer/counter.component.spec.ts b/frontend/src/app/shared/components/primer/counter.component.spec.ts new file mode 100644 index 00000000000..6c9ee1ba009 --- /dev/null +++ b/frontend/src/app/shared/components/primer/counter.component.spec.ts @@ -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; + 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):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'); + }); +}); diff --git a/frontend/src/app/shared/components/primer/counter.component.ts b/frontend/src/app/shared/components/primer/counter.component.ts new file mode 100644 index 00000000000..74f374cf3d9 --- /dev/null +++ b/frontend/src/app/shared/components/primer/counter.component.ts @@ -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(0); + + // Color scheme. One of default | primary | secondary. + readonly scheme = input('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(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 '∞'; + } + 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); + }); + + 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; + } +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 0775f06eb3e..473dbd8896f 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -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 { @@ -127,6 +128,7 @@ export function bootstrapModule(injector:Injector):void { FullCalendarModule, OpDatePickerModule, + PrimerCounterComponent, PrimerIconButtonComponent ], exports: [ @@ -181,6 +183,7 @@ export function bootstrapModule(injector:Injector):void { OpNonWorkingDaysListComponent, + PrimerCounterComponent, PrimerIconButtonComponent ], providers: [ diff --git a/frontend/src/global_styles/common/bubble/bubble.sass b/frontend/src/global_styles/common/bubble/bubble.sass index 15ec0fc4bf4..4604b5225b0 100644 --- a/frontend/src/global_styles/common/bubble/bubble.sass +++ b/frontend/src/global_styles/common/bubble/bubble.sass @@ -1,9 +1,6 @@ .op-bubble @include indicator-bubble - &_alt_highlighting - background: #878787 - &_squared padding: 0