mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Merge pull request #23558 from opf/bug/OP-19459-notifications-center-clipped-count-badges
[OP-19459] Use Primer Counter in notifications menu, fixing clipped counts
This commit is contained in:
@@ -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>
|
||||
<%= 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
+12
-5
@@ -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) {
|
||||
@@ -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>
|
||||
|
||||
-1
@@ -103,7 +103,6 @@ $subject-font-size: 14px
|
||||
|
||||
&--reason-count
|
||||
grid-area: count
|
||||
@include indicator-bubble
|
||||
justify-self: flex-end
|
||||
margin-right: 5px
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<span
|
||||
class="Counter"
|
||||
[class.Counter--primary]="scheme() === 'primary'"
|
||||
[class.Counter--secondary]="scheme() === 'secondary'"
|
||||
[title]="titleText()"
|
||||
[hidden]="hidden()"
|
||||
[textContent]="value()"
|
||||
></span>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 '∞';
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
.op-bubble
|
||||
@include indicator-bubble
|
||||
|
||||
&_alt_highlighting
|
||||
background: #878787
|
||||
|
||||
&_squared
|
||||
padding: 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user