Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update ui_extension to support Unified config #2349

Merged
merged 31 commits into from
Jul 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ee7fb52
New ui extension union schema
isaacroldan Jul 5, 2023
137c32b
Extract settings schema
isaacroldan Jul 5, 2023
f5fa23f
Improve transform
isaacroldan Jul 5, 2023
4731b47
Remove handle
isaacroldan Jul 5, 2023
c8871a8
Update schema to override global configs
isaacroldan Jul 5, 2023
884bc52
Update schema
isaacroldan Jul 6, 2023
9671be2
Merge branch 'main' into new-ui-extension-schema
isaacroldan Jul 11, 2023
25548b6
Merge branch 'main' into new-ui-extension-schema
isaacroldan Jul 12, 2023
f236c79
Simplify types in ui_extension
isaacroldan Jul 12, 2023
c9ac3ec
Remove targeting from BaseSchema
isaacroldan Jul 12, 2023
51e46e1
Simplified ui_extension schema
isaacroldan Jul 12, 2023
e7d59af
Updated ui_extension template
isaacroldan Jul 12, 2023
3b86b0f
Merge branch 'main' into new-ui-extension-schema
isaacroldan Jul 12, 2023
f62639d
Fix handle in template
isaacroldan Jul 12, 2023
a82f4b2
Clean up
isaacroldan Jul 12, 2023
3cf2679
Add test for targeting schema
isaacroldan Jul 12, 2023
6f05a27
Add new test to validate targeting presence
isaacroldan Jul 12, 2023
032c62b
Rever unnecessary changes
isaacroldan Jul 12, 2023
e0133a1
remvoe required from template
isaacroldan Jul 12, 2023
a32e7ef
Move handle to extension config
isaacroldan Jul 12, 2023
64e52d8
Merge branch 'main' into new-ui-extension-schema
isaacroldan Jul 13, 2023
5dd591b
Make handle mandatory for ui_extensions
isaacroldan Jul 13, 2023
c862b4b
Unified schema only parses specific fields
isaacroldan Jul 13, 2023
06f4510
Simplify unifiedSchema
isaacroldan Jul 13, 2023
ccc028e
Make handle optional for legacy ui_extensions
isaacroldan Jul 14, 2023
5f1acc9
Use BaseSchema for ui_extensions
isaacroldan Jul 14, 2023
85358ca
Merge branch 'main' into new-ui-extension-schema
isaacroldan Jul 14, 2023
abe8994
Merge branch 'main' into new-ui-extension-schema
isaacroldan Jul 14, 2023
8eaf31d
Show error if type is missing
isaacroldan Jul 14, 2023
917a941
Fix id-matching tests
isaacroldan Jul 14, 2023
cadea7a
Update tests with the new localIdentifier
isaacroldan Jul 17, 2023
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
10 changes: 4 additions & 6 deletions packages/app/src/cli/models/app/identifiers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,13 @@ describe('getAppIdentifiers', () => {
// Given
const uiExtension = await testUIExtension({
directory: '/tmp/project/extensions/my-extension',
localIdentifier: 'my-extension',
idEnvironmentVariableName: 'SHOPIFY_MY_EXTENSION_ID',
})
const app = testApp({
directory: tmpDir,
dotenv: {
path: joinPath(tmpDir, '.env'),
variables: {SHOPIFY_API_KEY: 'FOO', SHOPIFY_MY_EXTENSION_ID: 'BAR'},
variables: {SHOPIFY_API_KEY: 'FOO', SHOPIFY_TEST_UI_EXTENSION_ID: 'BAR'},
},
allExtensions: [uiExtension],
})
Expand All @@ -127,7 +126,7 @@ describe('getAppIdentifiers', () => {

// Then
expect(got.app).toEqual('FOO')
expect((got.extensions ?? {})['my-extension']).toEqual('BAR')
expect((got.extensions ?? {})['test-ui-extension']).toEqual('BAR')
})
})

