Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions docs/widgets-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
234 changes: 163 additions & 71 deletions src/hooks/useHandleActions.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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, {
Expand All @@ -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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useWidgetQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Widget> {
if (!requestUrl) {
Expand Down
1 change: 1 addition & 0 deletions src/types/Widget.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export type WidgetActions = {
timeout?: number
reloadRoutes?: boolean
loadingMessage?: string
mode?: 'navigate' | 'notification'
}
payloadToOverride?: {
name: string
Expand Down
Loading
Loading