Skip to content

Commit

Permalink
feat: show the error and function tab link in the entity card [FUS-97] (
Browse files Browse the repository at this point in the history
#1803)

* feat: show the error and function tab link in the entity card

* chore: fix unit test

* chore: make error simple red text

* test: function invocation error card

* chore: cleanup function invocation error card
  • Loading branch information
Seth-Carter authored Nov 27, 2024
1 parent 5744bed commit 2b52dfd
Show file tree
Hide file tree
Showing 7 changed files with 271 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,21 @@
"linkType": "ResourceProvider",
"id": "Resource Provider"
}
},
"organization": {
"sys": {
"type": "Link",
"linkType": "Organization",
"id": "organization-id"
}
},
"appDefinition": {
"sys": {
"type": "Link",
"linkType": "AppDefinition",
"id": "app-definition-id"
}
}
},
"name": "Resource Type"
}
}
129 changes: 126 additions & 3 deletions packages/reference/src/common/EntityStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { BaseAppSDK } from '@contentful/app-sdk';
import { FetchQueryOptions, Query, QueryKey } from '@tanstack/react-query';
import constate from 'constate';
import { PlainClientAPI, createClient, fetchAll } from 'contentful-management';
import { PlainClientAPI, ResourceProvider, createClient, fetchAll } from 'contentful-management';
import PQueue from 'p-queue';

