diff --git a/command-snapshot.json b/command-snapshot.json index 7fd523cf..8b94ca3d 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -91,6 +91,29 @@ "flags": ["api-version", "event", "flags-dir", "json", "loglevel", "name", "output-dir", "sobject", "template"], "plugin": "@salesforce/plugin-templates" }, + { + "alias": ["flexipage:generate"], + "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:visualforce:component:create"], "command": "visualforce:generate:component", 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 17d50356..48c2ca73 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,9 @@ "subtopics": { "apex": { "description": "Create an apex class or trigger." + }, + "flexipage": { + "description": "Generate a Lightning FlexiPage from a template." } } } diff --git a/src/commands/template/generate/flexipage/index.ts b/src/commands/template/generate/flexipage/index.ts new file mode 100644 index 00000000..0fa47c21 --- /dev/null +++ b/src/commands/template/generate/flexipage/index.ts @@ -0,0 +1,126 @@ +/* + * 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 aliases = ['flexipage:generate']; + public static readonly deprecateAliases = true; + 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'); + } + }); +});