diff --git a/eslint.config.mjs b/eslint.config.mjs index 38cdd930..45424ab9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -153,6 +153,7 @@ export default tseslint.config( ...angular.configs.templateAccessibility, ], rules: { + '@angular-eslint/template/prefer-control-flow': 'error', // TODO '@angular-eslint/template/click-events-have-key-events': 'off', '@angular-eslint/template/interactive-supports-focus': 'off', diff --git a/package.json b/package.json index a5fe56fd..3fea6bf1 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,13 @@ "watch": "ng build --watch -c development", "test": "ng test", "eslint": "ng lint", - "eslint:fix": "run-s eslint -- --fix", + "eslint:fix": "prettier -w \"src/**/*.ts\" && npm run eslint -- --fix", "lint": "run-p -c eslint prettier stylelint", "lint:fix": "run-p -c eslint:fix prettier:fix stylelint:fix", "prettier": "prettier -c \"src/**/*.html\"", - "prettier:fix": "run-s prettier -- -w", + "prettier:fix": "npm run prettier -- -w", "stylelint": "stylelint \"src/**/*.scss\"", - "stylelint:fix": "run-s stylelint -- --fix", + "stylelint:fix": "npm run stylelint -- --fix", "update-translations": "node --env-file=.env --import=tsx update-translations.mts" }, "dependencies": { diff --git a/src/@seed/api/column/column.types.ts b/src/@seed/api/column/column.types.ts new file mode 100644 index 00000000..0122c531 --- /dev/null +++ b/src/@seed/api/column/column.types.ts @@ -0,0 +1,36 @@ +export type Column = { + id: number; + name: string; + organization_id: number; + table_name: 'PropertyState' | 'TaxLotState'; + merge_protection: 'Favor New' | 'Favor Existing'; + shared_field_type: 'None' | 'Public'; + column_name: string; + is_extra_data: boolean; + unit_name: null; + unit_type: null; + display_name: string; + data_type: + | 'number' + | 'float' + | 'integer' + | 'string' + | 'geometry' + | 'datetime' + | 'date' + | 'boolean' + | 'area' + | 'eui' + | 'ghg_intensity' + | 'ghg' + | 'wui' + | 'water_use'; + is_matching_criteria: boolean; + is_updating: boolean; + geocoding_order: number; + recognize_empty: boolean; + comstock_mapping: string | null; + column_description: string; + derived_column: number | null; + is_excluded_from_hash: boolean; +} diff --git a/src/@seed/api/column/index.ts b/src/@seed/api/column/index.ts new file mode 100644 index 00000000..d89f3407 --- /dev/null +++ b/src/@seed/api/column/index.ts @@ -0,0 +1 @@ +export * from './column.types' diff --git a/src/@seed/api/cycle/cycle.types.ts b/src/@seed/api/cycle/cycle.types.ts new file mode 100644 index 00000000..98b9f0dc --- /dev/null +++ b/src/@seed/api/cycle/cycle.types.ts @@ -0,0 +1,18 @@ +export type Cycle = { + name: string; + start: string; + end: string; + organization: number; + user: number | null; + id: number; +} + +export type ListCyclesResponse = { + status: string; + cycles: Cycle[]; +} + +export type GetCycleResponse = { + status: string; + cycles: Cycle; +} diff --git a/src/@seed/api/cycle/index.ts b/src/@seed/api/cycle/index.ts new file mode 100644 index 00000000..ad7ed2a0 --- /dev/null +++ b/src/@seed/api/cycle/index.ts @@ -0,0 +1 @@ +export * from './cycle.types' diff --git a/src/@seed/api/organization/index.ts b/src/@seed/api/organization/index.ts new file mode 100644 index 00000000..955191c0 --- /dev/null +++ b/src/@seed/api/organization/index.ts @@ -0,0 +1,2 @@ +export * from './organization.service' +export * from './organization.types' diff --git a/src/@seed/api/organization/organization.service.ts b/src/@seed/api/organization/organization.service.ts new file mode 100644 index 00000000..6002d7d9 --- /dev/null +++ b/src/@seed/api/organization/organization.service.ts @@ -0,0 +1,36 @@ +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import type { Observable } from 'rxjs' +import { map, ReplaySubject, tap } from 'rxjs' +import { naturalSort } from '../../utils' +import type { BriefOrganization, Organization, OrganizationsResponse } from './organization.types' + +@Injectable({ providedIn: 'root' }) +export class OrganizationService { + private _httpClient = inject(HttpClient) + private _organizations: ReplaySubject = new ReplaySubject(1) + organizations$ = this._organizations.asObservable() + + get(): Observable { + return this._get(false) as Observable + } + + getBrief(): Observable { + return this._get(true) + } + + private _get(brief = false): Observable<(BriefOrganization | Organization)[]> { + const url = brief ? '/api/v3/organizations/?brief=true' : '/api/v3/organizations/' + return this._httpClient.get(url).pipe( + map(({ organizations }) => { + return organizations.toSorted((a, b) => naturalSort(a.name, b.name)) + }), + tap((organizations) => { + // TODO not sure if we actually want to cache this in the replaySubject + if (brief) { + this._organizations.next(organizations) + } + }), + ) + } +} diff --git a/src/@seed/api/organization/organization.types.ts b/src/@seed/api/organization/organization.types.ts new file mode 100644 index 00000000..5e8f4bea --- /dev/null +++ b/src/@seed/api/organization/organization.types.ts @@ -0,0 +1,78 @@ +import type { UserRole } from '@seed/api/user' +import type { Column } from '../column' + +type OrgCycle = { + name: string; + cycle_id: number; + num_properties: number; + num_taxlots: number; +} + +type OrgUser = { + first_name: string; + last_name: string; + email: string; + id: number; +} + +export type BriefOrganization = { + name: string; + org_id: number; + parent_id: number | null; + is_parent: boolean; + id: number; + user_role: UserRole; + display_decimal_places: number; + salesforce_enabled: boolean; + access_level_names: string[]; + audit_template_conditional_import: boolean; + property_display_field: string; + taxlot_display_field: string; +} + +export type Organization = BriefOrganization & { + number_of_users: number; + user_is_owner: boolean; + owners: OrgUser[]; + sub_orgs: (Organization & { is_parent: false })[]; + parent_id: number; + display_units_eui: string; + display_units_ghg: string; + display_units_ghg_intensity: string; + display_units_water_use: string; + display_units_wui: string; + display_units_area: string; + cycles: OrgCycle[]; + created: string; + mapquest_api_key: string; + geocoding_enabled: boolean; + better_analysis_api_key: string; + better_host_url: string; + display_meter_units: Record; + display_meter_water_units: Record; + thermal_conversion_assumption: number; + comstock_enabled: boolean; + new_user_email_from: string; + new_user_email_subject: string; + new_user_email_content: string; + new_user_email_signature: string; + at_organization_token: string; + at_host_url: string; + audit_template_user: string; + audit_template_password: string; + audit_template_city_id: number | null; + audit_template_report_type: string; + audit_template_status_types: string; + audit_template_sync_enabled: boolean; + ubid_threshold: number; + inventory_count: number; + public_feed_enabled: boolean; + public_feed_labels: boolean; + public_geojson_enabled: boolean; + default_reports_x_axis_options: Column[]; + default_reports_y_axis_options: Column[]; + require_2fa: boolean; +} +export type OrganizationsResponse = { + organizations: (BriefOrganization | Organization)[]; +} diff --git a/src/@seed/api/user/index.ts b/src/@seed/api/user/index.ts new file mode 100644 index 00000000..04774e18 --- /dev/null +++ b/src/@seed/api/user/index.ts @@ -0,0 +1,2 @@ +export * from './user.service' +export * from './user.types' diff --git a/src/@seed/api/user/user.service.ts b/src/@seed/api/user/user.service.ts new file mode 100644 index 00000000..d3d08eca --- /dev/null +++ b/src/@seed/api/user/user.service.ts @@ -0,0 +1,42 @@ +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import type { Observable } from 'rxjs' +import { ReplaySubject, switchMap, take, tap } from 'rxjs' +import type { CurrentUser, SetDefaultOrganizationResponse } from '@seed/api/user' + +@Injectable({ providedIn: 'root' }) +export class UserService { + private _httpClient = inject(HttpClient) + private _currentUser: ReplaySubject = new ReplaySubject(1) + currentUser$ = this._currentUser.asObservable() + + /** + * Get the current signed-in user data + */ + getCurrentUser(): Observable { + return this._httpClient.get('/api/v3/users/current/').pipe( + tap((user: CurrentUser) => { + this._currentUser.next(user) + }), + ) + } + + /** + * Set default org + */ + setDefaultOrganization(organizationId: number): Observable { + return this.currentUser$.pipe( + take(1), + switchMap(({ id: userId }) => { + return this._httpClient.put( + `/api/v3/users/${userId}/default_organization/?organization_id=${organizationId}`, + {}, + ) + }), + tap(() => { + // Refresh user info after changing the organization + this.getCurrentUser().subscribe() + }), + ) + } +} diff --git a/src/@seed/api/user/user.types.ts b/src/@seed/api/user/user.types.ts new file mode 100644 index 00000000..04792023 --- /dev/null +++ b/src/@seed/api/user/user.types.ts @@ -0,0 +1,30 @@ +export type UserRole = 'viewer' | 'member' | 'owner' + +export type CurrentUser = { + org_id: number; + org_name: string; + org_role: UserRole; + ali_name: string; + ali_id: number; + is_ali_root: boolean; + is_ali_leaf: boolean; + pk: number; + id: number; + first_name: string; + last_name: string; + email: string; + username: string; + is_superuser: boolean; + api_key: string; +} + +export type SetDefaultOrganizationResponse = { + status: string; + user: { + id: number; + access_level_instance: { + id: number; + name: string; + }; + }; +} diff --git a/src/@seed/utils/index.ts b/src/@seed/utils/index.ts index 9739cb33..f6498556 100644 --- a/src/@seed/utils/index.ts +++ b/src/@seed/utils/index.ts @@ -1,4 +1,5 @@ export * from './exact-match-options' +export * from './natural-sort' export * from './open-in-new-tab' export * from './random-id' export * from './sha256' diff --git a/src/@seed/utils/natural-sort.ts b/src/@seed/utils/natural-sort.ts new file mode 100644 index 00000000..525b0305 --- /dev/null +++ b/src/@seed/utils/natural-sort.ts @@ -0,0 +1 @@ +export const { compare: naturalSort } = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) diff --git a/src/app/app.resolvers.ts b/src/app/app.resolvers.ts index 56dba276..c80dbef6 100644 --- a/src/app/app.resolvers.ts +++ b/src/app/app.resolvers.ts @@ -1,6 +1,8 @@ import { inject } from '@angular/core' import { forkJoin } from 'rxjs' import { ConfigService } from '@seed/api/config' +import { OrganizationService } from '@seed/api/organization/organization.service' +import { UserService } from '@seed/api/user' import { VersionService } from '@seed/api/version' export const configResolver = () => { @@ -9,8 +11,10 @@ export const configResolver = () => { } export const initialDataResolver = () => { + const organizationService = inject(OrganizationService) + const userService = inject(UserService) const versionService = inject(VersionService) // Fork join multiple API endpoint calls to wait on all of them to finish - return forkJoin([versionService.get()]) + return forkJoin([versionService.get(), userService.getCurrentUser(), organizationService.getBrief()]) } diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index e482b4b5..e5a0fa65 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -3,8 +3,8 @@ import { inject, Injectable } from '@angular/core' import { Router } from '@angular/router' import type { Observable } from 'rxjs' import { map, of, tap, throwError } from 'rxjs' +import { UserService } from '@seed/api/user' import { AuthUtils } from 'app/core/auth/auth.utils' -import { UserService } from 'app/core/user/user.service' import type { TokenResponse } from './auth.types' @Injectable({ providedIn: 'root' }) @@ -70,9 +70,6 @@ export class AuthService { // Set the authenticated flag to true this._authenticated = true - - // Store the user on the user service - this._userService.user = AuthUtils.tokenUser(this.accessToken) } signOut(): Observable { diff --git a/src/app/core/auth/auth.types.ts b/src/app/core/auth/auth.types.ts index 666a89ec..8ee39e59 100644 --- a/src/app/core/auth/auth.types.ts +++ b/src/app/core/auth/auth.types.ts @@ -9,7 +9,4 @@ export type UserToken = { iat: number; jti: string; user_id: number; - name: string; - username: string; - email: string; } diff --git a/src/app/core/auth/auth.utils.ts b/src/app/core/auth/auth.utils.ts index 85c5a32a..69d3e659 100644 --- a/src/app/core/auth/auth.utils.ts +++ b/src/app/core/auth/auth.utils.ts @@ -5,20 +5,9 @@ // https://github.com/auth0/angular2-jwt // ----------------------------------------------------------------------------------------------------- import { jwtDecode } from 'jwt-decode' -import type { User } from '../user/user.types' import type { UserToken } from './auth.types' export class AuthUtils { - static tokenUser(token: string): User { - const { email, name, user_id, username } = this._decodeToken(token) - return { - id: user_id, - name, - username, - email, - } - } - static isTokenExpired(token: string): boolean { // Return if there is no token if (!token) { diff --git a/src/app/core/user/user.service.ts b/src/app/core/user/user.service.ts deleted file mode 100644 index 5c028900..00000000 --- a/src/app/core/user/user.service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { HttpClient } from '@angular/common/http' -import { inject, Injectable } from '@angular/core' -import type { Observable } from 'rxjs' -import { map, ReplaySubject, tap } from 'rxjs' -import type { User } from 'app/core/user/user.types' - -@Injectable({ providedIn: 'root' }) -export class UserService { - private _httpClient = inject(HttpClient) - private _user: ReplaySubject = new ReplaySubject(1) - - /** - * Setter & getter for user - */ - set user(value: User) { - // Store the value - this._user.next(value) - } - - get user$(): Observable { - return this._user.asObservable() - } - - /** - * Get the current signed-in user data - */ - get(): Observable { - return this._httpClient.get('api/common/user').pipe( - tap((user) => { - this._user.next(user) - }), - ) - } - - /** - * Update the user - */ - update(user: User): Observable { - return this._httpClient.patch('api/common/user', { user }).pipe( - map((response) => { - this._user.next(response) - }), - ) - } -} diff --git a/src/app/core/user/user.types.ts b/src/app/core/user/user.types.ts deleted file mode 100644 index f97a6603..00000000 --- a/src/app/core/user/user.types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type User = { - id: number; - name: string; - username: string; - email: string; -} diff --git a/src/app/layout/common/organization-selector/organization-selector.component.html b/src/app/layout/common/organization-selector/organization-selector.component.html new file mode 100644 index 00000000..c78c86ac --- /dev/null +++ b/src/app/layout/common/organization-selector/organization-selector.component.html @@ -0,0 +1,10 @@ + + + + @for (org of organizations; track org.id) { + + } + diff --git a/src/app/layout/common/organization-selector/organization-selector.component.ts b/src/app/layout/common/organization-selector/organization-selector.component.ts new file mode 100644 index 00000000..cfc02350 --- /dev/null +++ b/src/app/layout/common/organization-selector/organization-selector.component.ts @@ -0,0 +1,46 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { ChangeDetectionStrategy, Component, inject, ViewEncapsulation } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MatIcon } from '@angular/material/icon' +import { MatMenuModule } from '@angular/material/menu' +import { Subject, takeUntil } from 'rxjs' +import { OrganizationService } from '@seed/api/organization/organization.service' +import type { BriefOrganization } from '@seed/api/organization/organization.types' +import type { CurrentUser } from '@seed/api/user' +import { UserService } from '@seed/api/user' + +@Component({ + selector: 'seed-organization-selector', + templateUrl: './organization-selector.component.html', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + exportAs: 'organization-selector', + imports: [CommonModule, MatMenuModule, MatButtonModule, MatIcon], +}) +export class OrganizationSelectorComponent implements OnInit, OnDestroy { + private _organizationService = inject(OrganizationService) + private _userService = inject(UserService) + + private readonly _unsubscribeAll$ = new Subject() + currentUser: CurrentUser + organizations: BriefOrganization[] + + ngOnInit(): void { + this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => { + this.currentUser = currentUser + }) + this._organizationService.organizations$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((organizations) => { + this.organizations = organizations + }) + } + + selectOrganization(organizationId: number) { + this._userService.setDefaultOrganization(organizationId).pipe(takeUntil(this._unsubscribeAll$)).subscribe() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/layout/common/user/user.component.ts b/src/app/layout/common/user/user.component.ts index 7774df1e..ad5fb200 100644 --- a/src/app/layout/common/user/user.component.ts +++ b/src/app/layout/common/user/user.component.ts @@ -6,9 +6,9 @@ import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' import { MatMenuModule } from '@angular/material/menu' import { Subject, takeUntil } from 'rxjs' +import type { CurrentUser } from '@seed/api/user' +import { UserService } from '@seed/api/user' import { AuthService } from 'app/core/auth/auth.service' -import { UserService } from 'app/core/user/user.service' -import type { User } from 'app/core/user/user.types' import { sha256 } from '../../../../@seed/utils' @Component({ @@ -27,14 +27,14 @@ export class UserComponent implements OnInit, OnDestroy { static ngAcceptInputType_showAvatar: BooleanInput @Input() showAvatar = true - user: User + user: CurrentUser avatarUrl: string private readonly _unsubscribeAll$ = new Subject() ngOnInit(): void { // Subscribe to user changes - this._userService.user$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((user: User) => { + this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((user) => { this.user = user this.avatarUrl = `https://gravatar.com/avatar/${sha256(this.user.email.toLowerCase())}?size=128&d=mp` diff --git a/src/app/layout/layouts/main/main.component.html b/src/app/layout/layouts/main/main.component.html index 8f285bc4..ca214c54 100644 --- a/src/app/layout/layouts/main/main.component.html +++ b/src/app/layout/layouts/main/main.component.html @@ -46,6 +46,7 @@
+
diff --git a/src/app/layout/layouts/main/main.component.ts b/src/app/layout/layouts/main/main.component.ts index cf5726e8..81b050f7 100644 --- a/src/app/layout/layouts/main/main.component.ts +++ b/src/app/layout/layouts/main/main.component.ts @@ -6,9 +6,11 @@ import { MatIconModule } from '@angular/material/icon' import { RouterLink, RouterOutlet } from '@angular/router' import { Subject, takeUntil } from 'rxjs' import { VersionService } from '@seed/api/version' -import { type NavigationItem, SEEDLoadingBarComponent, SeedNavigationService, VerticalNavigationComponent } from '@seed/components' +import type { NavigationItem } from '@seed/components' +import { SEEDLoadingBarComponent, SeedNavigationService, VerticalNavigationComponent } from '@seed/components' import { MediaWatcherService } from '@seed/services' import { NavigationService } from 'app/core/navigation/navigation.service' +import { OrganizationSelectorComponent } from 'app/layout/common/organization-selector/organization-selector.component' import { UserComponent } from 'app/layout/common/user/user.component' import { DatasetService } from '../../../../@seed/api/dataset' @@ -20,6 +22,7 @@ import { DatasetService } from '../../../../@seed/api/dataset' CdkScrollable, MatButtonModule, MatIconModule, + OrganizationSelectorComponent, RouterLink, RouterOutlet, SEEDLoadingBarComponent, diff --git a/src/app/mock-api/common/user/api.ts b/src/app/mock-api/common/user/api.ts index 6f311f1d..d7704080 100644 --- a/src/app/mock-api/common/user/api.ts +++ b/src/app/mock-api/common/user/api.ts @@ -1,13 +1,13 @@ import { inject, Injectable } from '@angular/core' +import type { CurrentUser } from '@seed/api/user' import { MockApiService } from '@seed/mock-api' import { user as userData } from 'app/mock-api/common/user/data' -import type { User } from '../../../core/user/user.types' @Injectable({ providedIn: 'root' }) export class UserMockApi { private _mockApiService = inject(MockApiService) - private _user: User = userData + private _user: CurrentUser = userData constructor() { // Register Mock API handlers @@ -22,7 +22,7 @@ export class UserMockApi { this._mockApiService.onPatch('api/common/user').reply(({ request }) => { // Get the user mock-api - const user = structuredClone((request.body as { user: User }).user) + const user = structuredClone((request.body as { user: CurrentUser }).user) // Update the user mock-api this._user = { ...this._user, ...user } diff --git a/src/app/mock-api/common/user/data.ts b/src/app/mock-api/common/user/data.ts index 3dcacea3..3c251238 100644 --- a/src/app/mock-api/common/user/data.ts +++ b/src/app/mock-api/common/user/data.ts @@ -1,8 +1,19 @@ -import type { User } from '../../../core/user/user.types' +import type { CurrentUser } from '@seed/api/user' -export const user: User = { +export const user: CurrentUser = { + org_id: 1, + org_name: 'NREL', + org_role: 'owner', + ali_name: 'root', + ali_id: 1, + is_ali_root: true, + is_ali_leaf: true, + pk: 1, id: 1, - name: 'Alex Swindler', + first_name: 'Alex', + last_name: 'Swindler', email: 'Alex.Swindler@nrel.gov', username: 'alex.swindler@nrel.gov', + is_superuser: true, + api_key: '226347f24542d889e2f76c043696cfd21d1e0556', }