diff --git a/CHANGELOG.md b/CHANGELOG.md index defcf4f..3233a09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Previous changes not listed in this document can be traced using Git history. -## Unreleased +## [1.0.26] - 2026-06-16 ### Added +- Added `mode` property to `onEventNavigateTo` action to enable notification driven redirect - Added `displayingDependencies` property to `Form` widget to show / hide fields ## [1.0.25] - 2026-05-12 diff --git a/docs/widgets-api-reference.md b/docs/widgets-api-reference.md index 86e0b3e..4e29212 100644 --- a/docs/widgets-api-reference.md +++ b/docs/widgets-api-reference.md @@ -47,6 +47,7 @@ Button represents an interactive component which, when clicked, triggers a speci | actions.rest[].onEventNavigateTo.timeout | no | the timeout in seconds to wait for the event | integer | | actions.rest[].onEventNavigateTo.reloadRoutes | no | | boolean | | actions.rest[].onEventNavigateTo.loadingMessage | no | message to display while waiting for the event | string | +| actions.rest[].onEventNavigateTo.mode | no | navigate (default): closes drawer and redirects on event. notification: closes drawer immediately, shows a persistent notification with loader until event is received | `navigate` \| `notification` | | actions.rest[].type | yes | type of action to execute | `rest` | | actions.rest[].headers | yes | array of headers as strings, format 'key: value' | array | | actions.rest[].payload | no | static payload sent with the request | object | @@ -287,6 +288,7 @@ name of the k8s Custom Resource | actions.rest[].onEventNavigateTo.timeout | no | the timeout in seconds to wait for the event | integer | | actions.rest[].onEventNavigateTo.reloadRoutes | no | | boolean | | actions.rest[].onEventNavigateTo.loadingMessage | no | message to display while waiting for the event | string | +| actions.rest[].onEventNavigateTo.mode | no | navigate (default): closes drawer and redirects on event. notification: closes drawer immediately, shows a persistent notification with loader until event is received | `navigate` \| `notification` | | actions.rest[].type | yes | type of action to execute | `rest` | | actions.rest[].payload | no | static payload sent with the request | object | | actions.rest[].payloadToOverride | no | list of payload fields to override dynamically | array | @@ -346,6 +348,18 @@ name of the k8s Custom Resource | dependencies[].extra.key | yes | the key of the additional parameter | string | | dependencies[].name | yes | the name of the autocomplete field | string | | dependencies[].resourceRefId | yes | the identifier of the RESTAction that should be called to retrieve dependency data | string | +| displayingDependencies | no | Configuration for the form fields whose displaying is dependent from other form fields. | array | +| displayingDependencies[].name | yes | the name of the field | string | +| displayingDependencies[].dependsOn | yes | the field on which this field depends on | object | +| displayingDependencies[].dependsOn.name | yes | the name of the field on which this field depends on | string | +| displayingDependencies[].dependsOn.conditionType | yes | `notEmpty` (default) indicates that the current field is displayed if the dependency field has a non-empty value. `value` indicates that the current field is displayed only when the dependency field matches a specific value. | `notEmpty` \| `value` | +| displayingDependencies[].dependsOn.value | no | Expected value used when `type = value`. | object | +| displayingDependencies[].dependsOn.value.type | yes | Expected dependency value type. | `string` \| `integer` \| `boolean` \| `array` \| `option` \| `null` | +| displayingDependencies[].dependsOn.value.stringValue | no | Value if type = string | string | +| displayingDependencies[].dependsOn.value.integerValue | no | Value if type = integer | integer | +| displayingDependencies[].dependsOn.value.booleanValue | no | Value if type = boolean | boolean | +| displayingDependencies[].dependsOn.value.arrayValue | no | Value if type = array | array | +| displayingDependencies[].dependsOn.value.optionValue | no | Value if type = option: includes a single value or a { label, value } object | unknown | | objectFields | no | configuration for object fields in the form | array | | objectFields[].path | yes | the path of the object field | string | | objectFields[].displayField | yes | the field to display in the objects list | string | @@ -435,6 +449,23 @@ NavMenuItem represents a single item in the navigation menu and links to a speci --- +### Notifications + +Notifications renders messages coming from a Kubernetes cluster + +#### Props + +| Property | Required | Description | Type | +|----------|----------|-------------|------| +| queryParams | no | list of query parameters to add to the notifications call | array | +| queryParams[].name | yes | the name of the query parameter | string | +| queryParams[].value | yes | the value of the query parameter | string | + + +[Examples](../src/examples/widgets/Notifications/Notifications.example.yaml) + +--- + ### Page Page is a wrapper component, placed at the top of the component tree, that wraps and renders all nested components. @@ -474,6 +505,7 @@ Panel is a container to display information | actions.rest[].onEventNavigateTo.timeout | no | the timeout in seconds to wait for the event | integer | | actions.rest[].onEventNavigateTo.reloadRoutes | no | | boolean | | actions.rest[].onEventNavigateTo.loadingMessage | no | message to display while waiting for the event | string | +| actions.rest[].onEventNavigateTo.mode | no | navigate (default): closes drawer and redirects on event. notification: closes drawer immediately, shows a persistent notification with loader until event is received | `navigate` \| `notification` | | actions.rest[].type | yes | type of action to execute | `rest` | | actions.rest[].payload | no | static payload sent with the request | object | | actions.rest[].payloadToOverride | no | list of payload fields to override dynamically | array | @@ -633,6 +665,7 @@ Table displays structured data with customizable columns and pagination | actions.rest[].onEventNavigateTo.timeout | no | the timeout in seconds to wait for the event | integer | | actions.rest[].onEventNavigateTo.reloadRoutes | no | | boolean | | actions.rest[].onEventNavigateTo.loadingMessage | no | message to display while waiting for the event | string | +| actions.rest[].onEventNavigateTo.mode | no | navigate (default): closes drawer and redirects on event. notification: closes drawer immediately, shows a persistent notification with loader until event is received | `navigate` \| `notification` | | actions.rest[].type | yes | type of action to execute | `rest` | | actions.rest[].headers | yes | array of headers as strings, format 'key: value' | array | | actions.rest[].payload | no | static payload sent with the request | object | diff --git a/src/hooks/useHandleActions.ts b/src/hooks/useHandleActions.ts index 15a8862..09e4250 100644 --- a/src/hooks/useHandleActions.ts +++ b/src/hooks/useHandleActions.ts @@ -1,9 +1,10 @@ +import { LoadingOutlined } from '@ant-design/icons' import { useQueryClient } from '@tanstack/react-query' import useApp from 'antd/es/app/useApp' import type { EventListener, MessageEvent } from 'event-source-polyfill' import { EventSourcePolyfill } from 'event-source-polyfill' import { merge, set } from 'lodash' -import { useState } from 'react' +import { createElement, useState } from 'react' import { useLocation, useNavigate } from 'react-router' import { useAuth } from '../context/AuthContext' @@ -257,6 +258,8 @@ export const useHandleAction = () => { let eventReceived = false if (onEventNavigateTo) { + const mode = onEventNavigateTo.mode ?? 'navigate' + const eventsEndpoint = `${config!.api.EVENTS_PUSH_API_BASE_URL}/notifications` const eventSource = new EventSourcePolyfill(eventsEndpoint, { @@ -266,100 +269,189 @@ export const useHandleAction = () => { withCredentials: false, }) - let description = `Timeout waiting for event ${onEventNavigateTo.eventReason}` - // eslint-disable-next-line max-depth - if (errorMessage) { - description = errorMessage.startsWith('${') - ? await resolveJq(errorMessage, { + const defaultLoadingMessage = mode === 'notification' + ? 'Waiting for resource...' + : 'Waiting for resource and redirecting...' + + const loadingMessage = onEventNavigateTo.loadingMessage + ? await resolveJq(onEventNavigateTo.loadingMessage, { json: payload, response: jsonResponse }) + : defaultLoadingMessage + + const resolveErrorDescription = async () => { + let description = `Timeout waiting for event ${onEventNavigateTo.eventReason}` + if (errorMessage) { + description = errorMessage.startsWith('${') + ? await resolveJq(errorMessage, { json: payload, response: jsonResponse }) + : errorMessage + } + return description + } + + const resolveRedirectUrl = async (eventData: EventsApiResource) => { + if (onEventNavigateTo.url.startsWith('${')) { + return resolveJq(onEventNavigateTo.url, { + event: eventData as unknown as Record, json: payload, response: jsonResponse, }) - : errorMessage + } + if (customPayload) { + return interpolateRedirectUrl(customPayload, onEventNavigateTo.url) + } + return onEventNavigateTo.url } - const timeoutId = setTimeout(() => { - if (!eventReceived) { - setIsActionLoading(false) - eventSource.close() - notification.error({ - description, - message: 'Error while executing the action', - placement: 'bottomLeft', - }) + const resolveSuccessDescription = async (eventData: EventsApiResource) => { + if (successMessage) { + return successMessage.startsWith('${') + ? resolveJq(successMessage, { + event: eventData as unknown as Record, + json: payload, + response: jsonResponse, + }) + : successMessage } - message.destroy() - }, onEventNavigateTo.timeout! * 1000) + return 'The action has been executed successfully' + } - const loadingMessage = onEventNavigateTo.loadingMessage - ? await resolveJq(onEventNavigateTo.loadingMessage, { json: payload, response: jsonResponse }) - : 'Waiting for resource and redirecting...' + // eslint-disable-next-line max-depth + if (mode === 'notification') { + const notificationKey = `event-${onEventNavigateTo.eventReason}-${Date.now()}` + + setIsActionLoading(false) + closeDrawer() + + notification.info({ + description: loadingMessage, + duration: 0, + icon: createElement(LoadingOutlined, { spin: true }), + key: notificationKey, + message: 'Action in progress', + placement: 'topRight', + }) + + const timeoutId = setTimeout(() => { + void (async () => { + if (!eventReceived) { + eventSource.close() + notification.error({ + description: await resolveErrorDescription(), + duration: 0, + key: notificationKey, + message: 'Error while executing the action', + placement: 'topRight', + }) + } + })() + }, onEventNavigateTo.timeout! * 1000) - message.loading(loadingMessage, onEventNavigateTo.timeout) - eventSource.addEventListener('krateo', ((event: MessageEvent) => { - if (!resourceUid) { return } + eventSource.addEventListener('krateo', ((event: MessageEvent) => { + if (!resourceUid) { return } - const eventData = JSON.parse(event.data as string) as EventsApiResource + const eventData = JSON.parse(event.data as string) as EventsApiResource - if (eventData.reason === onEventNavigateTo.eventReason && eventData.involved_object_uid === resourceUid) { - eventReceived = true + if (eventData.reason === onEventNavigateTo.eventReason && eventData.involved_object_uid === resourceUid) { + eventReceived = true - if (onEventNavigateTo.reloadRoutes !== false) { - void reloadRoutes() - } + if (onEventNavigateTo.reloadRoutes !== false) { + void reloadRoutes() + } - eventSource.close() - clearTimeout(timeoutId) + eventSource.close() + clearTimeout(timeoutId) - void (async () => { - let redirectUrl: string | null | undefined + void (async () => { + const redirectUrl = await resolveRedirectUrl(eventData) - if (onEventNavigateTo.url.startsWith('${')) { - redirectUrl = await resolveJq(onEventNavigateTo.url, { - event: eventData as unknown as Record, - json: payload, - response: jsonResponse, + if (!redirectUrl) { + notification.error({ + description: 'Impossible to redirect, the route contains an undefined value', + message: 'Error while redirecting', + placement: 'bottomLeft', + }) + return + } + + notification.success({ + description: createElement( + 'span', + null, + await resolveSuccessDescription(eventData), + ' — ', + createElement( + 'a', + { href: redirectUrl, onClick: () => { notification.destroy(notificationKey) } }, + 'Go to resource' + ) + ), + duration: 0, + key: notificationKey, + message: `Successfully executed action`, + placement: 'topRight', }) - } else if (customPayload) { - redirectUrl = interpolateRedirectUrl(customPayload, onEventNavigateTo.url) - } else { - redirectUrl = onEventNavigateTo.url - } - - if (!redirectUrl) { - message.destroy() + })() + } + }) as EventListener) + } else { + const timeoutId = setTimeout(() => { + if (!eventReceived) { + setIsActionLoading(false) + eventSource.close() + void (async () => { notification.error({ - description: 'Impossible to redirect, the route contains an undefined value', - message: 'Error while redirecting', + description: await resolveErrorDescription(), + message: 'Error while executing the action', placement: 'bottomLeft', }) + })() + } + message.destroy() + }, onEventNavigateTo.timeout! * 1000) + + message.loading(loadingMessage, onEventNavigateTo.timeout) + + eventSource.addEventListener('krateo', ((event: MessageEvent) => { + if (!resourceUid) { return } + + const eventData = JSON.parse(event.data as string) as EventsApiResource - return + if (eventData.reason === onEventNavigateTo.eventReason && eventData.involved_object_uid === resourceUid) { + eventReceived = true + + if (onEventNavigateTo.reloadRoutes !== false) { + void reloadRoutes() } - let description = 'The action has been executed successfully' - if (successMessage) { - description = successMessage.startsWith('${') - ? await resolveJq(successMessage, { - event: eventData as unknown as Record, - json: payload, - response: jsonResponse, + eventSource.close() + clearTimeout(timeoutId) + + void (async () => { + const redirectUrl = await resolveRedirectUrl(eventData) + + if (!redirectUrl) { + message.destroy() + notification.error({ + description: 'Impossible to redirect, the route contains an undefined value', + message: 'Error while redirecting', + placement: 'bottomLeft', }) - : successMessage - } + return + } - message.destroy() - notification.success({ - description, - message: `Successfully executed action`, - placement: 'bottomLeft', - }) + message.destroy() + notification.success({ + description: await resolveSuccessDescription(eventData), + message: `Successfully executed action`, + placement: 'bottomLeft', + }) - setIsActionLoading(false) - closeDrawer() - void navigate(redirectUrl) - })() - } - }) as EventListener) + setIsActionLoading(false) + closeDrawer() + void navigate(redirectUrl) + })() + } + }) as EventListener) + } } const updatedUrl = customPayload diff --git a/src/hooks/useWidgetQuery.ts b/src/hooks/useWidgetQuery.ts index c460f44..dfb129b 100644 --- a/src/hooks/useWidgetQuery.ts +++ b/src/hooks/useWidgetQuery.ts @@ -56,7 +56,7 @@ export const useWidgetQuery = (widgetEndpoint: string, options?: { enabled: bool } }, [widgetFullUrl]) - const enabledFlag = (options?.enabled ?? true) && requestUrl !== null + const enabledFlag = (options?.enabled ?? true) && requestUrl !== null && !!widgetEndpoint async function fetchWidget({ cursor, page, perPage }: PageParam): Promise { if (!requestUrl) { diff --git a/src/types/Widget.d.ts b/src/types/Widget.d.ts index d58cb18..a3f2e27 100644 --- a/src/types/Widget.d.ts +++ b/src/types/Widget.d.ts @@ -62,6 +62,7 @@ export type WidgetActions = { timeout?: number reloadRoutes?: boolean loadingMessage?: string + mode?: 'navigate' | 'notification' } payloadToOverride?: { name: string diff --git a/src/widgets/Button/Button.schema.json b/src/widgets/Button/Button.schema.json index 9b1840e..676bd1f 100644 --- a/src/widgets/Button/Button.schema.json +++ b/src/widgets/Button/Button.schema.json @@ -82,6 +82,12 @@ "loadingMessage": { "type": "string", "description": "message to display while waiting for the event" + }, + "mode": { + "type": "string", + "enum": ["navigate", "notification"], + "default": "navigate", + "description": "navigate (default): closes drawer and redirects on event. notification: closes drawer immediately, shows a persistent notification with loader until event is received" } } }, diff --git a/src/widgets/Button/Button.type.d.ts b/src/widgets/Button/Button.type.d.ts index 6419801..eb4e727 100644 --- a/src/widgets/Button/Button.type.d.ts +++ b/src/widgets/Button/Button.type.d.ts @@ -66,6 +66,10 @@ export interface Button { * message to display while waiting for the event */ loadingMessage?: string + /** + * navigate (default): closes drawer and redirects on event. notification: closes drawer immediately, shows a persistent notification with loader until event is received + */ + mode?: 'navigate' | 'notification' } /** * type of action to execute diff --git a/src/widgets/Form/Form.schema.json b/src/widgets/Form/Form.schema.json index 6c4377d..d57c31d 100644 --- a/src/widgets/Form/Form.schema.json +++ b/src/widgets/Form/Form.schema.json @@ -92,6 +92,12 @@ "loadingMessage": { "type": "string", "description": "message to display while waiting for the event" + }, + "mode": { + "type": "string", + "enum": ["navigate", "notification"], + "default": "navigate", + "description": "navigate (default): closes drawer and redirects on event. notification: closes drawer immediately, shows a persistent notification with loader until event is received" } } }, diff --git a/src/widgets/Form/Form.type.d.ts b/src/widgets/Form/Form.type.d.ts index 6b0ad93..aa83f76 100644 --- a/src/widgets/Form/Form.type.d.ts +++ b/src/widgets/Form/Form.type.d.ts @@ -73,6 +73,10 @@ export interface Form { * message to display while waiting for the event */ loadingMessage?: string + /** + * navigate (default): closes drawer and redirects on event. notification: closes drawer immediately, shows a persistent notification with loader until event is received + */ + mode?: 'navigate' | 'notification' } /** * type of action to execute @@ -351,7 +355,7 @@ export interface Form { */ arrayValue?: string[] /** - * Value if type = option + * Value if type = option: includes a single value or a { label, value } object */ optionValue?: { /** diff --git a/src/widgets/Panel/Panel.schema.json b/src/widgets/Panel/Panel.schema.json index c775fac..42626e5 100644 --- a/src/widgets/Panel/Panel.schema.json +++ b/src/widgets/Panel/Panel.schema.json @@ -89,6 +89,12 @@ "loadingMessage": { "type": "string", "description": "message to display while waiting for the event" + }, + "mode": { + "type": "string", + "enum": ["navigate", "notification"], + "default": "navigate", + "description": "navigate (default): closes drawer and redirects on event. notification: closes drawer immediately, shows a persistent notification with loader until event is received" } } }, diff --git a/src/widgets/Panel/Panel.type.d.ts b/src/widgets/Panel/Panel.type.d.ts index 37df422..93f1089 100644 --- a/src/widgets/Panel/Panel.type.d.ts +++ b/src/widgets/Panel/Panel.type.d.ts @@ -67,6 +67,10 @@ export interface Panel { * message to display while waiting for the event */ loadingMessage?: string + /** + * navigate (default): closes drawer and redirects on event. notification: closes drawer immediately, shows a persistent notification with loader until event is received + */ + mode?: 'navigate' | 'notification' } /** * type of action to execute diff --git a/src/widgets/Table/Table.schema.json b/src/widgets/Table/Table.schema.json index ea2f5f4..a4b6367 100644 --- a/src/widgets/Table/Table.schema.json +++ b/src/widgets/Table/Table.schema.json @@ -82,6 +82,12 @@ "loadingMessage": { "type": "string", "description": "message to display while waiting for the event" + }, + "mode": { + "type": "string", + "enum": ["navigate", "notification"], + "default": "navigate", + "description": "navigate (default): closes drawer and redirects on event. notification: closes drawer immediately, shows a persistent notification with loader until event is received" } } }, diff --git a/src/widgets/Table/Table.type.d.ts b/src/widgets/Table/Table.type.d.ts index d5ade84..21aa9ac 100644 --- a/src/widgets/Table/Table.type.d.ts +++ b/src/widgets/Table/Table.type.d.ts @@ -63,6 +63,10 @@ export interface Table { * message to display while waiting for the event */ loadingMessage?: string + /** + * navigate (default): closes drawer and redirects on event. notification: closes drawer immediately, shows a persistent notification with loader until event is received + */ + mode?: 'navigate' | 'notification' } /** * type of action to execute