diff --git a/packages/entities/entities-routes/LICENSE b/packages/entities/entities-routes/LICENSE new file mode 100644 index 0000000000..47c89644eb --- /dev/null +++ b/packages/entities/entities-routes/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Kong, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/entities/entities-routes/README.md b/packages/entities/entities-routes/README.md new file mode 100644 index 0000000000..f7aa7696e3 --- /dev/null +++ b/packages/entities/entities-routes/README.md @@ -0,0 +1,50 @@ +# @kong-ui-public/entities-routes + +Route entity components. + +- [Requirements](#requirements) +- [Included components](#included-components) +- [Usage](#usage) + - [Install](#install) + - [Registration](#registration) +- [Individual component documentation](#individual-component-documentation) + +## Requirements + +- `vue` and `vue-router` must be initialized in the host application +- `@kong/kongponents` must be added as a dependency in the host application, globally available via the Vue Plugin installation, and the package's style imports must be added in the app entry file. [See here for instructions on installing Kongponents](https://kongponents.konghq.com/#globally-install-all-kongponents). +- `@kong-ui-public/i18n` must be available as a `dependency` in the host application. +- `axios` must be installed as a dependency in the host application + +## Included components + +- `RouteList` +- `RouteForm` +- `RouteConfigCard` + +Reference the [individual component docs](#individual-component-documentation) for more info. + +## Usage + +### Install + +Install the component in your host application + +```sh +yarn add @kong-ui-public/entities-routes +``` + +### Registration + +Import the component(s) in your host application as well as the package styles + +```ts +import { RouteList, RouteForm, RouteConfigCard } from '@kong-ui-public/entities-routes' +import '@kong-ui-public/entities-routes/dist/style.css' +``` + +## Individual component documentation + +- [``](docs/route-list.md) +- [``](docs/route-form.md) +- [``](docs/route-config-card.md) diff --git a/packages/entities/entities-routes/docs/compatibility.md b/packages/entities/entities-routes/docs/compatibility.md new file mode 100644 index 0000000000..c3ed523fd0 --- /dev/null +++ b/packages/entities/entities-routes/docs/compatibility.md @@ -0,0 +1,6 @@ +# Compatibility in the Route entity + +## Protocol + +`'ws'` and `'wss'` are not valid values for the `protocol` field in Gateway Community Edition or before Gateway Enterprise Edition 3.0: +- In the `RouteForm` component, `'ws'` and `'wss'` are hidden from the `protocol` select dropdown. \ No newline at end of file diff --git a/packages/entities/entities-routes/docs/route-config-card.md b/packages/entities/entities-routes/docs/route-config-card.md new file mode 100644 index 0000000000..a58145d167 --- /dev/null +++ b/packages/entities/entities-routes/docs/route-config-card.md @@ -0,0 +1,123 @@ +# RouteConfigCard.vue + +A config card component for routes. + +- [Requirements](#requirements) +- [Usage](#usage) + - [Install](#install) + - [Props](#props) + - [Events](#events) + - [Usage example](#usage-example) +- [TypeScript interfaces](#typescript-interfaces) + +## Requirements + +- `vue` and `vue-router` must be initialized in the host application +- `@kong/kongponents` must be added as a dependency in the host application, globally available via the Vue Plugin installation, and the package's style imports must be added in the app entry file. [See here for instructions on installing Kongponents](https://kongponents.konghq.com/#globally-install-all-kongponents). +- `@kong-ui-public/i18n` must be available as a `dependency` in the host application. +- `axios` must be installed as a dependency in the host application + +## Usage + +### Install + +[See instructions for installing the `@kong-ui-public/entities-routes` package.](../README.md#install) + +### Props + +#### `config` + +- type: `Object as PropType` +- required: `true` +- default: `undefined` +- properties: + - `app`: + - type: `'konnect' | 'kongManager'` + - required: `true` + - default: `undefined` + - App name. + + - `apiBaseUrl`: + - type: `string` + - required: `true` + - default: `undefined` + - Base URL for API requests. + + - `requestHeaders`: + - type: `RawAxiosRequestHeaders | AxiosHeaders` + - required: `false` + - default: `undefined` + - Additional headers to send with all Axios requests. + + - `workspace`: + - type: `string` + - required: `true` + - default: `undefined` + - *Specific to Kong Manager*. Name of the current workspace. + + - `controlPlaneId`: + - type: `string` + - required: `true` + - default: `undefined` + - *Specific to Konnect*. Name of the current control plane. + + - `entityId`: + - type: `string` + - required: `true` + - default: `''` + - The ID of the Route to display the config for + +The base konnect or kongManger config. + +#### `hideTitle` + +- type: `Boolean` +- required: `false` +- default: `false` + +Set this value to `true` to hide the card title. + +#### `serviceId` + +- type: `String` +- required: `false` +- default: `''` + +The id of the service with which the route is associated. + +### Events + +#### fetch:error + +An `@fetch:error` event is emitted when the component fails to fetch the route. The event payload is the response error. + +#### fetch:success + +A `@fetch:success` event is emitted when the route is successfully fetched. The event payload is the Route object. + +#### loading + +A `@loading` event is emitted when loading state changes. The event payload is a boolean. + +#### copy:success + +A `@copy:success` event is emitted when a user clicks the `Copy JSON` button and the JSON object is copied successfully. + +#### navigation-click + +A `@navigation-click` event is emitted when a user clicks the `service name` button and the event payload is the entity type (`services`) and the service id. + +### Usage example + +Please refer to the [sandbox](../sandbox/pages/RouteConfigCardPage.vue). The page is accessible by clicking on the row or `View details` button of an existing Route. + +## TypeScript interfaces + +TypeScript interfaces [are available here](https://github.com/Kong/public-ui-components/blob/main/packages/entities/entities-routes/src/types/route-config-card.ts) and can be directly imported into your host application. The following type interfaces are available for import: + +```ts +import type { + RouteConfigurationSchema, + KonnectRouteEntityConfig, + KongManagerRouteEntityConfig, +} from '@kong-ui-public/entities-routes' diff --git a/packages/entities/entities-routes/docs/route-form.md b/packages/entities/entities-routes/docs/route-form.md new file mode 100644 index 0000000000..4188dbdff8 --- /dev/null +++ b/packages/entities/entities-routes/docs/route-form.md @@ -0,0 +1,183 @@ +# RouteForm.vue + +A form component for Routes. + +- [Requirements](#requirements) +- [Usage](#usage) + - [Install](#install) + - [Props](#props) + - [Slots](#slots) + - [Events](#events) + - [Usage example](#usage-example) +- [TypeScript interfaces](#typescript-interfaces) + +## Requirements + +- `vue` and `vue-router` must be initialized in the host application +- `@kong/kongponents` must be added as a dependency in the host application, globally available via the Vue Plugin installation, and the package's style imports must be added in the app entry file. [See here for instructions on installing Kongponents](https://kongponents.konghq.com/#globally-install-all-kongponents). +- `@kong-ui-public/i18n` must be available as a `dependency` in the host application. +- `axios` must be installed as a dependency in the host application + +## Usage + +### Install + +[See instructions for installing the `@kong-ui-public/entities-routes` package.](../README.md#install) + +### Props + +#### `config` + +- type: `Object as PropType` +- required: `true` +- default: `undefined` +- properties: + - `app`: + - type: `'konnect' | 'kongManager'` + - required: `true` + - default: `undefined` + - App name. + + - `apiBaseUrl`: + - type: `string` + - required: `true` + - default: `undefined` + - Base URL for API requests. + + - `requestHeaders`: + - type: `RawAxiosRequestHeaders | AxiosHeaders` + - required: `false` + - default: `undefined` + - Additional headers to send with all Axios requests. + + - `cancelRoute`: + - type: `RouteLocationRaw` + - required: `true` + - default: `undefined` + - Route to return to when canceling creation of a Route. + + - `workspace`: + - type: `string` + - required: `true` + - default: `undefined` + - *Specific to Kong Manager*. Name of the current workspace. + + - `controlPlaneId`: + - type: `string` + - required: `true` + - default: `undefined` + - *Specific to Konnect*. Name of the current control plane. + +The base konnect or kongManger config. + +#### `routeId` + +- type: `String` +- required: `false` +- default: `''` + +If showing the `Edit` type form, the ID of the Route. + +#### `serviceId` + +- type: `String` +- required: `false` +- default: `''` + +If service is pre-selected, hides service select dropdown. + +#### `hideSectionsInfo` + +- type: `Boolean` +- required: `false` +- default: `false` + +Show/hide `EntityFormSection` component info column. + +#### `hideNameField` + +- type: `Boolean` +- required: `false` +- default: `false` + +Show/hide Route name field. If `true`, `name` field is stripped from payload object. + +#### `hideServiceField` + +- type: `Boolean` +- required: `false` +- default: `false` + +Show/hide Service Select field. Should be used in case of manual adding `service_id` in payload. + +#### `showTagsFiledUnderAdvanced` + +- type: `Boolean` +- required: `false` +- default: `false` + +Show tags field under _Advanced Fields_ collapse or in it's default place (before protocols field). + +### Slots + +#### `form-actions` + +Content to be displayed instead of the default `Cancel` and `Save` buttons, at the bottom of the form. + +Slot props: +- `canSubmit` + - type: `Boolean` + - Should the submit button be enabled or disabled. +- `submit` + - type: `Function` + - Form submit handler function. +- `cancel` + - type: `Function` + - Cancel handler function. + +### Events + +#### error + +An `@error` event is emitted when form validation fails. The event payload is the response error. + +#### loading + +A `@loading` event is emitted when loading state changes. The event payload is a boolean. + +#### update + +A `@update` event is emitted when the form is saved. The event payload is the Route object. + +#### model-updated + +A `@model-updated` event is emitted when any form value was changed. The event payload is the Route payload object. + +### 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. + +## TypeScript interfaces + +TypeScript interfaces [are available here](../src/types/route-form.ts) and can be directly imported into your host application. The following type interfaces are available for import: + +```ts +import type { + BaseRouteFormConfig, + KonnectRouteFormConfig, + KongManagerRouteFormConfig, + RoutingRulesEntities, + RoutingRuleEntity, + PathHandlingVersion, + Protocol, + HeaderFields, + MethodsFields, + Sources, + Destinations, + RouteStateFields, + RouteState, + Headers, + RoutePayload, + SelectItem +} from '@kong-ui-public/entities-routes' +``` diff --git a/packages/entities/entities-routes/docs/route-list.md b/packages/entities/entities-routes/docs/route-list.md new file mode 100644 index 0000000000..dd9d086bc7 --- /dev/null +++ b/packages/entities/entities-routes/docs/route-list.md @@ -0,0 +1,189 @@ +# RouteList.vue + +A table component for routes. + +- [Requirements](#requirements) +- [Usage](#usage) + - [Install](#install) + - [Props](#props) + - [Events](#events) + - [Usage example](#usage-example) +- [TypeScript interfaces](#typescript-interfaces) + +## Requirements + +- `vue` and `vue-router` must be initialized in the host application +- `@kong/kongponents` must be added as a `dependency` in the host application, globally available via the Vue Plugin installation, and the package's style imports must be added in the app entry file. [See here for instructions on installing Kongponents](https://kongponents.konghq.com/#globally-install-all-kongponents). + +## Usage + +### Install + +[See instructions for installing the `@kong-ui-public/entities-routes` package.](../README.md#install) + +### Props + +#### `config` + +- type: `Object as PropType` +- required: `true` +- default: `undefined` +- properties: + - `app`: + - type: `'konnect' | 'kongManager'` + - required: `true` + - default: `undefined` + - App name. + + - `apiBaseUrl`: + - type: `string` + - required: `true` + - default: `undefined` + - Base URL for API requests. + + - `requestHeaders`: + - type: `RawAxiosRequestHeaders | AxiosHeaders` + - required: `false` + - default: `undefined` + - Additional headers to send with all Axios requests. + + - `createRoute`: + - type: `RouteLocationRaw` + - required: `true` + - default: `undefined` + - Route for creating a route. + + - `getViewRoute`: + - type: `(id: string) => RouteLocationRaw` + - required: `true` + - default: `undefined` + - A function that returns the route for viewing a route. + + - `getEditRoute`: + - type: `(id: string) => RouteLocationRaw` + - required: `true` + - default: `undefined` + - A function that returns the route for editing a route. + + - `useExpression`: + - type: `boolean` + - required: `false` + - default: `undefined` + - Whether to use expression flavored routes. + + - `serviceId`: + - type: `string` + - required: `false` + - default: `null` + - Current service id if the RouteList is nested in the routes tab on a service detail page. + + - `workspace`: + - type: `string` + - required: `true` + - default: `undefined` + - *Specific to Kong Manager*. Name of the current workspace. + + - `isExactMatch`: + - type: `boolean` + - required: `false` + - default: `undefined` + - *Specific to Kong Manager*. Whether to use exact match. + + - `disableSorting`: + - type: `boolean` + - required: `false` + - default: `undefined` + - *Specific to Kong Manager*. Whether to disable table sorting. + + - `filterSchema`: + - type: `FilterSchema` + - required: `false` + - default: `undefined` + - *Specific to Kong Manager*. FilterSchema for fuzzy match. + + - `controlPlaneId`: + - type: `string` + - required: `true` + - default: `undefined` + - *Specific to Konnect*. Name of the current control plane. + +The base konnect or kongManger config. + +#### `cacheIdentifier` + +- type: `String` +- required: `false` +- default: `''` + +Used to override the default unique identifier for the table's entry in the cache. This should be unique across the Vue App. +Note: the default value is usually sufficient unless the app needs to support multiple separate instances of the table. + +#### `canCreate` + +- type: `Function as PropType<() => Promise>` +- required: `false` +- default: `async () => true` + +An asynchronous function, that returns a boolean, that evaluates if the user can create a new entity. + +#### `canDelete` + +- type: `Function as PropType<(row: object) => Promise>` +- required: `false` +- default: `async () => true` + +An asynchronous function, that returns a boolean, that evaluates if the user can delete a given entity. + +#### `canEdit` + +- type: `Function as PropType<(row: object) => Promise>` +- required: `false` +- default: `async () => true` + +An asynchronous function, that returns a boolean, that evaluates if the user can edit a given entity. + +#### `canRetrieve` + +- type: `Function as PropType<(row: object) => Promise>` +- required: `false` +- default: `async () => true` + +An asynchronous function, that returns a boolean, that evaluates if the user can retrieve (view details) a given entity. + +#### `title` + +The table is rendered inside a `KCard`. `title` text is displayed in the upper left corner of the `KCard` above the table. + +### Events + +#### error + +An `@error` event is emitted when the table fails to fetch routes or delete a route. The event payload is the response error. + +#### copy:success + +A `@copy:success` event is emitted when a route ID or the entity JSON is successfully copied to clipboard. The event payload shape is CopyEventPayload. + +#### copy:error + +A `@copy:error` event is emitted when an error occurs when trying to copy a route ID or the entity JSON. The event payload shape is CopyEventPayload. + +#### delete:success + +A `@delete:success` event is emitted when a route is successfully deleted. The event payload is the route item data object. + +### Usage example + +Please refer to the [sandbox](../sandbox/pages/RouteListPage.vue). + +## TypeScript interfaces + +TypeScript interfaces [are available here](https://github.com/Kong/public-ui-components/blob/main/packages/entities/entities-routes/src/types/index.ts) and can be directly imported into your host application. The following type interfaces are available for import: + +```ts +import type { + BaseRouteListConfig, + KonnectRouteListConfig, + KongManagerRouteListConfig, +} from '@kong-ui-public/entities-routes' +``` diff --git a/packages/entities/entities-routes/fixtures/mockData.ts b/packages/entities/entities-routes/fixtures/mockData.ts new file mode 100644 index 0000000000..1550e54e83 --- /dev/null +++ b/packages/entities/entities-routes/fixtures/mockData.ts @@ -0,0 +1,79 @@ +// FetcherRawResponse is the raw format of the endpoint's response +export interface FetcherRawResponse { + data: any[]; + total: number; + offset?: string; +} + +export const routes: FetcherRawResponse = { + data: [ + { + id: '1', + name: 'route-1', + methods: ['GET'], + hosts: ['example.com'], + }, + { + id: '2', + name: 'route-2', + hosts: ['example.com'], + tags: ['tag1', 'tag2'], + }, + ], + total: 2, +} + +export const routes100: any[] = Array(100) + .fill(null) + .map((_, i) => ({ + id: `${i + 1}`, + name: `route-${i + 1}`, + methods: ['GET'], + hosts: [`${i + 1}.example.com`], + })) + +export const paginate = ( + routes: any[], + size: number, + _offset: number, +): FetcherRawResponse => { + const sliced = routes.slice(_offset, _offset + size) + const offset = + _offset + size < routes.length ? String(_offset + size) : undefined + + return { + data: sliced, + total: sliced.length, + offset, + } +} + +export const services = [ + { + id: '1', + name: 'service-1', + }, + { + id: '2', + name: 'service-2', + }, +] + +export const route = { + service: { + id: services[0].id, + }, + id: '1', + name: 'route-1', + methods: ['GET', 'CASTOM'], + service_id: '', + tags: ['dev', 'test'], + regex_priority: 1, + path_handling: 'v1', + preserve_host: true, + https_redirect_status_code: 426, + protocols: ['http', 'https'], + strip_path: false, + paths: ['/foo', '/bar'], + headers: { Header1: ['cropped-jeans', 'expensive-petroleum'] }, +} diff --git a/packages/entities/entities-routes/package.json b/packages/entities/entities-routes/package.json new file mode 100644 index 0000000000..92f2ea7441 --- /dev/null +++ b/packages/entities/entities-routes/package.json @@ -0,0 +1,72 @@ +{ + "name": "@kong-ui-public/entities-routes", + "version": "1.0.0", + "type": "module", + "main": "./dist/entities-routes.umd.js", + "module": "./dist/entities-routes.es.js", + "types": "dist/types/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": "./dist/entities-routes.es.js", + "require": "./dist/entities-routes.umd.js" + }, + "./package.json": "./package.json", + "./dist/*": "./dist/*" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@kong-ui-public/i18n": "workspace:^", + "@kong/kongponents": "^8.116.2", + "axios": "^1.4.0", + "vue": "^3.3.4", + "vue-router": "^4.2.4" + }, + "devDependencies": { + "@kong-ui-public/i18n": "workspace:^", + "@kong/design-tokens": "^1.8.0", + "@kong/kongponents": "^8.116.2", + "axios": "^1.4.0", + "vue": "^3.3.4", + "vue-router": "^4.2.4" + }, + "scripts": { + "dev": "cross-env USE_SANDBOX=true vite", + "build": "run-s typecheck build:package build:types", + "build:package": "vite build -m production", + "build:analyzer": "BUILD_VISUALIZER='entities/entities-routes' vite build -m production", + "build:types": "vue-tsc -p './tsconfig.build.json' --emitDeclarationOnly", + "preview:package": "vite preview --port 4173", + "preview": "cross-env USE_SANDBOX=true PREVIEW_SANDBOX=true run-s build:package preview:package", + "lint": "eslint '**/*.{js,jsx,ts,tsx,vue}' --ignore-path '../../../.eslintignore'", + "lint:fix": "eslint '**/*.{js,jsx,ts,tsx,vue}' --ignore-path '../../../.eslintignore' --fix", + "stylelint": "stylelint --allow-empty-input './src/**/*.{css,scss,sass,less,styl,vue}'", + "stylelint:fix": "stylelint --allow-empty-input './src/**/*.{css,scss,sass,less,styl,vue}' --fix", + "typecheck": "vue-tsc -p './tsconfig.build.json' --noEmit", + "test:component": "BABEL_ENV=cypress cross-env FORCE_COLOR=1 cypress run --component -b chrome --spec './src/**/*.cy.ts' --project '../../../.'", + "test:component:open": "BABEL_ENV=cypress cross-env FORCE_COLOR=1 cypress open --component -b chrome --project '../../../.'", + "test:unit": "cross-env FORCE_COLOR=1 vitest run", + "test:unit:open": "cross-env FORCE_COLOR=1 vitest --ui" + }, + "repository": { + "type": "git", + "url": "https://github.com/Kong/public-ui-components.git", + "directory": "packages/entities/entities-routes" + }, + "homepage": "https://github.com/Kong/public-ui-components/tree/main/packages/entities/entities-routes", + "author": "Kong, Inc.", + "license": "Apache-2.0", + "volta": { + "extends": "../../../package.json" + }, + "distSizeChecker": { + "errorLimit": "700KB" + }, + "dependencies": { + "@kong-ui-public/entities-shared": "workspace:^" + } +} diff --git a/packages/entities/entities-routes/sandbox/App.vue b/packages/entities/entities-routes/sandbox/App.vue new file mode 100644 index 0000000000..656df1b4e6 --- /dev/null +++ b/packages/entities/entities-routes/sandbox/App.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/packages/entities/entities-routes/sandbox/index.html b/packages/entities/entities-routes/sandbox/index.html new file mode 100644 index 0000000000..26b5c57857 --- /dev/null +++ b/packages/entities/entities-routes/sandbox/index.html @@ -0,0 +1,27 @@ + + + + + + + EntitiesRoutes Component Sandbox + + + + + + + +
+ + + + diff --git a/packages/entities/entities-routes/sandbox/index.ts b/packages/entities/entities-routes/sandbox/index.ts new file mode 100644 index 0000000000..431312a604 --- /dev/null +++ b/packages/entities/entities-routes/sandbox/index.ts @@ -0,0 +1,49 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import App from './App.vue' +import Kongponents from '@kong/kongponents' +import '@kong/kongponents/dist/style.css' + +const app = createApp(App) + +// Initializing in an async function in order to fetch session and permissions data for the sandbox ONLY. +// DO NOT DO THIS IN AN ACTUAL APP +const init = async () => { + const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + name: 'home', + redirect: { name: 'route-list' }, + }, + { + path: '/route-list', + name: 'route-list', + component: () => import('./pages/RouteListPage.vue'), + }, + { + path: '/route/create', + name: 'create-route', + component: () => import('./pages/RouteFormPage.vue'), + }, + { + path: '/route/:id', + name: 'view-route', + component: () => import('./pages/RouteConfigCardPage.vue'), + props: true, + }, + { + path: '/route/:id/edit', + name: 'edit-route', + component: () => import('./pages/RouteFormPage.vue'), + }, + ], + }) + + app.use(Kongponents) + app.use(router) + app.mount('#app') +} + +init() diff --git a/packages/entities/entities-routes/sandbox/pages/HomePage.vue b/packages/entities/entities-routes/sandbox/pages/HomePage.vue new file mode 100644 index 0000000000..fc86373ad6 --- /dev/null +++ b/packages/entities/entities-routes/sandbox/pages/HomePage.vue @@ -0,0 +1,9 @@ + diff --git a/packages/entities/entities-routes/sandbox/pages/RouteConfigCardPage.vue b/packages/entities/entities-routes/sandbox/pages/RouteConfigCardPage.vue new file mode 100644 index 0000000000..36a465935a --- /dev/null +++ b/packages/entities/entities-routes/sandbox/pages/RouteConfigCardPage.vue @@ -0,0 +1,55 @@ + + + diff --git a/packages/entities/entities-routes/sandbox/pages/RouteFormPage.vue b/packages/entities/entities-routes/sandbox/pages/RouteFormPage.vue new file mode 100644 index 0000000000..9b90a0fab3 --- /dev/null +++ b/packages/entities/entities-routes/sandbox/pages/RouteFormPage.vue @@ -0,0 +1,93 @@ + + + diff --git a/packages/entities/entities-routes/sandbox/pages/RouteListPage.vue b/packages/entities/entities-routes/sandbox/pages/RouteListPage.vue new file mode 100644 index 0000000000..cb0658accd --- /dev/null +++ b/packages/entities/entities-routes/sandbox/pages/RouteListPage.vue @@ -0,0 +1,109 @@ + + + diff --git a/packages/entities/entities-routes/sandbox/tsconfig.json b/packages/entities/entities-routes/sandbox/tsconfig.json new file mode 100644 index 0000000000..6b0bff7930 --- /dev/null +++ b/packages/entities/entities-routes/sandbox/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@entities-shared-sandbox/*": [ + "../../entities-shared/sandbox/shared/*" + ] + } + }, + "include": [ + "**/*.ts", + "**/*.vue", + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/entities/entities-routes/src/components/RouteConfigCard.vue b/packages/entities/entities-routes/src/components/RouteConfigCard.vue new file mode 100644 index 0000000000..1a507e9f46 --- /dev/null +++ b/packages/entities/entities-routes/src/components/RouteConfigCard.vue @@ -0,0 +1,336 @@ + + + diff --git a/packages/entities/entities-routes/src/components/RouteForm.cy.ts b/packages/entities/entities-routes/src/components/RouteForm.cy.ts new file mode 100644 index 0000000000..263ee508df --- /dev/null +++ b/packages/entities/entities-routes/src/components/RouteForm.cy.ts @@ -0,0 +1,965 @@ +import { KonnectRouteFormConfig, KongManagerRouteFormConfig } from '../types' +import RouteForm from './RouteForm.vue' +import { route, services } from '../../fixtures/mockData' +import { EntityBaseForm } from '@kong-ui-public/entities-shared' + +const cancelRoute = { name: 'route-list' } + +const baseConfigKonnect: KonnectRouteFormConfig = { + app: 'konnect', + controlPlaneId: '1235-abcd-ilove-dogs', + apiBaseUrl: '/us/kong-api/konnect-api', + cancelRoute, +} + +const baseConfigKM: KongManagerRouteFormConfig = { + app: 'kongManager', + workspace: 'default', + apiBaseUrl: '/kong-manager', + cancelRoute, +} + +describe('', { viewportHeight: 700, viewportWidth: 700 }, () => { + describe('Kong Manager', () => { + const interceptKM = (params?: { + mockData?: object + alias?: string + }) => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/routes/*`, + }, + { + statusCode: 200, + body: params?.mockData ?? route, + }, + ).as(params?.alias ?? 'getRoute') + } + + const interceptKMServices = (params?: { + mockData?: object + alias?: string + }) => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/services/*`, + }, + { + statusCode: 200, + body: { data: params?.mockData ?? services }, + }, + ).as(params?.alias ?? 'getServices') + } + + const interceptUpdate = (status = 200): void => { + cy.intercept( + { + method: 'PATCH', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/routes/*`, + }, + { + statusCode: status, + body: { ...route, tags: ['tag1', 'tag2'] }, + }, + ).as('updateRoute') + } + + it('should show create form', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKM, + }, + }) + + cy.get('.kong-ui-entities-route-form').should('be.visible') + cy.get('.kong-ui-entities-route-form form').should('be.visible') + + // button state + cy.getTestId('form-cancel').should('be.visible') + cy.getTestId('form-submit').should('be.visible') + cy.getTestId('form-cancel').should('be.enabled') + cy.getTestId('form-submit').should('be.disabled') + + // form fields - general + cy.getTestId('route-form-name').should('be.visible') + cy.getTestId('route-form-service-id').should('be.visible') + cy.getTestId('route-form-tags').should('be.visible') + + // advanced fields + cy.getTestId('k-collapse-trigger-content').should('be.visible').click() + cy.getTestId('route-form-path-handling').should('be.visible') + cy.getTestId('route-form-http-redirect-status-code').should('be.visible') + cy.getTestId('route-form-regex-priority').should('be.visible') + cy.getTestId('route-form-strip-path').should('be.visible') + cy.getTestId('route-form-preserve-host').should('be.visible') + cy.getTestId('route-form-request-buffering').should('be.visible') + cy.getTestId('route-form-response-buffering').should('be.visible') + + // routing rules fields + cy.getTestId('route-form-protocols').should('be.visible') + + // paths + cy.getTestId('route-form-paths-input-1').should('be.visible') + cy.getTestId('add-paths').should('be.visible').click() + cy.getTestId('route-form-paths-input-2').should('be.visible') + cy.getTestId('remove-paths').first().should('be.visible').click() + cy.getTestId('route-form-paths-input-2').should('not.exist') + + cy.getTestId('route-form-paths-input-1').should('be.visible') + cy.getTestId('remove-paths').first().should('be.visible').click() + cy.get('.route-form-routing-rules-selector-options').should('be.visible') + + // snis + cy.getTestId('routing-rule-snis').should('be.visible').click() + cy.getTestId('route-form-snis-input-1').should('be.visible') + cy.getTestId('add-snis').should('be.visible').click() + cy.getTestId('route-form-snis-input-2').should('be.visible') + cy.getTestId('remove-snis').first().should('be.visible').click() + cy.getTestId('route-form-snis-input-2').should('not.exist') + + // hosts + cy.getTestId('routing-rule-hosts').should('be.visible').click() + cy.getTestId('route-form-hosts-input-1').should('be.visible') + cy.getTestId('add-hosts').should('be.visible').click() + cy.getTestId('route-form-hosts-input-2').should('be.visible') + cy.getTestId('remove-hosts').first().should('be.visible').click() + cy.getTestId('route-form-hosts-input-2').should('not.exist') + + // methods and custom methods + cy.getTestId('routing-rule-methods').should('be.visible').click() + cy.getTestId('routing-rule-methods').should('have.attr', 'aria-disabled').and('eq', 'true') + cy.getTestId('get-method-toggle').should('exist').should('be.visible') + cy.getTestId('post-method-toggle').should('exist').should('be.visible') + cy.getTestId('put-method-toggle').should('exist').should('be.visible') + cy.get('[data-testid="custom-method-toggle"] input').should('exist').should('not.be.visible').check({ force: true }) + cy.getTestId('route-form-custom-method-input-1').should('be.visible') + cy.getTestId('add-custom-method').should('be.visible').click() + cy.getTestId('route-form-custom-method-input-2').should('be.visible') + cy.getTestId('remove-custom-method').first().should('be.visible').click() + cy.getTestId('route-form-custom-method-input-2').should('not.exist') + cy.getTestId('remove-methods').should('be.visible').click() + cy.getTestId('get-method-toggle').should('not.exist') + cy.getTestId('routing-rule-methods').should('have.attr', 'aria-disabled').and('eq', 'false') + + // headers + cy.getTestId('routing-rule-headers').should('be.visible').click() + cy.getTestId('route-form-headers-name-input-1').should('be.visible') + cy.getTestId('route-form-headers-values-input-1').should('be.visible') + cy.getTestId('add-headers').should('be.visible').click() + cy.getTestId('route-form-headers-name-input-2').should('be.visible') + cy.getTestId('route-form-headers-values-input-2').should('be.visible') + cy.getTestId('remove-headers').first().should('be.visible').click() + cy.getTestId('route-form-headers-name-input-2').should('not.exist') + cy.getTestId('route-form-headers-values-input-2').should('not.exist') + + cy.getTestId('route-form-protocols').click({ force: true }) + cy.get("[data-testid='k-select-item-tcp,tls,udp']").click() + cy.getTestId('routing-rule-paths').should('not.exist') + cy.getTestId('routing-rule-hosts').should('not.exist') + cy.getTestId('routing-rule-methods').should('not.exist') + cy.getTestId('routing-rule-headers').should('not.exist') + + // sources + cy.getTestId('routing-rule-sources').should('be.visible').click() + cy.getTestId('route-form-sources-ip-input-1').should('be.visible') + cy.getTestId('route-form-sources-port-input-1').should('be.visible') + cy.getTestId('add-sources').should('be.visible').click() + cy.getTestId('route-form-sources-ip-input-2').should('be.visible') + cy.getTestId('route-form-sources-port-input-2').should('be.visible') + cy.getTestId('remove-sources').first().should('be.visible').click() + cy.getTestId('route-form-sources-ip-input-2').should('not.exist') + cy.getTestId('route-form-sources-port-input-2').should('not.exist') + + // destinations + cy.getTestId('routing-rule-destinations').should('be.visible').click() + cy.getTestId('route-form-destinations-ip-input-1').should('be.visible') + cy.getTestId('route-form-destinations-port-input-1').should('be.visible') + cy.getTestId('add-destinations').should('be.visible').click() + cy.getTestId('route-form-destinations-ip-input-2').should('be.visible') + cy.getTestId('route-form-destinations-port-input-2').should('be.visible') + cy.getTestId('remove-destinations').first().should('be.visible').click() + cy.getTestId('route-form-destinations-ip-input-2').should('not.exist') + cy.getTestId('route-form-destinations-port-input-2').should('not.exist') + }) + + it('should correctly handle button state - create', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKM, + }, + }) + + cy.get('.kong-ui-entities-route-form').should('be.visible') + + // default button state + cy.getTestId('form-cancel').should('be.visible') + cy.getTestId('form-submit').should('be.visible') + cy.getTestId('form-cancel').should('be.enabled') + cy.getTestId('form-submit').should('be.disabled') + + // enables save when required fields have values + // form fields - general + cy.getTestId('route-form-name').should('be.visible') + + // paths + cy.getTestId('route-form-paths-input-1').type(route.paths[0]) + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-paths-input-1').clear() + cy.getTestId('form-submit').should('be.disabled') + + // snis + cy.getTestId('routing-rule-snis').click() + cy.getTestId('route-form-snis-input-1').type('sni') + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-snis-input-1').clear() + cy.getTestId('form-submit').should('be.disabled') + + // hosts + cy.getTestId('routing-rule-hosts').click() + cy.getTestId('route-form-hosts-input-1').type('host') + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-hosts-input-1').clear() + cy.getTestId('form-submit').should('be.disabled') + + // methods and custom methods + cy.getTestId('routing-rule-methods').click() + cy.get('[data-testid="get-method-toggle"] input').check({ force: true }) + cy.getTestId('form-submit').should('be.enabled') + cy.get('[data-testid="get-method-toggle"] input').uncheck({ force: true }) + cy.getTestId('form-submit').should('be.disabled') + cy.get('[data-testid="custom-method-toggle"] input').check({ force: true }) + cy.getTestId('form-submit').should('be.disabled') + cy.getTestId('route-form-custom-method-input-1').type('castom') + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-custom-method-input-1').clear() + cy.getTestId('form-submit').should('be.disabled') + + // headers + cy.getTestId('routing-rule-headers').click() + cy.getTestId('route-form-headers-name-input-1').type(Object.keys(route.headers)[0]) + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-headers-name-input-1').clear() + cy.getTestId('route-form-headers-values-input-1').type(route.headers.Header1[0]) + cy.getTestId('form-submit').should('be.disabled') + + cy.getTestId('route-form-protocols').click({ force: true }) + cy.get("[data-testid='k-select-item-tcp,tls,udp']").click() + + // sources + cy.getTestId('routing-rule-sources').click() + cy.getTestId('route-form-sources-ip-input-1').type('127.0.0.1') + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-sources-ip-input-1').clear() + cy.getTestId('route-form-sources-port-input-1').type('8080') + cy.getTestId('form-submit').should('be.disabled') + + // destinations + cy.getTestId('routing-rule-destinations').click() + cy.getTestId('route-form-destinations-ip-input-1').type('127.0.0.2') + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-destinations-ip-input-1').clear() + cy.getTestId('route-form-destinations-port-input-1').type('8000') + cy.getTestId('form-submit').should('be.disabled') + }) + + it('should show edit form', () => { + interceptKM() + interceptKMServices() + + cy.mount(RouteForm, { + props: { + config: baseConfigKM, + routeId: route.id, + }, + }) + + cy.wait('@getRoute') + cy.wait('@getServices') + cy.get('.kong-ui-entities-route-form').should('be.visible') + + // button state + cy.getTestId('form-cancel').should('be.visible') + cy.getTestId('form-submit').should('be.visible') + cy.getTestId('form-cancel').should('be.enabled') + cy.getTestId('form-submit').should('be.disabled') + + // form fields + cy.getTestId('route-form-name').should('have.value', route.name) + cy.getTestId('route-form-service-id').should('have.value', route.service.id) + cy.getTestId('route-form-tags').should('have.value', route.tags.join(', ')) + cy.getTestId('route-form-paths-input-1').should('have.value', route.paths[0]) + cy.getTestId('route-form-paths-input-2').should('have.value', route.paths[1]) + cy.get(`[data-testid="${route.methods[0].toLowerCase()}-method-toggle"] input`).should('be.checked') + cy.get(`[data-testid="${route.methods[1].toLowerCase()}-method-toggle"] input`).should('be.checked') + cy.getTestId('route-form-headers-name-input-1').should('have.value', Object.keys(route.headers)[0]) + cy.getTestId('route-form-headers-values-input-1').should('have.value', route.headers.Header1.join(',')) + + cy.getTestId('k-collapse-trigger-content').click() + cy.getTestId('route-form-path-handling').should('have.value', route.path_handling) + cy.getTestId('route-form-regex-priority').should('have.value', route.regex_priority) + cy.getTestId('route-form-strip-path').should(`${route.strip_path ? '' : 'not.'}be.checked`) + cy.getTestId('route-form-preserve-host').should(`${route.preserve_host ? '' : 'not.'}be.checked`) + }) + + it('should correctly handle button state - edit', () => { + interceptKM() + interceptKMServices() + + cy.mount(RouteForm, { + props: { + config: baseConfigKM, + routeId: route.id, + }, + }) + + cy.wait('@getRoute') + cy.wait('@getServices') + + cy.get('.kong-ui-entities-route-form').should('be.visible') + + // default button state + cy.getTestId('form-cancel').should('be.visible') + cy.getTestId('form-submit').should('be.visible') + cy.getTestId('form-cancel').should('be.enabled') + cy.getTestId('form-submit').should('be.disabled') + cy.getTestId('routing-rules-warning').should('not.exist') + + // enables save when form has changes + cy.getTestId('route-form-service-id').click({ force: true }) + cy.get("[data-testid='k-select-item-2']").click() + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('remove-methods').click() + cy.getTestId('remove-paths').first().click() + cy.getTestId('remove-paths').click() + cy.getTestId('remove-headers').click() + cy.getTestId('routing-rules-warning').should('be.visible') + cy.getTestId('form-submit').should('be.disabled') + }) + + it('should handle error state - failed to load route', () => { + interceptKMServices() + + cy.intercept( + { + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/routes/*`, + }, + { + statusCode: 404, + body: {}, + }, + ).as('getRoute') + + cy.mount(RouteForm, { + props: { + config: baseConfigKM, + routeId: route.id, + }, + }) + + cy.wait('@getRoute') + cy.wait('@getServices') + + cy.get('.kong-ui-entities-route-form').should('be.visible') + + // error state is displayed + cy.getTestId('form-fetch-error').should('be.visible') + + // buttons and form hidden + cy.getTestId('form-cancel').should('not.exist') + cy.getTestId('form-submit').should('not.exist') + cy.get('.kong-ui-entities-route-form form').should('not.exist') + }) + + it('should allow exact match filtering of services', () => { + interceptKMServices() + + cy.mount(RouteForm, { + props: { + config: baseConfigKM, + }, + }) + + cy.wait('@getServices') + cy.get('.kong-ui-entities-route-form').should('be.visible') + + // search + cy.getTestId('route-form-service-id').should('be.visible') + cy.getTestId('route-form-service-id').type(services[1].name) + + // click kselect item + cy.getTestId(`k-select-item-${services[1].id}`).should('be.visible') + cy.get(`[data-testid="k-select-item-${services[1].id}"] button`).click() + cy.getTestId('route-form-service-id').should('have.value', services[1].id) + }) + + it('should handle error state - failed to load services', () => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/services/*`, + }, + { + statusCode: 500, + body: {}, + }, + ).as('getServices') + + cy.mount(RouteForm, { + props: { + config: baseConfigKM, + }, + }) + + cy.wait('@getServices') + cy.get('.kong-ui-entities-route-form').should('be.visible') + cy.getTestId('form-error').should('be.visible') + }) + + it('should correctly render with all props and slot content', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKM, + serviceId: services[0].id, + hideSectionsInfo: true, + hideNameField: true, + showTagsFiledUnderAdvanced: true, + }, + slots: { + 'form-actions': '', + }, + }) + + cy.get('.kong-ui-entities-route-form').should('be.visible') + cy.get('.kong-ui-entities-route-form form').should('be.visible') + + // name field should be hidden when hideNameField is true + cy.getTestId('route-form-name').should('not.exist') + + // tags field should render under advanced fields + cy.getTestId('route-form-tags').should('not.be.visible') + cy.getTestId('k-collapse-trigger-content').click() + cy.getTestId('route-form-tags').should('be.visible') + + // service id field should be hidden when serviceId is provided + cy.getTestId('route-form-service-id').should('not.exist') + + // sections info should be hidden when hideSectionsInfo is true + cy.get('.form-section-info sticky').should('not.exist') + + // default buttons should be replaced with slotted content + cy.getTestId('form-cancel').should('not.exist') + cy.getTestId('form-submit').should('not.exist') + cy.getTestId('slotted-cancel-button').should('be.visible') + cy.getTestId('slotted-submit-button').should('be.visible') + }) + + it('update event should be emitted when Route was edited', () => { + interceptKM() + interceptUpdate() + + cy.mount(RouteForm, { + props: { + config: baseConfigKM, + routeId: route.id, + onUpdate: cy.spy().as('onUpdateSpy'), + }, + }).then(({ wrapper }) => wrapper) + .as('vueWrapper') + + cy.wait('@getRoute') + cy.getTestId('route-form-tags').clear() + cy.getTestId('route-form-tags').type('tag1,tag2') + + cy.get('@vueWrapper').then((wrapper: any) => wrapper.findComponent(EntityBaseForm) + .vm.$emit('submit')) + + cy.wait('@updateRoute') + + cy.get('@onUpdateSpy').should('have.been.calledOnce') + }) + + it('should hide `ws` options when not supported', () => { + cy.mount(RouteForm, { + props: { + config: { + ...baseConfigKM, + gatewayInfo: { + edition: 'enterprise', + version: '2.8.0.0', + }, + }, + }, + }) + + cy.get('.kong-ui-entities-route-form').should('be.visible') + + cy.getTestId('route-form-protocols').click({ force: true }) + cy.getTestId('k-select-item-http').should('exist') + cy.getTestId('k-select-item-ws').should('not.exist') + cy.getTestId('k-select-item-wss').should('not.exist') + }) + }) + + describe('Konnect', { viewportHeight: 700, viewportWidth: 700 }, () => { + const interceptKonnect = (params?: { + mockData?: object + alias?: string + }) => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/api/runtime_groups/${baseConfigKonnect.controlPlaneId}/routes/*`, + }, + { + statusCode: 200, + body: params?.mockData ?? route, + }, + ).as(params?.alias ?? 'getRoute') + } + + const interceptKonnectServices = (params?: { + mockData?: object + alias?: string + }) => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/api/runtime_groups/${baseConfigKonnect.controlPlaneId}/services*`, + }, + { + statusCode: 200, + body: { data: params?.mockData ?? services }, + }, + ).as(params?.alias ?? 'getServices') + } + + const interceptUpdate = (status = 200): void => { + cy.intercept( + { + method: 'PUT', + url: `${baseConfigKonnect.apiBaseUrl}/api/runtime_groups/${baseConfigKonnect.controlPlaneId}/routes/*`, + }, + { + statusCode: status, + body: { ...route, tags: ['tag1', 'tag2'] }, + }, + ).as('updateRoute') + } + + it('should show create form', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKonnect, + }, + }) + + cy.get('.kong-ui-entities-route-form').should('be.visible') + cy.get('.kong-ui-entities-route-form form').should('be.visible') + + // button state + cy.getTestId('form-cancel').should('be.visible') + cy.getTestId('form-submit').should('be.visible') + cy.getTestId('form-cancel').should('be.enabled') + cy.getTestId('form-submit').should('be.disabled') + + // form fields - general + cy.getTestId('route-form-name').should('be.visible') + cy.getTestId('route-form-service-id').should('be.visible') + cy.getTestId('route-form-tags').should('be.visible') + + // advanced fields + cy.getTestId('k-collapse-trigger-content').should('be.visible').click() + cy.getTestId('route-form-path-handling').should('be.visible') + cy.getTestId('route-form-http-redirect-status-code').should('be.visible') + cy.getTestId('route-form-regex-priority').should('be.visible') + cy.getTestId('route-form-strip-path').should('be.visible') + cy.getTestId('route-form-preserve-host').should('be.visible') + + // routing rules fields + cy.getTestId('route-form-protocols').should('be.visible') + + // paths + cy.getTestId('route-form-paths-input-1').should('be.visible') + cy.getTestId('add-paths').should('be.visible').click() + cy.getTestId('route-form-paths-input-2').should('be.visible') + cy.getTestId('remove-paths').first().should('be.visible').click() + cy.getTestId('route-form-paths-input-2').should('not.exist') + + cy.getTestId('route-form-paths-input-1').should('be.visible') + cy.getTestId('remove-paths').first().should('be.visible').click() + cy.get('.route-form-routing-rules-selector-options').should('be.visible') + + // snis + cy.getTestId('routing-rule-snis').should('be.visible').click() + cy.getTestId('route-form-snis-input-1').should('be.visible') + cy.getTestId('add-snis').should('be.visible').click() + cy.getTestId('route-form-snis-input-2').should('be.visible') + cy.getTestId('remove-snis').first().should('be.visible').click() + cy.getTestId('route-form-snis-input-2').should('not.exist') + + // hosts + cy.getTestId('routing-rule-hosts').should('be.visible').click() + cy.getTestId('route-form-hosts-input-1').should('be.visible') + cy.getTestId('add-hosts').should('be.visible').click() + cy.getTestId('route-form-hosts-input-2').should('be.visible') + cy.getTestId('remove-hosts').first().should('be.visible').click() + cy.getTestId('route-form-hosts-input-2').should('not.exist') + + // methods and custom methods + cy.getTestId('routing-rule-methods').should('be.visible').click() + cy.getTestId('routing-rule-methods').should('have.attr', 'aria-disabled').and('eq', 'true') + cy.getTestId('get-method-toggle').should('exist').should('be.visible') + cy.getTestId('post-method-toggle').should('exist').should('be.visible') + cy.getTestId('put-method-toggle').should('exist').should('be.visible') + cy.get('[data-testid="custom-method-toggle"] input').should('exist').should('not.be.visible').check({ force: true }) + cy.getTestId('route-form-custom-method-input-1').should('be.visible') + cy.getTestId('add-custom-method').should('be.visible').click() + cy.getTestId('route-form-custom-method-input-2').should('be.visible') + cy.getTestId('remove-custom-method').first().should('be.visible').click() + cy.getTestId('route-form-custom-method-input-2').should('not.exist') + cy.getTestId('remove-methods').should('be.visible').click() + cy.getTestId('get-method-toggle').should('not.exist') + cy.getTestId('routing-rule-methods').should('have.attr', 'aria-disabled').and('eq', 'false') + + // headers + cy.getTestId('routing-rule-headers').should('be.visible').click() + cy.getTestId('route-form-headers-name-input-1').should('be.visible') + cy.getTestId('route-form-headers-values-input-1').should('be.visible') + cy.getTestId('add-headers').should('be.visible').click() + cy.getTestId('route-form-headers-name-input-2').should('be.visible') + cy.getTestId('route-form-headers-values-input-2').should('be.visible') + cy.getTestId('remove-headers').first().should('be.visible').click() + cy.getTestId('route-form-headers-name-input-2').should('not.exist') + cy.getTestId('route-form-headers-values-input-2').should('not.exist') + + cy.getTestId('route-form-protocols').click({ force: true }) + cy.get("[data-testid='k-select-item-tcp,tls,udp']").click() + cy.getTestId('routing-rule-paths').should('not.exist') + cy.getTestId('routing-rule-hosts').should('not.exist') + cy.getTestId('routing-rule-methods').should('not.exist') + cy.getTestId('routing-rule-headers').should('not.exist') + + // sources + cy.getTestId('routing-rule-sources').should('be.visible').click() + cy.getTestId('route-form-sources-ip-input-1').should('be.visible') + cy.getTestId('route-form-sources-port-input-1').should('be.visible') + cy.getTestId('add-sources').should('be.visible').click() + cy.getTestId('route-form-sources-ip-input-2').should('be.visible') + cy.getTestId('route-form-sources-port-input-2').should('be.visible') + cy.getTestId('remove-sources').first().should('be.visible').click() + cy.getTestId('route-form-sources-ip-input-2').should('not.exist') + cy.getTestId('route-form-sources-port-input-2').should('not.exist') + + // destinations + cy.getTestId('routing-rule-destinations').should('be.visible').click() + cy.getTestId('route-form-destinations-ip-input-1').should('be.visible') + cy.getTestId('route-form-destinations-port-input-1').should('be.visible') + cy.getTestId('add-destinations').should('be.visible').click() + cy.getTestId('route-form-destinations-ip-input-2').should('be.visible') + cy.getTestId('route-form-destinations-port-input-2').should('be.visible') + cy.getTestId('remove-destinations').first().should('be.visible').click() + cy.getTestId('route-form-destinations-ip-input-2').should('not.exist') + cy.getTestId('route-form-destinations-port-input-2').should('not.exist') + }) + + it('should correctly handle button state - create', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKonnect, + }, + }) + + cy.get('.kong-ui-entities-route-form').should('be.visible') + + // default button state + cy.getTestId('form-cancel').should('be.visible') + cy.getTestId('form-submit').should('be.visible') + cy.getTestId('form-cancel').should('be.enabled') + cy.getTestId('form-submit').should('be.disabled') + + // enables save when required fields have values + // form fields - general + cy.getTestId('route-form-name').should('be.visible') + + // paths + cy.getTestId('route-form-paths-input-1').type(route.paths[0]) + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-paths-input-1').clear() + cy.getTestId('form-submit').should('be.disabled') + + // snis + cy.getTestId('routing-rule-snis').click() + cy.getTestId('route-form-snis-input-1').type('sni') + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-snis-input-1').clear() + cy.getTestId('form-submit').should('be.disabled') + + // hosts + cy.getTestId('routing-rule-hosts').click() + cy.getTestId('route-form-hosts-input-1').type('host') + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-hosts-input-1').clear() + cy.getTestId('form-submit').should('be.disabled') + + // methods and custom methods + cy.getTestId('routing-rule-methods').click() + cy.get('[data-testid="get-method-toggle"] input').check({ force: true }) + cy.getTestId('form-submit').should('be.enabled') + cy.get('[data-testid="get-method-toggle"] input').uncheck({ force: true }) + cy.getTestId('form-submit').should('be.disabled') + cy.get('[data-testid="custom-method-toggle"] input').check({ force: true }) + cy.getTestId('form-submit').should('be.disabled') + cy.getTestId('route-form-custom-method-input-1').type('castom') + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-custom-method-input-1').clear() + cy.getTestId('form-submit').should('be.disabled') + + // headers + cy.getTestId('routing-rule-headers').click() + cy.getTestId('route-form-headers-name-input-1').type(Object.keys(route.headers)[0]) + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-headers-name-input-1').clear() + cy.getTestId('route-form-headers-values-input-1').type(route.headers.Header1[0]) + cy.getTestId('form-submit').should('be.disabled') + + cy.getTestId('route-form-protocols').click({ force: true }) + cy.get("[data-testid='k-select-item-tcp,tls,udp']").click() + + // sources + cy.getTestId('routing-rule-sources').click() + cy.getTestId('route-form-sources-ip-input-1').type('127.0.0.1') + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-sources-ip-input-1').clear() + cy.getTestId('route-form-sources-port-input-1').type('8080') + cy.getTestId('form-submit').should('be.disabled') + + // destinations + cy.getTestId('routing-rule-destinations').click() + cy.getTestId('route-form-destinations-ip-input-1').type('127.0.0.2') + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('route-form-destinations-ip-input-1').clear() + cy.getTestId('route-form-destinations-port-input-1').type('8000') + cy.getTestId('form-submit').should('be.disabled') + }) + + it('should show edit form', () => { + interceptKonnect() + interceptKonnectServices() + + cy.mount(RouteForm, { + props: { + config: baseConfigKonnect, + routeId: route.id, + }, + }) + + cy.wait('@getRoute') + cy.wait('@getServices') + cy.get('.kong-ui-entities-route-form').should('be.visible') + + // button state + cy.getTestId('form-cancel').should('be.visible') + cy.getTestId('form-submit').should('be.visible') + cy.getTestId('form-cancel').should('be.enabled') + cy.getTestId('form-submit').should('be.disabled') + + // form fields + cy.getTestId('route-form-name').should('have.value', route.name) + cy.getTestId('route-form-service-id').should('have.value', route.service.id) + cy.getTestId('route-form-tags').should('have.value', route.tags.join(', ')) + cy.getTestId('route-form-paths-input-1').should('have.value', route.paths[0]) + cy.getTestId('route-form-paths-input-2').should('have.value', route.paths[1]) + cy.get(`[data-testid="${route.methods[0].toLowerCase()}-method-toggle"] input`).should('be.checked') + cy.get(`[data-testid="${route.methods[1].toLowerCase()}-method-toggle"] input`).should('be.checked') + cy.getTestId('route-form-headers-name-input-1').should('have.value', Object.keys(route.headers)[0]) + cy.getTestId('route-form-headers-values-input-1').should('have.value', route.headers.Header1.join(',')) + + cy.getTestId('k-collapse-trigger-content').click() + cy.getTestId('route-form-path-handling').should('have.value', route.path_handling) + cy.getTestId('route-form-regex-priority').should('have.value', route.regex_priority) + cy.getTestId('route-form-strip-path').should(`${route.strip_path ? '' : 'not.'}be.checked`) + cy.getTestId('route-form-preserve-host').should(`${route.preserve_host ? '' : 'not.'}be.checked`) + }) + + it('should correctly handle button state - edit', () => { + interceptKonnect() + interceptKonnectServices() + + cy.mount(RouteForm, { + props: { + config: baseConfigKonnect, + routeId: route.id, + }, + }) + + cy.wait('@getRoute') + cy.wait('@getServices') + + cy.get('.kong-ui-entities-route-form').should('be.visible') + + // default button state + cy.getTestId('form-cancel').should('be.visible') + cy.getTestId('form-submit').should('be.visible') + cy.getTestId('form-cancel').should('be.enabled') + cy.getTestId('form-submit').should('be.disabled') + cy.getTestId('routing-rules-warning').should('not.exist') + + // enables save when form has changes + cy.getTestId('route-form-service-id').click({ force: true }) + cy.get("[data-testid='k-select-item-2']").click() + cy.getTestId('form-submit').should('be.enabled') + cy.getTestId('remove-methods').click() + cy.getTestId('remove-paths').first().click() + cy.getTestId('remove-paths').click() + cy.getTestId('remove-headers').click() + cy.getTestId('routing-rules-warning').should('be.visible') + cy.getTestId('form-submit').should('be.disabled') + }) + + it('should handle error state - failed to load route', () => { + interceptKonnectServices() + + cy.intercept( + { + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/api/runtime_groups/${baseConfigKonnect.controlPlaneId}/routes/*`, + }, + { + statusCode: 404, + body: {}, + }, + ).as('getRoute') + + cy.mount(RouteForm, { + props: { + config: baseConfigKonnect, + routeId: route.id, + }, + }) + + cy.wait('@getRoute') + cy.wait('@getServices') + + cy.get('.kong-ui-entities-route-form').should('be.visible') + + // error state is displayed + cy.getTestId('form-fetch-error').should('be.visible') + + // buttons and form hidden + cy.getTestId('form-cancel').should('not.exist') + cy.getTestId('form-submit').should('not.exist') + cy.get('.kong-ui-entities-route-form form').should('not.exist') + }) + + it('should allow exact match filtering of certs', () => { + interceptKonnectServices() + + cy.mount(RouteForm, { + props: { + config: baseConfigKonnect, + }, + }) + + cy.wait('@getServices') + cy.get('.kong-ui-entities-route-form').should('be.visible') + + // search + cy.getTestId('route-form-service-id').should('be.visible') + cy.getTestId('route-form-service-id').type(services[1].name) + + // click kselect item + cy.getTestId(`k-select-item-${services[1].id}`).should('be.visible') + cy.get(`[data-testid="k-select-item-${services[1].id}"] button`).click() + cy.getTestId('route-form-service-id').should('have.value', services[1].id) + }) + + it('should handle error state - failed to load services', () => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/api/runtime_groups/${baseConfigKonnect.controlPlaneId}/services*`, + }, + { + statusCode: 500, + body: {}, + }, + ).as('getServices') + + cy.mount(RouteForm, { + props: { + config: baseConfigKonnect, + }, + }) + + cy.wait('@getServices') + cy.get('.kong-ui-entities-route-form').should('be.visible') + cy.getTestId('form-error').should('be.visible') + }) + + it('should correctly render with all props and slot content', () => { + cy.mount(RouteForm, { + props: { + config: baseConfigKonnect, + serviceId: services[0].id, + hideSectionsInfo: true, + hideNameField: true, + showTagsFiledUnderAdvanced: true, + }, + slots: { + 'form-actions': '', + }, + }) + + cy.get('.kong-ui-entities-route-form').should('be.visible') + cy.get('.kong-ui-entities-route-form form').should('be.visible') + + // name field should be hidden when hideNameField is true + cy.getTestId('route-form-name').should('not.exist') + + // tags field should render under advanced fields + cy.getTestId('route-form-tags').should('not.be.visible') + cy.getTestId('k-collapse-trigger-content').click() + cy.getTestId('route-form-tags').should('be.visible') + + // service id field should be hidden when serviceId is provided + cy.getTestId('route-form-service-id').should('not.exist') + + // sections info should be hidden when hideSectionsInfo is true + cy.get('.form-section-info sticky').should('not.exist') + + // default buttons should be replaced with slotted content + cy.getTestId('form-cancel').should('not.exist') + cy.getTestId('form-submit').should('not.exist') + cy.getTestId('slotted-cancel-button').should('be.visible') + cy.getTestId('slotted-submit-button').should('be.visible') + }) + + it('update event should be emitted when Route was edited', () => { + interceptKonnect() + interceptUpdate() + + cy.mount(RouteForm, { + props: { + config: baseConfigKonnect, + routeId: route.id, + onUpdate: cy.spy().as('onUpdateSpy'), + }, + }).then(({ wrapper }) => wrapper) + .as('vueWrapper') + + cy.wait('@getRoute') + cy.getTestId('route-form-tags').clear() + cy.getTestId('route-form-tags').type('tag1,tag2') + + cy.get('@vueWrapper').then((wrapper: any) => wrapper.findComponent(EntityBaseForm) + .vm.$emit('submit')) + + cy.wait('@updateRoute') + + cy.get('@onUpdateSpy').should('have.been.calledOnce') + }) + }) +}) diff --git a/packages/entities/entities-routes/src/components/RouteForm.vue b/packages/entities/entities-routes/src/components/RouteForm.vue new file mode 100644 index 0000000000..30b9db3f99 --- /dev/null +++ b/packages/entities/entities-routes/src/components/RouteForm.vue @@ -0,0 +1,1049 @@ + + + + + + + diff --git a/packages/entities/entities-routes/src/components/RouteFormDestinationsFields.vue b/packages/entities/entities-routes/src/components/RouteFormDestinationsFields.vue new file mode 100644 index 0000000000..6e96dfaa84 --- /dev/null +++ b/packages/entities/entities-routes/src/components/RouteFormDestinationsFields.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/packages/entities/entities-routes/src/components/RouteFormHeadersFields.vue b/packages/entities/entities-routes/src/components/RouteFormHeadersFields.vue new file mode 100644 index 0000000000..fab9093164 --- /dev/null +++ b/packages/entities/entities-routes/src/components/RouteFormHeadersFields.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/packages/entities/entities-routes/src/components/RouteFormHostsFields.vue b/packages/entities/entities-routes/src/components/RouteFormHostsFields.vue new file mode 100644 index 0000000000..8d1551a774 --- /dev/null +++ b/packages/entities/entities-routes/src/components/RouteFormHostsFields.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/packages/entities/entities-routes/src/components/RouteFormMethodsFields.vue b/packages/entities/entities-routes/src/components/RouteFormMethodsFields.vue new file mode 100644 index 0000000000..83a1d08e22 --- /dev/null +++ b/packages/entities/entities-routes/src/components/RouteFormMethodsFields.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/packages/entities/entities-routes/src/components/RouteFormPathsFields.vue b/packages/entities/entities-routes/src/components/RouteFormPathsFields.vue new file mode 100644 index 0000000000..4cbaca6188 --- /dev/null +++ b/packages/entities/entities-routes/src/components/RouteFormPathsFields.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/packages/entities/entities-routes/src/components/RouteFormSnisFields.vue b/packages/entities/entities-routes/src/components/RouteFormSnisFields.vue new file mode 100644 index 0000000000..591a7d3c5e --- /dev/null +++ b/packages/entities/entities-routes/src/components/RouteFormSnisFields.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/packages/entities/entities-routes/src/components/RouteFormSourcesFields.vue b/packages/entities/entities-routes/src/components/RouteFormSourcesFields.vue new file mode 100644 index 0000000000..ec9f66e925 --- /dev/null +++ b/packages/entities/entities-routes/src/components/RouteFormSourcesFields.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/packages/entities/entities-routes/src/components/RouteList.cy.ts b/packages/entities/entities-routes/src/components/RouteList.cy.ts new file mode 100644 index 0000000000..fbfbac50f0 --- /dev/null +++ b/packages/entities/entities-routes/src/components/RouteList.cy.ts @@ -0,0 +1,767 @@ +// Cypress component test spec file +import RouteList from './RouteList.vue' +import type { FetcherResponse } from '@kong-ui-public/entities-shared' +import { + FetcherRawResponse, + paginate, + routes, + routes100, +} from '../../fixtures/mockData' +import { KongManagerRouteListConfig, KonnectRouteListConfig } from '../types' +import { createMemoryHistory, createRouter, Router } from 'vue-router' +import { v4 as uuidv4 } from 'uuid' + +const viewRoute = 'view-route' +const editRoute = 'edit-route' +const createRoute = 'create-route' + +const baseConfigKonnect: KonnectRouteListConfig = { + app: 'konnect', + controlPlaneId: '1234-abcd-ilove-cats', + apiBaseUrl: '/us/kong-api/konnect-api', + createRoute, + getViewRoute: () => viewRoute, + getEditRoute: () => editRoute, +} + +const baseConfigKM: KongManagerRouteListConfig = { + app: 'kongManager', + workspace: 'default', + apiBaseUrl: '/kong-manager', + isExactMatch: false, + filterSchema: {}, + createRoute, + getViewRoute: () => viewRoute, + getEditRoute: () => editRoute, +} + +describe('', () => { + beforeEach(() => { + cy.on('uncaught:exception', err => !err.message.includes('ResizeObserver loop limit exceeded')) + }) + + describe('actions', () => { + // Create a new router instance for each test + let router: Router + + beforeEach(() => { + // Initialize a new router before each test + router = createRouter({ + routes: [ + { path: '/', name: 'list-route', component: { template: '
ListPage
' } }, + { path: `/${viewRoute}`, name: viewRoute, component: { template: '
DetailPage
' } }, + ], + history: createMemoryHistory(), + }) + + // Mock data for each test in this block; doesn't matter if we use KM or Konnect + cy.intercept( + { + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/api/runtime_groups/${baseConfigKonnect.controlPlaneId}/routes*`, + }, + { + statusCode: 200, + body: routes, + }, + ) + }) + + it('should always show the Copy ID action', () => { + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKonnect, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + cy.getTestId('k-dropdown-trigger').eq(0).click() + cy.getTestId('action-entity-copy-id').should('be.visible') + }) + + for (const expected of [false, true]) { + describe(`${expected ? 'allowed' : 'denied'}`, () => { + it(`should ${expected ? 'allow' : 'prevent'} row click if canRetrieve evaluates to ${expected}`, () => { + expect(router.currentRoute.value.fullPath).not.to.include(viewRoute) + + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKonnect, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => expected, + }, + router, + }) + + // eslint-disable-next-line cypress/unsafe-to-chain-command + cy.get('table tbody td').eq(0).click().then(() => { + if (expected) { + expect(router.currentRoute.value.fullPath).to.include(viewRoute) + } else { + expect(router.currentRoute.value.fullPath).not.to.include(viewRoute) + } + }) + }) + + it(`should ${expected ? 'show' : 'hide'} the View Details action if canRetrieve evaluates to ${expected}`, () => { + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKonnect, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => expected, + }, + }) + + cy.getTestId('k-dropdown-trigger').eq(0).click() + cy.getTestId('action-entity-view').should(`${!expected ? 'not.' : ''}exist`) + }) + + it(`should ${expected ? '' : 'not'} include the Edit action if canEdit evaluates to ${expected}`, () => { + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKonnect, + canCreate: () => {}, + canEdit: () => expected, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + cy.getTestId('k-dropdown-trigger').eq(0).click() + cy.getTestId('action-entity-edit').should(`${expected ? '' : 'not.'}exist`) + }) + + it(`should ${expected ? '' : 'not'} include the Delete action if canDelete evaluates to ${expected}`, () => { + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKonnect, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => expected, + canRetrieve: () => {}, + }, + }) + + cy.getTestId('k-dropdown-trigger').eq(0).click() + cy.getTestId('action-entity-delete').should(`${expected ? '' : 'not.'}exist`) + }) + }) + } + }) + + describe('Kong Manager', () => { + const interceptKM = (params?: { + mockData?: FetcherRawResponse; + alias?: string; + }) => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/routes*`, + }, + { + statusCode: 200, + body: params?.mockData ?? { + data: [], + total: 0, + }, + }, + ).as(params?.alias ?? 'getRoutes') + } + + const interceptKMMultiPage = (params?: { + mockData?: FetcherRawResponse[]; + alias?: string; + }) => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/routes*`, + }, + (req) => { + const size = req.query.size ? Number(req.query.size) : 30 + const offset = req.query.offset ? Number(req.query.offset) : 0 + + req.reply({ + statusCode: 200, + body: paginate(params?.mockData ?? [], size, offset), + }) + }, + ).as(params?.alias ?? 'getRoutesMultiPage') + } + + it('should show empty state and create route cta', () => { + interceptKM() + + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKM, + canCreate: () => true, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + cy.wait('@getRoutes') + cy.get('.kong-ui-entities-routes-list').should('be.visible') + cy.get('.k-table-empty-state').should('be.visible') + cy.get('.k-table-empty-state .k-empty-state-cta .k-button').should('be.visible') + }) + + it('should hide empty state and create route cta if user can not create', () => { + interceptKM() + + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKM, + canCreate: () => false, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + cy.wait('@getRoutes') + cy.get('.kong-ui-entities-routes-list').should('be.visible') + cy.get('.k-table-empty-state').should('be.visible') + cy.get('.k-table-empty-state .k-empty-state-cta .k-button').should('not.exist') + }) + + it('should handle error state', () => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/routes*`, + }, + { + statusCode: 500, + body: {}, + }, + ).as('getRoutes') + + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKM, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + cy.wait('@getRoutes') + cy.get('.kong-ui-entities-routes-list').should('be.visible') + cy.get('.k-table-error-state').should('be.visible') + }) + + it('should show route items', () => { + interceptKM({ + mockData: routes, + }) + + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKM, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + cy.wait('@getRoutes') + cy.get('.kong-ui-entities-routes-list tr[data-testid="route-1"]').should( + 'exist', + ) + cy.get('.kong-ui-entities-routes-list tr[data-testid="route-2"]').should( + 'exist', + ) + }) + + it('should allow switching between pages', () => { + interceptKMMultiPage({ + mockData: routes100, + }) + + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKM, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + const l = '.kong-ui-entities-routes-list' + const p = '[data-testid="k-table-pagination"]' + + cy.wait('@getRoutesMultiPage') + + // Page #1 + cy.get(`${l} tbody tr`).should('have.length', 30) + cy.get(`${l} tbody tr[data-testid="route-1"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-2"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-29"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-30"]`).should('exist') + + cy.get(`${l} ${p}`).should('exist') + cy.get(`${l} ${p} [data-testid="prev-btn"]`).should( + 'have.class', + 'disabled', + ) + cy.get(`${l} ${p} [data-testid="next-btn"]`) + .should('not.have.class', 'disabled') + .click() // next page + + cy.wait('@getRoutesMultiPage') + + // Page #2 + cy.get(`${l} tbody tr`).should('have.length', 30) + cy.get(`${l} tbody tr[data-testid="route-31"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-32"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-59"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-60"]`).should('exist') + + cy.get(`${l} ${p} [data-testid="prev-btn"]`).should( + 'not.have.class', + 'disabled', + ) + cy.get(`${l} ${p} [data-testid="next-btn"]`) + .should('not.have.class', 'disabled') + .click() // next page + + cy.wait('@getRoutesMultiPage') + + cy.get(`${l} ${p} [data-testid="next-btn"]`) + .should('not.have.class', 'disabled') + .click() // next page + + // Page #4 + cy.get(`${l} tbody tr`).should('have.length', 10) + cy.get(`${l} tbody tr[data-testid="route-91"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-92"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-99"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-100"]`).should('exist') + + cy.get(`${l} ${p} [data-testid="prev-btn"]`).should( + 'not.have.class', + 'disabled', + ) + cy.get(`${l} ${p} [data-testid="next-btn"]`).should( + 'have.class', + 'disabled', + ) + }) + + it('should allow picking different page sizes and persist the preference', () => { + interceptKMMultiPage({ + mockData: routes100, + }) + + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKM, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + .then(({ wrapper }) => wrapper) + .as('vueWrapper') + + const l = '.kong-ui-entities-routes-list' + const p = '[data-testid="k-table-pagination"]' + + cy.wait('@getRoutesMultiPage') + + cy.get(`${l} tbody tr`).should('have.length', 30) + cy.get(`${l} tbody tr[data-testid="route-1"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-2"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-29"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-30"]`).should('exist') + + cy.get(`${l} ${p} [data-testid="page-size-dropdown"]`).should( + 'contain.text', + '30 items per page', + ) + cy.get(`${l} ${p} [data-testid="page-size-dropdown"]`).click() + cy.get( + `${l} ${p} [data-testid="page-size-dropdown"] [value="15"]`, + ).click() + + cy.wait('@getRoutesMultiPage') + + cy.get(`${l} tbody tr`).should('have.length', 15) + cy.get(`${l} tbody tr[data-testid="route-1"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-2"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-14"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-15"]`).should('exist') + + // Unmount and mount + cy.get('@vueWrapper').then((wrapper: any) => wrapper.unmount()) + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKM, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + cy.wait('@getRoutesMultiPage') + + cy.get(`${l} tbody tr`).should('have.length', 15) + cy.get(`${l} tbody tr[data-testid="route-1"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-2"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-14"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-15"]`).should('exist') + + cy.get(`${l} ${p} [data-testid="page-size-dropdown"]`).should( + 'contain.text', + '15 items per page', + ) + cy.get(`${l} ${p} [data-testid="page-size-dropdown"]`).click() + cy.get( + `${l} ${p} [data-testid="page-size-dropdown"] [value="50"]`, + ).click() + + cy.wait('@getRoutesMultiPage') + + cy.get(`${l} tbody tr`).should('have.length', 50) + cy.get(`${l} tbody tr[data-testid="route-1"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-2"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-49"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-50"]`).should('exist') + + cy.get(`${l} ${p} [data-testid="page-size-dropdown"]`).should( + 'contain.text', + '50 items per page', + ) + }) + }) + + describe('Konnect', () => { + const interceptKonnect = (params?: { + mockData?: FetcherResponse; + alias?: string; + }) => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/api/runtime_groups/${baseConfigKonnect.controlPlaneId}/routes*`, + }, + { + statusCode: 200, + body: params?.mockData ?? { + data: [], + total: 0, + }, + }, + ).as(params?.alias ?? 'getRoutes') + } + + const interceptKonnectMultiPage = (params?: { + mockData?: FetcherRawResponse[]; + alias?: string; + }) => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/api/runtime_groups/${baseConfigKonnect.controlPlaneId}/routes*`, + }, + (req) => { + const size = req.query.size ? Number(req.query.size) : 30 + const offset = req.query.offset ? Number(req.query.offset) : 0 + + req.reply({ + statusCode: 200, + body: paginate(params?.mockData ?? [], size, offset), + }) + }, + ).as(params?.alias ?? 'getRoutesMultiPage') + } + + it('should show empty state and create route cta', () => { + interceptKonnect() + + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKonnect, + canCreate: () => true, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + cy.wait('@getRoutes') + cy.get('.kong-ui-entities-routes-list').should('be.visible') + cy.get('.k-table-empty-state').should('be.visible') + cy.get('.k-table-empty-state .k-empty-state-cta .k-button').should('be.visible') + }) + + it('should hide empty state and create route cta if user can not create', () => { + interceptKonnect() + + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKonnect, + canCreate: () => false, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + cy.wait('@getRoutes') + cy.get('.kong-ui-entities-routes-list').should('be.visible') + cy.get('.k-table-empty-state').should('be.visible') + cy.get('.k-table-empty-state .k-empty-state-cta .k-button').should('not.exist') + }) + + it('should handle error state', () => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/api/runtime_groups/${baseConfigKonnect.controlPlaneId}/routes*`, + }, + { + statusCode: 500, + body: {}, + }, + ).as('getRoutes') + + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKonnect, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + cy.wait('@getRoutes') + cy.get('.kong-ui-entities-routes-list').should('be.visible') + cy.get('.k-table-error-state').should('be.visible') + }) + + it('should show route items', () => { + interceptKonnect({ + mockData: routes, + }) + + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKonnect, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + cy.wait('@getRoutes') + cy.get('.kong-ui-entities-routes-list tr[data-testid="route-1"]').should( + 'exist', + ) + cy.get('.kong-ui-entities-routes-list tr[data-testid="route-2"]').should( + 'exist', + ) + }) + + it('should allow switching between pages', () => { + interceptKonnectMultiPage({ + mockData: routes100, + }) + + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKonnect, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + const l = '.kong-ui-entities-routes-list' + const p = '[data-testid="k-table-pagination"]' + + cy.wait('@getRoutesMultiPage') + + // Page #1 + cy.get(`${l} tbody tr`).should('have.length', 30) + cy.get(`${l} tbody tr[data-testid="route-1"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-2"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-29"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-30"]`).should('exist') + + cy.get(`${l} ${p}`).should('exist') + cy.get(`${l} ${p} [data-testid="prev-btn"]`).should( + 'have.class', + 'disabled', + ) + cy.get(`${l} ${p} [data-testid="next-btn"]`) + .should('not.have.class', 'disabled') + .click() // next page + + cy.wait('@getRoutesMultiPage') + + // Page #2 + cy.get(`${l} tbody tr`).should('have.length', 30) + cy.get(`${l} tbody tr[data-testid="route-31"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-32"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-59"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-60"]`).should('exist') + + cy.get(`${l} ${p} [data-testid="prev-btn"]`).should( + 'not.have.class', + 'disabled', + ) + cy.get(`${l} ${p} [data-testid="next-btn"]`) + .should('not.have.class', 'disabled') + .click() // next page + + cy.wait('@getRoutesMultiPage') + + cy.get(`${l} ${p} [data-testid="next-btn"]`) + .should('not.have.class', 'disabled') + .click() // next page + + // Page #4 + cy.get(`${l} tbody tr`).should('have.length', 10) + cy.get(`${l} tbody tr[data-testid="route-91"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-92"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-99"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-100"]`).should('exist') + + cy.get(`${l} ${p} [data-testid="prev-btn"]`).should( + 'not.have.class', + 'disabled', + ) + cy.get(`${l} ${p} [data-testid="next-btn"]`).should( + 'have.class', + 'disabled', + ) + }) + + it('should allow picking different page sizes and persist the preference', () => { + interceptKonnectMultiPage({ + mockData: routes100, + }) + + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKonnect, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + .then(({ wrapper }) => wrapper) + .as('vueWrapper') + + const l = '.kong-ui-entities-routes-list' + const p = '[data-testid="k-table-pagination"]' + + cy.wait('@getRoutesMultiPage') + + cy.get(`${l} tbody tr`).should('have.length', 30) + cy.get(`${l} tbody tr[data-testid="route-1"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-2"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-29"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-30"]`).should('exist') + + cy.get(`${l} ${p} [data-testid="page-size-dropdown"]`).should( + 'contain.text', + '30 items per page', + ) + cy.get(`${l} ${p} [data-testid="page-size-dropdown"]`).click() + cy.get( + `${l} ${p} [data-testid="page-size-dropdown"] [value="15"]`, + ).click() + + cy.wait('@getRoutesMultiPage') + + cy.get(`${l} tbody tr`).should('have.length', 15) + cy.get(`${l} tbody tr[data-testid="route-1"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-2"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-14"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-15"]`).should('exist') + + // Unmount and mount + cy.get('@vueWrapper').then((wrapper: any) => wrapper.unmount()) + cy.mount(RouteList, { + props: { + cacheIdentifier: `route-list-${uuidv4()}`, + config: baseConfigKonnect, + canCreate: () => {}, + canEdit: () => {}, + canDelete: () => {}, + canRetrieve: () => {}, + }, + }) + + cy.wait('@getRoutesMultiPage') + + cy.get(`${l} tbody tr`).should('have.length', 15) + cy.get(`${l} tbody tr[data-testid="route-1"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-2"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-14"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-15"]`).should('exist') + + cy.get(`${l} ${p} [data-testid="page-size-dropdown"]`).should( + 'contain.text', + '15 items per page', + ) + cy.get(`${l} ${p} [data-testid="page-size-dropdown"]`).click() + cy.get( + `${l} ${p} [data-testid="page-size-dropdown"] [value="50"]`, + ).click() + + cy.wait('@getRoutesMultiPage') + + cy.get(`${l} tbody tr`).should('have.length', 50) + cy.get(`${l} tbody tr[data-testid="route-1"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-2"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-49"]`).should('exist') + cy.get(`${l} tbody tr[data-testid="route-50"]`).should('exist') + + cy.get(`${l} ${p} [data-testid="page-size-dropdown"]`).should( + 'contain.text', + '50 items per page', + ) + }) + }) +}) diff --git a/packages/entities/entities-routes/src/components/RouteList.vue b/packages/entities/entities-routes/src/components/RouteList.vue new file mode 100644 index 0000000000..0a26c26f2f --- /dev/null +++ b/packages/entities/entities-routes/src/components/RouteList.vue @@ -0,0 +1,524 @@ + + + + + diff --git a/packages/entities/entities-routes/src/components/RoutingRulesEntitiesControls.vue b/packages/entities/entities-routes/src/components/RoutingRulesEntitiesControls.vue new file mode 100644 index 0000000000..0373811d1a --- /dev/null +++ b/packages/entities/entities-routes/src/components/RoutingRulesEntitiesControls.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/packages/entities/entities-routes/src/composables/index.ts b/packages/entities/entities-routes/src/composables/index.ts new file mode 100644 index 0000000000..3893dfc828 --- /dev/null +++ b/packages/entities/entities-routes/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/entities/entities-routes/src/composables/useI18n.ts b/packages/entities/entities-routes/src/composables/useI18n.ts new file mode 100644 index 0000000000..337ff02756 --- /dev/null +++ b/packages/entities/entities-routes/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/entities/entities-routes/src/global-components.d.ts b/packages/entities/entities-routes/src/global-components.d.ts new file mode 100644 index 0000000000..2f0048d672 --- /dev/null +++ b/packages/entities/entities-routes/src/global-components.d.ts @@ -0,0 +1,2 @@ +// Import globally available components +import '@kong/kongponents/dist/types/global-components' diff --git a/packages/entities/entities-routes/src/index.ts b/packages/entities/entities-routes/src/index.ts new file mode 100644 index 0000000000..a4a2e2e13e --- /dev/null +++ b/packages/entities/entities-routes/src/index.ts @@ -0,0 +1,7 @@ +import RouteList from './components/RouteList.vue' +import RouteForm from './components/RouteForm.vue' +import RouteConfigCard from './components/RouteConfigCard.vue' + +export { RouteList, RouteForm, RouteConfigCard } + +export * from './types' diff --git a/packages/entities/entities-routes/src/locales/en.json b/packages/entities/entities-routes/src/locales/en.json new file mode 100644 index 0000000000..52ec645125 --- /dev/null +++ b/packages/entities/entities-routes/src/locales/en.json @@ -0,0 +1,227 @@ +{ + "actions": { + "create": "New Route", + "copy_id": "Copy ID", + "copy_json": "Copy JSON", + "edit": "Edit", + "delete": "Delete", + "clear": "Clear", + "view": "View Details", + "loading": "Loading..." + }, + "search": { + "placeholder": "Filter by exact name or ID", + "no_results": "No results found" + }, + "routes": { + "title": "Routes", + "list": { + "toolbar_actions": { + "new_route": "New Route" + }, + "table_headers": { + "name": "Name", + "protocols": "Protocols", + "hosts": "Hosts", + "methods": "Methods", + "paths": "Paths", + "expression": "Expression", + "id": "ID", + "tags": "Tags" + }, + "empty_state": { + "title": "Configure a New Route", + "description": "Routes proxy requests to an associated Service." + } + } + }, + "delete": { + "title": "Delete a Route", + "description": "Deleting this route will also remove any associated plugins. This action cannot be reversed." + }, + "errors": { + "general": "Routes could not be retrieved", + "delete": "The route could not be deleted at this time.", + "copy": "Failed to copy to clipboard", + "services": { + "fetch": "Could not fetch available services" + } + }, + "copy": { + "success": "Copied {val} to clipboard", + "success_brief": "Successfully copied to clipboard" + }, + "form": { + "protocols": { + "grpc": "GRPC", + "grpcs": "GRPCS", + "grpc,grpcs": "GRPC, GRPCS", + "http": "HTTP", + "https": "HTTPS", + "http,https": "HTTP, HTTPS", + "tls": "TLS", + "tcp": "TCP", + "udp": "UDP", + "tls,udp": "TLS, UDP", + "tcp,udp": "TCP, UDP", + "tcp,tls": "TCP, TLS", + "tcp,tls,udp": "TCP, TLS, UDP", + "ws": "WS", + "wss": "WSS", + "ws,wss": "WS, WSS", + "tls_passthrough": "TLS_PASSTHROUGH" + }, + "sections": { + "general": { + "title": "General Information", + "description": "General information will help you identify and manage this route" + }, + "config": { + "title": "Route Configuration", + "description": "Route configuration determines how this route will handle incoming requests" + }, + "routingRules": { + "title": "Routing Rules" + } + }, + "fields": { + "service_id": { + "label": "Service", + "placeholder": "Select a service" + }, + "name": { + "label": "Name", + "placeholder": "Enter a unique name", + "tooltip": "The name of the Route. Route names must be unique, and they are case sensitive. For example, there can be two different Routes named 'test' and 'Test'." + }, + "service": { + "label": "Gateway Service", + "tooltip": "The Service this Route is associated to. This is where the Route proxies traffic to." + }, + "tags": { + "label": "Tags", + "tooltip": "An optional set of strings associated with the Route for grouping and filtering.", + "placeholder": "Enter a list of tags separated by commas", + "help": "e.g. tag1, tag2, tag3" + }, + "protocols": { + "label": "Protocols", + "tooltip": "Routes have a protocols property to restrict the client protocol they should listen for.", + "tooltipConfig": "An array of the protocols this Route should allow. See the {code1} section for a list of accepted protocols. When set to only {code2}, HTTP requests are answered with an upgrade error. When it is set to only {code3}, this is essentially the same as {code4} in that both HTTP and HTTPS requests are allowed. Default: {code5}.", + "code1": "Route Object", + "code2": "https", + "code3": "http", + "code4": "['http', 'https']", + "code5": "['http', 'https']" + }, + "path_handling": { + "label": "Path Handling", + "tooltip": "This treats service.path, route.path and request path as segments of a URL.", + "tooltipConfig": "Controls how the Service path, Route path and requested path are combined when sending a request to the upstream. See above for a detailed description of each behavior." + }, + "https_redirect_status_code": { + "label": "HTTPS Redirect Status Code", + "tooltip": "The status code Kong responds with when all properties of a Route match except the protocol i.e. if the protocol of the request is {code1} instead of {code2}. {code3} header is injected by Kong if the field is set to 301, 302, 307 or 308. Note: This config applies only if the Route is configured to only accept the {code4} protocol.", + "code1": "HTTP", + "code2": "HTTPS", + "code3": "Location", + "code4": "https" + }, + "regex_priority": { + "label": "Regex Priority", + "tooltip": "A number used to choose which route resolves a given request when several routes match it using regexes simultaneously. When two routes match the path and have the same {code1}, the older one (lowest {code2}) is used. Note that the priority for non-regex routes is different (longer non-regex routes are matched before shorter ones).", + "code1": "regex_priority", + "code2": "created_at" + }, + "strip_path": { + "label": "Strip Path", + "tooltip": "When matching a Route via one of the {code1}, strip the matching prefix from the upstream request URL.", + "code1": "paths" + }, + "preserve_host": { + "label": "Preserve Host", + "tooltip": "When matching a Route via one of the {code1} domain names, use the request {code2} header in the upstream request headers. If set to {code3}, the upstream {code4} header will be that of the Service's {code5}.", + "code1": "hosts", + "code2": "Host", + "code3": "false", + "code4": "Host", + "code5": "host" + }, + "paths": { + "label": "Paths", + "placeholder": "Enter a path", + "tooltip": "AA list of paths that match this Route." + }, + "snis": { + "label": "SNIs", + "placeholder": "Enter a SNI", + "tooltip": "A list of SNIs (Server Name Indications from a TLS ClientHello) that match this Route." + }, + "hosts": { + "label": "Hosts", + "placeholder": "Enter a host", + "tooltip": "A list of domain names that match this Route.", + "tooltipConfig": "A list of domain names that match this Route. Note that the hosts value is case sensitive." + }, + "methods": { + "label": "Methods", + "tooltip": "A list of HTTP methods that match this Route.", + "custom": { + "label": "Custom", + "placeholder": "Enter a custom method", + "tooltip": "Custom methods can be used with default selections. Uppercase characters are required." + } + }, + "headers": { + "label": "Headers", + "tooltip": "Header values can be separated by commas (value1, value2, value3).", + "name": { + "placeholder": "Enter a header name" + }, + "values": { + "placeholder": "Enter a header value" + }, + "tooltipConfig": "One or more lists of values indexed by header name that will cause this Route to match if present in the request. The {code1} header cannot be used with this attribute: hosts should be specified using the {code2} attribute. When {code3} contains only one value and that value starts with the special prefix {code4], the value is interpreted as a regular expression.", + "code1": "Host", + "code2": "hosts", + "code3": "headers", + "code4": "*" + }, + "sources": { + "label": "Sources", + "tooltip": "A list of IP sources of incoming connections that match this Route when using stream routing.", + "ip": { + "placeholder": "Enter a source IP" + }, + "port": { + "placeholder": "Enter a source port" + } + }, + "destinations": { + "label": "Destinations", + "tooltip": "A list of IP destinations of incoming connections that match this Route when using stream routing.", + "ip": { + "placeholder": "Enter a destination IP" + }, + "port": { + "placeholder": "Enter a destination port" + } + }, + "response_buffering": { + "label": "Response buffering", + "tooltip": "Whether to enable response body buffering or not. With HTTP 1.1, it may make sense to turn this off on services that send data with chunked transfer encoding." + }, + "request_buffering": { + "label": "Request buffering", + "tooltip": "Whether to enable request body buffering or not. With HTTP 1.1, it may make sense to turn this off on services that receive data with chunked transfer encoding." + } + }, + "viewAdvancedFields": "View Advanced Fields", + "addNewRule": "Add New Rule", + "warning": { + "rulesMessage": "For {protocol}, at least one of {routingRules} must be set.", + "singleRule": "{routingRules}", + "multipleRules": "{routingRules} or {lastRoutingRule}" + } + } +} diff --git a/packages/entities/entities-routes/src/routes-endpoints.ts b/packages/entities/entities-routes/src/routes-endpoints.ts new file mode 100644 index 0000000000..712c685f6f --- /dev/null +++ b/packages/entities/entities-routes/src/routes-endpoints.ts @@ -0,0 +1,56 @@ +export default { + list: { + konnect: { + all: '/api/runtime_groups/{controlPlaneId}/routes', + forGatewayService: '/api/runtime_groups/{controlPlaneId}/services/{serviceId}/routes', + }, + kongManager: { + all: '/{workspace}/routes', + forGatewayService: '/{workspace}/services/{serviceId}/routes', + }, + }, + form: { + konnect: { + services: '/api/runtime_groups/{controlPlaneId}/services', + create: { + all: '/api/runtime_groups/{controlPlaneId}/routes', + forGatewayService: '/api/runtime_groups/{controlPlaneId}/services/{serviceId}/routes', + }, + fetch: { + all: '/api/runtime_groups/{controlPlaneId}/routes/{id}', + forGatewayService: '/api/runtime_groups/{controlPlaneId}/services/{serviceId}/routes/{id}', + }, + edit: { + all: '/api/runtime_groups/{controlPlaneId}/routes/{id}', + forGatewayService: '/api/runtime_groups/{controlPlaneId}/services/{serviceId}/routes/{id}', + }, + }, + kongManager: { + services: '/{workspace}/services/', + create: { + all: '/{workspace}/routes', + forGatewayService: '/{workspace}/services/{serviceId}/routes', + }, + fetch: { + all: '/{workspace}/routes/{id}', + forGatewayService: '/{workspace}/services/{serviceId}/routes/{id}', + }, + edit: { + all: '/{workspace}/routes/{id}', + forGatewayService: '/{workspace}/services/{serviceId}/routes/{id}', + }, + }, + }, + item: { + konnect: { + getService: '/api/runtime_groups/{controlPlaneId}/services/{serviceId}', + all: '/api/runtime_groups/{controlPlaneId}/routes/{id}', + forGatewayService: '/api/runtime_groups/{controlPlaneId}/services/{serviceId}/routes/{id}', + }, + kongManager: { + getService: '/{workspace}/services/{serviceId}', + all: '/{workspace}/routes/{id}', + forGatewayService: '/{workspace}/services/{serviceId}/routes/{id}', + }, + }, +} diff --git a/packages/entities/entities-routes/src/styles/_mixins.scss b/packages/entities/entities-routes/src/styles/_mixins.scss new file mode 100644 index 0000000000..a2263b786f --- /dev/null +++ b/packages/entities/entities-routes/src/styles/_mixins.scss @@ -0,0 +1,119 @@ +@mixin hr() { + border: 0; + border-top: 1px solid rgba(0, 0, 0, 0.1); + margin-bottom: $kui_space_60; +} + +@mixin routing-rule() { + &-container { + &:not(:first-of-type) { + margin-top: $kui_space_80; + } + + hr { + @include hr; + } + } + + &-input { + align-items: center; + column-gap: $kui_space_40; + display: flex; + + .k-input-wrapper { + width: 100%; + } + + &:not(:first-of-type) { + margin-top: $kui_space_60; + } + + .methods-input { + display: flex; + flex-wrap: wrap; + gap: $kui_space_50; + + // stylelint-disable-next-line selector-pseudo-class-no-unknown + :deep(label.k-switch) { + margin: 0 !important; + } + + // stylelint-disable-next-line selector-pseudo-class-no-unknown + :deep(.k-button) { + margin-left: $kui_space_50 !important; + + &.remove-button { + svg path { + fill: $kui_color_border_neutral_weak !important; + } + + &:hover { + svg path { + fill: $kui_color_border_danger_weak !important; + } + } + } + } + } + } +} + +@mixin routing-rules-selector() { + &-container { + margin-top: $kui_space_80; + + hr { + @include hr; + } + } + + &-options { + align-items: center; + background-color: $kui_color_background_primary_weakest; + border: 1px solid $kui_color_border_primary_weak; + border-radius: 30px; + display: flex; + padding: 0 6px; + width: fit-content; + + ul { + display: flex; + margin: 0; + padding: 0; + + li { + display: inline-flex; + list-style: none; + } + } + + .option { + border: 1px solid transparent; + border-radius: 19px; + box-shadow: none; + color: $kui_color_text_primary_strong; + cursor: pointer; + font-size: $kui_font_size_40; + font-weight: 600; + margin: 0; + margin-right: $kui_space_20; + padding: $kui_space_20 10px; + transition: box-shadow 0.2s ease; + + &.is-selected { + color: $kui_color_border_primary_weaker; + pointer-events: none; + text-decoration: line-through; + } + + &:hover { + box-shadow: 0px 0px 12px -2px rgba(0, 0, 0, 0.3); + transition: box-shadow 0.2s ease; + + &.is-selected { + box-shadow: none; + } + } + } + } +} diff --git a/packages/entities/entities-routes/src/types/index.ts b/packages/entities/entities-routes/src/types/index.ts new file mode 100644 index 0000000000..fb2fa0ace7 --- /dev/null +++ b/packages/entities/entities-routes/src/types/index.ts @@ -0,0 +1,7 @@ +// Export all types and interfaces from this index.ts +// The actual types and interfaces should be contained in separate files within this folder. + +export * from './route-list' +export * from './method-badge' +export * from './route-form' +export * from './route-config-card' diff --git a/packages/entities/entities-routes/src/types/method-badge.ts b/packages/entities/entities-routes/src/types/method-badge.ts new file mode 100644 index 0000000000..09216e8a7b --- /dev/null +++ b/packages/entities/entities-routes/src/types/method-badge.ts @@ -0,0 +1,21 @@ +/** Route method values */ +export enum Methods { + GET = 'GET', + PUT = 'PUT', + POST = 'POST', + PATCH = 'PATCH', + DELETE = 'DELETE', + OPTIONS = 'OPTIONS', + HEAD = 'HEAD', + CONNECT = 'CONNECT', + TRACE = 'TRACE', + CUSTOM = 'CUSTOM' +} + +export type Method = keyof typeof Methods + +/** Route method badge color config */ +export interface MethodBadgeColors { + color: string + backgroundColor: string +} diff --git a/packages/entities/entities-routes/src/types/route-config-card.ts b/packages/entities/entities-routes/src/types/route-config-card.ts new file mode 100644 index 0000000000..7265944b74 --- /dev/null +++ b/packages/entities/entities-routes/src/types/route-config-card.ts @@ -0,0 +1,33 @@ +import type { KonnectBaseEntityConfig, KongManagerBaseEntityConfig, ConfigurationSchemaItem } from '@kong-ui-public/entities-shared' + +/** Konnect Route entity config */ +export interface KonnectRouteEntityConfig extends KonnectBaseEntityConfig {} + +/** Kong Manager Route entity config */ +export interface KongManagerRouteEntityConfig extends KongManagerBaseEntityConfig {} + +export interface RouteConfigurationSchema { + // basic fields + id: ConfigurationSchemaItem, + name: ConfigurationSchemaItem, + protocols: ConfigurationSchemaItem, + service: ConfigurationSchemaItem, + hosts: ConfigurationSchemaItem, + methods: ConfigurationSchemaItem, + paths: ConfigurationSchemaItem, + headers: ConfigurationSchemaItem, + strip_path: ConfigurationSchemaItem, + preserve_host: ConfigurationSchemaItem, + tags: ConfigurationSchemaItem, + created_at: ConfigurationSchemaItem, + updated_at: ConfigurationSchemaItem + // advanced fields + snis: ConfigurationSchemaItem, + https_redirect_status_code: ConfigurationSchemaItem, + request_buffering: ConfigurationSchemaItem, + response_buffering: ConfigurationSchemaItem, + regex_priority: ConfigurationSchemaItem, + path_handling: ConfigurationSchemaItem, + destinations: ConfigurationSchemaItem, + sources: ConfigurationSchemaItem +} diff --git a/packages/entities/entities-routes/src/types/route-form.ts b/packages/entities/entities-routes/src/types/route-form.ts new file mode 100644 index 0000000000..087ecc2a00 --- /dev/null +++ b/packages/entities/entities-routes/src/types/route-form.ts @@ -0,0 +1,102 @@ +import { BaseFormConfig, KongManagerBaseFormConfig, KonnectBaseFormConfig } from '@kong-ui-public/entities-shared' +import { RouteLocationRaw } from 'vue-router' +import { Methods, Method } from './method-badge' + +export interface BaseRouteFormConfig extends Omit{ + /** Route to return to if canceling create/edit a Route form */ + cancelRoute: RouteLocationRaw +} + +/** Konnect Route form config */ +export interface KonnectRouteFormConfig extends Omit, BaseRouteFormConfig { } + +/** Kong Manager Route form config */ +export interface KongManagerRouteFormConfig extends Omit, BaseRouteFormConfig { } + +export enum RoutingRulesEntities { + PATHS = 'paths', + SNIS = 'snis', + HOSTS = 'hosts', + METHODS = 'methods', + HEADERS = 'headers', + SOURCES = 'sources', + DESTINATIONS = 'destinations', + CUSTOM_METHOD = 'custom-method' +} + +export type RoutingRuleEntity = Exclude<`${RoutingRulesEntities}`, 'custom-method'> + +export type PathHandlingVersion = 'v0' | 'v1' + +export type Protocol = 'grpc' | 'grpcs' | 'http' | 'https' | 'tcp' | 'tls' | 'tls_passthrough' | 'udp' | 'ws' | 'wss' + +export interface HeaderFields { + header: string + values: string +} + +export type MethodsFields = { + [key in Methods | string]: boolean +} + +export interface SourcesDestinationsFields { + ip: string + port: number +} + +export interface Sources extends SourcesDestinationsFields { } + +export interface Destinations extends SourcesDestinationsFields { } + +export interface RouteStateFields { + service_id: string + name: string + tags: string + regex_priority: number + path_handling: PathHandlingVersion + preserve_host: boolean + https_redirect_status_code: number + protocols: string + request_buffering: boolean + response_buffering: boolean + strip_path: boolean + paths?: string[] + snis?: string[] + hosts?: string[] + methods?: MethodsFields + headers?: HeaderFields[] + sources?: Sources[] + destinations?: Destinations[] +} + +export interface RouteState { + fields: RouteStateFields + isReadonly: boolean + errorMessage: string +} + +export interface Headers { + [key: string]: string[] +} + +export interface RoutePayload { + id?: string + service: { id: string } | null + name?: string | null + tags: string[] + regex_priority: number + path_handling: PathHandlingVersion + preserve_host: boolean + https_redirect_status_code: number + protocols: Protocol[] + request_buffering: boolean + response_buffering: boolean + strip_path?: boolean | null + paths?: string[] | null + snis?: string[] | null + hosts?: string[] | null + methods?: Array | null + headers?: Headers | null + sources?: Sources[] | null + destinations?: Destinations[] | null +} diff --git a/packages/entities/entities-routes/src/types/route-list.ts b/packages/entities/entities-routes/src/types/route-list.ts new file mode 100644 index 0000000000..4e1e1a140b --- /dev/null +++ b/packages/entities/entities-routes/src/types/route-list.ts @@ -0,0 +1,39 @@ +import type { RouteLocationRaw } from 'vue-router' +import { FilterSchema, KongManagerBaseTableConfig, KonnectBaseTableConfig } from '@kong-ui-public/entities-shared' + +export interface BaseRouteListConfig { + /** Current service id if the RouteList in nested in the routes tab on a service detail page */ + serviceId?: string + /** Whether to use expression flavored routes */ + useExpression?: boolean + /** Route for creating a route */ + createRoute: RouteLocationRaw + /** A function that returns the route for viewing a route */ + getViewRoute: (id: string) => RouteLocationRaw + /** A function that returns the route for editing a route */ + getEditRoute: (id: string) => RouteLocationRaw +} + +/** Konnect route list config */ +export interface KonnectRouteListConfig extends KonnectBaseTableConfig, BaseRouteListConfig {} + +/** Kong Manager route list config */ +export interface KongManagerRouteListConfig extends KongManagerBaseTableConfig, BaseRouteListConfig { + /** FilterSchema for fuzzy match */ + filterSchema?: FilterSchema +} + +export interface EntityRow extends Record { + id: string + name: string +} + +/** Copy field event payload */ +export interface CopyEventPayload { + /** The entity row */ + entity: EntityRow + /** The field being copied. If omitted, the entity JSON is being copied. */ + field?: string + /** The toaster message */ + message: string +} diff --git a/packages/entities/entities-routes/src/utilities/getMethodBadgeColors.ts b/packages/entities/entities-routes/src/utilities/getMethodBadgeColors.ts new file mode 100644 index 0000000000..2c5f192593 --- /dev/null +++ b/packages/entities/entities-routes/src/utilities/getMethodBadgeColors.ts @@ -0,0 +1,50 @@ +import { Method, MethodBadgeColors } from '../types' + +type DefinedMethod = Exclude + +const colorMap: Record = { + GET: { + color: '#0364AC', + backgroundColor: '#F2F6FE', + }, + DELETE: { + color: '#922021', + backgroundColor: '#FFDEDE', + }, + POST: { + color: '#13755E', + backgroundColor: '#E8F8F5', + }, + PATCH: { + color: '#006E9D', + backgroundColor: '#CDF1FE', + }, + PUT: { + color: '#A05604', + backgroundColor: '#FFF3D8', + }, + OPTIONS: { + color: '#273C61', + backgroundColor: '#DAE3F2', + }, + HEAD: { + color: '#A05604', + backgroundColor: '#FFE6BA', + }, + CONNECT: { + color: '#473CFB', + backgroundColor: '#D7D8FE', + }, + TRACE: { + color: '#FFFFFF', + backgroundColor: '#5C7299', + }, + DEFAULT: { + color: '#FFFFFF', + backgroundColor: '#5C7299', + }, +} + +export const getMethodBadgeColors = (method: DefinedMethod): MethodBadgeColors => { + return colorMap[method] ?? colorMap.DEFAULT +} diff --git a/packages/entities/entities-routes/src/utilities/helpers.ts b/packages/entities/entities-routes/src/utilities/helpers.ts new file mode 100644 index 0000000000..61a1795f8a --- /dev/null +++ b/packages/entities/entities-routes/src/utilities/helpers.ts @@ -0,0 +1,3 @@ +export const isRoutePayloadValid = (val: any): boolean => { + return 'service' in val && 'tags' in val && 'regex_priority' in val && 'path_handling' in val && 'protocols' in val +} diff --git a/packages/entities/entities-routes/src/utilities/index.ts b/packages/entities/entities-routes/src/utilities/index.ts new file mode 100644 index 0000000000..26999230a6 --- /dev/null +++ b/packages/entities/entities-routes/src/utilities/index.ts @@ -0,0 +1,2 @@ +export * from './getMethodBadgeColors' +export * from './helpers' diff --git a/packages/entities/entities-routes/tsconfig.build.json b/packages/entities/entities-routes/tsconfig.build.json new file mode 100644 index 0000000000..577de9d6ae --- /dev/null +++ b/packages/entities/entities-routes/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [] + }, + "exclude": [ + "src/**/*.cy.ts", + "src/**/*.spec.ts", + "sandbox", + "dist" + ] +} diff --git a/packages/entities/entities-routes/tsconfig.json b/packages/entities/entities-routes/tsconfig.json new file mode 100644 index 0000000000..e34e90e4e4 --- /dev/null +++ b/packages/entities/entities-routes/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist", + "declarationDir": "dist/types", + "types": [ + "node", + "vite/client", + "cypress", + "cypress/vue", + "../../../cypress/support" + ] + }, + "include": [ + "src/**/*", + "sandbox/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/entities/entities-routes/vite.config.ts b/packages/entities/entities-routes/vite.config.ts new file mode 100644 index 0000000000..49e25a424b --- /dev/null +++ b/packages/entities/entities-routes/vite.config.ts @@ -0,0 +1,35 @@ +import sharedViteConfig, { getApiProxies, sanitizePackageName } from '../../../vite.config.shared' +import { resolve } from 'path' +import { defineConfig, mergeConfig } from 'vite' + +// Package name MUST always match the kebab-case package name inside the component's package.json file and the name of your `/packages/{package-name}` directory +const packageName = 'entities-routes' +const sanitizedPackageName = sanitizePackageName(packageName) + +// Merge the shared Vite config with the local one defined below +const config = mergeConfig(sharedViteConfig, defineConfig({ + build: { + lib: { + // The kebab-case name of the exposed global variable. MUST be in the format `kong-ui-public-{package-name}` + // Example: name: 'kong-ui-public-demo-component' + name: `kong-ui-public-${sanitizedPackageName}`, + entry: resolve(__dirname, './src/index.ts'), + fileName: (format) => `${sanitizedPackageName}.${format}.js`, + }, + }, + server: { + proxy: { + // Add the API proxies to inject the Authorization header + ...getApiProxies(), + }, + }, +})) + +// If we are trying to preview a build of the local `package/entities-routes/sandbox` directory, +// unset the external and lib properties +if (process.env.PREVIEW_SANDBOX) { + config.build.rollupOptions.external = undefined + config.build.lib = undefined +} + +export default config diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c79ce3c16..589e4561e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) '@rushstack/eslint-patch': specifier: ^1.3.2 version: 1.3.2 @@ -233,7 +233,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) '@types/uuid': specifier: ^9.0.2 version: 9.0.2 @@ -284,7 +284,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) vue: specifier: ^3.3.4 version: 3.3.4 @@ -306,7 +306,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) '@types/lodash.clonedeep': specifier: ^4.5.7 version: 4.5.7 @@ -348,7 +348,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) vue: specifier: ^3.3.4 version: 3.3.4 @@ -386,7 +386,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) '@types/lodash': specifier: ^4.14.196 version: 4.14.197 @@ -413,7 +413,7 @@ importers: version: link:../i18n '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) vue: specifier: ^3.3.4 version: 3.3.4 @@ -435,7 +435,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) axios: specifier: ^1.4.0 version: 1.4.0 @@ -460,7 +460,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) axios: specifier: ^1.4.0 version: 1.4.0 @@ -485,7 +485,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) axios: specifier: ^1.4.0 version: 1.4.0 @@ -510,7 +510,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) axios: specifier: ^1.4.0 version: 1.4.0 @@ -535,7 +535,7 @@ importers: version: link:../../core/i18n '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) axios: specifier: ^1.4.0 version: 1.4.0 @@ -563,7 +563,32 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) + axios: + specifier: ^1.4.0 + version: 1.4.0 + vue: + specifier: ^3.3.4 + version: 3.3.4 + vue-router: + specifier: ^4.2.4 + version: 4.2.4(vue@3.3.4) + + packages/entities/entities-routes: + dependencies: + '@kong-ui-public/entities-shared': + specifier: workspace:^ + version: link:../entities-shared + devDependencies: + '@kong-ui-public/i18n': + specifier: workspace:^ + version: link:../../core/i18n + '@kong/design-tokens': + specifier: ^1.8.0 + version: 1.9.0 + '@kong/kongponents': + specifier: ^8.116.2 + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) axios: specifier: ^1.4.0 version: 1.4.0 @@ -594,7 +619,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) axios: specifier: ^1.4.0 version: 1.4.0 @@ -619,7 +644,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) axios: specifier: ^1.4.0 version: 1.4.0 @@ -644,7 +669,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) axios: specifier: ^1.4.0 version: 1.4.0 @@ -669,7 +694,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) '@types/prismjs': specifier: ^1.26.0 version: 1.26.0 @@ -700,7 +725,7 @@ importers: version: 1.9.0 '@kong/kongponents': specifier: ^8.121.1 - version: 8.122.2(vue-router@4.2.4)(vue@3.3.4) + version: 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) '@modyfi/vite-plugin-yaml': specifier: ^1.0.4 version: 1.0.4(vite@4.4.8) @@ -1841,7 +1866,7 @@ packages: '@kong/kongponents': ^8.83.5 vue: ^3.2.47 dependencies: - '@kong/kongponents': 8.122.2(vue-router@4.2.4)(vue@3.3.4) + '@kong/kongponents': 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) approximate-number: 2.1.1 vue: 3.3.4 dev: false @@ -1850,14 +1875,15 @@ packages: resolution: {integrity: sha512-y68F8uUxLQZDRqN8365TPVsYPNPS9jGTlsap8A6N2MkYHYZ75Qnewl7cK6M+YUbG9AUx1YE64vt1yQe5+/Ydzg==} dev: true - /@kong/kongponents@8.122.2(vue-router@4.2.4)(vue@3.3.4): + /@kong/kongponents@8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4): resolution: {integrity: sha512-TdjmomMZdBNRvucdYFC2/TVVF5gyggyPukSa35Q0TETpNL7Uq2IhFMM1arrjsbKun4Ri/J1L+pF0ViPudOMPQQ==} engines: {node: '>=16.19.0'} peerDependencies: + axios: ^0.27.2 vue: '>= 3.3.0' vue-router: ^4.1.6 dependencies: - axios: 0.27.2 + axios: 1.4.0 date-fns: 2.30.0 date-fns-tz: 1.3.8(date-fns@2.30.0) focus-trap: 7.5.2 @@ -1872,7 +1898,6 @@ packages: vue-router: 4.2.4(vue@3.3.4) transitivePeerDependencies: - '@popperjs/core' - - debug /@kong/swagger-ui-kong-theme-universal@4.2.6(react-dom@17.0.2)(react@17.0.2)(vue-router@4.2.4)(vue@3.3.4): resolution: {integrity: sha512-ZZtnsER3yHFnzjy5OO3jYgeY3ZCuhQP6IFqwq2vUj9HntyJUBOBUxvTJ9YJoQHz2wMVuatdUJHadQcUVS/Cktw==} @@ -1880,7 +1905,7 @@ packages: react: 17.0.2 dependencies: '@braintree/sanitize-url': 2.1.0 - '@kong/kongponents': 8.122.2(vue-router@4.2.4)(vue@3.3.4) + '@kong/kongponents': 8.122.2(axios@1.4.0)(vue-router@4.2.4)(vue@3.3.4) '@kyleshockey/xml': 1.0.2 classnames: 2.3.2 curl-to-har: 1.0.1 @@ -1897,7 +1922,7 @@ packages: transitivePeerDependencies: - '@babel/core' - '@popperjs/core' - - debug + - axios - mkdirp - prop-types - react-dom @@ -4318,14 +4343,6 @@ packages: resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} dev: true - /axios@0.27.2: - resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} - dependencies: - follow-redirects: 1.15.2 - form-data: 4.0.0 - transitivePeerDependencies: - - debug - /axios@1.4.0: resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==} dependencies: