-
Notifications
You must be signed in to change notification settings - Fork 3.4k
[OP-19459] Use Primer Counter in notifications menu, fixing clipped counts #23558
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> |
| 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'); | ||
| }); | ||
| }); |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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); | ||
| }); | ||
|
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; | ||
| } | ||
| } | ||
| 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 | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.