Przeglądaj źródła

feat(admin-ui): Add channel switcher

This commit adds a new ChannelSwitcherComponent which displays a channel switcher when the current Administrator has permissions on more than one Channel. Switching channel updates local client state in the Apollo cache, and also triggers a re-fetch of any active queries. Relates to #12
Michael Bromley 6 lat temu
rodzic
commit
0396e88875

+ 19 - 0
packages/admin-ui/src/app/common/generated-types.ts

@@ -1802,6 +1802,7 @@ export type Mutation = {
   setAsLoggedIn: UserStatus,
   setAsLoggedOut: UserStatus,
   setUiLanguage?: Maybe<LanguageCode>,
+  setActiveChannel: UserStatus,
 };
 
 
@@ -2167,6 +2168,11 @@ export type MutationSetUiLanguageArgs = {
   languageCode?: Maybe<LanguageCode>
 };
 
+
+export type MutationSetActiveChannelArgs = {
+  channelId: Scalars['ID']
+};
+
 export type NetworkStatus = {
   __typename?: 'NetworkStatus',
   inFlightRequests: Scalars['Int'],
@@ -3653,6 +3659,13 @@ export type GetUiStateQueryVariables = {};
 
 export type GetUiStateQuery = ({ __typename?: 'Query' } & { uiState: ({ __typename?: 'UiState' } & Pick<UiState, 'language'>) });
 
+export type SetActiveChannelMutationVariables = {
+  channelId: Scalars['ID']
+};
+
+
+export type SetActiveChannelMutation = ({ __typename?: 'Mutation' } & { setActiveChannel: ({ __typename?: 'UserStatus' } & UserStatusFragment) });
+
 export type GetCollectionFiltersQueryVariables = {};
 
 
@@ -4549,6 +4562,12 @@ export namespace GetUiState {
   export type UiState = GetUiStateQuery['uiState'];
 }
 
+export namespace SetActiveChannel {
+  export type Variables = SetActiveChannelMutationVariables;
+  export type Mutation = SetActiveChannelMutation;
+  export type SetActiveChannel = UserStatusFragment;
+}
+
 export namespace GetCollectionFilters {
   export type Variables = GetCollectionFiltersQueryVariables;
   export type Query = GetCollectionFiltersQuery;

+ 1 - 0
packages/admin-ui/src/app/core/components/app-shell/app-shell.component.html

@@ -11,6 +11,7 @@
         <div class="header-nav"></div>
         <div class="header-actions">
             <!-- <vdr-ui-language-switcher></vdr-ui-language-switcher> -->
+            <vdr-channel-switcher></vdr-channel-switcher>
             <vdr-user-menu [userName]="userName$ | async" (logOut)="logOut()"></vdr-user-menu>
         </div>
     </clr-header>

+ 18 - 0
packages/admin-ui/src/app/core/components/channel-switcher/channel-switcher.component.html

@@ -0,0 +1,18 @@
+<ng-container *ngIf="channels$ | async as channels">
+    <vdr-dropdown *ngIf="1 < channels.length">
+        <button class="btn btn-link active-channel" vdrDropdownTrigger>
+            <span class="active-channel">{{ (activeChannel$ | async)?.code }}</span>
+            <span class="trigger"><clr-icon shape="caret down"></clr-icon></span>
+        </button>
+        <vdr-dropdown-menu vdrPosition="bottom-right">
+            <button
+                *ngFor="let channel of channels"
+                type="button"
+                vdrDropdownItem
+                (click)="setActiveChannel(channel.id)"
+            >
+                {{ channel.code }}
+            </button>
+        </vdr-dropdown-menu>
+    </vdr-dropdown>
+</ng-container>

+ 16 - 0
packages/admin-ui/src/app/core/components/channel-switcher/channel-switcher.component.scss

@@ -0,0 +1,16 @@
+@import "variables";
+
+:host {
+    display: flex;
+    align-items: center;
+    margin: 0 0.5rem;
+    height: 2.5rem;
+}
+
+.active-channel {
+    color: $color-grey-200;
+
+    clr-icon {
+        color: white;
+    }
+}

+ 37 - 0
packages/admin-ui/src/app/core/components/channel-switcher/channel-switcher.component.ts

@@ -0,0 +1,37 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { Observable } from 'rxjs';
+
+import { CurrentUserChannel } from '../../../common/generated-types';
+import { DataService } from '../../../data/providers/data.service';
+import { LocalStorageService } from '../../providers/local-storage/local-storage.service';
+import set = Reflect.set;
+
+@Component({
+    selector: 'vdr-channel-switcher',
+    templateUrl: './channel-switcher.component.html',
+    styleUrls: ['./channel-switcher.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ChannelSwitcherComponent implements OnInit {
+    channels$: Observable<CurrentUserChannel[]>;
+    activeChannel$: Observable<CurrentUserChannel | null>;
+    constructor(private dataService: DataService, private localStorageService: LocalStorageService) {}
+
+    ngOnInit() {
+        this.channels$ = this.dataService.client.userStatus().mapStream(data => data.userStatus.channels);
+        this.activeChannel$ = this.dataService.client
+            .userStatus()
+            .mapStream(
+                data => data.userStatus.channels.find(c => c.id === data.userStatus.activeChannelId) || null,
+            );
+    }
+
+    setActiveChannel(channelId: string) {
+        this.dataService.client.setActiveChannel(channelId).subscribe(({ setActiveChannel }) => {
+            const activeChannel = setActiveChannel.channels.find(c => c.id === channelId);
+            if (activeChannel) {
+                this.localStorageService.set('activeChannelToken', activeChannel.token);
+            }
+        });
+    }
+}

+ 3 - 1
packages/admin-ui/src/app/core/components/user-menu/user-menu.component.scss

@@ -1,3 +1,5 @@
+@import "variables";
+
 :host {
     display: flex;
     align-items: center;
@@ -6,7 +8,7 @@
 }
 
 .user-name {
-    color: lightgrey;
+    color: $color-grey-200;
     margin-right: 12px;
 }
 

+ 2 - 0
packages/admin-ui/src/app/core/core.module.ts

@@ -6,6 +6,7 @@ import { SharedModule } from '../shared/shared.module';
 
 import { AppShellComponent } from './components/app-shell/app-shell.component';
 import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component';
+import { ChannelSwitcherComponent } from './components/channel-switcher/channel-switcher.component';
 import { JobListComponent } from './components/job-list/job-list.component';
 import { MainNavComponent } from './components/main-nav/main-nav.component';
 import { NotificationComponent } from './components/notification/notification.component';
@@ -45,6 +46,7 @@ import { OverlayHostService } from './providers/overlay-host/overlay-host.servic
         NotificationComponent,
         UiLanguageSwitcherComponent,
         JobListComponent,
+        ChannelSwitcherComponent,
     ],
     entryComponents: [NotificationComponent],
 })

+ 7 - 0
packages/admin-ui/src/app/core/providers/auth/auth.service.ts

@@ -90,6 +90,13 @@ export class AuthService {
     }
 
     private getActiveChannel(userChannels: CurrentUserFragment['channels']) {
+        const lastActiveChannelToken = this.localStorageService.get('activeChannelToken');
+        if (lastActiveChannelToken) {
+            const lastActiveChannel = userChannels.find(c => c.token === lastActiveChannelToken);
+            if (lastActiveChannel) {
+                return lastActiveChannel;
+            }
+        }
         const defaultChannel = userChannels.find(c => c.code === DEFAULT_CHANNEL_CODE);
         return defaultChannel || userChannels[0];
     }

+ 9 - 2
packages/admin-ui/src/app/core/providers/job-queue/job-queue.service.ts

@@ -1,5 +1,5 @@
 import { Injectable, OnDestroy } from '@angular/core';
-import { interval, Observable, of, Subject, Subscription, timer } from 'rxjs';
+import { EMPTY, interval, Observable, of, Subject, Subscription, timer } from 'rxjs';
 import { debounceTime, map, mapTo, scan, shareReplay, switchMap } from 'rxjs/operators';
 
 import { JobInfoFragment, JobState } from '../../../common/generated-types';
@@ -58,7 +58,14 @@ export class JobQueueService implements OnDestroy {
      */
     checkForJobs(delay: number = 1000) {
         timer(delay)
-            .pipe(switchMap(() => this.dataService.settings.getRunningJobs().single$))
+            .pipe(
+                switchMap(() =>
+                    this.dataService.client.userStatus().mapSingle(data => data.userStatus.isLoggedIn),
+                ),
+                switchMap(isLoggedIn =>
+                    isLoggedIn ? this.dataService.settings.getRunningJobs().single$ : EMPTY,
+                ),
+            )
             .subscribe(data => data.jobs.forEach(job => this.updateJob$.next(job)));
     }
 

+ 20 - 1
packages/admin-ui/src/app/data/client-state/client-resolvers.ts

@@ -6,11 +6,12 @@ import {
     GetUiState,
     GetUserStatus,
     LanguageCode,
+    SetActiveChannel,
     SetAsLoggedIn,
     SetUiLanguage,
     UserStatus,
 } from '../../common/generated-types';
-import { GET_NEWTORK_STATUS } from '../definitions/client-definitions';
+import { GET_NEWTORK_STATUS, GET_USER_STATUS } from '../definitions/client-definitions';
 
 export type ResolverContext = {
     cache: InMemoryCache;
@@ -77,6 +78,24 @@ export const clientResolvers: ResolverDefinition = {
             cache.writeData({ data });
             return args.languageCode;
         },
+        setActiveChannel: (_, args: SetActiveChannel.Variables, { cache }): UserStatus => {
+            // tslint:disable-next-line:no-non-null-assertion
+            const previous = cache.readQuery<GetUserStatus.Query>({ query: GET_USER_STATUS })!;
+            const activeChannel = previous.userStatus.channels.find(c => c.id === args.channelId);
+            if (!activeChannel) {
+                throw new Error('setActiveChannel: Could not find Channel with ID ' + args.channelId);
+            }
+            const permissions = activeChannel.permissions;
+            const data: { userStatus: Partial<UserStatus> } = {
+                userStatus: {
+                    __typename: 'UserStatus',
+                    permissions,
+                    activeChannelId: activeChannel.id,
+                },
+            };
+            cache.writeData({ data });
+            return { ...previous.userStatus, ...data.userStatus };
+        },
     },
 };
 

+ 1 - 0
packages/admin-ui/src/app/data/client-state/client-types.graphql

@@ -10,6 +10,7 @@ type Mutation {
     setAsLoggedIn(input: UserStatusInput!): UserStatus!
     setAsLoggedOut: UserStatus!
     setUiLanguage(languageCode: LanguageCode): LanguageCode
+    setActiveChannel(channelId: ID!): UserStatus!
 }
 
 type NetworkStatus {

+ 9 - 0
packages/admin-ui/src/app/data/definitions/client-definitions.ts

@@ -76,3 +76,12 @@ export const GET_UI_STATE = gql`
         }
     }
 `;
+
+export const SET_ACTIVE_CHANNEL = gql`
+    mutation SetActiveChannel($channelId: ID!) {
+        setActiveChannel(channelId: $channelId) @client {
+            ...UserStatus
+        }
+    }
+    ${USER_STATUS_FRAGMENT}
+`;

+ 35 - 4
packages/admin-ui/src/app/data/providers/base-data.service.ts

@@ -5,12 +5,13 @@ import { DataProxy } from 'apollo-cache';
 import { WatchQueryFetchPolicy } from 'apollo-client';
 import { ExecutionResult } from 'apollo-link';
 import { DocumentNode } from 'graphql/language/ast';
-import { Observable } from 'rxjs';
-import { map } from 'rxjs/operators';
+import { merge, Observable } from 'rxjs';
+import { delay, distinctUntilChanged, filter, map, skip, takeUntil } from 'rxjs/operators';
 
-import { CustomFields } from '../../common/generated-types';
+import { CustomFields, GetUserStatus } from '../../common/generated-types';
 import { LocalStorageService } from '../../core/providers/local-storage/local-storage.service';
 import { addCustomFields } from '../add-custom-fields';
+import { GET_USER_STATUS } from '../definitions/client-definitions';
 import { QueryResult } from '../query-result';
 import { ServerConfigService } from '../server-config';
 
@@ -50,7 +51,9 @@ export class BaseDataService {
             variables,
             fetchPolicy,
         });
-        return new QueryResult<T, any>(queryRef);
+        const queryResult = new QueryResult<T, any>(queryRef);
+        this.refetchOnChannelChange(queryResult);
+        return queryResult;
     }
 
     /**
@@ -70,4 +73,32 @@ export class BaseDataService {
             })
             .pipe(map(result => result.data as T));
     }
+
+    /**
+     * Causes all active queries to be refetched whenever the activeChannelId changes.
+     */
+    private refetchOnChannelChange(queryResult: QueryResult<any>) {
+        const userStatus$ = this.apollo.watchQuery<GetUserStatus.Query>({ query: GET_USER_STATUS })
+            .valueChanges;
+        const activeChannelId$ = userStatus$.pipe(
+            map(data => data.data.userStatus.activeChannelId),
+            distinctUntilChanged(),
+            skip(1),
+        );
+        const loggedOut$ = userStatus$.pipe(
+            map(data => data.data.userStatus.isLoggedIn),
+            distinctUntilChanged(),
+            skip(1),
+            filter(isLoggedIn => !isLoggedIn),
+        );
+
+        activeChannelId$
+            .pipe(
+                delay(50),
+                takeUntil(merge(queryResult.completed$, loggedOut$)),
+            )
+            .subscribe(() => {
+                queryResult.ref.refetch();
+            });
+    }
 }

