diff --git a/packages/core/expressions/README.md b/packages/core/expressions/README.md index 8e9ae7bd60..d59d17cb68 100644 --- a/packages/core/expressions/README.md +++ b/packages/core/expressions/README.md @@ -18,6 +18,7 @@ Reusable components to support [Kong's expressions language](https://docs.konghq - `vue` must be initialized in the host application - [`monaco-editor`](https://www.npmjs.com/package/monaco-editor) is required as a dependency in the host application - [`vite-plugin-monaco-editor`](https://www.npmjs.com/package/vite-plugin-monaco-editor) is a required Vite plugin to bundle the Monaco Editor and its web workers +- [`@kong-ui-public/forms`](https://www.npmjs.com/package/@kong-ui-public/forms) is an optional dependency required for the `RouterPlaygroundModal` component ## Usage @@ -27,6 +28,7 @@ Install required `dependencies` in your host application: ```sh yarn add monaco-editor +yarn add @kong-ui-public/forms # optional: required for `RouterPlaygroundModal` component ``` Install required `devDependencies` in your host application: @@ -58,9 +60,12 @@ Import the component(s) in your host application as well as the package styles: ```ts import { asyncInit, ExpressionsEditor } from '@kong-ui-public/expressions' import '@kong-ui-public/expressions/dist/style.css' +import '@kong-ui-public/forms/dist/style.css' // optional: required for `RouterPlaygroundModal` component + +app.component('VueFormGenerator', VueFormGenerator) // optional: required for `RouterPlaygroundModal` component ``` -This package utilizes [vite-plugin-top-level-await](https://github.com/Menci/vite-plugin-top-level-await) to transform code in order to use top-level await on older browsers. To load the WASM correctly, you must use `await` or `Promise.then` to wait the imported `asyncInit` before using any other imported values. +This package utilizes [vite-plugin-top-level-await](https://github.com/Menci/vite-plugin-top-level-await) to transform code in order to use top-level await on older browsers. To load the WASM correctly, you must use `await` or `Promise.then` to wait the imported `asyncInit` before using any other imported values. For example: @@ -77,4 +82,5 @@ You can also make use of Vue's experimental [Suspense](https://vuejs.org/guide/b ## Individual component documentation -- [``](docs/expressions-editor.md) +- [``](docs/expressions-editor.md) +- [``](docs/router-playground-modal.md) diff --git a/packages/core/expressions/docs/expressions-editor.md b/packages/core/expressions/docs/expressions-editor.md index e026f42828..c4db6b6406 100644 --- a/packages/core/expressions/docs/expressions-editor.md +++ b/packages/core/expressions/docs/expressions-editor.md @@ -1,4 +1,4 @@ -# ExpressionsEditor.vue +# ExpressionsEditor A Monaco-based editor with autocomplete and syntax highlighting support for the expressions language. diff --git a/packages/core/expressions/docs/router-playground-modal.md b/packages/core/expressions/docs/router-playground-modal.md new file mode 100644 index 0000000000..24aa13d711 --- /dev/null +++ b/packages/core/expressions/docs/router-playground-modal.md @@ -0,0 +1,86 @@ +# RouterPlaygroundModal + +The `RouterPlaygroundModal` component is a modal that allows the user to edit a route expression and see the result of the expression evaluation. + +- [Requirements](#requirements) +- [Usage](#usage) + - [Install](#install) + - [Props](#props) + - [Events](#events) + - [Usage example](#usage-example) +- [TypeScript definitions](#typescript-definitions) + +## Requirements + +[See requirements for the `@kong-ui-public/expressions` package.](../README.md#requirements) + +## Usage + +### Install + +[See instructions for installing the `@kong-ui-public/expressions` package.](../README.md#install) + +### Props + +#### `isVisible` + +- type: `boolean` +- required: `true` + +Controls whether the modal is visible or not. + +#### `localstorageKey` + +- type: `String` +- required: `false` +- default: `kong-manager-router-playground-requests` + +The key to use for storing the playground requests in the local storage. + +#### `hideEditorActions` + +- type: `boolean` +- required: `false` +- default: `false` + +Controls whether the editor actions should be hidden or not. + +#### `initialExpression` + +- type: `string` +- required: `false` +- default: `''` + +The initial expression to be displayed in the editor. + +### Events + +#### change + +A `change` event is emitted when the expression has been updated. + +#### commit + +A `commit` event is emitted when the expression has been committed. + +#### cancel + +A `cancel` event is emitted when the modal's cancel button has been clicked. + +#### notify + +A `notify` event is emitted when a Toast is triggered. The event payload is an object with the following properties: +- `message`: + - type: `string` + - The message to display in the Toast. +- `type`: + - type: `'success' | 'error' | 'warning' | 'info'` + - The type of Toast to display. + +### Usage example + +Please refer to the [sandbox](../sandbox/App.vue). + +## TypeScript definitions + +TypeScript definitions are bundled with the package and can be directly imported into your host application. diff --git a/packages/core/expressions/package.json b/packages/core/expressions/package.json index b0b387610c..6a6599c7a3 100644 --- a/packages/core/expressions/package.json +++ b/packages/core/expressions/package.json @@ -37,9 +37,11 @@ "test:unit:open": "cross-env FORCE_COLOR=1 vitest --ui" }, "devDependencies": { + "@kong-ui-public/forms": "workspace:^", "@kong/atc-router": "1.6.0-rc.1", "@kong/design-tokens": "1.15.3", "@kong/kongponents": "9.1.7", + "@types/uuid": "^9.0.8", "monaco-editor": "0.21.3", "vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-top-level-await": "^1.4.1", @@ -66,10 +68,14 @@ "peerDependencies": { "@kong/atc-router": "^1.6.0-rc.1", "@kong/kongponents": "^9.1.7", + "@kong-ui-public/forms": "workspace:^", "monaco-editor": "0.21.3", "vue": "^3.4.31" }, "dependencies": { - "@kong-ui-public/core": "workspace:^" + "@kong-ui-public/core": "workspace:^", + "@kong-ui-public/i18n": "workspace:^", + "@kong/icons": "^1.14.2", + "uuid": "^9.0.1" } } diff --git a/packages/core/expressions/sandbox/App.vue b/packages/core/expressions/sandbox/App.vue index 6ded682b41..e2f4b3d871 100644 --- a/packages/core/expressions/sandbox/App.vue +++ b/packages/core/expressions/sandbox/App.vue @@ -29,6 +29,25 @@ @parse-result-update="onParseResultUpdate" /> + Test with Router Playground + + + + +

ParseResult:

{{ parseResult }}
@@ -40,7 +59,7 @@ - - diff --git a/packages/core/expressions/sandbox/index.ts b/packages/core/expressions/sandbox/index.ts index 52668a0a54..70c15aa2a0 100644 --- a/packages/core/expressions/sandbox/index.ts +++ b/packages/core/expressions/sandbox/index.ts @@ -1,6 +1,9 @@ +import '@kong/kongponents/dist/style.css' import { createApp } from 'vue' import App from './App.vue' +import Kongponents from '@kong/kongponents' const app = createApp(App) +app.use(Kongponents) app.mount('#app') diff --git a/packages/core/expressions/src/components/MonacoEditor.vue b/packages/core/expressions/src/components/MonacoEditor.vue new file mode 100644 index 0000000000..89a26f388e --- /dev/null +++ b/packages/core/expressions/src/components/MonacoEditor.vue @@ -0,0 +1,83 @@ + + + diff --git a/packages/core/expressions/src/components/PageHeader.vue b/packages/core/expressions/src/components/PageHeader.vue new file mode 100644 index 0000000000..598438945c --- /dev/null +++ b/packages/core/expressions/src/components/PageHeader.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/packages/core/expressions/src/components/RequestCard.vue b/packages/core/expressions/src/components/RequestCard.vue new file mode 100644 index 0000000000..4ef8e37742 --- /dev/null +++ b/packages/core/expressions/src/components/RequestCard.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/packages/core/expressions/src/components/RequestImportModal.vue b/packages/core/expressions/src/components/RequestImportModal.vue new file mode 100644 index 0000000000..7ece82214f --- /dev/null +++ b/packages/core/expressions/src/components/RequestImportModal.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/packages/core/expressions/src/components/RequestModal.vue b/packages/core/expressions/src/components/RequestModal.vue new file mode 100644 index 0000000000..7112bfaa22 --- /dev/null +++ b/packages/core/expressions/src/components/RequestModal.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/packages/core/expressions/src/components/RouterPlayground.cy.ts b/packages/core/expressions/src/components/RouterPlayground.cy.ts new file mode 100644 index 0000000000..46cf4f0699 --- /dev/null +++ b/packages/core/expressions/src/components/RouterPlayground.cy.ts @@ -0,0 +1,186 @@ +// fixme(zehao): this test cannot run without vite plugins in ../../vite.config.ts +// it gives an erroe when importing RouterPlayground +// it should be fixed after refactoring whole cypress tests configuration method, see the conversation in PR: 1497 + +// import RouterPlayground from './RouterPlayground.vue' + +// describe('', () => { +// beforeEach(() => { +// cy.viewport(800, 800) +// }) + +// it('should show router playground', () => { +// cy.mount(RouterPlayground) + +// cy.getTestId('expression-header').should('be.visible') +// cy.getTestId('expressions-editor').should('be.visible') +// cy.getTestId('expressions-inspirations').should('be.visible') +// cy.getTestId('requests-header').should('be.visible') +// cy.getTestId('btn-commit').should('be.visible') +// cy.getTestId('btn-import').should('be.visible') +// cy.getTestId('empty-state-requests').should('be.visible') +// }) + +// it('initial expression', () => { +// cy.mount(RouterPlayground, { +// props: { +// initialExpression: 'http.host == "localhost"', +// }, +// }) + +// cy.getTestId('expressions-editor').contains('http.host == "localhost"') +// cy.getTestId('expressions-inspirations').should('not.exist') +// }) + +// it('inspirations', () => { +// cy.mount(RouterPlayground) + +// cy.getTestId('btn-inspiration-0').click() +// cy.getTestId('expressions-editor').contains('http.host == "localhost"') +// cy.getTestId('btn-inspiration-0').should('not.exist') +// }) + +// it('expression changed', () => { +// const onChangeSpy = cy.spy().as('onChangeSpy') +// const onCommitSpy = cy.spy().as('onCommitSpy') +// cy.mount(RouterPlayground, { +// props: { +// initialExpression: 'http.host == "localhost"', +// onChange: onChangeSpy, +// onCommit: onCommitSpy, +// }, +// }) +// cy.get('.view-lines').type(' && http.method == "GET"') +// cy.get('@onChangeSpy').should('have.been.calledWith', 'http.host == "localhost" && http.method == "GET"') + +// cy.getTestId('btn-commit').click() +// cy.get('@onCommitSpy').should('have.been.calledWith', 'http.host == "localhost" && http.method == "GET"') +// }) + +// it('requests placeholder', () => { +// cy.mount(RouterPlayground) + +// cy.getTestId('empty-state-requests').should('be.visible') +// }) + +// function addRequest({ +// url, +// method, +// headers, +// }: { +// url: string +// method: string +// headers?: Record[] +// }) { +// cy.getTestId('btn-add-request').click() +// cy.getTestId('url-input').should('be.visible') +// cy.getTestId('url-input').type(url) + +// cy.get('#method').select(method) + +// if (headers?.length) { +// headers.forEach((header, index) => { +// cy.getTestId('keyname-input').type(header.key) +// cy.getTestId('add-key').click() +// cy.get(`:nth-child(${index + 3}) > .form-control`).type(header.value) +// }) +// } + +// cy.getTestId('modal-action-button').click() +// } + +// it('add/remove requests', () => { +// cy.mount(RouterPlayground) + +// addRequest({ +// url: 'http://localhost:8000', +// method: 'GET', +// headers: [ +// { key: 'Foo', value: 'bar' }, +// ], +// }) + +// cy.get('.request-card').should('have.length', 1) +// cy.get('.request-card').first().contains('http://localhost:8000') +// cy.get('.request-card').first().find('.close-btn').click({ force: true }) +// cy.get('.request-card').should('not.exist') +// }) + +// it('matching requests', () => { +// cy.mount(RouterPlayground) +// const requestIds: string[] = [] + +// addRequest({ +// url: 'http://localhost:8000', +// method: 'GET', +// headers: [ +// { key: 'Foo', value: 'bar' }, +// ], +// }) + +// addRequest({ +// url: 'https://www.konghq.com', +// method: 'GET', +// }) + +// // eslint-disable-next-line cypress/unsafe-to-chain-command +// cy.get('.request-card').each($card => { +// requestIds.push($card.data('testid')) +// }).then(() => { +// cy.get('.view-lines').type('http.host == "localhost"') +// cy.getTestId(requestIds[0]).should('have.class', 'active') +// cy.getTestId(requestIds[1]).should('not.have.class', 'active') +// }) +// }) + +// it('cache requests', () => { +// cy.clearAllLocalStorage() + +// cy.mount(RouterPlayground) + +// addRequest({ +// url: 'http://localhost:8000', +// method: 'GET', +// }) + +// cy.mount(RouterPlayground) +// cy.get('.request-card').should('have.length', 1) + +// cy.getTestId('clear-requests-link').click() +// cy.getTestId('modal-action-button').click() + +// cy.mount(RouterPlayground) +// cy.get('.request-card').should('not.exist') +// }) + +// const REQUESTS_TEXT = '[{"method":"POST","headers":{},"protocol":"http","host":"localhost","port":8080,"path":"/","id":"42eafd0a-287b-441d-b88f-f6fe4b44da0d"},{"method":"GET","headers":{},"protocol":"https","host":"konghq.com","port":443,"path":"/abc","id":"da8f04a9-c8c5-495d-a26c-f7bc132a3a64"}]' + +// it('import/export requests', () => { +// cy.mount(RouterPlayground) +// cy.getTestId('btn-import').click() +// cy.getTestId('import-requests-editor').should('be.visible') +// cy.getTestId('import-requests-editor').find('.view-lines').type(REQUESTS_TEXT, { +// parseSpecialCharSequences: false, +// }) +// cy.getTestId('modal-action-button').click() +// cy.get('.request-card').should('have.length', 2) +// cy.get('.request-card').first().contains('http://localhost:8080/') +// cy.get('.request-card:nth-child(2)').contains('https://konghq.com/abc') + +// cy.window().then(() => { +// // eslint-disable-next-line cypress/unsafe-to-chain-command +// cy.getTestId('btn-export').focus().click() +// cy.assertValueCopiedToClipboard(REQUESTS_TEXT) +// }) +// }) + +// }) + +// // todo(zehao): add this to global commands +// // Cypress.Commands.add('assertValueCopiedToClipboard', value => { +// // cy.window().then(win => { +// // win.navigator.clipboard.readText().then(text => { +// // expect(text).to.eq(value) +// // }) +// // }) +// // }) diff --git a/packages/core/expressions/src/components/RouterPlayground.vue b/packages/core/expressions/src/components/RouterPlayground.vue new file mode 100644 index 0000000000..b31390340c --- /dev/null +++ b/packages/core/expressions/src/components/RouterPlayground.vue @@ -0,0 +1,511 @@ + + + + + diff --git a/packages/core/expressions/src/components/RouterPlaygroundModal.vue b/packages/core/expressions/src/components/RouterPlaygroundModal.vue new file mode 100644 index 0000000000..df23c35b05 --- /dev/null +++ b/packages/core/expressions/src/components/RouterPlaygroundModal.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/packages/core/expressions/src/components/SupportText.vue b/packages/core/expressions/src/components/SupportText.vue new file mode 100644 index 0000000000..23d17ffe9e --- /dev/null +++ b/packages/core/expressions/src/components/SupportText.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/core/expressions/src/composables/index.ts b/packages/core/expressions/src/composables/index.ts new file mode 100644 index 0000000000..3893dfc828 --- /dev/null +++ b/packages/core/expressions/src/composables/index.ts @@ -0,0 +1,6 @@ +import useI18n from './useI18n' + +// All composables must be exported as part of the default object for Cypress test stubs +export default { + useI18n, +} diff --git a/packages/core/expressions/src/composables/useI18n.ts b/packages/core/expressions/src/composables/useI18n.ts new file mode 100644 index 0000000000..337ff02756 --- /dev/null +++ b/packages/core/expressions/src/composables/useI18n.ts @@ -0,0 +1,11 @@ +import { createI18n, i18nTComponent } from '@kong-ui-public/i18n' +import english from '../locales/en.json' + +export default function useI18n() { + const i18n = createI18n('en-us', english) + + return { + i18n, + i18nT: i18nTComponent(i18n), // Translation component + } +} diff --git a/packages/core/expressions/src/definitions.ts b/packages/core/expressions/src/definitions.ts new file mode 100644 index 0000000000..bf8a9dc4ea --- /dev/null +++ b/packages/core/expressions/src/definitions.ts @@ -0,0 +1,25 @@ +export type Request = { + id: string; + protocol: string; + host: string; + port: number; + path: string; + method?: string; + headers?: { [k: string]: string | string[] }; + sni?: string; +} + +export const DEFAULT_PROTOCOL_PORTS = { + http: 80, + https: 443, + grpc: 80, + grpcs: 443, + ws: 80, + wss: 443, +} + +export const HTTP_PROTOCOLS = new Set(['http', 'https']) + +export const SECURED_PROTOCOLS = new Set(['https', 'grpcs', 'wss']) + +export const SUPPORTED_PROTOCOLS = new Set(Object.keys(DEFAULT_PROTOCOL_PORTS)) diff --git a/packages/core/expressions/src/external-links.ts b/packages/core/expressions/src/external-links.ts new file mode 100644 index 0000000000..6aaa75eb65 --- /dev/null +++ b/packages/core/expressions/src/external-links.ts @@ -0,0 +1,3 @@ +export default { + expressionsLanguageDoc: 'https://docs.konghq.com/gateway/latest/reference/expressions-language/language-references/', +} diff --git a/packages/core/expressions/src/index.ts b/packages/core/expressions/src/index.ts index ec9ebd21b3..b4916869b8 100644 --- a/packages/core/expressions/src/index.ts +++ b/packages/core/expressions/src/index.ts @@ -1,8 +1,9 @@ import ExpressionsEditor from './components/ExpressionsEditor.vue' +import RouterPlaygroundModal from './components/RouterPlaygroundModal.vue' export * as Atc from '@kong/atc-router' export * from './schema' -export { ExpressionsEditor } +export { ExpressionsEditor, RouterPlaygroundModal } declare const asyncInit: Promise export { asyncInit } diff --git a/packages/core/expressions/src/locales/en.json b/packages/core/expressions/src/locales/en.json new file mode 100644 index 0000000000..f9c80b9d8d --- /dev/null +++ b/packages/core/expressions/src/locales/en.json @@ -0,0 +1,64 @@ +{ + "comma": ", ", + "requestImport": { + "warning": "Warning: All saved requests {boldText} with the imported ones.", + "warningBoldText": "will be removed and replaced", + "title": "Import requests from JSON", + "jsonError": "Expecting a JSON array" + }, + "requestModal": { + "title": "Add a request", + "help": "Supported protocols: {protocols}", + "noneSelectedText": "Nothing Selected...", + "methodInputPlaceholder": "Enter a Method", + "headersInputPlaceholder": "Enter header name", + "headerButtonLabel": "Header Values", + "headerHint": "e.g. my-header", + "headerValueInputPlaceholder": "Comma separated list of header values", + "headerValueHint": "e.g. value1, value2, value 3", + "sniPlaceholder": "Enter an SNI", + "invalidRequest": "Invalid request: {err}", + "unsupportedProtocol": "Unsupported protocol. (Supported protocols: {protocols})" + }, + "request": { + "SNI": "SNI", + "headers": "headers", + "Method": "Method", + "Headers": "Headers" + }, + "routerPlayground": { + "help": "A playground where you can test out the Kong router Expressions. {link}", + "learnMore": "Learn more", + "expressions": "Expression", + "addToRoute": "Add to Route", + "inspiration": "Inspiration for Quickstart", + "importTooltip": "Import requests in JSON format", + "import": "Import", + "exportTooltip": "Export all requests as JSON to clipboard", + "export": "Export", + "add": "Add", + "addRequest": "Add a request", + "noRequests": "No requests", + "noRequestsDescription": "Add requests to test out route expressions.", + "clearRequests": "Requests appearing here are saved locally within the browser. {link} to clear all saved requests", + "click": "Click here", + "clearRequestsPrompt": "All saved requests will be removed from the browser. This operation is permanent and cannot be undone. Would you like to proceed?", + "notifyCopy": "Successfully copied to clipboard", + "notifyClear": "Successfully cleared all requests", + "notifyImport": "Successfully imported requests from JSON" + }, + "routerPlaygroundModal": { + "actionButton": "Add to Route", + "title": "Router Playground" + }, + "errors": { + "requiredProtocol": "Protocol is required", + "unsupportedProtocols": "Protocol is unsupported (Supported protocols: {protocols})", + "requiredHost": "Host is required", + "requiredPath": "Path is required", + "sniNotAvailable": "SNI is not available for \"{protocol}\" protocol", + "requiredMethod": "Method is required for \"{protocol}\" protocol", + "methodShouldCapitalized": "Method should be all capitalized", + "failedToImport": "Failed to import request #{i}: {err}" + } +} diff --git a/packages/core/expressions/src/schema.ts b/packages/core/expressions/src/schema.ts index 13681b446c..45cb62e272 100644 --- a/packages/core/expressions/src/schema.ts +++ b/packages/core/expressions/src/schema.ts @@ -42,14 +42,17 @@ export const STREAM_SCHEMA_DEFINITION: SchemaDefinition = { IpAddr: ['net.src.ip', 'net.dst.ip'], } +export const HTTP_BASED_PROTOCOLS = ['http', 'https', 'grpc', 'grpcs', 'ws', 'wss'] +export const STREAM_BASED_PROTOCOLS = ['tcp', 'udp', 'tls', 'tls_passthrough'] + export const PROTOCOL_TO_SCHEMA = (() => { const s: Record = {} - for (const protocol of ['http', 'https', 'grpc', 'grpcs', 'ws', 'wss']) { + for (const protocol of HTTP_BASED_PROTOCOLS) { s[protocol] = { name: protocol, definition: HTTP_SCHEMA_DEFINITION } } - for (const protocol of ['tcp', 'udp', 'tls', 'tls_passthrough']) { + for (const protocol of STREAM_BASED_PROTOCOLS) { s[protocol] = { name: protocol, definition: STREAM_SCHEMA_DEFINITION } } diff --git a/packages/core/expressions/src/utils.ts b/packages/core/expressions/src/utils.ts new file mode 100644 index 0000000000..cd5e771052 --- /dev/null +++ b/packages/core/expressions/src/utils.ts @@ -0,0 +1,62 @@ +import { v4 as uuidv4 } from 'uuid' +import { + DEFAULT_PROTOCOL_PORTS, HTTP_PROTOCOLS, SECURED_PROTOCOLS, SUPPORTED_PROTOCOLS, type Request, +} from './definitions' +import composables from './composables' + +export const validateRequest = (request: Request) => { + if (!SUPPORTED_PROTOCOLS.has(request.protocol)) throw new Error(`Unsupported protocol: ${request.protocol}. (Supported protocols: ${Array.from(SUPPORTED_PROTOCOLS.values()).join(', ')})`) +} + +/** + * Transforms and checks if the request is valid + * @param request + * @returns + */ +export const transformCheckRequest = (request: Partial): string | undefined => { + const { i18n } = composables.useI18n() + + if (request.id === undefined) { + request.id = uuidv4() + } + + if (!request.protocol) { + return i18n.t('errors.requiredProtocol') + } + + if (!SUPPORTED_PROTOCOLS.has(request.protocol)) { + return i18n.t('errors.unsupportedProtocols', { + protocols: Array.from(SUPPORTED_PROTOCOLS).join(i18n.t('comma')), + }) + } + + if (!request.port) { + request.port = (DEFAULT_PROTOCOL_PORTS as any)[request.protocol] + } + + if (!request.host) { + return i18n.t('errors.requiredHost') + } + + if (!request.path) { + return i18n.t('errors.requiredPath') + } + + if (!SECURED_PROTOCOLS.has(request.protocol) && request.sni) { + return i18n.t('errors.sniNotAvailable', { protocol: request.protocol }) + } + + if (HTTP_PROTOCOLS.has(request.protocol)) { + if (Array.isArray(request.method)) { + request.method = request.method[0] + } + + if (!request.method) { + return i18n.t('errors.requiredMethod', { protocol: request.protocol }) + } + + if (!/^[A-Z]+$/g.test(request.method)) { + return i18n.t('errors.methodShouldCapitalized') + } + } +} diff --git a/packages/core/expressions/vite.config.ts b/packages/core/expressions/vite.config.ts index ec1423e694..62ed455b16 100644 --- a/packages/core/expressions/vite.config.ts +++ b/packages/core/expressions/vite.config.ts @@ -21,7 +21,7 @@ const config = mergeConfig(sharedViteConfig, defineConfig({ fileName: (format) => `${sanitizedPackageName}.${format}.js`, }, rollupOptions: { - external: ['monaco-editor'], + external: ['monaco-editor', '@kong-ui-public/forms', '@kong-ui-public/forms/dist/style.css'], }, }, plugins: [ @@ -29,11 +29,11 @@ const config = mergeConfig(sharedViteConfig, defineConfig({ topLevelAwait({ promiseExportName: 'asyncInit', }), - // We don't need this plugin to bundle the library. Only for sandbox previews. - // See: https://github.com/vdesjs/vite-plugin-monaco-editor/issues/21 - ...process.env.USE_SANDBOX + // This plugin is only used in the sandbox & testing environment + // It generates extra files in dist folder which are not need in library build + ...(process.env.USE_SANDBOX ? [((monacoEditorPlugin as any).default as typeof monacoEditorPlugin)({})] - : [], + : []), ], })) diff --git a/packages/entities/entities-routes/docs/route-form.md b/packages/entities/entities-routes/docs/route-form.md index f5b92e3093..99472de710 100644 --- a/packages/entities/entities-routes/docs/route-form.md +++ b/packages/entities/entities-routes/docs/route-form.md @@ -109,7 +109,7 @@ Show/hide Route name field. If `true`, `name` field is stripped from payload obj - required: `false` - default: `false` -Show/hide Service Select field. Should be used in case of manual adding `service_id` in payload. +Show/hide Service Select field. Should be used in case of manually adding `service_id` in payload. #### `showTagsFiledUnderAdvanced` @@ -154,6 +154,13 @@ Show tags field under _Advanced Fields_ collapse or in it's default place (befor - default: `undefined` - Text to show in the tooltip of the Expressions config tab. +#### `showExpressionsModalEntry` + +- type: `Boolean` +- required: `false` +- default: `false` + +Show/hide the Expressions modal entry button. ### Slots @@ -202,6 +209,16 @@ A `@update` event is emitted when the form is saved. The event payload is the Ro A `@model-updated` event is emitted when any form value was changed. The event payload is the Route payload object. +#### notify + +A `@notify` event is emitted when a Toast is called. The event payload is an object with the following properties: +- `message`: + - type: `string` + - The message to display in the Toast. +- `type`: + - type: `'success' | 'error' | 'warning' | 'info'` + - The type of Toast to display. + ### Usage example Please refer to the [sandbox](../sandbox/pages/RouteListPage.vue). The form is accessible by clicking the `+ New Route` button or `Edit` action of an existing Route. diff --git a/packages/entities/entities-routes/package.json b/packages/entities/entities-routes/package.json index d2701c9753..d4cec5843c 100644 --- a/packages/entities/entities-routes/package.json +++ b/packages/entities/entities-routes/package.json @@ -67,7 +67,7 @@ "extends": "../../../package.json" }, "distSizeChecker": { - "errorLimit": "700KB" + "errorLimit": "800KB" }, "dependencies": { "@kong-ui-public/entities-shared": "workspace:^", diff --git a/packages/entities/entities-routes/sandbox/pages/RouteFormPage.vue b/packages/entities/entities-routes/sandbox/pages/RouteFormPage.vue index 11766c81a3..3de2c2e16f 100644 --- a/packages/entities/entities-routes/sandbox/pages/RouteFormPage.vue +++ b/packages/entities/entities-routes/sandbox/pages/RouteFormPage.vue @@ -26,6 +26,7 @@ :config="konnectConfig" :route-flavors="routeFlavors" :route-id="routeId" + show-expressions-modal-entry @error="onError" @update="onUpdate" > @@ -55,6 +56,7 @@ :config="kongManagerConfig" :route-flavors="routeFlavors" :route-id="routeId" + show-expressions-modal-entry @error="onError" @update="onUpdate" > diff --git a/packages/entities/entities-routes/src/components/RouteForm.cy.ts b/packages/entities/entities-routes/src/components/RouteForm.cy.ts index 68c643936d..4244db4938 100644 --- a/packages/entities/entities-routes/src/components/RouteForm.cy.ts +++ b/packages/entities/entities-routes/src/components/RouteForm.cy.ts @@ -3,6 +3,7 @@ import RouteForm from './RouteForm.vue' import { route, routeExpressions, services } from '../../fixtures/mockData' import { EntityBaseForm } from '@kong-ui-public/entities-shared' import type { RouteHandler } from 'cypress/types/net-stubbing' +import { HTTP_BASED_PROTOCOLS, STREAM_BASED_PROTOCOLS } from '@kong-ui-public/expressions' const cancelRoute = { name: 'route-list' } @@ -831,6 +832,83 @@ describe('', { viewportHeight: 700, viewportWidth: 700 }, () => { cy.getTestId('select-item-ws').should('not.exist') cy.getTestId('select-item-wss').should('not.exist') }) + + describe('RoutePlayground', () => { + beforeEach(() => { + cy.on('uncaught:exception', err => !err.message.includes('ResizeObserver loop completed with undelivered notifications.')) + }) + + it('route playground entry should hide if select stream-based protocols', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKM, + routeFlavors: TRADITIONAL_EXPRESSIONS, + showExpressionsModalEntry: true, + }, + }) + + cy.get('#expressions-tab').click() + + STREAM_BASED_PROTOCOLS.forEach((protocol) => { + cy.getTestId('route-form-protocols').click({ force: true }) + cy.get(`[data-testid='select-item-${protocol}']`).click() + cy.getTestId('open-router-playground').should('have.class', 'disabled') + cy.getTestId('open-router-playground').click() + cy.get('.router-playground-wrapper').should('not.exist') + }) + }) + + it('route playground entry should show if select http-based protocols', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKM, + routeFlavors: TRADITIONAL_EXPRESSIONS, + showExpressionsModalEntry: true, + }, + }) + + cy.get('#expressions-tab').click() + + HTTP_BASED_PROTOCOLS.forEach((protocol) => { + cy.getTestId('route-form-protocols').click({ force: true }) + cy.get(`[data-testid='select-item-${protocol}']`).click() + cy.getTestId('open-router-playground').should('not.have.class', 'disabled') + }) + }) + + it('route playground should have initial expression value', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKM, + routeFlavors: TRADITIONAL_EXPRESSIONS, + showExpressionsModalEntry: true, + }, + }) + + cy.get('#expressions-tab').click() + cy.get('.monaco-editor').first().as('monacoEditor').click() + cy.get('@monacoEditor').type('http.path == "/kong"') + cy.getTestId('open-router-playground').click() + cy.get('.router-playground > [data-testid="expressions-editor"]').contains('http.path == "/kong"') + }) + + it('should expression updated when save in route playground', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKM, + routeFlavors: TRADITIONAL_EXPRESSIONS, + showExpressionsModalEntry: true, + }, + }) + cy.get('#expressions-tab').click() + cy.get('.monaco-editor').first().as('monacoEditor').click() + cy.get('@monacoEditor').type('http.path == "/kong"') + cy.getTestId('open-router-playground').click() + cy.get('.router-playground > [data-testid="expressions-editor"]').type(' && http.method == "GET"') + cy.getTestId('modal-action-button').click() + cy.get('@monacoEditor').contains('http.path == "/kong" && http.method == "GET"') + }) + }) }) describe('Konnect', { viewportHeight: 700, viewportWidth: 700 }, () => { @@ -1609,6 +1687,83 @@ describe('', { viewportHeight: 700, viewportWidth: 700 }, () => { cy.get('@onUpdateSpy').should('have.been.calledOnce') }) + + describe('RoutePlayground', () => { + beforeEach(() => { + cy.on('uncaught:exception', err => !err.message.includes('ResizeObserver loop completed with undelivered notifications.')) + }) + + it('route playground entry should hide if select stream-based protocols', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKonnect, + routeFlavors: TRADITIONAL_EXPRESSIONS, + showExpressionsModalEntry: true, + }, + }) + + cy.get('#expressions-tab').click() + + STREAM_BASED_PROTOCOLS.forEach((protocol) => { + cy.getTestId('route-form-protocols').click({ force: true }) + cy.get(`[data-testid='select-item-${protocol}']`).click() + cy.getTestId('open-router-playground').should('have.class', 'disabled') + cy.getTestId('open-router-playground').click() + cy.get('.router-playground-wrapper').should('not.exist') + }) + }) + + it('route playground entry should show if select http-based protocols', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKonnect, + routeFlavors: TRADITIONAL_EXPRESSIONS, + showExpressionsModalEntry: true, + }, + }) + + cy.get('#expressions-tab').click() + + HTTP_BASED_PROTOCOLS.forEach((protocol) => { + cy.getTestId('route-form-protocols').click({ force: true }) + cy.get(`[data-testid='select-item-${protocol}']`).click() + cy.getTestId('open-router-playground').should('not.have.class', 'disabled') + }) + }) + + it('route playground should have initial expression value', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKonnect, + routeFlavors: TRADITIONAL_EXPRESSIONS, + showExpressionsModalEntry: true, + }, + }) + + cy.get('#expressions-tab').click() + cy.get('.monaco-editor').first().as('monacoEditor').click() + cy.get('@monacoEditor').type('http.path == "/kong"') + cy.getTestId('open-router-playground').click() + cy.get('.router-playground > [data-testid="expressions-editor"]').contains('http.path == "/kong"') + }) + + it('should expression updated when save in route playground', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKonnect, + routeFlavors: TRADITIONAL_EXPRESSIONS, + showExpressionsModalEntry: true, + }, + }) + cy.get('#expressions-tab').click() + cy.get('.monaco-editor').first().as('monacoEditor').click() + cy.get('@monacoEditor').type('http.path == "/kong"') + cy.getTestId('open-router-playground').click() + cy.get('.router-playground > [data-testid="expressions-editor"]').type(' && http.method == "GET"') + cy.getTestId('modal-action-button').click() + cy.get('@monacoEditor').contains('http.path == "/kong" && http.method == "GET"') + }) + }) } // for RouteFlavors[] }) }) diff --git a/packages/entities/entities-routes/src/components/RouteForm.vue b/packages/entities/entities-routes/src/components/RouteForm.vue index ab9a09ca2d..e6358925c4 100644 --- a/packages/entities/entities-routes/src/components/RouteForm.vue +++ b/packages/entities/entities-routes/src/components/RouteForm.vue @@ -359,6 +359,8 @@ diff --git a/packages/entities/entities-routes/src/components/RouteFormExpressionsEditorLoader.vue b/packages/entities/entities-routes/src/components/RouteFormExpressionsEditorLoader.vue index 46128e64ec..76a0b0a25f 100644 --- a/packages/entities/entities-routes/src/components/RouteFormExpressionsEditorLoader.vue +++ b/packages/entities/entities-routes/src/components/RouteFormExpressionsEditorLoader.vue @@ -12,8 +12,14 @@ + :show-expressions-modal-entry="showExpressionsModalEntry" + @notify="emit('notify', $event)" + > + + {{ t('form.expression_playground.test_link') }} + h('div', t('form.expressions_editor.loading')), @@ -49,7 +57,13 @@ const RouteFormExpressionsEditor = defineAsyncComponent({ errorComponent, }) -const props = defineProps<{ protocol?: string }>() +const props = defineProps<{ + protocol?: string + showExpressionsModalEntry?: boolean +}>() +const emit = defineEmits<{ + (e: 'notify', options: { message: string, type: string }): void +}>() const state = ref(ExpressionsEditorState.LOADING) const expression = defineModel({ required: true }) @@ -69,3 +83,23 @@ onMounted(async () => { } }) + + diff --git a/packages/entities/entities-routes/src/locales/en.json b/packages/entities/entities-routes/src/locales/en.json index 60e73075ab..4736b26696 100644 --- a/packages/entities/entities-routes/src/locales/en.json +++ b/packages/entities/entities-routes/src/locales/en.json @@ -248,6 +248,10 @@ "expressions_editor": { "loading": "Loading the Expressions editor…", "error": "Error occurred while loading the Expressions editor. Please view the console for more details." + }, + "expression_playground": { + "test_link": "Test with Router Playground", + "supported_protocols_hint": "Currently only supports the following protocols: {protocols}" } } } diff --git a/packages/entities/entities-routes/vite.config.ts b/packages/entities/entities-routes/vite.config.ts index 9219a541fe..251850395f 100644 --- a/packages/entities/entities-routes/vite.config.ts +++ b/packages/entities/entities-routes/vite.config.ts @@ -20,22 +20,28 @@ const config = mergeConfig(sharedViteConfig, defineConfig({ rollupOptions: { external: [ '@kong-ui-public/expressions', // This is optional if we do not use Expressions features + '@kong-ui-public/expressions/dist/style.css', // This is optional if we do not use Expressions features 'monaco-editor', // This is optional if we do not use Expressions features ], }, }, - server: { - proxy: { - // Add the API proxies to inject the Authorization header - ...getApiProxies(), - }, - }, - ...process.env.USE_SANDBOX && { - plugins: [ - // See: https://github.com/vdesjs/vite-plugin-monaco-editor/issues/21 - ((monacoEditorPlugin as any).default as typeof monacoEditorPlugin)({}), - ], - }, + ...(process.env.USE_SANDBOX + ? { + server: { + proxy: { + // Add the API proxies to inject the Authorization header + ...getApiProxies(), + }, + }, + } + : {}), + plugins: [ + // This plugin is only used in the sandbox & testing environment + // It generates extra files in dist folder whitch are not need in library build + ...(process.env.USE_SANDBOX + ? [((monacoEditorPlugin as any).default as typeof monacoEditorPlugin)({})] + : []), + ], })) // If we are trying to preview a build of the local `package/entities-routes/sandbox` directory, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c3f2ea00a..f1a55bc7a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -545,7 +545,19 @@ importers: '@kong-ui-public/core': specifier: workspace:^ version: link:../core + '@kong-ui-public/i18n': + specifier: workspace:^ + version: link:../i18n + '@kong/icons': + specifier: ^1.14.2 + version: 1.15.1(vue@3.4.31(typescript@5.3.3)) + uuid: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: + '@kong-ui-public/forms': + specifier: workspace:^ + version: link:../forms '@kong/atc-router': specifier: 1.6.0-rc.1 version: 1.6.0-rc.1 @@ -555,6 +567,9 @@ importers: '@kong/kongponents': specifier: 9.1.7 version: 9.1.7(axios@1.6.8)(vue-router@4.4.0(vue@3.4.31(typescript@5.3.3)))(vue@3.4.31(typescript@5.3.3)) + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 monaco-editor: specifier: 0.21.3 version: 0.21.3 @@ -1739,7 +1754,6 @@ packages: '@evilmartians/lefthook@1.7.1': resolution: {integrity: sha512-Wp8DaTMHZM1tUV4Mow6nG+6zq+giruD5054zHmFIDLXlPQxqYxnZMqJg0aYxe16vYwqFmH6NIClEMRdtGucO0Q==} - cpu: [x64, arm64, ia32] os: [darwin, linux, win32] hasBin: true