Expand All @@ -136,7 +135,6 @@ describe('getAppIdentifiers', () => {
// Given
const uiExtension = await testUIExtension({
directory: '/tmp/project/extensions/my-extension',
localIdentifier: 'my-extension',
idEnvironmentVariableName: 'SHOPIFY_MY_EXTENSION_ID',
})
const app = testApp({
Expand All @@ -149,12 +147,12 @@ describe('getAppIdentifiers', () => {
{
app,
},
{SHOPIFY_API_KEY: 'FOO', SHOPIFY_MY_EXTENSION_ID: 'BAR'},
{SHOPIFY_API_KEY: 'FOO', SHOPIFY_TEST_UI_EXTENSION_ID: 'BAR'},
)

// Then
expect(got.app).toEqual('FOO')
expect((got.extensions ?? {})['my-extension']).toEqual('BAR')
expect((got.extensions ?? {})['test-ui-extension']).toEqual('BAR')
})
})
})
66 changes: 61 additions & 5 deletions packages/app/src/cli/models/app/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,16 +271,73 @@ automatically_update_urls_on_dev = true

test('throws an error if the extension configuration file is invalid', async () => {
// Given
await writeConfig(appConfiguration, {
workspaces: ['web'],
name: 'my_app',
dependencies: {'empty-npm-package': '1.0.0'},
devDependencies: {},
})

const blockConfiguration = `
wrong = "my_extension"
type = "checkout_post_purchase"
`
await writeBlockConfig({
blockConfiguration,
name: 'my-extension',
})

// When
await expect(loadApp({directory: tmpDir, specifications})).rejects.toThrow()
await expect(loadApp({directory: tmpDir, specifications})).rejects.toThrow(/Fix a schema error in/)
})

test('throws an error if the extension configuration is unified and doesnt include a handle', async () => {
// Given
await writeConfig(appConfiguration, {
workspaces: ['web'],
name: 'my_app',
dependencies: {'empty-npm-package': '1.0.0'},
devDependencies: {},
})

const blockConfiguration = `
name = "my_extension-global"

[[extensions]]
name = "my_extension"
type = "checkout_post_purchase"
`
await writeBlockConfig({
blockConfiguration,
name: 'my-extension',
})

// When
await expect(loadApp({directory: tmpDir, specifications})).rejects.toThrow(
/Missing handle for extension "my_extension"/,
)
})

test('throws an error if the extension configuration is missing both extensions and type', async () => {
// Given
await writeConfig(appConfiguration, {
workspaces: ['web'],
name: 'my_app',
dependencies: {'empty-npm-package': '1.0.0'},
devDependencies: {},
})

const blockConfiguration = `
name = "my_extension-global"
handle = "handle"
`
await writeBlockConfig({
blockConfiguration,
name: 'my-extension',
})

// When
await expect(loadApp({directory: tmpDir, specifications})).rejects.toThrow(/Invalid extension type/)
})

test('loads the app with web blocks', async () => {
Expand Down Expand Up @@ -428,7 +485,7 @@ automatically_update_urls_on_dev = true
// Then
expect(app.allExtensions[0]!.configuration.name).toBe('custom_extension')
expect(app.allExtensions[0]!.idEnvironmentVariableName).toBe('SHOPIFY_CUSTOM_EXTENSION_ID')
expect(app.allExtensions[0]!.localIdentifier).toBe('custom_extension')
expect(app.allExtensions[0]!.localIdentifier).toBe('custom-extension')
})

