Skip to content

Commit 7f1f736

Browse files
committed
Add intents registration for admin_link
1 parent e6aa945 commit 7f1f736

8 files changed

Lines changed: 373 additions & 29 deletions

File tree

packages/app/src/cli/models/extensions/specification.ts

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js'
22
import {ExtensionInstance} from './extension-instance.js'
3+
import {adminLinkOverride} from './specifications/remote-overrides/admin_link.js'
34
import {blocks} from '../../constants.js'
45
import {ClientSteps} from '../../services/build/client-steps.js'
56

@@ -58,7 +59,7 @@ export interface BuildAsset {
5859
}
5960

6061
type BuildConfig =
61-
| {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none' | 'hosted_app_home'}
62+
| {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none' | 'hosted_app_home' | 'copy_static_assets'}
6263
| {mode: 'copy_files'; filePatterns: string[]; ignoredFilePatterns?: string[]}
6364

6465
/**
@@ -173,6 +174,11 @@ interface CreateExtensionSpecType<TConfiguration extends BaseConfigType = BaseCo
173174
schema?: ZodSchemaType<TConfiguration>
174175
}
175176

177+
export type CreateContractOverrideExtensionSpecType<TConfiguration extends BaseConfigType = BaseConfigType> = Partial<
178+
CreateExtensionSpecType<TConfiguration>
179+
> & {
180+
transform?: (config: TConfiguration) => TConfiguration
181+
}
176182
/**
177183
* Create a new ui extension spec.
178184
*
@@ -286,34 +292,31 @@ export function createConfigExtensionSpecification<TConfiguration extends BaseCo
286292
}
287293

288294
export function createContractBasedModuleSpecification<TConfiguration extends BaseConfigType = BaseConfigType>(
289-
spec: Pick<
290-
CreateExtensionSpecType<TConfiguration>,
291-
| 'identifier'
292-
| 'appModuleFeatures'
293-
| 'buildConfig'
294-
| 'uidStrategy'
295-
| 'clientSteps'
296-
| 'experience'
297-
| 'transformRemoteToLocal'
298-
>,
295+
spec: Pick<CreateExtensionSpecType<TConfiguration>, 'identifier' | 'appModuleFeatures'> &
296+
CreateContractOverrideExtensionSpecType<TConfiguration>,
299297
) {
298+
const defaultDeployConfig = async (config: TConfiguration, directory: string) => {
299+
let parsedConfig = configWithoutFirstClassFields(config)
300+
if (spec.appModuleFeatures().includes('localization')) {
301+
const localization = await loadLocalesConfig(directory, spec.identifier)
302+
parsedConfig = {...parsedConfig, localization}
303+
}
304+
return parsedConfig
305+
}
306+
307+
const schema = spec.transform ? zod.any({}).transform(spec.transform) : zod.any({})
308+
300309
return createExtensionSpecification({
310+
...spec,
311+
schema,
301312
identifier: spec.identifier,
302-
schema: zod.any({}) as unknown as ZodSchemaType<TConfiguration>,
303313
appModuleFeatures: spec.appModuleFeatures,
304314
experience: spec.experience,
305315
buildConfig: spec.buildConfig ?? {mode: 'none'},
306316
clientSteps: spec.clientSteps,
307317
uidStrategy: spec.uidStrategy,
308318
transformRemoteToLocal: spec.transformRemoteToLocal,
309-
deployConfig: async (config, directory) => {
310-
let parsedConfig = configWithoutFirstClassFields(config)
311-
if (spec.appModuleFeatures().includes('localization')) {
312-
const localization = await loadLocalesConfig(directory, spec.identifier)
313-
parsedConfig = {...parsedConfig, localization}
314-
}
315-
return parsedConfig
316-
},
319+
deployConfig: spec.deployConfig ?? defaultDeployConfig,
317320
})
318321
}
319322

@@ -431,3 +434,37 @@ export function configWithoutFirstClassFields(config: JsonMapType): JsonMapType
431434
const {type, handle, uid, path, extensions, ...configWithoutFirstClassFields} = config
432435
return configWithoutFirstClassFields
433436
}
437+
438+
/**
439+
* Specification overrides for remote specifications that need custom behavior.
440+
* These overrides are applied when creating contract-based module specifications
441+
* for extensions that are defined remotely but need local customization.
442+
*
443+
* Can include any method from ExtensionSpecification plus a schema.
444+
* All properties are optional - only provide what you want to override.
445+
*/
446+
export type SpecificationOverride<TConfiguration extends BaseConfigType = BaseConfigType> = Partial<
447+
ExtensionSpecification<TConfiguration>
448+
> & {
449+
schema?: ZodSchemaType<TConfiguration>
450+
}
451+
452+
/**
453+
* Registry of specification overrides by identifier.
454+
* Add custom behavior for remote specifications here.
455+
*/
456+
export const SPECIFICATION_OVERRIDES: {[key: string]: SpecificationOverride} = {
457+
admin_link: adminLinkOverride as unknown as SpecificationOverride,
458+
}
459+
460+
/**
461+
* Get the override configuration for a specific specification identifier.
462+
*
463+
* @param identifier - The specification identifier
464+
* @returns The override configuration if it exists, undefined otherwise
465+
*/
466+
export function getSpecificationOverride<TConfiguration extends BaseConfigType = BaseConfigType>(
467+
identifier: string,
468+
): SpecificationOverride<TConfiguration> | undefined {
469+
return SPECIFICATION_OVERRIDES[identifier] as SpecificationOverride<TConfiguration> | undefined
470+
}

packages/app/src/cli/models/extensions/specifications/build-manifest-schema.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {AssetIdentifier, BuildAsset} from '../specification.js'
2-
import {fileExists, copyFile} from '@shopify/cli-kit/node/fs'
2+
import {fileExists, copyFile, mkdir} from '@shopify/cli-kit/node/fs'
33
import {joinPath, dirname, basename} from '@shopify/cli-kit/node/path'
44
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
55
import {err, ok, Result} from '@shopify/cli-kit/node/result'
@@ -14,7 +14,7 @@ export {ok, err}
1414
*/
1515
export interface TargetingWithBuildManifest {
1616
target: string
17-
build_manifest?: BuildManifest
17+
build_manifest: BuildManifest
1818
}
1919

2020
/**
@@ -142,6 +142,10 @@ async function copyAsset(
142142
if (isStatic) {
143143
const sourceFile = joinPath(directory, module)
144144
const outputFilePath = joinPath(dirname(outputPath), filepath)
145+
146+
// Ensure the directory exists before copying
147+
await mkdir(dirname(outputFilePath))
148+
145149
await copyFile(sourceFile, outputFilePath).catch((error) => {
146150
throw new Error(`Failed to copy static asset ${module} to ${outputFilePath}: ${error.message}`)
147151
})
@@ -217,9 +221,7 @@ export async function validateBuildManifestAssets(
217221
* @param extensionPoint - Extension point with build manifest
218222
* @returns Extension point with updated asset paths
219223
*/
220-
export function addDistPathToAssets<T extends TargetingWithBuildManifest & {build_manifest: BuildManifest}>(
221-
extP: T,
222-
): T {
224+
export function addDistPathToAssets<T extends TargetingWithBuildManifest>(extP: T): T {
223225
return {
224226
...extP,
225227
build_manifest: {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {
2+
transformStaticAssets,
3+
copyStaticBuildManifestAssets,
4+
validateBuildManifestAssets,
5+
addDistPathToAssets,
6+
TargetingWithBuildManifest,
7+
} from '../build-manifest-schema.js'
8+
import {configWithoutFirstClassFields, CreateContractOverrideExtensionSpecType} from '../../specification.js'
9+
import {ExtensionInstance} from '../../extension-instance.js'
10+
import {loadLocalesConfig} from '../../../../utilities/extensions/locales-configuration.js'
11+
import {JsonMapType} from '@shopify/cli-kit/node/toml'
12+
13+
interface AdminLinkPartialConfig {
14+
targeting: TargetingWithBuildManifest[]
15+
handle?: string
16+
}
17+
18+
export const adminLinkOverride: CreateContractOverrideExtensionSpecType<AdminLinkPartialConfig> = {
19+
getOutputRelativePath: (extension: ExtensionInstance<AdminLinkPartialConfig>) => `dist/${extension.handle}.js`,
20+
buildConfig: {mode: 'copy_static_assets'},
21+
transform: (config: AdminLinkPartialConfig) => {
22+
return {
23+
...config,
24+
targeting: config.targeting.map((targeting) => {
25+
return {
26+
...targeting,
27+
...transformStaticAssets(targeting, config.handle ?? 'admin-link'),
28+
}
29+
}),
30+
}
31+
},
32+
validate: async (config: AdminLinkPartialConfig, _path: string, directory: string) =>
33+
validateBuildManifestAssets(directory, config.targeting),
34+
copyStaticAssets: async (config: AdminLinkPartialConfig, directory: string, outputPath: string) =>
35+
copyStaticBuildManifestAssets(config.targeting, directory, outputPath),
36+
deployConfig: async (config: AdminLinkPartialConfig, directory: string) => {
37+
const parsedConfig = configWithoutFirstClassFields(config as unknown as JsonMapType)
38+
const localization = await loadLocalesConfig(directory, 'admin_link')
39+
40+
return {
41+
...parsedConfig,
42+
localization,
43+
targeting: config.targeting.map(addDistPathToAssets),
44+
}
45+
},
46+
}

packages/app/src/cli/models/extensions/specifications/ui_extension.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
addDistPathToAssets,
1313
transformStaticAssets,
1414
BuildManifest,
15+
TargetingWithBuildManifest,
1516
} from './build-manifest-schema.js'
1617
import {Asset, AssetIdentifier, ExtensionFeature, createExtensionSpecification} from '../specification.js'
1718
import {NewExtensionPointSchemaType, NewExtensionPointsSchema, BaseSchema, MetafieldSchema} from '../schemas.js'
@@ -33,6 +34,9 @@ const validatePoints = (config: {extension_points?: unknown[]; targeting?: unkno
3334

3435
const missingExtensionPointsMessage = 'No extension targets defined, add a `targeting` field to your configuration'
3536

37+
// Re-export BuildManifest for backward compatibility with files that import it from ui_extension
38+
export type {BuildManifest}
39+
3640
type UIExtensionConfigType = zod.infer<typeof UIExtensionSchema>
3741
export const UIExtensionSchema = BaseSchema.extend({
3842
name: zod.string(),
@@ -312,7 +316,7 @@ const uiExtensionSpec = createExtensionSpecification({
312316

313317
async function validateUIExtensionPointConfig(
314318
directory: string,
315-
extensionPoints: (NewExtensionPointSchemaType & {build_manifest?: BuildManifest})[],
319+
extensionPoints: (NewExtensionPointSchemaType & TargetingWithBuildManifest)[],
316320
configPath: string,
317321
): Promise<Result<unknown, string>> {
318322
const errors: string[] = []

packages/app/src/cli/services/dev/extension/payload/store.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,53 @@ describe('ExtensionsPayloadStore()', () => {
212212
})
213213
})
214214

215+
test('replaces arrays in extension points instead of merging them', () => {
216+
// Given — initial payload has intents with resolved schema (Asset objects)
217+
const payload = {
218+
extensions: [
219+
{
220+
uuid: '123',
221+
extensionPoints: [
222+
{
223+
target: 'admin.app.intent.link',
224+
resource: {url: ''},
225+
intents: [
226+
{
227+
type: 'application/email',
228+
action: 'edit',
229+
schema: {name: 'schema', url: '/old-url', lastUpdated: 1},
230+
},
231+
],
232+
},
233+
],
234+
},
235+
],
236+
} as unknown as ExtensionsEndpointPayload
237+
238+
const extensionsPayloadStore = new ExtensionsPayloadStore(payload, mockOptions)
239+
240+
// When — update with new intents (simulating a rebuild)
241+
extensionsPayloadStore.updateExtensions([
242+
{
243+
uuid: '123',
244+
extensionPoints: [
245+
{
246+
target: 'admin.app.intent.link',
247+
resource: {url: ''},
248+
intents: [
249+
{type: 'application/email', action: 'edit', schema: {name: 'schema', url: '/new-url', lastUpdated: 2}},
250+
],
251+
},
252+
],
253+
},
254+
] as unknown as UIExtensionPayload[])
255+
256+
// Then — intents should be replaced, not accumulated
257+
const extensionPoints = extensionsPayloadStore.getRawPayload().extensions[0]?.extensionPoints as any[]
258+
expect(extensionPoints[0].intents).toHaveLength(1)
259+
expect(extensionPoints[0].intents[0].schema.url).toBe('/new-url')
260+
})
261+
215262
test('informs event listeners of updated extensions', () => {
216263
// Given
217264
const payload = {

packages/app/src/cli/services/dev/extension/payload/store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export class ExtensionsPayloadStore extends EventEmitter {
104104
return (destinationArray as DevNewExtensionPointSchema[]).map((extensionPoint) => {
105105
const extensionPointPayload = foundExtensionPointsPayloadMap[extensionPoint.target]
106106
if (extensionPointPayload) {
107-
return deepMergeObjects(extensionPoint, extensionPointPayload)
107+
return deepMergeObjects(extensionPoint, extensionPointPayload, (_dest, source) => source)
108108
}
109109
return extensionPoint
110110
})

0 commit comments

Comments
 (0)