diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3233a09..824fb3f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,12 @@ 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
+
+### Added
+
+- Added `externalNavigate` and `refresh` actions
+
## [1.0.26] - 2026-06-16
### Added
diff --git a/docs/actions.md b/docs/actions.md
new file mode 100644
index 0000000..5c590c2
--- /dev/null
+++ b/docs/actions.md
@@ -0,0 +1,296 @@
+# Actions
+
+Actions define what happens when a user interacts with a widget — clicking a button, selecting a row, submitting a form, or clicking a panel. Every action is declared inside `spec.widgetData.actions` and referenced by ID from the widget's interaction trigger (e.g. `clickActionId`).
+
+For a full list of properties per action type, see the [Widgets API Reference](./widgets-api-reference.md).
+
+---
+
+## Common properties
+
+Two properties are available on every action type:
+
+| Property | Type | Description |
+|---|---|---|
+| `requireConfirmation` | boolean | If `true`, shows a browser confirmation dialog before the action executes. The action is cancelled if the user dismisses it. |
+| `loading.display` | boolean | If `true`, the triggering widget shows a loading indicator for the duration of the action. |
+
+---
+
+## `navigate`
+
+Navigates to a different route within the portal.
+
+There are two modes:
+
+- **By path**: provide `path` to navigate to a fixed or dynamically resolved route.
+- **By resource**: provide `resourceRefId` to navigate to the endpoint of a referenced widget. The resulting URL is `{currentPath}?widgetEndpoint={encodedEndpoint}`. Use `resourceURLPathExtension` to override the base path instead of inheriting the current route.
+
+Both `path` and `resourceRefId` support JQ expressions using the `${ }` syntax, which allows values to be resolved from the triggering widget's payload (e.g. a table row).
+
+| Property | Required | Description |
+|---|---|---|
+| `id` | yes | unique identifier for the action |
+| `type` | yes | must be `navigate` |
+| `path` | no | route to navigate to; supports JQ expressions |
+| `resourceRefId` | no | ID of an entry in `resourcesRefs` to navigate to; supports JQ expressions |
+| `resourceURLPathExtension` | no | overrides the base path used when navigating via `resourceRefId` |
+| `requireConfirmation` | no | see [Common properties](#common-properties) |
+| `loading.display` | no | see [Common properties](#common-properties) |
+
+**Example — navigate to a fixed path:**
+
+```yaml
+actions:
+ navigate:
+ - id: go-to-deployments
+ type: navigate
+ path: /deployments
+```
+
+**Example — navigate to a path built from a table row:**
+
+```yaml
+actions:
+ navigate:
+ - id: go-to-pod
+ type: navigate
+ path: ${ "/pods/" + .json.name }
+```
+
+---
+
+## `openDrawer`
+
+Opens a widget inside a side drawer panel. The widget to display is identified by a `resourceRefId` that must match an entry in the widget's `resourcesRefs`.
+
+Both `resourceRefId` and `title` support JQ expressions, which means the drawer can show different widgets or titles depending on which row or element triggered the action.
+
+| Property | Required | Description |
+|---|---|---|
+| `id` | yes | unique identifier for the action |
+| `type` | yes | must be `openDrawer` |
+| `resourceRefId` | yes | ID of the widget to display inside the drawer; supports JQ expressions |
+| `title` | no | text shown in the drawer header; supports JQ expressions |
+| `size` | no | `default` or `large` |
+| `requireConfirmation` | no | see [Common properties](#common-properties) |
+| `loading.display` | no | see [Common properties](#common-properties) |
+
+**Example:**
+
+```yaml
+actions:
+ openDrawer:
+ - id: open-pod-detail
+ type: openDrawer
+ resourceRefId: ${ "pod-detail-" + .json.name }
+ title: ${ "Details for " + .json.name }
+ size: large
+```
+
+---
+
+## `openModal`
+
+Opens a widget inside a modal dialog. Works the same way as `openDrawer` but renders in a centered overlay. The modal offers more size options, including a fully custom width.
+
+Both `resourceRefId` and `title` support JQ expressions.
+
+| Property | Required | Description |
+|---|---|---|
+| `id` | yes | unique identifier for the action |
+| `type` | yes | must be `openModal` |
+| `resourceRefId` | yes | ID of the widget to display inside the modal; supports JQ expressions |
+| `title` | no | text shown in the modal header; supports JQ expressions |
+| `size` | no | `default` (520 px), `large` (80% viewport width), `fullscreen` (100%), or `custom` |
+| `customWidth` | no | custom width value (e.g. `800px`); only used when `size` is `custom` |
+| `requireConfirmation` | no | see [Common properties](#common-properties) |
+| `loading.display` | no | see [Common properties](#common-properties) |
+
+**Example:**
+
+```yaml
+actions:
+ openModal:
+ - id: open-namespace-modal
+ type: openModal
+ resourceRefId: ${ "namespace-detail-" + .json.name }
+ title: Namespace details
+ size: large
+```
+
+---
+
+## `rest`
+
+Triggers an HTTP request against a Kubernetes resource via the Snowplow API. The HTTP verb (GET, POST, PUT, PATCH, DELETE) is determined by the matching entry in `resourcesRefs`.
+
+The `rest` action is the primary way to mutate server state from the portal. On a successful response, it shows a success notification, closes any open drawer, and invalidates all cached widget queries so the page reflects the updated state.
+
+### Payload
+
+The request body is built by merging:
+1. The static `payload` defined on the action.
+2. The payload carried by the matching resource ref.
+3. Any fields listed in `payloadToOverride`, which are resolved at the time the action fires.
+
+`payloadToOverride` entries support JQ expressions against the triggering widget's payload (e.g. a table row), making it possible to inject row values into a request body.
+
+### Post-request navigation
+
+Two mutually exclusive post-request navigation options are available:
+
+- **`onSuccessNavigateTo`**: a URL to navigate to immediately after a successful response. Supports JQ expressions evaluated against the request payload and the API response.
+- **`onEventNavigateTo`**: waits for a Kubernetes event matching `eventReason` and the UID of the created/updated resource, then navigates. Useful when the action triggers an asynchronous reconciliation loop. Supports two `mode` values:
+ - `navigate` (default): shows a loading message inline and redirects when the event arrives.
+ - `notification`: closes the drawer immediately and shows a persistent notification that updates when the event arrives.
+
+| Property | Required | Description |
+|---|---|---|
+| `id` | yes | unique identifier for the action |
+| `type` | yes | must be `rest` |
+| `resourceRefId` | yes | ID of the resource in `resourcesRefs` to call; supports JQ expressions |
+| `payload` | no | static payload merged into the request body |
+| `payloadToOverride` | no | list of `{name, value}` pairs to override in the payload; `value` supports JQ |
+| `headers` | no | additional request headers, each as a `"Key: Value"` string |
+| `successMessage` | no | custom success notification message; supports JQ |
+| `errorMessage` | no | custom error notification message; supports JQ |
+| `onSuccessNavigateTo` | no | URL to navigate to after success; supports JQ; mutually exclusive with `onEventNavigateTo` |
+| `onEventNavigateTo` | no | event-driven navigation config; mutually exclusive with `onSuccessNavigateTo` |
+| `onEventNavigateTo.eventReason` | yes | the Kubernetes event reason to wait for |
+| `onEventNavigateTo.url` | yes | URL to navigate to when the event arrives; supports JQ |
+| `onEventNavigateTo.timeout` | no | seconds to wait before treating the action as failed |
+| `onEventNavigateTo.mode` | no | `navigate` (default) or `notification` |
+| `onEventNavigateTo.loadingMessage` | no | message shown while waiting for the event; supports JQ |
+| `onEventNavigateTo.reloadRoutes` | no | if `true`, reloads the portal route tree when the event arrives |
+| `requireConfirmation` | no | see [Common properties](#common-properties) |
+| `loading.display` | no | see [Common properties](#common-properties) |
+
+**Example — delete a resource with confirmation:**
+
+```yaml
+actions:
+ rest:
+ - id: delete-pod
+ type: rest
+ resourceRefId: ${ "pod-" + .json.name }
+ requireConfirmation: true
+ successMessage: Pod deleted successfully
+ errorMessage: ${ "Failed to delete pod: " + .response.message }
+ loading:
+ display: true
+```
+
+**Example — create a resource and wait for an event before navigating:**
+
+```yaml
+actions:
+ rest:
+ - id: create-composition
+ type: rest
+ resourceRefId: new-composition
+ onEventNavigateTo:
+ eventReason: CompositionReady
+ url: ${ "/compositions/" + .json.metadata.name }
+ timeout: 120
+ mode: notification
+ loadingMessage: Waiting for composition to be ready...
+```
+
+For details on how `resourcesRefs` and the Snowplow API work, see [RESTActions](./restactions.md).
+
+---
+
+## `externalNavigate`
+
+Opens a URL outside the portal in the browser. By default the URL opens in a new tab, but this can be changed with the `target` property.
+
+The `url` property supports JQ expressions, which allows the URL to be built dynamically from the triggering widget's payload — for example, constructing a link from a table row value.
+
+| Property | Required | Description |
+|---|---|---|
+| `id` | yes | unique identifier for the action |
+| `type` | yes | must be `externalNavigate` |
+| `url` | yes | the URL to open; supports JQ expressions |
+| `target` | no | where to open the URL — `_blank` (default), `_self`, `_parent`, `_top` |
+| `requireConfirmation` | no | see [Common properties](#common-properties) |
+| `loading.display` | no | see [Common properties](#common-properties) |
+
+**Example — static URL:**
+
+```yaml
+actions:
+ externalNavigate:
+ - id: open-docs
+ type: externalNavigate
+ url: https://docs.krateo.io
+```
+
+**Example — URL built from a table row:**
+
+```yaml
+actions:
+ externalNavigate:
+ - id: open-repo
+ type: externalNavigate
+ url: ${ "https://github.com/" + .json.repo }
+ target: _blank
+```
+
+---
+
+## `refresh`
+
+Invalidates TanStack Query cache entries for widget data, causing the affected widgets to re-fetch from the server. Use this after a `rest` action that changes state on a widget not automatically refreshed, or any time you need to force a data reload without a full page refresh.
+
+There are three modes:
+
+1. **Scoped by resource ID** (`resourcesRefsIds`): invalidates only the queries for the listed resource ref IDs. Each ID must match an entry in the widget's `resourcesRefs`. Use this for surgical refreshes that target specific widgets.
+2. **Scoped by widget kind** (`widgetKinds`): invalidates all cached widgets of the given kind(s) currently loaded on the page, regardless of which widget triggered the action. Use this to refresh all tables or all panels at once.
+3. **Global** (no scope): if neither `resourcesRefsIds` nor `widgetKinds` is specified, all cached queries are invalidated — equivalent to a full data reload of the current page.
+
+When both `resourcesRefsIds` and `widgetKinds` are specified, both scopes are applied simultaneously.
+
+| Property | Required | Description |
+|---|---|---|
+| `id` | yes | unique identifier for the action |
+| `type` | yes | must be `refresh` |
+| `resourcesRefsIds` | no | list of resource ref IDs to selectively invalidate |
+| `widgetKinds` | no | list of widget kind names (e.g. `Table`, `Panel`) to invalidate page-wide |
+| `requireConfirmation` | no | see [Common properties](#common-properties) |
+| `loading.display` | no | see [Common properties](#common-properties) |
+
+**Example — refresh specific widgets by resource ID:**
+
+```yaml
+actions:
+ refresh:
+ - id: refresh-pods-table
+ type: refresh
+ resourcesRefsIds:
+ - pods-table
+ - pods-count-panel
+```
+
+**Example — refresh all widgets of a given kind on the page:**
+
+```yaml
+actions:
+ refresh:
+ - id: refresh-all-tables
+ type: refresh
+ widgetKinds:
+ - Table
+```
+
+**Example — global refresh with confirmation:**
+
+```yaml
+actions:
+ refresh:
+ - id: refresh-all
+ type: refresh
+ requireConfirmation: true
+ loading:
+ display: true
+```
diff --git a/docs/docs.md b/docs/docs.md
index e6c47a3..11b2488 100644
--- a/docs/docs.md
+++ b/docs/docs.md
@@ -183,12 +183,14 @@ Actions define widget behavior and user interactions.
The currently supported actions are:
-- **`rest`**: triggers an HTTP request to a referenced resource
- **`navigate`**: navigates to a different route or referenced resource
- **`openDrawer`**: displays another widget inside a drawer (side panel)
- **`openModal`**: displays another widget inside a modal
+- **`rest`**: triggers an HTTP request to a referenced resource
+- **`externalNavigate`**: opens an external URL in the browser
+- **`refresh`**: invalidates cached widget data to trigger a re-fetch
-Actions are defined inside `widgetData`. A complete list of widgets supporting actions and their available properties can be found in the [Widgets API Reference](./widgets-api-reference.md).
+Actions are defined inside `widgetData`. Full documentation for each action type — properties, behavior, and examples — is available in the [Actions reference](./actions.md). A complete list of widgets supporting actions and their available properties can also be found in the [Widgets API Reference](./widgets-api-reference.md).
---
diff --git a/docs/widgets-api-reference.md b/docs/widgets-api-reference.md
index 4e29212..10238db 100644
--- a/docs/widgets-api-reference.md
+++ b/docs/widgets-api-reference.md
@@ -84,6 +84,22 @@ Button represents an interactive component which, when clicked, triggers a speci
| actions.openModal[].loading.display | yes | | boolean |
| actions.openModal[].customWidth | no | the custom width of the value, which should be used by setting the 'custom' value inside the 'size' property | string |
| actions.openModal[].size | no | sets the Modal size, 'default' is 520px, 'large' is 80% of the screen width, 'fullscreen' is 100% of the screen width, 'custom' should be used with the 'customWidth' property | `default` \| `large` \| `fullscreen` \| `custom` |
+| actions.externalNavigate | no | actions to navigate to an external URL | array |
+| actions.externalNavigate[].id | yes | unique identifier for the action | string |
+| actions.externalNavigate[].type | yes | type of action to execute | `externalNavigate` |
+| actions.externalNavigate[].url | yes | the external URL to navigate to; supports JQ expressions using ${ } syntax | string |
+| actions.externalNavigate[].target | no | specifies where to open the URL (default: _blank) | `_blank` \| `_self` \| `_parent` \| `_top` |
+| actions.externalNavigate[].requireConfirmation | no | whether user confirmation is required before navigating | boolean |
+| actions.externalNavigate[].loading | no | | object |
+| actions.externalNavigate[].loading.display | yes | | boolean |
+| actions.refresh | no | actions to invalidate and reload cached data | array |
+| actions.refresh[].id | yes | unique identifier for the action | string |
+| actions.refresh[].type | yes | type of action to execute | `refresh` |
+| actions.refresh[].resourcesRefsIds | no | list of resourcesRefs item IDs whose widget queries should be invalidated; each ID must match an entry in resourcesRefs.items | array |
+| actions.refresh[].widgetKinds | no | list of widget kind names (e.g. Table, Panel) whose queries should be invalidated; all widgets of those kinds on the page are re-fetched | array |
+| actions.refresh[].requireConfirmation | no | whether user confirmation is required before refreshing | boolean |
+| actions.refresh[].loading | no | | object |
+| actions.refresh[].loading.display | yes | | boolean |
| backgroundColor | no | the background color of the button | `blue` \| `darkBlue` \| `orange` \| `gray` \| `red` \| `green` \| `violet` |
| color | no | ***DEPRECATED*** the color of the button | `default` \| `primary` \| `danger` \| `blue` \| `purple` \| `cyan` \| `green` \| `magenta` \| `pink` \| `red` \| `orange` \| `yellow` \| `volcano` \| `geekblue` \| `lime` \| `gold` |
| label | no | the label of the button | string |
@@ -324,6 +340,22 @@ name of the k8s Custom Resource
| actions.openModal[].loading.display | yes | | boolean |
| actions.openModal[].customWidth | no | the custom width of the value, which should be used by setting the 'custom' value inside the 'size' property | string |
| actions.openModal[].size | no | sets the Modal size, 'default' is 520px, 'large' is 80% of the screen width, 'fullscreen' is 100% of the screen width, 'custom' should be used with the 'customWidth' property | `default` \| `large` \| `fullscreen` \| `custom` |
+| actions.externalNavigate | no | actions to navigate to an external URL | array |
+| actions.externalNavigate[].id | yes | unique identifier for the action | string |
+| actions.externalNavigate[].type | yes | type of action to execute | `externalNavigate` |
+| actions.externalNavigate[].url | yes | the external URL to navigate to; supports JQ expressions using ${ } syntax | string |
+| actions.externalNavigate[].target | no | specifies where to open the URL (default: _blank) | `_blank` \| `_self` \| `_parent` \| `_top` |
+| actions.externalNavigate[].requireConfirmation | no | whether user confirmation is required before navigating | boolean |
+| actions.externalNavigate[].loading | no | | object |
+| actions.externalNavigate[].loading.display | yes | | boolean |
+| actions.refresh | no | actions to invalidate and reload cached data | array |
+| actions.refresh[].id | yes | unique identifier for the action | string |
+| actions.refresh[].type | yes | type of action to execute | `refresh` |
+| actions.refresh[].resourcesRefsIds | no | list of resourcesRefs item IDs whose widget queries should be invalidated; each ID must match an entry in resourcesRefs.items | array |
+| actions.refresh[].widgetKinds | no | list of widget kind names (e.g. Table, Panel) whose queries should be invalidated; all widgets of those kinds on the page are re-fetched | array |
+| actions.refresh[].requireConfirmation | no | whether user confirmation is required before refreshing | boolean |
+| actions.refresh[].loading | no | | object |
+| actions.refresh[].loading.display | yes | | boolean |
| buttonConfig | no | custom labels and icons for form buttons | object |
| buttonConfig.primary | no | primary button configuration | object |
| buttonConfig.primary.label | no | text label for primary button | string |
@@ -541,6 +573,22 @@ Panel is a container to display information
| actions.openModal[].loading.display | yes | | boolean |
| actions.openModal[].customWidth | no | the custom width of the value, which should be used by setting the 'custom' value inside the 'size' property | string |
| actions.openModal[].size | no | sets the Modal size, 'default' is 520px, 'large' is 80% of the screen width, 'fullscreen' is 100% of the screen width, 'custom' should be used with the 'customWidth' property | `default` \| `large` \| `fullscreen` \| `custom` |
+| actions.externalNavigate | no | actions to navigate to an external URL | array |
+| actions.externalNavigate[].id | yes | unique identifier for the action | string |
+| actions.externalNavigate[].type | yes | type of action to execute | `externalNavigate` |
+| actions.externalNavigate[].url | yes | the external URL to navigate to; supports JQ expressions using ${ } syntax | string |
+| actions.externalNavigate[].target | no | specifies where to open the URL (default: _blank) | `_blank` \| `_self` \| `_parent` \| `_top` |
+| actions.externalNavigate[].requireConfirmation | no | whether user confirmation is required before navigating | boolean |
+| actions.externalNavigate[].loading | no | | object |
+| actions.externalNavigate[].loading.display | yes | | boolean |
+| actions.refresh | no | actions to invalidate and reload cached data | array |
+| actions.refresh[].id | yes | unique identifier for the action | string |
+| actions.refresh[].type | yes | type of action to execute | `refresh` |
+| actions.refresh[].resourcesRefsIds | no | list of resourcesRefs item IDs whose widget queries should be invalidated; each ID must match an entry in resourcesRefs.items | array |
+| actions.refresh[].widgetKinds | no | list of widget kind names (e.g. Table, Panel) whose queries should be invalidated; all widgets of those kinds on the page are re-fetched | array |
+| actions.refresh[].requireConfirmation | no | whether user confirmation is required before refreshing | boolean |
+| actions.refresh[].loading | no | | object |
+| actions.refresh[].loading.display | yes | | boolean |
| clickActionId | no | the id of the action to be executed when the panel is clicked | string |
| footer | no | footer section of the panel containing additional items | array |
| footer[].resourceRefId | yes | the identifier of the k8s custom resource that should be represented, usually a widget | string |
@@ -702,6 +750,22 @@ Table displays structured data with customizable columns and pagination
| actions.openModal[].loading.display | yes | | boolean |
| actions.openModal[].customWidth | no | the custom width of the value, which should be used by setting the 'custom' value inside the 'size' property | string |
| actions.openModal[].size | no | sets the Modal size, 'default' is 520px, 'large' is 80% of the screen width, 'fullscreen' is 100% of the screen width, 'custom' should be used with the 'customWidth' property | `default` \| `large` \| `fullscreen` \| `custom` |
+| actions.externalNavigate | no | actions to navigate to an external URL | array |
+| actions.externalNavigate[].id | yes | unique identifier for the action | string |
+| actions.externalNavigate[].type | yes | type of action to execute | `externalNavigate` |
+| actions.externalNavigate[].url | yes | the external URL to navigate to; supports JQ expressions using ${ } syntax | string |
+| actions.externalNavigate[].target | no | specifies where to open the URL (default: _blank) | `_blank` \| `_self` \| `_parent` \| `_top` |
+| actions.externalNavigate[].requireConfirmation | no | whether user confirmation is required before navigating | boolean |
+| actions.externalNavigate[].loading | no | | object |
+| actions.externalNavigate[].loading.display | yes | | boolean |
+| actions.refresh | no | actions to invalidate and reload cached data | array |
+| actions.refresh[].id | yes | unique identifier for the action | string |
+| actions.refresh[].type | yes | type of action to execute | `refresh` |
+| actions.refresh[].resourcesRefsIds | no | list of resourcesRefs item IDs whose widget queries should be invalidated; each ID must match an entry in resourcesRefs.items | array |
+| actions.refresh[].widgetKinds | no | list of widget kind names (e.g. Table, Panel) whose queries should be invalidated; all widgets of those kinds on the page are re-fetched | array |
+| actions.refresh[].requireConfirmation | no | whether user confirmation is required before refreshing | boolean |
+| actions.refresh[].loading | no | | object |
+| actions.refresh[].loading.display | yes | | boolean |
| allowedResources | yes | the list of resources that are allowed to be children of this widget or referenced by it | array |
| columns | yes | configuration of the table's columns | array |
| columns[].color | no | the color of the value (or the icon) to be represented | `blue` \| `darkBlue` \| `orange` \| `gray` \| `red` \| `green` \| `violet` |
diff --git a/src/components/WidgetRenderer/WidgetRenderer.tsx b/src/components/WidgetRenderer/WidgetRenderer.tsx
index 698d421..3a0e458 100644
--- a/src/components/WidgetRenderer/WidgetRenderer.tsx
+++ b/src/components/WidgetRenderer/WidgetRenderer.tsx
@@ -214,7 +214,7 @@ const WidgetRenderer = ({
}
}, [isLoading, onLoadingChange])
- if (isLoading) {
+ if (isFetching || isLoading) {
return (
diff --git a/src/examples/widgets/Button/Button.example.yaml b/src/examples/widgets/Button/Button.example.yaml
index 87bf6d3..9c25abc 100644
--- a/src/examples/widgets/Button/Button.example.yaml
+++ b/src/examples/widgets/Button/Button.example.yaml
@@ -1156,4 +1156,217 @@ spec:
resource: pods
name: my-sample-nginx-pod-event-navigation-success
namespace: krateo-system
- verb: POST
\ No newline at end of file
+ verb: POST
+---
+# externalNavigate — static URL, opens in new tab (default)
+# Target: clicking the button opens an external URL in a new browser tab.
+# Expected behavior: window.open is called with the static URL and target "_blank".
+kind: Button
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-external-navigate-static
+ namespace: krateo-system
+spec:
+ widgetData:
+ label: Open Grafana
+ icon: fa-chart-line
+ type: primary
+ clickActionId: open-grafana
+ actions:
+ externalNavigate:
+ - id: open-grafana
+ type: externalNavigate
+ url: https://grafana.example.com/dashboards
+ target: _blank
+ resourcesRefs:
+ items: []
+---
+# externalNavigate — JQ-interpolated URL using widget metadata
+# Target: the URL is built at runtime using the widget's own metadata.
+# JQ context: { json: customPayload, widget } — widget metadata is at .widget.metadata.
+# Expected behavior: the JQ expression resolves the widget name and opens the constructed URL.
+kind: Button
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-external-navigate-jq
+ namespace: krateo-system
+spec:
+ widgetData:
+ label: View in Argo CD
+ icon: fa-external-link-alt
+ type: default
+ clickActionId: open-argocd
+ actions:
+ externalNavigate:
+ - id: open-argocd
+ type: externalNavigate
+ url: ${ "https://argocd.example.com/applications/" + .widget.metadata.name }
+ target: _blank
+ resourcesRefs:
+ items: []
+---
+# externalNavigate — requires confirmation before opening
+# Target: user must confirm the navigation before the external URL is opened.
+# Expected behavior: a confirm dialog appears; URL opens only if user confirms.
+kind: Button
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-external-navigate-confirm
+ namespace: krateo-system
+spec:
+ widgetData:
+ label: Open External Resource
+ icon: fa-arrow-up-right-from-square
+ type: primary
+ clickActionId: open-external-confirm
+ actions:
+ externalNavigate:
+ - id: open-external-confirm
+ type: externalNavigate
+ url: https://external.example.com/resource
+ target: _blank
+ requireConfirmation: true
+ resourcesRefs:
+ items: []
+---
+# externalNavigate — open in same tab (_self)
+# Target: the URL replaces the current browser tab instead of opening a new one.
+# Expected behavior: window.open is called with target "_self", replacing the current page.
+kind: Button
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-external-navigate-self
+ namespace: krateo-system
+spec:
+ widgetData:
+ label: Go to Docs
+ icon: fa-book
+ type: link
+ clickActionId: open-docs-self
+ actions:
+ externalNavigate:
+ - id: open-docs-self
+ type: externalNavigate
+ url: https://docs.krateo.io
+ target: _self
+ resourcesRefs:
+ items: []
+---
+# refresh — companion table used to visually confirm that refresh actions re-fetch data
+# This widget is included alongside refresh buttons in example panels so the loading
+# state is visible when a refresh action fires.
+kind: Table
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-refresh-companion-table
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ allowedResources: []
+ columns:
+ - valueKey: name
+ title: Name
+ - valueKey: status
+ title: Status
+ data:
+ - - valueKey: name
+ kind: jsonSchemaType
+ type: string
+ stringValue: "service-alpha"
+ - valueKey: status
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Running"
+ - - valueKey: name
+ kind: jsonSchemaType
+ type: string
+ stringValue: "service-beta"
+ - valueKey: status
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Pending"
+ resourcesRefs:
+ items: []
+---
+# refresh — invalidate all cached queries
+# Target: clicking the button triggers a full TanStack Query cache invalidation.
+# Expected behavior: all widgets on the page are re-fetched; the companion table below
+# visibly enters a loading state before rendering again.
+kind: Button
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-refresh-all
+ namespace: krateo-system
+spec:
+ widgetData:
+ label: Refresh All
+ icon: fa-rotate-right
+ type: default
+ clickActionId: refresh-all
+ actions:
+ refresh:
+ - id: refresh-all
+ type: refresh
+ loading:
+ display: true
+ resourcesRefs:
+ items: []
+---
+# refresh — scoped invalidation via resourcesRefsIds
+# Target: invalidate only the query for the specific widget referenced by `resourcesRefsIds`.
+# Expected behavior: only the companion table below is re-fetched; other widgets on the
+# page remain cached and do not reload.
+kind: Button
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-refresh-by-resource-ref
+ namespace: krateo-system
+spec:
+ widgetData:
+ label: Refresh Companion Table
+ icon: fa-rotate-right
+ type: default
+ clickActionId: refresh-by-resource-ref
+ actions:
+ refresh:
+ - id: refresh-by-resource-ref
+ type: refresh
+ resourcesRefsIds:
+ - companion-table
+ loading:
+ display: true
+ resourcesRefs:
+ items:
+ - id: companion-table
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-refresh-companion-table
+ namespace: krateo-system
+ resource: tables
+ verb: GET
+---
+# refresh — kind-level invalidation via widgetKinds
+# Target: invalidate all widget queries of the specified kinds on the page.
+# Expected behavior: every Table widget on the page is re-fetched (including the companion
+# table below); widgets of other kinds stay cached.
+kind: Button
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-refresh-by-kind
+ namespace: krateo-system
+spec:
+ widgetData:
+ label: Refresh All Tables
+ icon: fa-rotate-right
+ type: default
+ clickActionId: refresh-by-kind
+ actions:
+ refresh:
+ - id: refresh-by-kind
+ type: refresh
+ widgetKinds:
+ - Table
+ loading:
+ display: true
+ resourcesRefs:
+ items: []
\ No newline at end of file
diff --git a/src/examples/widgets/Button/Button.menu.yaml b/src/examples/widgets/Button/Button.menu.yaml
index 5d325eb..4a014e2 100644
--- a/src/examples/widgets/Button/Button.menu.yaml
+++ b/src/examples/widgets/Button/Button.menu.yaml
@@ -175,6 +175,13 @@ spec:
- resourceRefId: example-button-rest-success-redirect-jq-panel
- resourceRefId: example-button-rest-event-navigation-fail-panel
- resourceRefId: example-button-rest-event-navigation-success-panel
+ - resourceRefId: example-button-external-navigate-static-panel
+ - resourceRefId: example-button-external-navigate-jq-panel
+ - resourceRefId: example-button-external-navigate-confirm-panel
+ - resourceRefId: example-button-external-navigate-self-panel
+ - resourceRefId: example-button-refresh-all-panel
+ - resourceRefId: example-button-refresh-by-resource-ref-panel
+ - resourceRefId: example-button-refresh-by-kind-panel
resourcesRefs:
items:
- id: example-button-no-action-panel
@@ -339,6 +346,48 @@ spec:
namespace: krateo-system
resource: panels
verb: GET
+ - id: example-button-external-navigate-static-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-external-navigate-static-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-button-external-navigate-jq-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-external-navigate-jq-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-button-external-navigate-confirm-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-external-navigate-confirm-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-button-external-navigate-self-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-external-navigate-self-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-button-refresh-all-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-refresh-all-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-button-refresh-by-resource-ref-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-refresh-by-resource-ref-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-button-refresh-by-kind-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-refresh-by-kind-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
---
kind: Panel
apiVersion: widgets.templates.krateo.io/v1beta1
@@ -1038,4 +1087,165 @@ spec:
name: example-button-rest-event-navigation-success
namespace: krateo-system
resource: buttons
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-external-navigate-static-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'externalNavigate - Static URL, opens in new tab'
+ items:
+ - resourceRefId: example-button-external-navigate-static
+ resourcesRefs:
+ items:
+ - id: example-button-external-navigate-static
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-external-navigate-static
+ namespace: krateo-system
+ resource: buttons
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-external-navigate-jq-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'externalNavigate - JQ-interpolated URL'
+ items:
+ - resourceRefId: example-button-external-navigate-jq
+ resourcesRefs:
+ items:
+ - id: example-button-external-navigate-jq
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-external-navigate-jq
+ namespace: krateo-system
+ resource: buttons
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-external-navigate-confirm-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'externalNavigate - Requires confirmation'
+ items:
+ - resourceRefId: example-button-external-navigate-confirm
+ resourcesRefs:
+ items:
+ - id: example-button-external-navigate-confirm
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-external-navigate-confirm
+ namespace: krateo-system
+ resource: buttons
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-external-navigate-self-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'externalNavigate - Open in same tab (_self)'
+ items:
+ - resourceRefId: example-button-external-navigate-self
+ resourcesRefs:
+ items:
+ - id: example-button-external-navigate-self
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-external-navigate-self
+ namespace: krateo-system
+ resource: buttons
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-refresh-all-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'refresh - Invalidate all cached queries'
+ items:
+ - resourceRefId: example-button-refresh-all
+ - resourceRefId: example-button-refresh-companion-table
+ resourcesRefs:
+ items:
+ - id: example-button-refresh-all
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-refresh-all
+ namespace: krateo-system
+ resource: buttons
+ verb: GET
+ - id: example-button-refresh-companion-table
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-refresh-companion-table
+ namespace: krateo-system
+ resource: tables
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-refresh-by-resource-ref-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'refresh - Scoped invalidation via resourcesRefsIds'
+ items:
+ - resourceRefId: example-button-refresh-by-resource-ref
+ - resourceRefId: example-button-refresh-companion-table
+ resourcesRefs:
+ items:
+ - id: example-button-refresh-by-resource-ref
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-refresh-by-resource-ref
+ namespace: krateo-system
+ resource: buttons
+ verb: GET
+ - id: example-button-refresh-companion-table
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-refresh-companion-table
+ namespace: krateo-system
+ resource: tables
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-button-refresh-by-kind-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'refresh - Kind-level invalidation via widgetKinds'
+ items:
+ - resourceRefId: example-button-refresh-by-kind
+ - resourceRefId: example-button-refresh-companion-table
+ resourcesRefs:
+ items:
+ - id: example-button-refresh-by-kind
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-refresh-by-kind
+ namespace: krateo-system
+ resource: buttons
+ verb: GET
+ - id: example-button-refresh-companion-table
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-button-refresh-companion-table
+ namespace: krateo-system
+ resource: tables
verb: GET
\ No newline at end of file
diff --git a/src/examples/widgets/Panel/Panel.example.yaml b/src/examples/widgets/Panel/Panel.example.yaml
index ff71e86..c49fc94 100644
--- a/src/examples/widgets/Panel/Panel.example.yaml
+++ b/src/examples/widgets/Panel/Panel.example.yaml
@@ -433,3 +433,160 @@ spec:
namespace: krateo-system
resource: yamlviewers
verb: GET
+---
+# externalNavigate — static URL, opens in new tab
+# Target: clicking the panel opens an external URL in a new browser tab.
+# Expected behavior: window.open is called with the static URL and target "_blank".
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-panel-external-navigate-static
+ namespace: krateo-system
+spec:
+ widgetData:
+ title: Open Grafana
+ clickActionId: open-grafana-panel
+ items: []
+ actions:
+ externalNavigate:
+ - id: open-grafana-panel
+ type: externalNavigate
+ url: https://grafana.example.com/dashboards
+ target: _blank
+ resourcesRefs:
+ items: []
+---
+# externalNavigate — JQ-interpolated URL using widget metadata
+# Target: the URL is built at click time using the widget's own metadata.
+# JQ context: { json: customPayload, widget } — widget metadata is at .widget.metadata.
+# Expected behavior: the JQ expression resolves the widget name and opens the constructed URL.
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-panel-external-navigate-jq
+ namespace: krateo-system
+spec:
+ widgetData:
+ title: View in Argo CD
+ clickActionId: open-argocd-panel
+ items: []
+ actions:
+ externalNavigate:
+ - id: open-argocd-panel
+ type: externalNavigate
+ url: ${ "https://argocd.example.com/applications/" + .widget.metadata.name }
+ target: _blank
+ resourcesRefs:
+ items: []
+---
+# refresh — companion table used to visually confirm that refresh actions re-fetch data
+kind: Table
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-panel-refresh-companion-table
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ allowedResources: []
+ columns:
+ - valueKey: name
+ title: Name
+ - valueKey: status
+ title: Status
+ data:
+ - - valueKey: name
+ kind: jsonSchemaType
+ type: string
+ stringValue: "service-alpha"
+ - valueKey: status
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Running"
+ - - valueKey: name
+ kind: jsonSchemaType
+ type: string
+ stringValue: "service-beta"
+ - valueKey: status
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Pending"
+ resourcesRefs:
+ items: []
+---
+# refresh — invalidate all cached queries on panel click
+# Target: clicking the panel triggers a full TanStack Query cache invalidation.
+# Expected behavior: all widgets on the page are re-fetched; the companion table below
+# visibly enters a loading state before rendering again.
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-panel-refresh-all
+ namespace: krateo-system
+spec:
+ widgetData:
+ title: Refresh All Data
+ clickActionId: refresh-all-panel
+ items: []
+ actions:
+ refresh:
+ - id: refresh-all-panel
+ type: refresh
+ loading:
+ display: true
+ resourcesRefs:
+ items: []
+---
+# refresh — scoped invalidation via resourcesRefsIds
+# Target: clicking the panel only re-fetches the companion table referenced by resourcesRefsIds.
+# Expected behavior: only the companion table reloads; other widgets stay cached.
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-panel-refresh-by-resource-ref
+ namespace: krateo-system
+spec:
+ widgetData:
+ title: Refresh Companion Table
+ clickActionId: refresh-by-resource-ref-panel
+ items: []
+ actions:
+ refresh:
+ - id: refresh-by-resource-ref-panel
+ type: refresh
+ resourcesRefsIds:
+ - companion-table
+ loading:
+ display: true
+ resourcesRefs:
+ items:
+ - id: companion-table
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-refresh-companion-table
+ namespace: krateo-system
+ resource: tables
+ verb: GET
+---
+# refresh — kind-level invalidation via widgetKinds
+# Target: clicking the panel re-fetches all Table widgets on the page.
+# Expected behavior: the companion table below reloads; Panel and Button widgets stay cached.
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-panel-refresh-by-kind
+ namespace: krateo-system
+spec:
+ widgetData:
+ title: Refresh All Tables
+ clickActionId: refresh-by-kind-panel
+ items: []
+ actions:
+ refresh:
+ - id: refresh-by-kind-panel
+ type: refresh
+ widgetKinds:
+ - Table
+ loading:
+ display: true
+ resourcesRefs:
+ items: []
diff --git a/src/examples/widgets/Panel/Panel.menu.yaml b/src/examples/widgets/Panel/Panel.menu.yaml
index d727f33..bc3abf6 100644
--- a/src/examples/widgets/Panel/Panel.menu.yaml
+++ b/src/examples/widgets/Panel/Panel.menu.yaml
@@ -139,6 +139,11 @@ spec:
- resourceRefId: example-panel-open-modal-large-panel
- resourceRefId: example-panel-open-modal-custom-panel
- resourceRefId: example-panel-open-modal-fullscreen-panel
+ - resourceRefId: example-panel-external-navigate-static-panel
+ - resourceRefId: example-panel-external-navigate-jq-panel
+ - resourceRefId: example-panel-refresh-all-panel
+ - resourceRefId: example-panel-refresh-by-resource-ref-panel
+ - resourceRefId: example-panel-refresh-by-kind-panel
resourcesRefs:
items:
- id: example-panel-no-action-panel
@@ -213,6 +218,36 @@ spec:
namespace: krateo-system
resource: panels
verb: GET
+ - id: example-panel-external-navigate-static-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-external-navigate-static-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-panel-external-navigate-jq-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-external-navigate-jq-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-panel-refresh-all-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-refresh-all-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-panel-refresh-by-resource-ref-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-refresh-by-resource-ref-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-panel-refresh-by-kind-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-refresh-by-kind-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
---
kind: Panel
apiVersion: widgets.templates.krateo.io/v1beta1
@@ -552,4 +587,125 @@ spec:
name: example-panel-open-modal-fullscreen
namespace: krateo-system
resource: panels
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-panel-external-navigate-static-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'externalNavigate - Static URL, opens in new tab'
+ items:
+ - resourceRefId: example-panel-external-navigate-static
+ resourcesRefs:
+ items:
+ - id: example-panel-external-navigate-static
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-external-navigate-static
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-panel-external-navigate-jq-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'externalNavigate - JQ-interpolated URL'
+ items:
+ - resourceRefId: example-panel-external-navigate-jq
+ resourcesRefs:
+ items:
+ - id: example-panel-external-navigate-jq
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-external-navigate-jq
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-panel-refresh-all-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'refresh - Invalidate all cached queries'
+ items:
+ - resourceRefId: example-panel-refresh-all
+ - resourceRefId: example-panel-refresh-companion-table
+ resourcesRefs:
+ items:
+ - id: example-panel-refresh-all
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-refresh-all
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-panel-refresh-companion-table
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-refresh-companion-table
+ namespace: krateo-system
+ resource: tables
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-panel-refresh-by-resource-ref-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'refresh - Scoped invalidation via resourcesRefsIds'
+ items:
+ - resourceRefId: example-panel-refresh-by-resource-ref
+ - resourceRefId: example-panel-refresh-companion-table
+ resourcesRefs:
+ items:
+ - id: example-panel-refresh-by-resource-ref
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-refresh-by-resource-ref
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-panel-refresh-companion-table
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-refresh-companion-table
+ namespace: krateo-system
+ resource: tables
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-panel-refresh-by-kind-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'refresh - Kind-level invalidation via widgetKinds'
+ items:
+ - resourceRefId: example-panel-refresh-by-kind
+ - resourceRefId: example-panel-refresh-companion-table
+ resourcesRefs:
+ items:
+ - id: example-panel-refresh-by-kind
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-refresh-by-kind
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-panel-refresh-companion-table
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-panel-refresh-companion-table
+ namespace: krateo-system
+ resource: tables
verb: GET
\ No newline at end of file
diff --git a/src/examples/widgets/Table/Table.example.yaml b/src/examples/widgets/Table/Table.example.yaml
index 52ac64f..a97bdaf 100644
--- a/src/examples/widgets/Table/Table.example.yaml
+++ b/src/examples/widgets/Table/Table.example.yaml
@@ -685,4 +685,214 @@ spec:
markdowns:
(.markdowns.items
| map(select(.metadata.name | startswith("example-markdown"))))
- }
\ No newline at end of file
+ }
+---
+# externalNavigate — open a per-row external URL from a table action
+# Target: each row exposes an action button that opens a row-specific external URL.
+# Expected behavior: clicking the button resolves the JQ URL with that row's data and opens it in a new tab.
+kind: Table
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-table-external-navigate
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions:
+ externalNavigate:
+ - id: open-grafana-row
+ type: externalNavigate
+ url: ${ "https://grafana.example.com/d/" + .json.rowId }
+ target: _blank
+ allowedResources: [barcharts, buttons, filters, flowcharts, linecharts, markdowns, paragraphs, piecharts, yamlviewers]
+ columns:
+ - valueKey: name
+ title: Name
+ - valueKey: rowId
+ title: Row ID
+ hidden: true
+ data:
+ - - valueKey: name
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Service A"
+ - valueKey: rowId
+ kind: jsonSchemaType
+ type: string
+ stringValue: "service-a"
+ - - valueKey: name
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Service B"
+ - valueKey: rowId
+ kind: jsonSchemaType
+ type: string
+ stringValue: "service-b"
+ tableActions:
+ - clickActionId: open-grafana-row
+ button:
+ label: Open in Grafana
+ resourcesRefs:
+ items: []
+---
+# refresh — companion panel used to visually confirm that refresh actions re-fetch data
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-table-refresh-companion-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ title: Companion Panel (re-fetches on refresh)
+ actions: {}
+ items: []
+ resourcesRefs:
+ items: []
+---
+# refresh — invalidate all cached queries from a table row action
+# Target: each row exposes an action button that triggers a full TanStack Query cache invalidation.
+# Expected behavior: clicking the button refetches all cached queries on the page; the companion
+# panel above visibly enters a loading state before rendering again.
+kind: Table
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-table-refresh-all
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions:
+ refresh:
+ - id: refresh-row
+ type: refresh
+ loading:
+ display: true
+ allowedResources: [barcharts, buttons, filters, flowcharts, linecharts, markdowns, paragraphs, piecharts, yamlviewers]
+ columns:
+ - valueKey: name
+ title: Name
+ - valueKey: status
+ title: Status
+ data:
+ - - valueKey: name
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Service A"
+ - valueKey: status
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Running"
+ - - valueKey: name
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Service B"
+ - valueKey: status
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Pending"
+ tableActions:
+ - clickActionId: refresh-row
+ button:
+ label: Refresh
+ resourcesRefs:
+ items: []
+---
+# refresh — scoped invalidation via resourcesRefsIds from a table row action
+# Target: each row action only re-fetches the companion panel referenced by resourcesRefsIds.
+# Expected behavior: only the companion panel reloads; other widgets stay cached.
+kind: Table
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-table-refresh-by-resource-ref
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions:
+ refresh:
+ - id: refresh-row-by-resource-ref
+ type: refresh
+ resourcesRefsIds:
+ - companion-panel
+ loading:
+ display: true
+ allowedResources: [barcharts, buttons, filters, flowcharts, linecharts, markdowns, paragraphs, piecharts, yamlviewers]
+ columns:
+ - valueKey: name
+ title: Name
+ - valueKey: status
+ title: Status
+ data:
+ - - valueKey: name
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Service A"
+ - valueKey: status
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Running"
+ - - valueKey: name
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Service B"
+ - valueKey: status
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Pending"
+ tableActions:
+ - clickActionId: refresh-row-by-resource-ref
+ button:
+ label: Refresh Panel
+ resourcesRefs:
+ items:
+ - id: companion-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-refresh-companion-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+---
+# refresh — kind-level invalidation via widgetKinds from a table row action
+# Target: each row action re-fetches all Panel widgets on the page.
+# Expected behavior: the companion panel above reloads; Table and Button widgets stay cached.
+kind: Table
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-table-refresh-by-kind
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions:
+ refresh:
+ - id: refresh-row-by-kind
+ type: refresh
+ widgetKinds:
+ - Panel
+ loading:
+ display: true
+ allowedResources: [barcharts, buttons, filters, flowcharts, linecharts, markdowns, paragraphs, piecharts, yamlviewers]
+ columns:
+ - valueKey: name
+ title: Name
+ - valueKey: status
+ title: Status
+ data:
+ - - valueKey: name
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Service A"
+ - valueKey: status
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Running"
+ - - valueKey: name
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Service B"
+ - valueKey: status
+ kind: jsonSchemaType
+ type: string
+ stringValue: "Pending"
+ tableActions:
+ - clickActionId: refresh-row-by-kind
+ button:
+ label: Refresh All Panels
+ resourcesRefs:
+ items: []
\ No newline at end of file
diff --git a/src/examples/widgets/Table/Table.menu.yaml b/src/examples/widgets/Table/Table.menu.yaml
index 0215723..dc62236 100644
--- a/src/examples/widgets/Table/Table.menu.yaml
+++ b/src/examples/widgets/Table/Table.menu.yaml
@@ -28,23 +28,56 @@ metadata:
spec:
widgetData:
allowedResources:
- - barcharts
- - buttons
+ - tablists
+ items:
+ - resourceRefId: example-table-tablist
+ resourcesRefs:
+ items:
+ - id: example-table-tablist
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-tablist
+ namespace: krateo-system
+ resource: tablists
+ verb: GET
+---
+kind: TabList
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-table-tablist
+ namespace: krateo-system
+spec:
+ widgetData:
+ allowedResources:
- columns
- - datagrids
- - eventlists
- - filters
- - flowcharts
- - forms
- - linecharts
- - markdowns
+ items:
+ - label: Examples
+ resourceRefId: example-table-examples-column
+ - label: Actions
+ resourceRefId: example-table-actions-column
+ resourcesRefs:
+ items:
+ - id: example-table-examples-column
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-examples-column
+ namespace: krateo-system
+ resource: columns
+ verb: GET
+ - id: example-table-actions-column
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-actions-column
+ namespace: krateo-system
+ resource: columns
+ verb: GET
+---
+kind: Column
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-table-examples-column
+ namespace: krateo-system
+spec:
+ widgetData:
+ allowedResources:
- panels
- - paragraphs
- - tables
- - rows
- - tables
- - tablists
- - yamlviewers
items:
- resourceRefId: example-table-empty-columns-panel
- resourceRefId: example-table-empty-data-panel
@@ -56,8 +89,6 @@ spec:
- resourceRefId: example-table-with-filter-panel
- resourceRefId: example-table-with-pagination-panel
- resourceRefId: example-table-restaction-panel
- - resourceRefId: example-table-actions-panel
- - resourceRefId: example-table-actions-dynamic-panel
resourcesRefs:
items:
- id: example-table-empty-columns-panel
@@ -65,25 +96,25 @@ spec:
name: example-table-empty-columns-panel
namespace: krateo-system
resource: panels
- verb: GET
+ verb: GET
- id: example-table-empty-data-panel
apiVersion: widgets.templates.krateo.io/v1beta1
name: example-table-empty-data-panel
namespace: krateo-system
resource: panels
- verb: GET
+ verb: GET
- id: example-table-basic-panel
apiVersion: widgets.templates.krateo.io/v1beta1
name: example-table-basic-panel
namespace: krateo-system
resource: panels
- verb: GET
+ verb: GET
- id: example-table-mixed-types-panel
apiVersion: widgets.templates.krateo.io/v1beta1
name: example-table-mixed-types-panel
namespace: krateo-system
resource: panels
- verb: GET
+ verb: GET
- id: example-table-icons-panel
apiVersion: widgets.templates.krateo.io/v1beta1
name: example-table-icons-panel
@@ -120,19 +151,62 @@ spec:
namespace: krateo-system
resource: panels
verb: GET
+---
+kind: Column
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-table-actions-column
+ namespace: krateo-system
+spec:
+ widgetData:
+ allowedResources:
+ - panels
+ items:
+ - resourceRefId: example-table-actions-panel
+ - resourceRefId: example-table-actions-dynamic-panel
+ - resourceRefId: example-table-external-navigate-panel
+ - resourceRefId: example-table-refresh-all-panel
+ - resourceRefId: example-table-refresh-by-resource-ref-panel
+ - resourceRefId: example-table-refresh-by-kind-panel
+ resourcesRefs:
+ items:
- id: example-table-actions-panel
apiVersion: widgets.templates.krateo.io/v1beta1
name: example-table-actions-panel
namespace: krateo-system
resource: panels
- verb: GET
+ verb: GET
- id: example-table-actions-dynamic-panel
apiVersion: widgets.templates.krateo.io/v1beta1
name: example-table-actions-dynamic-panel
namespace: krateo-system
resource: panels
- verb: GET
-
+ verb: GET
+ - id: example-table-external-navigate-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-external-navigate-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-table-refresh-all-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-refresh-all-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-table-refresh-by-resource-ref-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-refresh-by-resource-ref-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-table-refresh-by-kind-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-refresh-by-kind-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+
---
kind: Panel
apiVersion: widgets.templates.krateo.io/v1beta1
@@ -379,4 +453,105 @@ spec:
name: example-table-actions-dynamic
namespace: krateo-system
resource: tables
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-table-external-navigate-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'externalNavigate - Per-row external URL via JQ'
+ items:
+ - resourceRefId: example-table-external-navigate
+ resourcesRefs:
+ items:
+ - id: example-table-external-navigate
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-external-navigate
+ namespace: krateo-system
+ resource: tables
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-table-refresh-all-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'refresh - Invalidate all cached queries from a row action'
+ items:
+ - resourceRefId: example-table-refresh-companion-panel
+ - resourceRefId: example-table-refresh-all
+ resourcesRefs:
+ items:
+ - id: example-table-refresh-companion-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-refresh-companion-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-table-refresh-all
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-refresh-all
+ namespace: krateo-system
+ resource: tables
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-table-refresh-by-resource-ref-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'refresh - Scoped invalidation via resourcesRefsIds from a row action'
+ items:
+ - resourceRefId: example-table-refresh-companion-panel
+ - resourceRefId: example-table-refresh-by-resource-ref
+ resourcesRefs:
+ items:
+ - id: example-table-refresh-companion-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-refresh-companion-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-table-refresh-by-resource-ref
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-refresh-by-resource-ref
+ namespace: krateo-system
+ resource: tables
+ verb: GET
+---
+kind: Panel
+apiVersion: widgets.templates.krateo.io/v1beta1
+metadata:
+ name: example-table-refresh-by-kind-panel
+ namespace: krateo-system
+spec:
+ widgetData:
+ actions: {}
+ title: 'refresh - Kind-level invalidation via widgetKinds from a row action'
+ items:
+ - resourceRefId: example-table-refresh-companion-panel
+ - resourceRefId: example-table-refresh-by-kind
+ resourcesRefs:
+ items:
+ - id: example-table-refresh-companion-panel
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-refresh-companion-panel
+ namespace: krateo-system
+ resource: panels
+ verb: GET
+ - id: example-table-refresh-by-kind
+ apiVersion: widgets.templates.krateo.io/v1beta1
+ name: example-table-refresh-by-kind
+ namespace: krateo-system
+ resource: tables
verb: GET
\ No newline at end of file
diff --git a/src/hooks/actionHandlers/externalNavigate.handler.ts b/src/hooks/actionHandlers/externalNavigate.handler.ts
new file mode 100644
index 0000000..03757c3
--- /dev/null
+++ b/src/hooks/actionHandlers/externalNavigate.handler.ts
@@ -0,0 +1,21 @@
+import type { Widget, WidgetAction } from '../../types/Widget'
+import type { ActionHandlerContext } from '../useHandleActions'
+
+type ExternalNavigateAction = Extract
+
+export const handleExternalNavigateAction = async (
+ action: ExternalNavigateAction,
+ customPayload: Record | undefined,
+ widget: Widget | undefined,
+ context: ActionHandlerContext
+): Promise => {
+ const resolvedUrl = action.url.startsWith('${')
+ ? await context.resolveJq(action.url, { json: customPayload, widget })
+ : action.url
+
+ if (!action.requireConfirmation || window.confirm('Are you sure?')) {
+ window.open(resolvedUrl, action.target ?? '_blank')
+ }
+
+ context.setIsActionLoading(false)
+}
diff --git a/src/hooks/actionHandlers/navigate.handler.ts b/src/hooks/actionHandlers/navigate.handler.ts
new file mode 100644
index 0000000..1d090b0
--- /dev/null
+++ b/src/hooks/actionHandlers/navigate.handler.ts
@@ -0,0 +1,37 @@
+import type { ResourcesRefs, Widget, WidgetAction } from '../../types/Widget'
+import type { ActionHandlerContext } from '../useHandleActions'
+
+import { resolveResourceRef } from './utils'
+
+type NavigateAction = Extract
+
+export const handleNavigateAction = async (
+ action: NavigateAction,
+ resourcesRefs: ResourcesRefs,
+ customPayload: Record | undefined,
+ widget: Widget | undefined,
+ context: ActionHandlerContext
+): Promise => {
+ if (action.path) {
+ const resolvedPath = action.path.startsWith('${')
+ ? await context.resolveJq(action.path, { json: customPayload, widget })
+ : action.path
+
+ if (!action.requireConfirmation || window.confirm('Are you sure?')) {
+ await context.navigate(resolvedPath)
+ }
+
+ context.setIsActionLoading(false)
+ return
+ }
+
+ const resourceRef = await resolveResourceRef(action.resourceRefId, resourcesRefs, customPayload, widget, context)
+ if (!resourceRef) { return }
+
+ const prefix = action.resourceURLPathExtension || context.location.pathname
+ const url = `${prefix}?widgetEndpoint=${encodeURIComponent(resourceRef.path)}`
+
+ if (!action.requireConfirmation || window.confirm('Are you sure?')) {
+ await context.navigate(url)
+ }
+}
diff --git a/src/hooks/actionHandlers/openDrawer.handler.ts b/src/hooks/actionHandlers/openDrawer.handler.ts
new file mode 100644
index 0000000..fe4f9b0
--- /dev/null
+++ b/src/hooks/actionHandlers/openDrawer.handler.ts
@@ -0,0 +1,30 @@
+import type { ResourcesRefs, Widget, WidgetAction } from '../../types/Widget'
+import { openDrawer } from '../../widgets/Drawer/Drawer'
+import type { ActionHandlerContext } from '../useHandleActions'
+
+import { resolveResourceRef } from './utils'
+
+type OpenDrawerAction = Extract
+
+export const handleOpenDrawerAction = async (
+ action: OpenDrawerAction,
+ resourcesRefs: ResourcesRefs,
+ customPayload: Record | undefined,
+ widget: Widget | undefined,
+ context: ActionHandlerContext
+): Promise => {
+ const resourceRef = await resolveResourceRef(action.resourceRefId, resourcesRefs, customPayload, widget, context)
+ if (!resourceRef) { return }
+
+ const { size, title } = action
+
+ let drawerTitle: string | undefined
+ if (title) {
+ drawerTitle = title.startsWith('${')
+ ? await context.resolveJq(title, { json: customPayload, widget })
+ : title
+ }
+
+ context.setIsActionLoading(false)
+ openDrawer({ size, title: drawerTitle, widgetEndpoint: resourceRef.path })
+}
diff --git a/src/hooks/actionHandlers/openModal.handler.ts b/src/hooks/actionHandlers/openModal.handler.ts
new file mode 100644
index 0000000..c180f8f
--- /dev/null
+++ b/src/hooks/actionHandlers/openModal.handler.ts
@@ -0,0 +1,30 @@
+import type { ResourcesRefs, Widget, WidgetAction } from '../../types/Widget'
+import { openModal } from '../../widgets/Modal/Modal'
+import type { ActionHandlerContext } from '../useHandleActions'
+
+import { resolveResourceRef } from './utils'
+
+type OpenModalAction = Extract
+
+export const handleOpenModalAction = async (
+ action: OpenModalAction,
+ resourcesRefs: ResourcesRefs,
+ customPayload: Record | undefined,
+ widget: Widget | undefined,
+ context: ActionHandlerContext
+): Promise => {
+ const resourceRef = await resolveResourceRef(action.resourceRefId, resourcesRefs, customPayload, widget, context)
+ if (!resourceRef) { return }
+
+ const { customWidth, size, title } = action
+
+ let modalTitle: string | undefined
+ if (title) {
+ modalTitle = title.startsWith('${')
+ ? await context.resolveJq(title, { json: customPayload, widget })
+ : title
+ }
+
+ context.setIsActionLoading(false)
+ openModal({ customWidth, size, title: modalTitle, widgetEndpoint: resourceRef.path })
+}
diff --git a/src/hooks/actionHandlers/refresh.handler.ts b/src/hooks/actionHandlers/refresh.handler.ts
new file mode 100644
index 0000000..a4ee7a0
--- /dev/null
+++ b/src/hooks/actionHandlers/refresh.handler.ts
@@ -0,0 +1,48 @@
+import type { ResourcesRefs, WidgetAction } from '../../types/Widget'
+import type { ActionHandlerContext } from '../useHandleActions'
+
+type RefreshAction = Extract
+
+export const handleRefreshAction = async (
+ action: RefreshAction,
+ resourcesRefs: ResourcesRefs,
+ context: ActionHandlerContext
+): Promise => {
+ if (action.requireConfirmation && !window.confirm('Are you sure?')) {
+ context.setIsActionLoading(false)
+ return
+ }
+
+ const hasScoped = (action.resourcesRefsIds?.length ?? 0) > 0 || (action.widgetKinds?.length ?? 0) > 0
+
+ if (action.resourcesRefsIds?.length) {
+ await Promise.all(
+ action.resourcesRefsIds.map(id => {
+ const ref = resourcesRefs.items.find(item => item.id === id)
+
+ if (!ref) {
+ return Promise.resolve()
+ }
+
+ return context.queryClient.invalidateQueries({ queryKey: ['widgets', ref.path] })
+ })
+ )
+ }
+
+ if (action.widgetKinds?.length) {
+ const resourceNames = action.widgetKinds.map(kind => `resource=${kind.toLowerCase()}s`)
+
+ await context.queryClient.invalidateQueries({
+ predicate: query =>
+ query.queryKey[0] === 'widgets'
+ && typeof query.queryKey[1] === 'string'
+ && resourceNames.some(name => (query.queryKey[1] as string).includes(name)),
+ })
+ }
+
+ if (!hasScoped) {
+ await context.queryClient.invalidateQueries()
+ }
+
+ context.setIsActionLoading(false)
+}
diff --git a/src/hooks/actionHandlers/rest.handler.ts b/src/hooks/actionHandlers/rest.handler.ts
new file mode 100644
index 0000000..198f2c2
--- /dev/null
+++ b/src/hooks/actionHandlers/rest.handler.ts
@@ -0,0 +1,378 @@
+import { LoadingOutlined } from '@ant-design/icons'
+import type { EventListener, MessageEvent } from 'event-source-polyfill'
+import { EventSourcePolyfill } from 'event-source-polyfill'
+import { createElement } from 'react'
+
+import type { ResourcesRefs, Widget, WidgetAction } from '../../types/Widget'
+import type { EventsApiResource, Payload, RestApiResponse } from '../../utils/types'
+import { getHeadersObject } from '../../utils/utils'
+import { closeDrawer } from '../../widgets/Drawer/Drawer'
+import type { ActionHandlerContext } from '../useHandleActions'
+
+import { buildPayload, interpolateRedirectUrl, resolveResourceRef, updateNameNamespace } from './utils'
+
+type RestAction = Extract
+
+const handleOnEventNavigateTo = async (
+ eventConfig: NonNullable,
+ context: ActionHandlerContext,
+ customPayload: Record | undefined,
+ payload: Payload,
+ errorMessage: string | undefined,
+ successMessage: string | undefined,
+ getResourceUid: () => string | null,
+ getJsonResponse: () => RestApiResponse | null,
+): Promise => {
+ let eventReceived = false
+ const mode = eventConfig.mode ?? 'navigate'
+ const eventsEndpoint = `${context.config!.api.EVENTS_PUSH_API_BASE_URL}/notifications`
+
+ const eventSource = new EventSourcePolyfill(eventsEndpoint, {
+ headers: {
+ Authorization: `Bearer ${context.accessToken}`,
+ },
+ withCredentials: false,
+ })
+
+ const defaultLoadingMessage = mode === 'notification'
+ ? 'Waiting for resource...'
+ : 'Waiting for resource and redirecting...'
+
+ const loadingMessage = eventConfig.loadingMessage
+ ? await context.resolveJq(eventConfig.loadingMessage, { json: payload, response: getJsonResponse() })
+ : defaultLoadingMessage
+
+ const resolveErrorDescription = async () => {
+ let description = `Timeout waiting for event ${eventConfig.eventReason}`
+ if (errorMessage) {
+ description = errorMessage.startsWith('${')
+ ? await context.resolveJq(errorMessage, { json: payload, response: getJsonResponse() })
+ : errorMessage
+ }
+ return description
+ }
+
+ const resolveRedirectUrl = async (eventData: EventsApiResource) => {
+ if (eventConfig.url.startsWith('${')) {
+ return context.resolveJq(eventConfig.url, {
+ event: eventData as unknown as Record,
+ json: payload,
+ response: getJsonResponse(),
+ })
+ }
+ if (customPayload) {
+ return interpolateRedirectUrl(customPayload, eventConfig.url)
+ }
+ return eventConfig.url
+ }
+
+ const resolveSuccessDescription = async (eventData: EventsApiResource) => {
+ if (successMessage) {
+ return successMessage.startsWith('${')
+ ? context.resolveJq(successMessage, {
+ event: eventData as unknown as Record,
+ json: payload,
+ response: getJsonResponse(),
+ })
+ : successMessage
+ }
+ return 'The action has been executed successfully'
+ }
+
+ if (mode === 'notification') {
+ const notificationKey = `event-${eventConfig.eventReason}-${Date.now()}`
+
+ context.setIsActionLoading(false)
+ closeDrawer()
+
+ context.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()
+ context.notification.error({
+ description: await resolveErrorDescription(),
+ duration: 0,
+ key: notificationKey,
+ message: 'Error while executing the action',
+ placement: 'topRight',
+ })
+ }
+ })()
+ }, eventConfig.timeout! * 1000)
+
+ eventSource.addEventListener('krateo', ((event: MessageEvent) => {
+ const resourceUid = getResourceUid()
+ if (!resourceUid) { return }
+
+ const eventData = JSON.parse(event.data as string) as EventsApiResource
+
+ if (eventData.reason === eventConfig.eventReason && eventData.involved_object_uid === resourceUid) {
+ eventReceived = true
+
+ if (eventConfig.reloadRoutes !== false) {
+ void context.reloadRoutes()
+ }
+
+ eventSource.close()
+ clearTimeout(timeoutId)
+
+ void (async () => {
+ const redirectUrl = await resolveRedirectUrl(eventData)
+
+ if (!redirectUrl) {
+ context.notification.error({
+ description: 'Impossible to redirect, the route contains an undefined value',
+ message: 'Error while redirecting',
+ placement: 'bottomLeft',
+ })
+ return
+ }
+
+ context.notification.success({
+ description: createElement(
+ 'span',
+ null,
+ await resolveSuccessDescription(eventData),
+ ' — ',
+ createElement(
+ 'a',
+ { href: redirectUrl, onClick: () => { context.notification.destroy(notificationKey) } },
+ 'Go to resource'
+ )
+ ),
+ duration: 0,
+ key: notificationKey,
+ message: `Successfully executed action`,
+ placement: 'topRight',
+ })
+ })()
+ }
+ }) as EventListener)
+ } else {
+ const timeoutId = setTimeout(() => {
+ if (!eventReceived) {
+ context.setIsActionLoading(false)
+ eventSource.close()
+ void (async () => {
+ context.notification.error({
+ description: await resolveErrorDescription(),
+ message: 'Error while executing the action',
+ placement: 'bottomLeft',
+ })
+ })()
+ }
+ context.message.destroy()
+ }, eventConfig.timeout! * 1000)
+
+ context.message.loading(loadingMessage, eventConfig.timeout)
+
+ eventSource.addEventListener('krateo', ((event: MessageEvent) => {
+ const resourceUid = getResourceUid()
+ if (!resourceUid) { return }
+
+ const eventData = JSON.parse(event.data as string) as EventsApiResource
+
+ if (eventData.reason === eventConfig.eventReason && eventData.involved_object_uid === resourceUid) {
+ eventReceived = true
+
+ if (eventConfig.reloadRoutes !== false) {
+ void context.reloadRoutes()
+ }
+
+ eventSource.close()
+ clearTimeout(timeoutId)
+
+ void (async () => {
+ const redirectUrl = await resolveRedirectUrl(eventData)
+
+ if (!redirectUrl) {
+ context.message.destroy()
+ context.notification.error({
+ description: 'Impossible to redirect, the route contains an undefined value',
+ message: 'Error while redirecting',
+ placement: 'bottomLeft',
+ })
+ return
+ }
+
+ context.message.destroy()
+ context.notification.success({
+ description: await resolveSuccessDescription(eventData),
+ message: `Successfully executed action`,
+ placement: 'bottomLeft',
+ })
+
+ context.setIsActionLoading(false)
+ closeDrawer()
+ void context.navigate(redirectUrl)
+ })()
+ }
+ }) as EventListener)
+ }
+}
+
+export const handleRestAction = async (
+ action: RestAction,
+ resourcesRefs: ResourcesRefs,
+ customPayload: Record | undefined,
+ widget: Widget | undefined,
+ context: ActionHandlerContext
+): Promise => {
+ const resourceRef = await resolveResourceRef(action.resourceRefId, resourcesRefs, customPayload, widget, context)
+ if (!resourceRef) { return }
+
+ const { path, payload: resourcePayload, verb } = resourceRef
+ const url = context.config!.api.SNOWPLOW_API_BASE_URL + path
+
+ const {
+ errorMessage,
+ headers = [],
+ onEventNavigateTo,
+ onSuccessNavigateTo,
+ requireConfirmation,
+ successMessage,
+ } = action
+
+ let jsonResponse: RestApiResponse | null = null
+
+ if (!requireConfirmation || window.confirm('Are you sure?')) {
+ if (onSuccessNavigateTo && onEventNavigateTo) {
+ context.message.destroy()
+ context.notification.error({
+ description: 'Action has defined both the "onSuccessNavigateTo" and "onEventNavigateTo" properties',
+ message: 'Warning while executing the action',
+ placement: 'bottomLeft',
+ })
+
+ context.setIsActionLoading(false)
+ return
+ }
+
+ const payload = await buildPayload(action, resourcePayload, customPayload, context.resolveJq)
+
+ let resourceUid: string | null = null
+
+ if (onEventNavigateTo) {
+ await handleOnEventNavigateTo(
+ onEventNavigateTo,
+ context,
+ customPayload,
+ payload,
+ errorMessage,
+ successMessage,
+ () => resourceUid,
+ () => jsonResponse,
+ )
+ }
+
+ const updatedUrl = customPayload
+ ? updateNameNamespace(url, payload?.metadata?.name, payload?.metadata?.namespace)
+ : url
+
+ const headersObject = getHeadersObject(headers)
+ if (!headersObject) {
+ context.message.destroy()
+ context.notification.error({
+ description: 'Headers are not in the key: value format',
+ message: 'Error while executing the action',
+ placement: 'bottomLeft',
+ })
+ return
+ }
+
+ const requestHeaders = {
+ ...headersObject,
+ Accept: 'application/json',
+ Authorization: `Bearer ${context.accessToken}`,
+ }
+
+ const shouldSendPayload = ['POST', 'PUT', 'PATCH'].includes(verb)
+
+ const res = await fetch(updatedUrl, {
+ body: shouldSendPayload ? JSON.stringify(payload) : undefined,
+ headers: requestHeaders,
+ method: verb,
+ })
+
+ jsonResponse = (await res.json()) as RestApiResponse
+
+ context.setIsActionLoading(false)
+
+ if (!res.ok) {
+ let description = jsonResponse.message
+
+ if (errorMessage) {
+ description = errorMessage.startsWith('${')
+ ? await context.resolveJq(errorMessage, {
+ json: payload,
+ response: jsonResponse,
+ })
+ : errorMessage
+ }
+
+ context.message.destroy()
+ context.notification.error({
+ description,
+ message: `${jsonResponse.status} - ${jsonResponse.reason}`,
+ placement: 'bottomLeft',
+ })
+ return
+ }
+
+ if (jsonResponse.metadata?.uid) {
+ resourceUid = jsonResponse.metadata.uid
+ }
+
+ if (!onEventNavigateTo) {
+ closeDrawer()
+
+ const actionName = (() => {
+ switch (verb) {
+ case 'DELETE':
+ return 'deleted'
+ case 'POST':
+ return 'created'
+ case 'PUT':
+ case 'PATCH':
+ return 'updated'
+ default:
+ return 'updated'
+ }
+ })()
+
+ let description = `Successfully ${actionName} ${jsonResponse.metadata?.name} in ${jsonResponse.metadata?.namespace}`
+ if (successMessage) {
+ description = successMessage.startsWith('${')
+ ? await context.resolveJq(successMessage, { json: payload, response: jsonResponse })
+ : successMessage
+ }
+
+ context.notification.success({
+ description,
+ message: jsonResponse.message,
+ placement: 'bottomLeft',
+ })
+ }
+
+ await context.queryClient.invalidateQueries()
+
+ if (onSuccessNavigateTo) {
+ closeDrawer()
+
+ const onSuccessUrl = onSuccessNavigateTo.startsWith('${')
+ ? await context.resolveJq(onSuccessNavigateTo, { json: payload, response: jsonResponse })
+ : onSuccessNavigateTo
+
+ window.location.replace(onSuccessUrl)
+ }
+ }
+}
diff --git a/src/hooks/actionHandlers/utils.ts b/src/hooks/actionHandlers/utils.ts
new file mode 100644
index 0000000..75e7ea7
--- /dev/null
+++ b/src/hooks/actionHandlers/utils.ts
@@ -0,0 +1,125 @@
+import { merge, set } from 'lodash'
+
+import type { ResourceRef, ResourcesRefs, Widget, WidgetAction } from '../../types/Widget'
+import type { Payload } from '../../utils/types'
+import { getResourceRef } from '../../utils/utils'
+import type { ActionHandlerContext } from '../useHandleActions'
+
+type RestAction = Extract
+
+/**
+ * Interpolates a route template using values from a nested payload object.
+ * Placeholders must follow the format `${path.to.value}`.
+ * Returns null if any placeholder cannot be resolved or is not a primitive.
+ */
+export const interpolateRedirectUrl = (payload: Record, route: string): string | null => {
+ let allReplacementsSuccessful = true
+
+ const interpolatedRoute = route.replace(/\$\{([^}]+)\}/g, (_, key: string) => {
+ const parts = key.split('.')
+
+ let value: unknown = payload
+ for (const part of parts) {
+ if (typeof value === 'object' && value !== null && Object.prototype.hasOwnProperty.call(value, part)) {
+ value = (value as Record)[part]
+ } else {
+ value = undefined
+ break
+ }
+ }
+
+ if (
+ typeof value === 'string'
+ || typeof value === 'number'
+ || typeof value === 'boolean'
+ || typeof value === 'bigint'
+ || typeof value === 'symbol'
+ ) {
+ return String(value)
+ }
+
+ allReplacementsSuccessful = false
+ return ''
+ })
+
+ return allReplacementsSuccessful ? interpolatedRoute : null
+}
+
+/**
+ * Adds or replaces `name` and `namespace` query parameters in a URL.
+ */
+export const updateNameNamespace = (path: string, name?: string, namespace?: string) => {
+ const [base, queryString = ''] = path.split('?')
+ const qsParameters = queryString
+ .split('&')
+ .filter((el) => !el.startsWith('name=') && !el.startsWith('namespace='))
+ .join('&')
+
+ return `${base}?${qsParameters ? `${qsParameters}&` : ''}name=${name}&namespace=${namespace}`
+}
+
+export const buildPayload = async (
+ action: RestAction,
+ resourcePayload: object,
+ customPayload: Record | undefined,
+ resolveJq: ActionHandlerContext['resolveJq']
+): Promise => {
+ const { payload, payloadToOverride } = action
+ let finalPayload = payload ?? {}
+
+ finalPayload = merge({}, payload, resourcePayload)
+
+ if (payloadToOverride && payloadToOverride.length > 0 && customPayload) {
+ const overridePromises = payloadToOverride.map(async ({ name, value }) => {
+ let resolvedValue: unknown = value
+
+ if (typeof value === 'string' && value.startsWith('${')) {
+ resolvedValue = await resolveJq(value, { json: customPayload })
+ }
+
+ return { name, resolvedValue }
+ })
+
+ const resolvedOverrides = await Promise.all(overridePromises)
+
+ for (const { name, resolvedValue } of resolvedOverrides) {
+ set(finalPayload, name, resolvedValue)
+ }
+ }
+
+ return finalPayload
+}
+
+/**
+ * Resolves a resourceRefId (optionally a JQ expression) against resourcesRefs.
+ * Shows an error notification and returns null if the ref cannot be found.
+ */
+export const resolveResourceRef = async (
+ resourceRefId: string | undefined,
+ resourcesRefs: ResourcesRefs,
+ customPayload: Record | undefined,
+ widget: Widget | undefined,
+ ctx: Pick
+): Promise => {
+ let resolvedId: string | undefined
+
+ if (resourceRefId) {
+ resolvedId = resourceRefId.startsWith('${')
+ ? await ctx.resolveJq(resourceRefId, { json: customPayload, widget })
+ : resourceRefId
+ }
+
+ const resourceRef = resolvedId ? getResourceRef(resolvedId, resourcesRefs) : undefined
+
+ if (!resourceRef) {
+ ctx.message.destroy()
+ ctx.notification.error({
+ description: `The widget definition does not include a resource reference for resource (ID: ${resolvedId})`,
+ message: 'Error while executing the action',
+ placement: 'bottomLeft',
+ })
+ return null
+ }
+
+ return resourceRef
+}
diff --git a/src/hooks/useHandleActions.ts b/src/hooks/useHandleActions.ts
index 09e4250..f32c697 100644
--- a/src/hooks/useHandleActions.ts
+++ b/src/hooks/useHandleActions.ts
@@ -1,125 +1,37 @@
-import { LoadingOutlined } from '@ant-design/icons'
+import type { QueryClient } from '@tanstack/react-query'
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 { createElement, useState } from 'react'
+import type { MessageInstance } from 'antd/es/message/interface'
+import type { NotificationInstance } from 'antd/es/notification/interface'
+import { useState } from 'react'
+import type { Location, NavigateFunction } from 'react-router'
import { useLocation, useNavigate } from 'react-router'
import { useAuth } from '../context/AuthContext'
+import type { Config } from '../context/ConfigContext'
import { useConfigContext } from '../context/ConfigContext'
import { useRoutesContext } from '../context/RoutesContext'
import type { ResourcesRefs, Widget, WidgetAction } from '../types/Widget'
import { useResolveJqExpression } from '../utils/jq-expression'
-import type { EventsApiResource, Payload, RestApiResponse } from '../utils/types'
-import { getHeadersObject, getResourceRef } from '../utils/utils'
-import { closeDrawer, openDrawer } from '../widgets/Drawer/Drawer'
-import { openModal } from '../widgets/Modal/Modal'
-/**
- * Interpolates a route template using values from a nested payload object.
- * Placeholders in the route must follow the format `${path.to.value}`.
- * If any placeholder cannot be resolved or is not a primitive, the function returns null.
- *
- * Example:
- * interpolateRedirectUrl({ user: { id: 123 } }, "/profile/${user.id}")
- * → "/profile/123"
- *
- * @param payload - The object used to resolve placeholders (supports nested values)
- * @param route - The route string containing `${...}` placeholders to be replaced
- * @returns The interpolated route string or null if a placeholder could not be resolved
- */
-const interpolateRedirectUrl = (payload: Record, route: string): string | null => {
- let allReplacementsSuccessful = true
-
- const interpolatedRoute = route.replace(/\$\{([^}]+)\}/g, (_, key: string) => {
- const parts = key.split('.')
-
- let value: unknown = payload
- for (const part of parts) {
- if (typeof value === 'object' && value !== null && Object.prototype.hasOwnProperty.call(value, part)) {
- value = (value as Record)[part]
- } else {
- value = undefined
- break
- }
- }
-
- if (
- typeof value === 'string'
- || typeof value === 'number'
- || typeof value === 'boolean'
- || typeof value === 'bigint'
- || typeof value === 'symbol'
- ) {
- return String(value)
- }
-
- allReplacementsSuccessful = false
- return ''
- })
-
- return allReplacementsSuccessful ? interpolatedRoute : null
-}
-
-/**
- * Adds or replaces `name` and `namespace` query parameters in a given URL.
- * Existing `name` and `namespace` parameters (if any) are removed before appending the new values.
- *
- * Example:
- * updateNameNamespace("/api?foo=bar&name=old", "my-app", "prod")
- * → "/api?foo=bar&name=my-app&namespace=prod"
- *
- * @param path - The original URL (may already include query parameters)
- * @param name - The new `name` parameter to set
- * @param namespace - The new `namespace` parameter to set
- * @returns The updated URL with the new query parameters
- */
-const updateNameNamespace = (path: string, name?: string, namespace?: string) => {
- const [base, queryString = ''] = path.split('?')
- const qsParameters = queryString
- .split('&')
- .filter((el) => !el.startsWith('name=') && !el.startsWith('namespace='))
- .join('&')
-
- return `${base}?${qsParameters ? `${qsParameters}&` : ''}name=${name}&namespace=${namespace}`
-}
-
-const buildPayload = async (
- action: WidgetAction & {type: 'rest'},
- resourcePayload: object,
- customPayload: Record | undefined,
+import { handleExternalNavigateAction } from './actionHandlers/externalNavigate.handler'
+import { handleNavigateAction } from './actionHandlers/navigate.handler'
+import { handleOpenDrawerAction } from './actionHandlers/openDrawer.handler'
+import { handleOpenModalAction } from './actionHandlers/openModal.handler'
+import { handleRefreshAction } from './actionHandlers/refresh.handler'
+import { handleRestAction } from './actionHandlers/rest.handler'
+
+export interface ActionHandlerContext {
+ accessToken: string | null
+ config: Config | undefined
+ location: Location
+ message: MessageInstance
+ navigate: NavigateFunction
+ notification: NotificationInstance
+ queryClient: QueryClient
+ reloadRoutes: () => Promise
resolveJq: (expression: string, values: Record) => Promise
-): Promise => {
- const { payload, payloadToOverride } = action
- // 1. the action payload is the starting object
- let finalPayload = payload ?? {}
-
- // 2. the action payload and the referenced resource payload are merged
- finalPayload = merge({}, payload, resourcePayload)
-
- if (payloadToOverride && payloadToOverride.length > 0 && customPayload) {
- // 3. the values defined in payloadToOverride are interpolated
- const overridePromises = payloadToOverride.map(async ({ name, value }) => {
- let resolvedValue: unknown = value
-
- if (typeof value === 'string' && value.startsWith('${')) {
- resolvedValue = await resolveJq(value, { json: customPayload })
- }
-
- return { name, resolvedValue }
- })
-
- const resolvedOverrides = await Promise.all(overridePromises)
-
- // 4. the interpolated values replace the original values
- for (const { name, resolvedValue } of resolvedOverrides) {
- set(finalPayload, name, resolvedValue)
- }
- }
-
- return finalPayload
+ setIsActionLoading: (loading: boolean) => void
}
export const useHandleAction = () => {
@@ -133,12 +45,6 @@ export const useHandleAction = () => {
const [isActionLoading, setIsActionLoading] = useState(false)
const resolveJq = useResolveJqExpression()
- const handleNavigate = async (requireConfirmation: boolean | undefined, path: string) => {
- if (!requireConfirmation || window.confirm('Are you sure?')) {
- await navigate(path)
- }
- }
-
const handleAction = async (
action: WidgetAction,
resourcesRefs: ResourcesRefs,
@@ -149,420 +55,38 @@ export const useHandleAction = () => {
setIsActionLoading(true)
}
- if (action.type === 'navigate' && action.path) {
- const updatedUrl = action.path.startsWith('${')
- ? await resolveJq(action.path, { json: customPayload, widget })
- : action.path
-
- await handleNavigate(action.requireConfirmation, updatedUrl)
- setIsActionLoading(false)
-
- return
- }
-
- let resolvedResourceRefId: string | undefined
- if (action.resourceRefId) {
- resolvedResourceRefId = action.resourceRefId.startsWith('${')
- ? await resolveJq(action.resourceRefId, { json: customPayload, widget })
- : action.resourceRefId
- }
-
- const resourceRef = resolvedResourceRefId ? getResourceRef(resolvedResourceRefId, resourcesRefs) : undefined
-
- if (!resourceRef) {
- message.destroy()
- notification.error({
- description: `The widget definition does not include a resource reference for resource (ID: ${resolvedResourceRefId})`,
- message: 'Error while executing the action',
- placement: 'bottomLeft',
- })
-
- return
- }
-
- const { path, payload: resourcePayload, verb } = resourceRef
-
- let url: string
- if (action.type === 'navigate') {
- const prefix = action.resourceURLPathExtension || location.pathname
- url = `${prefix}?widgetEndpoint=${encodeURIComponent(path)}`
- } else {
- url = config?.api.SNOWPLOW_API_BASE_URL + path
+ const context: ActionHandlerContext = {
+ accessToken,
+ config,
+ location,
+ message,
+ navigate,
+ notification,
+ queryClient,
+ reloadRoutes,
+ resolveJq,
+ setIsActionLoading,
}
try {
- const { requireConfirmation, type } = action
-
- switch (type) {
+ switch (action.type) {
case 'navigate':
- await handleNavigate(requireConfirmation, url)
-
+ await handleNavigateAction(action, resourcesRefs, customPayload, widget, context)
break
- case 'openDrawer': {
- const { size, title } = action
-
- let drawerTitle: string | undefined
- if (title) {
- drawerTitle = title.startsWith('${')
- ? await resolveJq(title, { json: customPayload, widget })
- : title
- }
-
- setIsActionLoading(false)
- openDrawer({ size, title: drawerTitle, widgetEndpoint: path })
-
+ case 'openDrawer':
+ await handleOpenDrawerAction(action, resourcesRefs, customPayload, widget, context)
break
- }
- case 'openModal': {
- const { customWidth, size, title } = action
-
- let modalTitle: string | undefined
- if (title) {
- modalTitle = title.startsWith('${')
- ? await resolveJq(title, { json: customPayload, widget })
- : title
- }
-
- setIsActionLoading(false)
- openModal({ customWidth, size, title: modalTitle, widgetEndpoint: path })
-
+ case 'openModal':
+ await handleOpenModalAction(action, resourcesRefs, customPayload, widget, context)
break
- }
- case 'rest': {
- const {
- errorMessage,
- headers = [],
- onEventNavigateTo,
- onSuccessNavigateTo,
- successMessage,
- } = action
- let jsonResponse: RestApiResponse | null = null
-
- if (!requireConfirmation || window.confirm('Are you sure?')) {
- if (onSuccessNavigateTo && onEventNavigateTo) {
- message.destroy()
- notification.error({
- description: 'Action has defined both the "onSuccessNavigateTo" and "onEventNavigateTo" properties',
- message: 'Warning while executing the action',
- placement: 'bottomLeft',
- })
-
- setIsActionLoading(false)
-
- return
- }
-
- const payload = await buildPayload(action, resourcePayload, customPayload, resolveJq)
-
- let resourceUid: string | null = null
- 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, {
- headers: {
- Authorization: `Bearer ${accessToken}`,
- },
- withCredentials: false,
- })
-
- 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,
- })
- }
- if (customPayload) {
- return interpolateRedirectUrl(customPayload, onEventNavigateTo.url)
- }
- return onEventNavigateTo.url
- }
-
- const resolveSuccessDescription = async (eventData: EventsApiResource) => {
- if (successMessage) {
- return successMessage.startsWith('${')
- ? resolveJq(successMessage, {
- event: eventData as unknown as Record,
- json: payload,
- response: jsonResponse,
- })
- : successMessage
- }
- return 'The action has been executed successfully'
- }
-
- // 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)
-
- eventSource.addEventListener('krateo', ((event: MessageEvent) => {
- if (!resourceUid) { return }
-
- const eventData = JSON.parse(event.data as string) as EventsApiResource
-
- if (eventData.reason === onEventNavigateTo.eventReason && eventData.involved_object_uid === resourceUid) {
- eventReceived = true
-
- if (onEventNavigateTo.reloadRoutes !== false) {
- void reloadRoutes()
- }
-
- eventSource.close()
- clearTimeout(timeoutId)
-
- void (async () => {
- const redirectUrl = await resolveRedirectUrl(eventData)
-
- 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',
- })
- })()
- }
- }) as EventListener)
- } else {
- const timeoutId = setTimeout(() => {
- if (!eventReceived) {
- setIsActionLoading(false)
- eventSource.close()
- void (async () => {
- notification.error({
- 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
-
- if (eventData.reason === onEventNavigateTo.eventReason && eventData.involved_object_uid === resourceUid) {
- eventReceived = true
-
- if (onEventNavigateTo.reloadRoutes !== false) {
- void reloadRoutes()
- }
-
- 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',
- })
- return
- }
-
- message.destroy()
- notification.success({
- description: await resolveSuccessDescription(eventData),
- message: `Successfully executed action`,
- placement: 'bottomLeft',
- })
-
- setIsActionLoading(false)
- closeDrawer()
- void navigate(redirectUrl)
- })()
- }
- }) as EventListener)
- }
- }
-
- const updatedUrl = customPayload
- ? updateNameNamespace(url, payload?.metadata?.name, payload?.metadata?.namespace)
- : url
-
- const headersObject = getHeadersObject(headers)
- if (!headersObject) {
- message.destroy()
- notification.error({
- description: 'Headers are not in the key: value format',
- message: 'Error while executing the action',
- placement: 'bottomLeft',
- })
- break
- }
-
- const requestHeaders = {
- ...headersObject,
- Accept: 'application/json',
- Authorization: `Bearer ${accessToken}`,
- }
-
- const shouldSendPayload = ['POST', 'PUT', 'PATCH'].includes(verb)
-
- const res = await fetch(updatedUrl, {
- body: shouldSendPayload ? JSON.stringify(payload) : undefined,
- headers: requestHeaders,
- method: verb,
- })
-
- // eslint-disable-next-line require-atomic-updates
- jsonResponse = (await res.json()) as RestApiResponse
-
- setIsActionLoading(false)
-
- if (!res.ok) {
- let description = jsonResponse.message
-
- // eslint-disable-next-line max-depth
- if (errorMessage) {
- description = errorMessage.startsWith('${')
- ? await resolveJq(errorMessage, {
- json: payload,
- response: jsonResponse,
- })
- : errorMessage
- }
-
- message.destroy()
- notification.error({
- description,
- message: `${jsonResponse.status} - ${jsonResponse.reason}`,
- placement: 'bottomLeft',
- })
- break
- }
-
- if (jsonResponse.metadata?.uid) {
- resourceUid = jsonResponse.metadata.uid
- }
-
- if (!onEventNavigateTo) {
- closeDrawer()
-
- const actionName = (() => {
- switch (verb) {
- case 'DELETE':
- return 'deleted'
- case 'POST':
- return 'created'
- case 'PUT':
- return 'updated'
- case 'PATCH':
- return 'updated'
- default:
- return 'updated'
- }
- })()
-
- let description = `Successfully ${actionName} ${jsonResponse.metadata?.name} in ${jsonResponse.metadata?.namespace}`
- // eslint-disable-next-line max-depth
- if (successMessage) {
- description = successMessage.startsWith('${')
- ? await resolveJq(successMessage, { json: payload, response: jsonResponse })
- : successMessage
- }
-
- notification.success({
- description,
- message: jsonResponse.message,
- placement: 'bottomLeft',
- })
- }
-
- await queryClient.invalidateQueries()
-
- if (onSuccessNavigateTo) {
- closeDrawer()
-
- const onSuccessUrl = onSuccessNavigateTo.startsWith('${')
- ? await resolveJq(onSuccessNavigateTo, { json: payload, response: jsonResponse })
- : onSuccessNavigateTo
-
- window.location.replace(onSuccessUrl)
- }
- }
-
+ case 'rest':
+ await handleRestAction(action, resourcesRefs, customPayload, widget, context)
+ break
+ case 'externalNavigate':
+ await handleExternalNavigateAction(action, customPayload, widget, context)
break
- }
- default:
+ case 'refresh':
+ await handleRefreshAction(action, resourcesRefs, context)
break
}
} catch (error) {
diff --git a/src/types/Widget.d.ts b/src/types/Widget.d.ts
index a3f2e27..cf7cf5b 100644
--- a/src/types/Widget.d.ts
+++ b/src/types/Widget.d.ts
@@ -107,14 +107,36 @@ export type WidgetActions = {
customWidth?: string
size?: 'custom' | 'default' | 'fullscreen' | 'large'
}[]
+ externalNavigate?: {
+ id: string
+ type: 'externalNavigate'
+ url: string
+ target?: '_blank' | '_parent' | '_self' | '_top'
+ requireConfirmation?: boolean
+ loading?: {
+ display: boolean
+ }
+ }[]
+ refresh?: {
+ id: string
+ type: 'refresh'
+ resourcesRefsIds?: string[]
+ widgetKinds?: string[]
+ requireConfirmation?: boolean
+ loading?: {
+ display: boolean
+ }
+ }[]
}
type RestAction = NonNullable[number]
type NavigateAction = NonNullable[number]
type OpenDrawerAction = NonNullable[number]
type OpenModalAction = NonNullable[number]
+type ExternalNavigateAction = NonNullable[number]
+type RefreshAction = NonNullable[number]
-export type WidgetAction = RestAction | NavigateAction | OpenDrawerAction | OpenModalAction
+export type WidgetAction = RestAction | NavigateAction | OpenDrawerAction | OpenModalAction | ExternalNavigateAction | RefreshAction
export type WidgetProps = {
resourcesRefs: ResourcesRefs
diff --git a/src/widgets/Button/Button.schema.json b/src/widgets/Button/Button.schema.json
index 676bd1f..52f7ebf 100644
--- a/src/widgets/Button/Button.schema.json
+++ b/src/widgets/Button/Button.schema.json
@@ -285,6 +285,98 @@
},
"required": ["id", "type", "resourceRefId"]
}
+ },
+ "externalNavigate": {
+ "type": "array",
+ "description": "actions to navigate to an external URL",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "unique identifier for the action"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["externalNavigate"],
+ "description": "type of action to execute"
+ },
+ "url": {
+ "type": "string",
+ "description": "the external URL to navigate to; supports JQ expressions using ${ } syntax"
+ },
+ "target": {
+ "type": "string",
+ "enum": ["_blank", "_self", "_parent", "_top"],
+ "default": "_blank",
+ "description": "specifies where to open the URL (default: _blank)"
+ },
+ "requireConfirmation": {
+ "type": "boolean",
+ "description": "whether user confirmation is required before navigating"
+ },
+ "loading": {
+ "type": "object",
+ "properties": {
+ "display": {
+ "type": "boolean"
+ }
+ },
+ "required": ["display"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["id", "type", "url"]
+ }
+ },
+ "refresh": {
+ "type": "array",
+ "description": "actions to invalidate and reload cached data",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "unique identifier for the action"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["refresh"],
+ "description": "type of action to execute"
+ },
+ "resourcesRefsIds": {
+ "type": "array",
+ "description": "list of resourcesRefs item IDs whose widget queries should be invalidated; each ID must match an entry in resourcesRefs.items",
+ "items": {
+ "type": "string"
+ }
+ },
+ "widgetKinds": {
+ "type": "array",
+ "description": "list of widget kind names (e.g. Table, Panel) whose queries should be invalidated; all widgets of those kinds on the page are re-fetched",
+ "items": {
+ "type": "string"
+ }
+ },
+ "requireConfirmation": {
+ "type": "boolean",
+ "description": "whether user confirmation is required before refreshing"
+ },
+ "loading": {
+ "type": "object",
+ "properties": {
+ "display": {
+ "type": "boolean"
+ }
+ },
+ "required": ["display"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["id", "type"]
+ }
}
},
"additionalProperties": false
diff --git a/src/widgets/Form/Form.schema.json b/src/widgets/Form/Form.schema.json
index d57c31d..9600810 100644
--- a/src/widgets/Form/Form.schema.json
+++ b/src/widgets/Form/Form.schema.json
@@ -287,6 +287,98 @@
},
"required": ["id", "type", "resourceRefId"]
}
+ },
+ "externalNavigate": {
+ "type": "array",
+ "description": "actions to navigate to an external URL",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "unique identifier for the action"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["externalNavigate"],
+ "description": "type of action to execute"
+ },
+ "url": {
+ "type": "string",
+ "description": "the external URL to navigate to; supports JQ expressions using ${ } syntax"
+ },
+ "target": {
+ "type": "string",
+ "enum": ["_blank", "_self", "_parent", "_top"],
+ "default": "_blank",
+ "description": "specifies where to open the URL (default: _blank)"
+ },
+ "requireConfirmation": {
+ "type": "boolean",
+ "description": "whether user confirmation is required before navigating"
+ },
+ "loading": {
+ "type": "object",
+ "properties": {
+ "display": {
+ "type": "boolean"
+ }
+ },
+ "required": ["display"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["id", "type", "url"]
+ }
+ },
+ "refresh": {
+ "type": "array",
+ "description": "actions to invalidate and reload cached data",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "unique identifier for the action"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["refresh"],
+ "description": "type of action to execute"
+ },
+ "resourcesRefsIds": {
+ "type": "array",
+ "description": "list of resourcesRefs item IDs whose widget queries should be invalidated; each ID must match an entry in resourcesRefs.items",
+ "items": {
+ "type": "string"
+ }
+ },
+ "widgetKinds": {
+ "type": "array",
+ "description": "list of widget kind names (e.g. Table, Panel) whose queries should be invalidated; all widgets of those kinds on the page are re-fetched",
+ "items": {
+ "type": "string"
+ }
+ },
+ "requireConfirmation": {
+ "type": "boolean",
+ "description": "whether user confirmation is required before refreshing"
+ },
+ "loading": {
+ "type": "object",
+ "properties": {
+ "display": {
+ "type": "boolean"
+ }
+ },
+ "required": ["display"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["id", "type"]
+ }
}
},
"additionalProperties": false
diff --git a/src/widgets/Panel/Panel.schema.json b/src/widgets/Panel/Panel.schema.json
index 42626e5..f63144f 100644
--- a/src/widgets/Panel/Panel.schema.json
+++ b/src/widgets/Panel/Panel.schema.json
@@ -284,6 +284,98 @@
},
"required": ["id", "type", "resourceRefId"]
}
+ },
+ "externalNavigate": {
+ "type": "array",
+ "description": "actions to navigate to an external URL",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "unique identifier for the action"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["externalNavigate"],
+ "description": "type of action to execute"
+ },
+ "url": {
+ "type": "string",
+ "description": "the external URL to navigate to; supports JQ expressions using ${ } syntax"
+ },
+ "target": {
+ "type": "string",
+ "enum": ["_blank", "_self", "_parent", "_top"],
+ "default": "_blank",
+ "description": "specifies where to open the URL (default: _blank)"
+ },
+ "requireConfirmation": {
+ "type": "boolean",
+ "description": "whether user confirmation is required before navigating"
+ },
+ "loading": {
+ "type": "object",
+ "properties": {
+ "display": {
+ "type": "boolean"
+ }
+ },
+ "required": ["display"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["id", "type", "url"]
+ }
+ },
+ "refresh": {
+ "type": "array",
+ "description": "actions to invalidate and reload cached data",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "unique identifier for the action"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["refresh"],
+ "description": "type of action to execute"
+ },
+ "resourcesRefsIds": {
+ "type": "array",
+ "description": "list of resourcesRefs item IDs whose widget queries should be invalidated; each ID must match an entry in resourcesRefs.items",
+ "items": {
+ "type": "string"
+ }
+ },
+ "widgetKinds": {
+ "type": "array",
+ "description": "list of widget kind names (e.g. Table, Panel) whose queries should be invalidated; all widgets of those kinds on the page are re-fetched",
+ "items": {
+ "type": "string"
+ }
+ },
+ "requireConfirmation": {
+ "type": "boolean",
+ "description": "whether user confirmation is required before refreshing"
+ },
+ "loading": {
+ "type": "object",
+ "properties": {
+ "display": {
+ "type": "boolean"
+ }
+ },
+ "required": ["display"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["id", "type"]
+ }
}
},
"additionalProperties": false
diff --git a/src/widgets/Table/Table.schema.json b/src/widgets/Table/Table.schema.json
index a4b6367..b9b34fc 100644
--- a/src/widgets/Table/Table.schema.json
+++ b/src/widgets/Table/Table.schema.json
@@ -285,6 +285,98 @@
},
"required": ["id", "type", "resourceRefId"]
}
+ },
+ "externalNavigate": {
+ "type": "array",
+ "description": "actions to navigate to an external URL",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "unique identifier for the action"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["externalNavigate"],
+ "description": "type of action to execute"
+ },
+ "url": {
+ "type": "string",
+ "description": "the external URL to navigate to; supports JQ expressions using ${ } syntax"
+ },
+ "target": {
+ "type": "string",
+ "enum": ["_blank", "_self", "_parent", "_top"],
+ "default": "_blank",
+ "description": "specifies where to open the URL (default: _blank)"
+ },
+ "requireConfirmation": {
+ "type": "boolean",
+ "description": "whether user confirmation is required before navigating"
+ },
+ "loading": {
+ "type": "object",
+ "properties": {
+ "display": {
+ "type": "boolean"
+ }
+ },
+ "required": ["display"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["id", "type", "url"]
+ }
+ },
+ "refresh": {
+ "type": "array",
+ "description": "actions to invalidate and reload cached data",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "unique identifier for the action"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["refresh"],
+ "description": "type of action to execute"
+ },
+ "resourcesRefsIds": {
+ "type": "array",
+ "description": "list of resourcesRefs item IDs whose widget queries should be invalidated; each ID must match an entry in resourcesRefs.items",
+ "items": {
+ "type": "string"
+ }
+ },
+ "widgetKinds": {
+ "type": "array",
+ "description": "list of widget kind names (e.g. Table, Panel) whose queries should be invalidated; all widgets of those kinds on the page are re-fetched",
+ "items": {
+ "type": "string"
+ }
+ },
+ "requireConfirmation": {
+ "type": "boolean",
+ "description": "whether user confirmation is required before refreshing"
+ },
+ "loading": {
+ "type": "object",
+ "properties": {
+ "display": {
+ "type": "boolean"
+ }
+ },
+ "required": ["display"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["id", "type"]
+ }
}
},
"additionalProperties": false