+ 11 - 0
packages/admin-ui/src/app/data/providers/client-data.service.ts

@@ -6,6 +6,7 @@ import {
     LanguageCode,
     RequestCompleted,
     RequestStarted,
+    SetActiveChannel,
     SetAsLoggedIn,
     SetUiLanguage,
 } from '../../common/generated-types';
@@ -15,6 +16,7 @@ import {
     GET_USER_STATUS,
     REQUEST_COMPLETED,
     REQUEST_STARTED,
+    SET_ACTIVE_CHANNEL,
     SET_AS_LOGGED_IN,
     SET_AS_LOGGED_OUT,
     SET_UI_LANGUAGE,
@@ -72,4 +74,13 @@ export class ClientDataService {
             languageCode,
         });
     }
+
+    setActiveChannel(channelId: string) {
+        return this.baseDataService.mutate<SetActiveChannel.Mutation, SetActiveChannel.Variables>(
+            SET_ACTIVE_CHANNEL,
+            {
+                channelId,
+            },
+        );
+    }
 }

+ 12 - 2
packages/admin-ui/src/app/data/query-result.ts

@@ -1,7 +1,7 @@
 import { QueryRef } from 'apollo-angular';
 import { NetworkStatus } from 'apollo-client';
-import { Observable } from 'rxjs';
-import { filter, map, take } from 'rxjs/operators';
+import { Observable, Subject } from 'rxjs';
+import { filter, finalize, map, take } from 'rxjs/operators';
 
 /**
  * This class wraps the Apollo Angular QueryRef object and exposes some getters
@@ -10,6 +10,8 @@ import { filter, map, take } from 'rxjs/operators';
 export class QueryResult<T, V = Record<string, any>> {
     constructor(private queryRef: QueryRef<T, V>) {}
 
+    completed$ = new Subject();
+
     /**
      * Returns an Observable which emits a single result and then completes.
      */
@@ -18,6 +20,10 @@ export class QueryResult<T, V = Record<string, any>> {
             filter(result => result.networkStatus === NetworkStatus.ready),
             take(1),
             map(result => result.data),
+            finalize(() => {
+                this.completed$.next();
+                this.completed$.complete();
+            }),
         );
     }
 
@@ -28,6 +34,10 @@ export class QueryResult<T, V = Record<string, any>> {
         return this.queryRef.valueChanges.pipe(
             filter(result => result.networkStatus === NetworkStatus.ready),
             map(result => result.data),
+            finalize(() => {
+                this.completed$.next();
+                this.completed$.complete();
+            }),
         );
     }