+
-
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ }
diff --git a/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.ts b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.ts
index a724fa4ee..e5c385235 100644
--- a/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.ts
+++ b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.ts
@@ -4,35 +4,30 @@ import {
computed,
DestroyRef,
inject,
- OnInit,
signal,
} from '@angular/core';
-import {
- DeveloperApp,
- DeveloperAppFormFormControls,
- DeveloperAppForm,
-} from '@osf/features/settings/developer-apps/developer-app.entities';
import { Button } from 'primeng/button';
import { Card } from 'primeng/card';
-import { ActivatedRoute, RouterLink } from '@angular/router';
+import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { InputText } from 'primeng/inputtext';
import { IconField } from 'primeng/iconfield';
import { InputIcon } from 'primeng/inputicon';
import { CdkCopyToClipboard } from '@angular/cdk/clipboard';
import { IS_XSMALL } from '@shared/utils/breakpoints.tokens';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
-import {
- FormControl,
- FormGroup,
- FormsModule,
- ReactiveFormsModule,
- Validators,
-} from '@angular/forms';
-import { linkValidator } from '@core/helpers/link-validator.helper';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ConfirmationService } from 'primeng/api';
import { defaultConfirmationConfig } from '@shared/helpers/default-confirmation-config.helper';
-import { timer } from 'rxjs';
-import { NgClass } from '@angular/common';
+import { map, of, switchMap, timer } from 'rxjs';
+import { Store } from '@ngxs/store';
+import {
+ DeleteDeveloperApp,
+ DeveloperAppsSelectors,
+ GetDeveloperAppDetails,
+ ResetClientSecret,
+} from '@osf/features/settings/developer-apps/store';
+import { DeveloperAppAddEditFormComponent } from '@osf/features/settings/developer-apps/developer-app-add-edit-form/developer-app-add-edit-form.component';
+import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
@Component({
selector: 'osf-developer-application-details',
@@ -46,93 +41,82 @@ import { NgClass } from '@angular/common';
CdkCopyToClipboard,
FormsModule,
ReactiveFormsModule,
- NgClass,
+ DeveloperAppAddEditFormComponent,
],
templateUrl: './developer-app-details.component.html',
styleUrl: './developer-app-details.component.scss',
+ providers: [DialogService, DynamicDialogRef],
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class DeveloperAppDetailsComponent implements OnInit {
- private readonly destroyRef = inject(DestroyRef);
- private readonly activatedRoute = inject(ActivatedRoute);
- private readonly confirmationService = inject(ConfirmationService);
- private readonly isXSmall$ = inject(IS_XSMALL);
-
- isXSmall = toSignal(this.isXSmall$);
- developerAppId = signal
(null);
- developerApp = signal({
- id: '1',
- appName: 'Example name',
- projHomePageUrl: 'https://example.com',
- appDescription: 'Example description',
- authorizationCallbackUrl: 'https://example.com/callback',
- });
- isClientSecretVisible = signal(false);
- clientSecret = signal(
- 'clientsecretclientsecretclientsecretclientsecret',
- );
- hiddenClientSecret = computed(() =>
- '*'.repeat(this.clientSecret().length),
- );
- clientSecretCopiedNotificationVisible = signal(false);
+export class DeveloperAppDetailsComponent {
+ #destroyRef = inject(DestroyRef);
+ #confirmationService = inject(ConfirmationService);
+ #activatedRoute = inject(ActivatedRoute);
+ #router = inject(Router);
+ #store = inject(Store);
- clientId = signal('clientid');
- clientIdCopiedNotificationVisible = signal(false);
+ protected readonly isXSmall = toSignal(inject(IS_XSMALL));
- readonly DeveloperAppFormFormControls = DeveloperAppFormFormControls;
- readonly editAppForm: DeveloperAppForm = new FormGroup({
- [DeveloperAppFormFormControls.AppName]: new FormControl(
- this.developerApp().appName,
- {
- nonNullable: true,
- validators: [Validators.required],
- },
- ),
- [DeveloperAppFormFormControls.ProjectHomePageUrl]: new FormControl(
- this.developerApp().projHomePageUrl,
- {
- nonNullable: true,
- validators: [Validators.required, linkValidator()],
- },
- ),
- [DeveloperAppFormFormControls.AppDescription]: new FormControl(
- this.developerApp().appDescription,
- {
- nonNullable: false,
- },
- ),
- [DeveloperAppFormFormControls.AuthorizationCallbackUrl]: new FormControl(
- this.developerApp().authorizationCallbackUrl,
- {
- nonNullable: true,
- validators: [Validators.required, linkValidator()],
- },
+ readonly clientId = toSignal(
+ this.#activatedRoute.params.pipe(
+ map((params) => params['id']),
+ switchMap((clientId) => {
+ const app = this.#store.selectSnapshot(
+ DeveloperAppsSelectors.getDeveloperAppDetails,
+ )(clientId);
+ if (!app) {
+ this.#store.dispatch(new GetDeveloperAppDetails(clientId));
+ }
+ return of(clientId);
+ }),
),
+ );
+
+ readonly developerApp = computed(() => {
+ const id = this.clientId();
+ if (!id) return null;
+ const app = this.#store.selectSignal(
+ DeveloperAppsSelectors.getDeveloperAppDetails,
+ )();
+ return app(id) ?? null;
});
- ngOnInit(): void {
- this.developerAppId.set(this.activatedRoute.snapshot.params['id']);
- }
+ protected readonly isClientSecretVisible = signal(false);
+ protected readonly clientSecret = computed(
+ () => this.developerApp()?.clientSecret ?? '',
+ );
+ protected readonly hiddenClientSecret = computed(() =>
+ '*'.repeat(this.clientSecret().length),
+ );
+ protected readonly clientSecretCopiedNotificationVisible =
+ signal(false);
+ protected readonly clientIdCopiedNotificationVisible = signal(false);
deleteApp(): void {
- this.confirmationService.confirm({
+ this.#confirmationService.confirm({
...defaultConfirmationConfig,
message:
"Are you sure you want to delete this developer app? All users' access tokens will be revoked. This cannot be reversed.",
- header: `Delete App ${this.developerApp().appName}?`,
+ header: `Delete App ${this.developerApp()?.name}?`,
acceptButtonProps: {
...defaultConfirmationConfig.acceptButtonProps,
severity: 'danger',
label: 'Delete',
},
accept: () => {
- //TODO integrate API
+ this.#store
+ .dispatch(new DeleteDeveloperApp(this.clientId()))
+ .subscribe({
+ complete: () => {
+ this.#router.navigate(['settings/developer-apps']);
+ },
+ });
},
});
}
resetClientSecret(): void {
- this.confirmationService.confirm({
+ this.#confirmationService.confirm({
...defaultConfirmationConfig,
message:
'Resetting the client secret will render your application unusable until it is updated with the new client secret,' +
@@ -145,7 +129,7 @@ export class DeveloperAppDetailsComponent implements OnInit {
label: 'Reset',
},
accept: () => {
- //TODO integrate API
+ this.#store.dispatch(new ResetClientSecret(this.clientId()));
},
});
}
@@ -154,7 +138,7 @@ export class DeveloperAppDetailsComponent implements OnInit {
this.clientIdCopiedNotificationVisible.set(true);
timer(2500)
- .pipe(takeUntilDestroyed(this.destroyRef))
+ .pipe(takeUntilDestroyed(this.#destroyRef))
.subscribe(() => {
this.clientIdCopiedNotificationVisible.set(false);
});
@@ -164,28 +148,9 @@ export class DeveloperAppDetailsComponent implements OnInit {
this.clientSecretCopiedNotificationVisible.set(true);
timer(2500)
- .pipe(takeUntilDestroyed(this.destroyRef))
+ .pipe(takeUntilDestroyed(this.#destroyRef))
.subscribe(() => {
this.clientSecretCopiedNotificationVisible.set(false);
});
}
-
- submitForm(): void {
- if (!this.editAppForm.valid) {
- this.editAppForm.markAllAsTouched();
- this.editAppForm.get(DeveloperAppFormFormControls.AppName)?.markAsDirty();
- this.editAppForm
- .get(DeveloperAppFormFormControls.ProjectHomePageUrl)
- ?.markAsDirty();
- this.editAppForm
- .get(DeveloperAppFormFormControls.AppDescription)
- ?.markAsDirty();
- this.editAppForm
- .get(DeveloperAppFormFormControls.AuthorizationCallbackUrl)
- ?.markAsDirty();
- return;
- }
-
- //TODO integrate API
- }
}
diff --git a/src/app/features/settings/developer-apps/developer-app.mapper.ts b/src/app/features/settings/developer-apps/developer-app.mapper.ts
new file mode 100644
index 000000000..f1f546730
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-app.mapper.ts
@@ -0,0 +1,66 @@
+import {
+ DeveloperApp,
+ DeveloperAppCreateRequest,
+ DeveloperAppGetResponse,
+ DeveloperAppCreateUpdate,
+ DeveloperAppUpdateRequest,
+} from '@osf/features/settings/developer-apps/entities/developer-apps.models';
+
+export class DeveloperAppMapper {
+ static toCreateRequest(
+ developerCreate: DeveloperAppCreateUpdate,
+ ): DeveloperAppCreateRequest {
+ return {
+ data: {
+ attributes: {
+ name: developerCreate.name,
+ description: developerCreate.description,
+ home_url: developerCreate.projHomePageUrl,
+ callback_url: developerCreate.authorizationCallbackUrl,
+ },
+ type: 'applications',
+ },
+ };
+ }
+
+ static toUpdateRequest(
+ developerUpdate: DeveloperAppCreateUpdate,
+ ): DeveloperAppUpdateRequest {
+ return {
+ data: {
+ id: developerUpdate.id!,
+ attributes: {
+ name: developerUpdate.name,
+ description: developerUpdate.description,
+ home_url: developerUpdate.projHomePageUrl,
+ callback_url: developerUpdate.authorizationCallbackUrl,
+ },
+ type: 'applications',
+ },
+ };
+ }
+
+ static toResetSecretRequest(clientId: string) {
+ return {
+ data: {
+ id: clientId,
+ type: 'applications',
+ attributes: {
+ client_secret: null,
+ },
+ },
+ };
+ }
+
+ static fromGetResponse(response: DeveloperAppGetResponse): DeveloperApp {
+ return {
+ id: response.id,
+ name: response.attributes.name,
+ projHomePageUrl: response.attributes.home_url,
+ description: response.attributes.description,
+ authorizationCallbackUrl: response.attributes.callback_url,
+ clientId: response.attributes.client_id,
+ clientSecret: response.attributes.client_secret,
+ };
+ }
+}
diff --git a/src/app/features/settings/developer-apps/developer-apps-container.component.html b/src/app/features/settings/developer-apps/developer-apps-container.component.html
index 17a12df66..c5292d04b 100644
--- a/src/app/features/settings/developer-apps/developer-apps-container.component.html
+++ b/src/app/features/settings/developer-apps/developer-apps-container.component.html
@@ -1,5 +1,5 @@
this.#router.url === '/settings/developer-apps'),
+ ),
+ { initialValue: this.#router.url === '/settings/developer-apps' },
+ );
createDeveloperApp(): void {
let dialogWidth = '850px';
- if (this.isXSmall()) {
+ if (this.#isXSmall()) {
dialogWidth = '345px';
- } else if (this.isMedium()) {
+ } else if (this.#isMedium()) {
dialogWidth = '500px';
}
- this.dialogService.open(CreateDeveloperAppComponent, {
+ this.#dialogService.open(DeveloperAppAddEditFormComponent, {
width: dialogWidth,
focusOnShow: false,
header: 'Create Developer App',
diff --git a/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.html b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.html
index 8c19106e4..7aa2d0381 100644
--- a/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.html
+++ b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.html
@@ -8,8 +8,8 @@
@for (developerApp of developerApplications(); track $index) {
}
+
+ @if (isLoading()) {
+ @for (_ of [1, 2, 3]; track $index) {
+
+
+
+ }
+ }
diff --git a/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.ts b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.ts
index cdf7ac554..21b8bbc12 100644
--- a/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.ts
+++ b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.ts
@@ -2,60 +2,71 @@ import {
ChangeDetectionStrategy,
Component,
inject,
+ OnInit,
signal,
} from '@angular/core';
import { Button } from 'primeng/button';
import { Card } from 'primeng/card';
import { RouterLink } from '@angular/router';
-import { DeveloperApp } from '@osf/features/settings/developer-apps/developer-app.entities';
import { IS_XSMALL } from '@shared/utils/breakpoints.tokens';
import { toSignal } from '@angular/core/rxjs-interop';
import { defaultConfirmationConfig } from '@shared/helpers/default-confirmation-config.helper';
import { ConfirmationService } from 'primeng/api';
+import { Store } from '@ngxs/store';
+import {
+ DeleteDeveloperApp,
+ DeveloperAppsSelectors,
+ GetDeveloperApps,
+} from '@osf/features/settings/developer-apps/store';
+import { DeveloperApp } from '@osf/features/settings/developer-apps/entities/developer-apps.models';
+import { Skeleton } from 'primeng/skeleton';
@Component({
selector: 'osf-developer-applications-list',
- imports: [Button, Card, RouterLink],
+ imports: [Button, Card, RouterLink, Skeleton],
templateUrl: './developer-apps-list.component.html',
styleUrl: './developer-apps-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class DeveloperAppsListComponent {
- private readonly confirmationService = inject(ConfirmationService);
- #isXSmall$ = inject(IS_XSMALL);
- isXSmall = toSignal(this.#isXSmall$);
+export class DeveloperAppsListComponent implements OnInit {
+ #store = inject(Store);
+ #confirmationService = inject(ConfirmationService);
- developerApplications = signal([
- {
- id: '1',
- appName: 'Developer app name example',
- projHomePageUrl: 'https://example.com',
- appDescription: 'Example description',
- authorizationCallbackUrl: 'https://example.com/callback',
- },
- {
- id: '2',
- appName: 'Developer app name example',
- projHomePageUrl: 'https://example.com',
- appDescription: 'Example description',
- authorizationCallbackUrl: 'https://example.com/callback',
- },
- ]);
+ protected readonly isLoading = signal(false);
+ protected readonly isXSmall = toSignal(inject(IS_XSMALL));
+ readonly developerApplications = this.#store.selectSignal(
+ DeveloperAppsSelectors.getDeveloperApps,
+ );
deleteApp(developerApp: DeveloperApp): void {
- this.confirmationService.confirm({
+ this.#confirmationService.confirm({
...defaultConfirmationConfig,
message:
"Are you sure you want to delete this developer app? All users' access tokens will be revoked. This cannot be reversed.",
- header: `Delete App ${developerApp.appName}?`,
+ header: `Delete App ${developerApp.name}?`,
acceptButtonProps: {
...defaultConfirmationConfig.acceptButtonProps,
severity: 'danger',
label: 'Delete',
},
accept: () => {
- //TODO integrate API
+ this.#store.dispatch(new DeleteDeveloperApp(developerApp.clientId));
},
});
}
+
+ ngOnInit(): void {
+ if (!this.developerApplications().length) {
+ this.isLoading.set(true);
+
+ this.#store.dispatch(GetDeveloperApps).subscribe({
+ complete: () => {
+ this.isLoading.set(false);
+ },
+ error: () => {
+ this.isLoading.set(false);
+ },
+ });
+ }
+ }
}
diff --git a/src/app/features/settings/developer-apps/developer-apps.service.ts b/src/app/features/settings/developer-apps/developer-apps.service.ts
new file mode 100644
index 000000000..8097b61f9
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-apps.service.ts
@@ -0,0 +1,73 @@
+import { inject, Injectable } from '@angular/core';
+import { JsonApiService } from '@core/services/json-api/json-api.service';
+import { map, Observable } from 'rxjs';
+import { JsonApiResponse } from '@core/services/json-api/json-api.entity';
+import {
+ DeveloperApp,
+ DeveloperAppGetResponse,
+ DeveloperAppCreateUpdate,
+} from '@osf/features/settings/developer-apps/entities/developer-apps.models';
+import { DeveloperAppMapper } from '@osf/features/settings/developer-apps/developer-app.mapper';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class DeveloperApplicationsService {
+ jsonApiService = inject(JsonApiService);
+ baseUrl = 'https://api.staging4.osf.io/v2/applications/';
+
+ getApplications(): Observable {
+ return this.jsonApiService
+ .get>(this.baseUrl)
+ .pipe(
+ map((responses) => {
+ return responses.data.map((response) =>
+ DeveloperAppMapper.fromGetResponse(response),
+ );
+ }),
+ );
+ }
+
+ getApplicationDetails(clientId: string): Observable {
+ return this.jsonApiService
+ .get<
+ JsonApiResponse
+ >(this.baseUrl + clientId + '/')
+ .pipe(
+ map((response) => DeveloperAppMapper.fromGetResponse(response.data)),
+ );
+ }
+
+ createApplication(
+ developerAppCreate: DeveloperAppCreateUpdate,
+ ): Observable {
+ const request = DeveloperAppMapper.toCreateRequest(developerAppCreate);
+
+ return this.jsonApiService
+ .post(this.baseUrl, request)
+ .pipe(map((response) => DeveloperAppMapper.fromGetResponse(response)));
+ }
+
+ updateApp(
+ clientId: string,
+ developerAppUpdate: DeveloperAppCreateUpdate,
+ ): Observable {
+ const request = DeveloperAppMapper.toUpdateRequest(developerAppUpdate);
+
+ return this.jsonApiService
+ .patch(this.baseUrl + clientId + '/', request)
+ .pipe(map((response) => DeveloperAppMapper.fromGetResponse(response)));
+ }
+
+ resetClientSecret(clientId: string) {
+ const request = DeveloperAppMapper.toResetSecretRequest(clientId);
+
+ return this.jsonApiService
+ .patch(this.baseUrl + clientId + '/', request)
+ .pipe(map((response) => DeveloperAppMapper.fromGetResponse(response)));
+ }
+
+ deleteApplication(clientId: string): Observable {
+ return this.jsonApiService.delete(this.baseUrl + clientId + '/');
+ }
+}
diff --git a/src/app/features/settings/developer-apps/developer-app.entities.ts b/src/app/features/settings/developer-apps/entities/developer-app-form.entities.ts
similarity index 72%
rename from src/app/features/settings/developer-apps/developer-app.entities.ts
rename to src/app/features/settings/developer-apps/entities/developer-app-form.entities.ts
index 200a7ff5e..967725a85 100644
--- a/src/app/features/settings/developer-apps/developer-app.entities.ts
+++ b/src/app/features/settings/developer-apps/entities/developer-app-form.entities.ts
@@ -1,18 +1,10 @@
import { FormControl, FormGroup } from '@angular/forms';
import { StringOrNull } from '@core/helpers/types.helper';
-export interface DeveloperApp {
- id: string;
- appName: string;
- projHomePageUrl: string;
- appDescription: StringOrNull;
- authorizationCallbackUrl: string;
-}
-
export enum DeveloperAppFormFormControls {
- AppName = 'appName',
+ AppName = 'name',
+ AppDescription = 'description',
ProjectHomePageUrl = 'projHomePageUrl',
- AppDescription = 'appDescription',
AuthorizationCallbackUrl = 'authorizationCallbackUrl',
}
diff --git a/src/app/features/settings/developer-apps/entities/developer-apps.models.ts b/src/app/features/settings/developer-apps/entities/developer-apps.models.ts
new file mode 100644
index 000000000..bc05de7a5
--- /dev/null
+++ b/src/app/features/settings/developer-apps/entities/developer-apps.models.ts
@@ -0,0 +1,59 @@
+import { StringOrNull } from '@core/helpers/types.helper';
+
+//Domain models
+export interface DeveloperApp {
+ id: string;
+ name: string;
+ description: StringOrNull;
+ projHomePageUrl: string;
+ authorizationCallbackUrl: string;
+ clientId: string;
+ clientSecret: string;
+}
+
+export interface DeveloperAppCreateUpdate {
+ id?: string;
+ name: string;
+ description: StringOrNull;
+ projHomePageUrl: string;
+ authorizationCallbackUrl: string;
+}
+
+// API Request/Response Models
+export interface DeveloperAppCreateRequest {
+ data: {
+ type: 'applications';
+ attributes: {
+ name: string;
+ description: StringOrNull;
+ home_url: string;
+ callback_url: string;
+ };
+ };
+}
+
+export interface DeveloperAppUpdateRequest {
+ data: {
+ id: string;
+ type: 'applications';
+ attributes: {
+ name: string;
+ description: StringOrNull;
+ home_url: string;
+ callback_url: string;
+ };
+ };
+}
+
+export interface DeveloperAppGetResponse {
+ id: string;
+ type: 'applications';
+ attributes: {
+ name: string;
+ description: string;
+ home_url: string;
+ callback_url: string;
+ client_id: string;
+ client_secret: string;
+ };
+}
diff --git a/src/app/features/settings/developer-apps/store/developer-apps.actions.ts b/src/app/features/settings/developer-apps/store/developer-apps.actions.ts
new file mode 100644
index 000000000..516ec75ae
--- /dev/null
+++ b/src/app/features/settings/developer-apps/store/developer-apps.actions.ts
@@ -0,0 +1,38 @@
+import { DeveloperAppCreateUpdate } from '@osf/features/settings/developer-apps/entities/developer-apps.models';
+
+export class GetDeveloperApps {
+ static readonly type = '[Developer Apps] Get All';
+}
+
+export class GetDeveloperAppDetails {
+ static readonly type = '[Developer Apps] Get Details';
+
+ constructor(public clientId: string) {}
+}
+
+export class CreateDeveloperApp {
+ static readonly type = '[Developer Apps] Create';
+
+ constructor(public developerAppCreate: DeveloperAppCreateUpdate) {}
+}
+
+export class ResetClientSecret {
+ static readonly type = '[Developer Apps] Reset Client Secret';
+
+ constructor(public clientId: string) {}
+}
+
+export class UpdateDeveloperApp {
+ static readonly type = '[Developer Apps] Update';
+
+ constructor(
+ public clientId: string,
+ public developerAppUpdate: DeveloperAppCreateUpdate,
+ ) {}
+}
+
+export class DeleteDeveloperApp {
+ static readonly type = '[Developer Apps] Delete';
+
+ constructor(public clientId: string) {}
+}
diff --git a/src/app/features/settings/developer-apps/store/developer-apps.selectors.ts b/src/app/features/settings/developer-apps/store/developer-apps.selectors.ts
new file mode 100644
index 000000000..d06b5fe17
--- /dev/null
+++ b/src/app/features/settings/developer-apps/store/developer-apps.selectors.ts
@@ -0,0 +1,19 @@
+import { Selector } from '@ngxs/store';
+import { DeveloperAppsStateModel } from '@osf/features/settings/developer-apps/store/developer-apps.state-model';
+import { DeveloperAppsState } from '@osf/features/settings/developer-apps/store/developer-apps.state';
+import { DeveloperApp } from '@osf/features/settings/developer-apps/entities/developer-apps.models';
+
+export class DeveloperAppsSelectors {
+ @Selector([DeveloperAppsState])
+ static getDeveloperApps(state: DeveloperAppsStateModel): DeveloperApp[] {
+ return state.developerApps;
+ }
+
+ @Selector([DeveloperAppsState])
+ static getDeveloperAppDetails(
+ state: DeveloperAppsStateModel,
+ ): (clientId: string) => DeveloperApp | undefined {
+ return (clientId: string) =>
+ state.developerApps.find((app) => app.clientId === clientId);
+ }
+}
diff --git a/src/app/features/settings/developer-apps/store/developer-apps.state-model.ts b/src/app/features/settings/developer-apps/store/developer-apps.state-model.ts
new file mode 100644
index 000000000..c53e950ce
--- /dev/null
+++ b/src/app/features/settings/developer-apps/store/developer-apps.state-model.ts
@@ -0,0 +1,5 @@
+import { DeveloperApp } from '@osf/features/settings/developer-apps/entities/developer-apps.models';
+
+export interface DeveloperAppsStateModel {
+ developerApps: DeveloperApp[];
+}
diff --git a/src/app/features/settings/developer-apps/store/developer-apps.state.ts b/src/app/features/settings/developer-apps/store/developer-apps.state.ts
new file mode 100644
index 000000000..7c226ab43
--- /dev/null
+++ b/src/app/features/settings/developer-apps/store/developer-apps.state.ts
@@ -0,0 +1,143 @@
+import { inject, Injectable } from '@angular/core';
+import { State, Action, StateContext } from '@ngxs/store';
+import { tap, of } from 'rxjs';
+import { DeveloperAppsStateModel } from '@osf/features/settings/developer-apps/store/developer-apps.state-model';
+import {
+ CreateDeveloperApp,
+ DeleteDeveloperApp,
+ GetDeveloperAppDetails,
+ GetDeveloperApps,
+ ResetClientSecret,
+ UpdateDeveloperApp,
+} from './developer-apps.actions';
+import { DeveloperApplicationsService } from '@osf/features/settings/developer-apps/developer-apps.service';
+import { DeveloperApp } from '@osf/features/settings/developer-apps/entities/developer-apps.models';
+import {
+ insertItem,
+ patch,
+ removeItem,
+ updateItem,
+} from '@ngxs/store/operators';
+
+@State({
+ name: 'developerApps',
+ defaults: {
+ developerApps: [],
+ },
+})
+@Injectable()
+export class DeveloperAppsState {
+ #developerAppsService = inject(DeveloperApplicationsService);
+
+ @Action(GetDeveloperApps)
+ getDeveloperApps(ctx: StateContext) {
+ return this.#developerAppsService.getApplications().pipe(
+ tap((developerApps) => {
+ ctx.setState(patch({ developerApps }));
+ }),
+ );
+ }
+
+ @Action(GetDeveloperAppDetails)
+ getDeveloperAppDetails(
+ ctx: StateContext,
+ action: GetDeveloperAppDetails,
+ ) {
+ const state = ctx.getState();
+ const developerAppFromState = state.developerApps.find(
+ (app: DeveloperApp) => app.clientId === action.clientId,
+ );
+
+ if (developerAppFromState) {
+ return of(developerAppFromState);
+ }
+
+ return this.#developerAppsService
+ .getApplicationDetails(action.clientId)
+ .pipe(
+ tap((fetchedApp) => {
+ ctx.setState(
+ patch({
+ developerApps: insertItem(fetchedApp),
+ }),
+ );
+ }),
+ );
+ }
+
+ @Action(CreateDeveloperApp)
+ createDeveloperApp(
+ ctx: StateContext,
+ action: CreateDeveloperApp,
+ ) {
+ return this.#developerAppsService
+ .createApplication(action.developerAppCreate)
+ .pipe(
+ tap((newApp) => {
+ ctx.setState(
+ patch({
+ developerApps: insertItem(newApp, 0),
+ }),
+ );
+ }),
+ );
+ }
+
+ @Action(UpdateDeveloperApp)
+ updateDeveloperApp(
+ ctx: StateContext,
+ action: UpdateDeveloperApp,
+ ) {
+ return this.#developerAppsService
+ .updateApp(action.clientId, action.developerAppUpdate)
+ .pipe(
+ tap((updatedApp) => {
+ ctx.setState(
+ patch({
+ developerApps: updateItem(
+ (app) => app.clientId === action.clientId,
+ updatedApp,
+ ),
+ }),
+ );
+ }),
+ );
+ }
+
+ @Action(ResetClientSecret)
+ resetClientSecret(
+ ctx: StateContext,
+ action: ResetClientSecret,
+ ) {
+ return this.#developerAppsService.resetClientSecret(action.clientId).pipe(
+ tap((updatedApp) => {
+ ctx.setState(
+ patch({
+ developerApps: updateItem(
+ (app) => app.clientId === action.clientId,
+ updatedApp,
+ ),
+ }),
+ );
+ }),
+ );
+ }
+
+ @Action(DeleteDeveloperApp)
+ deleteDeveloperApp(
+ ctx: StateContext,
+ action: DeleteDeveloperApp,
+ ) {
+ return this.#developerAppsService.deleteApplication(action.clientId).pipe(
+ tap(() => {
+ ctx.setState(
+ patch({
+ developerApps: removeItem(
+ (app) => app.clientId === action.clientId,
+ ),
+ }),
+ );
+ }),
+ );
+ }
+}
diff --git a/src/app/features/settings/developer-apps/store/index.ts b/src/app/features/settings/developer-apps/store/index.ts
new file mode 100644
index 000000000..291fcbfa9
--- /dev/null
+++ b/src/app/features/settings/developer-apps/store/index.ts
@@ -0,0 +1,4 @@
+export * from './developer-apps.state';
+export * from './developer-apps.actions';
+export * from './developer-apps.state-model';
+export * from './developer-apps.selectors';
diff --git a/src/app/features/settings/tokens/token-details/token-details.component.html b/src/app/features/settings/tokens/token-details/token-details.component.html
index 26f80cae7..aef69f789 100644
--- a/src/app/features/settings/tokens/token-details/token-details.component.html
+++ b/src/app/features/settings/tokens/token-details/token-details.component.html
@@ -4,8 +4,7 @@
>
@if (token()) {
diff --git a/src/app/features/settings/tokens/tokens-list/tokens-list.component.html b/src/app/features/settings/tokens/tokens-list/tokens-list.component.html
index 477a9530b..5c43e7057 100644
--- a/src/app/features/settings/tokens/tokens-list/tokens-list.component.html
+++ b/src/app/features/settings/tokens/tokens-list/tokens-list.component.html
@@ -25,5 +25,19 @@ {{ token.name }}
}
+
+ @if (isLoading()) {
+ @for (_ of [1, 2, 3]; track $index) {
+
+
+
+ }
+ }
diff --git a/src/app/features/settings/tokens/tokens-list/tokens-list.component.ts b/src/app/features/settings/tokens/tokens-list/tokens-list.component.ts
index edcf6acfc..dead9740f 100644
--- a/src/app/features/settings/tokens/tokens-list/tokens-list.component.ts
+++ b/src/app/features/settings/tokens/tokens-list/tokens-list.component.ts
@@ -3,6 +3,7 @@ import {
Component,
inject,
OnInit,
+ signal,
} from '@angular/core';
import { ConfirmationService } from 'primeng/api';
import { IS_XSMALL } from '@shared/utils/breakpoints.tokens';
@@ -18,10 +19,11 @@ import {
GetTokens,
TokensSelectors,
} from '@osf/features/settings/tokens/store';
+import { Skeleton } from 'primeng/skeleton';
@Component({
selector: 'osf-tokens-list',
- imports: [Button, Card, RouterLink],
+ imports: [Button, Card, RouterLink, Skeleton],
templateUrl: './tokens-list.component.html',
styleUrl: './tokens-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -30,6 +32,7 @@ export class TokensListComponent implements OnInit {
#store = inject(Store);
#confirmationService = inject(ConfirmationService);
#isXSmall$ = inject(IS_XSMALL);
+ protected readonly isLoading = signal(false);
protected readonly isXSmall = toSignal(this.#isXSmall$);
tokens = this.#store.selectSignal(TokensSelectors.getTokens);
@@ -53,7 +56,15 @@ export class TokensListComponent implements OnInit {
ngOnInit(): void {
if (!this.tokens().length) {
- this.#store.dispatch(GetTokens);
+ this.isLoading.set(true);
+ this.#store.dispatch(GetTokens).subscribe({
+ complete: () => {
+ this.isLoading.set(false);
+ },
+ error: () => {
+ this.isLoading.set(false);
+ },
+ });
}
}
}