Add prettier as a dev dependency, add prettier ignore file, and run for all .ts .html and .scss files in app

This commit is contained in:
James Kiger committed 2025-04-14 10:13:40 -04:00
1 parent e663c1fa8c
commit 08145fb806
129 files changed
+2494 -1986

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Ignore openapi files
src/app/api/api-base.service.ts
src/app/api/api-schema.d.ts
src/app/api/allauth-schema.d.ts
+17
View File
@@ -87,6 +87,7 @@
"karma-jasmine-html-reporter": "^2.1.0",
"ng-extract-i18n-merge": "^2.14.3",
"openapi-typescript": "^7.6.1",
"prettier": "3.5.3",
"storybook": "^8.6.4",
"storybook-dark-mode": "^4.0.2",
"ts-loader": "^9.2.6",
@@ -19494,6 +19495,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
+1
View File
@@ -106,6 +106,7 @@
"karma-jasmine-html-reporter": "^2.1.0",
"ng-extract-i18n-merge": "^2.14.3",
"openapi-typescript": "^7.6.1",
"prettier": "3.5.3",
"storybook": "^8.6.4",
"storybook-dark-mode": "^4.0.2",
"ts-loader": "^9.2.6",
@@ -1,4 +1,9 @@
import { Component, OnInit, ChangeDetectionStrategy, inject } from "@angular/core";
import {
Component,
OnInit,
ChangeDetectionStrategy,
inject,
} from "@angular/core";
import { ActivatedRoute, RouterLink } from "@angular/router";
import { MatButtonModule } from "@angular/material/button";
import { MatCardModule } from "@angular/material/card";
+8 -8
View File
@@ -29,7 +29,7 @@ export class AcceptInviteService {
private readonly state = new BehaviorSubject<AcceptState>(initialState);
readonly acceptInfo$ = this.state.pipe(map((state) => state.acceptInfo));
readonly orgSlug$ = this.acceptInfo$.pipe(
map((acceptInfo) => acceptInfo?.orgUser.organization.slug)
map((acceptInfo) => acceptInfo?.orgUser.organization.slug),
);
readonly alreadyInOrg$ = combineLatest([
this.orgSlug$,
@@ -38,7 +38,7 @@ export class AcceptInviteService {
map(([orgSlugToMatch, organizations]) => {
if (orgSlugToMatch) {
const match = organizations.find(
(organization) => organization.slug === orgSlugToMatch
(organization) => organization.slug === orgSlugToMatch,
);
if (match) {
return true;
@@ -47,7 +47,7 @@ export class AcceptInviteService {
} else {
return false;
}
})
}),
);
private readonly url = baseUrl + "/accept/";
@@ -68,7 +68,7 @@ export class AcceptInviteService {
}
this.router.navigate(["/"]);
return EMPTY;
})
}),
)
.subscribe();
}
@@ -77,9 +77,9 @@ export class AcceptInviteService {
this.postAcceptInvite(memberId, token)
.pipe(
tap((response: AcceptAPIResponse) => {
this.organizationsService.reload()
this.organizationsService.reload();
this.snackBar.open(
`You have been added to ${response.orgUser.organization.name}.`
`You have been added to ${response.orgUser.organization.name}.`,
);
this.router.navigate(["/"]);
}),
@@ -104,7 +104,7 @@ export class AcceptInviteService {
this.snackBar.open(error.error?.detail);
}
return EMPTY;
})
}),
)
.subscribe();
}
@@ -118,7 +118,7 @@ export class AcceptInviteService {
`${this.url}${memberId}/${token}/`,
{
acceptInvite: true,
}
},
);
}
-1
View File
@@ -24,7 +24,6 @@ interface RecoveryCodes {
export class AccountService {
private http = inject(HttpClient);
changePassword(current_password: string, new_password: string) {
return this.http.post<AllAuthSessionResponse>(
baseUrl + "/password/change",
@@ -36,7 +36,6 @@ function postForm(action: string, data: JsonObject) {
export class AuthenticationService {
private http = inject(HttpClient);
getAuthenticationStatus() {
return this.http.get<AllAuthSessionResponse>(baseUrl + "/session");
}
@@ -55,7 +54,7 @@ export class AuthenticationService {
mfaAuthenticate(code: string) {
return this.http.post<AllAuthSessionResponse>(
baseUrl + "/2fa/authenticate",
{ code }
{ code },
);
}
@@ -73,7 +72,7 @@ export class AuthenticationService {
headers: {
"X-Email-Verification-Key": key,
},
}
},
);
}
@@ -97,7 +96,7 @@ export class AuthenticationService {
resetPassword(
key: string,
password: string
password: string,
): Observable<AllAuthSessionResponse> {
return this.http
.post<AllAuthSessionResponse>(baseUrl + "/password/reset", {
@@ -111,14 +110,14 @@ export class AuthenticationService {
return of(error.error);
}
return throwError(() => error);
})
}),
);
}
providerRedirect(
provider: string,
callbackUrl = "/",
process: "login" | "connect" = "login"
process: "login" | "connect" = "login",
) {
postForm(baseUrl + "/provider/redirect", {
provider,
@@ -130,14 +129,14 @@ export class AuthenticationService {
getWebAuthnCredentialRequest() {
return this.http.get<GetWebAuthnCredentialRequestResponse>(
baseUrl + "/webauthn/authenticate"
baseUrl + "/webauthn/authenticate",
);
}
perform2FAWebAuthn(credential: AuthenticationPublicKeyCredential) {
return this.http.post<AllAuthSessionResponse>(
baseUrl + "/webauthn/authenticate",
{ credential }
{ credential },
);
}
}
@@ -12,7 +12,7 @@ export class MonitorChecksAPIService {
organizationSlug: string,
monitorId: string,
cursor?: string | null,
isChange = true
isChange = true,
) {
let httpParams = new HttpParams();
const url = `${baseUrl}/organizations/${organizationSlug}/monitors/${monitorId}/checks/`;
+8 -8
View File
@@ -21,7 +21,7 @@ export class OrganizationsService {
#activeOrganizationSlug = signal<string | null>(null);
activeOrganizationSlug = computed(
() =>
this.#activeOrganizationSlug() ?? this.organizations()?.[0]?.slug ?? null
this.#activeOrganizationSlug() ?? this.organizations()?.[0]?.slug ?? null,
);
organizationsResource = resource({
request: () => ({ isAuthenticated: this.authService.isAuthenticated() }),
@@ -45,7 +45,7 @@ export class OrganizationsService {
params: {
path: { organization_slug: request.organization_slug },
},
}
},
);
if (error) {
throw error;
@@ -57,16 +57,16 @@ export class OrganizationsService {
organizationsCount = computed(() => this.organizations.length);
activeOrganization = computed(() => this.activeOrganizationResource.value());
activeOrganizationLoaded = computed(
() => this.activeOrganizationResource.status() >= ResourceStatus.Resolved
() => this.activeOrganizationResource.status() >= ResourceStatus.Resolved,
);
activeOrganizationProjects = computed(
() => this.activeOrganization()?.projects || []
() => this.activeOrganization()?.projects || [],
);
projectsCount = computed(() => this.activeOrganizationProjects().length);
initialLoad = computed(
() =>
this.organizationsResource.status() > ResourceStatus.Loading &&
this.activeOrganizationResource.status() > ResourceStatus.Loading
this.activeOrganizationResource.status() > ResourceStatus.Loading,
);
// For compatibility, remove when possible
@@ -97,7 +97,7 @@ export class OrganizationsService {
});
if (data) {
this.organizationsResource.update((orgs) =>
orgs ? [...orgs, data] : [data]
orgs ? [...orgs, data] : [data],
);
}
return { data, error };
@@ -114,8 +114,8 @@ export class OrganizationsService {
takeUntil(interval(2000).pipe(takeUntil(interval(12000)))),
takeWhile(
(org) =>
org?.eventThrottleRate === undefined || org.eventThrottleRate > 0
)
org?.eventThrottleRate === undefined || org.eventThrottleRate > 0,
),
)
.subscribe(() => this.activeOrganizationResource.reload());
}
+13 -13
View File
@@ -42,10 +42,10 @@ export class MembersService {
private readonly state = new BehaviorSubject<MembersState>(initialState);
private readonly getState$ = this.state.asObservable();
readonly loadingResendInvite$ = this.getState$.pipe(
map((state) => state.loadingResendInvite)
map((state) => state.loadingResendInvite),
);
readonly sentResendInvite$ = this.getState$.pipe(
map((state) => state.sentResendInvite)
map((state) => state.sentResendInvite),
);
/** Organization members with computed loading/success data */
readonly members$ = combineLatest([
@@ -66,7 +66,7 @@ export class MembersService {
isMe: member.email === activeUserEmail ? true : false,
};
});
})
}),
);
/** Send another invite to already invited org member */
@@ -82,14 +82,14 @@ export class MembersService {
this.organizationsService.activeOrganizationSlug$.pipe(
take(1),
mergeMap((orgSlug) =>
this.membersAPIService.inviteUser(orgSlug!, data as any)
this.membersAPIService.inviteUser(orgSlug!, data as any),
),
tap(() => this.setResendInviteSuccess(member.id)),
catchError(() => {
this.clearLoadingResendInvite();
return EMPTY;
})
)
}),
),
);
}
@@ -104,16 +104,16 @@ export class MembersService {
.pipe(
exhaustMap(() => {
this.snackBar.open(
`Successfully removed ${member.email} from organization`
`Successfully removed ${member.email} from organization`,
);
if (isRemovingSelf) {
this.organizationsService.reload()
this.organizationsService.setActiveOrganizationSlug(null)
this.organizationsService.reload();
this.organizationsService.setActiveOrganizationSlug(null);
this.router.navigate(["/"]);
}
if (orgSlug) {
return this.organizationDetailService.retrieveOrganizationMembers(
orgSlug
orgSlug,
);
}
return EMPTY;
@@ -129,11 +129,11 @@ export class MembersService {
}
this.snackBar.open(message);
return EMPTY;
})
}),
);
})
}),
),
{ defaultValue: null }
{ defaultValue: null },
);
}
@@ -70,7 +70,7 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
readonly initialLoad = computed(() => this.state().initialLoad);
readonly organizationMembers = computed(
() => this.state().organizationMembers
() => this.state().organizationMembers,
);
readonly orgHasAProject = computed(() => {
@@ -94,8 +94,8 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
return organizationMembers.filter(
(orgMembers) =>
!teamMembers.find(
(teamMems) => orgMembers.id === teamMems.id.toString()
)
(teamMems) => orgMembers.id === teamMems.id.toString(),
),
);
});
@@ -110,7 +110,7 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
});
readonly organizationEnvironments = computed(
() => this.state().organizationEnvironments
() => this.state().organizationEnvironments,
);
readonly organizationEnvironmentsProcessed = computed(() => {
@@ -120,7 +120,7 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
...accumulator,
...(!accumulator.includes(environment.name) ? [environment.name] : []),
],
[] as string[]
[] as string[],
);
});
@@ -138,14 +138,14 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
.pipe(
filter(
([billingEnabled, activeOrganization]) =>
!!billingEnabled && !!activeOrganization
!!billingEnabled && !!activeOrganization,
),
distinctUntilChanged((a, b) => a[1]?.id === b[1]?.id),
tap(([_, activeOrganization]) => {
this.subscriptionService.checkIfUserHasSubscription(
activeOrganization!.slug
activeOrganization!.slug,
);
})
}),
)
.subscribe();
}
@@ -157,7 +157,7 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
return this.organizationAPIService.update(orgSlug, data).pipe(
tap((resp) => {
this.updateOrgName(resp);
})
}),
);
}
return EMPTY;
@@ -172,13 +172,13 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
.then((result) => {
if (result.response.status === 204) {
this.organizationsService.organizationsResource.update((orgs) =>
orgs?.filter((org) => org.slug !== slug)
orgs?.filter((org) => org.slug !== slug),
);
const organizations = this.organizationsService.organizations();
if (organizations.length) {
this.organizationsService.setActiveOrganizationSlug(
organizations[0].slug
organizations[0].slug,
);
} else {
this.organizationsService.setActiveOrganizationSlug(null);
@@ -194,7 +194,7 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
"/api/0/organizations/{organization_slug}/members/",
{
params: { path: { organization_slug: orgSlug } },
}
},
);
if (result.data) {
this.setActiveOrganizationMembers(result.data!);
@@ -206,7 +206,7 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
inviteOrganizationMembers(
emailInput: string,
teamsInput: string[],
roleInput: MemberRole
roleInput: MemberRole,
) {
const orgSlug = this.organizationsService.activeOrganizationSlug();
const data = {
@@ -226,19 +226,19 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
.then((result) => {
if (result.data) {
this.snackBar.open(
`An email invite has been sent to ${result.data.email}`
`An email invite has been sent to ${result.data.email}`,
);
this.router.navigate([orgSlug, "settings", "members"]);
} else {
if (result.response.status === 403) {
this.setAddMemberError(
"Only organization members with a role of manager or owner can invite new members."
"Only organization members with a role of manager or owner can invite new members.",
);
// } else if (error.detail) {
// this.setAddMemberError(error.error?.detail);
} else {
this.setAddMemberError(
"There was an error processing this request."
"There was an error processing this request.",
);
}
}
@@ -250,8 +250,8 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
this.teamsAPIService.list(orgSlug).pipe(
tap((resp) => {
this.setOrganizationTeams(resp);
})
)
}),
),
);
}
@@ -261,7 +261,7 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
{
params: { path: { organization_slug: orgSlug } },
body: { slug: teamSlug },
}
},
);
if (result.data) {
this.organizationsService.refreshActiveOrganization();
@@ -274,10 +274,10 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
return this.teamsAPIService.addTeamMember(member, orgSlug, teamSlug).pipe(
tap((team: Team) => {
lastValueFrom(
this.teamsService.retrieveTeamMembers(orgSlug, team.slug)
this.teamsService.retrieveTeamMembers(orgSlug, team.slug),
);
this.retrieveOrganizationMembers(orgSlug);
})
}),
);
}
@@ -289,7 +289,7 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
.pipe(
tap(() => {
this.teamsService.removeMember(memberId);
})
}),
);
} else {
return EMPTY;
@@ -308,8 +308,8 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
catchError((error: HttpErrorResponse) => {
this.setLeaveTeamError(error);
return EMPTY;
})
)
}),
),
);
}
@@ -325,8 +325,8 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
catchError((error: HttpErrorResponse) => {
this.setJoinTeamError(error);
return EMPTY;
})
)
}),
),
);
}
@@ -346,7 +346,7 @@ export class OrganizationDetailService extends StatefulService<OrganizationsStat
return this.environmentsAPIService.list(orgSlug).pipe(
tap((environments) => {
this.setOrganizationEnvironments(environments);
})
}),
);
}
@@ -36,7 +36,7 @@ export class OrganizationAPIService extends APIBaseService {
project.id = normalizeID(project.id);
});
return orgDetail;
})
}),
);
}
@@ -9,7 +9,6 @@ import { ProjectEnvironment } from "../organizations/organizations.interface";
export class ProjectEnvironmentsAPIService {
protected http = inject(HttpClient);
list(organizationSlug: string, projectSlug: string) {
return this.http.get<ProjectEnvironment[]>(
this.projectEnvironmentsURL(organizationSlug, projectSlug) +
@@ -11,7 +11,6 @@ import { normalizeID } from "../shared-api.utils";
export class ProjectTeamsAPIService {
protected http = inject(HttpClient);
addProjectToTeam(
organizationSlug: string,
teamSlug: string,
@@ -7,7 +7,6 @@ import { Injectable, inject } from "@angular/core";
export class SocialAuthAPIService {
protected http = inject(HttpClient);
disconnect(account: string, provider: string) {
return this.http.request(
"delete",
@@ -55,10 +55,10 @@ export class SubscriptionService extends StatefulService<SubscriptionState> {
subscriptionLoading = computed(
() =>
this.subscriptionResource.isLoading() ||
this.state().subscriptionRefreshing
this.state().subscriptionRefreshing,
);
subscriptionRefreshTimeout = computed(
() => this.state().subscriptionRefreshTimeout
() => this.state().subscriptionRefreshTimeout,
);
eventCountResource = resource({
@@ -76,7 +76,7 @@ export class SubscriptionService extends StatefulService<SubscriptionState> {
params: {
path: { organization_slug: request.orgSlug },
},
}
},
);
if (error) {
throw error;
@@ -99,7 +99,7 @@ export class SubscriptionService extends StatefulService<SubscriptionState> {
billingPortalLoading = computed(() => this.state().billingPortalLoading);
billingPortalLoadingError = computed(
() => this.state().billingPortalLoadingError
() => this.state().billingPortalLoadingError,
);
fromStripe = computed(() => this.state().fromStripe);
totalEventsAllowed = computed(() => {
@@ -147,11 +147,11 @@ export class SubscriptionService extends StatefulService<SubscriptionState> {
params: {
path: { organization_slug: orgSlug },
},
}
},
);
if (error) {
this.setBillingPortalLoadingError(
"Something went wrong. Only organization owners can manage subscription settings."
"Something went wrong. Only organization owners can manage subscription settings.",
);
}
if (data) {
@@ -185,7 +185,7 @@ export class SubscriptionService extends StatefulService<SubscriptionState> {
params: {
path: { organization_slug: orgSlug },
},
}
},
);
if (error) {
throw error;
+2 -2
View File
@@ -46,13 +46,13 @@ export class TeamsAPIService extends APIBaseService {
addTeamMember(member: any, orgSlug: string, teamSlug: string) {
return this.http.post<Team>(
this.teamMemberURL(member.id, orgSlug, teamSlug),
member
member,
);
}
removeTeamMember(memberId: number, orgSlug: string, teamSlug: string) {
return this.http.delete<Team>(
this.teamMemberURL(memberId, orgSlug, teamSlug)
this.teamMemberURL(memberId, orgSlug, teamSlug),
);
}
+6 -6
View File
@@ -42,7 +42,7 @@ export class TeamsService {
readonly teams$ = this.getState$.pipe(map((state) => state.teams));
readonly team$ = this.getState$.pipe(map((data) => data.team));
readonly teamMembers$ = this.getState$.pipe(
map((state) => state.teamMembers)
map((state) => state.teamMembers),
);
readonly loading$ = this.getState$.pipe(map((data) => data.loading));
readonly errors$ = this.getState$.pipe(map((data) => data.errors));
@@ -53,10 +53,10 @@ export class TeamsService {
]).pipe(
map(([teamMembers, userEmail]) => {
const activeTeamMember = teamMembers.find(
(teamMember) => teamMember.email === userEmail
(teamMember) => teamMember.email === userEmail,
);
return activeTeamMember?.role;
})
}),
);
retrieveTeamsByOrg(orgSlug: string) {
@@ -69,7 +69,7 @@ export class TeamsService {
lastValueFrom(
this.teamsAPIService
.retrieve(orgSlug, teamSlug)
.pipe(tap((resp) => this.setSingleTeam(resp)))
.pipe(tap((resp) => this.setSingleTeam(resp))),
);
}
@@ -97,7 +97,7 @@ export class TeamsService {
catchError((error: HttpErrorResponse) => {
this.setUpdateTeamSlugError(error);
return EMPTY;
})
}),
);
}
@@ -107,7 +107,7 @@ export class TeamsService {
"/api/0/teams/{organization_slug}/{team_slug}/",
{
params: { path: { organization_slug: orgSlug, team_slug: teamSlug } },
}
},
);
this.setDeleteTeamLoading(false);
if (result.error) {
+9 -9
View File
@@ -54,7 +54,7 @@ export class AuthService {
effect(() => {
localStorage.setItem(
"isAuthenticated",
this.isAuthenticated().toString()
this.isAuthenticated().toString(),
);
});
}
@@ -77,7 +77,7 @@ export class AuthService {
}
this.initialized.set(true);
return throwError(() => new Error("Unable to check auth status"));
})
}),
);
}
@@ -85,7 +85,7 @@ export class AuthService {
return this.authenticationService
.login(email, password)
.pipe(
tap((resp) => this.isAuthenticated.set(resp.meta.is_authenticated))
tap((resp) => this.isAuthenticated.set(resp.meta.is_authenticated)),
);
}
@@ -102,7 +102,7 @@ export class AuthService {
return this.authenticationService
.mfaAuthenticate(code)
.pipe(
tap((resp) => this.isAuthenticated.set(resp.meta.is_authenticated))
tap((resp) => this.isAuthenticated.set(resp.meta.is_authenticated)),
);
}
@@ -110,13 +110,13 @@ export class AuthService {
return this.authenticationService.getWebAuthnCredentialRequest().pipe(
exhaustMap(async (resp) => {
return await get(
parseRequestOptionsFromJSON(resp.data.request_options)
parseRequestOptionsFromJSON(resp.data.request_options),
);
}),
exhaustMap((credential) => {
return this.authenticationService.perform2FAWebAuthn(credential);
}),
tap((resp) => this.isAuthenticated.set(resp.meta.is_authenticated))
tap((resp) => this.isAuthenticated.set(resp.meta.is_authenticated)),
);
}
@@ -124,14 +124,14 @@ export class AuthService {
return this.authenticationService
.signup(email, password)
.pipe(
tap((resp) => this.isAuthenticated.set(resp.meta.is_authenticated))
tap((resp) => this.isAuthenticated.set(resp.meta.is_authenticated)),
);
}
providerRedirect(
provider: string,
callbackUrl: string,
process: "login" | "connect"
process: "login" | "connect",
) {
this.authenticationService.providerRedirect(provider, callbackUrl, process);
}
@@ -145,7 +145,7 @@ export class AuthService {
return of(EMPTY);
}
return throwError(() => new Error("Unable to log out"));
})
}),
);
}
}
+3 -3
View File
@@ -9,7 +9,7 @@ import {
import { AuthService } from "./auth.service";
export const alreadyLoggedInGuard: CanActivateFn = (
next: ActivatedRouteSnapshot
next: ActivatedRouteSnapshot,
): boolean | UrlTree => {
const authService = inject(AuthService);
if (authService.loggedInGuard()) {
@@ -20,7 +20,7 @@ export const alreadyLoggedInGuard: CanActivateFn = (
export const loggedInGuard: CanActivateFn = (
next: ActivatedRouteSnapshot,
state: RouterStateSnapshot
state: RouterStateSnapshot,
): boolean | UrlTree => {
const authService = inject(AuthService);
if (authService.loggedInGuard()) {
@@ -29,6 +29,6 @@ export const loggedInGuard: CanActivateFn = (
return createUrlTreeFromSnapshot(
next,
["/", "login"],
state.url !== "/" ? { next: state.url } : {}
state.url !== "/" ? { next: state.url } : {},
);
};
@@ -31,7 +31,7 @@ export class CommentFormComponent implements OnInit {
readonly commentSubmitted = output<{
text: string;
id?: number;
}>();
}>();
readonly cancelUpdate = output<number>();
commentForm = new FormGroup({
+84 -59
View File
@@ -1,64 +1,89 @@
@if (commentsListLoading$ | async) {
<div class="comments-loading-wrapper">
<mat-progress-spinner [diameter]="33" color="primary" mode="indeterminate"></mat-progress-spinner>
</div>
<div class="comments-loading-wrapper">
<mat-progress-spinner
[diameter]="33"
color="primary"
mode="indeterminate"
></mat-progress-spinner>
</div>
} @else {
<div class="comment-create-wrapper">
@if (displayCommentCreation$ | async) {
<gt-comment-form [loading]="(createCommentLoading$ | async)!"
(commentSubmitted)="createOrUpdateComment($event)"></gt-comment-form>
}
</div>
<div class="comment-list">
@for (comment of comments$ | async; track comment; let i = $index) {
@if (i === 0) {
<mat-divider class="list-head-divider"></mat-divider>
}
<div class="comment">
@if (comment.updateMode) {
<gt-comment-form [comment]="comment" [loading]="comment.updateLoading"
(cancelUpdate)="cancelCommentUpdateMode($event)"
(commentSubmitted)="createOrUpdateComment($event)"></gt-comment-form>
} @else {
<div class="delete-loading-wrapper">
@if (comment.deleteLoading) {
<div class="delete-loading-overlay">
<div class="delete-spinner-wrapper">
<mat-progress-spinner [diameter]="33" color="primary" mode="indeterminate"></mat-progress-spinner>
</div>
</div>
}
<div class="comment-header">
<div class="comment-user-details">
<div>
<span class="name mat-subtitle-1">{{
comment.user?.email
}}</span>
</div>
<div>
<span class="date mat-caption">{{
comment.dateCreated | date: " LLL d, Y hh:mm a"
}}</span>
</div>
</div>
@if (user(); as user) {
@if (user.id === comment.user?.id) {
<div class="comment-edit-options">
<button mat-icon-button class="small-icon-button" (click)="triggerCommentUpdateMode(comment.id)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button class="small-icon-button" (click)="deleteComment(comment.id)">
<mat-icon>delete</mat-icon>
</button>
</div>
}
}
</div>
<div markdown [data]="comment.data.text" class="comment-markdown"></div>
</div>
<div class="comment-create-wrapper">
@if (displayCommentCreation$ | async) {
<gt-comment-form
[loading]="(createCommentLoading$ | async)!"
(commentSubmitted)="createOrUpdateComment($event)"
></gt-comment-form>
}
</div>
<div class="comment-list">
@for (comment of comments$ | async; track comment; let i = $index) {
@if (i === 0) {
<mat-divider class="list-head-divider"></mat-divider>
}
<div class="comment">
@if (comment.updateMode) {
<gt-comment-form
[comment]="comment"
[loading]="comment.updateLoading"
(cancelUpdate)="cancelCommentUpdateMode($event)"
(commentSubmitted)="createOrUpdateComment($event)"
></gt-comment-form>
} @else {
<div class="delete-loading-wrapper">
@if (comment.deleteLoading) {
<div class="delete-loading-overlay">
<div class="delete-spinner-wrapper">
<mat-progress-spinner
[diameter]="33"
color="primary"
mode="indeterminate"
></mat-progress-spinner>
</div>
</div>
}
<div class="comment-header">
<div class="comment-user-details">
<div>
<span class="name mat-subtitle-1">{{
comment.user?.email
}}</span>
</div>
<div>
<span class="date mat-caption">{{
comment.dateCreated | date: " LLL d, Y hh:mm a"
}}</span>
</div>
</div>
@if (user(); as user) {
@if (user.id === comment.user?.id) {
<div class="comment-edit-options">
<button
mat-icon-button
class="small-icon-button"
(click)="triggerCommentUpdateMode(comment.id)"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
class="small-icon-button"
(click)="deleteComment(comment.id)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
}
}
</div>
<div
markdown
[data]="comment.data.text"
class="comment-markdown"
></div>
</div>
}
</div>
<mat-divider></mat-divider>
}
</div>
<mat-divider></mat-divider>
}
</div>
}
@@ -41,7 +41,7 @@ export class CommentsComponent
commentUpdateLoading$ = this.commentsService.commentUpdateLoading$;
user = this.userService.user;
displayCommentCreation$ = this.comments$.pipe(
map((comments) => comments.length < 50)
map((comments) => comments.length < 50),
);
constructor(private commentsService: CommentsService) {
@@ -64,13 +64,13 @@ export class CommentsComponent
this.commentsService.updateComment(
+params["issue-id"],
data.id,
data.text
data.text,
);
} else {
this.commentsService.createComment(+params["issue-id"], data.text);
}
})
)
}),
),
);
}
@@ -88,8 +88,8 @@ export class CommentsComponent
this.route.params.pipe(
tap((params) => {
this.commentsService.deleteComment(+params["issue-id"], commentId);
})
)
}),
),
);
}
}
@@ -1,24 +1,31 @@
<section class="grid-wrapper">
@for (contexts of specialContexts$ | async; track contexts.title +
contexts.type) {
<div class="wrapper">
@if (contexts.icon || contexts.matIcon) { @if (contexts.icon) {
<img class="image" [src]="contexts.icon" [alt]="contexts.icon" />
} @else {
<mat-icon class="image">{{ contexts.matIcon }}</mat-icon>
} } @else {
<mat-icon class="image">help</mat-icon>
}
<div class="spacer">
<p class="mat-body-strong">{{ contexts.title }}</p>
@if ( !contexts.title.includes("Unknown") && contexts.key &&
contexts.subtitle ) {
<p class="mat-small mat-caption">
<span class="bold">{{ contexts.key }}:&nbsp;</span
><span class="spacer-small">{{ contexts.subtitle }}</span>
</p>
@for (
contexts of specialContexts$ | async;
track contexts.title + contexts.type
) {
<div class="wrapper">
@if (contexts.icon || contexts.matIcon) {
@if (contexts.icon) {
<img class="image" [src]="contexts.icon" [alt]="contexts.icon" />
} @else {
<mat-icon class="image">{{ contexts.matIcon }}</mat-icon>
}
} @else {
<mat-icon class="image">help</mat-icon>
}
<div class="spacer">
<p class="mat-body-strong">{{ contexts.title }}</p>
@if (
!contexts.title.includes("Unknown") &&
contexts.key &&
contexts.subtitle
) {
<p class="mat-small mat-caption">
<span class="bold">{{ contexts.key }}:&nbsp;</span
><span class="spacer-small">{{ contexts.subtitle }}</span>
</p>
}
</div>
</div>
</div>
}
</section>
@@ -1,4 +1,9 @@
import { Component, OnInit, ChangeDetectionStrategy, inject } from "@angular/core";
import {
Component,
OnInit,
ChangeDetectionStrategy,
inject,
} from "@angular/core";
import { IssueDetailService } from "../../issue-detail.service";
import { MatIconModule } from "@angular/material/icon";
import { AsyncPipe } from "@angular/common";
@@ -136,7 +136,7 @@ export const IconPaths = () => {
src="${generateIconPath(icon)}"
/>
</div>
`
`,
);
return {
@@ -6,7 +6,14 @@ import {
DatePipe,
KeyValuePipe,
} from "@angular/common";
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, ViewChild, inject } from "@angular/core";
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
ViewChild,
inject,
} from "@angular/core";
import { Json } from "src/app/interface-primitives";
import { IssueDetailService } from "../../issue-detail.service";
import { MatIconModule } from "@angular/material/icon";
@@ -1,4 +1,9 @@
import { Component, ChangeDetectionStrategy, input, inject } from "@angular/core";
import {
Component,
ChangeDetectionStrategy,
input,
inject,
} from "@angular/core";
import { IssueDetailService } from "../../issue-detail.service";
import { isStacktrace } from "src/app/issues/utils";
import { RawStacktraceComponent } from "./raw-stacktrace/raw-stacktrace.component";
@@ -1,5 +1,10 @@
import { KeyValuePipe } from "@angular/common";
import { Component, ChangeDetectionStrategy, Input, input } from "@angular/core";
import {
Component,
ChangeDetectionStrategy,
Input,
input,
} from "@angular/core";
import { MatDividerModule } from "@angular/material/divider";
import { JsonArrayOrObject, Json } from "src/app/interface-primitives";
import { FrameContextTuple } from "src/app/issues/interfaces";
@@ -1,4 +1,9 @@
import { Component, ChangeDetectionStrategy, Input, input } from "@angular/core";
import {
Component,
ChangeDetectionStrategy,
Input,
input,
} from "@angular/core";
import type { Frame } from "src/app/issues/interfaces";
import { MatIconModule } from "@angular/material/icon";
import { MatButtonModule } from "@angular/material/button";
@@ -1,4 +1,9 @@
import { Component, ChangeDetectionStrategy, input, inject } from "@angular/core";
import {
Component,
ChangeDetectionStrategy,
input,
inject,
} from "@angular/core";
import { isStacktrace } from "src/app/issues/utils";
import { IssueDetailService } from "../../../issue-detail.service";
import { AsyncPipe } from "@angular/common";
@@ -1,4 +1,9 @@
import { Component, OnInit, ChangeDetectionStrategy, inject } from "@angular/core";
import {
Component,
OnInit,
ChangeDetectionStrategy,
inject,
} from "@angular/core";
import { ActivatedRoute, RouterLink } from "@angular/router";
import { map, exhaustMap } from "rxjs/operators";
import { IssueDetailService } from "../issue-detail.service";
@@ -1,4 +1,9 @@
import { Component, ChangeDetectionStrategy, OnInit, inject } from "@angular/core";
import {
Component,
ChangeDetectionStrategy,
OnInit,
inject,
} from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { IssueDetailService } from "../issue-detail.service";
import { exhaustMap, map } from "rxjs/operators";
@@ -1,4 +1,9 @@
import { Component, OnInit, ChangeDetectionStrategy, inject } from "@angular/core";
import {
Component,
OnInit,
ChangeDetectionStrategy,
inject,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormControl, FormGroup } from "@angular/forms";
import { ActivatedRoute, RouterModule } from "@angular/router";
@@ -62,7 +67,7 @@ export class IssueDetailComponent implements OnInit {
default:
return [metadata.title!, null];
}
})
}),
);
issueSubtitle$ = this.issue$.pipe(
map((issue) => {
@@ -82,14 +87,14 @@ export class IssueDetailComponent implements OnInit {
default:
return issue.culprit;
}
})
}),
);
initialLoadComplete$ = this.issueService.issueInitialLoadComplete$;
form = new FormGroup({
assignee: new FormControl(""),
});
issueIdParam$ = this.route.paramMap.pipe(
map((params) => params.get("issue-id"))
map((params) => params.get("issue-id")),
);
organization$ = this.organizationsService.activeOrganization$;
participantCountPluralMapping: { [k: string]: string } = {
@@ -107,7 +112,7 @@ export class IssueDetailComponent implements OnInit {
return this.issueService.retrieveIssue(+issueId);
}
return EMPTY;
})
}),
)
.subscribe();
}
@@ -131,12 +136,12 @@ export class IssueDetailComponent implements OnInit {
if (
id &&
window.confirm(
`Are you sure you want delete this issue? You will permanently lose this issue and all associated events.`
`Are you sure you want delete this issue? You will permanently lose this issue and all associated events.`,
)
) {
this.issueService.deleteIssue(id.toString());
}
})
}),
)
.subscribe();
}
@@ -51,11 +51,11 @@ describe("IssueDetailService", () => {
const testApiData = sampleApiIssueDetail;
service.retrieveIssue(testApiData.id).toPromise();
const req = httpTestingController.expectOne(
`/api/0/issues/${testApiData.id}/`
`/api/0/issues/${testApiData.id}/`,
);
req.flush(testApiData);
service.issue$.subscribe((issue) =>
expect(issue).toEqual(sampleIssueDetail)
expect(issue).toEqual(sampleIssueDetail),
);
});
@@ -270,7 +270,7 @@ describe("IssueDetailService", () => {
service.setEvent(testData);
service.breadcrumbs$.subscribe((breadcrumb: any) => {
expect(breadcrumb.values[0].timestamp).toEqual(
"2020-12-18T18:09:47.841276Z"
"2020-12-18T18:09:47.841276Z",
);
});
});
@@ -68,24 +68,24 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
readonly issue$ = this.getState$.pipe(map((state) => state.issue));
readonly issueInitialLoadComplete$ = this.getState$.pipe(
map((state) => state.issueInitialLoadComplete)
map((state) => state.issueInitialLoadComplete),
);
readonly event$ = this.getState$.pipe(map((state) => state.event));
readonly tags$ = this.getState$.pipe(
map((state) => state.tags && this.tagsWithPercent(state.tags))
map((state) => state.tags && this.tagsWithPercent(state.tags)),
);
readonly eventInitialLoadComplete$ = this.getState$.pipe(
map((state) => state.eventInitialLoadComplete)
map((state) => state.eventInitialLoadComplete),
);
readonly isReversed$ = this.getState$.pipe(map((state) => state.isReversed));
readonly showShowMore$ = this.getState$.pipe(
map((state) => state.showShowMore)
map((state) => state.showShowMore),
);
readonly hasNextEvent$ = this.event$.pipe(
map((event) => event && event.nextEventID !== null)
map((event) => event && event.nextEventID !== null),
);
readonly hasPreviousEvent$ = this.event$.pipe(
map((event) => event && event.previousEventID !== null)
map((event) => event && event.previousEventID !== null),
);
readonly nextEventUrl$ = combineLatest([
this.organization.activeOrganizationSlug$,
@@ -97,7 +97,7 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
return this.eventUrl(orgSlug, issue, event.nextEventID);
}
return null;
})
}),
);
readonly previousEventUrl$ = combineLatest([
this.organization.activeOrganizationSlug$,
@@ -109,33 +109,33 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
return this.eventUrl(orgSlug, issue, event.previousEventID);
}
return null;
})
}),
);
readonly eventEntryException$ = combineLatest([
this.event$,
this.isReversed$,
]).pipe(
map(([event, isReversed]) =>
event ? this.reverseFrames(event, isReversed) : undefined
)
event ? this.reverseFrames(event, isReversed) : undefined,
),
);
readonly rawStacktraceValues$ = this.event$.pipe(
map((event) => (event ? this.rawStacktraceValues(event) : undefined))
map((event) => (event ? this.rawStacktraceValues(event) : undefined)),
);
readonly eventEntryRequest$ = this.event$.pipe(
map((event) => (event ? this.entryRequestData(event) : undefined))
map((event) => (event ? this.entryRequestData(event) : undefined)),
);
readonly eventEntryCSP$ = this.event$.pipe(
map((event) => (event ? this.eventEntryCSP(event) : undefined))
map((event) => (event ? this.eventEntryCSP(event) : undefined)),
);
readonly eventEntryMessage$ = this.event$.pipe(
map((event) => (event ? this.eventEntryMessage(event) : undefined))
map((event) => (event ? this.eventEntryMessage(event) : undefined)),
);
readonly specialContexts$ = this.event$.pipe(
map((event) => (event ? this.specialContexts(event) : undefined))
map((event) => (event ? this.specialContexts(event) : undefined)),
);
readonly breadcrumbs$ = this.event$.pipe(
map((event) => (event ? this.eventEntryBreadcrumbs(event) : undefined))
map((event) => (event ? this.eventEntryBreadcrumbs(event) : undefined)),
);
constructor() {
@@ -151,7 +151,7 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
this.clearIssue();
}
return EMPTY;
})
}),
);
}
@@ -189,7 +189,7 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
return this.issuesAPIService.retrieveTags(id.toString(), query).pipe(
tap((resp) => {
this.setTags(resp);
})
}),
);
}
@@ -216,11 +216,11 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
lastValueFrom(
this.issuesAPIService
.update(status, slug, issue.id)
.pipe(tap((resp) => this.setIssueStatus(resp.status)))
.pipe(tap((resp) => this.setIssueStatus(resp.status))),
);
}
})
)
}),
),
);
}
}
@@ -240,10 +240,10 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
}),
catchError((_) => {
this.snackBar.open(
`There was an error deleting this issue. Please try again.`
`There was an error deleting this issue. Please try again.`,
);
return EMPTY;
})
}),
)
.subscribe();
}
@@ -266,7 +266,7 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
});
const sumOfPercents = tagWithPercent.reduce(
(accum, item) => accum + item.percent,
0
0,
);
if (sumOfPercents < 100) {
return {
@@ -302,7 +302,7 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
this.clearEvent();
}
return EMPTY;
})
}),
);
}
@@ -316,7 +316,7 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
this.clearEvent();
}
return EMPTY;
})
}),
);
}
@@ -398,7 +398,7 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
/* Return the breadcrumbs entry type for an event */
private eventEntryBreadcrumbs(
event: EventDetail
event: EventDetail,
): BreadcrumbValueData | undefined {
const breadcrumbs = this.getBreadcrumbs(event);
if (breadcrumbs) {
@@ -428,7 +428,7 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
/* Reverse frame array, nested in the event object */
private reverseFrames(
event: EventDetail,
isReversed: boolean
isReversed: boolean,
): ExceptionValueData | undefined {
const eventException = this.getExceptionEntryData(event);
@@ -482,7 +482,7 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
checkContextName(
contextsObject: { [key: string]: Json },
defaultUnknown: string
defaultUnknown: string,
) {
if (
contextsObject.name !== "Other" &&
@@ -547,13 +547,13 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
subtitle: contextsObject.version
? (contextsObject.version as string)
: contextsObject.kernel_version
? (contextsObject.kernel_version as string)
: "Unknown",
? (contextsObject.kernel_version as string)
: "Unknown",
key: contextsObject.version
? "Version"
: contextsObject.kernel_version
? "Kernel"
: "Version",
? "Kernel"
: "Version",
});
}
if (key === "device") {
@@ -569,13 +569,13 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
subtitle: contextsObject.arch
? (contextsObject.arch as string)
: contextsObject.model_id
? (contextsObject.model_id as string)
: null,
? (contextsObject.model_id as string)
: null,
key: contextsObject.arch
? "Arch"
: contextsObject.model_id
? "Model"
: null,
? "Model"
: null,
});
}
if (key === "gpu") {
@@ -671,7 +671,7 @@ export class IssueDetailService extends StatefulService<IssueDetailState> {
private eventUrl(
orgSlug: string | null,
issue: IssueDetail | null,
eventID: string
eventID: string,
) {
if (orgSlug && issue) {
return `/${orgSlug}/issues/${issue.id}/events/${eventID}`;
@@ -1,118 +1,157 @@
<div class="empty-states">
<!-- Don't show any of this until loading state is resolved -->
@if (displayZeroStates$ | async) {
<!-- If there are errors, only show the error -->
@if ((errors$ | async)?.length) {
@if ((errors$ | async)?.length === 1) {
<h3 class="mat-headline-4" i18n>There was an error processing your request</h3>
} @else {
<h3 class="mat-headline-4" i18n>There were errors while processing your request</h3>
}
<ul>
@for (error of errors$ | async; track error) {
<li>{{ error }}</li>
}
</ul>
} @else {
<!-- If there are zero projects -->
@if (orgHasAProject() === false) {
<h3 class="mat-headline-4" i18n>This organization has no projects.</h3>
<p class="mat-body-1" i18n>To add a project to this organization, press the
"Add&nbsp;Project"&nbsp;button.</p>
} @else {
<!-- You're an admin, but you're not on the team -->
@if (projectsWhereAdminIsNotOnTheTeam$ | async; as notOnTeam) {
@if (notOnTeam.length > 0 && notOnTeam.length < 2) { <p class="not-on-team">
@if (singleProjectApplied$ | async) {
<ng-container i18n>You can't see issues for this project because you're not on
the team.</ng-container>
} @else {
<ng-container i18n>Out of the projects you have selected, you are not a part of
that project's team for at least one of them.</ng-container>
}
</p>
}
@if (notOnTeam.length > 2) {}
}
<!-- If there is one project applied -->
@if (singleProjectApplied$ | async) {
<!-- If no event has been sent -->
@if (showOnboarding$ | async) {
<div class="install-wrapper">
@if (activeProjectPlatform$ | async; as activeProjectPlatform) {
<div class="install-instructions mat-body-1">
@if (
activeProjectPlatform !== "other" &&
activeProjectPlatform !== "" &&
(activeProjectPlatformName$ | async)
) {
<h2 class="mat-headline-4" i18n>Connect your
{{ activeProjectPlatformName$ | async }} project to
GlitchTip</h2>
<markdown src="/static/sdk-docs/{{ activeProjectPlatform }}.md"></markdown>
} @else {
<h2 class="mat-headline-4" i18n>Connect your project to GlitchTip</h2>
<markdown src="/static/sdk-docs/all-sdks.md"></markdown>
<markdown src="/static/sdk-docs/other.md"></markdown>
}
</div>
<!-- If there are errors, only show the error -->
@if ((errors$ | async)?.length) {
@if ((errors$ | async)?.length === 1) {
<h3 class="mat-headline-4" i18n
>There was an error processing your request</h3
>
} @else {
<div class="install-instructions mat-body-1">
<h2 class="mat-headline-4" i18n>Connect your project to GlitchTip</h2>
<markdown src="/static/sdk-docs/all-sdks.md"></markdown>
<p class="mat-body-1" i18n>If you go to this project's
<a [routerLink]="[
<h3 class="mat-headline-4" i18n
>There were errors while processing your request</h3
>
}
<ul>
@for (error of errors$ | async; track error) {
<li>{{ error }}</li>
}
</ul>
} @else {
<!-- If there are zero projects -->
@if (orgHasAProject() === false) {
<h3 class="mat-headline-4" i18n>This organization has no projects.</h3>
<p class="mat-body-1" i18n
>To add a project to this organization, press the
"Add&nbsp;Project"&nbsp;button.</p
>
} @else {
<!-- You're an admin, but you're not on the team -->
@if (projectsWhereAdminIsNotOnTheTeam$ | async; as notOnTeam) {
@if (notOnTeam.length > 0 && notOnTeam.length < 2) {
<p class="not-on-team">
@if (singleProjectApplied$ | async) {
<ng-container i18n
>You can't see issues for this project because you're not on
the team.</ng-container
>
} @else {
<ng-container i18n
>Out of the projects you have selected, you are not a part of
that project's team for at least one of them.</ng-container
>
}
</p>
}
@if (notOnTeam.length > 2) {}
}
<!-- If there is one project applied -->
@if (singleProjectApplied$ | async) {
<!-- If no event has been sent -->
@if (showOnboarding$ | async) {
<div class="install-wrapper">
@if (activeProjectPlatform$ | async; as activeProjectPlatform) {
<div class="install-instructions mat-body-1">
@if (
activeProjectPlatform !== "other" &&
activeProjectPlatform !== "" &&
(activeProjectPlatformName$ | async)
) {
<h2 class="mat-headline-4" i18n
>Connect your
{{ activeProjectPlatformName$ | async }} project to
GlitchTip</h2
>
<markdown
src="/static/sdk-docs/{{ activeProjectPlatform }}.md"
></markdown>
} @else {
<h2 class="mat-headline-4" i18n
>Connect your project to GlitchTip</h2
>
<markdown src="/static/sdk-docs/all-sdks.md"></markdown>
<markdown src="/static/sdk-docs/other.md"></markdown>
}
</div>
} @else {
<div class="install-instructions mat-body-1">
<h2 class="mat-headline-4" i18n
>Connect your project to GlitchTip</h2
>
<markdown src="/static/sdk-docs/all-sdks.md"></markdown>
<p class="mat-body-1" i18n
>If you go to this project's
<a
[routerLink]="[
'/',
activeOrganizationSlug$ | async,
'settings',
'projects',
activeProjectSlug$ | async,
]">
settings page</a>, you can pick a platform. If you do, you'll get more
specific instructions about how to get started.
</p>
</div>
}
@if (firstProjectKey$ | async; as firstProjectKey) {
<div class="dsn">
<h2 class="mat-headline-4">DSN</h2>
<p class="mat-body-1" i18n>Most instructions will have a spot that says
"YOUR-GLITCHTIP-DSN-HERE". Copy the DSN and paste it into
that spot.</p>
<div class="dsn-area">
<gt-copy-input [value]="firstProjectKey.dsn.public" placeholder="DSN"></gt-copy-input>
<gt-copy-input [value]="firstProjectKey.dsn.security" placeholder="Security Endpoint"></gt-copy-input>
</div>
@if (
activeProjectPlatform$ | async;
as activeProjectPlatform
) {
@if (activeProjectPlatform !== "other") {
<h2 class="mat-body-strong" i18n>What's this all about?</h2>
<div class="mat-body-1">
<markdown src="/static/sdk-docs/all-sdks.md"></markdown>
</div>
<p class="mat-body-1" i18n>This page is showing instructions on how to connect
GlitchTip to your project based on the platform you
specified.</p>
]"
>
settings page</a
>, you can pick a platform. If you do, you'll get more
specific instructions about how to get started.
</p>
</div>
}
@if (firstProjectKey$ | async; as firstProjectKey) {
<div class="dsn">
<h2 class="mat-headline-4">DSN</h2>
<p class="mat-body-1" i18n
>Most instructions will have a spot that says
"YOUR-GLITCHTIP-DSN-HERE". Copy the DSN and paste it into
that spot.</p
>
<div class="dsn-area">
<gt-copy-input
[value]="firstProjectKey.dsn.public"
placeholder="DSN"
></gt-copy-input>
<gt-copy-input
[value]="firstProjectKey.dsn.security"
placeholder="Security Endpoint"
></gt-copy-input>
</div>
@if (
activeProjectPlatform$ | async;
as activeProjectPlatform
) {
@if (activeProjectPlatform !== "other") {
<h2 class="mat-body-strong" i18n
>What's this all about?</h2
>
<div class="mat-body-1">
<markdown src="/static/sdk-docs/all-sdks.md"></markdown>
</div>
<p class="mat-body-1" i18n
>This page is showing instructions on how to connect
GlitchTip to your project based on the platform you
specified.</p
>
}
}
</div>
}
</div>
} @else {
<!-- No issues match the filters, in general -->
<h3 class="mat-headline-4" i18n>No events match your filters.</h3>
<p class="mat-body-1" i18n
>Try changing your search parameters to get something to show
here.</p
>
}
} @else {
<h3 i18n>No events match your filters.</h3>
<p class="mat-body-1" i18n
>You have multiple projects selected. Select a single project to
learn more about why there's nothing here.</p
>
}
}
</div>
}
</div>
} @else {
<!-- No issues match the filters, in general -->
<h3 class="mat-headline-4" i18n>No events match your filters.</h3>
<p class="mat-body-1" i18n>Try changing your search parameters to get something to show
here.</p>
}
} @else {
<h3 i18n>No events match your filters.</h3>
<p class="mat-body-1" i18n>You have multiple projects selected. Select a single project to
learn more about why there's nothing here.</p>
}
}
}
<!-- If there are no errors, show an empty state option -->
}
}
</div>
@@ -1,4 +1,10 @@
import { Component, OnInit, ChangeDetectionStrategy, computed, inject } from "@angular/core";
import {
Component,
OnInit,
ChangeDetectionStrategy,
computed,
inject,
} from "@angular/core";
import { ActivatedRoute, RouterLink } from "@angular/router";
import { combineLatest } from "rxjs";
import { distinctUntilChanged, filter, map, switchMap } from "rxjs/operators";
@@ -40,13 +46,13 @@ export class IssueZeroStatesComponent implements OnInit {
this.loading$,
this.initialLoadComplete$,
]).pipe(
map(([loading, initialLoadComplete]) => !loading && initialLoadComplete)
map(([loading, initialLoadComplete]) => !loading && initialLoadComplete),
);
orgHasAProject = computed(
() => this.organizationsService.projectsCount() > 0
() => this.organizationsService.projectsCount() > 0,
);
projectsFromParams$ = this.activatedRoute.queryParams.pipe(
map((params) => normalizeProjectParams(params.project))
map((params) => normalizeProjectParams(params.project)),
);
activeOrganizationProjects$ =
this.organizationsService.activeOrganizationProjects$;
@@ -64,7 +70,7 @@ export class IssueZeroStatesComponent implements OnInit {
}
return null;
}),
distinctUntilChanged()
distinctUntilChanged(),
);
activeProject$ = combineLatest([
this.projectsService.projects$,
@@ -73,25 +79,27 @@ export class IssueZeroStatesComponent implements OnInit {
map(([projects, activeProjectID]) => {
if (projects && activeProjectID) {
const activeProject = projects.find(
(project) => project.id === activeProjectID
(project) => project.id === activeProjectID,
);
return activeProject ? activeProject : null;
}
return null;
}),
distinctUntilChanged()
distinctUntilChanged(),
);
showOnboarding$ = this.activeProject$.pipe(
map((project) => !project?.firstEvent)
map((project) => !project?.firstEvent),
);
activeProjectPlatform$ = this.activeProject$.pipe(
map((project) => project?.platform)
map((project) => project?.platform),
);
activeProjectSlug$ = this.activeProject$.pipe(
map((project) => project?.slug)
map((project) => project?.slug),
);
activeProjectPlatformName$ = this.activeProjectPlatform$.pipe(
map((id) => flattenedPlatforms.find((platform) => platform.id === id)?.name)
map(
(id) => flattenedPlatforms.find((platform) => platform.id === id)?.name,
),
);
firstProjectKey$ = combineLatest([
@@ -100,14 +108,14 @@ export class IssueZeroStatesComponent implements OnInit {
]).pipe(
filter(
([organizationSlug, activeProject]) =>
!!organizationSlug && !!activeProject
!!organizationSlug && !!activeProject,
),
distinctUntilChanged((x, y) => JSON.stringify(x) === JSON.stringify(y)),
switchMap(([organizationSlug, activeProject]) =>
this.projectKeysAPIService
.list(organizationSlug!, activeProject!.slug)
.pipe(map((keys) => keys[0]))
)
.pipe(map((keys) => keys[0])),
),
);
errors$ = this.issuesService.errors$;
@@ -121,7 +129,7 @@ export class IssueZeroStatesComponent implements OnInit {
return projects.length;
}
return 0;
})
}),
);
/**
@@ -134,8 +142,8 @@ export class IssueZeroStatesComponent implements OnInit {
]).pipe(
map(
([appliedProjectCount, activeOrganizationProjects]) =>
appliedProjectCount === 1 || activeOrganizationProjects?.length === 1
)
appliedProjectCount === 1 || activeOrganizationProjects?.length === 1,
),
);
projectsWhereAdminIsNotOnTheTeam$ = combineLatest([
@@ -149,16 +157,16 @@ export class IssueZeroStatesComponent implements OnInit {
const projectsMatchedFromParams: OrganizationProject[] = [];
projectsFromParams.forEach((projectId) => {
const matchedProject = activeOrgProjects?.find(
(project) => (project.id as any) === projectId
(project) => (project.id as any) === projectId,
);
if (matchedProject) {
projectsMatchedFromParams.push(matchedProject as any);
}
});
return projectsMatchedFromParams.filter(
(project) => project.isMember === false
(project) => project.isMember === false,
);
})
}),
);
userNotInSomeTeams$ = combineLatest([
@@ -167,8 +175,8 @@ export class IssueZeroStatesComponent implements OnInit {
]).pipe(
map(
([projectsWhereAdminIsNotOnTheTeam, appliedProjectCount]) =>
projectsWhereAdminIsNotOnTheTeam.length && appliedProjectCount > 1
)
projectsWhereAdminIsNotOnTheTeam.length && appliedProjectCount > 1,
),
);
ngOnInit() {
@@ -68,7 +68,7 @@ export class IssuesPageComponent implements OnInit, OnDestroy {
displayedColumns: string[] = ["select", "title", "events"];
paginator$ = this.service.paginator$;
loading$ = this.service.getState$.pipe(
map((state) => state.pagination.loading)
map((state) => state.pagination.loading),
);
searchHits$ = this.service.searchHits$;
searchDirectHit$ = this.service.searchDirectHit$;
@@ -129,11 +129,11 @@ export class IssuesPageComponent implements OnInit, OnDestroy {
sort: params.get("sort"),
environment: params.get("environment"),
};
})
}),
);
projectsFromParams$ = this.route.queryParams.pipe(
map((params) => normalizeProjectParams(params.project))
map((params) => normalizeProjectParams(params.project)),
);
/**
@@ -146,7 +146,7 @@ export class IssuesPageComponent implements OnInit, OnDestroy {
return projects.length;
}
return 0;
})
}),
);
showBulkSelectProject$ = combineLatest([
@@ -161,32 +161,32 @@ export class IssuesPageComponent implements OnInit, OnDestroy {
} else {
return false;
}
})
}),
);
organizationEnvironments$ = combineLatest([
this.appliedProjectCount$,
toObservable(
this.organizationDetailService.organizationEnvironmentsProcessed
this.organizationDetailService.organizationEnvironmentsProcessed,
),
this.projectEnvironmentsService.visibleEnvironments$,
]).pipe(
map(([appliedProjectCount, orgEnvironments, projectEnvironments]) =>
appliedProjectCount !== 1 ? orgEnvironments : projectEnvironments
)
appliedProjectCount !== 1 ? orgEnvironments : projectEnvironments,
),
);
constructor() {
this.issues$.subscribe((resp) =>
resp.length === 0
? this.sortForm.controls.sort.disable()
: this.sortForm.controls.sort.enable()
: this.sortForm.controls.sort.enable(),
);
this.organizationEnvironments$.subscribe((environments) =>
environments.length === 0
? this.environmentForm.controls.environment.disable()
: this.environmentForm.controls.environment.enable()
: this.environmentForm.controls.environment.enable(),
);
this.currentQueryParams$
@@ -201,12 +201,12 @@ export class IssuesPageComponent implements OnInit, OnDestroy {
params.start,
params.end,
params.sort,
params.environment
params.environment,
);
}
return EMPTY;
}),
takeUntilDestroyed()
takeUntilDestroyed(),
)
.subscribe();
@@ -215,12 +215,12 @@ export class IssuesPageComponent implements OnInit, OnDestroy {
switchMap((orgSlug) => {
if (orgSlug) {
return this.organizationDetailService.getOrganizationEnvironments(
orgSlug
orgSlug,
);
}
return EMPTY;
}),
takeUntilDestroyed()
takeUntilDestroyed(),
)
.subscribe();
@@ -229,24 +229,24 @@ export class IssuesPageComponent implements OnInit, OnDestroy {
this.route.queryParamMap.pipe(
map((params) => params.getAll("project")[0]),
filter((project) => !!project),
distinctUntilChanged()
distinctUntilChanged(),
),
this.organizationsService.activeOrganizationProjects$,
])
.pipe(
switchMap(([orgSlug, projectId, orgProjects]) => {
const projectSlug = orgProjects?.find(
(orgProject) => orgProject.id.toString() === projectId
(orgProject) => orgProject.id.toString() === projectId,
)?.slug;
if (orgSlug && projectSlug) {
return this.projectEnvironmentsService.retrieveEnvironmentsWithProperties(
orgSlug,
projectSlug
projectSlug,
);
}
return EMPTY;
}),
takeUntilDestroyed()
takeUntilDestroyed(),
)
.subscribe();
@@ -272,7 +272,7 @@ export class IssuesPageComponent implements OnInit, OnDestroy {
queryParamsHandling: "merge",
});
}
})
}),
)
.subscribe();
@@ -284,7 +284,7 @@ export class IssuesPageComponent implements OnInit, OnDestroy {
queryParams: { query: null },
queryParamsHandling: "merge",
replaceUrl: true, // so the browser back button works
}
},
);
});
}
@@ -365,14 +365,14 @@ export class IssuesPageComponent implements OnInit, OnDestroy {
params.query,
params.start,
params.end,
params.environment
params.environment,
);
} else {
this.service.updateStatusByIssueId(params.orgSlug, status);
}
}
})
)
}),
),
);
}
@@ -1,4 +1,9 @@
import { Component, ChangeDetectionStrategy, OnDestroy, inject } from "@angular/core";
import {
Component,
ChangeDetectionStrategy,
OnDestroy,
inject,
} from "@angular/core";
import { ActivatedRoute, RouterLink } from "@angular/router";
import { map } from "rxjs/operators";
import { combineLatest } from "rxjs";
@@ -25,10 +25,12 @@ import { MatSelectChange, MatSelectModule } from "@angular/material/select";
export class DataFilterBarComponent {
@Input() dateForm?: FormGroup;
@Input() sortForm?: FormGroup;
readonly sorts = input<{
param: string;
display: string;
}[]>();
readonly sorts = input<
{
param: string;
display: string;
}[]
>();
@Input() environmentForm?: FormGroup;
@Input() searchForm?: FormGroup;
readonly organizationEnvironments = input<string[]>([]);
@@ -1,61 +1,91 @@
@if (projects$ | async; as projects) {
<section [class.no-projects]="(projects$ | async)?.length === 0">
<div [class.no-projects__picker]="!projects.length" id="project-picker">
<mat-accordion displayMode="flat" [hideToggle]="projects.length < 2">
<mat-expansion-panel #expansionPanel class="mat-elevation-z1" (afterExpand)="focusPanel()"
[disabled]="projects.length < 2">
<mat-expansion-panel-header [class.disabled-looking]="projects.length === 0">
<mat-panel-title class="mat-body-1">
{{ selectedProjectsString$ | async }}
</mat-panel-title>
</mat-expansion-panel-header>
<mat-form-field appearance="outline">
<mat-label>Filter projects</mat-label>
<input #filterInput type="text" aria-label="Filter projects" matInput [formControl]="filterProjectInput" />
</mat-form-field>
<ul>
@for (project of filteredProjects$ | async; track project) {
<li class="list-item">
<button id="picker-button-{{ project.id }}" class="picker-button mat-body-1"
[class.disabled]="!project.isMember" (click)="selectProjectAndClose(forceString(project.id))">
{{ project.name }}
</button>
<mat-checkbox (change)="toggleProject(forceString(project.id))"
[checked]="isSelected(forceString(project.id))" [disabled]="!project.isMember">
</mat-checkbox>
</li>
}
</ul>
<div class="button-group">
@if (someProjectsAreSelected$ | async) {
<button mat-stroked-button (click)="navigate(null); expansionPanel.close()">
View All
</button>
}
<button mat-flat-button (click)="closePanel()" color="primary">
@if ((selectedAndAppliedIdsAreEqual$ | async) === false) {
Apply &
<section [class.no-projects]="(projects$ | async)?.length === 0">
<div [class.no-projects__picker]="!projects.length" id="project-picker">
<mat-accordion displayMode="flat" [hideToggle]="projects.length < 2">
<mat-expansion-panel
#expansionPanel
class="mat-elevation-z1"
(afterExpand)="focusPanel()"
[disabled]="projects.length < 2"
>
<mat-expansion-panel-header
[class.disabled-looking]="projects.length === 0"
>
<mat-panel-title class="mat-body-1">
{{ selectedProjectsString$ | async }}
</mat-panel-title>
</mat-expansion-panel-header>
<mat-form-field appearance="outline">
<mat-label>Filter projects</mat-label>
<input
#filterInput
type="text"
aria-label="Filter projects"
matInput
[formControl]="filterProjectInput"
/>
</mat-form-field>
<ul>
@for (project of filteredProjects$ | async; track project) {
<li class="list-item">
<button
id="picker-button-{{ project.id }}"
class="picker-button mat-body-1"
[class.disabled]="!project.isMember"
(click)="selectProjectAndClose(forceString(project.id))"
>
{{ project.name }}
</button>
<mat-checkbox
(change)="toggleProject(forceString(project.id))"
[checked]="isSelected(forceString(project.id))"
[disabled]="!project.isMember"
>
</mat-checkbox>
</li>
}
Close
</ul>
<div class="button-group">
@if (someProjectsAreSelected$ | async) {
<button
mat-stroked-button
(click)="navigate(null); expansionPanel.close()"
>
View All
</button>
}
<button mat-flat-button (click)="closePanel()" color="primary">
@if ((selectedAndAppliedIdsAreEqual$ | async) === false) {
Apply &
}
Close
</button>
</div>
</mat-expansion-panel>
@if ((appliedProjectIds$ | async)?.length) {
<button mat-button class="reset-button" (click)="resetProjects()">
<mat-icon>close</mat-icon>
</button>
</div>
</mat-expansion-panel>
@if ((appliedProjectIds$ | async)?.length) {
<button mat-button class="reset-button" (click)="resetProjects()">
<mat-icon>close</mat-icon>
</button>
}
</mat-accordion>
</div>
@if (orgSlug$ | async; as orgSlug) {
<div class="nav-action-group">
<a mat-stroked-button color="primary" class="nav-action"
[routerLink]="['/', orgSlug, 'settings', 'projects', 'new']">
<mat-icon>add</mat-icon>
<ng-container i18n>Add Project</ng-container>
</a>
@if (projects.length > 0) {
<a mat-stroked-button color="primary" class="nav-action" [routerLink]="
}
</mat-accordion>
</div>
@if (orgSlug$ | async; as orgSlug) {
<div class="nav-action-group">
<a
mat-stroked-button
color="primary"
class="nav-action"
[routerLink]="['/', orgSlug, 'settings', 'projects', 'new']"
>
<mat-icon>add</mat-icon>
<ng-container i18n>Add Project</ng-container>
</a>
@if (projects.length > 0) {
<a
mat-stroked-button
color="primary"
class="nav-action"
[routerLink]="
(singleProjectSlug$ | async)
? [
'/',
@@ -65,12 +95,13 @@
singleProjectSlug$ | async,
]
: ['/', orgSlug, 'settings', 'projects']
">
<mat-icon>settings</mat-icon>
Project Settings
</a>
"
>
<mat-icon>settings</mat-icon>
Project Settings
</a>
}
</div>
}
</div>
}
</section>
</section>
}
@@ -1,4 +1,12 @@
import { Component, ChangeDetectionStrategy, ViewChild, OnInit, HostListener, ElementRef, inject } from "@angular/core";
import {
Component,
ChangeDetectionStrategy,
ViewChild,
OnInit,
HostListener,
ElementRef,
inject,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { MatButtonModule } from "@angular/material/button";
import { MatDatepickerModule } from "@angular/material/datepicker";
@@ -57,7 +65,7 @@ export class ProjectFilterBarComponent implements OnInit {
const normalizedParams = normalizeProjectParams(params.project);
this.selectedProjectIds.next(normalizedParams);
return normalizedParams;
})
}),
);
appliedProjectIds?: number[];
@@ -80,11 +88,11 @@ export class ProjectFilterBarComponent implements OnInit {
return ids
.map(
(id) =>
projects?.find((project) => id.toString() === project.id)?.name
projects?.find((project) => id.toString() === project.id)?.name,
)
.join(", ");
}
})
}),
);
selectedAndAppliedIdsAreEqual$ = combineLatest([
@@ -93,8 +101,8 @@ export class ProjectFilterBarComponent implements OnInit {
]).pipe(
map(
([selected, applied]) =>
selected.sort().join(",") === applied.sort().join(",")
)
selected.sort().join(",") === applied.sort().join(","),
),
);
//** Yikes delete this please */
@@ -113,14 +121,14 @@ export class ProjectFilterBarComponent implements OnInit {
map(([projects, value]) =>
projects
? projects.filter((project) =>
project.name.toLowerCase().includes(value.toLowerCase())
project.name.toLowerCase().includes(value.toLowerCase()),
)
: null
)
: null,
),
);
someProjectsAreSelected$ = this.appliedProjectIds$.pipe(
map((ids) => ids.length !== 0)
map((ids) => ids.length !== 0),
);
singleProjectSlug$ = combineLatest([
@@ -130,7 +138,7 @@ export class ProjectFilterBarComponent implements OnInit {
map(([projects, ids]) => {
if (ids.length === 1 && projects) {
const matchedProject = projects.find(
(project) => project.id === ids[0].toString()
(project) => project.id === ids[0].toString(),
);
if (matchedProject) {
return matchedProject.slug;
@@ -138,7 +146,7 @@ export class ProjectFilterBarComponent implements OnInit {
return false;
}
return false;
})
}),
);
@ViewChild("expansionPanel", { static: false })
@@ -148,7 +156,7 @@ export class ProjectFilterBarComponent implements OnInit {
filterInput?: ElementRef<HTMLInputElement>;
@HostListener("document:keydown", ["$event"]) onKeydownHandler(
event: KeyboardEvent
event: KeyboardEvent,
) {
if (this.expansionPanel?.expanded) {
if (event.key === "Escape") {
@@ -172,14 +180,14 @@ export class ProjectFilterBarComponent implements OnInit {
moveDown() {
const projectButtons = Array.from(
document.querySelectorAll(".picker-button")
document.querySelectorAll(".picker-button"),
) as HTMLElement[];
// If the text box is focused, go to the first item
if (this.filterInput?.nativeElement.id === document.activeElement?.id) {
projectButtons[0]?.focus();
} else {
const indexOfActive = projectButtons.findIndex(
(button) => button.id === document.activeElement?.id
(button) => button.id === document.activeElement?.id,
);
if (indexOfActive <= projectButtons.length - 2) {
// If we're in the list items, go to the next list item
@@ -193,10 +201,10 @@ export class ProjectFilterBarComponent implements OnInit {
moveUp() {
const projectButtons = Array.from(
document.querySelectorAll(".picker-button")
document.querySelectorAll(".picker-button"),
) as HTMLElement[];
const indexOfActive = projectButtons.findIndex(
(button) => button.id === document.activeElement?.id
(button) => button.id === document.activeElement?.id,
);
if (indexOfActive > 0) {
// If we're in the list items, go to the previous list item
@@ -250,7 +258,7 @@ export class ProjectFilterBarComponent implements OnInit {
closePanel() {
this.navigate(
this.selectedProjectIds.getValue().map((id) => id.toString())
this.selectedProjectIds.getValue().map((id) => id.toString()),
);
this.expansionPanel?.close();
}
@@ -264,7 +272,7 @@ export class ProjectFilterBarComponent implements OnInit {
.pipe(
map((params) => params["org-slug"]),
filter((orgSlug: string) => orgSlug !== undefined),
tap(() => this.expansionPanel?.close())
tap(() => this.expansionPanel?.close()),
)
.subscribe();
}
-1
View File
@@ -12,7 +12,6 @@ import { UserService } from "./api/user/user.service";
export class LoggedInComponent implements OnInit {
private userService = inject(UserService);
ngOnInit() {
// this.organizationService.retrieveOrganizations().subscribe();
this.userService.getUserDetails();
@@ -62,7 +62,7 @@ export class LoginTotpComponent
const loginService = inject(LoginService);
toObservable(loginService.fieldErrors).subscribe((fieldErrors) =>
mapFormErrors(fieldErrors, this.form)
mapFormErrors(fieldErrors, this.form),
);
super(loginService);
this.hasWebAuthn = loginService.hasWebAuthn;
@@ -92,7 +92,7 @@ export class LoginTotpComponent
lastValueFrom(
this.loginService
.totpAuthenticate(code)
.pipe(tap(() => this.router.navigate(["/"])))
.pipe(tap(() => this.router.navigate(["/"]))),
);
}
}
@@ -1,4 +1,9 @@
import { Component, ChangeDetectionStrategy, OnInit, inject } from "@angular/core";
import {
Component,
ChangeDetectionStrategy,
OnInit,
inject,
} from "@angular/core";
import { RouterLink } from "@angular/router";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { MatButtonModule } from "@angular/material/button";
+254 -250
View File
@@ -1,263 +1,267 @@
<mat-sidenav-container>
@if (isLoggedIn()) {
<mat-sidenav
#sidenav
class="mat-elevation-z4"
[opened]="navOpen()"
(closed)="closeSideNav()"
[mode]="mobileNav() === true ? 'over' : 'side'"
>
<mat-toolbar class="logo-toolbar">
<img
class="main-logo"
routerLink="."
(click)="reload()"
src="static/assets/images/glitchtip-logo-v1.svg"
alt="GlitchTip"
/>
</mat-toolbar>
@for (org of organizations(); track org.id) { } @if ( (contextLoaded()) &&
organizations().length === 0 && (canCreateOrg()) ) {
<a
mat-button
id="create-new-link"
routerLink="/organizations/new"
class="org-create no-org-create"
i18n
>Create New Organization</a
<mat-sidenav
#sidenav
class="mat-elevation-z4"
[opened]="navOpen()"
(closed)="closeSideNav()"
[mode]="mobileNav() === true ? 'over' : 'side'"
>
} @else {
<button
mat-button
id="org-dropdown"
[disabled]="contextLoaded() === false"
[matMenuTriggerFor]="menu"
class="org-dropdown"
>
<div class="label-container">
<span class="overflow">
{{ activeOrganization()?.name }}
</span>
<span class="down-caret">▼</span>
</div>
</button>
}
<mat-menu #menu="matMenu">
@for (organization of organizations(); track organization.id) {
<button (click)="setOrganization(organization.slug)" mat-menu-item>
{{ organization.name }}
</button>
}
<mat-divider></mat-divider>
@if (canCreateOrg()) {
<a mat-menu-item id="create-new-link" routerLink="/organizations/new" i18n
>Create New Organization</a
>
<mat-toolbar class="logo-toolbar">
<img
class="main-logo"
routerLink="."
(click)="reload()"
src="static/assets/images/glitchtip-logo-v1.svg"
alt="GlitchTip"
/>
</mat-toolbar>
@for (org of organizations(); track org.id) {}
@if (contextLoaded() && organizations().length === 0 && canCreateOrg()) {
<a
mat-button
id="create-new-link"
routerLink="/organizations/new"
class="org-create no-org-create"
i18n
>Create New Organization</a
>
} @else {
<span
mat-menu-item
disabled
matTooltip="Organization creation is currently disabled"
matTooltipPosition="below"
>
Create New Organization
</span>
<button
mat-button
id="org-dropdown"
[disabled]="contextLoaded() === false"
[matMenuTriggerFor]="menu"
class="org-dropdown"
>
<div class="label-container">
<span class="overflow">
{{ activeOrganization()?.name }}
</span>
<span class="down-caret">▼</span>
</div>
</button>
}
</mat-menu>
<mat-nav-list class="nav-link-list">
<a
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="[activeOrganizationSlug(), 'issues']"
routerLinkActive="active-route"
i18n
>Issues</a
>
<a
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="[activeOrganizationSlug(), 'uptime-monitors']"
routerLinkActive="active-route"
i18n
>Uptime Monitors</a
>
<a
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="[
activeOrganizationSlug(),
'performance',
'transaction-groups'
]"
routerLinkActive="active-route"
i18n
>Performance</a
>
<mat-divider class="org-details-divider"></mat-divider>
<a
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="[activeOrganizationSlug(), 'projects']"
routerLinkActive="active-route"
i18n
>Projects</a
>
<a
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="[activeOrganizationSlug(), 'releases']"
routerLinkActive="active-route"
i18n
>Releases</a
>
<a
data-test-settings
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="[activeOrganizationSlug(), 'settings']"
routerLinkActive="active-route"
i18n
>Settings</a
>
<a
class="sub-items"
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="['/', activeOrganizationSlug(), 'settings', 'projects']"
routerLinkActive="active-route"
i18n
>Projects</a
>
@if (billingEnabled()) {
<a
class="sub-items"
mat-list-item
[routerLink]="[
'/',
activeOrganizationSlug(),
'settings',
'subscription'
]"
routerLinkActive="active-route"
i18n
>Subscription</a
>
<mat-menu #menu="matMenu">
@for (organization of organizations(); track organization.id) {
<button (click)="setOrganization(organization.slug)" mat-menu-item>
{{ organization.name }}
</button>
}
<mat-divider></mat-divider>
@if (canCreateOrg()) {
<a
mat-menu-item
id="create-new-link"
routerLink="/organizations/new"
i18n
>Create New Organization</a
>
} @else {
<span
mat-menu-item
disabled
matTooltip="Organization creation is currently disabled"
matTooltipPosition="below"
>
Create New Organization
</span>
}
</mat-menu>
<mat-nav-list class="nav-link-list">
<a
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="[activeOrganizationSlug(), 'issues']"
routerLinkActive="active-route"
i18n
>Issues</a
>
<a
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="[activeOrganizationSlug(), 'uptime-monitors']"
routerLinkActive="active-route"
i18n
>Uptime Monitors</a
>
<a
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="[
activeOrganizationSlug(),
'performance',
'transaction-groups',
]"
routerLinkActive="active-route"
i18n
>Performance</a
>
<mat-divider class="org-details-divider"></mat-divider>
<a
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="[activeOrganizationSlug(), 'projects']"
routerLinkActive="active-route"
i18n
>Projects</a
>
<a
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="[activeOrganizationSlug(), 'releases']"
routerLinkActive="active-route"
i18n
>Releases</a
>
<a
data-test-settings
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="[activeOrganizationSlug(), 'settings']"
routerLinkActive="active-route"
i18n
>Settings</a
>
<a
class="sub-items"
mat-list-item
[disabled]="!activeOrganization()"
[routerLink]="['/', activeOrganizationSlug(), 'settings', 'projects']"
routerLinkActive="active-route"
i18n
>Projects</a
>
@if (billingEnabled()) {
<a
class="sub-items"
mat-list-item
[routerLink]="[
'/',
activeOrganizationSlug(),
'settings',
'subscription',
]"
routerLinkActive="active-route"
i18n
>Subscription</a
>
}
<a
mat-list-item
[routerLink]="['/', activeOrganizationSlug(), 'settings', 'teams']"
routerLinkActive="active-route"
class="sub-items"
i18n
>Teams</a
>
<a
mat-list-item
[routerLink]="['/', activeOrganizationSlug(), 'settings', 'members']"
routerLinkActive="active-route"
class="sub-items"
i18n
>Members</a
>
<mat-divider class="org-details-divider"></mat-divider>
<a
mat-list-item
[routerLink]="['/profile']"
routerLinkActive="active-route"
i18n
>Profile</a
>
<a
mat-list-item
[routerLink]="['/profile']"
routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }"
class="sub-items"
i18n
>Account</a
>
<a
mat-list-item
[routerLink]="['/profile', 'multi-factor-auth']"
routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }"
class="sub-items"
i18n
>Multi-factor Authentication</a
>
<a
mat-list-item
[routerLink]="['/profile', 'notifications']"
routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }"
class="sub-items"
i18n
>Notifications</a
>
<a
mat-list-item
[routerLink]="['/profile', 'auth-tokens']"
routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }"
class="sub-items"
i18n
>Auth Tokens</a
>
<button mat-menu-item (click)="this.logout()" i18n>Log Out</button>
</mat-nav-list>
@if (paidForGlitchTip() === false) {
<mat-card class="nagware">
<mat-card-header>
<mat-card-title>
<h3 i18n>Support GlitchTip</h3>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<ul class="mat-body-1">
<li i18n
>Donate
<a
target="_blank"
rel="noopener noreferrer"
href="https://liberapay.com/GlitchTip/donate"
>
via Liberapay
</a>
</li>
<li i18n>
Switch to
<a
target="_blank"
rel="noopener"
href="https://app.glitchtip.com/register?utm_medium=website&utm_source=glitchtip&utm_campaign=selfhost_nagware"
>Hosted GlitchTip</a
>
</li>
<li i18n>
Purchase support:
<a
href="mailto:sales@glitchtip.com?subject=Purchase support inquiry"
>sales&#64;glitchtip.com</a
>
</li>
</ul>
</mat-card-content>
</mat-card>
}
<a
mat-list-item
[routerLink]="['/', activeOrganizationSlug(), 'settings', 'teams']"
routerLinkActive="active-route"
class="sub-items"
i18n
>Teams</a
>
<a
mat-list-item
[routerLink]="['/', activeOrganizationSlug(), 'settings', 'members']"
routerLinkActive="active-route"
class="sub-items"
i18n
>Members</a
>
<mat-divider class="org-details-divider"></mat-divider>
<a
mat-list-item
[routerLink]="['/profile']"
routerLinkActive="active-route"
i18n
>Profile</a
>
<a
mat-list-item
[routerLink]="['/profile']"
routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }"
class="sub-items"
i18n
>Account</a
>
<a
mat-list-item
[routerLink]="['/profile', 'multi-factor-auth']"
routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }"
class="sub-items"
i18n
>Multi-factor Authentication</a
>
<a
mat-list-item
[routerLink]="['/profile', 'notifications']"
routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }"
class="sub-items"
i18n
>Notifications</a
>
<a
mat-list-item
[routerLink]="['/profile', 'auth-tokens']"
routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }"
class="sub-items"
i18n
>Auth Tokens</a
>
<button mat-menu-item (click)="this.logout()" i18n>Log Out</button>
</mat-nav-list>
@if ((paidForGlitchTip()) === false) {
<mat-card class="nagware">
<mat-card-header>
<mat-card-title>
<h3 i18n>Support GlitchTip</h3>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<ul class="mat-body-1">
<li i18n
>Donate
<a
target="_blank"
rel="noopener noreferrer"
href="https://liberapay.com/GlitchTip/donate"
>
via Liberapay
</a>
</li>
<li i18n>
Switch to
<a
target="_blank"
rel="noopener"
href="https://app.glitchtip.com/register?utm_medium=website&utm_source=glitchtip&utm_campaign=selfhost_nagware"
>Hosted GlitchTip</a
>
</li>
<li i18n>
Purchase support:
<a
href="mailto:sales@glitchtip.com?subject=Purchase support inquiry"
>sales&#64;glitchtip.com</a
>
</li>
</ul>
</mat-card-content>
</mat-card>
}
<div class="mat-typography version">
<a [routerLink]="['/system-info']" routerLinkActive="active-route" i18n
>GlitchTip {{ version() }}</a
>
</div>
</mat-sidenav>
<div class="mat-typography version">
<a [routerLink]="['/system-info']" routerLinkActive="active-route" i18n
>GlitchTip {{ version() }}</a
>
</div>
</mat-sidenav>
}
<mat-sidenav-content>
@if (isLoggedIn()) {
<gt-mobile-nav-toolbar
[activeOrg]="activeOrganization()"
(buttonClicked)="toggleSideNav()"
></gt-mobile-nav-toolbar>
<gt-mobile-nav-toolbar
[activeOrg]="activeOrganization()"
(buttonClicked)="toggleSideNav()"
></gt-mobile-nav-toolbar>
}
<div class="wrapper">
<ng-content></ng-content>
@@ -65,19 +65,19 @@ export class MainNavComponent {
() =>
this.settingsService.initialLoad() &&
this.organizationsInitialLoad() &&
!!this.userService.user()
!!this.userService.user(),
);
canCreateOrg = computed(
() =>
this.settingsService.enableOrganizationCreation() ||
this.userService.user() ||
this.organizationsService.organizationsCount()
this.organizationsService.organizationsCount(),
);
logout() {
firstValueFrom(
this.auth.logout().pipe(tap(() => (window.location.href = "/login")))
this.auth.logout().pipe(tap(() => (window.location.href = "/login"))),
);
}
@@ -1,38 +1,38 @@
<div class="l-body">
@if (contextLoaded$ | async) {
<mat-card>
<mat-card-content>
<ng-container>
@if (canCreateOrg$ | async) {
<mat-card-title i18n>Create a New Organization</mat-card-title>
@if ((organizationCount()) === 0) {
<p i18n>This is the first step to get started with GlitchTip.</p>
}
<form
[formGroup]="form"
(ngSubmit)="onSubmit()"
id="create-organization-form"
>
@if (error(); as error) {
<mat-error>{{ error }}</mat-error>
<mat-card>
<mat-card-content>
<ng-container>
@if (canCreateOrg$ | async) {
<mat-card-title i18n>Create a New Organization</mat-card-title>
@if (organizationCount() === 0) {
<p i18n>This is the first step to get started with GlitchTip.</p>
}
<form
[formGroup]="form"
(ngSubmit)="onSubmit()"
id="create-organization-form"
>
@if (error(); as error) {
<mat-error>{{ error }}</mat-error>
}
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input matInput formControlName="name" i18n-placeholder />
</mat-form-field>
<button id="submit" mat-flat-button color="primary" i18n
>Create Organization</button
>
</form>
} @else {
<ng-container i18n
>Organization creation has been disabled on this instance of
GlitchTip. Contact an administrator to be invited into an existing
organization.</ng-container
>
}
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input matInput formControlName="name" i18n-placeholder />
</mat-form-field>
<button id="submit" mat-flat-button color="primary" i18n
>Create Organization</button
>
</form>
} @else {
<ng-container i18n
>Organization creation has been disabled on this instance of
GlitchTip. Contact an administrator to be invited into an existing
organization.</ng-container
>
}
</ng-container>
</mat-card-content>
</mat-card>
</ng-container>
</mat-card-content>
</mat-card>
}
</div>
@@ -1,4 +1,9 @@
import { ChangeDetectionStrategy, Component, signal, inject } from "@angular/core";
import {
ChangeDetectionStrategy,
Component,
signal,
inject,
} from "@angular/core";
import {
FormGroup,
FormControl,
@@ -49,7 +54,7 @@ export class NewOrganizationsComponent {
]).pipe(
map(([user, orgCount, enableOrgCreation]) => {
return enableOrgCreation || user?.isSuperuser || orgCount === 0;
})
}),
);
contextLoaded$ = combineLatest([
@@ -59,7 +64,7 @@ export class NewOrganizationsComponent {
]).pipe(
map(([settingsLoaded, orgsLoaded, user]) => {
return settingsLoaded && orgsLoaded && !!user;
})
}),
);
loading = false;
@@ -58,7 +58,7 @@ export class OrganizationFrameComponent implements OnDestroy, OnInit {
["../", activeOrganizationSlug, this.firstChildRoute],
{
relativeTo: this.route,
}
},
);
}
}
@@ -92,7 +92,7 @@ export class OrganizationFrameComponent implements OnDestroy, OnInit {
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => {
this.extractFirstChildRoute();
})
}),
);
}
+7 -7
View File
@@ -31,7 +31,7 @@ export class PerformanceService extends PaginationStatefulService<PerformanceSta
private organizationsService = inject(OrganizationsService);
transactionGroups$ = this.getState$.pipe(
map((state) => state.transactionGroups)
map((state) => state.transactionGroups),
);
transactionGroupsDisplay$ = combineLatest([
@@ -42,14 +42,14 @@ export class PerformanceService extends PaginationStatefulService<PerformanceSta
map(([projects, groups]) => {
return groups.map((group) => {
const projectSlug = projects?.find(
(project) => +project.id === group.project
(project) => +project.id === group.project,
)?.name;
return {
...group,
projectSlug,
};
});
})
}),
);
errors$ = this.getState$.pipe(map((state) => state.errors));
@@ -66,7 +66,7 @@ export class PerformanceService extends PaginationStatefulService<PerformanceSta
end: string | undefined | null,
sort: string | undefined | null,
environment: string | undefined | null,
query: string | undefined | null
query: string | undefined | null,
) {
return this.retrieveTransactionGroups(
orgSlug,
@@ -76,7 +76,7 @@ export class PerformanceService extends PaginationStatefulService<PerformanceSta
end,
sort,
environment,
query
query,
);
}
@@ -88,7 +88,7 @@ export class PerformanceService extends PaginationStatefulService<PerformanceSta
end?: string | null,
sort?: string | null,
environment?: string | null,
query?: string | null
query?: string | null,
) {
this.setLoadingStart();
return this.transactionGroupsAPIService
@@ -100,7 +100,7 @@ export class PerformanceService extends PaginationStatefulService<PerformanceSta
catchError((err: HttpErrorResponse) => {
this.setTransactionGroupsError(err);
return EMPTY;
})
}),
);
}
@@ -34,7 +34,7 @@ export class TransactionGroupDetailComponent implements OnInit, OnDestroy {
this.transactionGroupDetailService.transactionGroupInitialLoadComplete$;
transactionGroup$ = this.transactionGroupDetailService.transactionGroup$;
transactionGroupIdParam$ = this.route.paramMap.pipe(
map((params) => params.get("transaction-group-id"))
map((params) => params.get("transaction-group-id")),
);
retrieveDetailsSubscription?: Subscription;
@@ -51,11 +51,11 @@ export class TransactionGroupDetailComponent implements OnInit, OnDestroy {
if (orgSlug && groupId) {
return this.transactionGroupDetailService.retrieveTransactionGroup(
orgSlug,
parseInt(groupId)
parseInt(groupId),
);
}
return EMPTY;
})
}),
)
.subscribe();
}
@@ -90,7 +90,7 @@ export class TransactionGroupsComponent implements OnInit, OnDestroy {
this.organizationsService.activeOrganizationProjects$;
projectsFromParams$ = this.route.queryParams.pipe(
map((params) => normalizeProjectParams(params.project))
map((params) => normalizeProjectParams(params.project)),
);
appliedProjectCount$ = this.projectsFromParams$.pipe(
@@ -99,19 +99,19 @@ export class TransactionGroupsComponent implements OnInit, OnDestroy {
return projects.length;
}
return 0;
})
}),
);
organizationEnvironments$ = combineLatest([
this.appliedProjectCount$,
toObservable(
this.organizationDetailService.organizationEnvironmentsProcessed
this.organizationDetailService.organizationEnvironmentsProcessed,
),
this.projectEnvironmentsService.visibleEnvironments$,
]).pipe(
map(([appliedProjectCount, orgEnvironments, projectEnvironments]) =>
appliedProjectCount !== 1 ? orgEnvironments : projectEnvironments
)
appliedProjectCount !== 1 ? orgEnvironments : projectEnvironments,
),
);
constructor() {
@@ -128,19 +128,19 @@ export class TransactionGroupsComponent implements OnInit, OnDestroy {
params.get("end"),
params.get("sort"),
params.get("environment"),
params.get("query")
params.get("query"),
);
}
return EMPTY;
}),
takeUntilDestroyed()
takeUntilDestroyed(),
)
.subscribe();
this.organizationEnvironments$.subscribe((environments) =>
environments.length === 0
? this.environmentForm.controls.environment.disable()
: this.environmentForm.controls.environment.enable()
: this.environmentForm.controls.environment.enable(),
);
this.transactionGroupsDisplay$
@@ -148,7 +148,7 @@ export class TransactionGroupsComponent implements OnInit, OnDestroy {
.subscribe((groups) =>
groups.length === 0
? this.sortForm.controls.sort.disable()
: this.sortForm.controls.sort.enable()
: this.sortForm.controls.sort.enable(),
);
this.orgSlug$
@@ -156,12 +156,12 @@ export class TransactionGroupsComponent implements OnInit, OnDestroy {
switchMap((orgSlug) => {
if (orgSlug) {
return this.organizationDetailService.getOrganizationEnvironments(
orgSlug
orgSlug,
);
}
return EMPTY;
}),
takeUntilDestroyed()
takeUntilDestroyed(),
)
.subscribe();
@@ -170,23 +170,23 @@ export class TransactionGroupsComponent implements OnInit, OnDestroy {
this.route.queryParamMap.pipe(
map((params) => params.getAll("project")[0]),
filter((project) => !!project),
distinctUntilChanged()
distinctUntilChanged(),
),
this.organizationsService.activeOrganizationProjects$,
]).pipe(
switchMap(([orgSlug, projectId, orgProjects]) => {
const projectSlug = orgProjects?.find(
(orgProject) => orgProject.id.toString() === projectId
(orgProject) => orgProject.id.toString() === projectId,
)?.slug;
if (orgSlug && projectSlug) {
return this.projectEnvironmentsService.retrieveEnvironmentsWithProperties(
orgSlug,
projectSlug
projectSlug,
);
}
return EMPTY;
}),
takeUntilDestroyed()
takeUntilDestroyed(),
);
combineLatest([
@@ -207,7 +207,7 @@ export class TransactionGroupsComponent implements OnInit, OnDestroy {
queryParamsHandling: "merge",
});
}
})
}),
)
.subscribe();
}
+9 -1
View File
@@ -1,4 +1,12 @@
import { AfterViewInit, Directive, ElementRef, HostBinding, OnInit, input, inject } from "@angular/core";
import {
AfterViewInit,
Directive,
ElementRef,
HostBinding,
OnInit,
input,
inject,
} from "@angular/core";
import Prism from "prismjs";
import { GRAMMAR_MAPPINGS, PRISM_SUPPORTED_GRAMMAR } from "./constants";
@@ -29,7 +29,7 @@
<gt-loading-button
data-test
buttonText="Delete User Account"
[loading]="(userDeleteLoading()) === true"
[loading]="userDeleteLoading() === true"
(buttonClick)="deleteUser()"
id="delete-user"
>
+1 -1
View File
@@ -35,7 +35,7 @@ export class AccountComponent implements OnDestroy {
deleteUser() {
if (
window.confirm(
`Are you sure you want to delete your user account? You will permanently lose access to all organizations, projects, and teams associated with it.`
`Are you sure you want to delete your user account? You will permanently lose access to all organizations, projects, and teams associated with it.`,
)
) {
this.userService.deleteUser().then((result) => {
@@ -1,60 +1,89 @@
@if (user(); as user) {
<mat-card class="half-width">
<mat-card-header>
<mat-card-title i18n>Change Password</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="onSubmit()" id="change-password-form">
<gt-form-error [errors]="formErrors()"></gt-form-error>
@if (user.hasPasswordAuth) {
<mat-form-field class="full-width first-field" appearance="outline">
<mat-label i18n>Current Password</mat-label>
<input matInput type="password" formControlName="current_password" />
<mat-error>
@if (current_password?.errors?.required) {
<span i18n>Your current is required to change your password</span>
}
@if (current_password?.errors?.serverError) {
<span>{{ current_password?.errors?.serverError }}</span>
}
</mat-error>
</mat-form-field>
}
<mat-form-field class="full-width" [class.first-field]="!user.hasPasswordAuth" appearance="outline">
<mat-label i18n>New Password</mat-label>
<input matInput type="password" formControlName="new_password" />
<mat-error>
@if (new_password?.errors?.minlength) {
<span i18n>Your password should be at least 8 characters long</span>
}
@if (new_password?.errors?.required) {
<span i18n>Type in your new password</span>
}
@if (new_password?.errors?.serverError) {
<span>{{ new_password?.errors?.serverError }}</span>
}
</mat-error>
</mat-form-field>
<mat-form-field class="full-width" data-cy="confirm-password" appearance="outline">
<mat-label i18n>Confirm New Password</mat-label>
<input matInput type="password" formControlName="new_password2" gtInputMatcher="new_password" />
<mat-error>
@if (new_password2?.errors?.required) {
<span i18n>Type in your new password</span>
}
@if (new_password2?.errors?.notEqual) {
<span i18n>Your passwords don't match</span>
}
@if (new_password2?.errors?.serverError) {
<span>{{ new_password2?.errors?.serverError }}</span>
}
</mat-error>
</mat-form-field>
<gt-loading-button i18n-buttonText buttonText="Change Password" [loading]="loading()">
</gt-loading-button>
</form>
</mat-card-content>
</mat-card>
<section></section>
<mat-card class="half-width">
<mat-card-header>
<mat-card-title i18n>Change Password</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
<form
[formGroup]="form"
(ngSubmit)="onSubmit()"
id="change-password-form"
>
<gt-form-error [errors]="formErrors()"></gt-form-error>
@if (user.hasPasswordAuth) {
<mat-form-field class="full-width first-field" appearance="outline">
<mat-label i18n>Current Password</mat-label>
<input
matInput
type="password"
formControlName="current_password"
/>
<mat-error>
@if (current_password?.errors?.required) {
<span i18n
>Your current is required to change your password</span
>
}
@if (current_password?.errors?.serverError) {
<span>{{ current_password?.errors?.serverError }}</span>
}
</mat-error>
</mat-form-field>
}
<mat-form-field
class="full-width"
[class.first-field]="!user.hasPasswordAuth"
appearance="outline"
>
<mat-label i18n>New Password</mat-label>
<input matInput type="password" formControlName="new_password" />
<mat-error>
@if (new_password?.errors?.minlength) {
<span i18n
>Your password should be at least 8 characters long</span
>
}
@if (new_password?.errors?.required) {
<span i18n>Type in your new password</span>
}
@if (new_password?.errors?.serverError) {
<span>{{ new_password?.errors?.serverError }}</span>
}
</mat-error>
</mat-form-field>
<mat-form-field
class="full-width"
data-cy="confirm-password"
appearance="outline"
>
<mat-label i18n>Confirm New Password</mat-label>
<input
matInput
type="password"
formControlName="new_password2"
gtInputMatcher="new_password"
/>
<mat-error>
@if (new_password2?.errors?.required) {
<span i18n>Type in your new password</span>
}
@if (new_password2?.errors?.notEqual) {
<span i18n>Your passwords don't match</span>
}
@if (new_password2?.errors?.serverError) {
<span>{{ new_password2?.errors?.serverError }}</span>
}
</mat-error>
</mat-form-field>
<gt-loading-button
i18n-buttonText
buttonText="Change Password"
[loading]="loading()"
>
</gt-loading-button>
</form>
</mat-card-content>
</mat-card>
<section></section>
}
@@ -88,7 +88,7 @@ export class ChangePasswordComponent
const service = inject(PasswordService);
toObservable(service.fieldErrors).subscribe((fieldErrors) =>
mapFormErrors(fieldErrors, this.form)
mapFormErrors(fieldErrors, this.form),
);
super(service);
@@ -105,7 +105,7 @@ export class ChangePasswordComponent
this.service
.changePassword(
this.form.value.current_password!,
this.form.value.new_password!
this.form.value.new_password!,
)
.pipe(
tap(() => {
@@ -114,8 +114,8 @@ export class ChangePasswordComponent
Object.keys(this.form.controls).forEach((key) => {
this.form.get(key)!.setErrors(null);
});
})
)
}),
),
);
}
}
@@ -36,10 +36,10 @@ export class PasswordService extends StatefulService<PasswordState> {
errors = computed(() => this.state().errors);
success = computed(() => this.state().success);
formErrors = computed(() =>
messagesLookup(this.state().errors.filter((err) => !err.param))
messagesLookup(this.state().errors.filter((err) => !err.param)),
);
fieldErrors = computed(() =>
reduceParamErrors(this.state().errors.filter((err) => err.param))
reduceParamErrors(this.state().errors.filter((err) => err.param)),
);
constructor() {
@@ -65,7 +65,7 @@ export class PasswordService extends StatefulService<PasswordState> {
return of(EMPTY);
}
return throwError(() => new Error("Unable to change password"));
})
}),
);
}
}
@@ -1,4 +1,9 @@
import { Component, OnInit, ChangeDetectionStrategy, inject } from "@angular/core";
import {
Component,
OnInit,
ChangeDetectionStrategy,
inject,
} from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { map } from "rxjs/operators";
import { ConfirmEmailService } from "../../api/confirm-email/confirm-email.service";
@@ -13,7 +18,6 @@ export class ConfirmEmailComponent implements OnInit {
private activatedRoute = inject(ActivatedRoute);
private confirmEmailService = inject(ConfirmEmailService);
ngOnInit(): void {
this.activatedRoute.params
.pipe(map((params) => this.confirmEmailService.confirmEmail(params.key)))
@@ -1,4 +1,9 @@
import { Component, OnInit, ChangeDetectionStrategy, inject } from "@angular/core";
import {
Component,
OnInit,
ChangeDetectionStrategy,
inject,
} from "@angular/core";
import { lastValueFrom } from "rxjs";
import { MultiFactorAuthService } from "./multi-factor-auth.service";
@@ -1,4 +1,12 @@
import { Component, ChangeDetectionStrategy, ElementRef, ViewChild, OnInit, OnDestroy, inject } from "@angular/core";
import {
Component,
ChangeDetectionStrategy,
ElementRef,
ViewChild,
OnInit,
OnDestroy,
inject,
} from "@angular/core";
import {
FormControl,
FormGroup,
@@ -1,4 +1,9 @@
import { Component, OnInit, ChangeDetectionStrategy, inject } from "@angular/core";
import {
Component,
OnInit,
ChangeDetectionStrategy,
inject,
} from "@angular/core";
import { NotificationsService } from "./notifications.service";
import { NotificationStatus } from "./notifications.interface";
import { RouterLink } from "@angular/router";
@@ -65,7 +65,7 @@ export class PreferencesComponent implements OnInit {
}),
theme: new FormControl(
"",
autocompleteStringValidator(["system", "light", "dark"])
autocompleteStringValidator(["system", "light", "dark"]),
),
});
filteredOptions?: Observable<string[]>;
@@ -79,7 +79,7 @@ export class PreferencesComponent implements OnInit {
this.serverTimeZone$
.pipe(
filter((serverTimeZone) => !!serverTimeZone),
take(1)
take(1),
)
.subscribe((serverTimeZone) => {
this.defaultTimeZone = this.defaultTimeZone + ` \(${serverTimeZone}\)`;
@@ -106,7 +106,7 @@ export class PreferencesComponent implements OnInit {
});
this.filteredOptions = this.form.controls["timeZone"].valueChanges.pipe(
startWith(""),
map((value) => this._filter(value || ""))
map((value) => this._filter(value || "")),
);
}
@@ -114,7 +114,7 @@ export class PreferencesComponent implements OnInit {
const filterValue = value.toLowerCase().replace(/\s/g, "_");
return this.timeZones.filter((option) =>
option.toLowerCase().includes(filterValue)
option.toLowerCase().includes(filterValue),
);
}
+34 -11
View File
@@ -2,27 +2,50 @@
<mat-drawer class="mat-elevation-z2" mode="side" opened>
<nav>
<mat-nav-list>
<a mat-list-item routerLink="/profile" routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }">Account</a>
<a mat-list-item routerLink="./multi-factor-auth" routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }">Multi-factor Authentication</a>
<a mat-list-item routerLink="./notifications" routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }">Notifications</a>
<a mat-list-item routerLink="./auth-tokens" routerLinkActive="active-route">Auth Tokens</a>
<a
mat-list-item
routerLink="/profile"
routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }"
>Account</a
>
<a
mat-list-item
routerLink="./multi-factor-auth"
routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }"
>Multi-factor Authentication</a
>
<a
mat-list-item
routerLink="./notifications"
routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }"
>Notifications</a
>
<a
mat-list-item
routerLink="./auth-tokens"
routerLinkActive="active-route"
>Auth Tokens</a
>
</mat-nav-list>
</nav>
</mat-drawer>
<mat-drawer-content>
@if (isLoggedIn()) {
<gt-mobile-nav-toolbar [activeOrg]="activeOrganization()" (buttonClicked)="toggleSideNav()"></gt-mobile-nav-toolbar>
<gt-mobile-nav-toolbar
[activeOrg]="activeOrganization()"
(buttonClicked)="toggleSideNav()"
></gt-mobile-nav-toolbar>
}
<section class="l-body">
<header class="page-header">
<h1 class="mat-headline-4">User Profile</h1>
@if (user(); as user) {
<p class="mat-body-1 subtitle">
{{ user.email }}
</p>
<p class="mat-body-1 subtitle">
{{ user.email }}
</p>
}
</header>
<router-outlet></router-outlet>
@@ -1,58 +1,73 @@
@if (socialApps().length || (user$ | async)?.identities?.length) {
@if (user$ | async; as user) {
<mat-card>
<mat-card-header>
<mat-card-title>Social Auth Accounts</mat-card-title>
@if ((socialApps()).length) {
@if (socialApps(); as socialApps) {
<mat-form-field appearance="outline">
<mat-label>Add Account</mat-label>
<mat-select (selectionChange)="addAccount()" [formControl]="account">
@for (socialApp of socialApps; track socialApp) {
<mat-option [value]="socialApp">
<gt-auth-svg source="dropdown" [provider]="socialApp.provider" [text]="socialApp.name"></gt-auth-svg>
</mat-option>
@if (user$ | async; as user) {
<mat-card>
<mat-card-header>
<mat-card-title>Social Auth Accounts</mat-card-title>
@if (socialApps().length) {
@if (socialApps(); as socialApps) {
<mat-form-field appearance="outline">
<mat-label>Add Account</mat-label>
<mat-select
(selectionChange)="addAccount()"
[formControl]="account"
>
@for (socialApp of socialApps; track socialApp) {
<mat-option [value]="socialApp">
<gt-auth-svg
source="dropdown"
[provider]="socialApp.provider"
[text]="socialApp.name"
></gt-auth-svg>
</mat-option>
}
</mat-select>
</mat-form-field>
}
}
</mat-select>
</mat-form-field>
}
}
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
@if ((user$ | async)?.identities?.length === 0) {
<p class="empty-auth">
You haven't connected any social auth accounts.
</p>
} @else {
<div class="socialaccount-list-wrapper">
@for (
socialAccount of (user$ | async)?.identities;
track socialAccount;
let lastItem = $last
) {
<div class="socialaccount-wrapper">
<button mat-stroked-button (click)="
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
@if ((user$ | async)?.identities?.length === 0) {
<p class="empty-auth">
You haven't connected any social auth accounts.
</p>
} @else {
<div class="socialaccount-list-wrapper">
@for (
socialAccount of (user$ | async)?.identities;
track socialAccount;
let lastItem = $last
) {
<div class="socialaccount-wrapper">
<button
mat-stroked-button
(click)="
disconnect(
socialAccount.id!,
socialAccount.provider,
socialAccount.uid
)
" [disabled]="disconnectLoadingId() === socialAccount.id">
<gt-auth-svg [loading]="disconnectLoadingId() === socialAccount.id" source="disconnect"
text="Disconnect {{ socialAccount.name }}" [provider]="socialAccount.provider"></gt-auth-svg>
</button>
<div class="email">
{{ socialAccount.email }}
</div>
</div>
@if (!lastItem) {
<mat-divider></mat-divider>
}
}
</div>
}
</mat-card-content>
</mat-card>
}
"
[disabled]="disconnectLoadingId() === socialAccount.id"
>
<gt-auth-svg
[loading]="disconnectLoadingId() === socialAccount.id"
source="disconnect"
text="Disconnect {{ socialAccount.name }}"
[provider]="socialAccount.provider"
></gt-auth-svg>
</button>
<div class="email">
{{ socialAccount.email }}
</div>
</div>
@if (!lastItem) {
<mat-divider></mat-divider>
}
}
</div>
}
</mat-card-content>
</mat-card>
}
}
@@ -1,4 +1,9 @@
import { Component, OnInit, ChangeDetectionStrategy, inject } from "@angular/core";
import {
Component,
OnInit,
ChangeDetectionStrategy,
inject,
} from "@angular/core";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { catchError, lastValueFrom, of, tap, throwError } from "rxjs";
import { MatButtonModule } from "@angular/material/button";
@@ -69,7 +74,7 @@ export class SocialAuthComponent
this.authenticationService.providerRedirect(
this.account.value.provider,
window.location.href,
"connect"
"connect",
);
}
@@ -79,7 +84,7 @@ export class SocialAuthComponent
this.service.disconnect(id, provider, account).pipe(
tap(() => {
this.snackBar.open(
$localize`You have successfully disconnected your social auth account`
$localize`You have successfully disconnected your social auth account`,
);
}),
catchError((err: AllAuthHttpErrorResponse) => {
@@ -89,8 +94,8 @@ export class SocialAuthComponent
}
this.snackBar.open(UNHANDLED_ERROR);
return throwError(() => err);
})
)
}),
),
);
} else {
this.snackBar.open($localize`Your account has no password set up.`);
@@ -33,16 +33,16 @@ export class SocialAuthService extends StatefulService<SocialAuthState> {
return {
...socialAccount,
name: socialApps.find(
(socialApp) => socialApp.provider === socialAccount.provider
(socialApp) => socialApp.provider === socialAccount.provider,
)?.name,
};
}
},
);
return {
...userDetails,
identities: socialAccountsWithNames,
};
})
}),
);
constructor() {
super(initialState);
@@ -58,7 +58,7 @@ export class SocialAuthService extends StatefulService<SocialAuthState> {
catchError((err: AllAuthHttpErrorResponse) => {
this.setState({ loadingId: null });
return throwError(() => err);
})
}),
);
}
}
@@ -24,7 +24,7 @@ export class RateLimitBannerComponent {
private organizationsService = inject(OrganizationsService);
eventThrottleRate$ = this.organizationsService.activeOrganization$.pipe(
map((activeOrganization) => activeOrganization?.eventThrottleRate)
map((activeOrganization) => activeOrganization?.eventThrottleRate),
);
activeOrgSlug$ = this.organizationsService.activeOrganizationSlug$;
bannerVisible = true;
@@ -7,45 +7,62 @@
</mat-card-header>
<mat-card-content>
@if (success()) {
<p class="success" data-cy="reset-success" i18n>If you have an account with GlitchTip, an email has been sent to
the
email address you provided.</p>
<button mat-flat-button color="primary" class="full-width" routerLink="/login">
<span i18n>Return to login</span>
</button>
<p class="success" data-cy="reset-success" i18n
>If you have an account with GlitchTip, an email has been sent to the
email address you provided.</p
>
<button
mat-flat-button
color="primary"
class="full-width"
routerLink="/login"
>
<span i18n>Return to login</span>
</button>
} @else {
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<gt-form-error [errors]="formErrors()"></gt-form-error>
<mat-form-field class="full-width" appearance="outline">
<mat-label i18n>Email</mat-label>
<input [readonly]="success()" matInput type="email" formControlName="email" />
@if (email?.errors) {
<mat-error>
@if (email?.errors?.required || email?.errors?.email) {
<span i18n>Enter a valid email address</span>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<gt-form-error [errors]="formErrors()"></gt-form-error>
<mat-form-field class="full-width" appearance="outline">
<mat-label i18n>Email</mat-label>
<input
[readonly]="success()"
matInput
type="email"
formControlName="email"
/>
@if (email?.errors) {
<mat-error>
@if (email?.errors?.required || email?.errors?.email) {
<span i18n>Enter a valid email address</span>
}
@if (email?.errors?.serverError) {
<span>
{{ email?.errors?.serverError }}
</span>
}
</mat-error>
}
@if (email?.errors?.serverError) {
<span>
{{ email?.errors?.serverError }}
</span>
}
</mat-error>
</mat-form-field>
<gt-loading-button
class="full-width"
buttonText="Send Reset Password Email"
[fullWidth]="true"
[loading]="loading()"
[disabled]="success()"
id="submit"
>
</gt-loading-button>
</form>
<div class="sign-up">
<p class="mat-body-1">
<a routerLink="/login" i18n>Log in</a>
</p>
@if (enableUserRegistration()) {
<p class="mat-body-1">
<a routerLink="/register" i18n>Register</a>
</p>
}
</mat-form-field>
<gt-loading-button class="full-width" buttonText="Send Reset Password Email" [fullWidth]="true"
[loading]="loading()" [disabled]="success()" id="submit">
</gt-loading-button>
</form>
<div class="sign-up">
<p class="mat-body-1">
<a routerLink="/login" i18n>Log in</a>
</p>
@if (enableUserRegistration()) {
<p class="mat-body-1">
<a routerLink="/register" i18n>Register</a>
</p>
}
</div>
</div>
}
</mat-card-content>
</mat-card>
@@ -57,7 +57,7 @@ export class ResetPasswordComponent extends StatefulComponent<
const service = inject(ResetPasswordService);
toObservable(service.fieldErrors).subscribe((fieldErrors) =>
mapFormErrors(fieldErrors, this.form)
mapFormErrors(fieldErrors, this.form),
);
super(service);
@@ -1,152 +1,156 @@
@if (member(); as member) {
<section class="l-body">
<gt-detail-header [title]="member.email" backLinkText="Back to Members" />
<mat-card>
<mat-card-header>
<mat-card-title il8n>Basics</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content class="basics-content">
<div class="column">
<div class="mat-body-strong" i18n>Email</div>
<div>{{ member.email }}</div>
</div>
<div class="column">
<div class="mat-body-strong" i18n>Role</div>
<div>{{ member.roleName }}</div>
</div>
<div class="column">
<div class="mat-body-strong" i18n>Added</div>
<div>{{ member.dateCreated | date }}</div>
</div>
@if (member.isOwner) {
<div class="column">
<mat-chip color="primary" highlighted="true" i18n
>Account Owner</mat-chip
>
</div>
}
</mat-card-content>
</mat-card>
<mat-card>
<mat-card-header>
<mat-card-title i18n>Role</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
@if (updateMemberError(); as error) {
<mat-error>{{ error }}</mat-error>
}
<div class="role-form-elements">
<mat-radio-group formControlName="role">
<mat-radio-button value="member">Member</mat-radio-button>
<mat-radio-button value="admin">Admin</mat-radio-button>
<mat-radio-button value="manager">Manager</mat-radio-button>
<mat-radio-button value="owner">Owner</mat-radio-button>
</mat-radio-group>
<div class="role-button-container">
<gt-loading-button
[disabled]="member.role === formRole.value"
[loading]="updateMemberLoading() === true"
buttonText="Update Member"
></gt-loading-button>
</div>
<section class="l-body">
<gt-detail-header [title]="member.email" backLinkText="Back to Members" />
<mat-card>
<mat-card-header>
<mat-card-title il8n>Basics</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content class="basics-content">
<div class="column">
<div class="mat-body-strong" i18n>Email</div>
<div>{{ member.email }}</div>
</div>
</form>
<div class="role-description">
@if (formRole.value === "member") {
<span i18n
>Members can view and act on events, as well as view most other data
within the organization.</span
>
} @if (formRole.value === "admin") {
<span i18n
>Admin privileges on any teams of which user is a member. They can
create new teams and projects, as well as delete teams and projects
they are already members of. Additionally, they can manage memberships
of teams that they are members of.</span
>
} @if (formRole.value === "manager") {
<span i18n
>Gains admin access on all teams as well as the ability to add and
remove members.</span
>
} @if (formRole.value === "owner") {
<span i18n
>Unrestricted access to the organization, its data, and its settings.
Can add, modify, and delete projects and members.</span
>
<div class="column">
<div class="mat-body-strong" i18n>Role</div>
<div>{{ member.roleName }}</div>
</div>
<div class="column">
<div class="mat-body-strong" i18n>Added</div>
<div>{{ member.dateCreated | date }}</div>
</div>
@if (member.isOwner) {
<div class="column">
<mat-chip color="primary" highlighted="true" i18n
>Account Owner</mat-chip
>
</div>
}
</div>
<div class="role-scopes">
@if (selectedRoleScopes(); as scopes) {
<div class="mat-body-strong" i18n>Scopes:</div>
<div>{{ scopes }}</div>
}
</div>
</mat-card-content>
</mat-card>
<mat-card>
<mat-card-header>
<mat-card-title i18n>Organization Account Ownership</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
@if (!member.isOwner) { @if (transferOrgOwnershipError(); as error) {
<mat-error>{{ error }}</mat-error>
}
<p i18n>
The account owner has access to the organization's GlitchTip
subscription settings. If you are the current account owner or have an
owner role, you may transfer ownership to this member.
</p>
<gt-loading-button
buttonText="Transfer Ownership"
[loading]="transferOrgOwnershipLoading() === true"
(buttonClick)="transferOrgOwnership()"
>
Change Password
</gt-loading-button>
} @else {
<p i18n>
This member is the organization account owner and has access to the
organization's GlitchTip subscription settings.
</p>
}
<ng-template #isOwner>
<p i18n>
This member is the organization account owner and has access to the
organization's GlitchTip subscription settings.
</p>
</ng-template>
</mat-card-content>
</mat-card>
@if (memberTeams(); as memberTeams) {
<mat-card>
<mat-card-header>
<mat-card-title i18n>Team(s)</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
@if (memberTeams.length === 0) {
<p class="center-text" i18n>This user isn't on any teams.</p>
} @else {
<mat-list>
@for (team of memberTeams; track team) {
<mat-list-item>
<a
mat-line
[routerLink]="['teams', team, 'members']"
[relativeTo]="this.route.parent?.parent"
>{{ team }}</a
</mat-card-content>
</mat-card>
<mat-card>
<mat-card-header>
<mat-card-title i18n>Role</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
@if (updateMemberError(); as error) {
<mat-error>{{ error }}</mat-error>
}
<div class="role-form-elements">
<mat-radio-group formControlName="role">
<mat-radio-button value="member">Member</mat-radio-button>
<mat-radio-button value="admin">Admin</mat-radio-button>
<mat-radio-button value="manager">Manager</mat-radio-button>
<mat-radio-button value="owner">Owner</mat-radio-button>
</mat-radio-group>
<div class="role-button-container">
<gt-loading-button
[disabled]="member.role === formRole.value"
[loading]="updateMemberLoading() === true"
buttonText="Update Member"
></gt-loading-button>
</div>
</div>
</form>
<div class="role-description">
@if (formRole.value === "member") {
<span i18n
>Members can view and act on events, as well as view most other
data within the organization.</span
>
}
@if (formRole.value === "admin") {
<span i18n
>Admin privileges on any teams of which user is a member. They can
create new teams and projects, as well as delete teams and
projects they are already members of. Additionally, they can
manage memberships of teams that they are members of.</span
>
}
@if (formRole.value === "manager") {
<span i18n
>Gains admin access on all teams as well as the ability to add and
remove members.</span
>
}
@if (formRole.value === "owner") {
<span i18n
>Unrestricted access to the organization, its data, and its
settings. Can add, modify, and delete projects and members.</span
>
}
</div>
<div class="role-scopes">
@if (selectedRoleScopes(); as scopes) {
<div class="mat-body-strong" i18n>Scopes:</div>
<div>{{ scopes }}</div>
}
</div>
</mat-card-content>
</mat-card>
<mat-card>
<mat-card-header>
<mat-card-title i18n>Organization Account Ownership</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
@if (!member.isOwner) {
@if (transferOrgOwnershipError(); as error) {
<mat-error>{{ error }}</mat-error>
}
<p i18n>
The account owner has access to the organization's GlitchTip
subscription settings. If you are the current account owner or have
an owner role, you may transfer ownership to this member.
</p>
<gt-loading-button
buttonText="Transfer Ownership"
[loading]="transferOrgOwnershipLoading() === true"
(buttonClick)="transferOrgOwnership()"
>
</mat-list-item>
Change Password
</gt-loading-button>
} @else {
<p i18n>
This member is the organization account owner and has access to the
organization's GlitchTip subscription settings.
</p>
}
</mat-list>
}
</mat-card-content>
</mat-card>
}
</section>
<ng-template #isOwner>
<p i18n>
This member is the organization account owner and has access to the
organization's GlitchTip subscription settings.
</p>
</ng-template>
</mat-card-content>
</mat-card>
@if (memberTeams(); as memberTeams) {
<mat-card>
<mat-card-header>
<mat-card-title i18n>Team(s)</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
@if (memberTeams.length === 0) {
<p class="center-text" i18n>This user isn't on any teams.</p>
} @else {
<mat-list>
@for (team of memberTeams; track team) {
<mat-list-item>
<a
mat-line
[routerLink]="['teams', team, 'members']"
[relativeTo]="this.route.parent?.parent"
>{{ team }}</a
>
</mat-list-item>
}
</mat-list>
}
</mat-card-content>
</mat-card>
}
</section>
}
@@ -61,7 +61,7 @@ export class MemberDetailComponent implements OnInit, OnDestroy {
this.memberDetailService.transferOrgOwnershipLoading;
orgSlug$ = this.route.paramMap.pipe(map((params) => params.get("org-slug")));
memberIdParam$ = this.route.paramMap.pipe(
map((params) => params.get("member-id"))
map((params) => params.get("member-id")),
);
routeParams$ = combineLatest([this.orgSlug$, this.memberIdParam$]);
form = new FormGroup({
@@ -98,10 +98,10 @@ export class MemberDetailComponent implements OnInit, OnDestroy {
if (organizationSlug && memberIdParam) {
this.memberDetailService.retrieveMemberDetail(
organizationSlug,
+memberIdParam
+memberIdParam,
);
}
})
}),
)
.subscribe();
}
@@ -43,16 +43,16 @@ export class MemberDetailService extends StatefulService<MemberDetailState> {
readonly memberTeams = computed(() => this.state().memberTeams);
readonly availableRoles = computed(() => this.state().availableRoles);
readonly updateMemberRoleError = computed(
() => this.state().updateMemberRoleError
() => this.state().updateMemberRoleError,
);
readonly updateMemberRoleLoading = computed(
() => this.state().updateMemberRoleLoading
() => this.state().updateMemberRoleLoading,
);
readonly transferOrgOwnershipError = computed(
() => this.state().transferOrgOwnershipError
() => this.state().transferOrgOwnershipError,
);
readonly transferOrgOwnershipLoading = computed(
() => this.state().transferOrgOwnershipLoading
() => this.state().transferOrgOwnershipLoading,
);
constructor() {
@@ -77,15 +77,15 @@ export class MemberDetailService extends StatefulService<MemberDetailState> {
tap((resp) => {
this.setUpdateMemberRole(resp);
this.snackBar.open(
`Successfully updated ${resp.email}'s role to ${resp.roleName}`
`Successfully updated ${resp.email}'s role to ${resp.roleName}`,
);
}),
catchError((error: HttpErrorResponse) => {
this.setUpdateMemberRoleError(
`${error.statusText}: ${error.status}`
`${error.statusText}: ${error.status}`,
);
return EMPTY;
})
}),
)
.toPromise();
} else {
@@ -105,7 +105,7 @@ export class MemberDetailService extends StatefulService<MemberDetailState> {
.pipe(
tap((resp) => {
this.snackBar.open(
`Successfully transferred organization account ownership to ${resp.email}.`
`Successfully transferred organization account ownership to ${resp.email}.`,
);
this.setTransferOrgOwnership(resp);
}),
@@ -116,11 +116,11 @@ export class MemberDetailService extends StatefulService<MemberDetailState> {
this.setTransferOrgOwnershipError(err.error?.message);
} else {
this.setTransferOrgOwnershipError(
"Unable to transfer account ownership."
"Unable to transfer account ownership.",
);
}
return EMPTY;
})
}),
)
.toPromise();
} else {
@@ -133,7 +133,7 @@ export class MemberDetailService extends StatefulService<MemberDetailState> {
return lastValueFrom(
this.membersAPIService
.retrieve(orgSlug, memberId)
.pipe(tap((memberDetail) => this.setMemberDetails(memberDetail)))
.pipe(tap((memberDetail) => this.setMemberDetails(memberDetail))),
);
}
+46 -29
View File
@@ -6,47 +6,64 @@
<mat-card>
<mat-card-header>
<mat-card-title>
{{ (activeOrganizationDetail())?.name }}
{{ activeOrganizationDetail()?.name }}
</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
<div class="list">
@for (member of members$ | async; track member) {
<div class="list--item">
<div class="email-and-badge">
<a class="email" mat-line [routerLink]="[member.id]">{{ member.email }}
</a>
@if (member.isOwner) {
<mat-chip class="mat-caption" color="primary" highlighted="true">
Account Owner
</mat-chip>
}
@if (member.pending) {
<mat-chip class="mat-caption" color="primary">
Invite Pending
</mat-chip>
}
</div>
<div class="reinvite-and-role">
@if (member.pending) {
<div class="reinvite">
@if (!member.loadingResendInvite) {
<gt-loading-button class="resend-invite" [loading]="member.loadingResendInvite" [buttonText]="
<div class="list--item">
<div class="email-and-badge">
<a class="email" mat-line [routerLink]="[member.id]"
>{{ member.email }}
</a>
@if (member.isOwner) {
<mat-chip
class="mat-caption"
color="primary"
highlighted="true"
>
Account Owner
</mat-chip>
}
@if (member.pending) {
<mat-chip class="mat-caption" color="primary">
Invite Pending
</mat-chip>
}
</div>
<div class="reinvite-and-role">
@if (member.pending) {
<div class="reinvite">
@if (!member.loadingResendInvite) {
<gt-loading-button
class="resend-invite"
[loading]="member.loadingResendInvite"
[buttonText]="
member.sentResendInvite
? 'Invite Resent'
: 'Resend Invite'
" [disabled]="member.sentResendInvite" (buttonClick)="resendInvite(member)">
</gt-loading-button>
"
[disabled]="member.sentResendInvite"
(buttonClick)="resendInvite(member)"
>
</gt-loading-button>
}
</div>
}
<div class="role">{{ member.roleName }}</div>
</div>
}
<div class="role">{{ member.roleName }}</div>
<gt-loading-button
class="remove-button"
matTooltip="Account ownership must be transferred."
[matTooltipDisabled]="!member.isOwner"
[disabled]="member.isOwner"
(buttonClick)="removeMember(member)"
[buttonText]="member.isMe ? 'Leave' : 'Remove'"
buttonStyle="stroked"
></gt-loading-button>
</div>
<gt-loading-button class="remove-button" matTooltip="Account ownership must be transferred."
[matTooltipDisabled]="!member.isOwner" [disabled]="member.isOwner" (buttonClick)="removeMember(member)"
[buttonText]="member.isMe ? 'Leave' : 'Remove'" buttonStyle="stroked"></gt-loading-button>
</div>
}
</div>
</mat-card-content>
@@ -49,7 +49,7 @@ export class MembersComponent implements OnInit {
this.route.params
.pipe(
map((params) => params["org-slug"] as string),
filter((slug) => !!slug)
filter((slug) => !!slug),
)
.subscribe((slug) => {
this.organizationDetailService.retrieveOrganizationMembers(slug);
@@ -6,32 +6,35 @@
<mat-divider></mat-divider>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
@if ((errors()).addOrganizationMember; as error) {
<mat-error class="server-error">{{ error }}</mat-error>
@if (errors().addOrganizationMember; as error) {
<mat-error class="server-error">{{ error }}</mat-error>
}
<div>
<h2>Email(s)</h2>
<p class="email-paragraph">
Invite a member to join this organization via their email address.
@if (enableUserRegistration()) {
<span
>If they do not already have an account, they will first be asked
to create one.</span
>
} @else { Only existing GlitchTip users may be invited. } Multiple
emails delimited by commas.
<span
>If they do not already have an account, they will first be
asked to create one.</span
>
} @else {
Only existing GlitchTip users may be invited.
}
Multiple emails delimited by commas.
</p>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput type="email" formControlName="email" />
@if (form.get("email")?.errors) {
<mat-error>
@if (form.get("email")?.errors?.invalidEmails) {
<span> Enter a valid email</span>
} @if (form.get("email")?.errors?.required) {
<span>Enter at least one email</span>
}
</mat-error>
<mat-error>
@if (form.get("email")?.errors?.invalidEmails) {
<span> Enter a valid email</span>
}
@if (form.get("email")?.errors?.required) {
<span>Enter at least one email</span>
}
</mat-error>
}
</mat-form-field>
</div>
@@ -45,27 +48,31 @@
</mat-radio-group>
<div class="role-description">
@if (formRole.value === "member") {
<span i18n
>Members can view and act on events, as well as view most other
data within the organization.</span
>
} @if (formRole.value === "admin") {
<span i18n
>Admin privileges on any teams of which user is a member. They can
create new teams and projects, as well as delete teams and
projects they are already members of. Additionally, they can
manage memberships of teams that they are members of.</span
>
} @if (formRole.value === "manager") {
<span i18n
>Gains admin access on all teams as well as the ability to add and
remove members.</span
>
} @if (formRole.value === "owner") {
<span i18n
>Unrestricted access to the organization, its data, and its
settings. Can add, modify, and delete projects and members.</span
>
<span i18n
>Members can view and act on events, as well as view most other
data within the organization.</span
>
}
@if (formRole.value === "admin") {
<span i18n
>Admin privileges on any teams of which user is a member. They
can create new teams and projects, as well as delete teams and
projects they are already members of. Additionally, they can
manage memberships of teams that they are members of.</span
>
}
@if (formRole.value === "manager") {
<span i18n
>Gains admin access on all teams as well as the ability to add
and remove members.</span
>
}
@if (formRole.value === "owner") {
<span i18n
>Unrestricted access to the organization, its data, and its
settings. Can add, modify, and delete projects and
members.</span
>
}
</div>
</div>
@@ -74,13 +81,14 @@
<mat-form-field appearance="outline">
<mat-label>Add Team</mat-label>
<mat-select multiple formControlName="teams" disableOptionCentering>
@if ((organizationTeams()).length) { @for (team of
organizationTeams(); track team.id) {
<mat-option [value]="team.slug">
{{ team.slug }}
</mat-option>
} } @else {
<mat-option disabled>No Teams</mat-option>
@if (organizationTeams().length) {
@for (team of organizationTeams(); track team.id) {
<mat-option [value]="team.slug">
{{ team.slug }}
</mat-option>
}
} @else {
<mat-option disabled>No Teams</mat-option>
}
</mat-select>
</mat-form-field>
@@ -28,7 +28,7 @@ function validateEmails(emails: string) {
.map((email) =>
Validators.email({
value: email.replace(/\s/g, ""),
} as AbstractControl)
} as AbstractControl),
)
.find((email) => email !== null) === undefined
);
@@ -100,7 +100,7 @@ export class NewMemberComponent implements OnInit, OnDestroy {
this.organizationsService.inviteOrganizationMembers(
email.replace(/\s/g, ""),
teams!,
role! as any
role! as any,
);
});
}
@@ -3,45 +3,55 @@
<h1 class="mat-headline-4">General Settings</h1>
</header>
@if (activeOrganizationDetail(); as activeOrg) {
<mat-card>
<mat-card-header>
<mat-card-title>General</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="updateOrganization()">
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input matInput formControlName="name" [value]="activeOrg.name" />
@if (updateError) {
<mat-error>{{ updateError }}</mat-error>
<mat-card>
<mat-card-header>
<mat-card-title>General</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="updateOrganization()">
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input matInput formControlName="name" [value]="activeOrg.name" />
@if (updateError) {
<mat-error>{{ updateError }}</mat-error>
}
</mat-form-field>
<gt-loading-button
[disabled]="name?.value === activeOrg.name"
mat-flat-button
color="primary"
buttonText="Update Name"
id="update-org"
>
</gt-loading-button>
</form>
</mat-card-content>
</mat-card>
<mat-card>
<mat-card-header>
<mat-card-title>Delete Organization</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content class="remove-content">
<div>
<p>
By deleting this organization, you will lose all data including
projects and their associated events.
</p>
@if (deleteError) {
<mat-error>{{ deleteError }}</mat-error>
}
</mat-form-field>
<gt-loading-button [disabled]="name?.value === activeOrg.name" mat-flat-button color="primary"
buttonText="Update Name" id="update-org">
</div>
<gt-loading-button
data-test
buttonText="Delete Organization"
[loading]="deleteLoading"
(click)="removeOrganization(activeOrg.slug, activeOrg.name)"
id="delete-org"
>
</gt-loading-button>
</form>
</mat-card-content>
</mat-card>
<mat-card>
<mat-card-header>
<mat-card-title>Delete Organization</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content class="remove-content">
<div>
<p>
By deleting this organization, you will lose all data including
projects and their associated events.
</p>
@if (deleteError) {
<mat-error>{{ deleteError }}</mat-error>
}
</div>
<gt-loading-button data-test buttonText="Delete Organization" [loading]="deleteLoading"
(click)="removeOrganization(activeOrg.slug, activeOrg.name)" id="delete-org">
</gt-loading-button>
</mat-card-content>
</mat-card>
</mat-card-content>
</mat-card>
}
</section>
@@ -51,11 +51,11 @@ export class OrganizationComponent implements OnInit {
// this.organizationsService.retrieveOrganizations().toPromise();
this.organizationsService.refreshActiveOrganization();
}
})
}),
)
.toPromise();
this.activeOrganizationDetail$.subscribe((data) =>
data ? this.form.patchValue({ name: data.name }) : undefined
data ? this.form.patchValue({ name: data.name }) : undefined,
);
}
@@ -71,20 +71,20 @@ export class OrganizationComponent implements OnInit {
(org: Organization) => {
this.updateLoading = false;
this.snackBar.open(
`The name of your organization has been updated to ${org.name}`
`The name of your organization has been updated to ${org.name}`,
);
},
(err) => {
this.updateLoading = false;
this.updateError = `${err.statusText}: ${err.status}`;
}
},
);
}
removeOrganization(slug: string, name: string) {
if (
window.confirm(
`Are you sure you want to remove ${name}? You will permanently lose all projects and teams associated with it.`
`Are you sure you want to remove ${name}? You will permanently lose all projects and teams associated with it.`,
)
) {
this.deleteLoading = true;
@@ -92,7 +92,7 @@ export class OrganizationComponent implements OnInit {
if (result.response.status === 204) {
this.deleteLoading = false;
this.snackBar.open(
`You have successfully deleted ${name} from your organizations`
`You have successfully deleted ${name} from your organizations`,
);
} else {
this.deleteLoading = false;
@@ -81,7 +81,6 @@
buttonText="Create Project"
>
</gt-loading-button>
</form>
</mat-card-content
</form> </mat-card-content
></mat-card>
</section>
@@ -88,7 +88,7 @@ export class NewProjectComponent implements OnInit {
.pipe(
map((params) => params["org-slug"] as string),
filter((slug) => !!slug),
tap((slug) => (this.orgSlug = slug))
tap((slug) => (this.orgSlug = slug)),
)
.subscribe((slug) => {
this.teamsService.retrieveTeamsByOrg(slug).toPromise();
@@ -114,7 +114,7 @@ export class NewProjectComponent implements OnInit {
platform: this.form.value.platform ?? "",
},
this.form.value.team!,
this.orgSlug
this.orgSlug,
)
.pipe(
tap((project) => {
@@ -125,14 +125,14 @@ export class NewProjectComponent implements OnInit {
[this.orgService.activeOrganizationSlug(), "issues"],
{
queryParams: { project: project.id },
}
},
);
}),
catchError((err) => {
this.loading = false;
this.error = `${err.statusText}: ${err.status}`;
return EMPTY;
})
}),
)
.toPromise();
}
@@ -5,7 +5,7 @@ import {
ViewChild,
ElementRef,
HostListener,
input
input,
} from "@angular/core";
import {
MatExpansionPanel,
@@ -64,7 +64,7 @@ export class AlertFormComponent implements OnInit {
timespanMinutes: number;
quantity: number;
uptime: boolean;
}>();
}>();
readonly newAlert = input<boolean | undefined>(false);
timesI18nMapping = {
@@ -1,4 +1,9 @@
import { Component, OnInit, ChangeDetectionStrategy, inject } from "@angular/core";
import {
Component,
OnInit,
ChangeDetectionStrategy,
inject,
} from "@angular/core";
import { MatDialogRef, MatDialogModule } from "@angular/material/dialog";
import {
FormGroup,
@@ -90,7 +90,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
readonly initialLoad$ = this.getState$.pipe(map((data) => data.initialLoad));
readonly initialLoadError$ = this.getState$.pipe(
map((data) => data.initialLoadError)
map((data) => data.initialLoadError),
);
readonly projectAlerts$ = this.getState$.pipe(
map((data) => data.projectAlerts),
@@ -101,33 +101,33 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
...alert,
errorAlert: !alert.timespanMinutes && !alert.quantity ? false : true,
};
})
)
}),
),
);
/** New Alert */
readonly newAlertOpen$ = this.getState$.pipe(
map((data) => data.newAlertState.newAlertOpen)
map((data) => data.newAlertState.newAlertOpen),
);
readonly newProjectAlertRecipients$ = this.getState$.pipe(
map((data) => data.newAlertState.newProjectAlertRecipients)
map((data) => data.newAlertState.newProjectAlertRecipients),
);
readonly newAlertLoading$ = this.getState$.pipe(
map((data) => data.newAlertState.newAlertLoading)
map((data) => data.newAlertState.newAlertLoading),
);
readonly newAlertError$ = this.getState$.pipe(
map((data) => data.newAlertState.newAlertError)
map((data) => data.newAlertState.newAlertError),
);
/** Recipient Dialog */
readonly recipientError$ = this.getState$.pipe(
map((data) => data.recipientDialogState.recipientError)
map((data) => data.recipientDialogState.recipientError),
);
readonly recipientDialogOpen$ = this.getState$.pipe(
map((data) => data.recipientDialogState.recipientDialogOpen)
map((data) => data.recipientDialogState.recipientDialogOpen),
);
readonly activeAlert$ = this.getState$.pipe(
map((data) => data.recipientDialogState.activeAlert)
map((data) => data.recipientDialogState.activeAlert),
);
readonly emailSelected$ = combineLatest([
this.newProjectAlertRecipients$,
@@ -136,33 +136,33 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
map(([newRecipients, activeAlert]) => {
if (activeAlert?.id) {
return activeAlert.alertRecipients.some(
(data) => data.recipientType === "email"
(data) => data.recipientType === "email",
);
} else if (newRecipients !== null) {
return newRecipients.some((data) => data.recipientType === "email");
}
return;
})
}),
);
/** Current Alerts */
readonly removeAlertLoading$ = this.getState$.pipe(
map((data) => data.removeAlertLoading)
map((data) => data.removeAlertLoading),
);
readonly removeAlertError$ = this.getState$.pipe(
map((data) => data.removeAlertError)
map((data) => data.removeAlertError),
);
readonly updatePropertiesLoading$ = this.getState$.pipe(
map((data) => data.updatePropertiesLoading)
map((data) => data.updatePropertiesLoading),
);
readonly updatePropertiesError$ = this.getState$.pipe(
map((data) => data.updatePropertiesError)
map((data) => data.updatePropertiesError),
);
readonly deleteRecipientLoading$ = this.getState$.pipe(
map((data) => data.deleteRecipientLoading)
map((data) => data.deleteRecipientLoading),
);
readonly deleteRecipientError$ = this.getState$.pipe(
map((data) => data.deleteRecipientError)
map((data) => data.deleteRecipientError),
);
constructor() {
@@ -186,11 +186,11 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
catchError((err: HttpErrorResponse) => {
this.setProjectAlertsListError(err);
return EMPTY;
})
}),
);
}
return EMPTY;
})
}),
)
.subscribe();
}
@@ -219,7 +219,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
this.updateAlertRecipient(event);
}
return EMPTY;
})
}),
)
.subscribe();
}
@@ -255,7 +255,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
tap((resp) => {
this.setCreateAlert(resp);
this.snackBar.open(`Success! Your new alert has been added.`);
})
}),
);
}
return EMPTY;
@@ -263,7 +263,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
catchError((err: HttpErrorResponse) => {
this.setCreateAlertError(err);
return EMPTY;
})
}),
)
.subscribe();
}
@@ -285,7 +285,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
tap((_) => {
this.setDeleteProjectAlert(id);
this.snackBar.open(`Success: Your alert has been deleted`);
})
}),
);
}
return EMPTY;
@@ -293,7 +293,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
catchError((err: HttpErrorResponse) => {
this.setDeleteAlertError(err, id);
return EMPTY;
})
}),
)
.subscribe();
}
@@ -303,7 +303,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
newQuantity: number,
uptime: boolean,
id: number,
recipients: AlertRecipient[]
recipients: AlertRecipient[],
) {
this.setUpdatePropertiesLoading(id);
const data: PartialProjectAlert = {
@@ -327,7 +327,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
tap((resp) => {
this.setUpdateAlertProperties(resp);
this.snackBar.open(`Success: Your alert has been updated`);
})
}),
);
}
return EMPTY;
@@ -335,7 +335,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
catchError((err: HttpErrorResponse) => {
this.setUpdatePropertiesError(err, id);
return EMPTY;
})
}),
)
.subscribe();
}
@@ -373,7 +373,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
tap((resp) => {
this.setUpdateAlertRecipients(resp.alertRecipients, resp.id);
this.snackBar.open(`Success: Your alert has been updated`);
})
}),
);
}
return EMPTY;
@@ -381,7 +381,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
catchError((err: HttpErrorResponse) => {
this.setUpdateAlertRecipientsError(err, activeErrorId);
return EMPTY;
})
}),
)
.subscribe();
}
@@ -391,7 +391,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
const data = {
...alert,
alertRecipients: alert.alertRecipients.filter(
(currentRecipient) => currentRecipient.id !== recipientToRemove.id
(currentRecipient) => currentRecipient.id !== recipientToRemove.id,
),
};
combineLatest([
@@ -408,9 +408,9 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
tap((resp) => {
this.setUpdateAlertRecipients(resp.alertRecipients, resp.id);
this.snackBar.open(
`Success: Your recipient has been deleted`
`Success: Your recipient has been deleted`,
);
})
}),
);
}
return EMPTY;
@@ -418,7 +418,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
catchError((err: HttpErrorResponse) => {
this.setDeleteRecipientError(err);
return EMPTY;
})
}),
)
.subscribe();
}
@@ -500,7 +500,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
...newAlertState,
newProjectAlertRecipients:
newAlertState.newProjectAlertRecipients?.filter(
(recipient) => recipient.url !== url
(recipient) => recipient.url !== url,
) ?? null,
},
});
@@ -608,7 +608,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
this.setState({
projectAlerts: this.findAndReplaceAlert(
state.projectAlerts,
updatedAlert
updatedAlert,
),
updatePropertiesLoading: null,
updatePropertiesError: null,
@@ -649,7 +649,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
const recipientDialogState = this.state.getValue().recipientDialogState;
this.setState({
projectAlerts: state.projectAlerts?.map((alert) =>
alert.id === id ? { ...alert, alertRecipients: recipients } : alert
alert.id === id ? { ...alert, alertRecipients: recipients } : alert,
),
recipientDialogState: {
...recipientDialogState,
@@ -688,7 +688,7 @@ export class ProjectAlertsService extends StatefulService<ProjectAlertState> {
findAndReplaceAlert(
currentAlerts: ProjectAlert[] | null,
newAlert: ProjectAlert
newAlert: ProjectAlert,
): ProjectAlert[] | null {
const updatedAlert = currentAlerts?.map((alert) => {
if (alert.id === newAlert.id) {
@@ -101,7 +101,7 @@ export class ProjectDetailComponent implements OnInit, OnDestroy {
this.orgSlug = orgSlug;
this.projectSlug = projectSlug;
return { orgSlug, projectSlug };
})
}),
)
.subscribe(({ orgSlug, projectSlug }) => {
if (orgSlug && projectSlug) {
@@ -120,7 +120,7 @@ export class ProjectDetailComponent implements OnInit, OnDestroy {
this.platformForm.patchValue({
platform: data!.platform,
});
})
}),
)
.subscribe();
}
@@ -144,7 +144,7 @@ export class ProjectDetailComponent implements OnInit, OnDestroy {
(err) => {
this.deleteLoading = false;
this.deleteError = `${err.statusText}: ${err.status}`;
}
},
);
}
}
@@ -156,7 +156,7 @@ export class ProjectDetailComponent implements OnInit, OnDestroy {
.updateProjectName(
this.orgSlug,
this.projectSlug,
this.nameForm.value.name!
this.nameForm.value.name!,
)
.subscribe(
(resp: ProjectDetail) => {
@@ -165,12 +165,12 @@ export class ProjectDetailComponent implements OnInit, OnDestroy {
this.updateNameError = "";
}
this.snackBar.open(
`The name of your project has been updated to ${resp.name}`
`The name of your project has been updated to ${resp.name}`,
);
},
(err) => {
this.updateNameError = `${err.statusText}: ${err.status}`;
}
},
);
}
}
@@ -183,7 +183,7 @@ export class ProjectDetailComponent implements OnInit, OnDestroy {
this.orgSlug,
this.projectSlug,
this.platformForm.value.platform ?? "",
projectName
projectName,
)
.subscribe(
(resp: ProjectDetail) => {
@@ -193,14 +193,14 @@ export class ProjectDetailComponent implements OnInit, OnDestroy {
}
this.snackBar.open(
`Your project platform has been updated to ${this.getPlatformName(
resp.platform
)}.`
resp.platform,
)}.`,
);
this.platformForm.setValue({ platform: resp.platform });
},
(err) => {
this.updatePlatformError = `${err.statusText}: ${err.status}`;
}
},
);
}
}
@@ -1,4 +1,9 @@
import { Component, ChangeDetectionStrategy, OnDestroy, inject } from "@angular/core";
import {
Component,
ChangeDetectionStrategy,
OnDestroy,
inject,
} from "@angular/core";
import { ProjectEnvironment } from "src/app/api/organizations/organizations.interface";
import { ProjectEnvironmentsService } from "./project-environments.service";
import { LoadingButtonComponent } from "../../../../shared/loading-button/loading-button.component";
@@ -31,11 +31,11 @@ export class ProjectEnvironmentsService extends StatefulService<ProjectsState> {
readonly initialLoad$ = this.getState$.pipe(map((data) => data.initialLoad));
readonly toggleHiddenLoading$ = this.getState$.pipe(
map((data) => data.toggleHiddenLoading)
map((data) => data.toggleHiddenLoading),
);
readonly error$ = this.getState$.pipe(map((data) => data.error));
readonly environments$ = this.getState$.pipe(
map((data) => data.environments)
map((data) => data.environments),
);
readonly sortedEnvironments$ = this.environments$.pipe(
map((environments) => {
@@ -43,35 +43,35 @@ export class ProjectEnvironmentsService extends StatefulService<ProjectsState> {
const visible = {
heading: "Visible",
environments: environments.filter(
(environment) => environment.isHidden === false
(environment) => environment.isHidden === false,
),
};
const hidden = {
heading: "Hidden",
environments: environments.filter(
(environment) => environment.isHidden === true
(environment) => environment.isHidden === true,
),
};
const sorted = [];
if (visible.environments.length > 0) sorted.push(visible);
if (hidden.environments.length > 0) sorted.push(hidden);
return sorted;
})
}),
);
readonly visibleEnvironments$ = this.environments$.pipe(
map((environments) =>
environments
.filter((environment) => environment.isHidden === false)
.map((environment) => environment.name)
)
.map((environment) => environment.name),
),
);
readonly visibleEnvironmentsLoaded$ = this.getState$.pipe(
filter(({ initialLoad }) => initialLoad === true),
map((state) =>
state.environments
.filter((environment) => environment.isHidden === false)
.map((environment) => environment.name)
)
.map((environment) => environment.name),
),
);
constructor() {
@@ -93,12 +93,12 @@ export class ProjectEnvironmentsService extends StatefulService<ProjectsState> {
this.setState({
environments: this.sortEnvironments(environments),
initialLoad: true,
})
)
}),
),
);
}
return EMPTY;
})
}),
);
}
@@ -108,8 +108,8 @@ export class ProjectEnvironmentsService extends StatefulService<ProjectsState> {
this.setState({
environments: this.sortEnvironments(environments),
initialLoad: true,
})
)
}),
),
);
}
@@ -130,26 +130,26 @@ export class ProjectEnvironmentsService extends StatefulService<ProjectsState> {
this.setState({
environments: this.updatedEnvironments(updatedEnvironment),
toggleHiddenLoading: null,
})
)
}),
),
);
}
return EMPTY;
})
}),
);
}
private sortEnvironments(environments: ProjectEnvironment[]) {
// https://stackoverflow.com/a/17387454/
return environments.sort((a, b) =>
a.isHidden === b.isHidden ? 0 : a.isHidden ? 1 : -1
a.isHidden === b.isHidden ? 0 : a.isHidden ? 1 : -1,
);
}
private updatedEnvironments(newEnvironment: ProjectEnvironment) {
const currentEnvironments = this.state.getValue().environments;
const environmentToReplace = currentEnvironments.findIndex(
(currentEnvironment) => currentEnvironment.name === newEnvironment.name
(currentEnvironment) => currentEnvironment.name === newEnvironment.name,
);
currentEnvironments[environmentToReplace] = newEnvironment;
return this.sortEnvironments(currentEnvironments);
@@ -1,27 +1,35 @@
<section class="l-body">
@if (activeOrganization() ; as activeOrg) {
<header class="page-header">
<h1 class="mat-headline-4" i18n>Projects</h1>
<a mat-flat-button [routerLink]="['new']" color="primary" i18n>Create New Project</a>
</header>
@if (projectsForActiveOrg$ | async; as projects) {
<div>
@if (projects.length === 0) {
<gt-empty-projects [activeOrgOnly]="true"></gt-empty-projects>
@if (activeOrganization(); as activeOrg) {
<header class="page-header">
<h1 class="mat-headline-4" i18n>Projects</h1>
<a mat-flat-button [routerLink]="['new']" color="primary" i18n
>Create New Project</a
>
</header>
@if (projectsForActiveOrg$ | async; as projects) {
<div>
@if (projects.length === 0) {
<gt-empty-projects [activeOrgOnly]="true"></gt-empty-projects>
}
</div>
}
</div>
}
<div class="project-card-wrapper">
@for (project of projectsForActiveOrg$ | async; track project) {
<gt-project-card [cardLink]="project.slug" [title]="project.name" [descriptionList]="[
<div class="project-card-wrapper">
@for (project of projectsForActiveOrg$ | async; track project) {
<gt-project-card
[cardLink]="project.slug"
[title]="project.name"
[descriptionList]="[
{ key: 'organization', value: activeOrg.name },
{ key: 'date created', value: (project.dateCreated | date)! },
]" [isMember]="project.isMember" [secondaryButton]="{
]"
[isMember]="project.isMember"
[secondaryButton]="{
link: project.slug,
icon: 'settings',
text: 'Settings',
}"></gt-project-card>
}
</div>
}"
></gt-project-card>
}
</div>
}
</section>
@@ -1,4 +1,10 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, inject } from "@angular/core";
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
inject,
} from "@angular/core";
import { Subscription } from "rxjs";
import { distinct, filter, tap } from "rxjs/operators";
import { ProjectSettingsService } from "./project-settings.service";
@@ -34,7 +40,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
.pipe(
distinct(),
filter((slug) => !!slug),
tap((slug) => this.projectSettingsService.retrieveProjects(slug!))
tap((slug) => this.projectSettingsService.retrieveProjects(slug!)),
)
.subscribe();
}
@@ -2,16 +2,29 @@
<mat-drawer class="mat-elevation-z2" mode="side" opened>
<nav>
<mat-nav-list>
<a mat-list-item routerLink="./" routerLinkActive="active-route" [routerLinkActiveOptions]="{ exact: true }">
<a
mat-list-item
routerLink="./"
routerLinkActive="active-route"
[routerLinkActiveOptions]="{ exact: true }"
>
General Settings
</a>
<a mat-list-item routerLink="./projects" routerLinkActive="active-route">
<a
mat-list-item
routerLink="./projects"
routerLinkActive="active-route"
>
Projects
</a>
@if (billingEnabled()) {
<a mat-list-item routerLink="./subscription" routerLinkActive="active-route">
Subscription
</a>
<a
mat-list-item
routerLink="./subscription"
routerLinkActive="active-route"
>
Subscription
</a>
}
<a mat-list-item routerLink="./teams" routerLinkActive="active-route">
Teams
@@ -24,8 +37,10 @@
</mat-drawer>
<mat-drawer-content>
@if (isLoggedIn()) {
<gt-mobile-nav-toolbar [activeOrg]="activeOrganizationDetail()"
(buttonClicked)="toggleSideNav()"></gt-mobile-nav-toolbar>
<gt-mobile-nav-toolbar
[activeOrg]="activeOrganizationDetail()"
(buttonClicked)="toggleSideNav()"
></gt-mobile-nav-toolbar>
}
<router-outlet></router-outlet>
</mat-drawer-content>
@@ -1,54 +1,56 @@
@if (products(); as products) {
<div class="plans mat-elevation-z1">
@for (product of products; track product; let i = $index) {
<mat-card>
<mat-card-content>
<mat-card-title>{{ product.name }}</mat-card-title>
<p>{{ product.description }}</p>
<ul class="value-props">
<li>
<mat-icon color="primary">check_circle</mat-icon>
<ng-container i18n>Error tracking</ng-container>
</li>
<li>
<mat-icon color="primary">check_circle</mat-icon>
<ng-container i18n>Unlimited projects</ng-container>
</li>
<li>
<mat-icon color="primary">check_circle</mat-icon>
<ng-container i18n>Unlimited team members</ng-container>
</li>
</ul>
<p>
<span class="mat-headline-4">
${{ product.defaultPrice.price | number }}
</span>
/ month
</p>
<gt-loading-button
buttonText="Select"
[loading]="subscriptionCreationLoadingId() === product.defaultPrice.stripeID"
(buttonClick)="onSubmit(product.defaultPrice)"
></gt-loading-button>
</mat-card-content>
</mat-card>
}
</div>
<div class="event-info">
<mat-card>
<mat-card-header>
<mat-card-title i18n>Events</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
<gt-event-info></gt-event-info>
</mat-card-content>
</mat-card>
</div>
<p class="mat-body-1" i18n
>Questions about subscriptions? Need assistance, or a bigger plan? Contact us
at <a href="mailto:{{ billingEmail }}">{{ billingEmail }}</a
>.</p
>
<div class="plans mat-elevation-z1">
@for (product of products; track product; let i = $index) {
<mat-card>
<mat-card-content>
<mat-card-title>{{ product.name }}</mat-card-title>
<p>{{ product.description }}</p>
<ul class="value-props">
<li>
<mat-icon color="primary">check_circle</mat-icon>
<ng-container i18n>Error tracking</ng-container>
</li>
<li>
<mat-icon color="primary">check_circle</mat-icon>
<ng-container i18n>Unlimited projects</ng-container>
</li>
<li>
<mat-icon color="primary">check_circle</mat-icon>
<ng-container i18n>Unlimited team members</ng-container>
</li>
</ul>
<p>
<span class="mat-headline-4">
${{ product.defaultPrice.price | number }}
</span>
/ month
</p>
<gt-loading-button
buttonText="Select"
[loading]="
subscriptionCreationLoadingId() === product.defaultPrice.stripeID
"
(buttonClick)="onSubmit(product.defaultPrice)"
></gt-loading-button>
</mat-card-content>
</mat-card>
}
</div>
<div class="event-info">
<mat-card>
<mat-card-header>
<mat-card-title i18n>Events</mat-card-title>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
<gt-event-info></gt-event-info>
</mat-card-content>
</mat-card>
</div>
<p class="mat-body-1" i18n
>Questions about subscriptions? Need assistance, or a bigger plan? Contact
us at <a href="mailto:{{ billingEmail }}">{{ billingEmail }}</a
>.</p
>
}
<!-- TODO: add state for user who is not owner, since they cannot sign up for an org-->
@@ -32,7 +32,7 @@ export class PaymentService extends StatefulService<PaymentState> {
stripePublicKey = this.settingsService.stripePublicKey;
readonly subscriptionCreationLoadingId = computed(
() => this.state().subscriptionCreationLoadingId
() => this.state().subscriptionCreationLoadingId,
);
productsResource = resource({
@@ -56,8 +56,8 @@ export class PaymentService extends StatefulService<PaymentState> {
: product.name,
}))
.sort(
(a, b) => (a.defaultPrice.price || 0) - (b.defaultPrice.price || 0)
) || []
(a, b) => (a.defaultPrice.price || 0) - (b.defaultPrice.price || 0),
) || [],
);
constructor() {
@@ -75,29 +75,29 @@ export class PaymentService extends StatefulService<PaymentState> {
private async createFreeSubscription(
organization: Organization,
priceId: string
priceId: string,
) {
const { data, error, response } = await client.POST(
"/api/0/stripe/subscriptions/",
{
body: { organization: organization.id, price: priceId },
}
},
);
if (response.status === 400) {
this.setSubscriptionCreationError(
"This organization already has a subscription. Please reload page for latest details."
"This organization already has a subscription. Please reload page for latest details.",
);
return null;
}
if (response.status === 404) {
this.setSubscriptionCreationError(
"Only organization owners can choose subscriptions. Make sure you are authorized to perform this action."
"Only organization owners can choose subscriptions. Make sure you are authorized to perform this action.",
);
return null;
}
if (error) {
this.setSubscriptionCreationError(
"There was an error processing your request. Please try again"
"There was an error processing your request. Please try again",
);
throw error;
}
@@ -107,7 +107,7 @@ export class PaymentService extends StatefulService<PaymentState> {
private async redirectToSubscriptionCheckout(
orgSlug: string,
priceId: string
priceId: string,
) {
const stripePublicKey = this.stripePublicKey();
const { data, error, response } = await client.POST(
@@ -115,23 +115,23 @@ export class PaymentService extends StatefulService<PaymentState> {
{
params: { path: { organization_slug: orgSlug } },
body: { price: priceId },
}
},
);
if (response.status === 404) {
this.setSubscriptionCreationError(
"Only organization owners can choose subscriptions. Make sure you are authorized to perform this action."
"Only organization owners can choose subscriptions. Make sure you are authorized to perform this action.",
);
return null;
}
if (error) {
this.setSubscriptionCreationError(
"There was an error processing your request. Please try again"
"There was an error processing your request. Please try again",
);
throw error;
}
if (stripePublicKey) {
return loadStripe(stripePublicKey).then((stripe) =>
stripe?.redirectToCheckout({ sessionId: data.id })
stripe?.redirectToCheckout({ sessionId: data.id }),
);
}
return data;
@@ -1,31 +1,41 @@
<h1 mat-dialog-title i18n>Create a New Team</h1>
<form [formGroup]="form" (ngSubmit)="onSubmit()" id="create-team-form">
<mat-dialog-content class="mat-typography">
<p class="description" i18n>Your team slug can only consist of letters, numbers, underscores and/or
hyphens.</p>
<p class="description" i18n
>Your team slug can only consist of letters, numbers, underscores and/or
hyphens.</p
>
<mat-error class="error mat-caption">
@for (error of errors; track error) {
<span>{{ error }}</span>
<span>{{ error }}</span>
}
</mat-error>
<mat-form-field appearance="outline">
<mat-label>Slug</mat-label>
<input matInput gtSlugify formControlName="slug" />
@if (slug?.errors?.required) {
<mat-error>
<span i18n>Enter a valid slug to create a team</span>
</mat-error>
<mat-error>
<span i18n>Enter a valid slug to create a team</span>
</mat-error>
}
@if (slug?.errors?.pattern) {
<mat-error>
<span i18n>Use only letters, numbers, underscores and/or hyphens</span>
</mat-error>
<mat-error>
<span i18n
>Use only letters, numbers, underscores and/or hyphens</span
>
</mat-error>
}
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions>
<gt-loading-button id="create-team-submit" buttonText="Create Team" [loading]="loading()">
<gt-loading-button
id="create-team-submit"
buttonText="Create Team"
[loading]="loading()"
>
</gt-loading-button>
<button type="button" mat-flat-button (click)="closeDialog()" i18n>Cancel</button>
<button type="button" mat-flat-button (click)="closeDialog()" i18n
>Cancel</button
>
</mat-dialog-actions>
</form>
@@ -2,50 +2,52 @@
<mat-card-header class="card-header">
<mat-card-title i18n>Members</mat-card-title>
@if ((userTeamRole$ | async) !== "member") {
<mat-form-field appearance="outline" data-cy="team-member-select">
<mat-label i18n>Add Member</mat-label>
<mat-select (selectionChange)="addTeamMember()" [formControl]="member">
@if ((filteredAddTeamMembers()).length) { @for ( orgMember of
filteredAddTeamMembers(); track orgMember ) {
<mat-option [value]="orgMember">
{{ orgMember.email }}
</mat-option>
} } @else {
<mat-option disabled i18n>No members</mat-option>
<mat-form-field appearance="outline" data-cy="team-member-select">
<mat-label i18n>Add Member</mat-label>
<mat-select (selectionChange)="addTeamMember()" [formControl]="member">
@if (filteredAddTeamMembers().length) {
@for (orgMember of filteredAddTeamMembers(); track orgMember) {
<mat-option [value]="orgMember">
{{ orgMember.email }}
</mat-option>
}
} @else {
<mat-option disabled i18n>No members</mat-option>
}
</mat-select>
@if (addMemberError) {
<mat-error>{{ addMemberError }}</mat-error>
}
</mat-select>
@if (addMemberError) {
<mat-error>{{ addMemberError }}</mat-error>
}
</mat-form-field>
</mat-form-field>
}
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
@if ((teamMembers$ | async)?.length === 0) {
<p class="center-text" i18n>This team doesn't have any members</p>
<p class="center-text" i18n>This team doesn't have any members</p>
} @else {
<ul data-test-list class="member-list">
@if (removeMemberError) {
<mat-error class="center-text">{{ removeMemberError }}</mat-error>
} @for (teamMember of teamMembers$ | async; track teamMember) {
<li class="member-list-item">
<a
mat-line
[routerLink]="['../', 'members', teamMember.id]"
[relativeTo]="route.parent?.parent"
>{{ teamMember.email }}</a
>
<gt-loading-button
buttonText="Remove"
buttonStyle="stroked"
[loading]="selectedTeamMember === teamMember.id"
(buttonClick)="removeTeamMember(teamMember.id, teamMember.email)"
id="remove-team-member"
></gt-loading-button>
</li>
}
</ul>
<ul data-test-list class="member-list">
@if (removeMemberError) {
<mat-error class="center-text">{{ removeMemberError }}</mat-error>
}
@for (teamMember of teamMembers$ | async; track teamMember) {
<li class="member-list-item">
<a
mat-line
[routerLink]="['../', 'members', teamMember.id]"
[relativeTo]="route.parent?.parent"
>{{ teamMember.email }}</a
>
<gt-loading-button
buttonText="Remove"
buttonStyle="stroked"
[loading]="selectedTeamMember === teamMember.id"
(buttonClick)="removeTeamMember(teamMember.id, teamMember.email)"
id="remove-team-member"
></gt-loading-button>
</li>
}
</ul>
}
</mat-card-content>
</mat-card>
@@ -59,7 +59,7 @@ export class TeamMembersComponent implements OnInit {
this.teamSlug = teamSlug;
this.orgSlug = orgSlug;
return { orgSlug, teamSlug };
})
}),
)
.subscribe(({ orgSlug, teamSlug }) => {
if (orgSlug && teamSlug) {
@@ -84,7 +84,7 @@ export class TeamMembersComponent implements OnInit {
(err) => {
this.loading = false;
this.addMemberError = `${err.statusText}: ${err.status}`;
}
},
);
}
@@ -96,13 +96,13 @@ export class TeamMembersComponent implements OnInit {
(resp) => {
this.selectedTeamMember = null;
this.snackBar.open(
`${memberEmail} has been removed from #${resp.slug}`
`${memberEmail} has been removed from #${resp.slug}`,
);
},
(err) => {
this.selectedTeamMember = null;
this.removeMemberError = `${err.statusText}: ${err.status}`;
}
},
);
}
}
@@ -48,7 +48,7 @@ export class TeamSettingsComponent implements OnInit {
slug: new FormControl("", [Validators.required]),
});
routeSlugs$ = this.route.paramMap.pipe(
map((params) => [params.get("org-slug"), params.get("team-slug")])
map((params) => [params.get("org-slug"), params.get("team-slug")]),
);
ngOnInit(): void {
@@ -59,7 +59,7 @@ export class TeamSettingsComponent implements OnInit {
this.teamsService.retrieveSingleTeam(orgSlug, teamSlug);
this.form.patchValue({ slug: teamSlug });
}
})
}),
)
.subscribe();
}
@@ -77,7 +77,7 @@ export class TeamSettingsComponent implements OnInit {
this.organizationsService.updateTeam(resp.id, resp.slug);
});
}
})
}),
)
.toPromise();
}
@@ -92,7 +92,7 @@ export class TeamSettingsComponent implements OnInit {
.deleteTeam(orgSlug, teamSlug)
.then(() => this.organizationsService.deleteTeam(teamSlug));
}
})
}),
)
.toPromise();
}
+76 -68
View File
@@ -22,78 +22,86 @@
<mat-divider></mat-divider>
<mat-card-content>
@if ((yourTeams$ | async)?.length) {
<ul>
@if (errors(); as error) {
<mat-error class="center-text">{{ error.removeTeamMember }}</mat-error>
} @for (yourTeam of yourTeams$ | async; track yourTeam) {
<li class="team-list-item">
<div class="team-list-item-text">
<a [routerLink]="[yourTeam.slug, 'members']">
#{{ yourTeam.slug }}
</a>
<div>
{{ yourTeam.memberCount | i18nPlural : memberCountPluralMapping }}
</div>
</div>
<gt-loading-button
buttonText="Leave Team"
buttonStyle="stroked"
[loading]="loading().removeTeamMember === yourTeam.slug"
(buttonClick)="leaveTeam(yourTeam.slug)"
id="leave-team"
></gt-loading-button>
</li>
}
</ul>
} @else { @if ((otherTeams$ | async)?.length) {
<p i18n
>You aren't part of any teams. Join a team below or press the "Create
Team" button in the upper right.</p
>
<ul>
@if (errors(); as error) {
<mat-error class="center-text">{{
error.removeTeamMember
}}</mat-error>
}
@for (yourTeam of yourTeams$ | async; track yourTeam) {
<li class="team-list-item">
<div class="team-list-item-text">
<a [routerLink]="[yourTeam.slug, 'members']">
#{{ yourTeam.slug }}
</a>
<div>
{{
yourTeam.memberCount | i18nPlural: memberCountPluralMapping
}}
</div>
</div>
<gt-loading-button
buttonText="Leave Team"
buttonStyle="stroked"
[loading]="loading().removeTeamMember === yourTeam.slug"
(buttonClick)="leaveTeam(yourTeam.slug)"
id="leave-team"
></gt-loading-button>
</li>
}
</ul>
} @else {
<p i18n
>You don't have any teams set up. Teams determine which users have
access to projects in your organization.</p
>
<p i18n
>To create a new team, press the "Create Team" button in the upper
right.</p
>
} }
@if ((otherTeams$ | async)?.length) {
<p i18n
>You aren't part of any teams. Join a team below or press the
"Create Team" button in the upper right.</p
>
} @else {
<p i18n
>You don't have any teams set up. Teams determine which users have
access to projects in your organization.</p
>
<p i18n
>To create a new team, press the "Create Team" button in the upper
right.</p
>
}
}
</mat-card-content>
</mat-card>
@if ((otherTeams$ | async)?.length) {
<mat-card>
<mat-card-title i18n>Other Teams</mat-card-title>
<mat-divider></mat-divider>
<mat-card-content>
<ul>
@if (errors(); as error) {
<mat-error class="center-text">{{ error.addTeamMember }}</mat-error>
} @for (otherTeam of otherTeams$ | async; track otherTeam) {
<li class="team-list-item">
<div class="team-list-item-text">
<a [routerLink]="[otherTeam.slug, 'members']">
#{{ otherTeam.slug }}
</a>
<div>
{{
otherTeam.memberCount | i18nPlural : memberCountPluralMapping
}}
</div>
</div>
<gt-loading-button
buttonText="Join Team"
buttonStyle="stroked"
[loading]="loading().addTeamMember === otherTeam.slug"
(buttonClick)="joinTeam(otherTeam.slug)"
id="leave-team"
></gt-loading-button>
</li>
}
</ul>
</mat-card-content>
</mat-card>
<mat-card>
<mat-card-title i18n>Other Teams</mat-card-title>
<mat-divider></mat-divider>
<mat-card-content>
<ul>
@if (errors(); as error) {
<mat-error class="center-text">{{ error.addTeamMember }}</mat-error>
}
@for (otherTeam of otherTeams$ | async; track otherTeam) {
<li class="team-list-item">
<div class="team-list-item-text">
<a [routerLink]="[otherTeam.slug, 'members']">
#{{ otherTeam.slug }}
</a>
<div>
{{
otherTeam.memberCount | i18nPlural: memberCountPluralMapping
}}
</div>
</div>
<gt-loading-button
buttonText="Join Team"
buttonStyle="stroked"
[loading]="loading().addTeamMember === otherTeam.slug"
(buttonClick)="joinTeam(otherTeam.slug)"
id="leave-team"
></gt-loading-button>
</li>
}
</ul>
</mat-card-content>
</mat-card>
}
</section>
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more