diff --git a/src/commands/template/generate/ui-bundle/index.ts b/src/commands/template/generate/ui-bundle/index.ts index 1d046a01..81ea20d8 100644 --- a/src/commands/template/generate/ui-bundle/index.ts +++ b/src/commands/template/generate/ui-bundle/index.ts @@ -6,6 +6,7 @@ */ import path from 'node:path'; +import fs from 'node:fs/promises'; import { Flags, SfCommand, Ux } from '@salesforce/sf-plugins-core'; import { CreateOutput, UIBundleOptions, TemplateType } from '@salesforce/templates'; import { Messages, SfProject } from '@salesforce/core'; @@ -15,6 +16,7 @@ Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-templates', 'ui-bundle.generate'); export const UI_BUNDLES_DIR = 'uiBundles'; +const GRAPHQLRC_FILENAME = '.graphqlrc.yml'; export default class UiBundleGenerate extends SfCommand { public static readonly summary = messages.getMessage('summary'); @@ -62,6 +64,43 @@ export default class UiBundleGenerate extends SfCommand { } } + /** + * Creates a new `.graphqlrc.yml` at the sfdx project root so the GraphQL LSP extension + * can auto-discover it. The file at the project root references `schema.graphql` as a + * sibling and uses a recursive glob for documents so nested ui-bundle src trees are picked up. + * + * Returns the new file path on success, or undefined when not running inside an sfdx project, + * when the bundle's `.graphqlrc.yml` was not generated, or when a `.graphqlrc.yml` already + * exists at the project root. + */ + private static async createGraphqlrcAtProjectRoot(bundleSourcePath: string): Promise { + let projectRoot: string; + try { + const project = await SfProject.resolve(); + projectRoot = project.getPath(); + } catch { + return undefined; + } + + try { + await fs.access(bundleSourcePath); + } catch { + return undefined; + } + + const targetPath = path.join(projectRoot, GRAPHQLRC_FILENAME); + try { + await fs.access(targetPath); + return undefined; + } catch { + // target does not exist — proceed + } + + const content = "schema: 'schema.graphql'\ndocuments: './**/src/**/*.{graphql,js,ts,jsx,tsx}'\n"; + await fs.writeFile(targetPath, content); + return targetPath; + } + public async run(): Promise { const { flags } = await this.parse(UiBundleGenerate); @@ -75,11 +114,30 @@ export default class UiBundleGenerate extends SfCommand { apiversion: flags['api-version'], }; - return runGenerator({ + const ux = new Ux({ jsonEnabled: this.jsonEnabled() }); + + const result = await runGenerator({ templateType: TemplateType.UIBundle, opts: flagsAsOptions, - ux: new Ux({ jsonEnabled: this.jsonEnabled() }), + ux, templates: getCustomTemplates(this.configAggregator), }); + + if (flags.template === 'reactbasic') { + const bundleSourcePath = path.join(result.outputDir, flags.name, GRAPHQLRC_FILENAME); + const newPath = await UiBundleGenerate.createGraphqlrcAtProjectRoot(bundleSourcePath); + if (newPath) { + const targetRelative = path.relative(process.cwd(), newPath); + const createLine = ` create ${targetRelative}`; + ux.log(createLine); + return { + ...result, + created: [...result.created, targetRelative], + rawOutput: `${result.rawOutput.replace(/\n$/, '')}\n${createLine}\n`, + }; + } + } + + return result; } } diff --git a/test/commands/template/generate/ui-bundle/index.nut.ts b/test/commands/template/generate/ui-bundle/index.nut.ts index 17275b7f..3f532b21 100644 --- a/test/commands/template/generate/ui-bundle/index.nut.ts +++ b/test/commands/template/generate/ui-bundle/index.nut.ts @@ -5,6 +5,7 @@ * 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 fs from 'node:fs'; import { expect } from 'chai'; import { TestSession, execCmd } from '@salesforce/cli-plugins-testkit'; import { nls } from '@salesforce/templates/lib/i18n/index.js'; @@ -38,6 +39,7 @@ describe('template generate ui-bundle:', () => { path.join(outputDir, 'MyUiBundle', 'MyUiBundle.uibundle-meta.xml'), 'My Ui Bundle' ); + assert.noFile(path.join(projectDir, '.graphqlrc.yml')); }); it('should default to project uiBundles directory when --output-dir is omitted', () => { @@ -64,11 +66,21 @@ describe('template generate ui-bundle:', () => { }); describe('Check UI bundle creation with reactbasic template', () => { - it('should create React UI bundle with all required files', () => { + afterEach(() => { + const rootGraphqlrc = path.join(projectDir, '.graphqlrc.yml'); + if (fs.existsSync(rootGraphqlrc)) { + fs.unlinkSync(rootGraphqlrc); + } + }); + + it('should create React UI bundle with all required files and create .graphqlrc.yml at the project root', () => { const outputDir = path.join(projectDir, 'force-app', 'main', 'default', UI_BUNDLES_DIR); - execCmd(`template generate ui-bundle --name MyReactApp --template reactbasic --output-dir "${outputDir}"`, { - ensureExitCode: 0, - }); + const firstResult = execCmd( + `template generate ui-bundle --name MyReactApp --template reactbasic --output-dir "${outputDir}"`, + { + ensureExitCode: 0, + } + ); assert.file([ path.join(outputDir, 'MyReactApp', 'MyReactApp.uibundle-meta.xml'), path.join(outputDir, 'MyReactApp', 'index.html'), @@ -76,6 +88,58 @@ describe('template generate ui-bundle:', () => { path.join(outputDir, 'MyReactApp', 'package.json'), ]); assert.fileContent(path.join(outputDir, 'MyReactApp', 'package.json'), '"name": "base-react-app"'); + + const rootGraphqlrc = path.join(projectDir, '.graphqlrc.yml'); + assert.file(rootGraphqlrc); + assert.fileContent(rootGraphqlrc, "schema: 'schema.graphql'"); + assert.fileContent(rootGraphqlrc, "documents: './**/src/**/*.{graphql,js,ts,jsx,tsx}'"); + expect(firstResult.shellOutput.stdout).to.contain('create .graphqlrc.yml'); + + const contentBefore = fs.readFileSync(rootGraphqlrc, 'utf8'); + const mtimeBefore = fs.statSync(rootGraphqlrc).mtimeMs; + + const secondResult = execCmd( + `template generate ui-bundle --name MyReactApp2 --template reactbasic --output-dir "${outputDir}"`, + { + ensureExitCode: 0, + } + ); + assert.file([ + path.join(outputDir, 'MyReactApp2', 'MyReactApp2.uibundle-meta.xml'), + path.join(outputDir, 'MyReactApp2', 'package.json'), + ]); + expect(secondResult.shellOutput.stdout).to.not.contain('create .graphqlrc.yml'); + + const contentAfter = fs.readFileSync(rootGraphqlrc, 'utf8'); + const mtimeAfter = fs.statSync(rootGraphqlrc).mtimeMs; + expect(contentAfter).to.equal(contentBefore); + expect(mtimeAfter).to.equal(mtimeBefore); + }); + + it('should write the same project-root .graphqlrc.yml regardless of bundle output-dir', () => { + const outputDir = path.join(projectDir, 'force-app', 'main', 'default', 'custom-dir'); + execCmd( + `template generate ui-bundle --name CustomDirReactApp --template reactbasic --output-dir "${outputDir}"`, + { + ensureExitCode: 0, + } + ); + assert.file(path.join(projectDir, '.graphqlrc.yml')); + assert.fileContent(path.join(projectDir, '.graphqlrc.yml'), "schema: 'schema.graphql'"); + assert.fileContent(path.join(projectDir, '.graphqlrc.yml'), "documents: './**/src/**/*.{graphql,js,ts,jsx,tsx}'"); + }); + + it('should not overwrite an existing .graphqlrc.yml at the project root', () => { + const outputDir = path.join(projectDir, 'force-app', 'main', 'default', UI_BUNDLES_DIR); + const rootGraphqlrc = path.join(projectDir, '.graphqlrc.yml'); + const preexisting = "schema: 'preexisting.graphql'\ndocuments: 'preexisting/**/*.ts'\n"; + fs.writeFileSync(rootGraphqlrc, preexisting); + + execCmd(`template generate ui-bundle --name SecondReactApp --template reactbasic --output-dir "${outputDir}"`, { + ensureExitCode: 0, + }); + + assert.fileContent(rootGraphqlrc, "schema: 'preexisting.graphql'"); }); });