Skip to content
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
60 changes: 52 additions & 8 deletions packages/nx-forge/src/shared/manifest/util-manifest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ import {
import { HostedResourcesSchema } from '@forge/manifest/out/schema/manifest';

/**
* The following tests define a manifest with four UI resource definitions and
* one resource definition that is unrelated to UI.
* The following test defines a manifest with resource definitions and verifies
* that resources types are inferred correctly.
*
* - Resource 0: Custom UI resource associated with a Jira UI module
* - Resource 1: Custom UI resource associated with a Jira UI module
* - Resource 2: UI Kit resource associated with a Jira UI module
* - Resource 3: Custom UI resource defined in the manifest but not directly
* referenced by a UI module (Use case: Custom UI modal dialogs
* https://developer.atlassian.com/platform/forge/apis-reference/ui-api-bridge/modal/)
* - Resource 4: Static resource referencing a folder, as used in Rovo agents
* - Resource 4: Custom UI resource associated with a Jira custom field module
* - Resource 5: UI Kit resource associated with a Jira custom field module
* - Resource static: Static resource referencing a folder, as used in Rovo agents
* to reference file directories.
*/
describe('util-manifest', () => {
Expand All @@ -29,7 +31,7 @@ describe('util-manifest', () => {
},
}));

const resources = makeUIResources(4);
const resources = makeUIResources(6);
const staticResource: HostedResourcesSchema = {
key: 'static-resource',
path: 'not/a/project/ref',
Expand Down Expand Up @@ -64,6 +66,24 @@ describe('util-manifest', () => {
resolver: { function: 'resolver' },
},
],
'jira:customField': [
{
key: 'custom-field-0',
name: 'custom field 0',
description: 'custom field 0 description',
type: 'string',
view: { resource: resources[4].key },
edit: { resource: resources[4].key },
},
{
key: 'custom-field-1',
name: 'custom field 1',
description: 'custom field 1 description',
type: 'number',
view: { resource: resources[5].key, render: 'native' },
edit: { resource: resources[5].key, render: 'native' },
},
],
'rovo:agent': [
{
key: 'fake-agent',
Expand All @@ -90,6 +110,12 @@ describe('util-manifest', () => {
expect(
resourceTypeByResourceDefinition(manifest)(resources[3])
).toStrictEqual('custom-ui');
expect(
resourceTypeByResourceDefinition(manifest)(resources[4])
).toStrictEqual('custom-ui');
expect(
resourceTypeByResourceDefinition(manifest)(resources[5])
).toStrictEqual('ui-kit');
expect(
resourceTypeByResourceDefinition(manifest)(staticResource)
).toStrictEqual('static');
Expand All @@ -111,6 +137,12 @@ describe('util-manifest', () => {
expect(
isResourceType(manifest, acceptedResourceTypes)(resources[3])
).toStrictEqual(true);
expect(
isResourceType(manifest, acceptedResourceTypes)(resources[4])
).toStrictEqual(true);
expect(
isResourceType(manifest, acceptedResourceTypes)(resources[5])
).toStrictEqual(true);
expect(
isResourceType(manifest, acceptedResourceTypes)(staticResource)
).toStrictEqual(false);
Expand All @@ -129,6 +161,12 @@ describe('util-manifest', () => {
expect(
isResourceType(manifest, acceptedResourceTypes)(resources[3])
).toStrictEqual(true);
expect(
isResourceType(manifest, acceptedResourceTypes)(resources[4])
).toStrictEqual(true);
expect(
isResourceType(manifest, acceptedResourceTypes)(resources[5])
).toStrictEqual(false);
expect(
isResourceType(manifest, acceptedResourceTypes)(staticResource)
).toStrictEqual(false);
Expand All @@ -147,23 +185,29 @@ describe('util-manifest', () => {
expect(
isResourceType(manifest, acceptedResourceTypes)(resources[3])
).toStrictEqual(false);
expect(
isResourceType(manifest, acceptedResourceTypes)(resources[4])
).toStrictEqual(false);
expect(
isResourceType(manifest, acceptedResourceTypes)(resources[5])
).toStrictEqual(true);
expect(
isResourceType(manifest, acceptedResourceTypes)(staticResource)
).toStrictEqual(false);
});
it('should filter UI resources', () => {
expect(manifest.resources).toHaveLength(5);
expect(manifest.resources).toHaveLength(7);
expect(
manifest.resources.filter(
isResourceType(manifest, ['custom-ui', 'ui-kit'])
)
).toHaveLength(4);
).toHaveLength(6);
expect(
manifest.resources.filter(isResourceType(manifest, ['custom-ui']))
).toHaveLength(3);
).toHaveLength(4);
expect(
manifest.resources.filter(isResourceType(manifest, ['ui-kit']))
).toHaveLength(1);
).toHaveLength(2);
});
});
});
195 changes: 160 additions & 35 deletions packages/nx-forge/src/shared/manifest/util-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,142 @@
import { getAllModules, ManifestSchema } from '@forge/manifest';
import { ManifestSchema, Modules } from '@forge/manifest';
import { HostedResourcesSchema } from '@forge/manifest/out/schema/manifest';
import { logger } from '@nx/devkit';

// https://stackoverflow.com/a/8511350/5115898
const isObject = (v: unknown): v is object =>
typeof v === 'object' && v !== null;
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);

type ModuleDefinition = {
key: string;
[p: string]: unknown;
};

const isModuleDefinitionWithKey = (m: unknown): m is ModuleDefinition =>
isObject(m) && 'key' in m && typeof m.key === 'string';

type Module = {
type: string;
definition: ModuleDefinition;
};

/**
* Adapted version of getAllModules from @forge/manifest that preserves the module type for each module and validates
* that each module definition is a record with a key property.
*/
const allModulesWithType = (modules: Modules): Module[] =>
Object.keys(modules).reduce<Module[]>(
(acc, moduleType) => [
...acc,
...(modules[moduleType] as unknown[]).reduce<Module[]>(
(innerAcc, m: unknown) => {
if (!isModuleDefinitionWithKey(m)) {
logger.warn(
`Unexpected module definition for type ${moduleType}: Module is missing a 'key' property: ${JSON.stringify(
m
)}`
);
return innerAcc;
}
return [
...innerAcc,
{
type: moduleType,
definition: m,
},
];
},
[]
),
],
[]
);

export type ResourceType = 'ui-kit' | 'custom-ui' | 'static';

type ResourceRef = {
key: string;
type: ResourceType;
};

/**
* Determines the resource type based on the module definition.
* Extracts the resource reference from the given module like definition if the
* definition is an object with a `resource` property.
*
* The criteria are as follows:
* - UI Kit: module has the `resource` property and the `render` property is
* `native`
* - Custom UI: module has the `resource` property and the `render` property is
* not `native` or missing
* - Static: none of the above, consider it a static resource
* The resource type is determined as follows:
* - UI Kit: module has the `resource` property and the `render` property is `native`
* - Custom UI: module has the `resource` property, and the `render` property is not `native` or missing
*
* @param moduleDefinition Module definition to analyze
* @param moduleLikeDefinition
* @returns Resource reference if the definition is an object with a `resource` property, undefined otherwise.
*/
const resourceTypeByModuleDefinition = (
moduleDefinition: any
): ResourceType => {
if (moduleDefinition.resource && moduleDefinition.render === 'native')
return 'ui-kit';
if (moduleDefinition.resource && moduleDefinition.render !== 'native')
return 'custom-ui';
return 'static';
const extractResourceFromModuleLike = (
moduleLikeDefinition: unknown
): ResourceRef | undefined => {
if (
!isObject(moduleLikeDefinition) ||
typeof moduleLikeDefinition.resource !== 'string'
) {
return undefined;
}

const { resource, render } = moduleLikeDefinition;
return {
key: resource,
type: render === 'native' ? 'ui-kit' : 'custom-ui',
};
};

type ExtractionStrategy = (def: unknown) => unknown[];

/**
* Searches at the root of the module definition for resource references.
* @param def Module definition to analyze
*/
const defaultExtractionStrategy: ExtractionStrategy = (def) => [def];

/**
* Specialized resource reference extraction strategies for certain module types.
*/
const extractionStrategies: Record<string, ExtractionStrategy> = {
'jira:customField': (def) => {
return [
(def as Record<string, unknown>)?.view,
(def as Record<string, unknown>)?.edit,
];
},
};

/**
* Extracts resource references from the given module definition indexed by the
* resource key.
*
* Note that some modules may not define resources at the root, so for certain
* module types, we need to look deeper into the definition to find potential
* resource references.
*
* The resource type is determined as follows:
* - UI Kit: module has the `resource` property and the `render` property is `native`
* - Custom UI: module has the `resource` property, and the `render` property is not `native` or missing
* - otherwise: assume there is no resource reference in the module definition
*
* @param type Module type, e.g. `jira:customField`
* @param definition Module definition to analyze
*/
const extractResourceReferencesFromModule = ({
type,
definition,
}: Module): Record<string, ResourceRef> => {
const getSearchInputs =
extractionStrategies[type] ?? defaultExtractionStrategy;
return getSearchInputs(definition).reduce<Record<string, ResourceRef>>(
(acc, m) => {
const resource = extractResourceFromModuleLike(m);
return resource === undefined
? acc
: { ...acc, [resource.key]: resource };
},
{}
);
};

export type ResourceTypeIndex = { [resourceKey: string]: ResourceType };
Expand Down Expand Up @@ -62,48 +171,64 @@ export const getResourceTypeIndex = (
}, {} as ResourceTypeIndex);

/**
* Determines if type of the given resource.
* Determines the type of the given resource.
*
* The resource type is determined by the following reduction:
*
* - If the resource is referenced by a module as a 'resource', infer a Custom
* UI or UI Kit resource.
* - If the resource key is referenced via resource string interpolation
* (resource:<resource-key>;) anywhere in the manifest, infer a static resource.
* - Else infer Custom UI. These kind of resources are typically used as Modal
* - Else infer Custom UI. These kinds of resources are typically used as Modal
* dialogs in Custom UIs. They are not referenced in the manifest and instead,
* opened via @forge/bridge in Custom UI code.
*
* @see https://developer.atlassian.com/platform/forge/apis-reference/ui-api-bridge/modal/
*
* @param manifestSchema Complete manifest definition
* @param resource Resource to resolve against the manifest definition
* @returns Function that takes the resource to resolve against the manifest definition
*/
export const resourceTypeByResourceDefinition = (
manifestSchema: ManifestSchema
) => {
const manifestSchemaString = JSON.stringify(manifestSchema);
return (resource: HostedResourcesSchema): ResourceType => {
for (const moduleDefinition of getAllModules(
manifestSchema.modules ?? {}
)) {
if (
isObject(moduleDefinition) &&
Object.hasOwn(moduleDefinition, 'resource') &&
typeof (moduleDefinition as any).resource === 'string' &&
(moduleDefinition as any).resource === resource.key
) {
return resourceTypeByModuleDefinition(moduleDefinition);
const modules = allModulesWithType(manifestSchema.modules ?? {});
const moduleResourceReferencesByResourceKey = modules.reduce<
Record<string, ResourceRef>
>((acc, module) => {
const resourceReferences = extractResourceReferencesFromModule(module);
for (const r of Object.values(resourceReferences)) {
if (r.key in acc && acc[r.key].type !== r.type) {
logger.warn(
`nx-forge has already been inferred resource with key ${
r.key
} to a different type. The current module ${module.type}:${
module.definition.key
} inferred the type as ${r.type} but it was previously inferred as ${
acc[r.key].type
}. This may be a bug, please report it at https://github.com/toolsplus/nx-forge/issues including your Forge manifest.yml`
);
}
}
return {
...acc,
...resourceReferences,
};
}, {});
return (resource: HostedResourcesSchema): ResourceType => {
const maybeResourceRef =
moduleResourceReferencesByResourceKey[resource.key];
if (maybeResourceRef) {
return maybeResourceRef.type;
}

// If the resource key is referenced in the manifest via a resource
// string interpolation, assume it is a static resource
if (manifestSchemaString.includes(`resource:${resource.key};`)) {
return 'static';
}

// Assume that the resource is a Custom UI. There may be resources which are
// Assume that the resource is a Custom UI. There may be resources that are
// not directly referenced in the manifest. For example, UI Kit modal dialogs
// can be referenced in other Custom UI projects and loaded via @forge/bridge.
// https://developer.atlassian.com/platform/forge/apis-reference/ui-api-bridge/modal/
Expand All @@ -121,7 +246,7 @@ export const resourceTypeByResourceDefinition = (
*
* @param manifestSchema Complete manifest definition
* @param acceptedResourceTypes Allows to filer for specific resource types
* @param resource Resource to resolve against the manifest definition
* @returns Function that takes a resource to resolve against the manifest definition
*/
export const isResourceType =
(manifestSchema: ManifestSchema, acceptedResourceTypes: ResourceType[]) =>
Expand Down