</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">81</context>
+ <context context-type="linenumber">82</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">83</context>
+ <context context-type="linenumber">84</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">88</context>
+ <context context-type="linenumber">89</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">90</context>
+ <context context-type="linenumber">91</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">50</context>
+ <context context-type="linenumber">51</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">244</context>
+ <context context-type="linenumber">245</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">246</context>
+ <context context-type="linenumber">247</context>
</context-group>
</trans-unit>
<trans-unit id="2501522447884928778" datatype="html">
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">279</context>
+ <context context-type="linenumber">280</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">282</context>
+ <context context-type="linenumber">283</context>
</context-group>
</trans-unit>
<trans-unit id="2272120016352772836" datatype="html">
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">204</context>
+ <context context-type="linenumber">205</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">206</context>
+ <context context-type="linenumber">207</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">344</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html</context>
+ <context context-type="linenumber">11</context>
+ </context-group>
</trans-unit>
<trans-unit id="8545554728558600606" datatype="html">
<source>Document processing</source>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
- <context context-type="linenumber">159</context>
+ <context context-type="linenumber">161</context>
</context-group>
</trans-unit>
<trans-unit id="2991443309752293110" datatype="html">
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">267</context>
+ <context context-type="linenumber">268</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">269</context>
+ <context context-type="linenumber">270</context>
</context-group>
</trans-unit>
<trans-unit id="103921551219467537" datatype="html">
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">227</context>
+ <context context-type="linenumber">228</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">230</context>
+ <context context-type="linenumber">231</context>
</context-group>
</trans-unit>
<trans-unit id="3818027200170621545" datatype="html">
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">258</context>
+ <context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">260</context>
+ <context context-type="linenumber">261</context>
</context-group>
</trans-unit>
<trans-unit id="4569276013106377105" datatype="html">
<source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">42</context>
+ <context context-type="linenumber">43</context>
</context-group>
</trans-unit>
<trans-unit id="2127032578120864096" datatype="html">
<source>My Profile</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">46</context>
+ <context context-type="linenumber">47</context>
</context-group>
</trans-unit>
<trans-unit id="3797778920049399855" datatype="html">
<source>Logout</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">53</context>
+ <context context-type="linenumber">54</context>
</context-group>
</trans-unit>
<trans-unit id="4895326106573044490" datatype="html">
<source>Documentation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">58</context>
+ <context context-type="linenumber">59</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">288</context>
+ <context context-type="linenumber">289</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">291</context>
+ <context context-type="linenumber">292</context>
</context-group>
</trans-unit>
<trans-unit id="472206565520537964" datatype="html">
<source>Saved views</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">98</context>
+ <context context-type="linenumber">99</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">103</context>
+ <context context-type="linenumber">104</context>
</context-group>
</trans-unit>
<trans-unit id="6988090220128974198" datatype="html">
<source>Open documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">130</context>
+ <context context-type="linenumber">131</context>
</context-group>
</trans-unit>
<trans-unit id="5687256342387781369" datatype="html">
<source>Close all</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">150</context>
+ <context context-type="linenumber">151</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">152</context>
+ <context context-type="linenumber">153</context>
</context-group>
</trans-unit>
<trans-unit id="3897348120591552265" datatype="html">
<source>Manage</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">161</context>
+ <context context-type="linenumber">162</context>
</context-group>
</trans-unit>
<trans-unit id="7437910965833684826" datatype="html">
<source>Correspondents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">167</context>
+ <context context-type="linenumber">168</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">169</context>
+ <context context-type="linenumber">170</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
<source>Tags</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">174</context>
+ <context context-type="linenumber">175</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">177</context>
+ <context context-type="linenumber">178</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
<source>Document Types</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">183</context>
+ <context context-type="linenumber">184</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">185</context>
+ <context context-type="linenumber">186</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
<source>Storage Paths</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">190</context>
+ <context context-type="linenumber">191</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">192</context>
+ <context context-type="linenumber">193</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
<source>Custom Fields</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">197</context>
+ <context context-type="linenumber">198</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">199</context>
+ <context context-type="linenumber">200</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
<source>Workflows</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">213</context>
+ <context context-type="linenumber">214</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">215</context>
+ <context context-type="linenumber">216</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
<source>Mail</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">220</context>
+ <context context-type="linenumber">221</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">223</context>
+ <context context-type="linenumber">224</context>
</context-group>
</trans-unit>
<trans-unit id="7844706011418789951" datatype="html">
<source>Administration</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">238</context>
+ <context context-type="linenumber">239</context>
</context-group>
</trans-unit>
<trans-unit id="3008420115644088420" datatype="html">
<source>Configuration</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">251</context>
+ <context context-type="linenumber">252</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">253</context>
+ <context context-type="linenumber">254</context>
</context-group>
</trans-unit>
<trans-unit id="1534029177398918729" datatype="html">
<source>GitHub</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">298</context>
+ <context context-type="linenumber">299</context>
</context-group>
</trans-unit>
<trans-unit id="4112664765954374539" datatype="html">
<source>is available.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">307,308</context>
+ <context context-type="linenumber">308,309</context>
</context-group>
</trans-unit>
<trans-unit id="1175891574282637937" datatype="html">
<source>Click to view.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">308</context>
+ <context context-type="linenumber">309</context>
</context-group>
</trans-unit>
<trans-unit id="9811291095862612" datatype="html">
<source>Paperless-ngx can automatically check for updates</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">312</context>
+ <context context-type="linenumber">313</context>
</context-group>
</trans-unit>
<trans-unit id="894819944961861800" datatype="html">
<source> How does this work? </source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">319,321</context>
+ <context context-type="linenumber">320,322</context>
</context-group>
</trans-unit>
<trans-unit id="509090351011426949" datatype="html">
<source>Update available</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">332</context>
+ <context context-type="linenumber">333</context>
</context-group>
</trans-unit>
<trans-unit id="1542489069631984294" datatype="html">
<source>Sidebar views updated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
- <context context-type="linenumber">243</context>
+ <context context-type="linenumber">245</context>
</context-group>
</trans-unit>
<trans-unit id="3547923076537026828" datatype="html">
<source>Error updating sidebar views</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
- <context context-type="linenumber">246</context>
+ <context context-type="linenumber">248</context>
</context-group>
</trans-unit>
<trans-unit id="2526035785704676448" datatype="html">
<source>An error occurred while saving update checking settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
- <context context-type="linenumber">267</context>
+ <context context-type="linenumber">269</context>
</context-group>
</trans-unit>
<trans-unit id="4580988005648117665" datatype="html">
<context context-type="linenumber">250</context>
</context-group>
</trans-unit>
+ <trans-unit id="8193912662253833654" datatype="html">
+ <source>Clear All</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html</context>
+ <context context-type="linenumber">16</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="1656872994210958357" datatype="html">
+ <source>No notifications</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html</context>
+ <context context-type="linenumber">20</context>
+ </context-group>
+ </trans-unit>
<trans-unit id="8700121026680200191" datatype="html">
<source>Clear</source>
<context-group purpose="location">
<context context-type="linenumber">111</context>
</context-group>
<context-group purpose="location">
- <context context-type="sourcefile">src/app/components/common/toasts/toasts.component.html</context>
- <context context-type="linenumber">28</context>
+ <context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
+ <context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="6886003843406464884" datatype="html">
<context context-type="linenumber">47</context>
</context-group>
<context-group purpose="location">
- <context context-type="sourcefile">src/app/components/common/toasts/toasts.component.html</context>
- <context context-type="linenumber">26</context>
+ <context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
+ <context context-type="linenumber">28</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<trans-unit id="6732151329960766506" datatype="html">
<source>Copy Raw Error</source>
<context-group purpose="location">
- <context context-type="sourcefile">src/app/components/common/toasts/toasts.component.html</context>
- <context context-type="linenumber">41</context>
+ <context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
+ <context context-type="linenumber">43</context>
</context-group>
</trans-unit>
<trans-unit id="6581372518205328477" datatype="html">
</div>
</div>
<ul ngbNav class="order-sm-3">
+ <pngx-toasts-dropdown></pngx-toasts-dropdown>
<li ngbDropdown class="nav-item dropdown">
- <button class="btn border-0" id="userDropdown" ngbDropdownToggle>
- <span class="small me-2 d-none d-sm-inline">
+ <button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle>
+ <i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
+ <span class="small ms-2 d-none d-sm-inline">
{{this.settingsService.displayName}}
</span>
- <i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
</button>
<div ngbDropdownMenu class="dropdown-menu-end shadow me-2" aria-labelledby="userDropdown">
<div class="d-sm-none">
}
}
-.dropdown.show .dropdown-toggle,
-.dropdown-toggle:hover {
+:host ::ng-deep .dropdown.show .dropdown-toggle,
+:host ::ng-deep .dropdown-toggle:hover {
opacity: 0.7;
}
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { GlobalSearchComponent } from './global-search/global-search.component'
+import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.component'
@Component({
selector: 'pngx-app-frame',
GlobalSearchComponent,
DocumentTitlePipe,
IfPermissionsDirective,
+ ToastsDropdownComponent,
RouterModule,
NgClass,
NgbDropdownModule,
--- /dev/null
+
+<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
+ @if (toasts.length) {
+ <span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span>
+ }
+ <button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle>
+ <i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
+ </button>
+ <div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
+ <div class="btn-toolbar align-items-center" role="toolbar">
+ <h6 i18n>Notifications</h6>
+ <div class="btn-group ms-auto">
+ <button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
+ (click)="toastService.clearToasts()"
+ [disabled]="toasts.length === 0"
+ i18n>Clear All</button>
+ </div>
+ </div>
+ @if (toasts.length === 0) {
+ <p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
+ }
+ <div class="scroll-list">
+ @for (toast of toasts; track toast.id) {
+ <pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (close)="toastService.closeToast(toast)"></pngx-toast>
+ }
+ </div>
+ </div>
+</li>
--- /dev/null
+.dropdown-menu {
+ width: var(--pngx-toast-max-width);
+}
+
+.dropdown-menu .scroll-list {
+ max-height: 500px;
+ overflow-y: auto;
+}
+
+.dropdown-toggle::after {
+ display: none;
+}
+
+.dropdown-item {
+ white-space: initial;
+}
+
+@media screen and (max-width: 400px) {
+ :host ::ng-deep .dropdown-menu-end {
+ right: -3rem;
+ }
+}
--- /dev/null
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { provideHttpClientTesting } from '@angular/common/http/testing'
+import {
+ ComponentFixture,
+ TestBed,
+ discardPeriodicTasks,
+ fakeAsync,
+ flush,
+} from '@angular/core/testing'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { Subject } from 'rxjs'
+import { Toast, ToastService } from 'src/app/services/toast.service'
+import { ToastsDropdownComponent } from './toasts-dropdown.component'
+
+const toasts = [
+ {
+ id: 'abc-123',
+ content: 'foo bar',
+ delay: 5000,
+ },
+ {
+ id: 'def-123',
+ content: 'Error 1 content',
+ delay: 5000,
+ error: 'Error 1 string',
+ },
+ {
+ id: 'ghi-123',
+ content: 'Error 2 content',
+ delay: 5000,
+ error: {
+ url: 'https://example.com',
+ status: 500,
+ statusText: 'Internal Server Error',
+ message: 'Internal server error 500 message',
+ error: { detail: 'Error 2 message details' },
+ },
+ },
+]
+
+describe('ToastsDropdownComponent', () => {
+ let component: ToastsDropdownComponent
+ let fixture: ComponentFixture<ToastsDropdownComponent>
+ let toastService: ToastService
+ let toastsSubject: Subject<Toast[]> = new Subject()
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({
+ imports: [
+ ToastsDropdownComponent,
+ NgxBootstrapIconsModule.pick(allIcons),
+ ],
+ providers: [
+ provideHttpClient(withInterceptorsFromDi()),
+ provideHttpClientTesting(),
+ ],
+ }).compileComponents()
+
+ fixture = TestBed.createComponent(ToastsDropdownComponent)
+ toastService = TestBed.inject(ToastService)
+ jest.spyOn(toastService, 'getToasts').mockReturnValue(toastsSubject)
+
+ component = fixture.componentInstance
+
+ fixture.detectChanges()
+ })
+
+ it('should call getToasts and return toasts', fakeAsync(() => {
+ const spy = jest.spyOn(toastService, 'getToasts')
+
+ component.ngOnInit()
+ toastsSubject.next(toasts)
+ fixture.detectChanges()
+
+ expect(spy).toHaveBeenCalled()
+ expect(component.toasts).toContainEqual({
+ id: 'abc-123',
+ content: 'foo bar',
+ delay: 5000,
+ })
+
+ component.ngOnDestroy()
+ flush()
+ discardPeriodicTasks()
+ }))
+
+ it('should show a toast', fakeAsync(() => {
+ component.ngOnInit()
+ toastsSubject.next(toasts)
+ fixture.detectChanges()
+
+ expect(fixture.nativeElement.textContent).toContain('foo bar')
+
+ component.ngOnDestroy()
+ flush()
+ discardPeriodicTasks()
+ }))
+
+ it('should toggle suppressPopupToasts', fakeAsync((finish) => {
+ component.ngOnInit()
+ fixture.detectChanges()
+ toastsSubject.next(toasts)
+
+ const spy = jest.spyOn(toastService, 'suppressPopupToasts', 'set')
+ component.onOpenChange(true)
+ expect(spy).toHaveBeenCalledWith(true)
+
+ component.ngOnDestroy()
+ flush()
+ discardPeriodicTasks()
+ }))
+})
--- /dev/null
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import {
+ NgbDropdownModule,
+ NgbProgressbarModule,
+} from '@ng-bootstrap/ng-bootstrap'
+import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { Subscription } from 'rxjs'
+import { Toast, ToastService } from 'src/app/services/toast.service'
+import { ToastComponent } from '../../common/toast/toast.component'
+
+@Component({
+ selector: 'pngx-toasts-dropdown',
+ templateUrl: './toasts-dropdown.component.html',
+ styleUrls: ['./toasts-dropdown.component.scss'],
+ imports: [
+ ToastComponent,
+ NgbDropdownModule,
+ NgbProgressbarModule,
+ NgxBootstrapIconsModule,
+ ],
+})
+export class ToastsDropdownComponent implements OnInit, OnDestroy {
+ constructor(public toastService: ToastService) {}
+
+ private subscription: Subscription
+
+ public toasts: Toast[] = []
+
+ ngOnDestroy(): void {
+ this.subscription?.unsubscribe()
+ }
+
+ ngOnInit(): void {
+ this.subscription = this.toastService.getToasts().subscribe((toasts) => {
+ this.toasts = [...toasts]
+ })
+ }
+
+ onOpenChange(open: boolean): void {
+ this.toastService.suppressPopupToasts = open
+ }
+}
--- /dev/null
+<ngb-toast
+ [autohide]="autohide"
+ [delay]="toast.delay"
+ [class]="toast.classname"
+ [class.mb-2]="true"
+ (shown)="onShown(toast)"
+ (hidden)="hidden.emit(toast)">
+ @if (autohide) {
+ <ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar>
+ <span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
+ }
+ <div class="d-flex align-items-top">
+ @if (!toast.error) {
+ <i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
+ }
+ @if (toast.error) {
+ <i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
+ }
+ <div>
+ <p class="ms-2 mb-0">{{toast.content}}</p>
+ @if (toast.error) {
+ <details class="ms-2">
+ <div class="mt-2 ms-n4 me-n2 small">
+ @if (isDetailedError(toast.error)) {
+ <dl class="row mb-0">
+ <dt class="col-sm-3 fw-normal text-end">URL</dt>
+ <dd class="col-sm-9">{{ toast.error.url }}</dd>
+ <dt class="col-sm-3 fw-normal text-end" i18n>Status</dt>
+ <dd class="col-sm-9">{{ toast.error.status }} <em>{{ toast.error.statusText }}</em></dd>
+ <dt class="col-sm-3 fw-normal text-end" i18n>Error</dt>
+ <dd class="col-sm-9">{{ getErrorText(toast.error) }}</dd>
+ </dl>
+ }
+ <div class="row">
+ <div class="col offset-sm-3">
+ <button class="btn btn-sm btn-outline-dark" (click)="copyError(toast.error)">
+ @if (!copied) {
+ <i-bs name="clipboard"></i-bs>
+ }
+ @if (copied) {
+ <i-bs name="clipboard-check"></i-bs>
+ }
+ <ng-container i18n>Copy Raw Error</ng-container>
+ </button>
+ </div>
+ </div>
+ </div>
+ </details>
+ }
+ @if (toast.action) {
+ <p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="close.emit(toast); toast.action()">{{toast.actionName}}</button></p>
+ }
+ </div>
+ <button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="close.emit(toast);"></button>
+ </div>
+</ngb-toast>
--- /dev/null
+::ng-deep .toast-body {
+ position: relative;
+}
+
+::ng-deep .toast.error {
+ border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
+}
+
+::ng-deep .toast.error .toast-body {
+ background-color: hsla(350, 79%, 40%, 0.8); // bg-danger
+ border-top-left-radius: inherit;
+ border-top-right-radius: inherit;
+ border-bottom-left-radius: inherit;
+ border-bottom-right-radius: inherit;
+}
+
+.progress {
+ background-color: var(--pngx-primary);
+ opacity: .07;
+}
--- /dev/null
+import {
+ ComponentFixture,
+ discardPeriodicTasks,
+ fakeAsync,
+ flush,
+ TestBed,
+ tick,
+} from '@angular/core/testing'
+
+import { Clipboard } from '@angular/cdk/clipboard'
+import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { ToastComponent } from './toast.component'
+
+const toast1 = {
+ content: 'Error 1 content',
+ delay: 5000,
+ error: 'Error 1 string',
+}
+
+const toast2 = {
+ content: 'Error 2 content',
+ delay: 5000,
+ error: {
+ url: 'https://example.com',
+ status: 500,
+ statusText: 'Internal Server Error',
+ message: 'Internal server error 500 message',
+ error: { detail: 'Error 2 message details' },
+ },
+}
+
+describe('ToastComponent', () => {
+ let component: ToastComponent
+ let fixture: ComponentFixture<ToastComponent>
+ let clipboard: Clipboard
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)],
+ }).compileComponents()
+
+ fixture = TestBed.createComponent(ToastComponent)
+ clipboard = TestBed.inject(Clipboard)
+ component = fixture.componentInstance
+ })
+
+ it('should create', () => {
+ expect(component).toBeTruthy()
+ })
+
+ it('should countdown toast', fakeAsync(() => {
+ component.toast = toast2
+ fixture.detectChanges()
+ component.onShown(toast2)
+ tick(5000)
+ expect(component.toast.delayRemaining).toEqual(0)
+ flush()
+ discardPeriodicTasks()
+ }))
+
+ it('should show an error if given with toast', fakeAsync(() => {
+ component.toast = toast1
+ fixture.detectChanges()
+
+ expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
+ expect(fixture.nativeElement.textContent).toContain('Error 1 content')
+
+ flush()
+ discardPeriodicTasks()
+ }))
+
+ it('should show error details, support copy', fakeAsync(() => {
+ component.toast = toast2
+ fixture.detectChanges()
+
+ expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
+ expect(fixture.nativeElement.textContent).toContain(
+ 'Error 2 message details'
+ )
+
+ const copySpy = jest.spyOn(clipboard, 'copy')
+ component.copyError(toast2.error)
+ expect(copySpy).toHaveBeenCalled()
+
+ flush()
+ discardPeriodicTasks()
+ }))
+
+ it('should parse error text, add ellipsis', () => {
+ expect(component.getErrorText(toast2.error)).toEqual(
+ 'Error 2 message details'
+ )
+ expect(component.getErrorText({ error: 'Error string no detail' })).toEqual(
+ 'Error string no detail'
+ )
+ expect(component.getErrorText('Error string')).toEqual('')
+ expect(
+ component.getErrorText({ error: { message: 'foo error bar' } })
+ ).toContain('{"message":"foo error bar"}')
+ expect(
+ component.getErrorText({ error: new Array(205).join('a') })
+ ).toContain('...')
+ })
+})
--- /dev/null
+import { Clipboard } from '@angular/cdk/clipboard'
+import { DecimalPipe } from '@angular/common'
+import { Component, EventEmitter, Input, Output } from '@angular/core'
+import {
+ NgbProgressbarModule,
+ NgbToastModule,
+} from '@ng-bootstrap/ng-bootstrap'
+import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { interval, take } from 'rxjs'
+import { Toast } from 'src/app/services/toast.service'
+
+@Component({
+ selector: 'pngx-toast',
+ imports: [
+ DecimalPipe,
+ NgbToastModule,
+ NgbProgressbarModule,
+ NgxBootstrapIconsModule,
+ ],
+ templateUrl: './toast.component.html',
+ styleUrl: './toast.component.scss',
+})
+export class ToastComponent {
+ @Input() toast: Toast
+
+ @Input() autohide: boolean = true
+
+ @Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
+
+ @Output() close: EventEmitter<Toast> = new EventEmitter<Toast>()
+
+ public copied: boolean = false
+
+ constructor(private clipboard: Clipboard) {}
+
+ onShown(toast: Toast) {
+ if (!this.autohide) return
+
+ const refreshInterval = 150
+ const delay = toast.delay - 500 // for fade animation
+
+ interval(refreshInterval)
+ .pipe(take(Math.round(delay / refreshInterval)))
+ .subscribe((count) => {
+ toast.delayRemaining = Math.max(
+ 0,
+ delay - refreshInterval * (count + 1)
+ )
+ })
+ }
+
+ public isDetailedError(error: any): boolean {
+ return (
+ typeof error === 'object' &&
+ 'status' in error &&
+ 'statusText' in error &&
+ 'url' in error &&
+ 'message' in error &&
+ 'error' in error
+ )
+ }
+
+ public copyError(error: any) {
+ this.clipboard.copy(JSON.stringify(error))
+ this.copied = true
+ setTimeout(() => {
+ this.copied = false
+ }, 3000)
+ }
+
+ getErrorText(error: any) {
+ let text: string = error.error?.detail ?? error.error ?? ''
+ if (typeof text === 'object') text = JSON.stringify(text)
+ return `${text.slice(0, 200)}${text.length > 200 ? '...' : ''}`
+ }
+}
-@for (toast of toasts; track toast) {
- <ngb-toast
- [autohide]="true" [delay]="toast.delay"
- [class]="toast.classname"
- [class.mb-2]="true"
- (shown)="onShow(toast)"
- (hidden)="toastService.closeToast(toast)">
- <ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar>
- <span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
- <div class="d-flex align-items-top">
- @if (!toast.error) {
- <i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
- }
- @if (toast.error) {
- <i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
- }
- <div>
- <p class="ms-2 mb-0">{{toast.content}}</p>
- @if (toast.error) {
- <details class="ms-2">
- <div class="mt-2 ms-n4 me-n2 small">
- @if (isDetailedError(toast.error)) {
- <dl class="row mb-0">
- <dt class="col-sm-3 fw-normal text-end">URL</dt>
- <dd class="col-sm-9">{{ toast.error.url }}</dd>
- <dt class="col-sm-3 fw-normal text-end" i18n>Status</dt>
- <dd class="col-sm-9">{{ toast.error.status }} <em>{{ toast.error.statusText }}</em></dd>
- <dt class="col-sm-3 fw-normal text-end" i18n>Error</dt>
- <dd class="col-sm-9">{{ getErrorText(toast.error) }}</dd>
- </dl>
- }
- <div class="row">
- <div class="col offset-sm-3">
- <button class="btn btn-sm btn-outline-dark" (click)="copyError(toast.error)">
- @if (!copied) {
- <i-bs name="clipboard"></i-bs>
- }
- @if (copied) {
- <i-bs name="clipboard-check"></i-bs>
- }
- <ng-container i18n>Copy Raw Error</ng-container>
- </button>
- </div>
- </div>
- </div>
- </details>
- }
- @if (toast.action) {
- <p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="toastService.closeToast(toast); toast.action()">{{toast.actionName}}</button></p>
- }
- </div>
- <button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="toastService.closeToast(toast);"></button>
- </div>
- </ngb-toast>
+@for (toast of toasts; track toast.id) {
+ <pngx-toast [toast]="toast" [autohide]="true" (close)="closeToast()"></pngx-toast>
}
:host {
position: fixed;
top: 0;
- right: 0;
+ right: calc(50% - (var(--pngx-toast-max-width) / 2));
margin: 0.3em;
z-index: 1200;
}
.toast:not(.show) {
display: block; // this corrects an ng-bootstrap bug that prevented animations
}
-
-::ng-deep .toast-body {
- position: relative;
-}
-
-::ng-deep .toast.error {
- border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
-}
-
-::ng-deep .toast.error .toast-body {
- background-color: hsla(350, 79%, 40%, 0.8); // bg-danger
- border-top-left-radius: inherit;
- border-top-right-radius: inherit;
- border-bottom-left-radius: inherit;
- border-bottom-right-radius: inherit;
-}
-
-.progress {
- background-color: var(--pngx-primary);
- opacity: .07;
-}
-import { Clipboard } from '@angular/cdk/clipboard'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
-import {
- ComponentFixture,
- TestBed,
- discardPeriodicTasks,
- fakeAsync,
- flush,
- tick,
-} from '@angular/core/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
-import { of } from 'rxjs'
-import { ToastService } from 'src/app/services/toast.service'
+import { Subject } from 'rxjs'
+import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastsComponent } from './toasts.component'
-const toasts = [
- {
- content: 'foo bar',
- delay: 5000,
+const toast = {
+ content: 'Error 2 content',
+ delay: 5000,
+ error: {
+ url: 'https://example.com',
+ status: 500,
+ statusText: 'Internal Server Error',
+ message: 'Internal server error 500 message',
+ error: { detail: 'Error 2 message details' },
},
- {
- content: 'Error 1 content',
- delay: 5000,
- error: 'Error 1 string',
- },
- {
- content: 'Error 2 content',
- delay: 5000,
- error: {
- url: 'https://example.com',
- status: 500,
- statusText: 'Internal Server Error',
- message: 'Internal server error 500 message',
- error: { detail: 'Error 2 message details' },
- },
- },
-]
+}
describe('ToastsComponent', () => {
let component: ToastsComponent
let fixture: ComponentFixture<ToastsComponent>
let toastService: ToastService
- let clipboard: Clipboard
+ let toastSubject: Subject<Toast> = new Subject()
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [ToastsComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
- {
- provide: ToastService,
- useValue: {
- getToasts: () => of(toasts),
- },
- },
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
fixture = TestBed.createComponent(ToastsComponent)
toastService = TestBed.inject(ToastService)
- clipboard = TestBed.inject(Clipboard)
+ jest.replaceProperty(toastService, 'showToast', toastSubject)
component = fixture.componentInstance
fixture.detectChanges()
})
- it('should call getToasts and return toasts', fakeAsync(() => {
- const spy = jest.spyOn(toastService, 'getToasts')
-
- component.ngOnInit()
- fixture.detectChanges()
-
- expect(spy).toHaveBeenCalled()
- expect(component.toasts).toContainEqual({
- content: 'foo bar',
- delay: 5000,
- })
-
- component.ngOnDestroy()
- flush()
- discardPeriodicTasks()
- }))
-
- it('should show a toast', fakeAsync(() => {
- component.ngOnInit()
- fixture.detectChanges()
-
- expect(fixture.nativeElement.textContent).toContain('foo bar')
-
- component.ngOnDestroy()
- flush()
- discardPeriodicTasks()
- }))
-
- it('should countdown toast', fakeAsync(() => {
- component.ngOnInit()
- fixture.detectChanges()
- component.onShow(toasts[0])
- tick(5000)
- expect(component.toasts[0].delayRemaining).toEqual(0)
- component.ngOnDestroy()
- flush()
- discardPeriodicTasks()
- }))
-
- it('should show an error if given with toast', fakeAsync(() => {
- component.ngOnInit()
- fixture.detectChanges()
-
- expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
- expect(fixture.nativeElement.textContent).toContain('Error 1 content')
-
- component.ngOnDestroy()
- flush()
- discardPeriodicTasks()
- }))
+ it('should create', () => {
+ expect(component).toBeTruthy()
+ })
- it('should show error details, support copy', fakeAsync(() => {
- component.ngOnInit()
- fixture.detectChanges()
+ it('should close toast', () => {
+ component.toasts = [toast]
+ const closeToastSpy = jest.spyOn(toastService, 'closeToast')
+ component.closeToast()
+ expect(component.toasts).toEqual([])
+ expect(closeToastSpy).toHaveBeenCalledWith(toast)
+ })
- expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
- expect(fixture.nativeElement.textContent).toContain(
- 'Error 2 message details'
+ it('should unsubscribe', () => {
+ const unsubscribeSpy = jest.spyOn(
+ (component as any).subscription,
+ 'unsubscribe'
)
-
- const copySpy = jest.spyOn(clipboard, 'copy')
- component.copyError(toasts[2].error)
- expect(copySpy).toHaveBeenCalled()
-
component.ngOnDestroy()
- flush()
- discardPeriodicTasks()
- }))
+ expect(unsubscribeSpy).toHaveBeenCalled()
+ })
- it('should parse error text, add ellipsis', () => {
- expect(component.getErrorText(toasts[2].error)).toEqual(
- 'Error 2 message details'
- )
- expect(component.getErrorText({ error: 'Error string no detail' })).toEqual(
- 'Error string no detail'
- )
- expect(component.getErrorText('Error string')).toEqual('')
- expect(
- component.getErrorText({ error: { message: 'foo error bar' } })
- ).toContain('{"message":"foo error bar"}')
- expect(
- component.getErrorText({ error: new Array(205).join('a') })
- ).toContain('...')
+ it('should subscribe to toastService', () => {
+ component.ngOnInit()
+ toastSubject.next(toast)
+ expect(component.toasts).toEqual([toast])
})
})
-import { Clipboard } from '@angular/cdk/clipboard'
-import { DecimalPipe } from '@angular/common'
import { Component, OnDestroy, OnInit } from '@angular/core'
import {
+ NgbAccordionModule,
NgbProgressbarModule,
- NgbToastModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
-import { Subscription, interval, take } from 'rxjs'
+import { Subscription } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
+import { ToastComponent } from '../toast/toast.component'
@Component({
selector: 'pngx-toasts',
templateUrl: './toasts.component.html',
styleUrls: ['./toasts.component.scss'],
imports: [
- DecimalPipe,
- NgbToastModule,
+ ToastComponent,
+ NgbAccordionModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
],
})
export class ToastsComponent implements OnInit, OnDestroy {
- constructor(
- public toastService: ToastService,
- private clipboard: Clipboard
- ) {}
+ constructor(public toastService: ToastService) {}
private subscription: Subscription
- public toasts: Toast[] = []
-
- public copied: boolean = false
-
- public seconds: number = 0
+ public toasts: Toast[] = [] // array to force change detection
ngOnDestroy(): void {
this.subscription?.unsubscribe()
}
ngOnInit(): void {
- this.subscription = this.toastService.getToasts().subscribe((toasts) => {
- this.toasts = toasts
- this.toasts.forEach((t) => {
- if (typeof t.error === 'string') {
- try {
- t.error = JSON.parse(t.error)
- } catch (e) {}
- }
- })
+ this.subscription = this.toastService.showToast.subscribe((toast) => {
+ this.toasts = toast ? [toast] : []
})
}
- onShow(toast: Toast) {
- const refreshInterval = 150
- const delay = toast.delay - 500 // for fade animation
-
- interval(refreshInterval)
- .pipe(take(delay / refreshInterval))
- .subscribe((count) => {
- toast.delayRemaining = Math.max(
- 0,
- delay - refreshInterval * (count + 1)
- )
- })
- }
-
- public isDetailedError(error: any): boolean {
- return (
- typeof error === 'object' &&
- 'status' in error &&
- 'statusText' in error &&
- 'url' in error &&
- 'message' in error &&
- 'error' in error
- )
- }
-
- public copyError(error: any) {
- this.clipboard.copy(JSON.stringify(error))
- this.copied = true
- setTimeout(() => {
- this.copied = false
- }, 3000)
- }
-
- getErrorText(error: any) {
- let text: string = error.error?.detail ?? error.error ?? ''
- if (typeof text === 'object') text = JSON.stringify(text)
- return `${text.slice(0, 200)}${text.length > 200 ? '...' : ''}`
+ closeToast() {
+ this.toastService.closeToast(this.toasts[0])
+ this.toasts = []
}
}
})
})
+ it('adds a unique id to toast on show', () => {
+ const toast = {
+ title: 'Title',
+ content: 'content',
+ delay: 5000,
+ }
+ toastService.show(toast)
+
+ toastService.getToasts().subscribe((toasts) => {
+ expect(toasts[0].id).toBeDefined()
+ })
+ })
+
+ it('parses error string to object on show', () => {
+ const toast = {
+ title: 'Title',
+ content: 'content',
+ delay: 5000,
+ error: 'Error string',
+ }
+ toastService.show(toast)
+
+ toastService.getToasts().subscribe((toasts) => {
+ expect(toasts[0].error).toEqual('Error string')
+ })
+ })
+
it('creates toasts with defaults on showInfo and showError', () => {
toastService.showInfo('Info toast')
toastService.showError('Error toast')
expect(toasts).toHaveLength(0)
})
})
+
+ it('clears all toasts on clearToasts', () => {
+ toastService.showInfo('Info toast')
+ toastService.showError('Error toast')
+ toastService.clearToasts()
+
+ toastService.getToasts().subscribe((toasts) => {
+ expect(toasts).toHaveLength(0)
+ })
+ })
+
+ it('suppresses popup toasts if suppressPopupToasts is true', (finish) => {
+ toastService.showToast.subscribe((toast) => {
+ expect(toast).not.toBeNull()
+ })
+ toastService.showInfo('Info toast')
+
+ toastService.showToast.subscribe((toast) => {
+ expect(toast).toBeNull()
+ finish()
+ })
+
+ toastService.suppressPopupToasts = true
+ toastService.showInfo('Info toast')
+ })
})
import { Injectable } from '@angular/core'
import { Subject } from 'rxjs'
+import { v4 as uuidv4 } from 'uuid'
export interface Toast {
+ id?: string
+
content: string
delay: number
})
export class ToastService {
constructor() {}
+ _suppressPopupToasts: boolean
+
+ set suppressPopupToasts(value: boolean) {
+ this._suppressPopupToasts = value
+ this.showToast.next(null)
+ }
private toasts: Toast[] = []
private toastsSubject: Subject<Toast[]> = new Subject()
+ public showToast: Subject<Toast> = new Subject()
+
show(toast: Toast) {
- this.toasts.push(toast)
+ if (!toast.id) {
+ toast.id = uuidv4()
+ }
+ if (typeof toast.error === 'string') {
+ try {
+ toast.error = JSON.parse(toast.error)
+ } catch (e) {}
+ }
+ this.toasts.unshift(toast)
+ if (!this._suppressPopupToasts) {
+ this.showToast.next(toast)
+ }
this.toastsSubject.next(this.toasts)
}
}
closeToast(toast: Toast) {
- let index = this.toasts.findIndex((t) => t == toast)
+ let index = this.toasts.findIndex((t) => t.id == toast.id)
if (index > -1) {
this.toasts.splice(index, 1)
this.toastsSubject.next(this.toasts)
getToasts() {
return this.toastsSubject
}
+
+ clearToasts() {
+ this.toasts = []
+ this.toastsSubject.next(this.toasts)
+ this.showToast.next(null)
+ }
}
arrowRightShort,
arrowUpRight,
asterisk,
+ bell,
bodyText,
boxArrowUp,
boxArrowUpRight,
arrowRightShort,
arrowUpRight,
asterisk,
+ bell,
braces,
bodyText,
boxArrowUp,
color: var(--bs-body-color);
}
+.toast {
+ --bs-toast-max-width: var(--pngx-toast-max-width);
+}
+
.alert-primary {
--bs-alert-color: var(--bs-primary);
--bs-alert-bg: var(--pngx-primary-faded);
--pngx-bg-alt2: var(--bs-gray-200);
--pngx-bg-disabled: #f7f7f7;
--pngx-focus-alpha: 0.3;
+ --pngx-toast-max-width: 360px;
+ @media screen and (min-width: 1024px) {
+ --pngx-toast-max-width: 450px;
+ }
}
// Dark text colors allow for maintain contrast with theme color changes