test('loads the app from a extension directory when it has a extension with a valid configuration', async () => {
Expand Down Expand Up @@ -511,8 +568,8 @@ automatically_update_urls_on_dev = true
name = "my_extension_1_flow"
runtime_url = "https://example.com"

[settings]
[[settings.fields]]
[extensions.settings]
[[extensions.settings.fields]]
key = "my_field"
name = "My Field"
description = "My Field Description"
Expand All @@ -536,7 +593,6 @@ automatically_update_urls_on_dev = true
expect(extensions[0]!.configuration.name).toBe('my_extension_1')
expect(extensions[0]!.configuration.type).toBe('checkout_post_purchase')
expect(extensions[0]!.configuration.api_version).toBe('2022-07')
expect(extensions[0]!.configuration.settings!.fields![0]!.key).toBe('my_field')
expect(extensions[0]!.configuration.description).toBe('custom description')

expect(extensions[1]!.configuration.name).toBe('my_extension_1_flow')
Expand Down
27 changes: 24 additions & 3 deletions packages/app/src/cli/models/app/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
import {resolveFramework} from '@shopify/cli-kit/node/framework'
import {hashString} from '@shopify/cli-kit/node/crypto'
import {decodeToml} from '@shopify/cli-kit/node/toml'
import {joinPath, dirname, basename} from '@shopify/cli-kit/node/path'
import {joinPath, dirname, basename, relativePath} from '@shopify/cli-kit/node/path'
import {AbortError} from '@shopify/cli-kit/node/error'
import {outputContent, outputDebug, OutputMessage, outputToken} from '@shopify/cli-kit/node/output'
import {slugify} from '@shopify/cli-kit/common/string'
Expand Down Expand Up @@ -297,6 +297,7 @@ class AppLoader {
): Promise<ExtensionInstance | undefined> {
const specification = findSpecificationForType(this.specifications, type)
if (!specification) return

const configuration = await parseConfigurationObject(
specification.schema,
configurationPath,
Expand Down Expand Up @@ -343,13 +344,33 @@ class AppLoader {
// Parse all extensions by merging each extension config with the global unified configuration.
const configuration = await this.parseConfigurationFile(UnifiedSchema, configurationPath)
const extensionsInstancesPromises = configuration.extensions.map(async (extensionConfig) => {
const config = {...configuration, ...extensionConfig}
return this.createExtensionInstance(config.type, config, configurationPath, directory)
const mergedConfig = {...configuration, ...extensionConfig}
const {extensions, ...restConfig} = mergedConfig
if (!restConfig.handle) {
// Handle is required for unified config extensions.
return this.abortOrReport(
outputContent`Missing handle for extension "${restConfig.name}" at ${relativePath(
appDirectory,
configurationPath,
)}`,
undefined,
configurationPath,
)
}
return this.createExtensionInstance(mergedConfig.type, restConfig, configurationPath, directory)
})
return Promise.all(extensionsInstancesPromises)
} else if (type) {
// Legacy toml file with a single extension.
return this.createExtensionInstance(type, obj, configurationPath, directory)
} else {
return this.abortOrReport(
outputContent`Invalid extension type at "${outputToken.path(
relativePath(appDirectory, configurationPath),
)}". Please specify a type.`,
undefined,
configurationPath,
)
}
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import {bundleThemeExtension} from '../../services/extensions/bundle.js'
import {Identifiers} from '../app/identifiers.js'
import {uploadWasmBlob} from '../../services/deploy/upload.js'
import {ok} from '@shopify/cli-kit/node/result'
import {constantize} from '@shopify/cli-kit/common/string'
import {constantize, slugify} from '@shopify/cli-kit/common/string'
import {randomUUID} from '@shopify/cli-kit/node/crypto'
import {partnersFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {basename, joinPath} from '@shopify/cli-kit/node/path'
import {joinPath} from '@shopify/cli-kit/node/path'
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
import {useThemebundling} from '@shopify/cli-kit/node/context/local'
import {touchFile, writeFile} from '@shopify/cli-kit/node/fs'
Expand Down Expand Up @@ -116,7 +116,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
this.directory = options.directory
this.specification = options.specification
this.devUUID = `dev-${randomUUID()}`
this.handle = this.configuration.handle ?? basename(this.directory)
this.handle = this.configuration.handle ?? slugify(this.configuration.name ?? '')
this.localIdentifier = this.handle
this.idEnvironmentVariableName = `SHOPIFY_${constantize(this.localIdentifier)}_ID`
this.useExtensionsFramework = false
Expand Down
14 changes: 6 additions & 8 deletions packages/app/src/cli/models/extensions/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,12 @@ export const BaseSchemaWithHandle = BaseSchema.extend({
handle: HandleSchema,
})

export const UnifiedSchema = zod
.object({
name: zod.string(),
api_version: ApiVersionSchema.optional(),
extensions: zod.array(zod.any()),
})
// Include any other field not defined in the schema
.passthrough()
export const UnifiedSchema = zod.object({
name: zod.string(),
api_version: ApiVersionSchema.optional(),
description: zod.string().optional(),
extensions: zod.array(zod.any()),
})

export type NewExtensionPointSchemaType = zod.infer<typeof NewExtensionPointSchema>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {describe, expect, test, vi} from 'vitest'
import {err, ok} from '@shopify/cli-kit/node/result'
import {inTemporaryDirectory, mkdir, touchFile} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {zod} from '@shopify/cli-kit/node/schema'

describe('ui_extension', async () => {
interface GetUIExtensionProps {
Expand Down Expand Up @@ -65,6 +66,70 @@ describe('ui_extension', async () => {
})
})

test('targeting object is transformed into extension_points. metafields are inherited', async () => {
const allSpecs = await loadFSExtensionsSpecifications()
const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')!
const configuration = {
targeting: [
{
target: 'EXTENSION::POINT::A',
module: './src/ExtensionPointA.js',
},
],
api_version: '2023-01' as const,
name: 'UI Extension',
type: 'ui_extension',
metafields: [{namespace: 'test', key: 'test'}],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make sure that this stripped out of the payload when deploying/dev? We don't actually support this config at the extension level in the backend or front end. It's only supported in the TOML for convenience

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not included in the deployConfig for sure, not sure about how we build the dev payload, but I didn't change that 🤔

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I will take care of that on the PR I'm working on to update the dev payload to get rid of label on extension points

capabilities: {
block_progress: false,
network_access: false,
api_access: false,
},
settings: {},
}

// When
const got = specification.schema.parse(configuration)

// Then
expect(got.extension_points).toStrictEqual([
{
target: 'EXTENSION::POINT::A',
module: './src/ExtensionPointA.js',
metafields: [{namespace: 'test', key: 'test'}],
},
])
})

test('returns error if there is no targeting or extension_points', async () => {
// Given
const allSpecs = await loadFSExtensionsSpecifications()
const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')!
const configuration = {
api_version: '2023-01' as const,
name: 'UI Extension',
type: 'ui_extension',
metafields: [{namespace: 'test', key: 'test'}],
capabilities: {
block_progress: false,
network_access: false,
api_access: false,
},
settings: {},
}

// When/Then
expect(() => specification.schema.parse(configuration)).toThrowError(
new zod.ZodError([
{
code: zod.ZodIssueCode.custom,
message: 'No extension targets defined, add a `targeting` field to your configuration',
path: [],
},
]),
)
})

test('returns err(message) when extensionPoints[n].module does not map to a file', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,34 @@ import {NewExtensionPointSchemaType, NewExtensionPointsSchema, BaseSchema} from
import {loadLocalesConfig} from '../../../utilities/extensions/locales-configuration.js'
import {configurationFileNames} from '../../../constants.js'
import {getExtensionPointTargetSurface} from '../../../services/dev/extension/utilities.js'
import {zod} from '@shopify/cli-kit/node/schema'
import {err, ok, Result} from '@shopify/cli-kit/node/result'
import {fileExists} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'

const dependency = '@shopify/checkout-ui-extensions'

const validatePoints = (config: {extension_points?: unknown[]; targeting?: unknown[]}) => {
return config.extension_points !== undefined || config.targeting !== undefined
}

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

const UIExtensionSchema = BaseSchema.extend({
settings: zod
.object({
fields: zod.any().optional(),
})
.optional(),
extension_points: NewExtensionPointsSchema,
extension_points: NewExtensionPointsSchema.optional(),
targeting: NewExtensionPointsSchema.optional(),
})
.refine((config) => validatePoints(config), missingExtensionPointsMessage)
.transform((config) => {
const extensionPoints = (config.targeting ?? config.extension_points ?? []).map((targeting) => {
return {
target: targeting.target,
module: targeting.module,
metafields: targeting.metafields ?? config.metafields ?? [],
}
})
return {...config, extension_points: extensionPoints}
})

const spec = createExtensionSpecification({
identifier: 'ui_extension',
Expand Down Expand Up @@ -81,6 +93,10 @@ async function validateUIExtensionPointConfig(
const uniqueTargets: string[] = []
const duplicateTargets: string[] = []

if (!extensionPoints || extensionPoints.length === 0) {
return err(missingExtensionPointsMessage)
}

for await (const {module, target} of extensionPoints) {
const fullPath = joinPath(directory, module)
const exists = await fileExists(fullPath)
Expand Down
Loading