diff --git a/packages/entities/entities-redis-configurations/LICENSE b/packages/entities/entities-redis-configurations/LICENSE new file mode 100644 index 0000000000..b45e572c88 --- /dev/null +++ b/packages/entities/entities-redis-configurations/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 2024 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-redis-configurations/README.md b/packages/entities/entities-redis-configurations/README.md new file mode 100644 index 0000000000..8c1bdb0a1f --- /dev/null +++ b/packages/entities/entities-redis-configurations/README.md @@ -0,0 +1,52 @@ +# @kong-ui-public/entities-redis-configurations + +Redis Configuration components. + +- [Requirements](#requirements) +- [Included components](#included-components) +- [Usage](#usage) + - [Install](#install) + - [Registration](#registration) + +## 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. +- `@kong-ui-public/entities-shared` must be available as a `dependency` in the host application. +- `@kong-ui-public/entities-vaults` must be available as a `dependency` in the host application. +- `axios` must be installed as a dependency in the host application. + +## Included components + +- [`RedisConfigurationForm`](docs/redis-configuration-form.md) +- [`RedisConfigurationList`](docs/redis-configuration-list.md) +- [`RedisConfigurationConfigCard`](docs/redis-configuration-config-card.md) + +## Usage + +### Install + +Install the component in your host application + +```sh +pnpm add @kong-ui-public/entities-redis-configurations +``` + +### Registration + +Import the component(s) in your host application as well as the package styles + +```ts +import { + RedisConfigurationForm, + RedisConfigurationList, + RedisConfigurationConfigCard, +} from '@kong-ui-public/entities-redis-configurations' + +import '@kong-ui-public/entities-redis-configurations/dist/style.css' + +// `RedisConfigurationForm` only +import '@kong-ui-public/entities-shared/dist/style.css' +import '@kong-ui-public/entities-vaults/dist/style.css' +``` diff --git a/packages/entities/entities-redis-configurations/docs/redis-configuration-config-card.md b/packages/entities/entities-redis-configurations/docs/redis-configuration-config-card.md new file mode 100644 index 0000000000..b0998613cd --- /dev/null +++ b/packages/entities/entities-redis-configurations/docs/redis-configuration-config-card.md @@ -0,0 +1,115 @@ +# RedisConfigurationConfigCard.vue + +A config card component for Redis Configurations. + +- [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/redis-configurations` 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. + + - `axiosRequestConfig`: + - type: `AxiosRequestConfig` + - required: `false` + - default: `undefined` + - An optional configuration object for the underlying Axios request. + + - `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 Redis Configuration to display the config for + +The base konnect or kongManger config. + +#### `configCardDoc` + +- type: `String` +- required: `false` +- default: `null` + +Set this value to display the documentation button. + +#### `hideTitle` + +- type: `Boolean` +- required: `false` +- default: `false` + +Set this value to `true` to hide the card title. + +### Events + +#### loading + +A `@loading` event is emitted when loading state changes. The event payload is a boolean. + +#### fetch:error + +An `@fetch:error` event is emitted when the component fails to fetch the redis configuration. The event payload is the response error. + +#### fetch:success + +A `@fetch:success` event is emitted when the redis configuration is successfully fetched. The event payload is the redis configuration object. + +### Usage example + +Please refer to the [sandbox](../sandbox/pages/RedisConfigurationDetail.vue). + +## TypeScript interfaces + +TypeScript interfaces [are available here](../src/types/redis-confiugration-config.ts) and can be directly imported into your host application. The following type interfaces are available for import: + +```ts +import type { + KonnectRedisConfigurationEntityConfig, + KongManagerRedisConfigurationEntityConfig, +} from '@kong-ui-public/entities-redis-configurations' +``` diff --git a/packages/entities/entities-redis-configurations/docs/redis-configuration-form.md b/packages/entities/entities-redis-configurations/docs/redis-configuration-form.md new file mode 100644 index 0000000000..c8122e9f48 --- /dev/null +++ b/packages/entities/entities-redis-configurations/docs/redis-configuration-form.md @@ -0,0 +1,135 @@ +# RedisConfigurationForm + +A form component for Redis Configurations. + +- [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. +- `@kong-ui-public/entities-shared` must be available as a `dependency` in the host application. +- `@kong-ui-public/entities-vaults` 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/redis-configurations` 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. + + - `axiosRequestConfig`: + - type: `AxiosRequestConfig` + - required: `false` + - default: `undefined` + - An optional configuration object for the underlying Axios request. + + - `cancelRoute`: + - type: `RouteLocationRaw` + - required: `true` + - default: `undefined` + - Route to return to when canceling creation of a redis configuration. + + - `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. + +#### `partialId` + +- type: `String` +- required: `false` +- default: `''` + +If showing the `Edit` type form, the ID of the Redis configuration. + +#### `actionTeleportTarget` + +- type: `String` +- required: `false` +- default: `''` + +The name of the teleport target to render the form action buttons. + +#### `slidoutTopOffset` + +- type: `Number` +- required: `false` +- default: `60` + +The top offset of the `View Configuration` slidout. + +#### `disabledPartialType` + +- type: `String` +- required: `false` +- default: `''` + +The type of the partial to disable. If set it to `PartialType.REDIS_CE`, the Host/Port CE type option will be disabled. If set it to `PartialType.REDIS_EE`, all EE type options will be disabled. + +### 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 Redis Configuration object. + +### Usage example + +Please refer to the [sandbox](../sandbox/pages/RedisConfigurationListPage.vue). The form is accessible by clicking the `+ New Redis Configuration` button or `Edit` action of an existing Redis Configuration. + +## TypeScript interfaces + +TypeScript interfaces [are available here](../src/types/redis-confiugration-form.ts) and can be directly imported into your host application. The following type interfaces are available for import: + +```ts +import type { + KonnectRedisConfigurationFormConfig, + KongManagerRedisConfigurationFormConfig, + RedisConfigurationFields, + RedisConfigurationFormState, +} from '@kong-ui-public/entities-redis-configurations' +``` diff --git a/packages/entities/entities-redis-configurations/docs/redis-configuration-list.md b/packages/entities/entities-redis-configurations/docs/redis-configuration-list.md new file mode 100644 index 0000000000..fd871444bf --- /dev/null +++ b/packages/entities/entities-redis-configurations/docs/redis-configuration-list.md @@ -0,0 +1,186 @@ +# RedisConfigurationList.vue + +A table component for Redis Configurations. + +- [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/redis-configurations` 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. + + - `axiosRequestConfig`: + - type: `AxiosRequestConfig` + - required: `false` + - default: `undefined` + - An optional configuration object for the underlying Axios request. + + - `createRoute`: + - type: `RouteLocationRaw` + - required: `true` + - default: `undefined` + - Route for creating a redis configuration. + + - `getViewRoute`: + - type: `(id: string) => RouteLocationRaw` + - required: `true` + - default: `undefined` + - A function that returns the route for viewing a redis configuration. + + - `getEditRoute`: + - type: `(id: string) => RouteLocationRaw` + - required: `true` + - default: `undefined` + - A function that returns the route for editing a redis configuration. + + - `additionMessageForEmptyState`: + - type: `string` + - required: `false` + - default: `undefined` + - Additional message to show when there are no records. + + - `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<() => boolean | Promise>` +- required: `false` +- default: `async () => true` + +A synchronous or asynchronous function, that returns a boolean, that evaluates if the user can create a new entity. + +#### `canDelete` + +- type: `Function as PropType<(row: object) => boolean | Promise>` +- required: `false` +- default: `async () => true` + +A synchronous or asynchronous function, that returns a boolean, that evaluates if the user can delete a given entity. + +#### `canEdit` + +- type: `Function as PropType<(row: object) => boolean | Promise>` +- required: `false` +- default: `async () => true` + +A synchronous or asynchronous function, that returns a boolean, that evaluates if the user can edit a given entity. + +#### `canRetrieve` + +- type: `Function as PropType<(row: object) => boolean | Promise>` +- required: `false` +- default: `async () => true` + +A synchronous or asynchronous function, that returns a boolean, that evaluates if the user can retrieve (view details) a given entity. + +### Events + +#### error + +An `@error` event is emitted when the table fails to fetch redis configurations or delete a redis configuration. The event payload is the response error. + +#### copy:success + +A `@copy:success` event is emitted when a redis configuration 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 redis configuration ID or the entity JSON. The event payload shape is CopyEventPayload. + +#### delete:success + +A `@delete:success` event is emitted when a redis configuration is successfully deleted. The event payload is the redis configuration item data object. + +#### view-plugin + +A `@view-plugin` event is emitted when the user clicks on the view plugin button. The event payload is the pluginId. + +### Usage example + +Please refer to the [sandbox](../sandbox/pages/RedisConfigurationListPage.vue). + +## TypeScript interfaces + +TypeScript interfaces [are available here](../src/types/redis-confiugration-list.ts) and can be directly imported into your host application. The following type interfaces are available for import: + +```ts +import type { + BaseRedisConfigurationListConfig, + KonnectRedisConfigurationListConfig, + KongManagerRedisConfigurationListConfig, + EntityRow, +} from '@kong-ui-public/entities-redis-configurations' +``` diff --git a/packages/entities/entities-redis-configurations/fixtures/mockData.ts b/packages/entities/entities-redis-configurations/fixtures/mockData.ts new file mode 100644 index 0000000000..62a5c5e614 --- /dev/null +++ b/packages/entities/entities-redis-configurations/fixtures/mockData.ts @@ -0,0 +1,168 @@ +import { PartialType, type RedisConfigurationLinkedPluginsResponse, type RedisConfigurationResponse } from '../src' +import { v4 as uuid } from 'uuid' + +export const redisConfigurationCE: Readonly = { + created_at: '2025-02-07T00:00:00Z', + updated_at: '2025-02-07T00:00:00Z', + // randomized ID to avoid swrv cache in tests, otherwise cy.wait() will not resolve + get id() { + return uuid() + }, + name: 'redis-config-1', + type: PartialType.REDIS_CE, + config: { + cluster_max_redirections: 0, + cluster_nodes: [], + connect_timeout: 2031, + connection_is_proxied: false, + database: 0, + host: 'localhost', + keepalive_backlog: 0, + keepalive_pool_size: 0, + password: '', + port: 6379, + timeout: 2031, + read_timeout: 0, + send_timeout: 0, + sentinel_master: '', + sentinel_nodes: [], + sentinel_password: '', + sentinel_role: '', + sentinel_username: '', + server_name: '', + ssl_verify: false, + ssl: false, + username: '', + }, +} + +export const redisConfigurationHostPortEE: Readonly = { + created_at: '2025-02-07T00:00:00Z', + updated_at: '2025-02-07T00:00:00Z', + // randomized ID to avoid swrv cache in tests, otherwise cy.wait() will not resolve + get id() { + return uuid() + }, + name: 'redis-config-2', + type: PartialType.REDIS_EE, + config: { + cluster_max_redirections: 0, + cluster_nodes: [], + connect_timeout: 3000, + connection_is_proxied: false, + database: 0, + host: 'localhost', + keepalive_backlog: 0, + keepalive_pool_size: 0, + password: '', + port: 6379, + timeout: 3000, + read_timeout: 3000, + send_timeout: 3000, + sentinel_master: '', + sentinel_nodes: [], + sentinel_password: '', + sentinel_role: '', + sentinel_username: '', + server_name: '', + ssl_verify: false, + ssl: false, + username: '', + }, +} + +export const redisConfigurationCluster: Readonly = { + created_at: '2025-02-07T00:00:00Z', + updated_at: '2025-02-07T00:00:00Z', + // randomized ID to avoid swrv cache in tests, otherwise cy.wait() will not resolve + get id() { + return uuid() + }, + name: 'redis-config-3', + type: PartialType.REDIS_EE, + config: { + cluster_max_redirections: 0, + cluster_nodes: [ + { + ip: '127.0.0.1', + port: 6379, + }, + ], + connect_timeout: 2000, + connection_is_proxied: false, + database: 0, + host: 'localhost', + keepalive_backlog: 0, + keepalive_pool_size: 0, + password: '', + port: 6379, + timeout: 2000, + read_timeout: 0, + send_timeout: 0, + sentinel_master: '', + sentinel_nodes: [], + sentinel_password: '', + sentinel_role: '', + sentinel_username: '', + server_name: '', + ssl_verify: false, + ssl: false, + username: '', + }, +} + +export const redisConfigurationSentinel: Readonly = { + created_at: '2025-02-07T00:00:00Z', + updated_at: '2025-02-07T00:00:00Z', + // randomized ID to avoid swrv cache in tests, otherwise cy.wait() will not resolve + get id() { + return uuid() + }, + name: 'redis-config-4', + type: PartialType.REDIS_EE, + config: { + cluster_max_redirections: 0, + cluster_nodes: [], + connect_timeout: 2000, + connection_is_proxied: false, + database: 0, + host: 'localhost', + keepalive_backlog: 0, + keepalive_pool_size: 0, + password: '', + port: 6379, + timeout: 2000, + read_timeout: 0, + send_timeout: 0, + sentinel_master: 'mymaster', + sentinel_nodes: [ + { + host: 'localhost', + port: 26379, + }, + ], + sentinel_password: '', + sentinel_role: '', + sentinel_username: '', + server_name: '', + ssl_verify: false, + ssl: false, + username: '', + }, +} + +export const partials = { + data: [ + redisConfigurationCE, + redisConfigurationHostPortEE, + redisConfigurationCluster, + redisConfigurationSentinel, + ], + next: null, +} + +export const links: RedisConfigurationLinkedPluginsResponse = { + count: 1, + data: [{ id: '1', name: 'rate-limiting' }], + next: null, +} diff --git a/packages/entities/entities-redis-configurations/package.json b/packages/entities/entities-redis-configurations/package.json new file mode 100644 index 0000000000..553cf3d87e --- /dev/null +++ b/packages/entities/entities-redis-configurations/package.json @@ -0,0 +1,85 @@ +{ + "name": "@kong-ui-public/entities-redis-configurations", + "version": "0.0.1", + "type": "module", + "main": "./dist/entities-redis-configurations.umd.js", + "module": "./dist/entities-redis-configurations.es.js", + "types": "dist/types/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": "./dist/entities-redis-configurations.es.js", + "require": "./dist/entities-redis-configurations.umd.js", + "types": "./dist/types/index.d.ts" + }, + "./package.json": "./package.json", + "./dist/*": "./dist/*" + }, + "publishConfig": { + "access": "public" + }, + "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-redis-configurations' vite build -m production", + "build:types": "vue-tsc -p './tsconfig.build.json' --emitDeclarationOnly", + "build:sandbox": "cross-env USE_SANDBOX=true vite build -m production", + "preview": "cross-env USE_SANDBOX=true vite preview", + "lint": "eslint", + "lint:fix": "eslint --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" + }, + "devDependencies": { + "@kong-ui-public/entities-plugins": "workspace:^", + "@kong-ui-public/entities-shared": "workspace:^", + "@kong-ui-public/entities-vaults": "workspace:^", + "@kong-ui-public/i18n": "workspace:^", + "@kong/design-tokens": "1.17.2", + "@kong/icons": "^1.20.0", + "@kong/kongponents": "9.14.8", + "@types/uuid": "^10.0.0", + "axios": "^1.7.7", + "vue": "^3.5.12", + "vue-router": "^4.4.5" + }, + "repository": { + "type": "git", + "url": "https://github.com/Kong/public-ui-components.git", + "directory": "packages/entities/entities-redis-configurations" + }, + "homepage": "https://github.com/Kong/public-ui-components/tree/main/packages/entities/entities-redis-configurations", + "bugs": { + "url": "https://github.com/Kong/public-ui-components/issues" + }, + "author": "Kong, Inc.", + "license": "Apache-2.0", + "volta": { + "extends": "../../../package.json" + }, + "distSizeChecker": { + "errorLimit": "400KB" + }, + "peerDependencies": { + "@kong-ui-public/entities-plugins": "workspace:^", + "@kong-ui-public/entities-shared": "workspace:^", + "@kong-ui-public/entities-vaults": "workspace:^", + "@kong-ui-public/i18n": "workspace:^", + "@kong/icons": "^1.20.0", + "@kong/kongponents": "9.14.8", + "axios": "^1.7.7", + "vue": ">= 3.3.13 < 4", + "vue-router": "^4.4.5" + }, + "dependencies": { + "uuid": "^10.0.0" + } +} diff --git a/packages/entities/entities-redis-configurations/sandbox/App.vue b/packages/entities/entities-redis-configurations/sandbox/App.vue new file mode 100644 index 0000000000..b9c9d037ec --- /dev/null +++ b/packages/entities/entities-redis-configurations/sandbox/App.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/packages/entities/entities-redis-configurations/sandbox/index.html b/packages/entities/entities-redis-configurations/sandbox/index.html new file mode 100644 index 0000000000..5d67940102 --- /dev/null +++ b/packages/entities/entities-redis-configurations/sandbox/index.html @@ -0,0 +1,25 @@ + + + + + + + EntitiesRedisConfigurations Component Sandbox + + + + + + + +
+ + + + diff --git a/packages/entities/entities-redis-configurations/sandbox/index.ts b/packages/entities/entities-redis-configurations/sandbox/index.ts new file mode 100644 index 0000000000..6c171f33f4 --- /dev/null +++ b/packages/entities/entities-redis-configurations/sandbox/index.ts @@ -0,0 +1,46 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import Kongponents from '@kong/kongponents' +import '@kong/kongponents/dist/style.css' +import App from './App.vue' + +const app = createApp(App) + +const init = async () => { + const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + name: 'redis-configuration-list', + component: () => import('./pages/RedisConfigurationListPage.vue'), + }, + { + path: '/redis-configuration/create', + name: 'create-redis-configuration', + component: () => import('./pages/RedisConfigurationFormPage.vue'), + }, + { + path: '/redis-configuration/create-modal', + name: 'create-redis-configuration-modal', + component: () => import('./pages/RedisConfigurationFormModalPage.vue'), + }, + { + path: '/redis-configuration/edit/:id', + name: 'edit-redis-configuration', + component: () => import('./pages/RedisConfigurationFormPage.vue'), + }, + { + path: '/redis-configuration/:id', + name: 'view-redis-configuration', + component: () => import('./pages/RedisConfigurationDetail.vue'), + }, + ], + }) + + app.use(Kongponents) + app.use(router) + app.mount('#app') +} + +init() diff --git a/packages/entities/entities-redis-configurations/sandbox/pages/RedisConfigurationDetail.vue b/packages/entities/entities-redis-configurations/sandbox/pages/RedisConfigurationDetail.vue new file mode 100644 index 0000000000..dcf74ba30d --- /dev/null +++ b/packages/entities/entities-redis-configurations/sandbox/pages/RedisConfigurationDetail.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/entities/entities-redis-configurations/sandbox/pages/RedisConfigurationFormModalPage.vue b/packages/entities/entities-redis-configurations/sandbox/pages/RedisConfigurationFormModalPage.vue new file mode 100644 index 0000000000..437bd51fe2 --- /dev/null +++ b/packages/entities/entities-redis-configurations/sandbox/pages/RedisConfigurationFormModalPage.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/packages/entities/entities-redis-configurations/sandbox/pages/RedisConfigurationFormPage.vue b/packages/entities/entities-redis-configurations/sandbox/pages/RedisConfigurationFormPage.vue new file mode 100644 index 0000000000..e572c09243 --- /dev/null +++ b/packages/entities/entities-redis-configurations/sandbox/pages/RedisConfigurationFormPage.vue @@ -0,0 +1,57 @@ + + + diff --git a/packages/entities/entities-redis-configurations/sandbox/pages/RedisConfigurationListPage.vue b/packages/entities/entities-redis-configurations/sandbox/pages/RedisConfigurationListPage.vue new file mode 100644 index 0000000000..bce38b0410 --- /dev/null +++ b/packages/entities/entities-redis-configurations/sandbox/pages/RedisConfigurationListPage.vue @@ -0,0 +1,71 @@ + + + diff --git a/packages/entities/entities-redis-configurations/sandbox/tsconfig.json b/packages/entities/entities-redis-configurations/sandbox/tsconfig.json new file mode 100644 index 0000000000..6b0bff7930 --- /dev/null +++ b/packages/entities/entities-redis-configurations/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-redis-configurations/src/components/ClusterNodes.vue b/packages/entities/entities-redis-configurations/src/components/ClusterNodes.vue new file mode 100644 index 0000000000..26047c2b58 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/ClusterNodes.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/packages/entities/entities-redis-configurations/src/components/DeleteWarningModal.vue b/packages/entities/entities-redis-configurations/src/components/DeleteWarningModal.vue new file mode 100644 index 0000000000..b033233a70 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/DeleteWarningModal.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/entities/entities-redis-configurations/src/components/FieldArrayCardContainer.vue b/packages/entities/entities-redis-configurations/src/components/FieldArrayCardContainer.vue new file mode 100644 index 0000000000..5c8104e12a --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/FieldArrayCardContainer.vue @@ -0,0 +1,65 @@ + + + + + + + diff --git a/packages/entities/entities-redis-configurations/src/components/LinkedPluginList.vue b/packages/entities/entities-redis-configurations/src/components/LinkedPluginList.vue new file mode 100644 index 0000000000..bc91a9200d --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/LinkedPluginList.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/packages/entities/entities-redis-configurations/src/components/LinkedPluginListModal.vue b/packages/entities/entities-redis-configurations/src/components/LinkedPluginListModal.vue new file mode 100644 index 0000000000..84e9a63830 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/LinkedPluginListModal.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/packages/entities/entities-redis-configurations/src/components/LinkedPluginsInline.vue b/packages/entities/entities-redis-configurations/src/components/LinkedPluginsInline.vue new file mode 100644 index 0000000000..642af732c8 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/LinkedPluginsInline.vue @@ -0,0 +1,64 @@ + + + + + + diff --git a/packages/entities/entities-redis-configurations/src/components/PluginItem.vue b/packages/entities/entities-redis-configurations/src/components/PluginItem.vue new file mode 100644 index 0000000000..2c5c7fb790 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/PluginItem.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/packages/entities/entities-redis-configurations/src/components/RedisConfigurationConfigCard.cy.ts b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationConfigCard.cy.ts new file mode 100644 index 0000000000..637a471f8b --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationConfigCard.cy.ts @@ -0,0 +1,301 @@ +import RedisConfigurationConfigCard from './RedisConfigurationConfigCard.vue' +import { + redisConfigurationCE, + redisConfigurationHostPortEE, + redisConfigurationCluster, + redisConfigurationSentinel, +} from '../../fixtures/mockData' + +import type { + KonnectRedisConfigurationEntityConfig, + KongManagerRedisConfigurationEntityConfig, + RedisConfigurationResponse, +} from '../types' + +const kmConfig: KongManagerRedisConfigurationEntityConfig = { + app: 'kongManager', + workspace: 'default', + apiBaseUrl: '/kong-manager', + entityId: redisConfigurationCE.id, +} + +const konnectConfig: KonnectRedisConfigurationEntityConfig = { + app: 'konnect', + controlPlaneId: '1', + apiBaseUrl: '/us/kong-api', + entityId: redisConfigurationCE.id, +} + +describe('', { + viewportHeight: 700, + viewportWidth: 700, +}, () => { + for (const app of ['Konnect', 'Kong Admin']) { + const interceptGetRedisConfiguration = ({ + body = redisConfigurationCE, + status = 200, + }: { + body?: RedisConfigurationResponse + status?: number + } = {}): void => { + if (app === 'Konnect') { + cy.intercept( + { + method: 'GET', + url: `${konnectConfig.apiBaseUrl}/v2/control-planes/${konnectConfig.controlPlaneId}/core-entities/partials/*`, + }, + { + statusCode: status, + body, + }, + ).as('getRedisConfiguration') + } else { + cy.intercept( + { + method: 'GET', + url: `${kmConfig.apiBaseUrl}/${kmConfig.workspace}/partials/*`, + }, + { + statusCode: status, + body, + }, + ).as('getRedisConfiguration') + } + } + + describe(app, () => { + it('emits loading event when EntityBaseConfigCard emits loading event', () => { + interceptGetRedisConfiguration() + + cy.mount(RedisConfigurationConfigCard, { + props: { + config: app === 'Konnect' ? konnectConfig : kmConfig, + onLoading: cy.spy().as('onLoadingSpy'), + }, + }).then(({ wrapper }) => wrapper) + .as('vueWrapper') + + cy.wait('@getRedisConfiguration') + + cy.get('@vueWrapper').then(wrapper => wrapper.findComponent(RedisConfigurationConfigCard) + .vm.$emit('loading', true)) + + cy.get('@onLoadingSpy').should('have.been.calledWith', true) + }) + + it('emits fetch:error event when EntityBaseConfigCard emits fetch:error event', () => { + interceptGetRedisConfiguration() + + cy.mount(RedisConfigurationConfigCard, { + props: { + config: app === 'Konnect' ? konnectConfig : kmConfig, + 'onFetch:error': cy.spy().as('onError'), + }, + }).then(({ wrapper }) => wrapper) + .as('vueWrapper') + + cy.wait('@getRedisConfiguration') + + cy.get('@vueWrapper').then(wrapper => wrapper.findComponent(RedisConfigurationConfigCard) + .vm.$emit('fetch:error', { message: 'text' })) + + cy.get('@onError').should('have.been.calledWith', { message: 'text' }) + }) + + it('emits fetch:success event when EntityBaseConfigCard emits fetch:success event', () => { + interceptGetRedisConfiguration() + + cy.mount(RedisConfigurationConfigCard, { + props: { + config: app === 'Konnect' ? konnectConfig : kmConfig, + 'onFetch:success': cy.spy().as('onFetch'), + }, + }).then(({ wrapper }) => wrapper) + .as('vueWrapper') + + cy.wait('@getRedisConfiguration') + + cy.get('@vueWrapper').then(wrapper => wrapper.findComponent(RedisConfigurationConfigCard) + .vm.$emit('fetch:success')) + + cy.get('@onFetch').should('have.been.called') + }) + + describe('fields', () => { + it('only shows host/port CE fields', () => { + interceptGetRedisConfiguration() + + cy.mount(RedisConfigurationConfigCard, { + props: { + config: app === 'Konnect' ? konnectConfig : kmConfig, + }, + }) + + cy.wait('@getRedisConfiguration') + + const fieldsShouldExist = [ + 'host-label', + 'port-label', + 'timeout-label', + ] + + const fieldsShouldNotExist = [ + 'connection_is_proxied-label', + 'keepalive_backlog-label', + 'keepalive_pool_size-label', + 'read_timeout-label', + 'send_timeout-label', + 'connect_timeout-label', + 'cluster_nodes-label', + 'cluster_max_redirections-label', + 'sentinel_master-label', + 'sentinel_role-label', + 'sentinel_nodes-label', + 'sentinel_username-label', + 'sentinel_password-label', + ] + + fieldsShouldExist.forEach((field) => { + cy.getTestId(field).should('exist') + }) + + fieldsShouldNotExist.forEach((field) => { + cy.getTestId(field).should('not.exist') + }) + }) + + it('only shows host/port EE fields', () => { + interceptGetRedisConfiguration({ body: redisConfigurationHostPortEE }) + + cy.mount(RedisConfigurationConfigCard, { + props: { + config: app === 'Konnect' + ? { ...konnectConfig, entityId: redisConfigurationHostPortEE.id } + : { ...kmConfig, entityId: redisConfigurationHostPortEE.id }, + }, + }) + + cy.wait('@getRedisConfiguration') + + const fieldsShouldExist = [ + 'host-label', + 'port-label', + 'connection_is_proxied-label', + 'keepalive_backlog-label', + 'keepalive_pool_size-label', + 'read_timeout-label', + 'send_timeout-label', + 'connect_timeout-label', + ] + + const fieldsShouldNotExist = [ + 'timeout-label', + 'cluster_nodes-label', + 'cluster_max_redirections-label', + 'sentinel_master-label', + 'sentinel_role-label', + 'sentinel_nodes-label', + 'sentinel_username-label', + 'sentinel_password-label', + ] + + fieldsShouldExist.forEach((field) => { + cy.getTestId(field).should('exist') + }) + + fieldsShouldNotExist.forEach((field) => { + cy.getTestId(field).should('not.exist') + }) + }) + + it('only shows Cluster EE fields', () => { + interceptGetRedisConfiguration({ body: redisConfigurationCluster }) + + cy.mount(RedisConfigurationConfigCard, { + props: { + config: app === 'Konnect' + ? { ...konnectConfig, entityId: redisConfigurationCluster.id } + : { ...kmConfig, entityId: redisConfigurationCluster.id }, + }, + }) + + cy.wait('@getRedisConfiguration') + + const fieldsShouldExist = [ + 'cluster_nodes-label', + 'cluster_max_redirections-label', + 'keepalive_backlog-label', + 'keepalive_pool_size-label', + 'read_timeout-label', + 'send_timeout-label', + 'connect_timeout-label', + ] + + const fieldsShouldNotExist = [ + 'host-label', + // 'port-label', // Port is shown in cluster nodes + 'timeout-label', + 'sentinel_master-label', + 'sentinel_role-label', + 'sentinel_nodes-label', + 'sentinel_username-label', + 'sentinel_password-label', + 'connection_is_proxied-label', + ] + + fieldsShouldExist.forEach((field) => { + cy.getTestId(field).should('exist') + }) + + fieldsShouldNotExist.forEach((field) => { + cy.getTestId(field).should('not.exist') + }) + }) + + it('only shows Sentinel EE fields', () => { + interceptGetRedisConfiguration({ body: redisConfigurationSentinel }) + + cy.mount(RedisConfigurationConfigCard, { + props: { + config: app === 'Konnect' + ? { ...konnectConfig, entityId: redisConfigurationSentinel.id } + : { ...kmConfig, entityId: redisConfigurationSentinel.id }, + }, + }) + + cy.wait('@getRedisConfiguration') + + const fieldsShouldExist = [ + 'sentinel_master-label', + 'sentinel_role-label', + 'sentinel_nodes-label', + 'sentinel_username-label', + 'sentinel_password-label', + 'keepalive_backlog-label', + 'keepalive_pool_size-label', + 'read_timeout-label', + 'send_timeout-label', + 'connect_timeout-label', + ] + + const fieldsShouldNotExist = [ + // 'host-label', // Host is shown in sentinel nodes + // 'port-label', // Port is shown in sentinel nodes + 'timeout-label', + 'connection_is_proxied-label', + 'cluster_nodes-label', + 'cluster_max_redirections-label', + ] + + fieldsShouldExist.forEach((field) => { + cy.getTestId(field).should('exist') + }) + + fieldsShouldNotExist.forEach((field) => { + cy.getTestId(field).should('not.exist') + }) + }) + }) + }) + } +}) diff --git a/packages/entities/entities-redis-configurations/src/components/RedisConfigurationConfigCard.vue b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationConfigCard.vue new file mode 100644 index 0000000000..941e078e48 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationConfigCard.vue @@ -0,0 +1,401 @@ + + + diff --git a/packages/entities/entities-redis-configurations/src/components/RedisConfigurationForm.cy.ts b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationForm.cy.ts new file mode 100644 index 0000000000..061a884822 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationForm.cy.ts @@ -0,0 +1,1015 @@ +import RedisConfigurationForm from './RedisConfigurationForm.vue' +import { PartialType, RedisType, type RedisConfigurationResponse } from '../types' +import { redisConfigurationCE, redisConfigurationCluster, redisConfigurationHostPortEE, redisConfigurationSentinel, links } from '../../fixtures/mockData' + +import type { + KongManagerRedisConfigurationFormConfig, + KonnectRedisConfigurationFormConfig, +} from '../types/redis-configuration-form' +import type { RouteHandler } from 'cypress/types/net-stubbing' + +const cancelRoute = { name: 'redis-configuration-list' } + +const baseConfigKM: KongManagerRedisConfigurationFormConfig = { + app: 'kongManager', + workspace: 'default', + apiBaseUrl: '/kong-manager', + cancelRoute, +} + +const baseConfigKonnect: KonnectRedisConfigurationFormConfig = { + app: 'konnect', + controlPlaneId: 'test-control-plane-id', + apiBaseUrl: '/us/kong-api', + cancelRoute, +} + +describe('', { + viewportHeight: 700, + viewportWidth: 700, +}, () => { + + for (const app of ['Kong Manager', 'Konnect']) { + describe(app, () => { + const config = app === 'Kong Manager' ? baseConfigKM : baseConfigKonnect + + const stubCreateEdit = ({ status = 200 }: { status?: number } = {}) => { + const handler: RouteHandler = req => { + const { body: { name, type, config } } = req + req.reply({ + statusCode: status, + body: { + name: name, + type: type, + config: config, + id: 'test-id', + }, + }) + } + + if (app === 'Kong Manager') { + cy.intercept('POST', `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/partials`, handler) + .as('createRedisConfiguration') + + cy.intercept('PATCH', `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/partials/*`, handler) + .as('editRedisConfiguration') + } else { + cy.intercept('POST', `${baseConfigKonnect.apiBaseUrl}/v2/control-planes/${baseConfigKonnect.controlPlaneId}/core-entities/partials`, handler) + .as('createRedisConfiguration') + + cy.intercept('PATCH', `${baseConfigKonnect.apiBaseUrl}/v2/control-planes/${baseConfigKonnect.controlPlaneId}/core-entities/partials/*`, handler) + .as('editRedisConfiguration') + } + } + + const interceptDetail = ({ + body = redisConfigurationCE, + status = 200, + }: { + body?: RedisConfigurationResponse + status?: number + } = {}) => { + if (app === 'Kong Manager') { + cy.intercept('GET', `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/partials/*`, { + statusCode: status, + body, + }).as('getRedisConfiguration') + } else { + cy.intercept('GET', `${baseConfigKonnect.apiBaseUrl}/v2/control-planes/${baseConfigKonnect.controlPlaneId}/core-entities/partials/*`, { + statusCode: status, + body, + }).as('getRedisConfiguration') + } + } + + function interceptLinkedPlugins({ + body = { data: [], next: null, count: 0 }, + }: { + body?: typeof links, + } = {}) { + if (app === 'Kong Manager') { + cy.intercept({ + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/partials/*/links*`, + }, { + statusCode: 200, + body, + }).as('getLinkedPlugins') + } else { + cy.intercept({ + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/v2/control-planes/${baseConfigKonnect.controlPlaneId}/core-entities/partials/*/links*`, + }, { + statusCode: 200, + body, + }).as('getLinkedPlugins') + } + } + + it('should show create form', () => { + cy.mount(RedisConfigurationForm, { + props: { + config, + }, + }) + + cy.get('.kong-ui-entities-redis-configurations-form').should('be.visible') + cy.get('.kong-ui-entities-redis-configurations-form form').should('be.visible') + + // button state + cy.getTestId('redis_configuration-create-form-cancel').should('be.visible') + cy.getTestId('redis_configuration-create-form-cancel').should('be.enabled') + cy.getTestId('redis_configuration-create-form-submit').should('be.visible') + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + + // form fields + cy.getTestId('redis-type-select').should('be.visible') + cy.getTestId('redis-name-input').should('be.visible') + + // redis type select items + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .should('be.visible') + + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.HOST_PORT_CE}"]`) + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'value', RedisType.HOST_PORT_CE) + + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.HOST_PORT_EE}"]`) + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'value', RedisType.HOST_PORT_EE) + + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.CLUSTER}"]`) + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'value', RedisType.CLUSTER) + + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.SENTINEL}"]`) + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'value', RedisType.SENTINEL) + + // CE fields + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.HOST_PORT_CE}"]`) + .click() + + cy.getTestId('redis-sentinel-configuration-section').should('not.exist') + cy.getTestId('redis-cluster-configuration-section').should('not.exist') + cy.getTestId('.redis-keepalive-section').should('not.exist') + cy.getTestId('.redis-read-write-configuration-section').should('not.exist') + cy.getTestId('redis-connection-is-proxied-checkbox').should('not.exist') + + cy.getTestId('redis-host-input').should('be.visible') + cy.getTestId('redis-port-input').should('be.visible') + cy.getTestId('redis-timeout-input').should('be.visible') + + // Host/Port EE fields + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.HOST_PORT_EE}"]`) + .click() + + cy.getTestId('redis-sentinel-configuration-section').should('not.exist') + cy.getTestId('redis-cluster-configuration-section').should('not.exist') + cy.getTestId('redis-timeout-input').should('not.exist') + + cy.getTestId('redis-host-input').should('be.visible') + cy.getTestId('redis-port-input').should('be.visible') + cy.getTestId('redis-connection-is-proxied-checkbox').should('be.visible') + cy.getTestId('redis-keepalive-section').should('be.visible') + cy.getTestId('redis-read-write-configuration-section').should('be.visible') + + // Cluster fields + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.CLUSTER}"]`) + .click() + + cy.getTestId('redis-sentinel-configuration-section').should('not.exist') + cy.getTestId('redis-host-input').should('not.exist') + cy.getTestId('redis-port-input').should('not.exist') + cy.getTestId('redis-timeout-input').should('not.exist') + + cy.getTestId('redis-keepalive-section').should('be.visible') + cy.getTestId('redis-read-write-configuration-section').should('be.visible') + cy.getTestId('redis-cluster-configuration-section').should('be.visible') + + // Sentinel fields + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.SENTINEL}"]`) + .click() + + cy.getTestId('redis-cluster-configuration-section').should('not.exist') + cy.getTestId('redis-host-input').should('not.exist') + cy.getTestId('redis-port-input').should('not.exist') + cy.getTestId('redis-timeout-input').should('not.exist') + + cy.getTestId('redis-keepalive-section').should('be.visible') + cy.getTestId('redis-read-write-configuration-section').should('be.visible') + cy.getTestId('redis-sentinel-configuration-section').should('be.visible') + }) + + it('should correctly handle button state - create CE', () => { + stubCreateEdit() + + cy.mount(RedisConfigurationForm, { + props: { + config, + }, + }) + + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.HOST_PORT_CE}"]`) + .click() + + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + + cy.getTestId('redis-name-input').type('test') + + cy.getTestId('redis_configuration-create-form-submit').should('be.enabled') + + cy.getTestId('redis-host-input').clear() + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + + cy.getTestId('redis-host-input').type('localhost') + cy.getTestId('redis_configuration-create-form-submit').should('be.enabled') + + cy.getTestId('redis-port-input').clear() + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + + cy.getTestId('redis-port-input').type('6379') + cy.getTestId('redis_configuration-create-form-submit') + .should('be.enabled') + .click() + + cy.wait('@createRedisConfiguration') + + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + }) + + it('should correctly handle button state - create Host/port EE', () => { + stubCreateEdit() + + cy.mount(RedisConfigurationForm, { + props: { + config, + }, + }) + + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.HOST_PORT_EE}"]`) + .click() + + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + + cy.getTestId('redis-name-input').type('test') + + cy.getTestId('redis_configuration-create-form-submit').should('be.enabled') + + cy.getTestId('redis-host-input').clear() + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + + cy.getTestId('redis-host-input').type('localhost') + cy.getTestId('redis_configuration-create-form-submit').should('be.enabled') + + cy.getTestId('redis-port-input').clear() + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + + cy.getTestId('redis-port-input').type('6379') + cy.getTestId('redis_configuration-create-form-submit') + .should('be.enabled') + .click() + + cy.wait('@createRedisConfiguration') + + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + }) + + it('should correctly handle button state - create Cluster', () => { + stubCreateEdit() + + cy.mount(RedisConfigurationForm, { + props: { + config, + }, + }) + + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.CLUSTER}"]`) + .click() + + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + + cy.getTestId('redis-name-input').type('test') + + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + + // Add cluster node + cy.getTestId('redis-add-cluster-node-button').click() + + cy.getTestId('redis-cluster-nodes').should('be.visible') + + cy.getTestId('redis_configuration-create-form-submit') + .should('be.enabled') + + // Remove cluster node + cy.getTestId('redis-cluster-nodes').find('button.array-card-remove-button').click() + + cy.getTestId('redis_configuration-create-form-submit') + .should('be.disabled') + + // Add cluster node again but set invalid values + cy.getTestId('redis-add-cluster-node-button').click() + cy.getTestId('redis-cluster-nodes').find('input[name="ip"]').clear() + cy.getTestId('redis_configuration-create-form-submit') + .should('be.disabled') + + // Aet valid value + cy.getTestId('redis-cluster-nodes').find('input[name="ip"]').type('127.0.0.1') + + cy.getTestId('redis_configuration-create-form-submit') + .should('be.enabled') + .click() + + cy.wait('@createRedisConfiguration') + + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + }) + + it('should correctly handle button state - create Sentinel', () => { + stubCreateEdit() + + cy.mount(RedisConfigurationForm, { + props: { + config, + }, + }) + + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.SENTINEL}"]`) + .click() + + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + + // Set name + cy.getTestId('redis-name-input').type('test') + + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + + // Set sentinel master + cy.getTestId('redis-sentinel-master-input').type('master') + + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + + // Set sentinel role + cy.getTestId('redis-sentinel-role-select').click() + cy.getTestId('redis-sentinel-role-select-popover') + .should('be.visible') + .find('button:eq(0)') + .click() + + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + + // Add sentinel node + cy.getTestId('redis-add-sentinel-node-button').click() + + cy.getTestId('redis-sentinel-nodes').should('be.visible') + + cy.getTestId('redis_configuration-create-form-submit') + .should('be.enabled') + + // Remove sentinel node + cy.getTestId('redis-sentinel-nodes').find('button.array-card-remove-button').click() + + cy.getTestId('redis_configuration-create-form-submit') + .should('be.disabled') + + // Add sentinel node again but set invalid values + cy.getTestId('redis-add-sentinel-node-button').click() + cy.getTestId('redis-sentinel-nodes').find('input[name="host"]').clear() + cy.getTestId('redis_configuration-create-form-submit') + .should('be.disabled') + + // Aet valid value + cy.getTestId('redis-sentinel-nodes').find('input[name="host"]').type('localhost') + + cy.getTestId('redis_configuration-create-form-submit') + .should('be.enabled') + .click() + + cy.wait('@createRedisConfiguration') + + cy.getTestId('redis_configuration-create-form-submit').should('be.disabled') + }) + + it('should show edit form', () => { + // CE + interceptDetail({ body: redisConfigurationCE }) + interceptLinkedPlugins() + + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationCE.id, + }, + }) + + cy.wait('@getRedisConfiguration') + + cy.getTestId('redis-type-select-popover').should('contain.text', 'Host/Port (Open Source)') + + // button state + cy.getTestId('redis_configuration-edit-form-submit').should('be.visible') + cy.getTestId('redis_configuration-edit-form-submit').should('be.disabled') + cy.getTestId('redis_configuration-edit-form-cancel').should('be.visible') + cy.getTestId('redis_configuration-edit-form-cancel').should('be.enabled') + + // redis type cannot be changed + cy.getTestId('redis-type-select').should('be.disabled') + + // CE fields + cy.getTestId('redis-sentinel-configuration-section').should('not.exist') + cy.getTestId('redis-cluster-configuration-section').should('not.exist') + cy.getTestId('.redis-keepalive-section').should('not.exist') + cy.getTestId('.redis-read-write-configuration-section').should('not.exist') + cy.getTestId('redis-connection-is-proxied-checkbox').should('not.exist') + + cy.getTestId('redis-name-input').should('be.visible').should('have.value', redisConfigurationCE.name) + cy.getTestId('redis-host-input').should('be.visible').should('have.value', redisConfigurationCE.config.host) + cy.getTestId('redis-port-input').should('be.visible').should('have.value', redisConfigurationCE.config.port) + cy.getTestId('redis-timeout-input').should('be.visible').should('have.value', redisConfigurationCE.config.timeout) + cy.getTestId('redis-database-input').should('be.visible').should('have.value', redisConfigurationCE.config.database) + cy.getTestId('redis-ssl-checkbox').should('be.visible').should('not.be.checked') + cy.getTestId('redis-ssl-verify-checkbox').should('be.visible').should('not.be.checked') + + // Host/Port EE + interceptDetail({ body: redisConfigurationHostPortEE }) + + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationHostPortEE.id, + }, + }) + + cy.wait('@getRedisConfiguration') + + cy.getTestId('redis-type-select') + .should('be.visible') + .should('have.value', 'Host/Port') + + // an EE type can be changed to other EE types + cy.getTestId('redis-type-select').should('not.be.disabled') + + // Host/port EE fields + cy.getTestId('redis-sentinel-configuration-section').should('not.exist') + cy.getTestId('redis-cluster-configuration-section').should('not.exist') + cy.getTestId('redis-timeout-input').should('not.exist') + + cy.getTestId('redis-name-input').should('be.visible').should('have.value', redisConfigurationHostPortEE.name) + cy.getTestId('redis-host-input').should('be.visible').should('have.value', redisConfigurationHostPortEE.config.host) + cy.getTestId('redis-port-input').should('be.visible').should('have.value', redisConfigurationHostPortEE.config.port) + cy.getTestId('redis-database-input').should('be.visible').should('have.value', redisConfigurationHostPortEE.config.database) + cy.getTestId('redis-ssl-checkbox').should('be.visible').should(redisConfigurationHostPortEE.config.ssl ? 'be.checked' : 'not.be.checked') + cy.getTestId('redis-ssl-verify-checkbox').should('be.visible').should(redisConfigurationHostPortEE.config.ssl_verify ? 'be.checked' : 'not.be.checked') + cy.getTestId('redis-keepalive-backlog-input').should('be.visible').should('have.value', redisConfigurationHostPortEE.config.keepalive_backlog) + cy.getTestId('redis-keepalive-pool-size-input').should('be.visible').should('have.value', redisConfigurationHostPortEE.config.keepalive_pool_size) + cy.getTestId('redis-send-timeout-input').should('be.visible').should('have.value', redisConfigurationHostPortEE.config.send_timeout) + cy.getTestId('redis-connect-timeout-input').should('be.visible').should('have.value', redisConfigurationHostPortEE.config.connect_timeout) + cy.getTestId('redis-connect-timeout-input').should('be.visible').should('have.value', redisConfigurationHostPortEE.config.connect_timeout) + + // Cluster EE + interceptDetail({ body: redisConfigurationCluster }) + + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationCluster.id, + }, + }) + + cy.wait('@getRedisConfiguration') + + cy.getTestId('redis-type-select') + .should('be.visible') + .should('have.value', 'Cluster') + + // an EE type can be changed to other EE types + cy.getTestId('redis-type-select').should('not.be.disabled') + + // Cluster fields + cy.getTestId('redis-sentinel-configuration-section').should('not.exist') + cy.getTestId('redis-host-input').should('not.exist') + cy.getTestId('redis-port-input').should('not.exist') + cy.getTestId('redis-timeout-input').should('not.exist') + + cy.getTestId('redis-name-input').should('be.visible').should('have.value', redisConfigurationCluster.name) + + // Cluster nodes + cy.getTestId('redis-cluster-nodes') + .should('be.visible') + .find('.cluster-node-items') + .should('have.length', redisConfigurationCluster.config.cluster_nodes!.length) + + cy.getTestId('redis-cluster-nodes') + .find('input[name="ip"]').should('have.value', redisConfigurationCluster.config.cluster_nodes![0].ip) + + cy.getTestId('redis-cluster-nodes') + .find('input[name="port"]').should('have.value', redisConfigurationCluster.config.cluster_nodes![0].port) + + // max redirections + cy.getTestId('redis-cluster-max-redirections-input').should('be.visible').should('have.value', redisConfigurationCluster.config.cluster_max_redirections) + + cy.getTestId('redis-database-input').should('be.visible').should('have.value', redisConfigurationCluster.config.database) + cy.getTestId('redis-ssl-checkbox').should('be.visible').should(redisConfigurationCluster.config.ssl ? 'be.checked' : 'not.be.checked') + cy.getTestId('redis-ssl-verify-checkbox').should('be.visible').should(redisConfigurationCluster.config.ssl_verify ? 'be.checked' : 'not.be.checked') + cy.getTestId('redis-keepalive-backlog-input').should('be.visible').should('have.value', redisConfigurationCluster.config.keepalive_backlog) + cy.getTestId('redis-keepalive-pool-size-input').should('be.visible').should('have.value', redisConfigurationCluster.config.keepalive_pool_size) + cy.getTestId('redis-send-timeout-input').should('be.visible').should('have.value', redisConfigurationCluster.config.send_timeout) + cy.getTestId('redis-connect-timeout-input').should('be.visible').should('have.value', redisConfigurationCluster.config.connect_timeout) + cy.getTestId('redis-connect-timeout-input').should('be.visible').should('have.value', redisConfigurationCluster.config.connect_timeout) + + // Sentinel EE + interceptDetail({ body: redisConfigurationSentinel }) + + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationSentinel.id, + }, + }) + + cy.wait('@getRedisConfiguration') + + cy.getTestId('redis-type-select') + .should('be.visible') + .should('have.value', 'Sentinel') + + // an EE type can be changed to other EE types + cy.getTestId('redis-type-select').should('not.be.disabled') + + // Sentinel fields + cy.getTestId('redis-cluster-configuration-section').should('not.exist') + cy.getTestId('redis-host-input').should('not.exist') + cy.getTestId('redis-port-input').should('not.exist') + cy.getTestId('redis-timeout-input').should('not.exist') + + cy.getTestId('redis-name-input').should('be.visible').should('have.value', redisConfigurationSentinel.name) + cy.getTestId('redis-sentinel-master-input').should('be.visible').should('have.value', redisConfigurationSentinel.config.sentinel_master) + cy.getTestId('redis-sentinel-role-select').should('be.visible').should('have.attr', 'value', redisConfigurationSentinel.config.sentinel_role) + cy.getTestId('redis-sentinel-master-input').should('be.visible').should('have.value', redisConfigurationSentinel.config.sentinel_master) + + // Sentinel nodes + cy.getTestId('redis-sentinel-nodes') + .should('be.visible') + .find('.sentinel-node-items') + .should('have.length', redisConfigurationSentinel.config.sentinel_nodes!.length) + + cy.getTestId('redis-sentinel-nodes') + .find('input[name="host"]').should('have.value', redisConfigurationSentinel.config.sentinel_nodes![0].host) + + cy.getTestId('redis-sentinel-nodes') + .find('input[name="port"]').should('have.value', redisConfigurationSentinel.config.sentinel_nodes![0].port) + + cy.getTestId('redis-database-input').should('be.visible').should('have.value', redisConfigurationSentinel.config.database) + cy.getTestId('redis-ssl-checkbox').should('be.visible').should(redisConfigurationSentinel.config.ssl ? 'be.checked' : 'not.be.checked') + cy.getTestId('redis-ssl-verify-checkbox').should('be.visible').should(redisConfigurationSentinel.config.ssl_verify ? 'be.checked' : 'not.be.checked') + cy.getTestId('redis-keepalive-backlog-input').should('be.visible').should('have.value', redisConfigurationSentinel.config.keepalive_backlog) + cy.getTestId('redis-keepalive-pool-size-input').should('be.visible').should('have.value', redisConfigurationSentinel.config.keepalive_pool_size) + cy.getTestId('redis-send-timeout-input').should('be.visible').should('have.value', redisConfigurationSentinel.config.send_timeout) + cy.getTestId('redis-connect-timeout-input').should('be.visible').should('have.value', redisConfigurationSentinel.config.connect_timeout) + cy.getTestId('redis-connect-timeout-input').should('be.visible').should('have.value', redisConfigurationSentinel.config.connect_timeout) + }) + + it('should correctly handle button state - edit', () => { + stubCreateEdit() + interceptLinkedPlugins() + interceptDetail({ body: redisConfigurationCE }) + + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationCE.id, + }, + }) + + cy.wait('@getRedisConfiguration') + + cy.getTestId('redis_configuration-edit-form-submit').should('be.disabled') + + cy.getTestId('redis-name-input').type('test') + + cy.getTestId('redis_configuration-edit-form-submit') + .should('be.enabled') + .click() + + cy.wait('@editRedisConfiguration') + + cy.getTestId('redis_configuration-edit-form-submit').should('be.disabled') + }) + + it('should show error message', () => { + interceptDetail({ status: 404 }) + interceptLinkedPlugins() + + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: 'invalid-id', + }, + }) + + cy.wait('@getRedisConfiguration') + + // error state is displayed + cy.getTestId('form-fetch-error').should('be.visible') + + // buttons and form hidden + cy.getTestId('route-edit-form-cancel').should('not.exist') + cy.getTestId('route-edit-form-submit').should('not.exist') + cy.get('.kong-ui-entities-route-form form').should('not.exist') + }) + + it('@update should be emitted when form is submitted', () => { + stubCreateEdit() + interceptDetail() + interceptLinkedPlugins() + + // create + cy.mount(RedisConfigurationForm, { + props: { + config, + onUpdate: cy.stub().as('onCreateUpdateSpy'), + }, + }) + + cy.getTestId('redis-name-input').type('test') + cy.getTestId('redis_configuration-create-form-submit').click() + cy.wait('@createRedisConfiguration') + cy.get('@onCreateUpdateSpy').should('have.been.calledOnce') + + // edit + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationCE.id, + onUpdate: cy.stub().as('onEditUpdateSpy'), + }, + }) + + cy.wait('@getRedisConfiguration') + cy.getTestId('redis-name-input').type('test') + + cy.getTestId('redis_configuration-edit-form-submit').click() + + cy.wait('@editRedisConfiguration') + + cy.get('@onEditUpdateSpy').should('have.been.calledOnce') + }) + + it('should show a warning when modifying a redis configuration that is in use by plugins', () => { + interceptDetail({ body: redisConfigurationCE }) + interceptLinkedPlugins({ body: links }) + stubCreateEdit() + + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationCE.id, + }, + }) + + cy.wait('@getRedisConfiguration') + cy.wait('@getLinkedPlugins') + + cy.getTestId('redis-update-warning-alert').should('be.visible') + + cy.getTestId('redis-name-input').type('test') + cy.getTestId('redis_configuration-edit-form-submit').click() + + cy.wait('@getLinkedPlugins') + cy.getTestId('redis-update-warning-modal').find('.modal-container').should('be.visible') + cy.getTestId('redis-update-warning-modal').find('[data-testid="modal-action-button"]').click() + + cy.wait('@editRedisConfiguration') + + interceptLinkedPlugins() + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationCE.id, + }, + }) + cy.wait('@getRedisConfiguration') + cy.wait('@getLinkedPlugins') + cy.getTestId('redis-update-warning-alert').should('not.exist') + }) + + it('props `slidoutTopOffset` should be working', () => { + cy.mount(RedisConfigurationForm, { + props: { + config, + slidoutTopOffset: 0, + }, + }) + + cy.getTestId('redis_configuration-create-form-view-configuration').click() + cy.getTestId('slideout-container').should('be.visible').should('have.css', 'top', '0px') + }) + + it('props `actionTeleportTarget` should be working', () => { + cy.document().then(doc => { + const elem = doc.createElement('div') + elem.id = 'test' + doc.body.appendChild(elem) + + cy.mount(RedisConfigurationForm, { + props: { + config, + actionTeleportTarget: '#test', + }, + }) + + cy.get('#test') + .should('be.visible') + .findTestId('redis_configuration-create-form-view-configuration').should('be.visible') + }) + }) + + it('props `disabledPartialType` should be working', () => { + cy.mount(RedisConfigurationForm, { + props: { + config, + disabledPartialType: PartialType.REDIS_CE, + }, + }) + + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.HOST_PORT_CE}"]`) + .should('be.disabled') + + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.HOST_PORT_EE}"]`) + .should('not.be.disabled') + .should('have.class', 'selected') + + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.CLUSTER}"]`) + .should('not.be.disabled') + + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.SENTINEL}"]`) + .should('not.be.disabled') + + cy.mount(RedisConfigurationForm, { + props: { + config, + disabledPartialType: PartialType.REDIS_EE, + }, + }) + + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.HOST_PORT_CE}"]`) + .should('not.be.disabled') + .should('have.class', 'selected') + + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.HOST_PORT_EE}"]`) + .should('be.disabled') + + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.CLUSTER}"]`) + .should('be.disabled') + + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.SENTINEL}"]`) + .should('be.disabled') + }) + + describe('fields do not belong to the selected type should be reset when editing', () => { + it('Host/Port EE -> Cluster', () => { + // Host/Port EE -> Cluster + interceptDetail({ body: redisConfigurationHostPortEE }) + stubCreateEdit() + interceptLinkedPlugins() + + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationHostPortEE.id, + }, + }) + + cy.wait('@getRedisConfiguration') + + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.CLUSTER}"]`) + .click() + + // Add cluster node + cy.getTestId('redis-add-cluster-node-button').click() + cy.getTestId('redis_configuration-edit-form-submit').click() + + cy.wait('@editRedisConfiguration').then(({ request }) => { + const { body: { config } } = request + expect(config.host).to.be.null + expect(config.port).to.be.null + }) + }) + + it('Host/Port EE -> Sentinel', () => { + // Host/Port EE -> Sentinel + interceptDetail({ body: redisConfigurationHostPortEE }) + interceptLinkedPlugins() + stubCreateEdit() + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationHostPortEE.id, + }, + }) + + cy.wait('@getRedisConfiguration') + + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.SENTINEL}"]`) + .click() + + // Add sentinel node + cy.getTestId('redis-add-sentinel-node-button').click() + + // Set sentinel master + cy.getTestId('redis-sentinel-master-input').type('master') + + // Set sentinel role + cy.getTestId('redis-sentinel-role-select').click() + cy.getTestId('redis-sentinel-role-select-popover') + .should('be.visible') + .find('button:eq(0)') + .click() + + cy.getTestId('redis_configuration-edit-form-submit').click() + cy.wait('@editRedisConfiguration').then(({ request }) => { + const { body: { config } } = request + expect(config.host).to.be.null + expect(config.port).to.be.null + }) + }) + + it('Cluster -> Host/Port EE', () => { + // Cluster -> Host/Port EE + interceptDetail({ body: redisConfigurationCluster }) + interceptLinkedPlugins() + stubCreateEdit() + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationCluster.id, + }, + }) + + cy.wait('@getRedisConfiguration') + + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.HOST_PORT_EE}"]`) + .click() + + cy.getTestId('redis-host-input').type('localhost') + cy.getTestId('redis-port-input').type('6379') + + cy.getTestId('redis_configuration-edit-form-submit').click() + cy.wait('@editRedisConfiguration').then(({ request }) => { + const { body: { config } } = request + expect(config.cluster_nodes).to.be.null + expect(config.cluster_max_redirections).to.be.null + }) + }) + + it('Cluster -> Sentinel', () => { + // Cluster -> Sentinel + interceptDetail({ body: redisConfigurationCluster }) + interceptLinkedPlugins() + stubCreateEdit() + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationCluster.id, + }, + }) + + cy.wait('@getRedisConfiguration') + + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.SENTINEL}"]`) + .click() + + // Add sentinel node + cy.getTestId('redis-add-sentinel-node-button').click() + + // Set sentinel master + cy.getTestId('redis-sentinel-master-input').type('master') + + // Set sentinel role + cy.getTestId('redis-sentinel-role-select').click() + cy.getTestId('redis-sentinel-role-select-popover') + .should('be.visible') + .find('button:eq(0)') + .click() + + cy.getTestId('redis_configuration-edit-form-submit').click() + cy.wait('@editRedisConfiguration').then(({ request }) => { + const { body: { config } } = request + expect(config.cluster_nodes).to.be.null + expect(config.cluster_max_redirections).to.be.null + }) + }) + + it('Sentinel -> Host/Port EE', () => { + // Sentinel -> Host/Port EE + interceptDetail({ body: redisConfigurationSentinel }) + interceptLinkedPlugins() + stubCreateEdit() + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationSentinel.id, + }, + }) + + cy.wait('@getRedisConfiguration') + + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.HOST_PORT_EE}"]`) + .click() + + cy.getTestId('redis-host-input').type('localhost') + cy.getTestId('redis-port-input').type('6379') + + cy.getTestId('redis_configuration-edit-form-submit').click() + cy.wait('@editRedisConfiguration').then(({ request }) => { + const { body: { config } } = request + expect(config.sentinel_master).to.be.null + expect(config.sentinel_role).to.be.null + expect(config.sentinel_nodes).to.be.null + expect(config.sentinel_username).to.be.null + expect(config.sentinel_password).to.be.null + }) + }) + + it('Sentinel -> Cluster', () => { + // Sentinel -> Cluster + interceptDetail({ body: redisConfigurationSentinel }) + interceptLinkedPlugins() + stubCreateEdit() + cy.mount(RedisConfigurationForm, { + props: { + config, + partialId: redisConfigurationSentinel.id, + }, + }) + + cy.wait('@getRedisConfiguration') + + cy.getTestId('redis-type-select').click() + cy.getTestId('redis-type-select-popover') + .find(`button[value="${RedisType.CLUSTER}"]`) + .click() + + // Add cluster node + cy.getTestId('redis-add-cluster-node-button').click() + cy.getTestId('redis_configuration-edit-form-submit').click() + + cy.wait('@editRedisConfiguration').then(({ request }) => { + const { body: { config } } = request + expect(config.sentinel_master).to.be.null + expect(config.sentinel_role).to.be.null + expect(config.sentinel_nodes).to.be.null + expect(config.sentinel_username).to.be.null + expect(config.sentinel_password).to.be.null + }) + }) + }) + + }) + + } +}) diff --git a/packages/entities/entities-redis-configurations/src/components/RedisConfigurationForm.vue b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationForm.vue new file mode 100644 index 0000000000..64f10e32e2 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationForm.vue @@ -0,0 +1,633 @@ + + + + + diff --git a/packages/entities/entities-redis-configurations/src/components/RedisConfigurationList.cy.ts b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationList.cy.ts new file mode 100644 index 0000000000..9315d0ec42 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationList.cy.ts @@ -0,0 +1,278 @@ +import type { KongManagerRedisConfigurationListConfig, KonnectRedisConfigurationListConfig } from 'src/types' +import RedisConfigurationList from './RedisConfigurationList.vue' +import { createRouter, createWebHistory } from 'vue-router' +import { partials, links } from '../../fixtures/mockData' +import { v4 as uuidv4 } from 'uuid' + +const baseConfigKM: KongManagerRedisConfigurationListConfig = { + app: 'kongManager', + workspace: 'default', + apiBaseUrl: '/kong-manager', + createRoute: { name: 'redis-configuration-create' }, + getViewRoute: (id) => ({ name: 'redis-configuration-detail', params: { id } }), + getEditRoute: (id) => ({ name: 'redis-configuration-edit', params: { id } }), +} + +const baseConfigKonnect: KonnectRedisConfigurationListConfig = { + app: 'konnect', + controlPlaneId: 'test-control-plane-id', + apiBaseUrl: '/us/kong-api', + createRoute: { name: 'redis-configuration-create' }, + getViewRoute: (id) => ({ name: 'redis-configuration-detail', params: { id } }), + getEditRoute: (id) => ({ name: 'redis-configuration-edit', params: { id } }), +} + +describe('', () => { + + function interceptList({ + app = 'Kong Manager', + status = 200, + body = partials, + }: { + app?: string + status?: number + body?: any + } = {}) { + if (app === 'Kong Manager') { + cy.intercept({ + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/partials*`, + }, { + statusCode: status, + body, + }).as('getRedisConfigurations') + } else { + cy.intercept({ + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/v2/control-planes/${baseConfigKonnect.controlPlaneId}/core-entities/partials*`, + }, { + statusCode: status, + body, + }).as('getRedisConfigurations') + } + } + + function interceptLinkedPlugins({ + app = 'Kong Manager', + }: { + app?: string + } = {}) { + if (app === 'Kong Manager') { + cy.intercept({ + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/partials/*/links*`, + }, { + statusCode: 200, + body: links, + }).as('getLinkedPlugins') + } else { + cy.intercept({ + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/v2/control-planes/${baseConfigKonnect.controlPlaneId}/core-entities/partials/*/links*`, + }, { + statusCode: 200, + body: links, + }).as('getLinkedPlugins') + } + } + + beforeEach(() => { + // Initialize a new router before each test + createRouter({ + routes: [ + { name: 'redis-configuration-create', path: '/kong-manager/workspaces/default/redis-configurations/create', component: { template: '
CreatePage
' } }, + { name: 'redis-configuration-detail', path: '/kong-manager/workspaces/default/redis-configurations/:id', component: { template: '
DetailPage
' } }, + { name: 'redis-configuration-edit', path: '/kong-manager/workspaces/default/redis-configurations/:id/edit', component: { template: '
EditPage
' } }, + ], + history: createWebHistory(), + }) + }) + + describe('actions', { + viewportHeight: 700, + viewportWidth: 700, + }, () => { + for (const expected of [false, true]) { + describe(expected ? 'allowed' : 'denied', () => { + it(`should ${expected ? 'allow' : 'deny'} to create a new RedisConfiguration`, () => { + interceptList() + interceptLinkedPlugins() + + cy.mount(RedisConfigurationList, { + props: { + config: baseConfigKM, + cacheIdentifier: uuidv4(), + canCreate: () => expected, + }, + }) + + cy.getTestId('toolbar-add-redis-configuration').should(expected ? 'exist' : 'not.exist') + }) + + it(`should ${expected ? 'show' : 'hide'} the View Details action if CanRetrieve evaluates to ${expected}`, () => { + interceptList() + interceptLinkedPlugins() + + cy.mount(RedisConfigurationList, { + props: { + config: baseConfigKM, + cacheIdentifier: uuidv4(), + canRetrieve: () => expected, + }, + }) + + cy.getTestId('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}`, () => { + interceptList() + interceptLinkedPlugins() + + cy.mount(RedisConfigurationList, { + props: { + config: baseConfigKM, + cacheIdentifier: uuidv4(), + canEdit: () => expected, + }, + }) + + cy.getTestId('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}`, () => { + interceptList() + interceptLinkedPlugins() + + cy.mount(RedisConfigurationList, { + props: { + config: baseConfigKM, + cacheIdentifier: uuidv4(), + canDelete: () => expected, + }, + }) + + cy.getTestId('dropdown-trigger').eq(0).click() + cy.getTestId('action-entity-delete').should(`${expected ? '' : 'not.'}exist`) + }) + }) + } + }) + + for (const app of ['Kong Manager', 'Konnect']) { + describe(app, () => { + it('should show empty state and create redis configuration cta', () => { + interceptList({ app, body: [] }) + interceptLinkedPlugins({ app }) + + cy.mount(RedisConfigurationList, { + props: { + config: app === 'Kong Manager' ? baseConfigKM : baseConfigKonnect, + cacheIdentifier: uuidv4(), + }, + }) + + cy.wait('@getRedisConfigurations') + cy.get('.table-empty-state').should('be.visible') + cy.get('.table-empty-state .empty-state-action .k-button').should('be.visible') + }) + + it('should hide create redis configuration cta if user can not create', () => { + interceptList({ app, body: [] }) + interceptLinkedPlugins({ app }) + + cy.mount(RedisConfigurationList, { + props: { + config: app === 'Kong Manager' ? baseConfigKM : baseConfigKonnect, + cacheIdentifier: uuidv4(), + canCreate: () => false, + }, + }) + + cy.wait('@getRedisConfigurations') + cy.get('.table-empty-state .empty-state-action .k-button').should('not.exist') + }) + + it('should show redis configuration items', () => { + interceptList({ app }) + interceptLinkedPlugins({ app }) + + cy.mount(RedisConfigurationList, { + props: { + config: app === 'Kong Manager' ? baseConfigKM : baseConfigKonnect, + cacheIdentifier: uuidv4(), + }, + }) + + cy.wait('@getRedisConfigurations') + partials.data.forEach((partial) => { + cy.get(`table tr[data-testid="${partial.name}"]`).should('be.visible') + }) + }) + + it('should handle error state', () => { + interceptList({ app, status: 500 }) + interceptLinkedPlugins({ app }) + + cy.mount(RedisConfigurationList, { + props: { + config: app === 'Kong Manager' ? baseConfigKM : baseConfigKonnect, + cacheIdentifier: uuidv4(), + }, + }) + + cy.wait('@getRedisConfigurations') + cy.get('.table-error-state').should('be.visible') + }) + + it('should show linked plugins', () => { + interceptList({ app }) + interceptLinkedPlugins({ app }) + + cy.mount(RedisConfigurationList, { + props: { + config: app === 'Kong Manager' ? baseConfigKM : baseConfigKonnect, + cacheIdentifier: uuidv4(), + }, + }) + + cy.wait('@getRedisConfigurations') + cy.wait(Array(partials.data.length).fill('@getLinkedPlugins')) + + cy.getTestId('linked-plugins-inline').should('be.visible') + + // open linked plugins modal + cy.get('[data-testid="linked-plugins-inline"]:first').click() + cy.wait('@getLinkedPlugins') + + cy.getTestId('linked-plugins-modal').find('.modal-container').should('be.visible') + }) + + it('should stop deleting if user are deleting a partial with linked plugins', () => { + interceptList({ app }) + interceptLinkedPlugins({ app }) + + cy.mount(RedisConfigurationList, { + props: { + config: app === 'Kong Manager' ? baseConfigKM : baseConfigKonnect, + cacheIdentifier: uuidv4(), + }, + }) + + cy.wait('@getRedisConfigurations') + + cy.get('[data-testid="redis-config-1"]') + .find('[data-testid="actions-dropdown"]') + .click() + + cy.get('[data-testid="redis-config-1"]') + .find('[data-testid="action-entity-delete"]') + .click() + + cy.wait('@getLinkedPlugins') + cy.getTestId('remove-links-modal').find('.modal-container').should('be.visible') + }) + }) + } +}) diff --git a/packages/entities/entities-redis-configurations/src/components/RedisConfigurationList.vue b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationList.vue new file mode 100644 index 0000000000..52b11f795e --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationList.vue @@ -0,0 +1,510 @@ + + + + + diff --git a/packages/entities/entities-redis-configurations/src/components/SentinelNodes.vue b/packages/entities/entities-redis-configurations/src/components/SentinelNodes.vue new file mode 100644 index 0000000000..3fb50b554a --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/SentinelNodes.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/packages/entities/entities-redis-configurations/src/composables/index.ts b/packages/entities/entities-redis-configurations/src/composables/index.ts new file mode 100644 index 0000000000..3893dfc828 --- /dev/null +++ b/packages/entities/entities-redis-configurations/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-redis-configurations/src/composables/useI18n.ts b/packages/entities/entities-redis-configurations/src/composables/useI18n.ts new file mode 100644 index 0000000000..950be42ab7 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/composables/useI18n.ts @@ -0,0 +1,16 @@ +import { createI18n, i18nTComponent } from '@kong-ui-public/i18n' +import english from '../locales/en.json' + +interface UseI18nReturn { + i18n: ReturnType> + i18nT: ReturnType> +} + +export default function useI18n(): UseI18nReturn { + const i18n = createI18n('en-us', english) + + return { + i18n, + i18nT: i18nTComponent(i18n), // Translation component + } +} diff --git a/packages/entities/entities-redis-configurations/src/composables/useLinkedPlugins.ts b/packages/entities/entities-redis-configurations/src/composables/useLinkedPlugins.ts new file mode 100644 index 0000000000..283df3a8e1 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/composables/useLinkedPlugins.ts @@ -0,0 +1,72 @@ +import { useAxios, type KongManagerConfig, type KonnectConfig } from '@kong-ui-public/entities-shared' +import { ref, watch } from 'vue' +import useSwrv from 'swrv' + +import endpoints from '../partials-endpoints' + +import type { RedisConfigurationLinkedPluginsResponse } from '../types' + +type RequestParams = { + partialId: string + size?: number, + offset?: string | null, + query?: string, +} + +export function buildLinksCacheKey(partialId: string) { + return `redis-partial-links-${partialId}` +} + +export const useLinkedPluginsFetcher = (config: KonnectConfig | KongManagerConfig) => { + const { axiosInstance } = useAxios(config.axiosRequestConfig) + + return { + fetcher: async (params: RequestParams) => { + const { partialId, size, offset, query } = params + let url = `${config.apiBaseUrl}${endpoints.links[config.app]}` + + if (config.app === 'konnect') { + url = url.replace(/{controlPlaneId}/gi, config?.controlPlaneId || '') + } else if (config.app === 'kongManager') { + url = url.replace(/\/{workspace}/gi, config?.workspace ? `/${config.workspace}` : '') + } + + // Always replace the id when editing + url = url.replace(/{id}/gi, partialId || '') + + if (query) { + url = `${url}?${query}` + } + + const { data } = await axiosInstance.get( + url, + { params: { size, offset } }, + ) + return data + }, + } +} + +export const useLinkedPlugins = (param: { + partialId: string, + config: KonnectConfig | KongManagerConfig, + requestParams?: RequestParams, +}) => { + const { partialId, config } = param + + const { fetcher } = useLinkedPluginsFetcher(config) + const result = ref([]) + const { data } = useSwrv( + buildLinksCacheKey(partialId), + () => fetcher({ partialId }), + { + revalidateOnFocus: false, + }, + ) + + watch(data, () => { + result.value = data.value?.data ?? [] + }) + + return result +} diff --git a/packages/entities/entities-redis-configurations/src/composables/useRedisConfigurationForm.ts b/packages/entities/entities-redis-configurations/src/composables/useRedisConfigurationForm.ts new file mode 100644 index 0000000000..82c42f5998 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/composables/useRedisConfigurationForm.ts @@ -0,0 +1,249 @@ +import { computed, reactive, ref, watch } from 'vue' +import { EntityBaseFormType, useAxios, useErrors } from '@kong-ui-public/entities-shared' +import { isEqual } from 'lodash-es' + +import { getRedisType, mapRedisTypeToPartialType, standardize as s } from '../helpers' +import { RedisType } from '../types' +import { DEFAULT_REDIS_TYPE, DEFAULT_FIELDS } from '../constants' +import endpoints from '../partials-endpoints' + +import type { KongManagerRedisConfigurationFormConfig, KonnectRedisConfigurationFormConfig, RedisConfigurationFields, RedisConfigurationFormState, RedisConfigurationResponse } from '../types' + +export type Options = { + partialId?: string + defaultRedisType?: RedisType + config: KonnectRedisConfigurationFormConfig | KongManagerRedisConfigurationFormConfig +} + +export const useRedisConfigurationForm = (options: Options) => { + const { partialId, config, defaultRedisType = DEFAULT_REDIS_TYPE } = options + const isEdit = !!partialId + const { axiosInstance } = useAxios(config.axiosRequestConfig) + const { getMessageFromError } = useErrors() + const formType = computed((): EntityBaseFormType => partialId + ? EntityBaseFormType.Edit + : EntityBaseFormType.Create) + + const form = reactive({ + fields: { + name: '', + type: mapRedisTypeToPartialType(defaultRedisType), + config: JSON.parse(JSON.stringify(DEFAULT_FIELDS)), + }, + readonly: false, + errorMessage: '', + }) + + // Used to diff the form values when editing + const initialPayload = ref() + const redisType = ref(defaultRedisType) + const redisTypeIsEnterprise = computed(() => redisType.value === RedisType.HOST_PORT_EE || redisType.value === RedisType.CLUSTER || redisType.value === RedisType.SENTINEL) + + watch(redisType, (newValue) => { + form.fields.type = mapRedisTypeToPartialType(newValue) + }) + + const canSubmit = computed(() => { + if (isEdit) { + if (isEqual(initialPayload.value, payload.value)) { + return false + } + } + + if (!form.fields.name.length) { + return false + } + + const { config: fieldValues } = form.fields + + switch (redisType.value) { + case RedisType.HOST_PORT_CE: + case RedisType.HOST_PORT_EE: + return (!!fieldValues.host && fieldValues.host.length > 0) && (!!fieldValues.port && fieldValues.port > 0) + case RedisType.CLUSTER: + return !!fieldValues.cluster_nodes.length + && fieldValues.cluster_nodes.every((node) => node.ip.length > 0) + case RedisType.SENTINEL: + return !!fieldValues.sentinel_nodes.length + && fieldValues.sentinel_nodes.every((node) => node.host.length > 0) + && !!fieldValues.sentinel_master?.length + && fieldValues.sentinel_role + && !!fieldValues.sentinel_role.length + default: + throw new Error('Invalid redis type') + } + }) + + const payload = computed(() => { + switch (redisType.value) { + case RedisType.HOST_PORT_CE: + return { + name: form.fields.name, + type: form.fields.type, + config: { + host: form.fields.config.host, + port: s.int(form.fields.config.port), + timeout: s.int(form.fields.config.timeout), + username: s.str(form.fields.config.username, null), + database: s.int(form.fields.config.database), + password: s.str(form.fields.config.password, null), + ssl: form.fields.config.ssl, + ssl_verify: form.fields.config.ssl_verify, + server_name: s.str(form.fields.config.server_name, null), + }, + } + case RedisType.HOST_PORT_EE: + return { + name: form.fields.name, + type: form.fields.type, + config: { + connect_timeout: s.int(form.fields.config.connect_timeout), + connection_is_proxied: form.fields.config.connection_is_proxied, + database: s.int(form.fields.config.database), + host: form.fields.config.host, + keepalive_backlog: s.int(form.fields.config.keepalive_backlog), + keepalive_pool_size: s.int(form.fields.config.keepalive_pool_size), + password: s.str(form.fields.config.password, null), + port: s.int(form.fields.config.port), + read_timeout: s.int(form.fields.config.read_timeout), + send_timeout: s.int(form.fields.config.send_timeout), + server_name: s.str(form.fields.config.server_name, null), + ssl_verify: form.fields.config.ssl_verify, + ssl: form.fields.config.ssl, + username: s.str(form.fields.config.username, null), + // reset other EE fields + cluster_nodes: null, + cluster_max_redirections: null, + sentinel_master: null, + sentinel_role: null, + sentinel_nodes: null, + sentinel_username: null, + sentinel_password: null, + }, + } + case RedisType.CLUSTER: + return { + name: form.fields.name, + type: form.fields.type, + config: { + cluster_nodes: s.removeIdClusterNodes(form.fields.config.cluster_nodes), + cluster_max_redirections: s.int(form.fields.config.cluster_max_redirections), + username: s.str(form.fields.config.username, null), + password: s.str(form.fields.config.password, null), + ssl: form.fields.config.ssl, + ssl_verify: form.fields.config.ssl_verify, + server_name: s.str(form.fields.config.server_name, null), + connect_timeout: s.int(form.fields.config.connect_timeout), + database: s.int(form.fields.config.database), + send_timeout: s.int(form.fields.config.send_timeout), + read_timeout: s.int(form.fields.config.read_timeout), + keepalive_pool_size: s.int(form.fields.config.keepalive_pool_size), + keepalive_backlog: s.int(form.fields.config.keepalive_backlog), + // reset other EE fields + connection_is_proxied: null, + sentinel_master: null, + sentinel_role: null, + sentinel_nodes: null, + sentinel_username: null, + sentinel_password: null, + host: null, + port: null, + }, + } + case RedisType.SENTINEL: + return { + name: form.fields.name, + type: form.fields.type, + config: { + sentinel_master: s.str(form.fields.config.sentinel_master, null), + sentinel_nodes: s.removeIdFromSentinelNodes(form.fields.config.sentinel_nodes), + sentinel_role: s.str(form.fields.config.sentinel_role, null), + sentinel_username: s.str(form.fields.config.sentinel_username, null), + sentinel_password: s.str(form.fields.config.sentinel_password, null), + username: s.str(form.fields.config.username, null), + password: s.str(form.fields.config.password, null), + ssl: form.fields.config.ssl, + ssl_verify: form.fields.config.ssl_verify, + server_name: s.str(form.fields.config.server_name, null), + database: s.int(form.fields.config.database), + connect_timeout: s.int(form.fields.config.connect_timeout), + send_timeout: s.int(form.fields.config.send_timeout), + read_timeout: s.int(form.fields.config.read_timeout), + keepalive_pool_size: s.int(form.fields.config.keepalive_pool_size), + keepalive_backlog: s.int(form.fields.config.keepalive_backlog), + // reset other EE fields + connection_is_proxied: null, + cluster_nodes: null, + cluster_max_redirections: null, + host: null, + port: null, + }, + } + default: + throw new Error('Invalid redis type') + } + }) + + const submitUrl = computed(() => { + let url = `${config.apiBaseUrl}${endpoints.form[config.app][formType.value]}` + + if (config.app === 'konnect') { + url = url.replace(/{controlPlaneId}/gi, config?.controlPlaneId || '') + } else if (config.app === 'kongManager') { + url = url.replace(/\/{workspace}/gi, config?.workspace ? `/${config.workspace}` : '') + } + + // Always replace the id when editing + url = url.replace(/{id}/gi, partialId || '') + + return url + }) + + const fetchUrl = computed(() => endpoints.form[config?.app]?.edit) + + const submit = async () => { + try { + form.readonly = true + form.errorMessage = '' + + if (formType.value === EntityBaseFormType.Create) { + return await axiosInstance.post(submitUrl.value, payload.value) + } else { + // todo(zehao): check is patch or put in Konnect + return await axiosInstance.patch(submitUrl.value, payload.value) + } + } catch (e: unknown) { + form.errorMessage = getMessageFromError(e) + form.readonly = false + throw e + } + } + + const setInitialFormValues = (data: RedisConfigurationResponse) => { + // merge the default values with the data + form.fields.config = Object.assign( + {}, + form.fields.config, + s.removeNullValues(data.config), // remove null values if data, so they can be replaced with default values + ) + form.fields.config.sentinel_nodes = s.addIdToSentinelNodes(data.config.sentinel_nodes ?? []) + form.fields.config.cluster_nodes = s.addIdToClusterNodes(data.config.cluster_nodes ?? []) + form.fields.name = data.name + form.fields.type = data.type + redisType.value = getRedisType(data) + initialPayload.value = JSON.parse(JSON.stringify(payload.value)) + } + + return { + form, + canSubmit, + payload, + isEdit, + redisType, + redisTypeIsEnterprise, + formType, + fetchUrl, + submit, + setInitialFormValues, + } +} diff --git a/packages/entities/entities-redis-configurations/src/constants.ts b/packages/entities/entities-redis-configurations/src/constants.ts new file mode 100644 index 0000000000..b0d1bc2b53 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/constants.ts @@ -0,0 +1,38 @@ +import { RedisType } from './types' +import type { ClusterNode, RedisConfigurationFormState, SentinelNode } from './types' + +export const DEFAULT_CLUSTER_NODE: Readonly = { + ip: '127.0.0.1', + port: 6379, +} + +export const DEFAULT_SENTINEL_NODE: Readonly = { + host: '127.0.0.1', + port: 6379, +} + +export const DEFAULT_REDIS_TYPE = RedisType.HOST_PORT_CE + +export const DEFAULT_FIELDS: Readonly = { + port: 6379, + host: '127.0.0.1', + database: 0, + username: '', + password: '', + ssl: false, + ssl_verify: false, + server_name: '', + connect_timeout: 2000, + send_timeout: 2000, + read_timeout: 2000, + sentinel_username: '', + sentinel_password: '', + keepalive_pool_size: 256, + keepalive_backlog: 0, + sentinel_master: '', + sentinel_nodes: [], + cluster_nodes: [], + cluster_max_redirections: 0, + connection_is_proxied: false, + timeout: 2000, +} diff --git a/packages/entities/entities-redis-configurations/src/global-components.d.ts b/packages/entities/entities-redis-configurations/src/global-components.d.ts new file mode 100644 index 0000000000..2f0048d672 --- /dev/null +++ b/packages/entities/entities-redis-configurations/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-redis-configurations/src/helpers.ts b/packages/entities/entities-redis-configurations/src/helpers.ts new file mode 100644 index 0000000000..ecb69be564 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/helpers.ts @@ -0,0 +1,86 @@ +import { v4 as uuidv4 } from 'uuid' + +import { DEFAULT_CLUSTER_NODE, DEFAULT_SENTINEL_NODE } from './constants' +import { PartialType, type ClusterNode, type Identifiable, type RedisConfigurationDTO, type RedisConfigurationFields, type SentinelNode } from './types' +import { RedisType } from './types' + +export const shallowCopyWithId = >(node: T): Identifiable => { + return { ...node, id: uuidv4() } +} + +export const shallowCopyWithoutId = (node: T): Omit => { + const { id, ...rest } = node + return rest +} + +export const genDefaultSentinelNode = () => shallowCopyWithId(DEFAULT_SENTINEL_NODE) + +export const genDefaultClusterNode = () => shallowCopyWithId(DEFAULT_CLUSTER_NODE) + +export const getRedisType = (fields: RedisConfigurationFields | RedisConfigurationDTO): RedisType => { + if (fields.type === PartialType.REDIS_CE) { + return RedisType.HOST_PORT_CE + } + + if (fields.config.sentinel_nodes?.length) { + return RedisType.SENTINEL + } + + if (fields.config.cluster_nodes?.length) { + return RedisType.CLUSTER + } + + return RedisType.HOST_PORT_EE +} + +export const mapRedisTypeToPartialType = (type: RedisType): PartialType => { + return type === RedisType.HOST_PORT_CE ? PartialType.REDIS_CE : PartialType.REDIS_EE +} + +export const standardize = { + int(value: string | number | undefined | null, defaultValue?: T): number | T { + if (value === undefined || value === null) { + return defaultValue as T + } + return parseInt(value.toString(), 10) + }, + + str(value: string | number | undefined | null, defaultValue?: T): string | T { + if (value === undefined || value === null || value === '') { + return defaultValue as T + } + return value.toString() + }, + + removeIdClusterNodes(nodes: Identifiable[]): ClusterNode[] { + return nodes.map(node => ({ + ...shallowCopyWithoutId(node), + port: standardize.int(node.port)!, + })) + }, + + removeIdFromSentinelNodes(nodes: Identifiable[]): SentinelNode[] { + return nodes.map(node => ({ + ...shallowCopyWithoutId(node), + port: standardize.int(node.port)!, + })) + }, + + addIdToClusterNodes(nodes: ClusterNode[]): Identifiable[] { + return nodes.map(shallowCopyWithId) + }, + + addIdToSentinelNodes(nodes: SentinelNode[]): Identifiable[] { + return nodes.map(shallowCopyWithId) + }, + + removeNullValues(obj: Record): Record { + const newObj = { ...obj } + for (const key in newObj) { + if (newObj[key] === null) { + delete newObj[key] + } + } + return newObj + }, +} diff --git a/packages/entities/entities-redis-configurations/src/index.ts b/packages/entities/entities-redis-configurations/src/index.ts new file mode 100644 index 0000000000..f1cde3780e --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/index.ts @@ -0,0 +1,33 @@ +import RedisConfigurationForm from './components/RedisConfigurationForm.vue' +import RedisConfigurationList from './components/RedisConfigurationList.vue' +import RedisConfigurationConfigCard from './components/RedisConfigurationConfigCard.vue' +import LinkedPlugins from './components/LinkedPluginList.vue' +import DeleteWarningModal from './components/DeleteWarningModal.vue' + +export { + RedisConfigurationForm, + RedisConfigurationList, + RedisConfigurationConfigCard, + LinkedPlugins, + DeleteWarningModal, +} + +export * from './types' + +import * as helpers from './helpers' + +export { helpers } + +import * as constants from './constants' + +export { constants } + +import { + useLinkedPlugins, + useLinkedPluginsFetcher, +} from './composables/useLinkedPlugins' + +export const composables = { + useLinkedPlugins, + useLinkedPluginsFetcher, +} diff --git a/packages/entities/entities-redis-configurations/src/locales/en.json b/packages/entities/entities-redis-configurations/src/locales/en.json new file mode 100644 index 0000000000..7d82090081 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/locales/en.json @@ -0,0 +1,239 @@ +{ + "actions": { + "create": "New Redis Configuration", + "copy_id": "Copy ID", + "copy_json": "Copy JSON", + "edit": "Edit", + "delete": "Delete", + "done": "Done", + "view": "View Details", + "loading": "Loading...", + "view_plugin": "View Plugin", + "close": "Close" + }, + "search": { + "placeholder": "Filter by name", + "no_results": "No results found" + }, + "delete": { + "title": "Delete Redis configuration", + "description": "You’re about to delete this item. Are you sure you want to proceed?", + "warning": "To delete this configuration, first remove it from all associated plugins." + }, + "errors": { + "copy": "Failed to copy to clipboard", + "general": "Redis configuration could not be retrieved", + "delete": "The redis configuration could not be deleted at this time." + }, + "copy": { + "success": "Copied {val} to clipboard" + }, + "form": { + "sections": { + "type": { + "title": "Redis type", + "description": "Both Enterprise and Open Source plugins support Redis. Enterprise plugins can connect to a standalone Redis instance (host/port), Cluster, or Sentinel, while Open Source plugins support only a simplified host/port configuration." + }, + "general": { + "title": "General information", + "description": "Name your Redis configuration." + }, + "connection": { + "title": "Connection settings", + "description": "Define the Redis server’s host, port, authentication, and timeout options for establishing a connection." + }, + "cluster": { + "title": "Cluster configuration", + "description": "Enables data sharding and distribution across multiple Redis nodes, allowing for scalability and load balancing." + }, + "tls": { + "title": "TLS settings", + "description": "Configure secure connections to Redis, including SSL and verification options." + }, + "keepalive": { + "title": "Keepalive configuration", + "description": "Keepalive reuses active connections to Redis, improving performance and efficiency." + }, + "read_write_configuration": { + "title": "Read/Write configuration", + "description": "Set timeouts for reading from and writing to the Redis server to control data transmission reliability." + }, + "sentinel_configuration": { + "title": "Sentinel configuration", + "description": "Manages Redis failover and high availability by connecting to Sentinel nodes that monitor and switch masters." + } + }, + "fields": { + "type": { + "label": "Redis type" + }, + "name": { + "label": "Name", + "placeholder": "Enter unique name" + }, + "host": { + "label": "Host", + "tooltip": "A string representing a host name, such as example.com." + }, + "port": { + "label": "Port", + "tooltip": "An integer representing a port number between 0 and 65535, inclusive." + }, + "database": { + "label": "Database", + "tooltip": "Database to use for the Redis connection." + }, + "password": { + "label": "Password", + "tooltip": "Password to use for Redis connections. If undefined, no AUTH commands are sent to Redis." + }, + "username": { + "label": "Username", + "tooltip": "Username to use for Redis connections. If undefined, ACL authentication won't be performed. This requires Redis v6.0.0+. To be compatible with Redis v5.x.y, you can set it to `default`." + }, + "ssl": { + "label": "SSL", + "description": "If set to true, uses SSL to connect to Redis.", + "tooltip": "If set to true, uses SSL to connect to Redis." + }, + "ssl_verify": { + "label": "SSL Verify", + "description": "If set to true, verifies the validity of the server SSL certificate. If setting this parameter, also configure lua_ssl_trusted_certificate in kong. conf to specify the CA (or server) certificate used by your Redis server. You may also need to configure lua_ss1_verify_depth accordingly.", + "tooltip": "If set to true, verifies the validity of the server SSL certificate. If setting this parameter, also configure lua_ssl_trusted_certificate in kong.conf to specify the CA (or server) certificate used by your Redis server. You may also need to configure lua_ssl_verify_depth accordingly." + }, + "server_name": { + "label": "Server name", + "tooltip": "A string representing an SNI (server name indication) value for TLS." + }, + "keepalive_backlog": { + "label": "Keepalive backlog", + "tooltip": "Limits the total number of opened connections for a pool. If the connection pool is full, connection queues above the limit go into the backlog queue. If the backlog queue is full, subsequent connect operations fail and return `nil`. Queued operations (subject to set timeouts) resume once the number of connections in the pool is less than `keepalive_pool_size`. If latency is high or throughput is low, try increasing this value. Empirically, this value is larger than `keepalive_pool_size`." + }, + "keepalive_pool_size": { + "label": "Keepalive pool size", + "tooltip": "The size limit for every cosocket connection pool associated with every remote server, per worker process. If neither `keepalive_pool_size` nor `keepalive_backlog` is specified, no pool is created. If `keepalive_pool_size` isn't specified but `keepalive_backlog` is specified, then the pool uses the default value. Try to increase (e.g. 512) this value if latency is high or throughput is low." + }, + "read_timeout": { + "label": "Read timeout", + "tooltip": "An integer representing a timeout in milliseconds. Must be between 0 and 2^31-2." + }, + "send_timeout": { + "label": "Send timeout", + "tooltip": "An integer representing a timeout in milliseconds. Must be between 0 and 2^31-2." + }, + "connect_timeout": { + "label": "Connect timeout", + "tooltip": "An integer representing a timeout in milliseconds. Must be between 0 and 2^31-2." + }, + "sentinel_master": { + "label": "Sentinel master", + "tooltip": "Sentinel master to use for Redis connections. Defining this value implies using Redis Sentinel." + }, + "sentinel_role": { + "label": "Sentinel role", + "tooltip": "Sentinel role to use for Redis connections when the `redis` strategy is defined. Defining this value implies using Redis Sentinel." + }, + "sentinel_username": { + "label": "Sentinel username", + "tooltip": "Sentinel username to authenticate with a Redis Sentinel instance. If undefined, ACL authentication won't be performed. This requires Redis v6.2.0+." + }, + "sentinel_password": { + "label": "Sentinel password", + "tooltip": "Sentinel password to authenticate with a Redis Sentinel instance. If undefined, no AUTH commands are sent to Redis Sentinels." + }, + "cluster_node_ip": { + "label": "IP", + "tooltip": "A string representing a host name, such as example.com." + }, + "cluster_node_port": { + "label": "Port", + "tooltip": "An integer representing a port number between 0 and 65535, inclusive." + }, + "cluster_max_redirections": { + "label": "Cluster max redirections", + "tooltip": "Maximum retry attempts for redirection." + }, + "timeout": { + "label": "Timeout", + "tooltip": "redis schema field `timeout` is deprecated, use `connect_timeout`, `send_timeout` and `read_timeout`" + }, + "connection_is_proxied": { + "label": "Connection is proxied", + "tooltip": "If the connection to Redis is proxied (e.g. Envoy), set it `true`. Set the `host` and `port` to point to the proxy address." + }, + "cluster_nodes": { + "title": "Cluster nodes", + "tooltip": "Cluster addresses to use for Redis connections when the `redis` strategy is defined. Defining this field implies using a Redis Cluster. The minimum length of the array is 1 element.", + "add_button": "New item" + }, + "sentinel_nodes": { + "title": "Sentinel nodes", + "tooltip": "Sentinel node addresses to use for Redis connections when the `redis` strategy is defined. Defining this field implies using a Redis Sentinel. The minimum length of the array is 1 element.", + "add_button": "New item" + }, + "sentinel_node_host": { + "label": "Host", + "tooltip": "A string representing a host name, such as example.com." + }, + "sentinel_node_port": { + "label": "Port", + "tooltip": "An integer representing a port number between 0 and 65535, inclusive." + } + }, + "options": { + "type": { + "host_port": "Host/Port", + "cluster": "Cluster", + "sentinel": "Sentinel", + "open_source": "Open Source", + "enterprise": "Enterprise", + "suffix_open_source": " (Open Source)", + "suffix_enterprise": " (Enterprise)" + }, + "sentinel_role": { + "master": "master", + "slave": "slave", + "any": "any" + } + }, + "edit_warning_modal": { + "title": "Edit Redis configuration", + "description": "This Redis configuration is connected to {pluginCount}. Any updates will take effect across all associated instances.", + "plugin_count": "{count} plugin(s)", + "confirm": "Confirm" + } + }, + "linked_plugins_modal": { + "title": "Associated plugins ({count})", + "headers": { + "plugin": "Plugin", + "instance_name": "Name" + } + }, + "redis": { + "title": "Redis Configurations", + "empty_state": { + "title": "Configure a Redis Configuration", + "description": "Set up shared Redis configurations for your gateway plugins to store and retrieve data — like counters or other data — needed during request processing." + } + }, + "list": { + "action": "New configuration", + "table_headers": { + "name": "Name", + "type": "Type", + "plugins": "Plugins" + }, + "empty_state": { + "description": "Set up shared Redis configurations for your gateway plugins to store and retrieve data — like counters or other data — needed during request processing.", + "feature_1": { + "title": "Store your Redis configurations", + "description": "Define a Redis configuration once and use it across multiple plugins." + }, + "feature_2": { + "title": "Sync across plugins", + "description": "Edits to your configuration will be cascaded automatically to all associated plugins." + } + } + } +} diff --git a/packages/entities/entities-redis-configurations/src/partials-endpoints.ts b/packages/entities/entities-redis-configurations/src/partials-endpoints.ts new file mode 100644 index 0000000000..bdb8c02ee6 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/partials-endpoints.ts @@ -0,0 +1,23 @@ +const konnectBaseApiUrl = '/v2/control-planes/{controlPlaneId}/core-entities' +const KMBaseApiUrl = '/{workspace}' + +export default { + list: { + konnect: `${konnectBaseApiUrl}/partials`, + kongManager: `${KMBaseApiUrl}/partials`, + }, + form: { + konnect: { + create: `${konnectBaseApiUrl}/partials`, + edit: `${konnectBaseApiUrl}/partials/{id}`, + }, + kongManager: { + create: `${KMBaseApiUrl}/partials`, + edit: `${KMBaseApiUrl}/partials/{id}`, + }, + }, + links: { + konnect: `${konnectBaseApiUrl}/partials/{id}/links`, + kongManager: `${KMBaseApiUrl}/partials/{id}/links`, + }, +} diff --git a/packages/entities/entities-redis-configurations/src/types/index.ts b/packages/entities/entities-redis-configurations/src/types/index.ts new file mode 100644 index 0000000000..4ecae0547a --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/types/index.ts @@ -0,0 +1,5 @@ +export * from './redis-configuration' +export * from './redis-configuration-form' +export * from './redis-configuration-list' +export * from './redis-configuration-config' +export * from './redis-configuration-linked-plugins' diff --git a/packages/entities/entities-redis-configurations/src/types/redis-configuration-config.ts b/packages/entities/entities-redis-configurations/src/types/redis-configuration-config.ts new file mode 100644 index 0000000000..00752e7c41 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/types/redis-configuration-config.ts @@ -0,0 +1,10 @@ +import type { + KonnectBaseEntityConfig, + KongManagerBaseEntityConfig, +} from '@kong-ui-public/entities-shared' + +/** Konnect redis configuration entity config */ +export interface KonnectRedisConfigurationEntityConfig extends KonnectBaseEntityConfig { } + +/** Kong Manager redis configuration entity config */ +export interface KongManagerRedisConfigurationEntityConfig extends KongManagerBaseEntityConfig { } diff --git a/packages/entities/entities-redis-configurations/src/types/redis-configuration-form.ts b/packages/entities/entities-redis-configurations/src/types/redis-configuration-form.ts new file mode 100644 index 0000000000..771c1b006d --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/types/redis-configuration-form.ts @@ -0,0 +1,42 @@ + +import type { KonnectBaseFormConfig, KongManagerBaseFormConfig } from '@kong-ui-public/entities-shared' +import type { ClusterNode, Identifiable, PartialType, SentinelNode } from './redis-configuration' + +export interface KonnectRedisConfigurationFormConfig extends KonnectBaseFormConfig { } +export interface KongManagerRedisConfigurationFormConfig extends KongManagerBaseFormConfig { } + + +export interface RedisConfigurationFields { + name: string + type: PartialType + config: { + cluster_max_redirections: number + cluster_nodes: Identifiable[] + connect_timeout: number + connection_is_proxied: boolean + database: number + host?: string + keepalive_backlog: number + keepalive_pool_size: number + password: string + port?: number + read_timeout: number + send_timeout: number + sentinel_master?: string + sentinel_nodes: Identifiable[] + sentinel_password: string + sentinel_role?: 'master' | 'slave' | 'any' + sentinel_username: string + server_name?: string + ssl_verify: boolean + ssl: boolean + timeout?: number + username: string + } +} + +export interface RedisConfigurationFormState { + fields: RedisConfigurationFields + readonly: boolean + errorMessage: string +} diff --git a/packages/entities/entities-redis-configurations/src/types/redis-configuration-linked-plugins.ts b/packages/entities/entities-redis-configurations/src/types/redis-configuration-linked-plugins.ts new file mode 100644 index 0000000000..42c185b18f --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/types/redis-configuration-linked-plugins.ts @@ -0,0 +1,11 @@ +export type RedisConfigurationLinkedPlugin = { + id: string + name: string + instance_name?: string +} + +export type RedisConfigurationLinkedPluginsResponse = { + next: string | null + count: number + data: RedisConfigurationLinkedPlugin[] +} diff --git a/packages/entities/entities-redis-configurations/src/types/redis-configuration-list.ts b/packages/entities/entities-redis-configurations/src/types/redis-configuration-list.ts new file mode 100644 index 0000000000..b13032091f --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/types/redis-configuration-list.ts @@ -0,0 +1,34 @@ +import type { KonnectBaseTableConfig, KongManagerBaseTableConfig, FilterSchema } from '@kong-ui-public/entities-shared' +import type { RouteLocationRaw } from 'vue-router' + +export interface BaseRedisConfigurationListConfig { + /** Route for creating a redis configuration */ + createRoute: RouteLocationRaw + /** A function that returns the route for viewing a redis configuration */ + getViewRoute: (id: string) => RouteLocationRaw + /** A function that returns the route for editing a redis configuration */ + getEditRoute: (id: string) => RouteLocationRaw +} + +/** Konnect redis configuration list config */ +export interface KonnectRedisConfigurationListConfig extends KonnectBaseTableConfig, BaseRedisConfigurationListConfig { } + +/** Kong Manager redis configuration list config */ +export interface KongManagerRedisConfigurationListConfig extends KongManagerBaseTableConfig, BaseRedisConfigurationListConfig { + /** FilterSchema for fuzzy match */ + filterSchema?: FilterSchema +} + +export interface EntityRow extends Record { + id: string + name: string +} + +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-redis-configurations/src/types/redis-configuration.ts b/packages/entities/entities-redis-configurations/src/types/redis-configuration.ts new file mode 100644 index 0000000000..3bb1ac1d33 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/types/redis-configuration.ts @@ -0,0 +1,60 @@ +export enum RedisType { + HOST_PORT_CE, + HOST_PORT_EE, + SENTINEL, + CLUSTER, +} + +export enum PartialType { + REDIS_CE = 'redis-ce', + REDIS_EE = 'redis-ee', +} + +export type SentinelNode = { + host: string + port: number +} + +export type ClusterNode = { + ip: string + port: number +} + +export type RedisConfigurationDTO = { + name: string + type: PartialType + config: RedisConfigurationConfigDTO +} + +export type RedisConfigurationConfigDTO = { + cluster_max_redirections: number | null + cluster_nodes: ClusterNode[] | null + connect_timeout: number | null + connection_is_proxied: boolean | null + database: number | null + host: string | null + keepalive_backlog: number | null + keepalive_pool_size: number | null + password: string | null + port: number | null + timeout: number | null + read_timeout: number | null + send_timeout: number | null + sentinel_master: string | null + sentinel_nodes: SentinelNode[] | null + sentinel_password: string | null + sentinel_role: string | null + sentinel_username: string | null + server_name: string | null + ssl_verify: boolean | null + ssl: boolean | null + username: string | null +} + +export type RedisConfigurationResponse = RedisConfigurationDTO & { + created_at: string + id: string + updated_at: string +} + +export type Identifiable = T & { id: string } diff --git a/packages/entities/entities-redis-configurations/tsconfig.build.json b/packages/entities/entities-redis-configurations/tsconfig.build.json new file mode 100644 index 0000000000..577de9d6ae --- /dev/null +++ b/packages/entities/entities-redis-configurations/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-redis-configurations/tsconfig.json b/packages/entities/entities-redis-configurations/tsconfig.json new file mode 100644 index 0000000000..e34e90e4e4 --- /dev/null +++ b/packages/entities/entities-redis-configurations/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-redis-configurations/vite.config.ts b/packages/entities/entities-redis-configurations/vite.config.ts new file mode 100644 index 0000000000..9ca518db9b --- /dev/null +++ b/packages/entities/entities-redis-configurations/vite.config.ts @@ -0,0 +1,50 @@ +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-redis-configurations' +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`, + }, + rollupOptions: { + external: [ + '@kong-ui-public/entities-shared/dist/style.css', + '@kong-ui-public/entities-vaults/dist/style.css', + '@kong-ui-public/entities-plugins', + '@kong-ui-public/entities-vaults', + ], + output: { + globals: { + '@kong-ui-public/entities-plugins': 'kong-ui-public-entities-plugins', + '@kong-ui-public/entities-vaults': 'kong-ui-public-entities-vaults', + }, + }, + }, + }, + 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-redis-configurations/sandbox` directory, +// unset the lib, rollupOptions.external and rollupOptions.output.globals properties +if (process.env.USE_SANDBOX) { + config.build.lib = undefined + config.build.rollupOptions.external = undefined + config.build.rollupOptions.output.global = undefined +} + +export default config diff --git a/packages/entities/entities-shared/src/components/entity-base-config-card/ConfigCardDisplay.vue b/packages/entities/entities-shared/src/components/entity-base-config-card/ConfigCardDisplay.vue index 8ffbcf40cc..94c2c40ddd 100644 --- a/packages/entities/entities-shared/src/components/entity-base-config-card/ConfigCardDisplay.vue +++ b/packages/entities/entities-shared/src/components/entity-base-config-card/ConfigCardDisplay.vue @@ -82,6 +82,9 @@ export interface PropList { plugin?: RecordItem[] } +export type CodeFormat = 'yaml' | 'json' | 'terraform' +export type Format = 'structured' | CodeFormat + const props = defineProps({ /** The base konnect or kongManger config. Pass additional config props in the shared entity component as needed. */ config: { @@ -100,7 +103,7 @@ const props = defineProps({ default: () => null, }, format: { - type: String, + type: String as PropType, required: false, default: 'structured', validator: (val: string) => ['structured', 'yaml', 'json', 'terraform'].includes(val), @@ -122,6 +125,14 @@ const props = defineProps({ required: false, default: '', }, + /** + * A function to format the entity record before displaying it in the code block. + */ + codeBlockRecordFormatter: { + type: Function as PropType<(entityRecord: Record, format: CodeFormat) => Record>, + required: false, + default: (entityRecord: Record) => entityRecord, + }, }) const slots = useSlots() @@ -132,7 +143,11 @@ const entityRecord = computed((): PropType> => { if (!props.record) { return props.record } - const processedRecord = JSON.parse(JSON.stringify(props.record)) + let record = props.record + if (props.codeBlockRecordFormatter) { + record = props.codeBlockRecordFormatter(record, props.format as CodeFormat) + } + const processedRecord = JSON.parse(JSON.stringify(record)) // remove dates from JSON/YAML config [KHCP-9837] delete processedRecord.created_at delete processedRecord.updated_at diff --git a/packages/entities/entities-shared/src/components/entity-base-config-card/EntityBaseConfigCard.vue b/packages/entities/entities-shared/src/components/entity-base-config-card/EntityBaseConfigCard.vue index 235bde2bea..8242fb8407 100644 --- a/packages/entities/entities-shared/src/components/entity-base-config-card/EntityBaseConfigCard.vue +++ b/packages/entities/entities-shared/src/components/entity-base-config-card/EntityBaseConfigCard.vue @@ -75,6 +75,7 @@ class="config-card-details-section" > any>, + required: false, + default: (data: any) => data, + }, + /** + * A function to format the entity record before displaying it in the code block. + */ + codeBlockRecordFormatter: { + type: Function as PropType<(entityRecord: Record, format: CodeFormat) => Record>, + required: false, + default: (entityRecord: Record) => entityRecord, + }, /** * Boolean to control card title visibility. */ @@ -249,7 +267,7 @@ if (props.config.app === 'konnect') { }) } -const configFormat = ref('structured') +const configFormat = ref('structured') const handleChange = (payload: any): void => { configFormat.value = payload?.value @@ -463,6 +481,8 @@ onBeforeMount(async () => { } else { throw new Error(t('errors.dataKeyUndefined', { dataKey: props.dataKey })) } + } else if (props.recordResolver) { + record.value = { ...props.recordResolver(data) } } else { record.value = { ...data } } diff --git a/packages/entities/entities-shared/src/components/entity-base-form/EntityBaseForm.vue b/packages/entities/entities-shared/src/components/entity-base-form/EntityBaseForm.vue index 8af74c43de..5c99abd760 100644 --- a/packages/entities/entities-shared/src/components/entity-base-form/EntityBaseForm.vue +++ b/packages/entities/entities-shared/src/components/entity-base-form/EntityBaseForm.vue @@ -40,43 +40,50 @@ :message="errorMessage" /> - -
- - - {{ t('baseForm.actions.viewConfiguration') }} - - - {{ t('baseForm.actions.cancel') }} - - - {{ t('baseForm.actions.save') }} - - -
+ +
+ + + {{ t('baseForm.actions.viewConfiguration') }} + + + {{ t('baseForm.actions.cancel') }} + + + {{ t('baseForm.actions.save') }} + + +
+ { max-width: $kui-breakpoint-desktop; width: 100%; - .form-actions { - align-items: center; - display: flex; - justify-content: flex-end; - margin-top: $kui-space-80; - - :deep(.k-button) { - &:last-of-type, - &:nth-last-of-type(2) { - margin-left: $kui-space-60; - } - } - } - & :deep(.k-slideout-title) { color: $kui-color-text !important; font-size: $kui-font-size-70 !important; @@ -366,4 +372,18 @@ onBeforeMount(async () => { font-weight: $kui-font-weight-semibold !important; } } + +.form-actions { + align-items: center; + display: flex; + justify-content: flex-end; + margin-top: $kui-space-80; + + :deep(.k-button) { + &:last-of-type, + &:nth-last-of-type(2) { + margin-left: $kui-space-60; + } + } +} diff --git a/packages/entities/entities-shared/src/types/entity-base-config-card.ts b/packages/entities/entities-shared/src/types/entity-base-config-card.ts index da7fec3bf4..0855c9a40d 100644 --- a/packages/entities/entities-shared/src/types/entity-base-config-card.ts +++ b/packages/entities/entities-shared/src/types/entity-base-config-card.ts @@ -19,6 +19,7 @@ export enum SupportedEntityType { Upstream = 'upstream', Target = 'target', Vault = 'vault', + RedisConfiguration = 'redis_configuration', // Use this for any entity type that is not supported by terraform // If entityType is 'other' terraform scripts will not be available // Note: This is currently only supported by EntityBaseForm not EntityBaseConfigCard!! diff --git a/packages/entities/entities-shared/src/types/entity-delete-modal.ts b/packages/entities/entities-shared/src/types/entity-delete-modal.ts index baaeb7340d..d54aad29f3 100644 --- a/packages/entities/entities-shared/src/types/entity-delete-modal.ts +++ b/packages/entities/entities-shared/src/types/entity-delete-modal.ts @@ -24,4 +24,5 @@ export enum EntityTypes { Target = 'target', Policy = 'policy', Secret = 'secret', + RedisConfiguration = 'redis configuration', } diff --git a/packages/entities/entities-vaults/src/components/VaultSecretPickerProvider.vue b/packages/entities/entities-vaults/src/components/VaultSecretPickerProvider.vue index c2faa00bd5..42dddcf5ec 100644 --- a/packages/entities/entities-vaults/src/components/VaultSecretPickerProvider.vue +++ b/packages/entities/entities-vaults/src/components/VaultSecretPickerProvider.vue @@ -6,8 +6,8 @@ >