diff --git a/command-snapshot.json b/command-snapshot.json index d18f08e0..838aa196 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -31,6 +31,29 @@ "flags": ["admin-email", "flags-dir", "json", "name", "output-dir", "target-org", "template", "url-path-prefix"], "plugin": "@salesforce/plugin-templates" }, + { + "alias": [], + "command": "template:generate:flexipage", + "flagAliases": ["apiversion", "entity", "entity-name", "flexipagename", "masterlabel", "outputdir"], + "flagChars": ["d", "i", "n", "s", "t"], + "flags": [ + "api-version", + "description", + "detail-fields", + "flags-dir", + "internal", + "json", + "label", + "loglevel", + "name", + "output-dir", + "primary-field", + "secondary-fields", + "sobject", + "template" + ], + "plugin": "@salesforce/plugin-templates" + }, { "alias": ["force:lightning:app:create", "lightning:generate:app"], "command": "template:generate:lightning:app", diff --git a/messages/digitalExperienceSite.md b/messages/digitalExperienceSite.md index 7163a9b1..4c9ec8c3 100644 --- a/messages/digitalExperienceSite.md +++ b/messages/digitalExperienceSite.md @@ -12,7 +12,7 @@ Creates an Experience Cloud site with the specified template, name, and URL path <%= config.bin %> <%= command.id %> --template BuildYourOwnLWR --name mysite --url-path-prefix mysite -- Generate an Experience Cloud site like the last example, but generate the files into the specified output directory: +- Generate an Experience Cloud site like the last example, but generate the files into the specified output directory: <%= config.bin %> <%= command.id %> --template BuildYourOwnLWR --name mysite --url-path-prefix mysite --output-dir force-app/main/default @@ -28,7 +28,7 @@ Template to use when generating the site. Supported templates: -- BuildYourOwnLWR - Creates blazing-fast digital experiences, such as websites, microsites, and portals, using the Lightning Web Components programming model. Powered by Lightning Web Runtime (LWR), this customizable template delivers unparalleled site performance. For additional details, see this Salesforce Help topic: https://help.salesforce.com/s/articleView?id=experience.rss_build_your_own_lwr.htm. +- BuildYourOwnLWR - Creates blazing-fast digital experiences, such as websites, microsites, and portals, using the Lightning Web Components programming model. Powered by Lightning Web Runtime (LWR), this customizable template delivers unparalleled site performance. For additional details, see this Salesforce Help topic: https://help.salesforce.com/s/articleView?id=experience.rss_build_your_own_lwr.htm. # flags.url-path-prefix.summary diff --git a/messages/flexipage.md b/messages/flexipage.md new file mode 100644 index 00000000..ac81959a --- /dev/null +++ b/messages/flexipage.md @@ -0,0 +1,83 @@ +# examples + +- Generate a RecordPage FlexiPage for the Account object in the current directory: + + <%= config.bin %> <%= command.id %> --name Account_Record_Page --template RecordPage --sobject Account + +- Generate an AppPage FlexiPage in the "force-app/main/default/flexipages" directory: + + <%= config.bin %> <%= command.id %> --name Sales_Dashboard --template AppPage --output-dir force-app/main/default/flexipages + +- Generate a HomePage FlexiPage with a custom label: + + <%= config.bin %> <%= command.id %> --name Custom_Home --template HomePage --label "Sales Home Page" + +- Generate a RecordPage with dynamic highlights and detail fields: + + <%= config.bin %> <%= command.id %> --name Property_Page --template RecordPage --sobject Rental_Property__c --primary-field Name --secondary-fields Property_Address__c,City__c --detail-fields Name,Property_Address__c,City__c,Monthly_Rent__c,Bedrooms__c + +# summary + +Generate a FlexiPage, also known as a Lightning page. + +# description + +FlexiPages are the metadata types associated with a Lightning page. A Lightning page represents a customizable screen made up of regions containing Lightning components. + +You can use this command to generate these types of FlexiPages; specify the type with the --template flag: + +- AppPage: A Lightning page used as the home page for a custom app or a standalone application page. +- HomePage: A Lightning page used to override the Home page in Lightning Experience. +- RecordPage: A Lightning page used to override an object record page in Lightning Experience. Requires that you specify the object name with the --sobject flag. + +# flags.name.summary + +Name of the FlexiPage. + +# flags.name.description + +The name can contain only alphanumeric characters, must start with a letter, and can't end with an underscore or contain two consecutive underscores. + +# flags.template.summary + +Template type for the FlexiPage. + +# flags.label.summary + +Label of this FlexiPage; if not specified, uses the FlexiPage name as the label. + +# flags.description.summary + +Description for the FlexiPage, which provides context about its purpose. + +# flags.sobject.summary + +API name of the Salesforce object; required when creating a RecordPage. + +# flags.sobject.description + +For RecordPage FlexiPages, you must specify the associated object API name, such as 'Account', 'Opportunity', or 'Custom_Object__c'. This sets the `sobjectType` field in the FlexiPage metadata. + +# flags.primary-field.summary + +Primary field for the dynamic highlights header; typically 'Name'. Used only with RecordPage. + +# flags.secondary-fields.summary + +Secondary fields shown in the dynamic highlights header. Specify multiple fields separated by commas. Maximum of 11 fields. Used only with RecordPage. + +# flags.detail-fields.summary + +Fields to display in the Details tab. Specify multiple fields separated by commas. Fields are split into two columns. Used only with RecordPage. + +# errors.recordPageRequiresSobject + +RecordPage template requires the --sobject flag to specify the Salesforce object API name (e.g., 'Account', 'Opportunity', 'Custom_Object__c'). + +# errors.tooManySecondaryFields + +Too many secondary fields specified (%s). The Dynamic Highlights Panel supports a maximum of %s secondary fields. + +# errors.flagRequiresRecordPage + +The --%s flag can only be used with --template RecordPage. diff --git a/package.json b/package.json index ecba6af4..d95bcd9b 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,9 @@ "apex": { "description": "Create an apex class or trigger." }, + "flexipage": { + "description": "Generate a Lightning FlexiPage from a template." + }, "digital-experience": { "description": "Create a Digital Experience site." }, diff --git a/src/commands/template/generate/flexipage/index.ts b/src/commands/template/generate/flexipage/index.ts new file mode 100644 index 00000000..de16c3e9 --- /dev/null +++ b/src/commands/template/generate/flexipage/index.ts @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2019, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { Flags, loglevel, orgApiVersionFlagWithDeprecations, SfCommand, Ux } from '@salesforce/sf-plugins-core'; +import { CreateOutput, FlexipageOptions, TemplateType } from '@salesforce/templates'; +import { Messages } from '@salesforce/core'; +import { getCustomTemplates, runGenerator } from '../../../../utils/templateCommand.js'; +import { internalFlag, outputDirFlag } from '../../../../utils/flags.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-templates', 'flexipage'); + +export default class FlexipageGenerate extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + public static readonly state = 'beta'; + public static readonly flags = { + name: Flags.string({ + char: 'n', + summary: messages.getMessage('flags.name.summary'), + description: messages.getMessage('flags.name.description'), + required: true, + aliases: ['flexipagename'], + deprecateAliases: true, + }), + template: Flags.option({ + char: 't', + summary: messages.getMessage('flags.template.summary'), + required: true, + options: ['RecordPage', 'AppPage', 'HomePage'] as const, + })(), + 'output-dir': outputDirFlag, + 'api-version': orgApiVersionFlagWithDeprecations, + label: Flags.string({ + summary: messages.getMessage('flags.label.summary'), + aliases: ['masterlabel'], + deprecateAliases: true, + }), + description: Flags.string({ + summary: messages.getMessage('flags.description.summary'), + }), + sobject: Flags.string({ + char: 's', + summary: messages.getMessage('flags.sobject.summary'), + description: messages.getMessage('flags.sobject.description'), + aliases: ['entity-name', 'entity'], + deprecateAliases: true, + }), + 'primary-field': Flags.string({ + summary: messages.getMessage('flags.primary-field.summary'), + }), + 'secondary-fields': Flags.string({ + summary: messages.getMessage('flags.secondary-fields.summary'), + multiple: true, + delimiter: ',', + }), + 'detail-fields': Flags.string({ + summary: messages.getMessage('flags.detail-fields.summary'), + multiple: true, + delimiter: ',', + }), + internal: internalFlag, + loglevel, + }; + + private static readonly MAX_SECONDARY_FIELDS = 11; + + public async run(): Promise { + const { flags } = await this.parse(FlexipageGenerate); + + // Validate RecordPage requires sobject + if (flags.template === 'RecordPage' && !flags.sobject) { + throw new Error(messages.getMessage('errors.recordPageRequiresSobject')); + } + + // Validate RecordPage-specific flags are only used with RecordPage template + const recordPageOnlyFlags = ['primary-field', 'secondary-fields', 'detail-fields'] as const; + if (flags.template !== 'RecordPage') { + for (const flagName of recordPageOnlyFlags) { + if (flags[flagName]) { + throw new Error(messages.getMessage('errors.flagRequiresRecordPage', [flagName])); + } + } + } + + // Validate secondary fields limit (Dynamic Highlights Panel supports max 11) + const secondaryFieldsCount = flags['secondary-fields']?.length ?? 0; + if (secondaryFieldsCount > FlexipageGenerate.MAX_SECONDARY_FIELDS) { + throw new Error( + messages.getMessage('errors.tooManySecondaryFields', [ + secondaryFieldsCount.toString(), + FlexipageGenerate.MAX_SECONDARY_FIELDS.toString(), + ]) + ); + } + + // Convert CLI flags to library options + const flagsAsOptions: FlexipageOptions = { + flexipagename: flags.name, + template: flags.template, + outputdir: flags['output-dir'], + apiversion: flags['api-version'], + masterlabel: flags.label, + description: flags.description, + entityName: flags.sobject, + primaryField: flags['primary-field'], + secondaryFields: flags['secondary-fields'] ?? [], + detailFields: flags['detail-fields'] ?? [], + internal: flags.internal, + }; + + return runGenerator({ + templateType: TemplateType.Flexipage, + opts: flagsAsOptions, + ux: new Ux({ jsonEnabled: this.jsonEnabled() }), + templates: getCustomTemplates(this.configAggregator), + }); + } +} diff --git a/test/commands/template/generate/flexipage/index.nut.ts b/test/commands/template/generate/flexipage/index.nut.ts new file mode 100644 index 00000000..42698142 --- /dev/null +++ b/test/commands/template/generate/flexipage/index.nut.ts @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2019, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import path from 'node:path'; +import { expect } from 'chai'; +import { TestSession, execCmd } from '@salesforce/cli-plugins-testkit'; +import assert from 'yeoman-assert'; + +describe('template generate flexipage:', () => { + let session: TestSession; + before(async () => { + session = await TestSession.create({ + project: {}, + devhubAuthStrategy: 'NONE', + }); + }); + after(async () => { + await session?.clean(); + }); + + describe('RecordPage creation', () => { + it('should create a RecordPage flexipage with required flags', () => { + execCmd('template generate flexipage --name AccountPage --template RecordPage --sobject Account', { + ensureExitCode: 0, + }); + const filePath = path.join(session.project.dir, 'flexipages', 'AccountPage.flexipage-meta.xml'); + assert.file(filePath); + assert.fileContent(filePath, 'RecordPage'); + assert.fileContent(filePath, 'Account'); + }); + + it('should create a RecordPage with custom output directory', () => { + execCmd( + 'template generate flexipage --name ContactPage --template RecordPage --sobject Contact --output-dir custom', + { ensureExitCode: 0 } + ); + const filePath = path.join(session.project.dir, 'custom', 'flexipages', 'ContactPage.flexipage-meta.xml'); + assert.file(filePath); + assert.fileContent(filePath, 'RecordPage'); + assert.fileContent(filePath, 'Contact'); + }); + + it('should create a RecordPage with primary and secondary fields', () => { + execCmd( + 'template generate flexipage --name OpportunityPage --template RecordPage --sobject Opportunity ' + + '--primary-field Name --secondary-fields Amount,StageName,CloseDate', + { ensureExitCode: 0 } + ); + const filePath = path.join(session.project.dir, 'flexipages', 'OpportunityPage.flexipage-meta.xml'); + assert.file(filePath); + assert.fileContent(filePath, 'RecordPage'); + assert.fileContent(filePath, 'Opportunity'); + }); + + it('should create a RecordPage with detail fields', () => { + execCmd( + 'template generate flexipage --name LeadPage --template RecordPage --sobject Lead ' + + '--detail-fields Name,Email,Phone,Company', + { ensureExitCode: 0 } + ); + const filePath = path.join(session.project.dir, 'flexipages', 'LeadPage.flexipage-meta.xml'); + assert.file(filePath); + assert.fileContent(filePath, 'RecordPage'); + }); + + it('should create a RecordPage with custom label', () => { + execCmd( + 'template generate flexipage --name CasePage --template RecordPage --sobject Case --label "Case Details Page"', + { ensureExitCode: 0 } + ); + const filePath = path.join(session.project.dir, 'flexipages', 'CasePage.flexipage-meta.xml'); + assert.file(filePath); + assert.fileContent(filePath, 'Case Details Page'); + }); + }); + + describe('AppPage creation', () => { + it('should create an AppPage flexipage', () => { + execCmd('template generate flexipage --name SalesDashboard --template AppPage', { ensureExitCode: 0 }); + const filePath = path.join(session.project.dir, 'flexipages', 'SalesDashboard.flexipage-meta.xml'); + assert.file(filePath); + assert.fileContent(filePath, 'AppPage'); + }); + + it('should create an AppPage with custom label and description', () => { + execCmd( + 'template generate flexipage --name AnalyticsDashboard --template AppPage ' + + '--label "Analytics Dashboard" --description "Dashboard for analytics"', + { ensureExitCode: 0 } + ); + const filePath = path.join(session.project.dir, 'flexipages', 'AnalyticsDashboard.flexipage-meta.xml'); + assert.file(filePath); + assert.fileContent(filePath, 'Analytics Dashboard'); + }); + }); + + describe('HomePage creation', () => { + it('should create a HomePage flexipage', () => { + execCmd('template generate flexipage --name CustomHome --template HomePage', { ensureExitCode: 0 }); + const filePath = path.join(session.project.dir, 'flexipages', 'CustomHome.flexipage-meta.xml'); + assert.file(filePath); + assert.fileContent(filePath, 'HomePage'); + }); + + it('should create a HomePage with custom output directory', () => { + execCmd('template generate flexipage --name SalesHome --template HomePage --output-dir pages', { + ensureExitCode: 0, + }); + const filePath = path.join(session.project.dir, 'pages', 'flexipages', 'SalesHome.flexipage-meta.xml'); + assert.file(filePath); + assert.fileContent(filePath, 'HomePage'); + }); + }); + + describe('Error handling', () => { + it('should throw error when name flag is missing', () => { + const stderr = execCmd('template generate flexipage --template RecordPage').shellOutput.stderr; + expect(stderr).to.contain('Missing required flag'); + }); + + it('should throw error when template flag is missing', () => { + const stderr = execCmd('template generate flexipage --name TestPage').shellOutput.stderr; + expect(stderr).to.contain('Missing required flag'); + }); + + it('should throw error when sobject is missing for RecordPage', () => { + const stderr = execCmd('template generate flexipage --name TestPage --template RecordPage').shellOutput.stderr; + expect(stderr).to.contain('sobject'); + }); + + it('should throw error when primary-field is used with non-RecordPage template', () => { + const stderr = execCmd('template generate flexipage --name TestPage --template AppPage --primary-field Name') + .shellOutput.stderr; + expect(stderr).to.contain('primary-field'); + expect(stderr).to.contain('RecordPage'); + }); + + it('should throw error when secondary-fields is used with non-RecordPage template', () => { + const stderr = execCmd('template generate flexipage --name TestPage --template HomePage --secondary-fields Name') + .shellOutput.stderr; + expect(stderr).to.contain('secondary-fields'); + expect(stderr).to.contain('RecordPage'); + }); + + it('should throw error when too many secondary fields are provided', () => { + const stderr = execCmd( + 'template generate flexipage --name TestPage --template RecordPage --sobject Account ' + + '--secondary-fields F1,F2,F3,F4,F5,F6,F7,F8,F9,F10,F11,F12' + ).shellOutput.stderr; + expect(stderr).to.contain('Too many secondary fields'); + }); + }); +}); diff --git a/test/commands/template/generate/flexipage/index.test.ts b/test/commands/template/generate/flexipage/index.test.ts new file mode 100644 index 00000000..77f97d78 --- /dev/null +++ b/test/commands/template/generate/flexipage/index.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2019, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { TestContext } from '@salesforce/core/testSetup'; +import { expect } from 'chai'; +import { stubSfCommandUx } from '@salesforce/sf-plugins-core'; +import FlexipageGenerate from '../../../../../src/commands/template/generate/flexipage/index.js'; + +describe('template:generate:flexipage', () => { + const $$ = new TestContext(); + + beforeEach(() => { + stubSfCommandUx($$.SANDBOX); + }); + + afterEach(() => { + $$.restore(); + }); + + it('should require name flag', async () => { + try { + await FlexipageGenerate.run([]); + expect.fail('Should have thrown an error'); + } catch (err) { + const error = err as Error; + expect(error.message).to.include('Missing required flag'); + } + }); + + it('should require template flag', async () => { + try { + await FlexipageGenerate.run(['--name', 'TestPage']); + expect.fail('Should have thrown an error'); + } catch (err) { + const error = err as Error; + expect(error.message).to.include('Missing required flag'); + } + }); + + it('should require sobject for RecordPage', async () => { + try { + await FlexipageGenerate.run(['--name', 'TestPage', '--template', 'RecordPage']); + expect.fail('Should have thrown an error'); + } catch (err) { + const error = err as Error; + expect(error.message).to.include('sobject'); + } + }); + + it('should reject more than 11 secondary fields', async () => { + try { + await FlexipageGenerate.run([ + '--name', + 'TestPage', + '--template', + 'RecordPage', + '--sobject', + 'Account', + '--secondary-fields', + 'F1,F2,F3,F4,F5,F6,F7,F8,F9,F10,F11,F12', + ]); + expect.fail('Should have thrown an error'); + } catch (err) { + const error = err as Error; + expect(error.message).to.include('Too many secondary fields'); + } + }); + + it('should be marked as beta', () => { + expect(FlexipageGenerate.state).to.equal('beta'); + }); + + it('should reject primary-field with non-RecordPage template', async () => { + try { + await FlexipageGenerate.run(['--name', 'TestPage', '--template', 'AppPage', '--primary-field', 'Name']); + expect.fail('Should have thrown an error'); + } catch (err) { + const error = err as Error; + expect(error.message).to.include('primary-field'); + expect(error.message).to.include('RecordPage'); + } + }); + + it('should reject secondary-fields with non-RecordPage template', async () => { + try { + await FlexipageGenerate.run(['--name', 'TestPage', '--template', 'HomePage', '--secondary-fields', 'Industry']); + expect.fail('Should have thrown an error'); + } catch (err) { + const error = err as Error; + expect(error.message).to.include('secondary-fields'); + expect(error.message).to.include('RecordPage'); + } + }); + + it('should reject detail-fields with non-RecordPage template', async () => { + try { + await FlexipageGenerate.run(['--name', 'TestPage', '--template', 'AppPage', '--detail-fields', 'Name,Phone']); + expect.fail('Should have thrown an error'); + } catch (err) { + const error = err as Error; + expect(error.message).to.include('detail-fields'); + expect(error.message).to.include('RecordPage'); + } + }); +});