diff --git a/packages/entities/entities-plugins/README.md b/packages/entities/entities-plugins/README.md index 8d9ec8b01b..8711178ee5 100644 --- a/packages/entities/entities-plugins/README.md +++ b/packages/entities/entities-plugins/README.md @@ -37,11 +37,13 @@ yarn add @kong-ui-public/entities-plugins Import the component(s) in your host application as well as the package styles ```ts -import { PluginList, PluginConfigCard } from '@kong-ui-public/entities-plugins' +import { PluginList, PluginSelect, PluginForm, PluginConfigCard } from '@kong-ui-public/entities-plugins' import '@kong-ui-public/entities-plugins/dist/style.css' ``` ## Individual component documentation - [``](docs/plugin-list.md) +- [``](docs/plugin-select.md) +- [``](docs/plugin-form.md) - [``](docs/plugin-config-card.md) diff --git a/packages/entities/entities-plugins/docs/plugin-form.md b/packages/entities/entities-plugins/docs/plugin-form.md new file mode 100644 index 0000000000..bbcda141e0 --- /dev/null +++ b/packages/entities/entities-plugins/docs/plugin-form.md @@ -0,0 +1,113 @@ +# PluginForm.vue + +A form component for Plugins. + +- [Requirements](#requirements) +- [Usage](#usage) + - [Install](#install) + - [Props](#props) + - [Events](#events) + - [Usage example](#usage-example) +- [TypeScript interfaces](#typescript-interfaces) + +## Requirements + +- `vue` and `vue-router` must be initialized in the host application +- `@kong/kongponents` must be added as a dependency in the host application, globally available via the Vue Plugin installation, and the package's style imports must be added in the app entry file. [See here for instructions on installing Kongponents](https://kongponents.konghq.com/#globally-install-all-kongponents). +- `@kong-ui-public/i18n` must be available as a `dependency` in the host application. +- `axios` must be installed as a dependency in the host application + +## Usage + +### Install + +[See instructions for installing the `@kong-ui-public/entities-plugins` package.](../README.md#install) + +### Props + +#### `config` + +- type: `Object as PropType` +- required: `true` +- default: `undefined` +- properties: + - `app`: + - type: `'konnect' | 'kongManager'` + - required: `true` + - default: `undefined` + - App name. + + - `apiBaseUrl`: + - type: `string` + - required: `true` + - default: `undefined` + - Base URL for API requests. + + - `requestHeaders`: + - type: `RawAxiosRequestHeaders | AxiosHeaders` + - required: `false` + - default: `undefined` + - Additional headers to send with all Axios requests. + + - `cancelRoute`: + - type: `RouteLocationRaw` + - required: `true` + - default: `undefined` + - Route to return to when canceling creation of a plugin. + + - `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: `false` + - default: `''` + - Id of the entity to bind the plugin to on creation. + +The base konnect or kongManger config. + +#### `pluginId` + +- type: `String` +- required: `false` +- default: `''` + +If showing the `Edit` type form, the ID of the plugin. + +### 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 plugin object. + +### Usage example + +Please refer to the [sandbox](../sandbox/pages/PluginListPage.vue). The form is accessible by clicking the `Edit` action of an existing plugin or after selecting a plugin when creating a new one. + +## TypeScript interfaces + +TypeScript interfaces [are available here](https://github.com/Kong/public-ui-components/blob/main/packages/entities/entities-plugins/src/types/plugin-form.ts) and can be directly imported into your host application. The following type interfaces are available for import: + +```ts +import type { + KonnectPluginFormConfig, + KongManagerPluginFormConfig, +} from '@kong-ui-public/entities-plugins' +``` diff --git a/packages/entities/entities-plugins/docs/plugin-select.md b/packages/entities/entities-plugins/docs/plugin-select.md new file mode 100644 index 0000000000..643c0b1658 --- /dev/null +++ b/packages/entities/entities-plugins/docs/plugin-select.md @@ -0,0 +1,193 @@ +# PluginSelect.vue + +A grid component for selecting Plugins. + +- [Requirements](#requirements) +- [Usage](#usage) + - [Install](#install) + - [Props](#props) + - [Events](#events) + - [Usage example](#usage-example) +- [TypeScript interfaces](#typescript-interfaces) + +## Requirements + +- `vue` and `vue-router` must be initialized in the host application +- `@kong/kongponents` must be added as a dependency in the host application, globally available via the Vue Plugin installation, and the package's style imports must be added in the app entry file. [See here for instructions on installing Kongponents](https://kongponents.konghq.com/#globally-install-all-kongponents). +- `@kong-ui-public/i18n` must be available as a `dependency` in the host application. +- `axios` must be installed as a dependency in the host application + +## Usage + +### Install + +[See instructions for installing the `@kong-ui-public/entities-plugins` package.](../README.md#install) + +### Props + +#### `config` + +- type: `Object as PropType` +- required: `true` +- properties: + - `app`: + - type: `'konnect' | 'kongManager'` + - required: `true` + - default: `undefined` + - App name. + + - `apiBaseUrl`: + - type: `string` + - required: `true` + - default: `undefined` + - Base URL for API requests. + + - `requestHeaders`: + - type: `RawAxiosRequestHeaders | AxiosHeaders` + - required: `false` + - default: `undefined` + - Additional headers to send with all Axios requests. + + - `getCreateRoute`: + - type: `(plugin: string) => RouteLocationRaw` + - required: `true` + - default: `undefined` + - A function that returns the route for creating a specific plugin type. + + - `createCustomRoute`: + - type: RouteLocationRaw + - required: `false` + - default: `undefined` + - The route for creating a custom plugin. + + - `getCustomEditRoute`: + - type: `(plugin: string) => RouteLocationRaw` + - required: `false` + - default: `undefined` + - A function that returns the route for editing a custom plugin. + + - `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: `false` + - default: `null` + - Current entity id if the PluginSelect is being launched from the plugins tab on a consumer, consumer group, gateway service, or route detail page. + + - `entityType`: + - type: `'consumers' | 'consumer_groups' | 'services' | 'routes'` + - required: `false` + - default: `null` + - Current entity type if the PluginSelect is being launched from the plugins tab on a consumer, consumer group, gateway service, or route detail page. + +The base konnect or kongManger config. + +#### `canCreateCustomPlugin` + +- 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 custom plugin. + +#### `canDeleteCustomPlugin` + +- 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 custom plugin. + +#### `canEditCustomPlugin` + +- 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 custom plugin. + +#### `navigateOnClick` + +- type: `boolean` +- required: `false` +- default: `true` + +If false, let consuming component handle event when clicking on a plugin. Used in conjunction with `@plugin-clicked` event. + +#### `availableOnServer` + +- type: `boolean` +- required: `false` +- default: `false` + +Checks the kong config plugins.available_on_server and if true, then it will not show plugins from PluginMeta that are outside of the available_on_server array. + +#### `ignoredPlugins` + +- type: `string[]` +- required: `false` +- default: '[]' + +An array of the plugin names. These are plugins that should not be displayed. + +#### `disabledPlugins` + +- type: `DisabledPlugin` +- required: `false` +- default: `{}` + +Plugins that should be disabled and their disabled messages. +Example: + +```json +{ + 'acl': 'ACL is not supported for this entity type', +} +``` + +#### `pluginsPerRow` + +- type: `number` +- required: `false` +- default: `4` + +Number of plugins to always have visible (never will be collapsed). + +### Events + +#### plugin-clicked + +An `@plugin-clicked` event is emitted when a plugin in the selection grid is clicked. The event payload is the plugin object. + +#### loading + +A `@loading` event is emitted when loading state changes. The event payload is a boolean. + +#### delete-custom:success + +A `@delete-custom:success` event is emitted when custom plugin is successfully deleted. The event payload is the deleted plugin's name. + +### Usage example + +Please refer to the [sandbox](../sandbox/pages/PluginListPage.vue). The form is accessible by clicking the `+ New Plugin` button. + +## TypeScript interfaces + +TypeScript interfaces [are available here](https://github.com/Kong/public-ui-components/blob/main/packages/entities/entities-plugins/src/types/plugin-form.ts) and can be directly imported into your host application. The following type interfaces are available for import: + +```ts +import type { + KonnectPluginFormConfig, + KongManagerPluginFormConfig, +} from '@kong-ui-public/entities-plugins' +``` diff --git a/packages/entities/entities-plugins/fixtures/mockData.ts b/packages/entities/entities-plugins/fixtures/mockData.ts index 9f7f5a3b10..72a763fd64 100644 --- a/packages/entities/entities-plugins/fixtures/mockData.ts +++ b/packages/entities/entities-plugins/fixtures/mockData.ts @@ -72,3 +72,421 @@ export const paginate = ( offset, } } + +export const kmLimitedAvailablePlugins = { + plugins: { + enabled_in_cluster: [], + available_on_server: { + 'hmac-auth': { + version: '3.6.0', + priority: 1030, + }, + 'ip-restriction': { + version: '3.6.0', + priority: 990, + }, + }, + }, +} + +export const firstShownPlugin = 'basic-auth' +export const kmAvailablePlugins = { + plugins: { + enabled_in_cluster: [], + available_on_server: { + 'hmac-auth': { + version: '3.6.0', + priority: 1030, + }, + [firstShownPlugin]: { + version: '3.6.0', + priority: 1100, + }, + 'ip-restriction': { + version: '3.6.0', + priority: 990, + }, + 'request-transformer': { + version: '3.6.0', + priority: 801, + }, + 'response-transformer': { + version: '3.6.0', + priority: 800, + }, + 'request-size-limiting': { + version: '3.6.0', + priority: 951, + }, + 'rate-limiting': { + version: '3.6.0', + priority: 910, + }, + 'response-ratelimiting': { + version: '3.6.0', + priority: 900, + }, + syslog: { + version: '3.6.0', + priority: 4, + }, + loggly: { + version: '3.6.0', + priority: 6, + }, + datadog: { + version: '3.6.0', + priority: 10, + }, + 'ldap-auth': { + version: '3.6.0', + priority: 1200, + }, + statsd: { + version: '3.6.0', + priority: 11, + }, + 'bot-detection': { + version: '3.6.0', + priority: 2500, + }, + 'aws-lambda': { + version: '3.6.0', + priority: 750, + }, + 'request-termination': { + version: '3.6.0', + priority: 2, + }, + prometheus: { + version: '3.6.0', + priority: 13, + }, + 'proxy-cache': { + version: '3.6.0', + priority: 100, + }, + session: { + version: '3.6.0', + priority: 1900, + }, + acme: { + version: '3.6.0', + priority: 1705, + }, + 'grpc-gateway': { + version: '3.6.0', + priority: 998, + }, + 'grpc-web': { + version: '3.6.0', + priority: 3, + }, + 'pre-function': { + version: '3.6.0', + priority: 1000000, + }, + 'post-function': { + version: '3.6.0', + priority: -1000, + }, + 'azure-functions': { + version: '3.6.0', + priority: 749, + }, + zipkin: { + version: '3.6.0', + priority: 100000, + }, + opentelemetry: { + version: '3.6.0', + priority: 14, + }, + 'key-auth': { + version: '3.6.0', + priority: 1250, + }, + 'http-log': { + version: '3.6.0', + priority: 12, + }, + 'file-log': { + version: '3.6.0', + priority: 9, + }, + 'udp-log': { + version: '3.6.0', + priority: 8, + }, + 'tcp-log': { + version: '3.6.0', + priority: 7, + }, + oauth2: { + version: '3.6.0', + priority: 1400, + }, + cors: { + version: '3.6.0', + priority: 2000, + }, + 'correlation-id': { + version: '3.6.0', + priority: 100001, + }, + acl: { + version: '3.6.0', + priority: 950, + }, + jwt: { + version: '3.6.0', + priority: 1450, + }, + 'tls-metadata-headers': { + version: '3.6.0', + priority: 996, + }, + saml: { + version: '3.6.0', + priority: 900, + }, + 'xml-threat-protection': { + version: '3.6.0', + priority: 999, + }, + 'jwe-decrypt': { + version: '3.6.0', + priority: 1999, + }, + 'oas-validation': { + version: '3.6.0', + priority: 850, + }, + 'tls-handshake-modifier': { + version: '3.6.0', + priority: 997, + }, + 'konnect-application-auth': { + version: '3.6.0', + priority: 950, + }, + 'websocket-validator': { + version: '3.6.0', + priority: 999, + }, + 'websocket-size-limit': { + version: '3.6.0', + priority: 999, + }, + jq: { + version: '3.6.0', + priority: 811, + }, + opa: { + version: '3.6.0', + priority: 920, + }, + 'application-registration': { + version: '3.6.0', + priority: 995, + }, + 'oauth2-introspection': { + version: '3.6.0', + priority: 1700, + }, + 'proxy-cache-advanced': { + version: '3.6.0', + priority: 100, + }, + 'openid-connect': { + version: '3.6.0', + priority: 1050, + }, + 'forward-proxy': { + version: '3.6.0', + priority: 50, + }, + canary: { + version: '3.6.0', + priority: 20, + }, + 'request-transformer-advanced': { + version: '3.6.0', + priority: 802, + }, + 'response-transformer-advanced': { + version: '3.6.0', + priority: 800, + }, + 'rate-limiting-advanced': { + version: '3.6.0', + priority: 910, + }, + 'ldap-auth-advanced': { + version: '3.6.0', + priority: 1200, + }, + 'statsd-advanced': { + version: '3.6.0', + priority: 11, + }, + 'route-by-header': { + version: '3.6.0', + priority: 850, + }, + 'jwt-signer': { + version: '3.6.0', + priority: 1020, + }, + 'vault-auth': { + version: '3.6.0', + priority: 1350, + }, + 'request-validator': { + version: '3.6.0', + priority: 999, + }, + 'mtls-auth': { + version: '3.6.0', + priority: 1600, + }, + 'graphql-proxy-cache-advanced': { + version: '3.6.0', + priority: 99, + }, + 'graphql-rate-limiting-advanced': { + version: '3.6.0', + priority: 902, + }, + degraphql: { + version: '3.6.0', + priority: 1500, + }, + 'route-transformer-advanced': { + version: '3.6.0', + priority: 780, + }, + 'kafka-log': { + version: '3.6.0', + priority: 5, + }, + 'kafka-upstream': { + version: '3.6.0', + priority: 751, + }, + 'exit-transformer': { + version: '3.6.0', + priority: 9999, + }, + 'key-auth-enc': { + version: '3.6.0', + priority: 1250, + }, + 'upstream-timeout': { + version: '3.6.0', + priority: 400, + }, + mocking: { + version: '3.6.0', + priority: -1, + }, + }, + disabled_on_server: {}, + }, +} + +// keep this list sorted alphabetically!!! +export const firstShownCustomPlugin = 'moesif' +export const customPluginNames = [ + firstShownCustomPlugin, + 'myplugin', + 'myplugin2', + 'myplugin3', + 'myplugin4', +] +export const kongPluginNames = [ + 'acl', + 'acme', + 'app-dynamics', + 'aws-lambda', + 'azure-functions', + firstShownPlugin, + 'bot-detection', + 'canary', + 'correlation-id', + 'cors', + 'datadog', + 'degraphql', + 'exit-transformer', + 'file-log', + 'forward-proxy', + 'graphql-proxy-cache-advanced', + 'graphql-rate-limiting-advanced', + 'grpc-gateway', + 'grpc-web', + 'hmac-auth', + 'http-log', + 'ip-restriction', + 'jq', + 'jwe-decrypt', + 'jwt', + 'kafka-log', + 'kafka-upstream', + 'key-auth', + 'ldap-auth', + 'ldap-auth-advanced', + 'loggly', + 'mocking', + 'mtls-auth', + 'oas-validation', + 'oauth2-introspection', + 'opa', + 'openid-connect', + 'opentelemetry', + 'post-function', + 'pre-function', + 'prometheus', + 'proxy-cache', + 'proxy-cache-advanced', + 'rate-limiting', + 'rate-limiting-advanced', + 'request-size-limiting', + 'request-termination', + 'request-transformer', + 'request-transformer-advanced', + 'request-validator', + 'response-ratelimiting', + 'response-transformer', + 'response-transformer-advanced', + 'route-by-header', + 'route-transformer-advanced', + 'saml', + 'session', + 'statsd', + 'statsd-advanced', + 'syslog', + 'tcp-log', + 'tls-handshake-modifier', + 'tls-metadata-headers', + 'udp-log', + // 'upstream-authenticator', + 'upstream-timeout', + 'websocket-size-limit', + 'websocket-validator', + 'xml-threat-protection', + 'zipkin', +] + +export const konnectLimitedAvailablePlugins = { + names: [ + 'hmac-auth', + 'ip-restriction', + ], +} + +export const konnectAvailablePlugins = { + names: [ + ...kongPluginNames, + ...customPluginNames, + ], +} diff --git a/packages/entities/entities-plugins/package.json b/packages/entities/entities-plugins/package.json index 798a7de3c9..3deee8e209 100644 --- a/packages/entities/entities-plugins/package.json +++ b/packages/entities/entities-plugins/package.json @@ -64,7 +64,7 @@ "extends": "../../../package.json" }, "distSizeChecker": { - "errorLimit": "1MB" + "errorLimit": "1.6MB" }, "dependencies": { "@kong-ui-public/copy-uuid": "workspace:^", @@ -72,6 +72,7 @@ "@kong-ui-public/entities-consumers": "workspace:^", "@kong-ui-public/entities-gateway-services": "workspace:^", "@kong-ui-public/entities-routes": "workspace:^", - "@kong-ui-public/entities-shared": "workspace:^" + "@kong-ui-public/entities-shared": "workspace:^", + "@kong/icons": "^1.7.8" } } diff --git a/packages/entities/entities-plugins/sandbox/index.ts b/packages/entities/entities-plugins/sandbox/index.ts index bc99ab7b01..7b1545a7e7 100644 --- a/packages/entities/entities-plugins/sandbox/index.ts +++ b/packages/entities/entities-plugins/sandbox/index.ts @@ -16,16 +16,31 @@ const init = async () => { component: () => import('./pages/PluginListPage.vue'), }, { - path: '/plugin/create', + path: '/plugin/select', + name: 'select-plugin', + component: () => import('./pages/PluginSelectPage.vue'), + }, + { + path: '/plugin/create/:plugin', name: 'create-plugin', component: () => import('./pages/FallbackPage.vue'), }, + { + path: '/custom-plugin/create', + name: 'create-custom-plugin', + component: () => import('./pages/FallbackPage.vue'), + }, { path: '/plugin/:plugin/:id', name: 'view-plugin', component: () => import('./pages/PluginConfigCardPage.vue'), props: true, }, + { + path: '/custom-plugin/:plugin/edit', + name: 'edit-custom-plugin', + component: () => import('./pages/FallbackPage.vue'), + }, { path: '/plugin/:id/edit', name: 'edit-plugin', diff --git a/packages/entities/entities-plugins/sandbox/pages/PluginListPage.vue b/packages/entities/entities-plugins/sandbox/pages/PluginListPage.vue index c260517b62..591263b8c7 100644 --- a/packages/entities/entities-plugins/sandbox/pages/PluginListPage.vue +++ b/packages/entities/entities-plugins/sandbox/pages/PluginListPage.vue @@ -56,7 +56,7 @@ const konnectConfig = ref({ apiBaseUrl: '/us/kong-api/konnect-api', // `/{geo}/kong-api/konnect-api`, with leading slash and no trailing slash // Set the root `.env.development.local` variable to a control plane your PAT can access controlPlaneId, - createRoute: { name: 'create-plugin' }, + createRoute: { name: 'select-plugin' }, getViewRoute: (plugin: EntityRow) => ({ name: 'view-plugin', params: { id: plugin.id, plugin: plugin.name } }), getEditRoute: (plugin: EntityRow) => ({ name: 'edit-plugin', params: { id: plugin.id } }), getScopedEntityViewRoute: (type: ViewRouteType, id: string) => ({ name: `view-${type}`, params: { id } }), @@ -73,7 +73,7 @@ const kongManagerConfig = ref({ workspace: 'default', apiBaseUrl: '/kong-manager', // For local dev server proxy isExactMatch: false, - createRoute: { name: 'create-plugin' }, + createRoute: { name: 'select-plugin' }, getViewRoute: (plugin: EntityRow) => ({ name: 'view-plugin', params: { id: plugin.id, plugin: plugin.name } }), getEditRoute: (plugin: EntityRow) => ({ name: 'edit-plugin', params: { id: plugin.id } }), getScopedEntityViewRoute: (type: ViewRouteType, id: string) => ({ name: `view-${type}`, params: { id } }), diff --git a/packages/entities/entities-plugins/sandbox/pages/PluginSelectPage.vue b/packages/entities/entities-plugins/sandbox/pages/PluginSelectPage.vue new file mode 100644 index 0000000000..3579c52a78 --- /dev/null +++ b/packages/entities/entities-plugins/sandbox/pages/PluginSelectPage.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/packages/entities/entities-plugins/src/components/PluginForm.vue b/packages/entities/entities-plugins/src/components/PluginForm.vue new file mode 100644 index 0000000000..6aa94c4876 --- /dev/null +++ b/packages/entities/entities-plugins/src/components/PluginForm.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/packages/entities/entities-plugins/src/components/PluginList.vue b/packages/entities/entities-plugins/src/components/PluginList.vue index 95328e1a30..85aa7fa53f 100644 --- a/packages/entities/entities-plugins/src/components/PluginList.vue +++ b/packages/entities/entities-plugins/src/components/PluginList.vue @@ -237,6 +237,7 @@ import { useDeleteUrlBuilder, useGatewayFeatureSupported, } from '@kong-ui-public/entities-shared' +import '@kong-ui-public/entities-shared/dist/style.css' import type { BaseTableHeaders, @@ -259,8 +260,6 @@ import type { import PluginIcon from './PluginIcon.vue' -import '@kong-ui-public/entities-shared/dist/style.css' - const pluginMetaData = composables.usePluginMetaData() const emit = defineEmits<{ diff --git a/packages/entities/entities-plugins/src/components/PluginSelect.cy.ts b/packages/entities/entities-plugins/src/components/PluginSelect.cy.ts new file mode 100644 index 0000000000..ff5bf0d8dd --- /dev/null +++ b/packages/entities/entities-plugins/src/components/PluginSelect.cy.ts @@ -0,0 +1,614 @@ +// Cypress component test spec file +import { PluginGroupArray, PluginGroup, type KongManagerPluginFormConfig, type KonnectPluginFormConfig } from '../types' +import { + kmAvailablePlugins, + kmLimitedAvailablePlugins, + konnectAvailablePlugins, + konnectLimitedAvailablePlugins, + firstShownPlugin, + firstShownCustomPlugin, + kongPluginNames, + customPluginNames, +} from '../../fixtures/mockData' +import type { Router } from 'vue-router' +import { createMemoryHistory, createRouter } from 'vue-router' +import PluginSelect from './PluginSelect.vue' + +const baseConfigKonnect: KonnectPluginFormConfig = { + app: 'konnect', + apiBaseUrl: '/us/kong-api/konnect-api', + controlPlaneId: 'abc-123-i-love-cats', + // force the scope + // entityType: 'services', + // entityId: '6f1ef200-d3d4-4979-9376-726f2216d90c', + getCreateRoute: (plugin: string) => ({ + name: 'create-plugin', + params: { + control_plane_id: 'abc-123-i-love-cats', + plugin, + }, + }), + // custom plugins + createCustomRoute: { name: 'create-custom-plugin' }, + getCustomEditRoute: (plugin: string) => ({ + name: 'edit-custom-plugin', + params: { + control_plane_id: 'abc-123-i-love-cats', + plugin, + }, + }), +} + +const baseConfigKM:KongManagerPluginFormConfig = { + app: 'kongManager', + workspace: 'default', + apiBaseUrl: '/kong-manager', + // force the scope + // entityType: 'consumers', + // entityId: '123-abc-i-lover-cats', + getCreateRoute: (plugin: string) => ({ + name: 'create-plugin', + params: { + plugin, + }, + }), +} + +// filter out these 3 groups because we currently don't have +// any plugins for 'Deployment' and 'WebSocket Plugins'. +// Custom plugins are not shown in Kong Manager and displayed +// separately in Konnect. +const PLUGIN_GROUPS_IN_USE = PluginGroupArray.filter((group: string) => { + if (group === PluginGroup.CUSTOM_PLUGINS || group === PluginGroup.DEPLOYMENT || + group === PluginGroup.WEBSOCKET) { + return false + } + + return true +}) + +describe('', () => { + describe('Kong Manager', () => { + const interceptKM = (params?: { + mockData?: object + alias?: string + }) => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/kong`, + }, + { + statusCode: 200, + body: params?.mockData ?? kmAvailablePlugins, + }, + ).as(params?.alias ?? 'getAvailablePlugins') + } + + it('should show the select page', () => { + interceptKM() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKM, + }, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + cy.get('.plugins-filter-input-container').should('be.visible') + cy.get('.plugin-select-grid').should('be.visible') + cy.getTestId(`${firstShownPlugin}-card`).should('be.visible') + + // plugin group collapses show + PLUGIN_GROUPS_IN_USE.forEach((pluginGroup: string) => { + cy.get('[data-testid="k-collapse-title"]').should('contain.text', pluginGroup) + }) + // renders all plugins + for (const pluginName in kmAvailablePlugins.plugins.available_on_server) { + cy.getTestId(`${pluginName}-card`).should('exist') + } + }) + + it('should allow customizing the pluginsPerRow', () => { + const pluginsPerRow = 3 + const expectedCount = pluginsPerRow * PLUGIN_GROUPS_IN_USE.length + + interceptKM() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKM, + pluginsPerRow, + }, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + cy.get('.k-collapse-visible-content .plugin-card-content').should('have.length', expectedCount) + }) + + it('should correctly render disabled plugins', () => { + const disabledPlugins = { 'basic-auth': 'This plugin is disabled' } + + interceptKM() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKM, + disabledPlugins, + }, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + + for (const key in disabledPlugins) { + cy.get(`.plugin-card.disabled [data-testid="${key}-card"]`).should('be.visible') + } + }) + + it('should correctly hide ignored plugins', () => { + const ignoredPlugins = ['basic-auth'] + + interceptKM() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKM, + ignoredPlugins, + }, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + + ignoredPlugins.forEach((pluginName) => { + cy.getTestId(`${pluginName}-card`).should('not.exist') + }) + }) + + it('should correctly render available plugins when isAvailableOnly is true', () => { + interceptKM({ + mockData: kmLimitedAvailablePlugins, + }) + + cy.mount(PluginSelect, { + props: { + config: baseConfigKM, + showAvailableOnly: true, + }, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + + // renders all plugins + for (const pluginName in kmLimitedAvailablePlugins.plugins.available_on_server) { + cy.getTestId(`${pluginName}-card`).should('exist') + } + + // does not render a plugin when not available + cy.getTestId(`${firstShownPlugin}-card`).should('not.exist') + }) + + it('should allow filtering of plugins', () => { + interceptKM() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKM, + }, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + + // search + cy.getTestId('plugins-filter').should('be.visible') + cy.getTestId('plugins-filter').type(firstShownPlugin) + + cy.getTestId(`${firstShownPlugin}-card`).should('be.visible') + cy.get('.plugin-card-content').should('have.length', 1) + }) + + it('should handle error state - available plugins failed to load', () => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/kong`, + }, + { + statusCode: 500, + body: {}, + }, + ).as('getAvailablePlugins2') + + cy.mount(PluginSelect, { + props: { + config: baseConfigKM, + }, + }) + + cy.wait('@getAvailablePlugins2') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.getTestId('plugins-fetch-error').should('be.visible') + }) + + it('should handle empty state - invalid plugin name', () => { + interceptKM() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKM, + }, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + + // search + cy.getTestId('plugins-filter').should('be.visible') + cy.getTestId('plugins-filter').type('xxxxx') + + cy.getTestId('plugins-empty-state').should('be.visible') + }) + + it('click event should be emitted when Plugin was clicked and navigateOnClick is false', () => { + interceptKM() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKM, + onPluginClicked: cy.spy().as('onPluginClickedSpy'), + navigateOnClick: false, + }, + }).then(({ wrapper }) => wrapper) + .as('vueWrapper') + + cy.wait('@getAvailablePlugins') + + cy.getTestId(`${firstShownPlugin}-card`).should('be.visible') + cy.getTestId(`${firstShownPlugin}-card`).click() + + cy.get('@vueWrapper').then((wrapper: any) => wrapper.findComponent(PluginSelect) + .vm.$emit('plugin-clicked', {})) + + cy.get('@onPluginClickedSpy').should('have.been.called') + }) + }) + + describe('Konnect', () => { + // Create a new router instance for each test + let router: Router + const interceptKonnect = (params?: { + mockData?: object + alias?: string + }) => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/api/runtime_groups/${baseConfigKonnect.controlPlaneId}/v1/available-plugins`, + }, + { + statusCode: 200, + body: params?.mockData ?? konnectAvailablePlugins, + }, + ).as(params?.alias ?? 'getAvailablePlugins') + } + + beforeEach(() => { + // Initialize a new router before each test + router = createRouter({ + routes: [ + { path: '/', name: 'list-plugin', component: { template: '
ListPage
' } }, + ], + history: createMemoryHistory(), + }) + }) + + it('should show the select page tabs', () => { + interceptKonnect() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKonnect, + }, + router, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + cy.get('.plugins-filter-input-container').should('be.visible') + + // kong plugins + cy.get('#kong-tab').should('be.visible') + cy.get('.plugin-select-grid').should('be.visible') + // kong visible / custom not + cy.getTestId(`${firstShownPlugin}-card`).should('be.visible') + cy.getTestId(`${firstShownCustomPlugin}-card`).should('not.exist') + + // renders all plugins (kong) + kongPluginNames.forEach((pluginName: string) => { + cy.getTestId(`${pluginName}-card`).should('exist') + }) + + // plugin group collapses show + PLUGIN_GROUPS_IN_USE.forEach((pluginGroup: string) => { + cy.get('[data-testid="k-collapse-title"]').should('contain.text', pluginGroup) + }) + + // custom plugins + cy.get('#custom-tab').should('be.visible') + cy.get('#custom-tab').click() + cy.get('.custom-plugins-grid').should('be.visible') + // kong hidden / custom not + cy.getTestId(`${firstShownPlugin}-card`).should('not.exist') + cy.getTestId(`${firstShownCustomPlugin}-card`).should('be.visible') + + // renders all plugins (custom) + customPluginNames.forEach((pluginName: string) => { + cy.getTestId(`${pluginName}-card`).should('exist') + }) + }) + + it('should allow customizing the pluginsPerRow', () => { + const pluginsPerRow = 3 + const expectedCount = pluginsPerRow * PLUGIN_GROUPS_IN_USE.length + + interceptKonnect() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKonnect, + pluginsPerRow, + }, + router, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + cy.get('.k-collapse-visible-content .plugin-card-content').should('have.length', expectedCount) + }) + + it('should correctly render disabled plugins', () => { + const disabledPlugins = { 'basic-auth': 'This plugin is disabled' } + + interceptKonnect() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKonnect, + disabledPlugins, + }, + router, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + + for (const key in disabledPlugins) { + cy.get(`.plugin-card.disabled [data-testid="${key}-card"]`).should('be.visible') + } + }) + + it('should correctly render ignored plugins', () => { + const ignoredPlugins = ['basic-auth'] + + interceptKonnect() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKonnect, + ignoredPlugins, + }, + router, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + + ignoredPlugins.forEach((pluginName) => { + cy.getTestId(`${pluginName}-card`).should('not.exist') + }) + }) + + it('should correctly render available plugins when isAvailableOnly is true', () => { + interceptKonnect({ + mockData: konnectLimitedAvailablePlugins, + }) + + cy.mount(PluginSelect, { + props: { + config: baseConfigKonnect, + showAvailableOnly: true, + }, + router, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + + // renders all plugins + konnectLimitedAvailablePlugins.names.forEach((pluginName: string) => { + cy.getTestId(`${pluginName}-card`).should('exist') + }) + + // does not render a plugin when not available + cy.getTestId(`${firstShownPlugin}-card`).should('not.exist') + }) + + it('should allow filtering of plugins', () => { + interceptKonnect() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKonnect, + }, + router, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + + // search + cy.getTestId('plugins-filter').should('be.visible') + cy.getTestId('plugins-filter').type(firstShownPlugin) + + cy.getTestId(`${firstShownPlugin}-card`).should('be.visible') + cy.get('.plugin-card-content').should('have.length', 1) + }) + + it('should handle error state - available plugins failed to load', () => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/api/runtime_groups/${baseConfigKonnect.controlPlaneId}/v1/available-plugins`, + }, + { + statusCode: 500, + body: {}, + }, + ).as('getAvailablePlugins') + + cy.mount(PluginSelect, { + props: { + config: baseConfigKonnect, + }, + router, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.getTestId('plugins-fetch-error').should('be.visible') + }) + + it('should handle empty state - invalid plugin name', () => { + interceptKonnect() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKonnect, + }, + router, + }) + + cy.wait('@getAvailablePlugins') + + cy.get('.kong-ui-entities-plugin-select-form').should('be.visible') + cy.get('.kong-ui-entities-plugin-select-form .plugins-results-container').should('be.visible') + + // search + cy.getTestId('plugins-filter').should('be.visible') + cy.getTestId('plugins-filter').type('xxxxx') + + cy.getTestId('plugins-empty-state').should('be.visible') + }) + + it('click event should be emitted when Plugin was clicked and navigateOnClick is false', () => { + interceptKonnect() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKonnect, + onPluginClicked: cy.spy().as('onPluginClickedSpy'), + navigateOnClick: false, + }, + router, + }).then(({ wrapper }) => wrapper) + .as('vueWrapper') + + cy.wait('@getAvailablePlugins') + + cy.getTestId(`${firstShownPlugin}-card`).should('be.visible') + cy.getTestId(`${firstShownPlugin}-card`).click() + + cy.get('@vueWrapper').then((wrapper: any) => wrapper.findComponent(PluginSelect) + .vm.$emit('plugin-clicked', {})) + + cy.get('@onPluginClickedSpy').should('have.been.called') + }) + + describe('custom plugin actions', () => { + it('should correctly render custom plugin actions', () => { + interceptKonnect() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKonnect, + canCreateCustomPlugin: () => false, + canEditCustomPlugin: () => true, + canDeleteCustomPlugin: () => true, + }, + router, + }) + + cy.wait('@getAvailablePlugins') + + // custom plugins + cy.get('#custom-tab').should('be.visible') + cy.get('#custom-tab').click() + cy.get('.custom-plugins-grid').should('be.visible') + + cy.getTestId('custom-plugin-actions').eq(0).click() + cy.getTestId('edit-plugin-schema').should('be.visible') + cy.getTestId('delete-plugin-schema').should('be.visible') + // negative test for create + cy.getTestId('custom-plugin-create-card').should('not.exist') + }) + + it('should render create card if user has create rights', () => { + interceptKonnect() + + cy.mount(PluginSelect, { + props: { + config: baseConfigKonnect, + canCreateCustomPlugin: () => true, + canEditCustomPlugin: () => false, + canDeleteCustomPlugin: () => false, + }, + router, + }) + + cy.wait('@getAvailablePlugins') + + // custom plugins + cy.get('#custom-tab').should('be.visible') + cy.get('#custom-tab').click() + cy.get('.custom-plugins-grid').should('be.visible') + + cy.getTestId('custom-plugin-create-card').should('be.visible') + // negative test for edit and delete + cy.getTestId('custom-plugin-actions').should('not.exist') + }) + }) + }) +}) diff --git a/packages/entities/entities-plugins/src/components/PluginSelect.vue b/packages/entities/entities-plugins/src/components/PluginSelect.vue new file mode 100644 index 0000000000..70d3b803ae --- /dev/null +++ b/packages/entities/entities-plugins/src/components/PluginSelect.vue @@ -0,0 +1,498 @@ + + + + + diff --git a/packages/entities/entities-plugins/src/components/custom-plugins/DeleteCustomPluginSchemaModal.vue b/packages/entities/entities-plugins/src/components/custom-plugins/DeleteCustomPluginSchemaModal.vue new file mode 100644 index 0000000000..05fc055bf8 --- /dev/null +++ b/packages/entities/entities-plugins/src/components/custom-plugins/DeleteCustomPluginSchemaModal.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/packages/entities/entities-plugins/src/components/custom-plugins/PluginCustomGrid.vue b/packages/entities/entities-plugins/src/components/custom-plugins/PluginCustomGrid.vue new file mode 100644 index 0000000000..04d7a3efdb --- /dev/null +++ b/packages/entities/entities-plugins/src/components/custom-plugins/PluginCustomGrid.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/packages/entities/entities-plugins/src/components/select/PluginCardSkeleton.vue b/packages/entities/entities-plugins/src/components/select/PluginCardSkeleton.vue new file mode 100644 index 0000000000..654636c4d4 --- /dev/null +++ b/packages/entities/entities-plugins/src/components/select/PluginCardSkeleton.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/entities/entities-plugins/src/components/select/PluginSelectCard.vue b/packages/entities/entities-plugins/src/components/select/PluginSelectCard.vue new file mode 100644 index 0000000000..746a2ff6f4 --- /dev/null +++ b/packages/entities/entities-plugins/src/components/select/PluginSelectCard.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/packages/entities/entities-plugins/src/components/select/PluginSelectGrid.vue b/packages/entities/entities-plugins/src/components/select/PluginSelectGrid.vue new file mode 100644 index 0000000000..7255193aa1 --- /dev/null +++ b/packages/entities/entities-plugins/src/components/select/PluginSelectGrid.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/packages/entities/entities-plugins/src/composables/usePluginHelpers.ts b/packages/entities/entities-plugins/src/composables/usePluginHelpers.ts index 205849c907..066834c4a3 100644 --- a/packages/entities/entities-plugins/src/composables/usePluginHelpers.ts +++ b/packages/entities/entities-plugins/src/composables/usePluginHelpers.ts @@ -1,5 +1,6 @@ import type { ConfigurationSchema } from '@kong-ui-public/entities-shared' import { ConfigurationSchemaType } from '@kong-ui-public/entities-shared' +import type { PluginType } from '../types' export default function useHelpers() { const METHOD_KEYS = ['methods', 'logout_methods'] @@ -88,7 +89,18 @@ export default function useHelpers() { } } + const getPluginCards = (type: 'all' | 'visible' | 'hidden', plugins: PluginType[], pluginsPerRow: number) => { + if (type === 'all') { + return plugins + } else if (type === 'visible') { + return plugins.slice(0, pluginsPerRow) + } + + return plugins.slice(pluginsPerRow) + } + return { setFieldType, + getPluginCards, } } diff --git a/packages/entities/entities-plugins/src/index.ts b/packages/entities/entities-plugins/src/index.ts index 55d3d14213..38a360b351 100644 --- a/packages/entities/entities-plugins/src/index.ts +++ b/packages/entities/entities-plugins/src/index.ts @@ -1,5 +1,7 @@ import PluginIcon from './components/PluginIcon.vue' import PluginList from './components/PluginList.vue' +import PluginSelect from './components/PluginSelect.vue' +import PluginSelectGrid from './components/select/PluginSelectGrid.vue' import PluginConfigCard from './components/PluginConfigCard.vue' import composables from './composables' @@ -8,6 +10,8 @@ const { getPluginIconURL, usePluginMetaData } = composables export { PluginIcon, PluginList, + PluginSelect, + PluginSelectGrid, PluginConfigCard, getPluginIconURL, usePluginMetaData, diff --git a/packages/entities/entities-plugins/src/locales/en.json b/packages/entities/entities-plugins/src/locales/en.json index 1d64dedb23..e768f0685f 100644 --- a/packages/entities/entities-plugins/src/locales/en.json +++ b/packages/entities/entities-plugins/src/locales/en.json @@ -1,16 +1,31 @@ { "actions": { + "cancel": "Cancel", "create": "New Plugin", + "create_custom": "Create", "copy_id": "Copy ID", "copy_json": "Copy JSON", "edit": "Edit", + "enable": "Enable", + "enabled": "Enabled", "delete": "Delete", + "confirm_delete": "Yes, delete", "view": "View Details", - "configure_dynamic_ordering": "Configure Dynamic Ordering" + "configure_dynamic_ordering": "Configure Dynamic Ordering", + "go_to_plugins": "Go to Plugins" }, "delete": { "title": "Delete a Plugin", - "description": "Are you sure you want to delete this plugin? This action cannot be reversed." + "custom_title": "Delete {name}", + "custom_plugin": "Custom Plugin", + "description": "Are you sure you want to delete this plugin? This action cannot be reversed.", + "description_custom": "Please ensure this plugin is not in use. This action cannot be reversed.", + "plugin_schema_in_use_title": "Unable to Delete Custom Plugin", + "plugin_schema_in_use_message": "The custom plugin schema {name} is being used in this control plane, please delete any existing instances before you delete this schema.", + "confirmModalText1": "Are you sure you want to delete", + "confirmModalText2": "Please ensure this plugin is not in use. This delete action cannot be reversed.", + "confirm_text": "Type {name} to confirm your action", + "success_message": "Successfully deleted custom plugin {name}" }, "copy": { "success": "Copied {val} to clipboard", @@ -21,17 +36,20 @@ "errors": { "general": "Plugins could not be retrieved", "delete": "The plugin could not be deleted at this time.", - "copy": "Failed to copy to clipboard" + "copy": "Failed to copy to clipboard", + "load_results": "Error loading available plugins" }, "search": { "placeholder": { - "konnect": "Filter by exact instance name or ID" + "konnect": "Filter by exact instance name or ID", + "select": "Filter plugins" }, "filter": { "field": { "enabled": "Enabled" } - } + }, + "no_results": "No results found for \"{filter}\"" }, "plugins": { "title": "Plugins", @@ -391,6 +409,32 @@ "route": "Route ID", "consumer": "Consumer ID", "consumer_group": "Consumer Group ID" + }, + "select": { + "custom_badge_text": "Data Plane Node Specific", + "unavailable_tooltip": "This plugin is not available", + "already_exists": "This plugin is already applied to this resource", + "misc_plugins": "Other Plugins", + "view_more": "View {count} more", + "view_less": "View less", + "tabs": { + "kong": { + "title": "Kong Plugins", + "description": "Kong plugins are bundled by default with Kong Gateway and are available across all control planes.", + "empty_title": "No Plugins", + "empty_description": "No plugins are available." + }, + "custom": { + "title": "Custom Plugins", + "description": "Custom plugins will be available in this control plane only.", + "empty_title": "No Custom Plugins", + "empty_description": "No custom plugins have been added to this Control Plane.", + "create": { + "name": "Custom Plugin", + "description": "Upload schema file to create custom plugin" + } + } + } } }, "glossary": { diff --git a/packages/entities/entities-plugins/src/plugins-endpoints.ts b/packages/entities/entities-plugins/src/plugins-endpoints.ts index 432723bf26..c6fcfb1d44 100644 --- a/packages/entities/entities-plugins/src/plugins-endpoints.ts +++ b/packages/entities/entities-plugins/src/plugins-endpoints.ts @@ -9,14 +9,26 @@ export default { forEntity: '/{workspace}/{entityType}/{entityId}/plugins', }, }, + select: { + konnect: { + availablePlugins: '/api/runtime_groups/{controlPlaneId}/v1/available-plugins', + }, + kongManager: { + availablePlugins: '/{workspace}/kong', + }, + }, form: { konnect: { + create: '/api/runtime_groups/{controlPlaneId}/plugins', edit: '/api/runtime_groups/{controlPlaneId}/plugins/{id}', pluginSchema: '/api/runtime_groups/{controlPlaneId}/schemas/plugins/{plugin}', + validate: '/api/runtime_groups/{controlPlaneId}/v1/schemas/json/plugin/validate', }, kongManager: { + create: '/{workspace}/plugins', edit: '/{workspace}/plugins/{id}', pluginSchema: '/{workspace}/schemas/plugins/{plugin}', + validate: '/{workspace}/schemas/plugins/validate', }, }, item: { diff --git a/packages/entities/entities-plugins/src/types/index.ts b/packages/entities/entities-plugins/src/types/index.ts index 323770ab02..50c9c2cd36 100644 --- a/packages/entities/entities-plugins/src/types/index.ts +++ b/packages/entities/entities-plugins/src/types/index.ts @@ -1,3 +1,4 @@ export * from './plugin-config-card' +export * from './plugin-form' export * from './plugin-list' export * from './plugin' diff --git a/packages/entities/entities-plugins/src/types/plugin-form.ts b/packages/entities/entities-plugins/src/types/plugin-form.ts new file mode 100644 index 0000000000..2fb7eaf226 --- /dev/null +++ b/packages/entities/entities-plugins/src/types/plugin-form.ts @@ -0,0 +1,37 @@ +import type { RouteLocationRaw } from 'vue-router' +import type { KonnectBaseFormConfig, KongManagerBaseFormConfig } from '@kong-ui-public/entities-shared' +import type { EntityType } from './plugin' + +export interface BasePluginFormConfig { + /** A function that returns the route for creating a plugin */ + getCreateRoute: (id: string) => RouteLocationRaw + /** Current entity type and id for plugins for specific entity */ + entityType?: EntityType + entityId?: string +} + +/** Konnect Plugin form config */ +export interface KonnectPluginFormConfig extends BasePluginFormConfig, KonnectBaseFormConfig { + /** Route for creating a custom plugin */ + createCustomRoute?: RouteLocationRaw + /** A function that returns the route for editing a custom plugin */ + getCustomEditRoute?: (id: string) => RouteLocationRaw +} + +/** Kong Manager Plugin form config */ +export interface KongManagerPluginFormConfig extends BasePluginFormConfig, KongManagerBaseFormConfig {} + +export interface PluginFormFields { + name: string + tags: string + entity_id: string +} + +export interface PluginFormState { + /** Form fields */ + fields: PluginFormFields + /** Form readonly state (only used when saving entity details) */ + isReadonly: boolean + /** The error message to show on the form */ + errorMessage: string +} diff --git a/packages/entities/entities-plugins/src/types/plugin-list.ts b/packages/entities/entities-plugins/src/types/plugin-list.ts index 446b01dac9..03c7352129 100644 --- a/packages/entities/entities-plugins/src/types/plugin-list.ts +++ b/packages/entities/entities-plugins/src/types/plugin-list.ts @@ -1,13 +1,13 @@ import type { RouteLocationRaw } from 'vue-router' import type { FilterSchema, KongManagerBaseTableConfig, KonnectBaseTableConfig } from '@kong-ui-public/entities-shared' +import type { EntityType } from './plugin' import type { EntityRow as ServiceEntity } from '@kong-ui-public/entities-gateway-services' import type { EntityRow as ConsumerEntity } from '@kong-ui-public/entities-consumers' import type { EntityRow as RouteEntity } from '@kong-ui-public/entities-routes' import type { EntityRow as ConsumerGroupEntity } from '@kong-ui-public/entities-consumer-groups' export type ViewRouteType = 'consumer' | 'route' | 'service' | 'consumer_group' -export type EntityType = 'consumers' | 'routes' | 'services' | 'consumer_groups' export interface EntityRow extends Record { config: any diff --git a/packages/entities/entities-plugins/src/types/plugin.ts b/packages/entities/entities-plugins/src/types/plugin.ts index c8777722a9..36fa9ac1a1 100644 --- a/packages/entities/entities-plugins/src/types/plugin.ts +++ b/packages/entities/entities-plugins/src/types/plugin.ts @@ -8,9 +8,36 @@ export enum PluginGroup { LOGGING = 'Logging', DEPLOYMENT = 'Deployment', WEBSOCKET = 'WebSocket Plugins', - CUSTOM_PLUGINS = 'Other Plugins', + CUSTOM_PLUGINS = 'Custom Plugins', } +export const PluginGroupArray = [ + PluginGroup.AUTHENTICATION, + PluginGroup.SECURITY, + PluginGroup.TRAFFIC_CONTROL, + PluginGroup.SERVERLESS, + PluginGroup.ANALYTICS_AND_MONITORING, + PluginGroup.TRANSFORMATIONS, + PluginGroup.LOGGING, + PluginGroup.DEPLOYMENT, + PluginGroup.WEBSOCKET, + PluginGroup.CUSTOM_PLUGINS, +] + +export const PLUGIN_GROUPS_COLLAPSE_STATUS = { + AUTHENTICATION: true, + SECURITY: true, + TRAFFIC_CONTROL: true, + SERVERLESS: true, + ANALYTICS_AND_MONITORING: true, + TRANSFORMATIONS: true, + LOGGING: true, + DEPLOYMENT: true, + CUSTOM_PLUGINS: true, +} + +export type EntityType = 'consumers' | 'routes' | 'services' | 'consumer_groups' + export enum PluginScope { GLOBAL = 'global', SERVICE = 'service', @@ -28,3 +55,22 @@ export type PluginMetaData = { name: string // A display name of the Plugin. scope: PluginScope[] // The scope supported by the Plugin. } + +export interface PluginType extends PluginMetaData { + id: string // the plugin schema name + available?: boolean // whether the plugin is available or not + exists?: boolean // whether the plugin exists already for the current entity + disabledMessage?: string // An optional field for plugin's disabled message. +} + +export type DisabledPlugin = { + [key: string]: string // [plugin.id]: plugin.disabledMessage +} + +export type PluginCardList = { + [key in PluginGroup]?: PluginType[] +} + +export type TriggerLabels = { + [key in PluginGroup]?: string // [plugin.group]: label +} diff --git a/packages/entities/entities-shared/src/composables/useHelpers.ts b/packages/entities/entities-shared/src/composables/useHelpers.ts index ebfd40a499..56585671a9 100644 --- a/packages/entities/entities-shared/src/composables/useHelpers.ts +++ b/packages/entities/entities-shared/src/composables/useHelpers.ts @@ -10,7 +10,47 @@ export default function useHelpers() { return slotProps?.[propName] ?? undefined } + /** + * Check if 2 objects are equal + * @param {Object} a first object to compare + * @param {Object} b second object to compare + * @returns {Boolean} whether or not the objects are equal + */ + const objectsAreEqual = (a: Record, b: Record): boolean => { + try { + return JSON.stringify(a) === JSON.stringify(b) + } catch (e) { + return false + } + } + + /** + * A comparator function that given a key, compares object values with that key, and returns the results of + * localCompare on those values (see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare for reference) + * Also checks for undefined, nulls and sub-Arrays. + * @param {String} property the key to sort on + * @returns {Function} a comparator function + */ + const sortAlpha = (property: string) => { + return (a: Record, b: Record) => { + let propertyA = a[property] === undefined || a[property] === null ? '' : a[property] + let propertyB = b[property] === undefined || b[property] === null ? '' : b[property] + + if (Array.isArray(a[property])) { + propertyA = a[property][0] + } + + if (Array.isArray(b[property])) { + propertyB = b[property][0] + } + + return propertyA.localeCompare(propertyB) + } + } + return { getPropValue, + objectsAreEqual, + sortAlpha, } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d219181ea2..27c047030a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: false - excludeLinksFromLockfile: false - importers: .: @@ -666,6 +662,9 @@ importers: '@kong-ui-public/entities-shared': specifier: workspace:^ version: link:../entities-shared + '@kong/icons': + specifier: ^1.7.8 + version: 1.7.8(vue@3.3.4) devDependencies: '@kong-ui-public/i18n': specifier: workspace:^ @@ -2046,10 +2045,11 @@ packages: resolution: {integrity: sha512-+VT9MeR3EcKXhst0KLoMme8dRYQef5skGaob1bR6xbyRXbwHG34ymLmYUeZ+ObIqISz7yTPGgarMJfDyzuX/YQ==} engines: {node: '>=v16.20.2'} peerDependencies: + axios: ^0.27.2 vue: '>= 3.3.4 < 4' vue-router: ^4.2.4 dependencies: - axios: 0.27.2 + axios: 1.5.1 date-fns: 2.30.0 date-fns-tz: 2.0.0(date-fns@2.30.0) focus-trap: 7.5.3 @@ -2062,8 +2062,6 @@ packages: vue: 3.3.4 vue-draggable-next: 2.2.1(sortablejs@1.15.0)(vue@3.3.4) vue-router: 4.2.5(vue@3.3.4) - transitivePeerDependencies: - - debug /@kong/swagger-ui-kong-theme-universal@4.2.8(react-dom@17.0.2)(react@17.0.2)(vue-router@4.2.5)(vue@3.3.4): resolution: {integrity: sha512-HS2Fjjd/tLWCmcEeRefzsDotsdNcF2d10L5ugzOYOuIMTRbCXq6wB7lTKJZQDCa7uy5syIeDuku1QSNpnmHOyw==} @@ -2087,7 +2085,7 @@ packages: util: 0.12.5 transitivePeerDependencies: - '@babel/core' - - debug + - axios - mkdirp - prop-types - react-dom @@ -4528,14 +4526,6 @@ packages: resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} dev: true - /axios@0.27.2: - resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} - dependencies: - follow-redirects: 1.15.2 - form-data: 4.0.0 - transitivePeerDependencies: - - debug - /axios@1.5.1: resolution: {integrity: sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==} dependencies: @@ -14399,3 +14389,7 @@ packages: /zenscroll@4.0.2: resolution: {integrity: sha512-jEA1znR7b4C/NnaycInCU6h/d15ZzCd1jmsruqOKnZP6WXQSMH3W2GL+OXbkruslU4h+Tzuos0HdswzRUk/Vgg==} dev: false + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false