Skip to content

Commit 71cc840

Browse files
crutanperryr16
andauthored
Email templates (#32)
* email template frontend and postoffice service * package change to include ngx-wig, the simple html content editor for email-templates * prettier * linter/package addition * Revert "package change to include ngx-wig, the simple html content editor for email-templates" This reverts commit 681fa95. * remove console statement * lockfile * styles and use alert for delete * help text * help content * guard emtpy templates * error cleanup * delete modal * prettier --------- Co-authored-by: Ross Perry <[email protected]>
1 parent dff7dc5 commit 71cc840

13 files changed

Lines changed: 529 additions & 23 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"lodash-es": "^4.17.21",
5959
"luxon": "^3.5.0",
6060
"material-icons": "^1.13.14",
61+
"ngx-wig": "^19.0.7",
6162
"ol": "^10.5.0",
6263
"ol-ext": "^4.0.31",
6364
"perfect-scrollbar": "^1.5.6",

pnpm-lock.yaml

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/@seed/api/postoffice/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './postoffice.service'
2+
export * from './postoffice.types'
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { HttpClient, type HttpErrorResponse } from '@angular/common/http'
2+
import { inject, Injectable } from '@angular/core'
3+
import { catchError, map, type Observable, ReplaySubject, Subject, takeUntil, tap } from 'rxjs'
4+
import { UserService } from '@seed/api/user'
5+
import { ErrorService } from '@seed/services'
6+
import type { CreateEmailTemplateResponse, EmailTemplate, ListEmailTemplatesResponse } from './postoffice.types'
7+
8+
@Injectable({ providedIn: 'root' })
9+
export class PostOfficeService {
10+
private _httpClient = inject(HttpClient)
11+
private _userService = inject(UserService)
12+
private _errorService = inject(ErrorService)
13+
private _emailTemplates = new ReplaySubject<EmailTemplate[]>()
14+
private readonly _unsubscribeAll$ = new Subject<void>()
15+
orgId: number
16+
emailTemplates$ = this._emailTemplates.asObservable()
17+
18+
constructor() {
19+
this._userService.currentOrganizationId$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((organizationId) => {
20+
this.getEmailTemplates(organizationId).subscribe()
21+
})
22+
}
23+
24+
getEmailTemplates(organizationId: number): Observable<EmailTemplate[]> {
25+
const url = `/api/v3/postoffice/?organization_id=${organizationId}`
26+
return this._httpClient.get<ListEmailTemplatesResponse>(url).pipe(
27+
map((response) => response.data),
28+
tap((emailTemplates) => {
29+
this._emailTemplates.next(emailTemplates)
30+
}),
31+
catchError((error: HttpErrorResponse) => {
32+
return this._errorService.handleError(error, 'Could not fetch email templates')
33+
}),
34+
)
35+
}
36+
37+
create(organization_id: number, name: string): Observable<EmailTemplate> {
38+
const url = '/api/v3/postoffice/'
39+
return this._httpClient.post<CreateEmailTemplateResponse>(url, { name, organization_id }).pipe(
40+
map((response: CreateEmailTemplateResponse) => {
41+
return response.data
42+
}),
43+
catchError((error: HttpErrorResponse) => {
44+
return this._errorService.handleError(error, 'could not create template')
45+
}),
46+
)
47+
}
48+
49+
update(organization_id: number, template: EmailTemplate): Observable<EmailTemplate> {
50+
const url = `/api/v3/postoffice/${template.id}/?organization_id=${organization_id}`
51+
return this._httpClient.put<CreateEmailTemplateResponse>(url, { ...template }).pipe(
52+
map((response: CreateEmailTemplateResponse) => {
53+
return response.data
54+
}),
55+
catchError((error: HttpErrorResponse) => {
56+
return this._errorService.handleError(error, 'could not update template')
57+
}),
58+
)
59+
}
60+
61+
delete(id: number, organization_id: number): Observable<unknown> {
62+
const url = `/api/v3/postoffice/${id}/?organization_id=${organization_id}`
63+
return this._httpClient.delete(url).pipe(
64+
catchError((error: HttpErrorResponse) => {
65+
return this._errorService.handleError(error, 'could not delete template')
66+
}),
67+
)
68+
}
69+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export type EmailTemplate = {
2+
id: number;
3+
name: string;
4+
description: string;
5+
subject: string;
6+
content: string;
7+
html_content: string;
8+
created: string;
9+
last_updated: string;
10+
default_template_id?: number;
11+
language: string;
12+
}
13+
14+
export type CreateEmailTemplateResponse = {
15+
status: string;
16+
data: EmailTemplate;
17+
}
18+
19+
export type ListEmailTemplatesResponse = {
20+
status: string;
21+
data: EmailTemplate[];
22+
}

src/app/modules/inventory-list/cross-cycles/cross-cycles.component.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { ActivatedRoute } from '@angular/router'
77
import { AgGridAngular, AgGridModule } from 'ag-grid-angular'
88
import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
99
import type { Observable } from 'rxjs'
10-
import { combineLatest, EMPTY, filter, switchMap, tap } from 'rxjs'
10+
import { combineLatest, EMPTY, switchMap, tap } from 'rxjs'
1111
import type { Column } from '@seed/api/column'
1212
import { ColumnService } from '@seed/api/column'
1313
import { InventoryService } from '@seed/api/inventory'
@@ -96,17 +96,19 @@ export class CrossCyclesComponent implements OnInit {
9696
}
9797

9898
setGrid() {
99-
return this._inventoryService.filterByCycle(this.orgId, this.selectedProfileId, this.selectedCycleIds, this.type).pipe(
100-
filter((dataByCycle) => {
101-
const noData = !dataByCycle
102-
const emptyData = dataByCycle[Object.keys(dataByCycle)[0]]?.length === 0
103-
return !noData && !emptyData
104-
}),
105-
tap((dataByCycle) => {
106-
this.setColumnDefs()
107-
this.setRowData(dataByCycle)
108-
}),
109-
)
99+
return EMPTY
100+
// DEVELOPER NOTE: Cross cycles is not yet implemented. This is a placeholder for the future
101+
// return this._inventoryService.filterByCycle(this.orgId, this.selectedProfileId, this.selectedCycleIds, this.type).pipe(
102+
// filter((dataByCycle: Record<number, Record<string, unknown>[]>) => {
103+
// const noData = !dataByCycle
104+
// const emptyData = dataByCycle[Object.keys(dataByCycle)[0]]?.length === 0
105+
// return !noData && !emptyData
106+
// }),
107+
// tap((dataByCycle) => {
108+
// this.setColumnDefs()
109+
// this.setRowData(dataByCycle)
110+
// }),
111+
// )
110112
}
111113

112114
setColumnDefs() {
Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,90 @@
1-
<seed-page [config]="{ title: 'Email Templates', titleIcon: 'fa-solid:envelope' }">
2-
<div>Email Templates Content</div>
1+
<seed-page
2+
[config]="{
3+
titleIcon: 'fa-solid:envelope',
4+
title: 'Email Templates',
5+
action: toggleHelp,
6+
actionIcon: 'fa-solid:circle-question',
7+
}"
8+
>
9+
<div class="h-[calc(100vh-260px)]">
10+
<mat-drawer-container class="h-full border">
11+
<mat-drawer class="w-1/2" #drawer [(opened)]="helpOpened" mode="over" position="end">
12+
<ng-container *ngTemplateOutlet="helpTemplate"></ng-container>
13+
</mat-drawer>
14+
<ng-container *ngTemplateOutlet="mainTemplate"></ng-container>
15+
</mat-drawer-container>
16+
</div>
317
</seed-page>
18+
19+
<!-- HELP CONTENT -->
20+
<ng-template class="w-1/3" #helpTemplate>
21+
<div class="prose px-4">
22+
<h2 class="mt-6 flex items-center border-b-2 font-extrabold tracking-tight">
23+
Help<mat-icon class="mx-2 text-current icon-size-3" svgIcon="fa-solid:chevron-right"></mat-icon>Email Templates
24+
</h2>
25+
<h3>Custom Emails</h3>
26+
<div class="my-4">
27+
Custom emails can be sent to Building Owners using the templates defined below. The email will be sent to the SEED record's Owner
28+
Email address and is currently not configurable. The email 'from' address is the same as the server email address which is also used
29+
to email users their account information.
30+
</div>
31+
<div class="my-4">
32+
The email supports brace templating to pull in data from the SEED property record. For example, the snippet below will replace the
33+
latitude and longitude from the SEED record. Other fields can be added, but make sure to use the SEED field name not the display name.
34+
</div>
35+
<div class="my-4 w-fit rounded bg-gray-100 p-2 dark:bg-gray-900">
36+
"Your building's latitude and longitude is {{ '{{' }}latitude{{ '}}' }}, {{ '{{' }}longitude{{ '}}' }}!"
37+
</div>
38+
</div>
39+
</ng-template>
40+
41+
<!-- MAIN CONTENT -->
42+
<ng-template #mainTemplate>
43+
<!-- select template form -->
44+
<div class="flex w-full flex-row items-center justify-end gap-x-2">
45+
<form class="mt-4 flex w-100" [formGroup]="selectedTemplateForm">
46+
<mat-label class="text-secondary mr-4 mt-4">Templates</mat-label>
47+
<mat-form-field class="flex w-full">
48+
<mat-select (selectionChange)="selectTemplate()" formControlName="selectedTemplate">
49+
@for (template of templates; track template.id) {
50+
<mat-option [value]="template.id">{{ template.name }}</mat-option>
51+
}
52+
</mat-select>
53+
</mat-form-field>
54+
</form>
55+
<!-- template actions -->
56+
<a class="flex" [disabled]="!selectedTemplate" (click)="rename()" mat-stroked-button matTooltip="Rename">
57+
<mat-icon class="fill-blue-700 icon-size-4" svgIcon="fa-solid:eraser"></mat-icon>
58+
</a>
59+
<a class="flex" [disabled]="!selectedTemplate" (click)="delete()" mat-stroked-button matTooltip="Delete">
60+
<mat-icon class="fill-red-700 icon-size-4" svgIcon="fa-solid:x"></mat-icon>
61+
</a>
62+
<a class="flex" (click)="create()" mat-stroked-button matTooltip="Create New">
63+
<mat-icon class="fill-blue-600 icon-size-4" svgIcon="fa-solid:folder-plus"></mat-icon>
64+
</a>
65+
</div>
66+
67+
<!-- email template -->
68+
<div class="ml-8 w-2/3">
69+
<form class="m-l-8 flex flex-col" [formGroup]="templateForm">
70+
<mat-form-field class="flex">
71+
<mat-label class="text-secondary">Subject</mat-label>
72+
<input matInput formControlName="subject" />
73+
@if (templateForm.controls.subject?.hasError('required')) {
74+
<mat-error>Subject is a required field</mat-error>
75+
}
76+
</mat-form-field>
77+
78+
<mat-label class="text-secondary">Content</mat-label>
79+
<ngx-wig class="" formControlName="html_content"></ngx-wig>
80+
@if (templateForm.controls.html_content?.hasError('required')) {
81+
<mat-error>Content is a required field</mat-error>
82+
}
83+
<div class="mt-4 flex">
84+
<button [disabled]="templateForm.invalid || templateForm.pending" (click)="save()" mat-flat-button color="primary">
85+
<span>Save Changes</span>
86+
</button>
87+
</div>
88+
</form>
89+
</div>
90+
</ng-template>

0 commit comments

Comments
 (0)