diff --git a/packages/nx-forge/src/shared/manifest/util-manifest.spec.ts b/packages/nx-forge/src/shared/manifest/util-manifest.spec.ts index 7fc3012..b58e275 100644 --- a/packages/nx-forge/src/shared/manifest/util-manifest.spec.ts +++ b/packages/nx-forge/src/shared/manifest/util-manifest.spec.ts @@ -7,8 +7,8 @@ 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 @@ -16,7 +16,9 @@ import { HostedResourcesSchema } from '@forge/manifest/out/schema/manifest'; * - 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', () => { @@ -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', @@ -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', @@ -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'); @@ -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); @@ -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); @@ -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); }); }); }); diff --git a/packages/nx-forge/src/shared/manifest/util-manifest.ts b/packages/nx-forge/src/shared/manifest/util-manifest.ts index 56941bf..cb9f046 100644 --- a/packages/nx-forge/src/shared/manifest/util-manifest.ts +++ b/packages/nx-forge/src/shared/manifest/util-manifest.ts @@ -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 => + 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( + (acc, moduleType) => [ + ...acc, + ...(modules[moduleType] as unknown[]).reduce( + (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 = { + 'jira:customField': (def) => { + return [ + (def as Record)?.view, + (def as Record)?.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 => { + const getSearchInputs = + extractionStrategies[type] ?? defaultExtractionStrategy; + return getSearchInputs(definition).reduce>( + (acc, m) => { + const resource = extractResourceFromModuleLike(m); + return resource === undefined + ? acc + : { ...acc, [resource.key]: resource }; + }, + {} + ); }; export type ResourceTypeIndex = { [resourceKey: string]: ResourceType }; @@ -62,7 +171,7 @@ 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: * @@ -70,32 +179,48 @@ export const getResourceTypeIndex = ( * UI or UI Kit resource. * - If the resource key is referenced via resource string interpolation * (resource:;) 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 + >((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 @@ -103,7 +228,7 @@ export const resourceTypeByResourceDefinition = ( 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/ @@ -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[]) =>