diff --git a/packages/core/forms/fixtures/mockData.ts b/packages/core/forms/fixtures/mockData.ts new file mode 100644 index 0000000000..b90b66a75e --- /dev/null +++ b/packages/core/forms/fixtures/mockData.ts @@ -0,0 +1,888 @@ +export const redisPartials = [ + { + 'created_at': 1739167439, + 'updated_at': 1739167439, + 'name': 'test-redis-ce-config', + 'type': 'redis-ce', + 'config': { + 'host': '127.0.0.1', + 'password': null, + 'ssl_verify': false, + 'database': 0, + 'server_name': 'test-ce-server-name', + 'username': null, + 'timeout': 2000, + 'ssl': false, + 'port': 6379, + }, + 'id': '028c9637-0408-4ab0-8d35-0b474de4bde6', + }, + { + 'created_at': 1739166982, + 'updated_at': 1739166982, + 'name': 'test-redis-ee', + 'type': 'redis-ee', + 'config': { + 'host': '127.0.0.1', + 'password': null, + 'ssl_verify': false, + 'username': 'test-username', + 'keepalive_pool_size': 256, + 'port': 6379, + 'connection_is_proxied': false, + 'cluster_nodes': null, + 'sentinel_role': null, + 'send_timeout': 2000, + 'sentinel_nodes': null, + 'database': 0, + 'ssl': false, + 'keepalive_backlog': 0, + 'server_name': null, + 'read_timeout': 2000, + 'cluster_max_redirections': 5, + 'sentinel_password': null, + 'sentinel_username': null, + 'connect_timeout': 2000, + 'sentinel_master': null, + }, + 'id': '3f2d22a2-fe4c-4211-b1db-2ad44b36eab7', + }, + { + 'created_at': 1739181896, + 'updated_at': 1739181896, + 'name': 'test-redis-ee-2', + 'type': 'redis-ee', + 'config': { + 'host': '127.0.0.1', + 'password': null, + 'ssl_verify': false, + 'username': null, + 'keepalive_pool_size': 256, + 'port': 6379, + 'connection_is_proxied': false, + 'cluster_nodes': null, + 'sentinel_role': null, + 'send_timeout': 2000, + 'sentinel_nodes': null, + 'database': 0, + 'ssl': false, + 'keepalive_backlog': 0, + 'server_name': 'test-redis-ee-server', + 'read_timeout': 2000, + 'cluster_max_redirections': 5, + 'sentinel_password': null, + 'sentinel_username': null, + 'connect_timeout': 2000, + 'sentinel_master': null, + }, + 'id': 'c41669c8-9ff6-4d8c-b667-494864a585ec', + }, +] + +export const redisCEConfig = { + 'created_at': 1739167439, + 'updated_at': 1739167439, + 'name': 'test-redis-ce-config', + 'type': 'redis-ce', + 'config': { + 'host': '127.0.0.1', + 'password': null, + 'ssl_verify': false, + 'database': 0, + 'server_name': 'test-ce-server-name', + 'username': null, + 'timeout': 2000, + 'ssl': false, + 'port': 6379, + }, + 'id': '028c9637-0408-4ab0-8d35-0b474de4bde6', +} + +export const redisEEConfig = { + 'created_at': 1739181896, + 'updated_at': 1739181896, + 'name': 'test-redis-ee', + 'type': 'redis-ee', + 'config': { + 'host': '127.0.0.1', + 'password': null, + 'ssl_verify': false, + 'username': null, + 'keepalive_pool_size': 256, + 'port': 6379, + 'connection_is_proxied': false, + 'cluster_nodes': null, + 'sentinel_role': null, + 'send_timeout': 2000, + 'sentinel_nodes': null, + 'database': 0, + 'ssl': false, + 'keepalive_backlog': 0, + 'server_name': 'test-redis-ee-server', + 'read_timeout': 2000, + 'cluster_max_redirections': 5, + 'sentinel_password': null, + 'sentinel_username': null, + 'connect_timeout': 2000, + 'sentinel_master': null, + }, + 'id': 'c41669c8-9ff6-4d8c-b667-494864a585ec', +} + +export const RLSchema = { + 'groups': [ + { + 'fields': [ + { + 'default': 'rate-limiting', + 'type': 'input', + 'inputType': 'hidden', + 'styleClasses': 'd-none hidden-field', + 'pinned': true, + 'id': 'name', + 'model': 'name', + 'label': '', + }, + { + 'type': 'switch', + 'model': 'enabled', + 'label': 'Enabled', + 'textOn': 'This plugin is Enabled', + 'textOff': 'This plugin is Disabled', + 'styleClasses': 'field-switch hide-label', + 'default': true, + 'pinned': true, + 'id': 'enabled', + }, + { + 'type': 'selectionGroup', + 'disabled': false, + 'inputType': 'hidden', + 'styleClasses': 'hide-label', + 'fields': [ + { + 'label': 'Global', + 'description': 'All services, routes, and consumers', + }, + { + 'label': 'Scoped', + 'description': 'Specific Gateway Services, Routes, Consumers, and/or Consumer Groups', + 'fields': [ + { + 'id': 'service-id', + 'model': 'service-id', + 'label': 'Gateway Service', + 'placeholder': 'Select a Gateway Service', + 'type': 'AutoSuggest', + 'entity': 'services', + 'inputValues': { + 'fields': [ + 'name', + 'id', + ], + }, + 'help': 'The Gateway Service to which this plugin configuration will apply', + 'disabled': false, + }, + { + 'id': 'route-id', + 'model': 'route-id', + 'label': 'Route', + 'placeholder': 'Select a Route', + 'type': 'AutoSuggest', + 'entity': 'routes', + 'inputValues': { + 'fields': [ + 'name', + 'id', + ], + 'primaryField': 'id', + }, + 'help': 'The Route that this Plugin configuration will target', + 'disabled': false, + }, + { + 'model': 'consumer-id', + 'label': 'Consumer', + 'placeholder': 'Select a Consumer', + 'type': 'AutoSuggest', + 'entity': 'consumers', + 'inputValues': { + 'fields': [ + 'username', + 'custom_id', + 'id', + ], + 'primaryField': 'username', + }, + 'help': 'The Consumer that this plugin configuration will target', + 'disabled': false, + }, + { + 'model': 'consumer_group-id', + 'label': 'Consumer Group', + 'placeholder': 'Select a Consumer Group', + 'type': 'AutoSuggest', + 'entity': 'consumer_groups', + 'entityDataKey': 'consumer_group', + 'inputValues': { + 'fields': [ + 'name', + 'id', + ], + 'primaryField': 'name', + }, + 'help': 'The Consumer Group that this plugin configuration will target', + 'disabled': false, + }, + ], + }, + ], + 'pinned': true, + 'id': 'selectionGroup', + 'model': 'selectionGroup', + 'label': '', + }, + ], + }, + { + 'fields': [ + { + 'id': 'protocols', + 'default': [ + 'grpc', + 'grpcs', + 'http', + 'https', + ], + 'help': 'A list of the request protocols that will trigger this plugin. The default value, as well as the possible values allowed on this field, may change depending on the plugin type.', + 'label': 'Protocols', + 'required': true, + 'styleClasses': 'plugin-protocols-select', + 'type': 'multiselect', + 'values': [ + { + 'label': 'grpc', + 'value': 'grpc', + }, + { + 'label': 'grpcs', + 'value': 'grpcs', + }, + { + 'label': 'http', + 'value': 'http', + }, + { + 'label': 'https', + 'value': 'https', + }, + ], + 'placeholder': 'Select valid protocols for the plugin. Default protocols: grpc, grpcs, http, https', + 'model': 'protocols', + }, + { + 'id': 'config-day', + 'model': 'config-day', + 'type': 'input', + 'label': 'Config.Day', + 'help': '

The number of HTTP requests that can be made per day.

\n', + 'inputType': 'number', + 'valueType': 'number', + }, + { + 'id': 'config-fault_tolerant', + 'model': 'config-fault_tolerant', + 'type': 'checkbox', + 'required': true, + 'label': 'Config.Fault Tolerant', + 'help': '

A boolean value that determines if the requests should be proxied even if Kong has troubles connecting a third-party data store. If true, requests will be proxied anyway, effectively disabling the rate-limiting function until the data store is working again. If false, then the clients will see 500 errors.

\n', + 'default': true, + 'valueType': 'boolean', + }, + { + 'id': 'config-hide_client_headers', + 'model': 'config-hide_client_headers', + 'type': 'checkbox', + 'required': true, + 'label': 'Config.Hide Client Headers', + 'help': '

Optionally hide informative response headers.

\n', + 'default': false, + 'valueType': 'boolean', + }, + { + 'id': 'config-hour', + 'model': 'config-hour', + 'type': 'input', + 'label': 'Config.Hour', + 'help': '

The number of HTTP requests that can be made per hour.

\n', + 'inputType': 'number', + 'valueType': 'number', + }, + { + 'id': 'config-minute', + 'model': 'config-minute', + 'type': 'input', + 'label': 'Config.Minute', + 'help': '

The number of HTTP requests that can be made per minute.

\n', + 'inputType': 'number', + 'valueType': 'number', + }, + { + 'id': 'config-month', + 'model': 'config-month', + 'type': 'input', + 'label': 'Config.Month', + 'help': '

The number of HTTP requests that can be made per month.

\n', + 'inputType': 'number', + 'valueType': 'number', + }, + { + 'id': 'config-second', + 'model': 'config-second', + 'type': 'input', + 'label': 'Config.Second', + 'help': '

The number of HTTP requests that can be made per second.

\n', + 'inputType': 'number', + 'valueType': 'number', + }, + { + 'id': 'config-sync_rate', + 'model': 'config-sync_rate', + 'type': 'input', + 'required': true, + 'label': 'Config.Sync Rate', + 'help': '

How often to sync counter data to the central data store. A value of -1 results in synchronous behavior.

\n', + 'selectOptions': { + 'hideNoneSelectedText': true, + }, + 'default': -1, + 'placeholder': 'Default: -1', + 'inputType': 'number', + 'valueType': 'number', + }, + { + 'id': 'config-year', + 'model': 'config-year', + 'type': 'input', + 'label': 'Config.Year', + 'help': '

The number of HTTP requests that can be made per year.

\n', + 'inputType': 'number', + 'valueType': 'number', + }, + ], + 'collapsible': { + 'title': 'Plugin Configuration', + 'description': 'Configuration parameters for this plugin. View advanced parameters for extended configuration.', + 'nestedCollapsible': { + 'fields': [ + { + 'id': '_redis', + 'fields': [ + { + 'id': 'config-redis-database', + 'model': 'config-redis-database', + 'type': 'input', + 'label': 'Config.Redis.Database', + 'help': '

Database to use for the Redis connection when using the redis strategy

\n', + 'default': 0, + 'placeholder': 'Default: 0', + 'inputType': 'number', + 'valueType': 'number', + }, + { + 'id': 'config-redis-host', + 'model': 'config-redis-host', + 'type': 'input', + 'label': 'Config.Redis.Host', + 'help': '

A string representing a host name, such as example.com.

\n', + 'inputType': 'text', + 'valueType': 'string', + }, + { + 'id': 'config-redis-password', + 'model': 'config-redis-password', + 'type': 'input', + 'referenceable': true, + 'label': 'Config.Redis.Password', + 'help': '

Password to use for Redis connections. If undefined, no AUTH commands are sent to Redis.

\n', + 'inputType': 'password', + 'valueType': 'string', + }, + { + 'id': 'config-redis-port', + 'model': 'config-redis-port', + 'type': 'input', + 'label': 'Config.Redis.Port', + 'help': '

An integer representing a port number between 0 and 65535, inclusive.

\n', + 'default': 6379, + 'placeholder': 'Default: 6379', + 'inputType': 'number', + 'valueType': 'number', + }, + { + 'id': 'config-redis-server_name', + 'model': 'config-redis-server_name', + 'type': 'input', + 'required': false, + 'label': 'Config.Redis.Server Name', + 'help': '

A string representing an SNI (server name indication) value for TLS.

\n', + 'inputType': 'text', + 'valueType': 'string', + }, + { + 'id': 'config-redis-ssl', + 'model': 'config-redis-ssl', + 'type': 'checkbox', + 'required': false, + 'label': 'Config.Redis.Ssl', + 'help': '

If set to true, uses SSL to connect to Redis.

\n', + 'default': false, + 'valueType': 'boolean', + }, + { + 'id': 'config-redis-ssl_verify', + 'model': 'config-redis-ssl_verify', + 'type': 'checkbox', + 'required': false, + 'label': 'Config.Redis.Ssl Verify', + 'help': '

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.

\n', + 'default': false, + 'valueType': 'boolean', + }, + { + 'id': 'config-redis-timeout', + 'model': 'config-redis-timeout', + 'type': 'input', + 'label': 'Config.Redis.Timeout', + 'help': '

An integer representing a timeout in milliseconds. Must be between 0 and 2^31-2.

\n', + 'default': 2000, + 'placeholder': 'Default: 2000', + 'inputType': 'number', + 'valueType': 'number', + }, + { + 'id': 'config-redis-username', + 'model': 'config-redis-username', + 'type': 'input', + 'referenceable': true, + 'label': 'Config.Redis.Username', + 'help': '

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.

\n', + 'inputType': 'text', + 'valueType': 'string', + }, + ], + 'model': '__redis_partial', + 'pluginType': 'bundled', + 'redisType': 'redis-ce', + 'order': -1, + }, + { + 'default': '', + 'type': 'input', + 'label': 'Instance Name', + 'inputType': 'text', + 'help': 'A custom name for this plugin instance to help identifying from the list view.', + 'id': 'instance_name', + 'model': 'instance_name', + }, + { + 'label': 'Tags', + 'name': 'tags', + 'type': 'input', + 'inputType': 'text', + 'valueType': 'array', + 'valueArrayType': 'string', + 'placeholder': 'Enter list of tags', + 'help': 'An optional set of strings for grouping and filtering, separated by commas.', + 'hint': 'e.g. tag1, tag2, tag3', + 'id': 'tags', + 'model': 'tags', + }, + { + 'id': 'config-error_code', + 'model': 'config-error_code', + 'type': 'input', + 'label': 'Config.Error Code', + 'help': '

Set a custom error code to return when the rate limit is exceeded.

\n', + 'default': 429, + 'placeholder': 'Default: 429', + 'inputType': 'number', + 'valueType': 'number', + }, + { + 'id': 'config-error_message', + 'model': 'config-error_message', + 'type': 'input', + 'label': 'Config.Error Message', + 'help': '

Set a custom error message to return when the rate limit is exceeded.

\n', + 'default': 'API rate limit exceeded', + 'placeholder': 'Default: API rate limit exceeded', + 'inputType': 'text', + 'valueType': 'string', + }, + { + 'id': 'config-header_name', + 'model': 'config-header_name', + 'type': 'input', + 'label': 'Config.Header Name', + 'help': '

A string representing an HTTP header name.

\n', + 'inputType': 'text', + 'valueType': 'string', + }, + { + 'id': 'config-limit_by', + 'model': 'config-limit_by', + 'type': 'select', + 'label': 'Config.Limit By', + 'help': '

The entity that is used when aggregating the limits.

\n', + 'values': [ + 'consumer', + 'credential', + 'ip', + 'service', + 'header', + 'path', + 'consumer-group', + ], + 'selectOptions': { + 'noneSelectedText': 'No selection...', + }, + 'default': 'consumer', + 'valueType': 'string', + }, + { + 'id': 'config-path', + 'model': 'config-path', + 'type': 'input', + 'label': 'Config.Path', + 'help': '

A string representing a URL path, such as /path/to/resource. Must start with a forward slash (/) and must not contain empty segments (i.e., two consecutive forward slashes).

\n', + 'inputType': 'text', + 'valueType': 'string', + }, + { + 'id': 'config-policy', + 'model': 'config-policy', + 'type': 'select', + 'label': 'Config.Policy', + 'help': '

The rate-limiting policies to use for retrieving and incrementing the limits.

\n', + 'values': [ + 'local', + 'cluster', + 'redis', + ], + 'selectOptions': { + 'noneSelectedText': 'No selection...', + }, + 'default': 'local', + 'valueType': 'string', + }, + ], + 'triggerLabel': { + 'expand': 'View Advanced Parameters', + 'collapse': 'Hide Advanced Parameters', + }, + }, + }, + 'slots': { + 'beforeContent': 'plugin-config-before-content', + 'emptyState': 'plugin-config-empty-state', + }, + }, + ], +} + +export const RLModel = { + 'name': 'rate-limiting', + 'enabled': true, + 'service-id': null, + 'route-id': null, + 'consumer-id': null, + 'consumer_group-id': null, + 'protocols': [ + 'grpc', + 'grpcs', + 'http', + 'https', + ], + 'instance_name': '', + 'tags': null, + 'config-day': null, + 'config-error_code': 429, + 'config-error_message': 'API rate limit exceeded', + 'config-fault_tolerant': true, + 'config-header_name': null, + 'config-hide_client_headers': false, + 'config-hour': null, + 'config-limit_by': 'consumer', + 'config-minute': null, + 'config-month': null, + 'config-path': null, + 'config-policy': 'local', + 'config-redis-database': 0, + 'config-redis-host': null, + 'config-redis-password': null, + 'config-redis-port': 6379, + 'config-redis-server_name': null, + 'config-redis-ssl': false, + 'config-redis-ssl_verify': false, + 'config-redis-timeout': 2000, + 'config-redis-username': null, + 'config-second': null, + 'config-sync_rate': -1, + 'config-year': null, +} + +export const customPluginSchema = { + 'groups': [ + { + 'fields': [ + { + 'default': 'custom-redis', + 'type': 'input', + 'inputType': 'hidden', + 'styleClasses': 'd-none hidden-field', + 'pinned': true, + 'id': 'name', + 'model': 'name', + 'label': '', + }, + { + 'type': 'switch', + 'model': 'enabled', + 'label': 'Enabled', + 'textOn': 'This plugin is Enabled', + 'textOff': 'This plugin is Disabled', + 'styleClasses': 'field-switch hide-label', + 'default': true, + 'pinned': true, + 'id': 'enabled', + }, + { + 'type': 'selectionGroup', + 'disabled': false, + 'inputType': 'hidden', + 'styleClasses': 'hide-label', + 'fields': [ + { + 'label': 'Global', + 'description': 'All services, routes, and consumers', + }, + { + 'label': 'Scoped', + 'description': 'Specific Gateway Services, Routes, Consumers, and/or Consumer Groups', + 'fields': [ + { + 'id': 'service-id', + 'model': 'service-id', + 'label': 'Gateway Service', + 'placeholder': 'Select a Gateway Service', + 'type': 'AutoSuggest', + 'entity': 'services', + 'inputValues': { + 'fields': [ + 'name', + 'id', + ], + }, + 'help': 'The Gateway Service to which this plugin configuration will apply', + 'disabled': false, + }, + { + 'id': 'route-id', + 'model': 'route-id', + 'label': 'Route', + 'placeholder': 'Select a Route', + 'type': 'AutoSuggest', + 'entity': 'routes', + 'inputValues': { + 'fields': [ + 'name', + 'id', + ], + 'primaryField': 'id', + }, + 'help': 'The Route that this Plugin configuration will target', + 'disabled': false, + }, + { + 'model': 'consumer-id', + 'label': 'Consumer', + 'placeholder': 'Select a Consumer', + 'type': 'AutoSuggest', + 'entity': 'consumers', + 'inputValues': { + 'fields': [ + 'username', + 'custom_id', + 'id', + ], + 'primaryField': 'username', + }, + 'help': 'The Consumer that this plugin configuration will target', + 'disabled': false, + }, + { + 'model': 'consumer_group-id', + 'label': 'Consumer Group', + 'placeholder': 'Select a Consumer Group', + 'type': 'AutoSuggest', + 'entity': 'consumer_groups', + 'entityDataKey': 'consumer_group', + 'inputValues': { + 'fields': [ + 'name', + 'id', + ], + 'primaryField': 'name', + }, + 'help': 'The Consumer Group that this plugin configuration will target', + 'disabled': false, + }, + ], + }, + ], + 'pinned': true, + 'id': 'selectionGroup', + 'model': 'selectionGroup', + 'label': '', + }, + ], + }, + { + 'fields': [ + { + 'id': 'protocols', + 'default': [ + 'grpc', + 'grpcs', + 'http', + 'https', + ], + 'help': 'A list of the request protocols that will trigger this plugin. The default value, as well as the possible values allowed on this field, may change depending on the plugin type.', + 'label': 'Protocols', + 'required': true, + 'styleClasses': 'plugin-protocols-select', + 'type': 'multiselect', + 'values': [ + { + 'label': 'grpc', + 'value': 'grpc', + }, + { + 'label': 'grpcs', + 'value': 'grpcs', + }, + { + 'label': 'http', + 'value': 'http', + }, + { + 'label': 'https', + 'value': 'https', + }, + ], + 'placeholder': 'Select valid protocols for the plugin. Default protocols: grpc, grpcs, http, https', + 'model': 'protocols', + }, + { + 'id': 'config-test_schema', + 'model': 'config-test_schema', + 'type': 'checkbox', + 'required': true, + 'label': 'Config.Test Schema', + 'default': true, + 'valueType': 'boolean', + }, + ], + 'collapsible': { + 'title': 'Plugin Configuration', + 'description': 'Configuration parameters for this plugin. View advanced parameters for extended configuration.', + 'nestedCollapsible': { + 'fields': [ + { + 'id': '_redis', + 'fields': [ + { + 'id': 'config-redis-host', + 'model': 'config-redis-host', + 'type': 'input', + 'label': 'Config.Redis.Host', + 'help': '

A string representing a host name, such as example.com.

\n', + 'default': '127.0.0.1', + 'placeholder': 'Default: 127.0.0.1', + 'inputType': 'text', + 'valueType': 'string', + }, + { + 'id': 'config-redis-port', + 'model': 'config-redis-port', + 'type': 'input', + 'label': 'Config.Redis.Port', + 'help': '

An integer representing a port number between 0 and 65535, inclusive.

\n', + 'default': 6379, + 'placeholder': 'Default: 6379', + 'inputType': 'number', + 'valueType': 'number', + }, + ], + 'model': '__redis_partial', + 'pluginType': 'custom', + 'order': -1, + }, + { + 'default': '', + 'type': 'input', + 'label': 'Instance Name', + 'inputType': 'text', + 'help': 'A custom name for this plugin instance to help identifying from the list view.', + 'id': 'instance_name', + 'model': 'instance_name', + }, + { + 'label': 'Tags', + 'name': 'tags', + 'type': 'input', + 'inputType': 'text', + 'valueType': 'array', + 'valueArrayType': 'string', + 'placeholder': 'Enter list of tags', + 'help': 'An optional set of strings for grouping and filtering, separated by commas.', + 'hint': 'e.g. tag1, tag2, tag3', + 'id': 'tags', + 'model': 'tags', + }, + ], + 'triggerLabel': { + 'expand': 'View Advanced Parameters', + 'collapse': 'Hide Advanced Parameters', + }, + }, + }, + 'slots': { + 'beforeContent': 'plugin-config-before-content', + 'emptyState': 'plugin-config-empty-state', + }, + }, + ], +} + +export const customPluginModel = { + 'name': 'custom-redis', + 'enabled': true, + 'service-id': null, + 'route-id': null, + 'consumer-id': null, + 'consumer_group-id': null, + 'protocols': [ + 'grpc', + 'grpcs', + 'http', + 'https', + ], + 'instance_name': '', + 'tags': null, + 'config-redis-host': '127.0.0.1', + 'config-redis-port': 6379, + 'config-test_schema': true, +} diff --git a/packages/core/forms/package.json b/packages/core/forms/package.json index 113f898cc3..9ef76645d7 100644 --- a/packages/core/forms/package.json +++ b/packages/core/forms/package.json @@ -55,11 +55,13 @@ "lodash-es": "^4.17.21" }, "peerDependencies": { + "@kong-ui-public/entities-shared": "workspace:^", "@kong-ui-public/i18n": "workspace:^", "@kong/kongponents": "^9.21.5", "vue": "^3.5.12" }, "devDependencies": { + "@kong-ui-public/entities-shared": "workspace:^", "@kong-ui-public/i18n": "workspace:^", "@kong/design-tokens": "1.17.2", "@kong/kongponents": "9.21.5", diff --git a/packages/core/forms/src/components/FormGenerator.vue b/packages/core/forms/src/components/FormGenerator.vue index 644795941d..da7eb1b1db 100644 --- a/packages/core/forms/src/components/FormGenerator.vue +++ b/packages/core/forms/src/components/FormGenerator.vue @@ -11,8 +11,21 @@ v-for="field in fields" :key="field.model" > + + 0 }, }, + + enableRedisPartial: { + type: Boolean, + default: false, + }, }, - emits: ['validated', 'modelUpdated', 'refreshModel'], + emits: ['validated', 'modelUpdated', 'refreshModel', 'partialToggled', 'showNewPartialModal'], data() { return { @@ -339,6 +371,10 @@ export default { this.$emit('modelUpdated', newVal, schema) }, + onPartialToggled(field, model) { + this.$emit('partialToggled', field, model) + }, + // Validating the model properties validate(isAsync = null) { if (isAsync === null) { diff --git a/packages/core/forms/src/components/FormRedis.vue b/packages/core/forms/src/components/FormRedis.vue new file mode 100644 index 0000000000..9cbc933544 --- /dev/null +++ b/packages/core/forms/src/components/FormRedis.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/packages/core/forms/src/components/RedisConfigCard.vue b/packages/core/forms/src/components/RedisConfigCard.vue new file mode 100644 index 0000000000..003f080125 --- /dev/null +++ b/packages/core/forms/src/components/RedisConfigCard.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/packages/core/forms/src/components/RedisConfigSelect.vue b/packages/core/forms/src/components/RedisConfigSelect.vue new file mode 100644 index 0000000000..3706d7f935 --- /dev/null +++ b/packages/core/forms/src/components/RedisConfigSelect.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/packages/core/forms/src/components/__tests__/FormGenerator.cy.ts b/packages/core/forms/src/components/__tests__/FormGenerator.cy.ts index 82ec050017..cffce4e587 100644 --- a/packages/core/forms/src/components/__tests__/FormGenerator.cy.ts +++ b/packages/core/forms/src/components/__tests__/FormGenerator.cy.ts @@ -4,16 +4,132 @@ // Cypress component test spec file import VueFormGenerator from '../FormGenerator.vue' -import { mount } from 'cypress/vue' +import { RLSchema, RLModel, customPluginSchema, customPluginModel, redisPartials, redisCEConfig } from '../../../fixtures/mockData' +import { createI18n } from '@kong-ui-public/i18n' +import english from '../../locales/en.json' + +const baseConfigKM = { + app: 'kongManager', + workspace: 'default', + apiBaseUrl: '/kong-manager', +} describe('', () => { - it('TODO: This is an example test', () => { - mount(VueFormGenerator, { + // it('should render redis fields as common fields when enableRedisPartial is not passed', () => { + // cy.mount(VueFormGenerator, { + // props: { + // schema: RLSchema, + // model: RLModel, + // enableRedisPartial: true, + // }, + // }) + // cy.get('.vue-form-generator').should('exist') + + // cy.getTestId('redis-config-card').should('not.exist') + + // }) + + it('should show shared config/grouped redis fields when toggling shared/dedicated redis configuration', () => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/partials?size=1000`, + }, + { + statusCode: 200, + body: { + data: redisPartials, + next: null, + }, + }, + ).as('getPartials') + cy.intercept( + { + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/partials/${redisPartials[0].id}`, + }, + { + statusCode: 200, + body: redisCEConfig, + }, + ).as('getRedisCEPartial') + cy.mount(VueFormGenerator, { props: { - schema: {}, + schema: RLSchema, + model: RLModel, + enableRedisPartial: true, + }, + global: { + provide: { + 'kong-ui-forms-config': baseConfigKM, + }, }, }) + cy.get('.vue-form-generator').should('exist') + cy.getTestId('collapse-trigger-label').click() + cy.getTestId('shared-redis-config-radio').click() + const [redisCEConfigDetail, redisEEConfigDetail] = redisPartials + cy.getTestId('redis-config-select').click() + + // should filter out EE plugins for CE plugin + cy.getTestId(`redis-configuration-dropdown-item-${redisCEConfigDetail.name}`).should('exist') + cy.getTestId(`redis-configuration-dropdown-item-${redisEEConfigDetail.name}`).should('not.exist') + cy.getTestId(`redis-configuration-dropdown-item-${redisCEConfigDetail.name}`).click() + cy.get('.partial-config-card').getTestId('name-property-value').should('contain.text', redisCEConfigDetail.name) + + }) + + it('should show redis configuration selector in advanced fields in custom plugin form', () => { + cy.intercept( + { + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/partials?size=1000`, + }, + { + statusCode: 200, + body: { + data: redisPartials, + next: null, + }, + }, + ).as('getPartials') + cy.intercept( + { + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/partials/${redisPartials[0].id}`, + }, + { + statusCode: 200, + body: redisCEConfig, + }, + ).as('getRedisCEPartial') + const { t } = createI18n('en-us', english) + cy.mount(VueFormGenerator, { + props: { + schema: customPluginSchema, + model: customPluginModel, + enableRedisPartial: true, + }, + global: { + provide: { + 'kong-ui-forms-config': baseConfigKM, + }, + }, + }) cy.get('.vue-form-generator').should('exist') + cy.getTestId('collapse-trigger-label').click() + cy.getTestId('custom-plugin-redis-config-note').should('contain.text', t('redis.custom_plugin.alert')) + cy.getTestId('redis-config-select').click() + + // all redis partials(CE/EE) are present in the redis configuration selector in a custom plugin form + for (const partial of redisPartials) { + cy.getTestId(`redis-configuration-dropdown-item-${partial.name}`).should('exist') + } + + const [redisCEConfigDetail] = redisPartials + cy.getTestId(`redis-configuration-dropdown-item-${redisCEConfigDetail.name}`).click() + cy.get('.partial-config-card').getTestId('name-property-value').should('contain.text', redisCEConfigDetail.name) + }) }) diff --git a/packages/core/forms/src/components/forms/OIDCForm.vue b/packages/core/forms/src/components/forms/OIDCForm.vue index 3b1ef91c86..6d04e22fbc 100644 --- a/packages/core/forms/src/components/forms/OIDCForm.vue +++ b/packages/core/forms/src/components/forms/OIDCForm.vue @@ -91,10 +91,13 @@ @@ -104,6 +107,7 @@ + + + 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..63226fc4be --- /dev/null +++ b/packages/entities/entities-redis-configurations/sandbox/pages/RedisConfigurationFormPage.vue @@ -0,0 +1,63 @@ + + + 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/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..9a0105edb4 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/LinkedPluginList.vue @@ -0,0 +1,91 @@ + + + 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..d5ed87d5f1 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationConfigCard.vue @@ -0,0 +1,396 @@ + + + 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..3bcf84e743 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationForm.cy.ts @@ -0,0 +1,897 @@ +import RedisConfigurationForm from './RedisConfigurationForm.vue' +import { RedisType, type RedisConfigurationResponse } from '../types' +import { redisConfigurationCE, redisConfigurationCluster, redisConfigurationHostPortEE, redisConfigurationSentinel } 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') + } + } + + 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 }) + + 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() + + 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 }) + + 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() + + // 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('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') + }) + }) + + 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() + + 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 }) + + 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 }) + + 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 }) + + 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 }) + + 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 }) + + 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..18b2bf2a2a --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationForm.vue @@ -0,0 +1,528 @@ + + + + + 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..eb269bb761 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationList.cy.ts @@ -0,0 +1,228 @@ +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('', () => { + + describe('actions', { + viewportHeight: 700, + viewportWidth: 700, + }, () => { + function interceptList({ + status = 200, + body = partials, + }: { + status?: number, + body?: any, + } = {}) { + cy.intercept({ + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/partials*`, + }, { + statusCode: status, + body, + }).as('getRedisConfigurations') + + cy.intercept({ + method: 'GET', + url: `${baseConfigKonnect.apiBaseUrl}/v2/control-planes/${baseConfigKonnect.controlPlaneId}/core-entities/partials*`, + }, { + statusCode: status, + body, + }).as('getRedisConfigurations') + } + + function interceptLinkedPlugins() { + cy.intercept({ + method: 'GET', + url: `${baseConfigKM.apiBaseUrl}/${baseConfigKM.workspace}/partials/*/links*`, + }, { + statusCode: 200, + body: links, + }).as('getLinkedPlugins') + + 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(), + }) + + // Mock data for each test in this block + interceptList() + }) + + for (const expected of [false, true]) { + describe(expected ? 'allowed' : 'denied', () => { + it(`should ${expected ? 'allow' : 'deny'} to create a new RedisConfiguration`, () => { + 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}`, () => { + cy.mount(RedisConfigurationList, { + props: { + config: baseConfigKonnect, + 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}`, () => { + cy.mount(RedisConfigurationList, { + props: { + config: baseConfigKonnect, + 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}`, () => { + cy.mount(RedisConfigurationList, { + props: { + config: baseConfigKonnect, + 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({ body: [] }) + + 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({ body: [] }) + + 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() + 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({ status: 500 }) + + 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() + interceptLinkedPlugins() + + 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') + }) + }) + } + }) +}) 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..a208517a5c --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/components/RedisConfigurationList.vue @@ -0,0 +1,395 @@ + + + + + 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..04c315eea7 --- /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 { computed, ref, watch } from 'vue' +import useSwrv from 'swrv' +import endpoints from '../partials-endpoints' +import type { RedisConfigurationLinkedPluginsResponse } from '../types' + +type RequestParams = { + size?: number, + offset?: string | null, +} + +export function buildLinksCacheKey(partialId: string) { + return `redis-partial-links-${partialId}` +} + +export const useLinkedPluginsFetcher = (param: { + partialId: string, + config: KonnectConfig | KongManagerConfig, +}) => { + const { partialId, config } = param + const { axiosInstance } = useAxios(config.axiosRequestConfig) + + const linksUrl = computed(() => { + let url = `${config.apiBaseUrl}${endpoints.links[config.app].all}` + + 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 + }) + + return { + fetcher: async (params?: RequestParams) => { + const { data } = await axiosInstance.get(linksUrl.value, { params }) + // todo(zehao): remove this when the backend is fixed + // https://kongstrong.slack.com/archives/C0663589T3R/p1739519613780909?thread_ts=1739446191.148239&cid=C0663589T3R + if (data && !Array.isArray(data.data)) { + data.data = [] + } + return data + }, + } +} + +export const useLinkedPlugins = (param: { + partialId: string, + config: KonnectConfig | KongManagerConfig, + requestParams?: RequestParams, +}) => { + const { partialId, config } = param + + const { fetcher } = useLinkedPluginsFetcher({ partialId, config }) + const { data } = useSwrv( + buildLinksCacheKey(partialId), + () => fetcher(), + { + revalidateOnFocus: false, + }, + ) + const result = ref([]) + + 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..e29de23365 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/composables/useRedisConfigurationForm.ts @@ -0,0 +1,247 @@ +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 + config: KonnectRedisConfigurationFormConfig | KongManagerRedisConfigurationFormConfig +} + +export const useRedisConfigurationForm = (options: Options) => { + const { partialId, config } = 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(DEFAULT_REDIS_TYPE), + config: JSON.parse(JSON.stringify(DEFAULT_FIELDS)), + }, + readonly: false, + errorMessage: '', + }) + + // Used to diff the form values when editing + const initialPayload = ref() + const redisType = ref(DEFAULT_REDIS_TYPE) + 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), + connection_is_proxied: form.fields.config.connection_is_proxied, + // reset other EE fields + 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), + connection_is_proxied: form.fields.config.connection_is_proxied, + // reset other EE fields + 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 { + 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..f58f3407cd --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/index.ts @@ -0,0 +1,21 @@ +import RedisConfigurationForm from './components/RedisConfigurationForm.vue' +import RedisConfigurationList from './components/RedisConfigurationList.vue' +import RedisConfigurationConfigCard from './components/RedisConfigurationConfigCard.vue' +import LinkedPlugins from './components/LinkedPluginList.vue' + +export { + RedisConfigurationForm, + RedisConfigurationList, + RedisConfigurationConfigCard, + LinkedPlugins, +} + +export * from './types' + +import * as helpers from './helpers' + +export { helpers } + +import * as constants from './constants' + +export { constants } 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..fc9c9a2185 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/locales/en.json @@ -0,0 +1,203 @@ +{ + "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" + }, + "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?" + }, + "errors": { + "general": "Redis configuration could not be retrieved", + "delete": "The redis configuration could not be deleted at this time." + }, + "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 when using the `redis` strategy" + }, + "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." + }, + "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." + }, + "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" + }, + "send_timeout": { + "label": "Send timeout" + }, + "connect_timeout": { + "label": "Connect timeout" + }, + "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" + } + } + }, + "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." + } + } +} 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..80fc2a79a7 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/partials-endpoints.ts @@ -0,0 +1,31 @@ +const konnectBaseApiUrl = '/v2/control-planes/{controlPlaneId}/core-entities' +const KMBaseApiUrl = '/{workspace}' + +export default { + list: { + konnect: { + all: `${konnectBaseApiUrl}/partials`, + }, + kongManager: { + all: `${KMBaseApiUrl}/partials`, + }, + }, + form: { + konnect: { + create: `${konnectBaseApiUrl}/partials`, + edit: `${konnectBaseApiUrl}/partials/{id}`, + }, + kongManager: { + create: `${KMBaseApiUrl}/partials`, + edit: `${KMBaseApiUrl}/partials/{id}`, + }, + }, + links: { + konnect: { + all: `${konnectBaseApiUrl}/partials/{id}/links`, + }, + kongManager: { + all: `${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..907d5033ae --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/types/redis-configuration-linked-plugins.ts @@ -0,0 +1,10 @@ +export type RedisConfigurationLinkedPlugin = { + id: string + name: string + instance_name?: string +} + +export type RedisConfigurationLinkedPluginsResponse = { + next: string | null + 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..55d8a37d23 --- /dev/null +++ b/packages/entities/entities-redis-configurations/src/types/redis-configuration-list.ts @@ -0,0 +1,25 @@ +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 +} 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..90d1798d25 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 +266,7 @@ if (props.config.app === 'konnect') { }) } -const configFormat = ref('structured') +const configFormat = ref('structured') const handleChange = (payload: any): void => { configFormat.value = payload?.value @@ -292,6 +309,12 @@ const DEFAULT_BASIC_FIELDS_CONFIGURATION: DefaultCommonFieldsConfigurationSchema order: -1, // the last property displayed section: ConfigurationSchemaSection.Basic, }, + partials: { + type: ConfigurationSchemaType.LinkInternal, + label: t('baseConfigCard.commonFields.partial_label'), + order: -1, // the last property displayed + section: ConfigurationSchemaSection.Basic, + }, } const isLoading = ref(false) @@ -463,6 +486,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/locales/en.json b/packages/entities/entities-shared/src/locales/en.json index d89ae7205d..f20dc64a01 100644 --- a/packages/entities/entities-shared/src/locales/en.json +++ b/packages/entities/entities-shared/src/locales/en.json @@ -53,7 +53,8 @@ "updated_at_label": "Last Updated", "created_at_label": "Created", "tags_label": "Tags", - "link": "Link" + "link": "Link", + "partial_label": "Redis Configuration" }, "statusBadge": { "enabledLabel": "Enabled", 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..1b7f3b1cfc 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!! @@ -99,6 +100,7 @@ export interface DefaultCommonFieldsConfigurationSchema { updated_at: ConfigurationSchemaItem created_at: ConfigurationSchemaItem tags: ConfigurationSchemaItem + partials: ConfigurationSchemaItem } export interface ComponentAttrsData { 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 @@ >