Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: app list from new app service #838

Merged
merged 12 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/pr-838-2148699148.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

---
"fusion-project-portal": minor
---
- Menu is filtered with portal configuration form new endpoint
- New portal class enabling hot-swapping of portal for development.
- New portal app module for listing apps in portal landeplages.
- Refactor of portal configuration.
1 change: 1 addition & 0 deletions client/packages/core/src/modules/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './portal-apps';
export * from './portal-config';
export * from './menu';
export * from './telemetry';
58 changes: 58 additions & 0 deletions client/packages/core/src/modules/portal-apps/configurator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/* eslint-disable class-methods-use-this */
import { BaseConfigBuilder, ConfigBuilderCallback, ConfigBuilderCallbackArgs } from '@equinor/fusion-framework-module';

import { IPortalAppsClient, PortalAppsClient } from './portal-apps-client';

export interface PortalAppsConfiguration {
portalConfig: PortalConfig;
client: IPortalAppsClient;
}

type PortalConfig = { portalId: string; isContextPortal: boolean };

export class PortalAppsConfigConfigurator extends BaseConfigBuilder<PortalAppsConfiguration> {
public setClient(client: IPortalAppsClient) {
this._set('client', async () => client);
}

selPortalConfig(config_or_callback: Promise<PortalConfig> | ConfigBuilderCallback<PortalConfig>) {
const cb = typeof config_or_callback === 'object' ? () => config_or_callback : config_or_callback;

this._set('portalConfig', cb);
}

/**
* Create an HTTP client based on the provided parameters.
* @param clientId - Identifier for the client.
* @param init - Configuration builder callback arguments.
* @returns An instance of the HTTP client.
*/
private async _createHttpClient(clientId: string, init: ConfigBuilderCallbackArgs) {
const http = await init.requireInstance('http');

if (http.hasClient(clientId)) {
return http.createClient(clientId);
} else {
/** load service discovery module */
const serviceDiscovery = await init.requireInstance('serviceDiscovery');
return await serviceDiscovery.createClient(clientId);
}
}

protected override async _processConfig(
config: Partial<PortalAppsConfiguration>,
_init: ConfigBuilderCallbackArgs
) {
const httpClient = await this._createHttpClient('portal-client', _init);

if (!config.portalConfig) {
throw new Error('portalConfig is required');
}

if (!config.client && config.portalConfig.portalId) {
config.client = new PortalAppsClient(httpClient, config.portalConfig.portalId);
}

return config as PortalAppsConfiguration;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { IModulesConfigurator } from '@equinor/fusion-framework-module';

import { modulePortalApps } from './module';
import { PortalAppsConfigConfigurator } from './configurator';

export type PortalAppsBuilderCallback = (builder: PortalAppsConfigConfigurator) => void | Promise<void>;

/**
* Method for enabling the portal apps module
* @param configurator - configuration object
*/
export const enablePortalApps = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
configurator: IModulesConfigurator<any, any>,
builder?: PortalAppsBuilderCallback
): void => {
configurator.addConfig({
module: modulePortalApps,
configure: (configurator) => {
builder && builder(configurator);
},
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './use-apps-favorites';
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useObservableState } from '@equinor/fusion-observable/react';
import { Observable, combineLatest, map } from 'rxjs';
import { useCallback, useEffect, useMemo } from 'react';
import { AppManifest, AppCategory } from '../types/portal-apps-types';

import { useFramework } from '@equinor/fusion-framework-react';
import { AppModule } from '@equinor/fusion-framework-module-app';
import { menuFavoritesController } from '../utils/menuFavorites';
import { getDisabledApps, getPinnedAppsKeys } from '../utils';
import { usePortalApps } from './use-portal-apps';

export const useApps = () => {
const { apps, appCategories, isLoading } = usePortalApps();
const { app } = useFramework<[AppModule]>().modules;

const favorite$ = useMemo(
() =>
combineLatest([
app?.getAppManifests({ filterByCurrentUser: true }),
menuFavoritesController.favorites$,
]).pipe(map(([apps, favorites]) => apps.filter((app) => favorites.includes(app.appKey)))),
[apps]
) as Observable<AppManifest[]>;

useEffect(() => {
const sub = menuFavoritesController.cleanFavorites();
return () => sub.unsubscribe();
}, []);

const favorites = useObservableState(favorite$).value || [];

const disabledAppKeys = useMemo(() => {
const enabledApps = (appCategories?.map((group) => group.apps) ?? []).flat();
return getDisabledApps(enabledApps, favorites ?? [])
.filter((app) => app.isDisabled)
.map((app) => app.appKey);
}, [appCategories, favorites]);

const isPinned = useCallback(
(appKey: string) => {
const enabledApps = (appCategories?.map((group) => group.apps) ?? []).flat();
return getPinnedAppsKeys(enabledApps, favorites ?? []).includes(appKey);
},
[appCategories, favorites]
);

const isDisabled = useCallback(
(key: string) => {
return disabledAppKeys.includes(key);
},
[disabledAppKeys]
);

const favoritesWithDisabled =
useMemo(() => favorites.map((p) => ({ ...p, isDisabled: isDisabled(p.appKey) })), [favorites, isDisabled]) ||
[];

const appGroupsWithPinned = useMemo(() => {
return (appCategories || []).map((group) => ({
...group,
apps: group.apps.map((app) => ({ ...app, isPinned: isPinned(app.appKey) })),
})) as AppCategory[];
}, [isPinned, appCategories]);

return {
apps,
appGroups: appGroupsWithPinned,
favorites: favoritesWithDisabled,
disabledAppKeys,
isDisabled,
hasFavorites: favorites?.length,
isLoading,
addFavorite: menuFavoritesController.onClickFavorite,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useFramework } from '@equinor/fusion-framework-react';
import { PortalApps } from '../module';

import { useObservableState } from '@equinor/fusion-observable/react';
import { useEffect, useMemo } from 'react';
import { combineLatestWith, map } from 'rxjs';

import { AppModule } from '@equinor/fusion-framework-module-app';
import { appsToAppCategory } from '../utils/appsToAppCategory';

export const usePortalApps = () => {
const { portalApps, app, context } = useFramework<[PortalApps, AppModule]>().modules;

if (!portalApps) {
const error = new Error('PortalApps module is required');
error.name = 'PortalApps Module is Missing';
throw error;
}

useEffect(() => {
if (!portalApps.isContextPortal) {
portalApps.getAppKeys();
}

const sub = context.currentContext$.subscribe((context) => {
portalApps.isContextPortal && portalApps.getAppKeys({ contextId: context?.id });
});

return () => sub.unsubscribe();
}, [portalApps, context]);

const {
value: apps,
error,
complete,
} = useObservableState(
useMemo(
() =>
portalApps.appKeys$.pipe(
combineLatestWith(app.getAppManifests({ filterByCurrentUser: true })),
map(([filter, appManifests]) => appManifests?.filter((app) => filter?.includes(app.appKey)))
),
[portalApps, app]
)
);

// Organize apps into categories using memoized the result
const appCategories = useMemo(() => {
return appsToAppCategory(apps);
}, [apps]);

return {
apps,
appCategories,
error,
isLoading: !complete && !apps,
};
};
6 changes: 6 additions & 0 deletions client/packages/core/src/modules/portal-apps/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './configurator';
export * from './provider';
export * from './module';
export { default } from './module';
export * from './enable-portal-apps-services';
export * from './hooks';
22 changes: 22 additions & 0 deletions client/packages/core/src/modules/portal-apps/module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { PortalAppsConfigConfigurator } from './configurator';
import { IPortalAppsProvider, PortalAppsProvider } from './provider';
import type { Module } from '@equinor/fusion-framework-module';

export type PortalApps = Module<'portalApps', IPortalAppsProvider, PortalAppsConfigConfigurator>;

export const modulePortalApps: PortalApps = {
name: 'portalApps',
configure: () => new PortalAppsConfigConfigurator(),
initialize: async (args): Promise<IPortalAppsProvider> => {
const config = await (args.config as PortalAppsConfigConfigurator).createConfigAsync(args);
return new PortalAppsProvider(config);
},
};

export default modulePortalApps;

declare module '@equinor/fusion-framework-module' {
interface Modules {
portalApps: PortalApps;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { HttpResponseError, IHttpClient } from '@equinor/fusion-framework-module-http';

import { Query } from '@equinor/fusion-query';
import { catchError, Observable } from 'rxjs';
import { queryValue } from '@equinor/fusion-query/operators';
import { PortalLoadError } from './utils/portal-error';

export interface IPortalAppsClient extends Disposable {
getAppKeysByContextId(args: { contextId: string }): Observable<string[]>;
getAppKeys(): Observable<string[]>;
}

export class PortalAppsClient implements IPortalAppsClient {
#appKeysQuery: Query<string[]>;

#contextAppKeysQuery: Query<string[]>;

constructor(httpClient: IHttpClient, private portalId: string) {
const expire = 1 * 60 * 1000;

this.#appKeysQuery = new Query<string[]>({
client: {
fn: async () => {
return await httpClient.json<string[]>(`/api/portals/${this.portalId}/appkeys`);
},
},
key: () => `app-keys-${this.portalId}`,
expire,
});

this.#contextAppKeysQuery = new Query<string[], { contextId: string }>({
client: {
fn: async (args) => {
return await httpClient.json<string[]>(
`/api/portals/${this.portalId}/contexts/${args.contextId}/appkeys`
);
},
},
key: (args) => `app-keys-${this.portalId}-${args.contextId}`,
expire,
});
}

getAppKeys(): Observable<string[]> {
return this.#appKeysQuery.query(null).pipe(
queryValue,
catchError((err) => {
// Extract the cause since the error will be a `QueryError`
const { cause } = err;

// Handle specific errors and throw a `PortalLoadError` if applicable
if (cause instanceof PortalLoadError) {
throw cause;
}
if (cause instanceof HttpResponseError) {
throw PortalLoadError.fromHttpResponse(cause.response, { cause });
}
// Throw a generic `PortalLoadError` for unknown errors
throw new PortalLoadError('unknown', 'failed to load portal apps', {
cause,
});
})
);
}

getAppKeysByContextId(args: { contextId: string }): Observable<string[]> {
return this.#contextAppKeysQuery.query(args).pipe(
queryValue,
catchError((err) => {
// Extract the cause since the error will be a `QueryError`
const { cause } = err;

// Handle specific errors and throw a `PortalLoadError` if applicable
if (cause instanceof PortalLoadError) {
throw cause;
}
if (cause instanceof HttpResponseError) {
throw PortalLoadError.fromHttpResponse(cause.response, { cause });
}
// Throw a generic `PortalLoadError` for unknown errors
throw new PortalLoadError('unknown', 'failed to load portal apps', {
cause,
});
})
);
}

[Symbol.dispose]() {
console.warn('PortalAppsClient disposed');
this.#appKeysQuery.complete();
this.#contextAppKeysQuery.complete();
}
}
37 changes: 37 additions & 0 deletions client/packages/core/src/modules/portal-apps/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { BehaviorSubject, Observable, firstValueFrom, from } from 'rxjs';
import { IPortalAppsClient } from './portal-apps-client';
import { PortalAppsConfiguration } from './configurator';
import { aR } from 'vitest/dist/reporters-yx5ZTtEV';

export interface IPortalAppsProvider {
getAppKeys(args?: { contextId?: string }): Promise<void>;
appKeys$: Observable<string[] | undefined>;
isContextPortal: boolean;
}

export class PortalAppsProvider implements IPortalAppsProvider {
#portalClient: IPortalAppsClient;

#appKeys$ = new BehaviorSubject<string[] | undefined>(undefined);

public isContextPortal: boolean;

get appKeys$(): Observable<string[] | undefined> {
return this.#appKeys$;
}

constructor(protected args: PortalAppsConfiguration) {
this.#portalClient = args.client;
this.isContextPortal = args.portalConfig.isContextPortal;
}

public async getAppKeys(args?: { contextId?: string }) {
if (this.args.portalConfig.isContextPortal && args?.contextId) {
this.#appKeys$.next(
await firstValueFrom(this.#portalClient.getAppKeysByContextId({ contextId: args.contextId }))
);
} else {
this.#appKeys$.next(await firstValueFrom(this.#portalClient.getAppKeys()));
}
}
}
Loading
Loading