Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add organization members page #10

Merged
merged 15 commits into from
Feb 13, 2025
41 changes: 23 additions & 18 deletions src/@seed/api/cycle/cycle.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ import type { HttpErrorResponse } from '@angular/common/http'
import { HttpClient } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import type { Observable } from 'rxjs'
import { BehaviorSubject, catchError, map, of, throwError } from 'rxjs'
import { BehaviorSubject, catchError, map, tap } from 'rxjs'
import { OrganizationService } from '@seed/api/organization'
import { ErrorService } from '@seed/services/error/error.service'
import { SnackbarService } from 'app/core/snackbar/snackbar.service'
import type { Cycle, CycleResponse, CyclesResponse } from './cycle.types'

@Injectable({ providedIn: 'root' })
export class CycleService {
private _httpClient = inject(HttpClient)
private _organizationService = inject(OrganizationService)
private _snackBar = inject(SnackbarService)
private _errorService = inject(ErrorService)
private _cycles = new BehaviorSubject<Cycle[]>([])
orgId: number

Expand All @@ -25,45 +29,46 @@ export class CycleService {
.get<CyclesResponse>(url)
.pipe(
map((response) => response.cycles),
catchError((error) => {
console.error('Error fetching cycles:', error)
return of([])
tap((cycles) => {
this._cycles.next(cycles)
}),
catchError((error: HttpErrorResponse) => {
return this._errorService.handleError(error, 'Error fetching cycles')
}),
)
.subscribe((cycles) => {
this._cycles.next(cycles)
})
.subscribe()
})
}

post({ data, orgId }): Observable<CycleResponse | null> {
// create a cycle
const url = `/api/v3/cycles/?organization_id=${orgId}`
return this._httpClient.post<CycleResponse>(url, data).pipe(
map((response) => response),
catchError(({ error }: { error: HttpErrorResponse }) => {
console.error('Error creating cycle:', error)
return throwError(() => new Error(error?.message || 'Error creating cycle'))
tap((response) => {
this._snackBar.success(`Created Cycle ${response.cycles.name}`)
}),
catchError((error: HttpErrorResponse) => {
return this._errorService.handleError(error, 'Error creating cycle')
}),
)
}

put({ data, id, orgId }): Observable<CycleResponse | null> {
const url = `/api/v3/cycles/${id}/?organization_id=${orgId}`
return this._httpClient.put<CycleResponse>(url, data).pipe(
catchError(({ error }: { error: HttpErrorResponse }) => {
console.error('Error updating cycle:', error)
return throwError(() => new Error(error?.message || 'Error updating cycle'))
tap((response) => {
this._snackBar.success(`Updated Cycle ${response.cycles.name}`)
}),
catchError((error: HttpErrorResponse) => {
return this._errorService.handleError(error, 'Error updating cycle')
}),
)
}

delete(id: number, orgId: number) {
const url = `/api/v3/cycles/${id}/?organization_id=${orgId}`
return this._httpClient.delete(url).pipe(
catchError(({ error }: { error: HttpErrorResponse }) => {
console.error('Error deleting cycle:', error)
return throwError(() => new Error(error?.message || 'Error deleting cycle'))
catchError((error: HttpErrorResponse) => {
return this._errorService.handleError(error, 'Error deleting cycle')
}),
)
}
Expand Down
79 changes: 74 additions & 5 deletions src/@seed/api/organization/organization.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ import { HttpClient } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import type { Observable } from 'rxjs'
import { catchError, map, of, ReplaySubject, Subject, takeUntil, tap } from 'rxjs'
import { ErrorService } from '@seed/services/error/error.service'
import { SnackbarService } from 'app/core/snackbar/snackbar.service'
import { naturalSort } from '../../utils'
import { UserService } from '../user'
import type {
AccessLevelNode,
AccessLevelsByDepth,
AccessLevelTree,
AccessLevelTreeResponse,
BriefOrganization,
Organization,
OrganizationResponse,
OrganizationSettings,
OrganizationsResponse,
OrganizationUser,
OrganizationUsersResponse,
} from './organization.types'

@Injectable({ providedIn: 'root' })
Expand All @@ -20,11 +27,17 @@ export class OrganizationService {
private _userService = inject(UserService)
private _organizations = new ReplaySubject<BriefOrganization[]>(1)
private _currentOrganization = new ReplaySubject<Organization>(1)
private _organizationUsers = new ReplaySubject<OrganizationUser[]>(1)
private _accessLevelTree = new ReplaySubject<AccessLevelTree>(1)
private _accessLevelInstancesByDepth: AccessLevelsByDepth = {}
private _errorService = inject(ErrorService)
private readonly _unsubscribeAll$ = new Subject<void>()
private _snackBar = inject(SnackbarService)

organizations$ = this._organizations.asObservable()
currentOrganization$ = this._currentOrganization.asObservable()
organizationUsers$ = this._organizationUsers.asObservable()
accessLevelTree$ = this._accessLevelTree.asObservable()

constructor() {
// Fetch current org data whenever user org id changes
Expand Down Expand Up @@ -54,8 +67,53 @@ export class OrganizationService {
}),
catchError((error: HttpErrorResponse) => {
// TODO need to figure out error handling
console.error('Error occurred fetching organization: ', error.error)
return of({} as Organization)
return this._errorService.handleError(error, 'Error fetching organization')
}),
)
}

getOrganizationUsers(orgId: number): void {
const url = `/api/v3/organizations/${orgId}/users/`
this._httpClient.get<OrganizationUsersResponse>(url)
.pipe(
map((response) => response.users.sort((a, b) => naturalSort(a.last_name, b.last_name))),
tap((users) => { this._organizationUsers.next(users) }),
catchError((error: HttpErrorResponse) => {
return this._errorService.handleError(error, 'Error fetching organization users')
}),
).subscribe()
}

getOrganizationAccessLevelTree(orgId: number): void {
const url = `/api/v3/organizations/${orgId}/access_levels/tree`
this._httpClient.get<AccessLevelTreeResponse>(url)
.pipe(
map((response) => {
// update response to include more usable accessLevelInstancesByDepth
this._accessLevelInstancesByDepth = this._calculateAccessLevelInstancesByDepth(response.access_level_tree, 0)
return {
accessLevelNames: response.access_level_names,
accessLevelInstancesByDepth: this._accessLevelInstancesByDepth,
}
}),
tap((accessLevelTree) => {
this._accessLevelTree.next(accessLevelTree)
}),
catchError((error: HttpErrorResponse) => {
return this._errorService.handleError(error, 'Error fetching organization access level tree')
}),
)
.subscribe()
}

deleteOrganizationUser(userId: number, orgId: number) {
const url = `/api/v3/organizations/${orgId}/users/${userId}/remove/`
return this._httpClient.delete(url).pipe(
tap(() => {
this._snackBar.success('Member removed from organization')
}),
catchError((error: HttpErrorResponse) => {
return this._errorService.handleError(error, 'Error removing member from organization')
}),
)
}
Expand All @@ -81,13 +139,24 @@ export class OrganizationService {
})
}),
catchError((error: HttpErrorResponse) => {
console.error('Error occurred fetching organization: ', error.error)
this._snackBar.alert(`An error occurred updating the organization: ${error.error}`)
return of(null)
return this._errorService.handleError(error, 'Error updating organization settings')
}),
)
}

/*
* Transform access level tree into a more usable format
*/
private _calculateAccessLevelInstancesByDepth(tree: AccessLevelNode[], depth: number, result: AccessLevelsByDepth = {}): AccessLevelsByDepth {
if (!tree) return result
if (!result[depth]) result[depth] = []
for (const ali of tree) {
result[depth].push({ id: ali.id, name: ali.name })
this._calculateAccessLevelInstancesByDepth(ali.children, depth + 1, result)
}
return result
}

private _get(brief = false): Observable<(BriefOrganization | Organization)[]> {
const url = brief ? '/api/v3/organizations/?brief=true' : '/api/v3/organizations/'
return this._httpClient.get<OrganizationsResponse>(url).pipe(
Expand Down
37 changes: 37 additions & 0 deletions src/@seed/api/organization/organization.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,40 @@ export type OrganizationResponse = {
export type OrganizationsResponse = {
organizations: (BriefOrganization | Organization)[];
}

export type OrganizationUser = {
access_level: string;
access_level_instance_id: number;
access_level_instance_name: string;
email: string;
first_name: string;
last_name: string;
number_of_orgs: number;
role: UserRole;
user_id: number;
}

export type OrganizationUsersResponse = {
users: OrganizationUser[];
status: string;
}

export type AccessLevelTreeResponse = {
access_level_names: string[];
access_level_tree: AccessLevelNode[];
}

export type AccessLevelTree = {
accessLevelNames: string[];
accessLevelInstancesByDepth: AccessLevelsByDepth;
}

export type AccessLevelNode = {
id: number;
name: string;
organization: number;
path: Record<string, string>;
children: AccessLevelNode[];
}

export type AccessLevelsByDepth = Record<string, { id: number; name: string }[]>
48 changes: 37 additions & 11 deletions src/@seed/api/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { HttpErrorResponse } from '@angular/common/http'
import { HttpClient } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import type { Observable } from 'rxjs'
import { catchError, distinctUntilChanged, ReplaySubject, switchMap, take, tap, throwError } from 'rxjs'
import { catchError, distinctUntilChanged, ReplaySubject, switchMap, take, tap } from 'rxjs'
import type {
CurrentUser,
GenerateApiKeyResponse,
Expand All @@ -11,12 +11,14 @@ import type {
SetDefaultOrganizationResponse,
UserUpdateRequest,
} from '@seed/api/user'
import { ErrorService } from '@seed/services/error/error.service'

@Injectable({ providedIn: 'root' })
export class UserService {
private _httpClient = inject(HttpClient)
private _currentOrganizationId = new ReplaySubject<number>(1)
private _currentUser = new ReplaySubject<CurrentUser>(1)
private _errorService = inject(ErrorService)
currentOrganizationId$ = this._currentOrganizationId.asObservable().pipe(distinctUntilChanged())
currentUser$ = this._currentUser.asObservable()

Expand Down Expand Up @@ -55,15 +57,39 @@ export class UserService {
* Update user
*/
updateUser(userId: number, params: UserUpdateRequest): Observable<CurrentUser> {
return this._httpClient.put<CurrentUser>(`api/v3/users/${userId}/`, params).pipe(
tap((user) => {
this._currentUser.next(user)
}),
catchError((error: HttpErrorResponse) => {
console.error('Error occurred while updating user:', error.error)
return this._currentUser
}),
)
return this._httpClient.put<CurrentUser>(`api/v3/users/${userId}/`, params)
.pipe(
tap((user) => {
this._currentUser.next(user)
}),
catchError((error: HttpErrorResponse) => {
return this._errorService.handleError(error, 'Error updating user')
}),
)
}

/**
* Update user role
*/
updateUserRole(userId: number, orgId: number, role: string): Observable<{ status: string }> {
const url = `api/v3/users/${userId}/role/?organization_id=${orgId}`
return this._httpClient.put<{ status: string }>(url, { role })
.pipe(
catchError((error: HttpErrorResponse) => {
return this._errorService.handleError(error, 'Error updating user role')
}),
)
}

updateUserAccessLevelInstance(userId: number, orgId: number, accessLevelInstanceId: number): Observable<{ status: string }> {
console.log('update access level instance', userId, orgId, accessLevelInstanceId)
const url = `/api/v3/users/${userId}/access_level_instance/?organization_id=${orgId}`
return this._httpClient.put<{ status: string }>(url, { access_level_instance_id: accessLevelInstanceId })
.pipe(
catchError((error: HttpErrorResponse) => {
return this._errorService.handleError(error, 'Error updating user access level instance')
}),
)
}

/**
Expand All @@ -79,7 +105,7 @@ export class UserService {
this.getCurrentUser().subscribe()
}),
catchError((error: HttpErrorResponse) => {
return throwError(() => error)
return this._errorService.handleError(error, 'Error updating password')
}),
)
}
Expand Down
1 change: 1 addition & 0 deletions src/@seed/components/page/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './page.component'
export * from './table/table-container.component'
11 changes: 11 additions & 0 deletions src/@seed/components/page/table/table-container.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<div class="flex-auto pt-4 sm:pt-6">
<div class="mx-auto w-full max-w-screen-xl">
<div class="grid w-full min-w-0 grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-4">
<div class="bg-card flex flex-auto flex-col overflow-hidden rounded-2xl p-6 shadow sm:col-span-2 md:col-span-4">
<div class="mx-6 overflow-x-auto">
<ng-content></ng-content>
</div>
</div>
</div>
</div>
</div>
9 changes: 9 additions & 0 deletions src/@seed/components/page/table/table-container.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Component } from '@angular/core'

@Component({
selector: 'seed-page-table-container',
templateUrl: './table-container.component.html',
imports: [],
})
export class TableContainerComponent {
}
15 changes: 15 additions & 0 deletions src/@seed/services/error/error.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { HttpErrorResponse } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import { throwError } from 'rxjs'
import { SnackbarService } from 'app/core/snackbar/snackbar.service'

@Injectable({ providedIn: 'root' })
export class ErrorService {
private _snackBar = inject(SnackbarService)

handleError(error: HttpErrorResponse, defaultMessage: string) {
const errorMessage = (error.error as { message: string })?.message || defaultMessage
this._snackBar.alert(errorMessage)
return throwError(() => new Error(error?.message || defaultMessage))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of the backend error responses come in as

{ status: 'error', message: 'some descriptive error message' } 

This should reduce a lot of duplicate code and make service catchErrors much simpler going forward

}
}
Loading