import {
Expand Down Expand Up @@ -92,8 +92,43 @@ type EntityQueryKey = [
environmentId: string
];

type ResourceProviderQueryKey = [
ident: 'ResourceProvider',
organizationId: string,
appDefinitionId: string
];

type ScheduledActionsQueryKey = ['scheduled-actions', ...EntityQueryKey];

export type FunctionInvocationErrorResponse = {
status: number;
statusText: string;
message:
| 'Response payload of the Contentful Function is invalid'
| 'An error occurred while executing the Contentful Function code';
request: {
url: string;
headers: Record<string, string>;
method: string;
};
};

function isFunctionInvocationErrorResponse(
response: unknown
): response is FunctionInvocationErrorResponse {
const functionInvocationErrorMessages = [
'An error occurred while executing the Contentful Function code',
'Response payload of the Contentful Function is invalid',
];
return (
response !== null &&
typeof response === 'object' &&
'message' in response &&
typeof response.message === 'string' &&
functionInvocationErrorMessages.includes(response.message)
);
}

export class UnsupportedError extends Error {
isUnsupportedError: boolean;
constructor(message: string) {
Expand All @@ -108,6 +143,43 @@ export function isUnsupportedError(value: unknown): value is UnsupportedError {
);
}

export class FunctionInvocationError extends Error {
isFunctionInvocationError: boolean;
organizationId: string;
appDefinitionId: string;
constructor(message: string, organizationId: string, appDefinitionId: string) {
super(message);
this.isFunctionInvocationError = true;
this.organizationId = organizationId;
this.appDefinitionId = appDefinitionId;
}
}

export function isFunctionInvocationError(value: unknown): value is FunctionInvocationError {
return (
typeof value === 'object' &&
(value as FunctionInvocationError | null)?.isFunctionInvocationError === true
);
}

function handleResourceFetchError(
resourceFetchError: Error,
resourceTypeEntity: ResourceType
): void {
const parsedError = JSON.parse(resourceFetchError.message);
if (isFunctionInvocationErrorResponse(parsedError)) {
const organizationId = resourceTypeEntity.sys.organization?.sys.id;
const appDefinitionId = resourceTypeEntity.sys.appDefinition?.sys.id;

if (!organizationId || !appDefinitionId) throw new Error('Missing resource');

throw new FunctionInvocationError(resourceFetchError.message, organizationId, appDefinitionId);
}

// Rethrow original error if it's not a function invocation error
throw resourceFetchError;
}

const isEntityQueryKey = (queryKey: QueryKey): queryKey is EntityQueryKey => {
return (
Array.isArray(queryKey) &&
Expand Down Expand Up @@ -193,6 +265,7 @@ async function fetchExternalResource({
}: FetchParams & { spaceId: string; environmentId: string; resourceType: string }): Promise<
ResourceInfo<ExternalResource>
> {
let resourceFetchError: unknown;
const [resource, resourceTypes] = await Promise.all([
fetch(
['resource', spaceId, environmentId, resourceType, urn],
Expand All @@ -204,7 +277,19 @@ async function fetchExternalResource({
resourceTypeId: resourceType,
query: { 'sys.urn[in]': urn },
})
.then(({ items }) => items[0] ?? null),
.then(({ items }) => {
return items[0] ?? null;
})
.catch((e) => {
/*
We're storing the error in this variable
so we can use the data returned by the
resourceType CMA client call in our
error handling logic later.
*/
resourceFetchError = e;
return null;
}),
options
),
fetch(['resource-types', spaceId, environmentId], ({ cmaClient }) =>
Expand All @@ -221,6 +306,10 @@ async function fetchExternalResource({
throw new UnsupportedError('Unsupported resource type');
}

if (resourceFetchError instanceof Error) {
handleResourceFetchError(resourceFetchError, resourceTypeEntity);
}

if (!resource) {
throw new Error('Missing resource');
}
Expand Down Expand Up @@ -475,20 +564,42 @@ const [InternalServiceProvider, useFetch, useEntityLoader, useCurrentIds] = cons
onSlideInNavigation,
]);

const getResourceProvider = useCallback(
function getResourceProvider(
organizationId: string,
appDefinitionId: string
): QueryEntityResult<ResourceProvider> {
const queryKey: ResourceProviderQueryKey = [
'ResourceProvider',
organizationId,
appDefinitionId,
];
return fetch(queryKey, async ({ cmaClient }) => {
return cmaClient.resourceProvider.get({
organizationId,
appDefinitionId,
});
});
},
[fetch]
);

return {
ids: props.sdk.ids,
cmaClient,
fetch,
getResource,
getEntity,
getEntityScheduledActions,
getResourceProvider,
};
},
({ fetch }) => fetch,
({ getResource, getEntity, getEntityScheduledActions }) => ({
({ getResource, getEntity, getEntityScheduledActions, getResourceProvider }) => ({
getResource,
getEntity,
getEntityScheduledActions,
getResourceProvider,
}),
({ ids }) => ({
environment: ids.environmentAlias ?? ids.environment,
Expand Down Expand Up @@ -533,6 +644,18 @@ export function useResource<R extends Resource = Resource>(
return { status, data, error };
}

export function useResourceProvider(organizationId: string, appDefinitionId: string) {
const queryKey = ['Resource', organizationId, appDefinitionId];
const { getResourceProvider } = useEntityLoader();
const { status, data, error } = useQuery(
queryKey,
() => getResourceProvider(organizationId, appDefinitionId),
{}
);

return { status, data, error };
}

function EntityProvider({ children, ...props }: React.PropsWithChildren<EntityStoreProps>) {
return (
<SharedQueryClientProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { ReactNode } from 'react';

import { EntryCard, IconButton } from '@contentful/f36-components';
import { CloseIcon } from '@contentful/f36-icons';
Expand All @@ -9,14 +9,25 @@ type MissingEntityCardProps = {
isSelected?: boolean;
onRemove?: Function;
providerName?: string;
as?: 'a' | 'article' | 'button' | 'div' | 'fieldset';
testId?: string;
children?: ReactNode;
};

export function MissingEntityCard(props: MissingEntityCardProps) {
const providerName = props.providerName ?? 'Source';
const description = props.customMessage ?? 'Content missing or inaccessible';
export function MissingEntityCard({
as = 'a',
providerName = 'Source',
customMessage,
isDisabled,
isSelected,
onRemove,
testId = 'cf-ui-missing-entity-card',
children,
}: MissingEntityCardProps) {
const description = customMessage ?? 'Content missing or inaccessible';

function CustomActionButton() {
if (props.isDisabled || !props.onRemove) return null;
if (isDisabled || !onRemove) return null;

return (
<IconButton
Expand All @@ -25,20 +36,22 @@ export function MissingEntityCard(props: MissingEntityCardProps) {
size="small"
variant="transparent"
onClick={() => {
props.onRemove && props.onRemove();
onRemove && onRemove();
}}
/>
);
}

return (
<EntryCard
as="a"
as={as}
contentType={providerName}
description={description}
isSelected={props.isSelected}
isSelected={isSelected}
customActionButton={<CustomActionButton />}
testId="cf-ui-missing-entity-card"
/>
testId={testId}
>
{children}
</EntryCard>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as React from 'react';

import { Flex, Text, TextLink } from '@contentful/f36-components';

import { MissingEntityCard } from '..';

import { ErrorCircleOutlineIcon, ExternalLinkIcon } from '@contentful/f36-icons';

import { useResourceProvider } from '../../common/EntityStore';

type FunctionInvocationErrorCardProps = {
isSelected?: boolean;
isDisabled?: boolean;
organizationId: string;
appDefinitionId: string;
onRemove?: Function;
providerName?: string;
};

export function FunctionInvocationErrorCard({
providerName = 'Source',
organizationId,
appDefinitionId,
isDisabled,
isSelected,
onRemove,
}: FunctionInvocationErrorCardProps) {
const { status, data } = useResourceProvider(organizationId, appDefinitionId);

const functionId = data?.function.sys.id;
const functionLink = `/account/organizations/${organizationId}/apps/definitions/${appDefinitionId}/functions/${functionId}/logs`;

return (
<MissingEntityCard
as="div"
providerName={providerName}
isDisabled={isDisabled}
isSelected={isSelected}
onRemove={onRemove}
customMessage={''}
testId="cf-ui-function-invocation-error-card"
>
<Flex justifyContent="left" alignItems="center">
<ErrorCircleOutlineIcon variant="negative" />
<Text fontColor="colorNegative">&nbsp;Function invocation error.</Text>
{status === 'success' && functionId && (
<Text fontColor="colorNegative">
&nbsp;For more information, go to&nbsp;
<TextLink
testId="cf-ui-function-invocation-log-link"
icon={<ExternalLinkIcon />}
alignIcon="end"
href={functionLink}
>
function logs
</TextLink>
</Text>
)}
</Flex>
</MissingEntityCard>
);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as React from 'react';

import { isUnsupportedError } from '../../common/EntityStore';
import { isFunctionInvocationError, isUnsupportedError } from '../../common/EntityStore';
import { getProviderName } from '../../utils/getProviderName';
import { MissingEntityCard } from '../MissingEntityCard/MissingEntityCard';
import { FunctionInvocationErrorCard } from './FunctionInvocationErrorCard';
import { UnsupportedEntityCard } from './UnsupportedEntityCard';

type ResourceEntityErrorCardProps = {
Expand All @@ -20,6 +21,19 @@ export function ResourceEntityErrorCard(props: ResourceEntityErrorCardProps) {

const providerName = getProviderName(props.linkType);

if (isFunctionInvocationError(props.error)) {
return (
<FunctionInvocationErrorCard
isSelected={props.isSelected}
isDisabled={props.isDisabled}
organizationId={props.error.organizationId}
appDefinitionId={props.error.appDefinitionId}
onRemove={props.onRemove}
providerName={providerName}
/>
);
}

return (
<MissingEntityCard
isDisabled={props.isDisabled}
Expand Down
Loading

0 comments on commit 2b52dfd

Please sign in to comment.