Skip to content

Commit

Permalink
Added support for showing and accessing custom helpers
Browse files Browse the repository at this point in the history
  • Loading branch information
cohansen committed Oct 10, 2023
1 parent 1201855 commit c6bdce4
Show file tree
Hide file tree
Showing 8 changed files with 255 additions and 1 deletion.
88 changes: 88 additions & 0 deletions src/components/menus/ExtensionMenu.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<svelte:options accessors={true} immutable={true} />

<script lang="ts">
import SettingsIcon from '@nasa-jpl/stellar/icons/settings.svg?component';
import { createEventDispatcher } from 'svelte';
import type { User } from '../../types/app';
import type { Extension } from '../../types/extension';
import { permissionHandler } from '../../utilities/permissionHandler';
import PlanNavButton from '../plan/PlanNavButton.svelte';
import MenuItem from './MenuItem.svelte';
export let extensions: Extension[];
export let title: string;
export let user: User | null;
const DESCRIPTION_MAX_LENGTH = 50;
const dispatch = createEventDispatcher();
function callExtension(extension: Extension) {
dispatch('callExtension', extension);
}
function formatDescription(description: string): string {
// Truncate the description at DESCRIPTION_MAX_LENGTH and add ellipsis.
if (description !== null && description.length > DESCRIPTION_MAX_LENGTH) {
return `${description.substring(0, Math.min(description.length, DESCRIPTION_MAX_LENGTH))}...`;
} else if (description?.length < DESCRIPTION_MAX_LENGTH) {
return description;
}
return '';
}
function hasExtensionPermission(extension: Extension): boolean {
for (const extensionRole of extension.extension_roles) {
if (user?.activeRole === extensionRole.role) {
return true;
}
}
return false;
}
</script>

