flows: add diagrams (#415)
* flows: initial diagram implementation * web: install flowchart.js, add flow diagram page * web: adjust diagram colours for dark mode * flows: add permission checks for diagram * flows: fix formatting * web: fix formatting for web * flows: add fix when last stage has policy * flows: add test for diagram * web: flows/diagram: add support for light mode * flows: make Flows's Diagram API return json, add more tests and fix swagger response
This commit is contained in:
parent
33f5169f36
commit
a9336f069c
|
@ -1,9 +1,17 @@
|
||||||
"""Flow API Views"""
|
"""Flow API Views"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.db.models import Model
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_yasg2.utils import swagger_auto_schema
|
||||||
|
from guardian.shortcuts import get_objects_for_user
|
||||||
|
from rest_framework.decorators import action
|
||||||
from rest_framework.mixins import ListModelMixin
|
from rest_framework.mixins import ListModelMixin
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import (
|
from rest_framework.serializers import (
|
||||||
|
CharField,
|
||||||
ModelSerializer,
|
ModelSerializer,
|
||||||
Serializer,
|
Serializer,
|
||||||
SerializerMethodField,
|
SerializerMethodField,
|
||||||
|
@ -40,6 +48,30 @@ class FlowSerializer(ModelSerializer):
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FlowDiagramSerializer(Serializer):
|
||||||
|
"""response of the flow's /diagram/ action"""
|
||||||
|
|
||||||
|
diagram = CharField(read_only=True)
|
||||||
|
|
||||||
|
def create(self, validated_data: dict) -> Model:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def update(self, instance: Model, validated_data: dict) -> Model:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiagramElement:
|
||||||
|
"""Single element used in a diagram"""
|
||||||
|
|
||||||
|
identifier: str
|
||||||
|
type: str
|
||||||
|
rest: str
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.identifier}=>{self.type}: {self.rest}"
|
||||||
|
|
||||||
|
|
||||||
class FlowViewSet(ModelViewSet):
|
class FlowViewSet(ModelViewSet):
|
||||||
"""Flow Viewset"""
|
"""Flow Viewset"""
|
||||||
|
|
||||||
|
@ -47,6 +79,78 @@ class FlowViewSet(ModelViewSet):
|
||||||
serializer_class = FlowSerializer
|
serializer_class = FlowSerializer
|
||||||
lookup_field = "slug"
|
lookup_field = "slug"
|
||||||
|
|
||||||
|
@swagger_auto_schema(responses={200: FlowDiagramSerializer()})
|
||||||
|
@action(detail=True, methods=["get"])
|
||||||
|
def diagram(self, request: Request, slug: str) -> Response:
|
||||||
|
"""Return diagram for flow with slug `slug`, in the format used by flowchart.js"""
|
||||||
|
flow = get_object_or_404(
|
||||||
|
get_objects_for_user(request.user, "authentik_flows.view_flow").filter(
|
||||||
|
slug=slug
|
||||||
|
)
|
||||||
|
)
|
||||||
|
header = [
|
||||||
|
DiagramElement("st", "start", "Start"),
|
||||||
|
]
|
||||||
|
body: list[DiagramElement] = []
|
||||||
|
footer = []
|
||||||
|
# First, collect all elements we need
|
||||||
|
for s_index, stage_binding in enumerate(
|
||||||
|
get_objects_for_user(request.user, "authentik_flows.view_flowstagebinding")
|
||||||
|
.filter(target=flow)
|
||||||
|
.order_by("order")
|
||||||
|
):
|
||||||
|
body.append(
|
||||||
|
DiagramElement(
|
||||||
|
f"stage_{s_index}",
|
||||||
|
"operation",
|
||||||
|
f"Stage\n{stage_binding.stage.name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for p_index, policy_binding in enumerate(
|
||||||
|
get_objects_for_user(
|
||||||
|
request.user, "authentik_policies.view_policybinding"
|
||||||
|
)
|
||||||
|
.filter(target=stage_binding)
|
||||||
|
.order_by("order")
|
||||||
|
):
|
||||||
|
body.append(
|
||||||
|
DiagramElement(
|
||||||
|
f"stage_{s_index}_policy_{p_index}",
|
||||||
|
"condition",
|
||||||
|
f"Policy\n{policy_binding.policy.name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# If the 2nd last element is a policy, we need to have an item to point to
|
||||||
|
# for a negative case
|
||||||
|
body.append(
|
||||||
|
DiagramElement("e", "end", "End|future"),
|
||||||
|
)
|
||||||
|
if len(body) == 1:
|
||||||
|
footer.append("st(right)->e")
|
||||||
|
else:
|
||||||
|
# Actual diagram flow
|
||||||
|
footer.append(f"st(right)->{body[0].identifier}")
|
||||||
|
for index in range(len(body) - 1):
|
||||||
|
element: DiagramElement = body[index]
|
||||||
|
if element.type == "condition":
|
||||||
|
# Policy passes, link policy yes to next stage
|
||||||
|
footer.append(
|
||||||
|
f"{element.identifier}(yes, right)->{body[index + 1].identifier}"
|
||||||
|
)
|
||||||
|
# Policy doesn't pass, go to stage after next stage
|
||||||
|
no_element = body[index + 1]
|
||||||
|
if no_element.type != "end":
|
||||||
|
no_element = body[index + 2]
|
||||||
|
footer.append(
|
||||||
|
f"{element.identifier}(no, bottom)->{no_element.identifier}"
|
||||||
|
)
|
||||||
|
elif element.type == "operation":
|
||||||
|
footer.append(
|
||||||
|
f"{element.identifier}(bottom)->{body[index + 1].identifier}"
|
||||||
|
)
|
||||||
|
diagram = "\n".join([str(x) for x in header + body + footer])
|
||||||
|
return Response({"diagram": diagram})
|
||||||
|
|
||||||
|
|
||||||
class StageSerializer(ModelSerializer):
|
class StageSerializer(ModelSerializer):
|
||||||
"""Stage Serializer"""
|
"""Stage Serializer"""
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""API flow tests"""
|
||||||
|
from django.shortcuts import reverse
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from authentik.core.models import User
|
||||||
|
from authentik.flows.api import StageSerializer, StageViewSet
|
||||||
|
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage
|
||||||
|
from authentik.policies.dummy.models import DummyPolicy
|
||||||
|
from authentik.policies.models import PolicyBinding
|
||||||
|
from authentik.stages.dummy.models import DummyStage
|
||||||
|
|
||||||
|
DIAGRAM_EXPECTED = """st=>start: Start
|
||||||
|
stage_0=>operation: Stage
|
||||||
|
dummy1
|
||||||
|
stage_1=>operation: Stage
|
||||||
|
dummy2
|
||||||
|
stage_1_policy_0=>condition: Policy
|
||||||
|
None
|
||||||
|
e=>end: End|future
|
||||||
|
st(right)->stage_0
|
||||||
|
stage_0(bottom)->stage_1
|
||||||
|
stage_1(bottom)->stage_1_policy_0
|
||||||
|
stage_1_policy_0(yes, right)->e
|
||||||
|
stage_1_policy_0(no, bottom)->e"""
|
||||||
|
DIAGRAM_SHORT_EXPECTED = """st=>start: Start
|
||||||
|
e=>end: End|future
|
||||||
|
st(right)->e"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestFlowsAPI(APITestCase):
|
||||||
|
"""API tests"""
|
||||||
|
|
||||||
|
def test_models(self):
|
||||||
|
"""Test that ui_user_settings returns none"""
|
||||||
|
self.assertIsNone(Stage().ui_user_settings)
|
||||||
|
|
||||||
|
def test_api_serializer(self):
|
||||||
|
"""Test that stage serializer returns the correct type"""
|
||||||
|
obj = DummyStage()
|
||||||
|
self.assertEqual(StageSerializer().get_type(obj), "dummy")
|
||||||
|
self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage")
|
||||||
|
|
||||||
|
def test_api_viewset(self):
|
||||||
|
"""Test that stage serializer returns the correct type"""
|
||||||
|
dummy = DummyStage.objects.create()
|
||||||
|
self.assertIn(dummy, StageViewSet().get_queryset())
|
||||||
|
|
||||||
|
def test_api_diagram(self):
|
||||||
|
"""Test flow diagram."""
|
||||||
|
user = User.objects.get(username="akadmin")
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
flow = Flow.objects.create(
|
||||||
|
name="test-default-context",
|
||||||
|
slug="test-default-context",
|
||||||
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
|
)
|
||||||
|
false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
|
||||||
|
|
||||||
|
FlowStageBinding.objects.create(
|
||||||
|
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
||||||
|
)
|
||||||
|
binding2 = FlowStageBinding.objects.create(
|
||||||
|
target=flow,
|
||||||
|
stage=DummyStage.objects.create(name="dummy2"),
|
||||||
|
order=1,
|
||||||
|
re_evaluate_policies=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:flow-diagram", kwargs={"slug": flow.slug})
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(response.content, {"diagram": DIAGRAM_EXPECTED})
|
||||||
|
|
||||||
|
def test_api_diagram_no_stages(self):
|
||||||
|
"""Test flow diagram with no stages."""
|
||||||
|
user = User.objects.get(username="akadmin")
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
flow = Flow.objects.create(
|
||||||
|
name="test-default-context",
|
||||||
|
slug="test-default-context",
|
||||||
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
|
)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:flow-diagram", kwargs={"slug": flow.slug})
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(response.content, {"diagram": DIAGRAM_SHORT_EXPECTED})
|
|
@ -1,25 +0,0 @@
|
||||||
"""miscellaneous flow tests"""
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from authentik.flows.api import StageSerializer, StageViewSet
|
|
||||||
from authentik.flows.models import Stage
|
|
||||||
from authentik.stages.dummy.models import DummyStage
|
|
||||||
|
|
||||||
|
|
||||||
class TestFlowsMisc(TestCase):
|
|
||||||
"""miscellaneous tests"""
|
|
||||||
|
|
||||||
def test_models(self):
|
|
||||||
"""Test that ui_user_settings returns none"""
|
|
||||||
self.assertIsNone(Stage().ui_user_settings)
|
|
||||||
|
|
||||||
def test_api_serializer(self):
|
|
||||||
"""Test that stage serializer returns the correct type"""
|
|
||||||
obj = DummyStage()
|
|
||||||
self.assertEqual(StageSerializer().get_type(obj), "dummy")
|
|
||||||
self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage")
|
|
||||||
|
|
||||||
def test_api_viewset(self):
|
|
||||||
"""Test that stage serializer returns the correct type"""
|
|
||||||
dummy = DummyStage.objects.create()
|
|
||||||
self.assertIn(dummy, StageViewSet().get_queryset())
|
|
30
swagger.yaml
30
swagger.yaml
|
@ -1317,6 +1317,27 @@ paths:
|
||||||
type: string
|
type: string
|
||||||
format: slug
|
format: slug
|
||||||
pattern: ^[-a-zA-Z0-9_]+$
|
pattern: ^[-a-zA-Z0-9_]+$
|
||||||
|
/flows/instances/{slug}/diagram/:
|
||||||
|
get:
|
||||||
|
operationId: flows_instances_diagram
|
||||||
|
description: Return diagram for flow with slug `slug`, in the format used by
|
||||||
|
flowchart.js
|
||||||
|
parameters: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: response of the flow's /diagram/ action
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/FlowDiagram'
|
||||||
|
tags:
|
||||||
|
- flows
|
||||||
|
parameters:
|
||||||
|
- name: slug
|
||||||
|
in: path
|
||||||
|
description: Visible in the URL.
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
format: slug
|
||||||
|
pattern: ^[-a-zA-Z0-9_]+$
|
||||||
/outposts/outposts/:
|
/outposts/outposts/:
|
||||||
get:
|
get:
|
||||||
operationId: outposts_outposts_list
|
operationId: outposts_outposts_list
|
||||||
|
@ -7176,6 +7197,15 @@ definitions:
|
||||||
title: Cache count
|
title: Cache count
|
||||||
type: string
|
type: string
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
FlowDiagram:
|
||||||
|
description: response of the flow's /diagram/ action
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
diagram:
|
||||||
|
title: Diagram
|
||||||
|
type: string
|
||||||
|
readOnly: true
|
||||||
|
minLength: 1
|
||||||
Outpost:
|
Outpost:
|
||||||
description: Outpost Serializer
|
description: Outpost Serializer
|
||||||
required:
|
required:
|
||||||
|
|
|
@ -1335,6 +1335,11 @@
|
||||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"eve-raphael": {
|
||||||
|
"version": "0.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eve-raphael/-/eve-raphael-0.5.0.tgz",
|
||||||
|
"integrity": "sha1-F8dUt5K+7z+maE15z1pHxjxM2jA="
|
||||||
|
},
|
||||||
"expand-brackets": {
|
"expand-brackets": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
|
||||||
|
@ -1562,6 +1567,14 @@
|
||||||
"integrity": "sha512-tW+UkmtNg/jv9CSofAKvgVcO7c2URjhTdW1ZTkcAritblu8tajiYy7YisnIflEwtKssCtOxpnBRoCB7iap0/TA==",
|
"integrity": "sha512-tW+UkmtNg/jv9CSofAKvgVcO7c2URjhTdW1ZTkcAritblu8tajiYy7YisnIflEwtKssCtOxpnBRoCB7iap0/TA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"flowchart.js": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/flowchart.js/-/flowchart.js-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-IyCVUFfHPLPgKLynw3NCkZ7CvKJdc/bAu0aHm+2AxKhtSBCiUC1kcTX1KautC3HOp1A2JS1IOcYxDTmcMkx5nQ==",
|
||||||
|
"requires": {
|
||||||
|
"raphael": "2.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"for-in": {
|
"for-in": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
|
||||||
|
@ -2580,6 +2593,14 @@
|
||||||
"safe-buffer": "^5.1.0"
|
"safe-buffer": "^5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"raphael": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/raphael/-/raphael-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-w2yIenZAQnp257XUWGni4bLMVxpUpcIl7qgxEgDIXtmSypYtlNxfXWpOBxs7LBTps5sDwhRnrToJrMUrivqNTQ==",
|
||||||
|
"requires": {
|
||||||
|
"eve-raphael": "0.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"regenerator-runtime": {
|
"regenerator-runtime": {
|
||||||
"version": "0.13.7",
|
"version": "0.13.7",
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
"chart.js": "^2.9.4",
|
"chart.js": "^2.9.4",
|
||||||
"codemirror": "^5.59.0",
|
"codemirror": "^5.59.0",
|
||||||
"construct-style-sheets-polyfill": "^2.4.3",
|
"construct-style-sheets-polyfill": "^2.4.3",
|
||||||
|
"flowchart.js": "^1.15.0",
|
||||||
"lit-element": "^2.4.0",
|
"lit-element": "^2.4.0",
|
||||||
"lit-html": "^1.3.0",
|
"lit-html": "^1.3.0",
|
||||||
"rollup": "^2.35.1",
|
"rollup": "^2.35.1",
|
||||||
|
|
|
@ -30,6 +30,10 @@ export class Flow {
|
||||||
return DefaultClient.fetch<Flow>(["flows", "instances", slug]);
|
return DefaultClient.fetch<Flow>(["flows", "instances", slug]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static diagram(slug: string): Promise<{ diagram: string }> {
|
||||||
|
return DefaultClient.fetch<{ diagram: string }>(["flows", "instances", slug, "diagram"]);
|
||||||
|
}
|
||||||
|
|
||||||
static list(filter?: QueryArguments): Promise<PBResponse<Flow>> {
|
static list(filter?: QueryArguments): Promise<PBResponse<Flow>> {
|
||||||
return DefaultClient.fetch<PBResponse<Flow>>(["flows", "instances"], filter);
|
return DefaultClient.fetch<PBResponse<Flow>>(["flows", "instances"], filter);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { customElement, html, LitElement, property, TemplateResult } from "lit-element";
|
||||||
|
import FlowChart from "flowchart.js";
|
||||||
|
import { Flow } from "../../api/Flows";
|
||||||
|
import { loading } from "../../utils";
|
||||||
|
|
||||||
|
export const FONT_COLOUR_DARK_MODE = "#fafafa";
|
||||||
|
export const FONT_COLOUR_LIGHT_MODE = "#151515";
|
||||||
|
export const FILL_DARK_MODE = "#18191a";
|
||||||
|
export const FILL_LIGHT_MODE = "#f0f0f0";
|
||||||
|
|
||||||
|
@customElement("ak-flow-diagram")
|
||||||
|
export class FlowDiagram extends LitElement {
|
||||||
|
|
||||||
|
@property()
|
||||||
|
set flowSlug(value: string) {
|
||||||
|
Flow.diagram(value).then((data) => {
|
||||||
|
this.diagram = FlowChart.parse(data.diagram);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@property({attribute: false})
|
||||||
|
diagram?: FlowChart.Instance;
|
||||||
|
|
||||||
|
@property()
|
||||||
|
fontColour: string = FONT_COLOUR_DARK_MODE;
|
||||||
|
|
||||||
|
@property()
|
||||||
|
fill: string = FILL_DARK_MODE;
|
||||||
|
|
||||||
|
createRenderRoot(): Element | ShadowRoot {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", (ev) => {
|
||||||
|
if (ev.matches) {
|
||||||
|
this.fontColour = FONT_COLOUR_LIGHT_MODE;
|
||||||
|
this.fill = FILL_LIGHT_MODE;
|
||||||
|
} else {
|
||||||
|
this.fontColour = FONT_COLOUR_DARK_MODE;
|
||||||
|
this.fill = FILL_DARK_MODE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): TemplateResult {
|
||||||
|
if (this.diagram) {
|
||||||
|
this.diagram.drawSVG(this, {
|
||||||
|
"font-color": this.fontColour,
|
||||||
|
"line-color": "#bebebe",
|
||||||
|
"element-color": "#bebebe",
|
||||||
|
"fill": this.fill,
|
||||||
|
"yes-text": "Policy passes",
|
||||||
|
"no-text": "Policy denies",
|
||||||
|
});
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
return loading(this.diagram, html``);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import "../../elements/buttons/ModalButton";
|
||||||
import "../../elements/buttons/SpinnerButton";
|
import "../../elements/buttons/SpinnerButton";
|
||||||
import "../../elements/policies/BoundPoliciesList";
|
import "../../elements/policies/BoundPoliciesList";
|
||||||
import "./BoundStagesList";
|
import "./BoundStagesList";
|
||||||
|
import "./FlowDiagram";
|
||||||
|
|
||||||
@customElement("ak-flow-view")
|
@customElement("ak-flow-view")
|
||||||
export class FlowViewPage extends LitElement {
|
export class FlowViewPage extends LitElement {
|
||||||
|
@ -49,6 +50,12 @@ export class FlowViewPage extends LitElement {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<ak-tabs>
|
<ak-tabs>
|
||||||
|
<div slot="page-1" data-tab-title="${gettext("Flow Diagram")}" class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||||
|
<div class="pf-c-card">
|
||||||
|
<ak-flow-diagram flowSlug=${this.flow.slug}>
|
||||||
|
</ak-flow-diagram>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div slot="page-2" data-tab-title="${gettext("Stage Bindings")}" class="pf-c-page__main-section pf-m-no-padding-mobile">
|
<div slot="page-2" data-tab-title="${gettext("Stage Bindings")}" class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||||
<div class="pf-c-card">
|
<div class="pf-c-card">
|
||||||
<ak-bound-stages-list .target=${this.flow.pk}>
|
<ak-bound-stages-list .target=${this.flow.pk}>
|
||||||
|
|
Reference in New Issue