diff --git a/.changeset/blue-crews-add.md b/.changeset/blue-crews-add.md new file mode 100644 index 00000000000..e21283f9c4e --- /dev/null +++ b/.changeset/blue-crews-add.md @@ -0,0 +1,5 @@ +--- +'@sap-ux/preview-middleware': major +--- + +i18n for CAP based apps diff --git a/packages/preview-middleware/README.md b/packages/preview-middleware/README.md index e7749b71508..2dc5c8e3345 100644 --- a/packages/preview-middleware/README.md +++ b/packages/preview-middleware/README.md @@ -32,7 +32,7 @@ When this middleware is used together with the `reload-middleware`, then the ord | `editors.rta` | `array` | optional | `undefined` | Configuration allowing to add mount points for runtime adaptation | | `editors.rta.layer` | `string` | optional | `(calculated)` | Property for defining the runtime adaptation layer for changes (default is `CUSTOMER_BASE` or read from the project for adaptation projects) | | `editors.rta.endpoints` | `array` | optional | `undefined` | List of mount points for editing | -| `editors.cardGenerator` | --- | optional | `undefined` | Configuration object to enable card generation for an application (only supported for non-CAP apps). | +| `editors.cardGenerator` | --- | optional | `undefined` | Configuration object to enable card generation for an application. | | `editors.cardGenerator.path` | `string` | optional | `test/flpGeneratorSandbox.html` | The mount point of the local SAP Fiori launchpad which will be considered for card generation. | | `test` | `array` | optional | `undefined` | List of configurations for automated testing. | | `debug` | `boolean` | optional | `false` | Enables the debug output | diff --git a/packages/preview-middleware/src/base/flp.ts b/packages/preview-middleware/src/base/flp.ts index 5609c1b59e6..2753866734c 100644 --- a/packages/preview-middleware/src/base/flp.ts +++ b/packages/preview-middleware/src/base/flp.ts @@ -521,12 +521,7 @@ export class FlpSandbox { res: Response | http.ServerResponse, next: NextFunction ) => { - if (this.projectType === 'EDMXBackend') { - this.templateConfig.enableCardGenerator = !!this.cardGenerator?.path; - } else { - this.logger.warn(`The Card Generator is not available for CAP projects.`); - this.templateConfig.enableCardGenerator = false; - } + this.templateConfig.enableCardGenerator = !!this.cardGenerator?.path; await this.flpGetHandler(req, res, next); } ); @@ -1045,9 +1040,6 @@ export class FlpSandbox { * @returns {Promise} A promise that resolves when the route is added. */ async addStoreCardManifestRoute(): Promise { - if (this.projectType !== 'EDMXBackend') { - return; - } this.router.use(CARD_GENERATOR_DEFAULT.cardsStore, json()); this.logger.debug(`Add route for ${CARD_GENERATOR_DEFAULT.cardsStore}`); @@ -1067,8 +1059,37 @@ export class FlpSandbox { try { this.fs = this.fs ?? create(createStorage()); const webappPath = await getWebappPath(path.resolve(), this.fs); - const i18nPath = this.manifest['sap.app'].i18n as string; - const filePath = i18nPath ? join(webappPath, i18nPath) : join(webappPath, 'i18n', 'i18n.properties'); + const i18nConfig = this.manifest['sap.app'].i18n; + let i18nPath: string; + let supportedLocales: unknown[] = []; + let fallbackLocale: string | undefined; + + if (typeof i18nConfig === 'string') { + i18nPath = i18nConfig; + } else if (typeof i18nConfig === 'object' && i18nConfig !== null && 'bundleUrl' in i18nConfig) { + const { bundleUrl } = i18nConfig; + i18nPath = bundleUrl; + supportedLocales = i18nConfig.supportedLocales ?? []; + fallbackLocale = i18nConfig.fallbackLocale; + } else { + i18nPath = 'i18n/i18n.properties'; + } + + const requestedLocale = (req.query.locale as string) || fallbackLocale || ''; + const baseFilePath = join(webappPath, i18nPath); + const filePath = requestedLocale + ? baseFilePath.replace('.properties', `_${requestedLocale}.properties`) + : baseFilePath; + + if (requestedLocale && supportedLocales.length > 0 && !supportedLocales.includes(requestedLocale)) { + this.sendResponse( + res, + 'text/plain', + 400, + `Locale "${requestedLocale}" is not supported. Supported: ${supportedLocales.join(', ')}` + ); + return; + } const entries = (req.body as Array) || []; entries.forEach((entry) => { if (entry.comment) { @@ -1090,9 +1111,6 @@ export class FlpSandbox { * @returns {Promise} A promise that resolves when the route is added. */ async addStoreI18nKeysRoute(): Promise { - if (this.projectType !== 'EDMXBackend') { - return; - } this.router.use(CARD_GENERATOR_DEFAULT.i18nStore, json()); this.logger.debug(`Add route for ${CARD_GENERATOR_DEFAULT.i18nStore}`); diff --git a/packages/preview-middleware/test/unit/base/flp.test.ts b/packages/preview-middleware/test/unit/base/flp.test.ts index 2aa62ac0b86..a0ccaaecb16 100644 --- a/packages/preview-middleware/test/unit/base/flp.test.ts +++ b/packages/preview-middleware/test/unit/base/flp.test.ts @@ -820,9 +820,9 @@ describe('FlpSandbox', () => { afterEach(() => { fetchMock.mockRestore(); }); - + let flp: FlpSandbox; const setupMiddleware = async (mockConfig: Partial) => { - const flp = new FlpSandbox(mockConfig, mockProject, mockUtils, logger); + flp = new FlpSandbox(mockConfig, mockProject, mockUtils, logger); const manifest = JSON.parse(readFileSync(join(fixtures, 'simple-app/webapp/manifest.json'), 'utf-8')); await flp.init(manifest); @@ -830,8 +830,8 @@ describe('FlpSandbox', () => { app.use(flp.router); server = supertest(app); + return { flp, app }; }; - beforeAll(async () => { await setupMiddleware(mockConfig as MiddlewareConfig); }); @@ -926,62 +926,68 @@ describe('FlpSandbox', () => { expect(createPropertiesI18nEntriesMock).toHaveBeenCalledTimes(1); expect(createPropertiesI18nEntriesMock).toHaveBeenCalledWith(filePath, newI18nEntry); }); - }); + test('should handle string i18n path', async () => { + const newI18nEntry = [{ key: 'HELLO', value: 'Hello World' }]; + const manifest = JSON.parse(readFileSync(join(fixtures, 'simple-app/webapp/manifest.json'), 'utf-8')); + manifest['sap.app'].i18n = 'i18n/custom.properties'; + await flp.init(manifest); + const response = await server.post(`${CARD_GENERATOR_DEFAULT.i18nStore}?locale=de`).send(newI18nEntry); + const webappPath = await getWebappPath(path.resolve()); + const expectedPath = join(webappPath, 'i18n', 'custom_de.properties'); - describe('router with enableCardGenerator in CAP project', () => { - let server!: SuperTest; - const mockConfig = { - editors: { - cardGenerator: { - path: 'test/flpCardGeneratorSandbox.html' - } - } - }; + expect(response.status).toBe(201); + expect(response.text).toBe('i18n file updated.'); + expect(createPropertiesI18nEntriesMock).toHaveBeenCalledWith(expectedPath, newI18nEntry); + }); - let mockFsPromisesWriteFile: jest.Mock; + test('should handle bundleUrl with supported and fallback locales', async () => { + const newI18nEntry = [{ key: 'GREETING', value: 'Hallo Welt' }]; + const manifest = JSON.parse(readFileSync(join(fixtures, 'simple-app/webapp/manifest.json'), 'utf-8')); + manifest['sap.app'].i18n = { + bundleUrl: 'i18n/i18n.properties', + supportedLocales: ['de', 'es'], + fallbackLocale: 'de' + }; + await flp.init(manifest); - beforeEach(() => { - mockFsPromisesWriteFile = jest.fn(); - promises.writeFile = mockFsPromisesWriteFile; - }); + const response = await server.post(`${CARD_GENERATOR_DEFAULT.i18nStore}?locale=de`).send(newI18nEntry); + const webappPath = await getWebappPath(path.resolve()); + const expectedPath = join(webappPath, 'i18n', 'i18n_de.properties'); - afterEach(() => { - fetchMock.mockRestore(); + expect(response.status).toBe(201); + expect(response.text).toBe('i18n file updated.'); + expect(createPropertiesI18nEntriesMock).toHaveBeenCalledWith(expectedPath, newI18nEntry); }); - const setupMiddleware = async (mockConfig: Partial) => { - const flp = new FlpSandbox(mockConfig, mockProject, mockUtils, logger); + test('should reject unsupported locale', async () => { + const newI18nEntry = [{ key: 'GREETING', value: 'Bonjour' }]; + const manifest = JSON.parse(readFileSync(join(fixtures, 'simple-app/webapp/manifest.json'), 'utf-8')); - jest.spyOn(projectAccess, 'getProjectType').mockImplementationOnce(() => Promise.resolve('CAPNodejs')); + manifest['sap.app'].i18n = { + bundleUrl: 'i18n/i18n.properties', + supportedLocales: ['de', 'es'] + }; await flp.init(manifest); - const app = express(); - app.use(flp.router); - - server = supertest(app); - }; + const response = await server.post(`${CARD_GENERATOR_DEFAULT.i18nStore}?locale=fr`).send(newI18nEntry); - beforeAll(async () => { - await setupMiddleware(mockConfig as MiddlewareConfig); + expect(response.status).toBe(400); + expect(response.text).toContain('Locale "fr" is not supported'); }); + test('should fallback to default i18n/i18n.properties if no i18n defined', async () => { + const newI18nEntry = [{ key: 'HELLO', value: 'Hello World' }]; - test('GET /test/flpCardGeneratorSandbox.html', async () => { - const response = await server.get( - `${CARD_GENERATOR_DEFAULT.previewGeneratorSandbox}?sap-ui-xx-viewCache=false` - ); - expect(response.status).toBe(200); - expect(response.type).toBe('text/html'); - expect(logger.warn).toHaveBeenCalledWith('The Card Generator is not available for CAP projects.'); - }); + const manifest = JSON.parse(readFileSync(join(fixtures, 'simple-app/webapp/manifest.json'), 'utf-8')); + delete manifest['sap.app'].i18n; + await flp.init(manifest); + const response = await server.post(`${CARD_GENERATOR_DEFAULT.i18nStore}`).send(newI18nEntry); - test('POST /cards/store with payload', async () => { - const response = await server.post(CARD_GENERATOR_DEFAULT.cardsStore).send('hello'); - expect(response.status).toBe(404); - }); + const webappPath = await getWebappPath(path.resolve()); + const expectedPath = join(webappPath, 'i18n', 'i18n.properties'); - test('POST /editor/i18n with payload', async () => { - const response = await server.post(CARD_GENERATOR_DEFAULT.i18nStore).send('hello'); - expect(response.status).toBe(404); + expect(response.status).toBe(201); + expect(response.text).toBe('i18n file updated.'); + expect(createPropertiesI18nEntriesMock).toHaveBeenCalledWith(expectedPath, newI18nEntry); }); });