{#if extensions.length > 0}
<div class="extension-menu st-typography-medium">
<PlanNavButton {title} menuTitle="Extensions">
<SettingsIcon />
<div class="st-typography-medium" slot="menu">
{#each extensions as extension}
<MenuItem
on:click={() => callExtension(extension)}
use={[
[
permissionHandler,
{
hasPermission: hasExtensionPermission(extension),
permissionError: 'You do not have permission to call this extension',
},
],
]}
>
<div class="extension-menu--menu-item">
{extension.label}
<span class="st-typography-label">{formatDescription(extension.description)}</span>
</div>
</MenuItem>
{/each}
</div>
</PlanNavButton>
</div>
{/if}

<style>
.extension-menu {
--aerie-menu-item-template-columns: auto;
align-items: center;
cursor: pointer;
display: grid;
height: inherit;
justify-content: center;
position: relative;
}
.extension-menu--menu-item {
display: flex;
flex-direction: column;
}
</style>
22 changes: 22 additions & 0 deletions src/routes/extensions/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import type { ExtensionResponse } from '../../types/extension';
import { reqExtension } from '../../utilities/requests';

/**
* Used to proxy requests from the UI to an external extension. This avoids any CORS errors we might
* encounter by calling a tool that may or may not be external to Aerie.
*/
export const POST: RequestHandler = async event => {
const { url, ...body } = await event.request.json();
const response = await reqExtension(url, body, event.locals.user);

if (isExtensionResponse(response)) {
return json(response);
}

return json({ message: response, success: false });
};

function isExtensionResponse(result: any): result is ExtensionResponse {
return 'message' in result && 'success' in result;
}
19 changes: 19 additions & 0 deletions src/routes/plans/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import Console from '../../../components/console/Console.svelte';
import ConsoleSection from '../../../components/console/ConsoleSection.svelte';
import ConsoleTab from '../../../components/console/ConsoleTab.svelte';
import ExtensionMenu from '../../../components/menus/ExtensionMenu.svelte';
import PlanMenu from '../../../components/menus/PlanMenu.svelte';
import ViewMenu from '../../../components/menus/ViewMenu.svelte';
import PlanMergeRequestsStatusButton from '../../../components/plan/PlanMergeRequestsStatusButton.svelte';
Expand All @@ -31,6 +32,7 @@
activityDirectivesMap,
resetActivityStores,
selectActivity,
selectedActivityDirectiveId,
} from '../../../stores/activities';
import { checkConstraintsStatus, constraintResults, resetConstraintStores } from '../../../stores/constraints';
import {
Expand Down Expand Up @@ -88,6 +90,7 @@
viewUpdateGrid,
} from '../../../stores/views';
import type { ActivityDirective } from '../../../types/activity';
import type { Extension } from '../../../types/extension';
import type { PlanSnapshot } from '../../../types/plan-snapshot';
import type { ViewSaveEvent, ViewToggleEvent } from '../../../types/view';
import effects from '../../../utilities/effects';
Expand Down Expand Up @@ -313,6 +316,16 @@
clearSnapshot();
}
async function onCallExtension(event: CustomEvent<Extension>) {
const payload = {
planId: $planId,
selectedActivityDirectiveId: $selectedActivityDirectiveId,
simulationDatasetId: $simulationDatasetId,
url: event.detail.url,
};
effects.callExtension(event.detail, payload, data.user);
}
async function onSaveView(event: CustomEvent<ViewSaveEvent>) {
const { detail } = event;
const { definition, id, name } = detail;
Expand Down Expand Up @@ -491,6 +504,12 @@
>
<CalendarIcon />
</PlanNavButton>
<ExtensionMenu
extensions={data.extensions}
title={!compactNavMode ? 'Extensions' : ''}
user={data.user}
on:callExtension={onCallExtension}
/>
<ViewMenu
hasCreatePermission={hasCreateViewPermission}
hasUpdatePermission={hasUpdateViewPermission}
Expand Down
2 changes: 2 additions & 0 deletions src/routes/plans/[id]/+page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ export const load: PageLoad = async ({ parent, params, url }) => {
const initialPlanTags = await effects.getPlanTags(initialPlan.id, user);
const initialView = await effects.getView(url.searchParams, user, initialActivityTypes, initialResourceTypes);
const initialPlanSnapshotId = getSearchParameterNumber(SearchParameters.SNAPSHOT_ID, url.searchParams);
const extensions = await effects.getExtensions(user);

return {
extensions,
initialActivityTypes,
initialPlan,
initialPlanSnapshotId,
Expand Down
28 changes: 28 additions & 0 deletions src/types/extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export type Extension = {
description: string;
extension_roles: ExtensionRole[];
id: number;
label: string;
updated_at: string;
url: string;
};

export type ExtensionPayload = {
gateway?: string;
hasura?: string;
planId: number;
selectedActivityDirectiveId: number | null;
simulationDatasetId: number | null;
};

export type ExtensionResponse = {
message: string;
success: boolean;
url: string;
};

export type ExtensionRole = {
extension_id: number;
id: number;
role: string;
};
40 changes: 39 additions & 1 deletion src/utilities/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type {
ExpansionSet,
SeqId,
} from '../types/expansion';
import type { Extension, ExtensionPayload } from '../types/extension';
import type { Model, ModelInsertInput, ModelSlim } from '../types/model';
import type { DslTypeScriptResponse, TypeScriptFile } from '../types/monaco';
import type {
Expand Down Expand Up @@ -130,7 +131,7 @@ import {
showUploadViewModal,
} from './modal';
import { queryPermissions } from './permissions';
import { reqGateway, reqHasura } from './requests';
import { reqExtension, reqGateway, reqHasura } from './requests';
import { sampleProfiles } from './resources';
import { Status } from './status';
import { getDoyTime, getDoyTimeFromInterval, getIntervalFromDoyRange } from './time';
Expand Down Expand Up @@ -225,6 +226,28 @@ const effects = {
}
},

async callExtension(
extension: Extension,
payload: ExtensionPayload & Record<'url', string>,
user: User | null,
): Promise<void> {
try {
const response = await reqExtension(`${base}/extensions`, payload, user);

if (response.success) {
showSuccessToast(response.message);
window.open(response.url, '_blank');
} else {
throw new Error(response.message);
}
} catch (error: any) {
const failureMessage = `Extension: ${extension.label} was not executed successfully`;

catchError(failureMessage, error as Error);
showFailureToast(failureMessage);
}
},

async cancelPendingSimulation(simulationDatasetId: number, user: User | null): Promise<void> {
try {
if (!queryPermissions.UPDATE_SIMULATION_DATASET(user)) {
Expand Down Expand Up @@ -2352,6 +2375,21 @@ const effects = {
}
},

async getExtensions(user: User | null): Promise<Extension[]> {
try {
const data = await reqHasura<Extension[]>(gql.GET_EXTENSIONS, {}, user);
const { extensions = [] } = data;
if (extensions != null) {
return extensions;
} else {
throw Error('Unable to retrieve extensions');
}
} catch (e) {
catchError(e as Error);
return [];
}
},

async getModels(user: User | null): Promise<ModelSlim[]> {
try {
const data = await reqHasura<ModelSlim[]>(gql.GET_MODELS, {}, user);
Expand Down
17 changes: 17 additions & 0 deletions src/utilities/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,23 @@ const gql = {
}
`,

GET_EXTENSIONS: `#graphql
query GetExtensions {
extensions {
description
extension_roles {
extension_id
id
role
}
id
label
updated_at
url
}
}
`,

GET_MODELS: `#graphql
query GetModels {
models: mission_model {
Expand Down
40 changes: 40 additions & 0 deletions src/utilities/requests.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,50 @@
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import type { BaseUser, User } from '../types/app';
import type { ExtensionPayload, ExtensionResponse } from '../types/extension';
import type { QueryVariables } from '../types/subscribable';
import { logout } from '../utilities/login';
import { INVALID_JWT } from '../utilities/permissions';

/**
* Used to make calls to application external to Aerie.
*
* @param url The external URL to call.
* @param payload The JSON payload that is serialized as the body of the request.
* @param user The user information serialized as a bearer token.
* @returns
*/
export async function reqExtension(
url: string,
payload: ExtensionPayload | (ExtensionPayload & Record<'url', string>),
user: BaseUser | User | null,
): Promise<ExtensionResponse> {
const headers: HeadersInit = {
Authorization: `Bearer ${user?.token ?? ''}`,
...{ 'Content-Type': 'application/json' },
};
const options: RequestInit = {
headers,
method: 'POST',
};

if (payload !== null) {
options.body = JSON.stringify({
...payload,
gateway: browser ? env.PUBLIC_GATEWAY_CLIENT_URL : env.PUBLIC_GATEWAY_SERVER_URL,
hasura: browser ? env.PUBLIC_HASURA_CLIENT_URL : env.PUBLIC_HASURA_SERVER_URL,
});
}

const response = await fetch(`${url}`, options);

if (!response.ok) {
throw new Error(response.statusText);
}

return await response.json();
}

/**
* Function to make HTTP requests to the Aerie Gateway.
*/
Expand Down

0 comments on commit c6bdce4

Please sign in to comment.